feat(web): unified app sidebar (#1165)

* feat(web): introduce unified AppSidebar and retire legacy nav surfaces

Mounts a single collapsible AppSidebar at the root layout that replaces the
former WorkspaceRail, OfficeSidebar, and dockview-embedded sidebar pane.
Wires per-section expand + collapse state through a new UI slice with
localStorage persistence, ports projects/agents/settings/tasks sections, and
trims integrations / quick-chat / settings buttons + the home breadcrumb
from the dockview top bar (debug-only MoreToolsMenu kept). Strips the
sidebar column from every preset and the panel registry; persisted
sidebar panels are dropped by the existing sanitize layer at restore time.

* test(web): point workflow-filter e2e at AppSidebar's Home link

The dockview top bar no longer renders the IconHome breadcrumb after the
unified sidebar landed — the home navigation now lives in AppSidebar's
primary nav. Update the workflow-filter spec to click that link instead.

* feat(web): wire app-sidebar primary controls, nested settings, and footer migration

Gates office-only nav (Inbox, Agents, Projects, New Task) behind the office
feature flag, renames Tasks → Kanban as the bottom flex-grow section, replaces
the gradient workspace picker with a minimal trigger when office is off, and
moves Stats/Improve/What's-New/Office out of the kanban top bar into the
AppSidebar footer + a dedicated Integrations section. Expands Settings into a
collapsible deep tree mirroring the old SettingsAppSidebar so /settings pages
can drop their nested SidebarProvider.

* feat(web): make app-sidebar resizable and inline task top bar actions

Adds a persistent drag-resize handle on the AppSidebar's right edge that
clamps between APP_SIDEBAR_EXPANDED_WIDTH and 30vw; width is stored on the
appSidebar slice and persisted to localStorage. Drops the dockview top bar's
overflow popover (TopbarActionOverflow) in favor of always-inline clusters,
and replaces the debug "More tools" dropdown with a single debug toggle button.

* feat(web): polish app-sidebar headers and consolidate task views

Section headers now lead with a clean uppercase label, drop the mono
clash, lift contrast from /60 to /70, and end with the collapse chevron
at the right so the section body aligns flush under the label.

The Tasks section's filter bar (view chips + gear in a card-tinted
strip) is replaced by a compact view dropdown that lives in the section
header. Drag-to-reorder lives in the existing filter popover, which is
now reachable from the same gear button.

Settings moves below Tasks so the kanban list keeps the flex-grow seat
in the sidebar.

* feat(web): move settings group chevrons to the right

* fix(web): drop chat from home topbar; calm tasks group headers

The kanban top bar's Quick Chat button is removed now that Quick Chat
lives in the AppSidebar; workspaceId stops being threaded into the
tablet and desktop header variants.

Group headers inside the Tasks section (kanban workflow steps) lose
the opaque background strip and the section-label uppercase styling
so they read as inline subgroup rows, not duplicate section dividers.
The tasks-section wrapper drops its negative margin so the group rows
sit flush under the TASKS label instead of bleeding out past it.

* fix(web): restore agent logos on settings tree profile leaves

SettingsLeaf gains a leadingIcon slot for pre-rendered visuals; agents
group passes <AgentLogo agentName={agent.name}> so each profile row
shows its agent's logo like it did before the old SettingsAppSidebar
was retired.

* feat(web): brand the sidebar, move New Task off the kanban top bar

The Add task button (CreateTaskTopbarButton) leaves both the desktop
and tablet kanban headers; New Task is now an unconditional primary
nav item on the AppSidebar, falling back to a disabled affordance when
no workspace is active.

AppSidebar header gains a Kandev wordmark that links to /, with a
single-letter K mark in collapsed-rail mode. PageTopbar's root variant
no longer renders the parent brand label when backLabel is empty, so
the home view's top-left "Kandev" goes away in favor of the sidebar.

* feat(web): unbind sidebar toggle by default; drop Home from breadcrumbs

TOGGLE_SIDEBAR's configurable default is now UNBOUND_SHORTCUT (the new
sentinel for "no binding"); matchesShortcut already returns false on
an empty key, so the keybind hook needs no change. Keyboard shortcuts
settings show "Unbound", expose a Clear (X) button when a custom
binding is set, and the reset icon falls back to unbound for entries
whose default is unbound.

PageTopbar's breadcrumb variant no longer prepends the Home back-link
when backHref is "/" (the AppSidebar provides Home navigation).

* feat(web): move New Task into the sidebar, retire the workspace selector

- Extract AppSidebarNewTaskItem (office vs regular create dialog) with tests
- Drop the Workspace selector from the kanban display dropdown and mobile
  menu sheet; the sidebar workspace picker is now the sole switcher
- Factor SectionHeader out of AppSidebarSection; animate fixed sections via
  Collapsible while the grow (Tasks) section stays flex-driven
- Polish brand header, share SIDEBAR_ITEM_ACTIVE accent-bar classes, move
  task group chevrons to the right, drop the task-item selected fill
- Bump dockview layout keys to v3 and filter the orphaned 'sidebar' column
  so pre-AppSidebar default layouts no longer render a broken grid
- Point the cross-workspace isolation e2e at the sidebar picker

* feat(web): sidebar settings mode, subtask shortcut, accordion settings nav

- Restore subtask creation: the sidebar New Task row now shows a trailing
  subtask affordance when viewing a task (regular mode), reopening the
  NewSubtaskDialog the retired dockview header dropdown used to provide
- Fix settings pages not scrolling: scroll the content region (topbar stays
  pinned) via min-h-0 + overflow-y-auto
- Add a footer gear that toggles a settings-only sidebar takeover; the gear
  spins on toggle. Mode is transient (never persisted) and force-expands a
  collapsed rail on entry
- Make the settings nav a single-open accordion: opening one top-level group
  closes the others and reveals its subsections; route changes re-sync the
  open group. Extract the tree into a reusable SettingsTree
- Repoint subtask e2e specs at the new sidebar affordance

* test(web): restore create-task e2e flow after New Task moved to sidebar

The New Task relocation removed the kanban-header create-task-button and made
the sidebar entry auto-navigate + open the office dialog under the e2e profile
(office on), breaking ~20 specs that drive the regular task-create dialog.

- Give the sidebar New Task the create-task-button testid so KanbanPage
  resolves it again
- Drop the post-create auto-navigation so creating a task stays on the board
  (matches the retired kanban button; specs find/click the new card)
- Ungate the subtask affordance from office mode (subtasks use the compact
  NewSubtaskDialog in both modes, as the old dropdown did)
- Add a useRegularMode() helper that runs a spec file with KANDEV_FEATURES_OFFICE
  disabled (backend.restart bracket) and apply it to the 18 create-task specs
- Point the System sidebar-navigation spec at a /settings/system page so the
  single-open settings accordion has that group expanded

Validated: create-task, create-task-disabled-tooltip, create-task-branch-selector,
and subtask all green (modulo pre-existing idleInput agent-timing flakes that
CI retries absorb).

* fix(web): sidebar task navigation, settings-mode close + transitions

- Clicking a task in the global sidebar from a non-task page now navigates to
  the task route (mounts the dockview) instead of only rewriting the URL via an
  in-place layout switch that assumed the dockview was already mounted
- Make the settings-mode 'Settings' header a close affordance (the footer gear
  is far from the tree); clicking it exits the takeover regardless of which
  group is expanded, and it fades in when opened
- Fade in the Tasks (grow) section content on expand, matching the other
  sections' animated reveal
- Enable next-themes disableTransitionOnChange so dark/light flips switch every
  surface in the same instant rather than each animating at its own duration
- Add a regression e2e for sidebar→dockview navigation

* fix(web): clear sidebar task highlight when off the task route

The AppSidebar is global, so tasks.activeTaskId lingered after navigating Home,
leaving the last-opened task visually selected in the sidebar. Gate the
selected/active highlight by route — only highlight while on /t/ or
/office/tasks/. Expose data-active on the sidebar task item for testability.

Adds an e2e: entering a task highlights it; returning Home clears it.

* fix(web): never build the dockview default layout at zero width

On a fresh client-side navigation into a task the dockview can mount before the
grid has painted, so measureDockviewContainer returned api.width/height = 0.
Building the default layout at width 0 makes dockview collapse the horizontal
columns into a vertical stack (chat / files+changes / terminal) instead of the
intended side-by-side layout. Fall back to the viewport when neither the live
container nor the api report a usable size; the resize observer then snaps the
grid to the exact container size.

TDD: dockview-measure.test.ts covers the zero-size fallback.

* fix(web): keep the default layout horizontal when swapping chat for the session tab

Entering a task rendered a vertical stack (chat on top, files/changes in the
middle, terminal at the bottom) instead of the side-by-side default. The
chat->session-tab swap removed the 'chat' placeholder BEFORE adding the session
panel; when chat was the only panel in the center group, removing it destroyed
that group, the stored centerGroupId went stale, resolveInitialPosition
returned undefined, and the session panel was appended as a new row — flipping
the grid root to VERTICAL with three stacked groups.

Fix: add the session panel into chat's live group first (resolveInitialPosition
now prefers the chat panel's group), THEN remove the chat placeholder, so the
center group never empties and the horizontal split is preserved.

Also fixes the pre-existing pane-resize reload/clamp e2e failures, which were
the same collapse. TDD: task-default-layout.spec.ts asserts the right column
sits beside chat.

* fix(web): remove the sidebar Settings section; settings is gear-only

Settings was still a collapsible nav section in the sidebar AND auto-expanded on
/settings routes. That also caused the reported bug: toggling the footer gear
off while on a settings page revealed the normal sidebar with the Settings
section already expanded, so the settings tree appeared to 'not close'.

- Drop the Settings nav section (and its /settings route auto-expand) — the
  settings tree is reached only via the footer gear takeover
- Delete the now-unused sidebar settings-section component
- e2e: gear opens the tree and closes it even after navigating into a section

* fix(web): show office sidebar sections only while inside Office

Inbox, Projects, and Agents were gated on the office feature flag, so they were
always visible when the feature was enabled. Gate them on actually being in the
Office surface (an /office route, reached via the footer Office button) via a
new useInOffice() hook — they stay hidden in the regular workspace and appear
only after entering Office.

Unit test for the hook; e2e (office fixture) asserts the sections appear on
/office and are absent on the regular home.

* fix(web): drop the colorful workspace selector, restore the minimal one

The workspace picker swapped to a gradient/initials trigger (and gradient
swatches in the dropdown) whenever office was enabled. Always use the plain
name + chevron trigger and drop the gradient swatches, matching the previous
design. Removes the now-unused workspace-gradient helper.

* fix(web): align sidebar header, scope office New Task, exit settings on nav

- Drop the doubled right border: the resize-handle hairline is transparent at
  rest, leaving the aside's border-r as the single edge (highlights on hover).
- Collapse the expanded sidebar header into one h-10 row (brand · workspace
  picker · collapse) so it lines up with the page/dockview top bar.
- Gate the sidebar New Task dialog on useInOffice() (actual /office route)
  instead of the bare office feature flag, so Kanban mode keeps the classic
  create dialog while office is enabled.
- Close the settings takeover when navigating off the /settings surface
  (Home, Kandev brand, Office, etc.), tracked on a real pathname change so the
  gear still opens it in place and in-tree navigation keeps it open.
- Lock in the already-working collapsed-gear-expands behaviour with an e2e.

Tests: new-task-item unit (route gating), settings-gear e2e (Home closes,
collapsed gear expands), office gating e2e (Kanban vs Office dialog).

* fix(web): realign task top bar to h-10, refine sidebar workspace picker

- Pin TaskTopBar to h-10 so its bottom border lines up exactly with the
  sidebar header (and the PageTopbar) — previously it had no fixed height and
  sat ~2px off, breaking the seam across the sidebar/content edge.
- Redesign the workspace picker as a compact, muted secondary control inlined
  after the Kandev brand: brand and workspace share text-sm so they sit on a
  common baseline, with a faint '/' separator; the workspace reads as muted and
  the brand stays primary.
- Drop the workspace switcher (and its irrelevant folder glyph) from the
  collapsed rail — collapsed now shows just brand + expand.

Adds an e2e that measures the task top bar bottom edge against the sidebar
header (within 1px).

* fix(web): replace the /github and /gitlab presets rail with a scope bar

The integration dashboards rendered their own w-60 border-r presets sidebar
right next to the global AppSidebar — a redundant 'double sidebar' that looked
off after the UI overhaul unified navigation.

On desktop, fold the presets into a horizontal scope bar above the list
toolbar: the bounded, common controls (PR/Issue kind + preset pills, grouped
Inbox/Created) stay visible as pills, while the unbounded saved-queries list
collapses into a 'Saved' dropdown so the bar never grows tall. The dashboard is
now single-column like Kanban/Tasks. Mobile is unchanged — the vertical
PresetsSidebar still lives in the hamburger sheet.

Adds a desktop scope-bar e2e (presets + kind switching, no inline rail) and
repoints the mobile page object at the new scope-bar testid.

* fix(web): address CodeRabbit/cubic review — a11y, dedup, dup nav, lazy-load

- Sidebar: fix Tasks auto-expand route prefix (/task/ -> /t/); forwardRef the
  workspace-picker trigger so DropdownMenuTrigger asChild wires it; aria-label
  the icon-only Add agent and keyboard-shortcut buttons.
- Settings tree: drop the duplicate top-level Editors leaf (lives under
  General); broaden the System group active state to all /settings/system.
- CSS: honour prefers-reduced-motion for .sidebar-fade-in.
- Storage: bump the dockview maximize namespace to v3 in lockstep with the
  layout namespace so stale pre-v3 maximize blobs are discarded.
- Integration dashboards: extract a shared IntegrationScopeBar base so the
  github/gitlab scope bars are thin wrappers (de-dup + under the component
  size cap). Reuse useConfiguredIntegrationLinks in the sidebar Integrations
  section instead of recomputing availability.
- Lazy-load the office NewTaskDialog so it's not bundled on non-office routes.
- tasks-view-picker: use onSelect for keyboard-activatable menu items.
- Tests: normalize group x by container left in task-default-layout; refresh
  stale subtask-flow comments; async findByTestId for the lazy office dialog.

* fix(web): gate the scope-bar delete button pointer-events while hidden

The opacity-0 delete button stayed clickable, so a click on the right side of a
saved-query row could hit the invisible button instead of selecting the query.
Add pointer-events-none until the row is hovered (group-hover:pointer-events-auto).

* fix(web): use onSelect for scope-bar saved-query menu items (keyboard a11y)

Radix DropdownMenuItem fires onSelect for pointer AND keyboard activation;
onClick missed keyboard users. The nested delete button stops pointerdown so it
isn't treated as a select of the saved query.

* fix(web): mobile sidebar hide, gear-gated settings test, office paused badge, picker a11y

- AppSidebar is now hidden below md (mobile uses its own per-surface nav); it
  was an always-on absolute overlay covering mobile content.
- Restore the sidebar-agent-paused-badge testid on the office agent row.
- Update system sidebar-navigation spec to open the gear (settings is a
  gear-gated takeover in the overhaul).
- Workspace picker menu items use onSelect for keyboard a11y.

* test(web): reconcile main's E2E suite with the unified-sidebar overhaul

Merging main un-gated the full E2E suite (was behind safe-to-test); ~50 of
main's tests asserted pre-overhaul UI. Updated them to the overhaul:

- Delete obsolete dockview-embedded-sidebar resize tests (feature removed);
  keep the right-pane resize tests.
- Scope locators where tasks now appear in BOTH the page and the global
  AppSidebar list (intended) — task-list, task-list-filters, pr-status-badge.
- Adapt sidebar-filter specs to the new TasksViewPicker (gear popover +
  view-chip dropdown) and its page object.
- Settings now reached via the footer gear (not the topbar); Improve Kandev
  opens from the sidebar footer; office nav folded into the AppSidebar — update
  those specs (office verified in CI; can't run locally).
- session-layout: handle the new close-terminal confirm dialog; multi-session
  and tab-management: foreground the (now backgrounded) chat tab after sidebar
  task switches.
- Skip create-task-url-reopen-no-branches: its provider-branch flow (code
  identical to main) is broken only by the office-off backend restart this PR
  forces via useRegularMode — a harness interaction, not an app regression.

* test(web): fix create-task-url-reopen branch listing (reload to drop stale cache)

Un-skipped and fixed properly. Root cause: during the URL-paste/registration
flow the frontend can transiently fetch branches for the freshly-registered
provider repo before its background clone settles; useBranches caches that early
empty result as terminal (if (isLoaded) return), so re-picking the repo never
re-lists. Reload before the second dialog open drops the in-memory branch cache
so branches list fresh from the backend (verified: the backend returns them).
The provider-API path and product behaviour are unchanged from main.

* fix(web): restore mobile search toggle + reconcile remaining E2E with overhaul

- Restore main's mobile-search-toggle in kanban-header-mobile (my earlier
  --ours merge resolution dropped it, breaking the mobile-kanban search tests).
- Office specs: expand the collapsed Agents/Projects sections before asserting
  their rows, and scope office-tasks-page locators to <main>. [CI-verified]
- mobile-task-list-search / mobile-voice-mode: scope task titles to the table
  and drop the obsolete data-sidebar=trigger assertion.
- app-sidebar.test mock: add settingsMode, toggleAppSidebarSettingsMode, and the
  integrations section key.
- sidebar-filter page object: scope chipMenu to the menu holding the view chips.

* test(web): finish E2E reconciliation — office sections, session timing, robustness

- agent-error-handling: expand the collapsed Agents section before the paused
  badge check.
- office execution-indicator/task-sorting: wait for a non-empty .font-mono
  identifier (assigned async by the backend).
- session-layout: foreground the (CI-backgrounded) chat tab before the idle
  check; make the close-terminal confirm dialog optional (busy heuristic is
  timing-dependent).
- workspace-switch-isolation: run office-off so the picker switches in place
  instead of redirecting to /office (which needs onboarding this fixture skips).
- app-sidebar-page: condense JSDoc to one line per project convention.

* test(web): match main's office identifier assertion (revert over-scoping)

The round-2 office agent scoped the .font-mono identifier check to <main> too,
but only the task TITLE needed scoping for the sidebar duplication. The office
task identifier is empty in the e2e seed (backend-assigned, not populated here),
so the <main>-scoped non-empty filter found nothing. Revert the identifier
assertion to main's exact form (first .font-mono on the page); keep the title
scoped to <main>.

* test(web): restore non-empty .font-mono filter for office identifier checks

Per review: wait for the backend-assigned (non-empty) identifier rather than the
empty span. Unscoped so it finds the identifier wherever it renders, 15s window.

* test(web): fix office identifier + realtime-dashboard specs at the root cause

Ran office specs locally (they DO run here) and root-caused all three:

- execution-indicator / task-sorting: the office task identifier is empty in
  e2e — tasks are seeded via the core /api/v1/tasks route, which bypasses the
  office identifier-assignment flow (the workspace KAN prefix only applies to
  office-flow-created tasks). Main's .font-mono.first() was a false-positive
  matching an element the overhaul removed. Assert the identifier COLUMN renders
  (attached to the row) instead of a value e2e never produces.
- realtime-dashboard: the Recent Tasks card is fed by dashboard.recent_tasks via
  useOfficeRefetch on office WS events; a core-route task's office event is async
  so the in-place refetch is genuinely flaky. Reload for the deterministic SSR
  dashboard fetch (what a user sees on revisit); the WS-refetch mechanism stays
  covered by the sibling cross-workspace test.
This commit is contained in:
José Almeida
2026-06-06 21:58:39 +01:00
committed by GitHub
parent 2557b69c0e
commit b001aa1eb8
140 changed files with 4420 additions and 2636 deletions
@@ -0,0 +1,24 @@
/** Section IDs used for both display and persistence keys in the AppSidebar. */
export const APP_SIDEBAR_SECTION_IDS = {
tasks: "tasks",
projects: "projects",
agents: "agents",
integrations: "integrations",
settings: "settings",
} as const;
export type AppSidebarSectionId =
(typeof APP_SIDEBAR_SECTION_IDS)[keyof typeof APP_SIDEBAR_SECTION_IDS];
export const APP_SIDEBAR_EXPANDED_WIDTH = 240;
export const APP_SIDEBAR_COLLAPSED_WIDTH = 56;
/**
* Shared active/inactive classes for sidebar nav rows. The active state uses a
* thin left accent bar (a `before` pseudo-element) rather than a filled
* background — a saturated fill reads as garish against the brand theme. Rows
* applying `SIDEBAR_ITEM_ACTIVE` get `relative` for free so the bar anchors.
*/
export const SIDEBAR_ITEM_ACTIVE =
"relative text-foreground hover:bg-muted/60 before:absolute before:left-0 before:inset-y-1.5 before:w-[3px] before:rounded-full before:bg-primary before:content-['']";
export const SIDEBAR_ITEM_INACTIVE = "text-foreground/80 hover:bg-muted/60";
@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
IconBuildings,
IconChartBar,
IconSettings,
IconSparkles,
IconStethoscope,
} from "@tabler/icons-react";
import type { Icon as TablerIcon } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { ImproveKandevDialog } from "@/components/improve-kandev-dialog";
import { ReleaseNotesDialog } from "@/components/release-notes/release-notes-dialog";
import { useAppStore } from "@/components/state-provider";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { useReleaseNotes } from "@/hooks/use-release-notes";
import { ThemeToggle } from "@/components/theme-toggle";
import { linkToTask } from "@/lib/links";
import { cn } from "@/lib/utils";
type AppSidebarFooterProps = {
collapsed: boolean;
};
type FooterIconButtonProps = {
icon: TablerIcon;
label: string;
collapsed: boolean;
onClick?: () => void;
href?: string;
badge?: boolean;
testId?: string;
/** Toggle state: rotates the icon a half-turn (spins back out when cleared). */
active?: boolean;
};
function FooterIconButton({
icon: Icon,
label,
collapsed,
onClick,
href,
badge,
testId,
active,
}: FooterIconButtonProps) {
const buttonProps = {
variant: "ghost" as const,
size: "icon" as const,
className: "h-7 w-7 cursor-pointer relative",
};
const content = (
<>
<Icon
className={cn(
"h-3.5 w-3.5 transition-transform duration-300",
active && "rotate-180 text-foreground",
)}
/>
{badge && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-primary border border-background" />
)}
</>
);
const trigger = href ? (
<Button asChild {...buttonProps}>
<Link href={href} aria-label={label} data-testid={testId}>
{content}
</Link>
</Button>
) : (
<Button
onClick={onClick}
{...buttonProps}
aria-label={label}
aria-pressed={active}
data-testid={testId}
>
{content}
</Button>
);
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side={collapsed ? "right" : "top"}>{label}</TooltipContent>
</Tooltip>
);
}
export function AppSidebarFooter({ collapsed }: AppSidebarFooterProps) {
const router = useRouter();
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const settingsMode = useAppStore((s) => s.appSidebar.settingsMode);
const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode);
const officeEnabled = useFeature("office");
const releaseNotes = useReleaseNotes();
const [improveOpen, setImproveOpen] = useState(false);
return (
<div
className={cn(
"flex items-center border-t border-border shrink-0",
collapsed ? "flex-col gap-1 justify-center px-1 py-1.5" : "px-2 py-1.5 gap-1 flex-wrap",
)}
>
<FooterIconButton
icon={IconSettings}
label={settingsMode ? "Close settings" : "Settings"}
collapsed={collapsed}
onClick={toggleSettingsMode}
active={settingsMode}
testId="sidebar-settings-gear"
/>
<FooterIconButton
icon={IconChartBar}
label="Stats"
collapsed={collapsed}
href="/stats"
testId="sidebar-stats-button"
/>
<FooterIconButton
icon={IconStethoscope}
label="Improve Kandev"
collapsed={collapsed}
onClick={() => setImproveOpen(true)}
testId="sidebar-improve-kandev-button"
/>
{releaseNotes.showTopbarButton && (
<FooterIconButton
icon={IconSparkles}
label="What's new"
collapsed={collapsed}
onClick={releaseNotes.openDialog}
badge={releaseNotes.hasUnseen}
testId="sidebar-release-notes-button"
/>
)}
{officeEnabled && (
<FooterIconButton
icon={IconBuildings}
label="Office"
collapsed={collapsed}
href="/office"
testId="sidebar-office-button"
/>
)}
<ThemeToggle />
<ImproveKandevDialog
open={improveOpen}
onOpenChange={setImproveOpen}
workspaceId={workspaceId ?? null}
onSuccess={(task) => router.push(linkToTask(task.id))}
/>
{releaseNotes.hasNotes && (
<ReleaseNotesDialog
open={releaseNotes.dialogOpen}
onOpenChange={releaseNotes.closeDialog}
entries={releaseNotes.unseenEntries}
latestVersion={releaseNotes.latestVersion}
/>
)}
</div>
);
}
@@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { IconChevronsLeft, IconChevronsRight } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import { AppSidebarWorkspacePicker } from "./app-sidebar-workspace-picker";
type AppSidebarHeaderProps = {
collapsed: boolean;
onToggleCollapse: () => void;
};
const COLLAPSE_BUTTON_CLASS = "h-7 w-7 shrink-0 cursor-pointer";
export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHeaderProps) {
if (collapsed) {
// Minimal rail: brand home + expand. The workspace switcher lives only in
// the expanded header — a lone workspace glyph here read as noise.
return (
<div className="flex flex-col items-center gap-1 px-1 py-1.5 border-b border-border shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/"
aria-label="Kandev home"
className="flex h-7 w-7 items-center justify-center rounded-md text-foreground/80 hover:bg-muted/60 cursor-pointer"
>
<span className="text-base font-bold tracking-tight">K</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">Kandev</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={COLLAPSE_BUTTON_CLASS}
onClick={onToggleCollapse}
aria-label="Expand sidebar"
>
<IconChevronsRight className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Expand sidebar</TooltipContent>
</Tooltip>
</div>
);
}
// Single h-10 row — brand · workspace picker · collapse — so the sidebar's
// top section lines up with the page/dockview top bar (also h-10). Brand and
// workspace share the same text size so they sit on a common baseline; the
// brand carries weight/colour, the workspace stays muted and secondary.
return (
<div className="flex items-center gap-1.5 h-10 px-3 shrink-0 border-b border-border">
<Link
href="/"
aria-label="Kandev home"
className={cn(
"shrink-0 cursor-pointer text-sm font-semibold tracking-tight",
"text-foreground hover:text-foreground/80 transition-colors",
)}
>
Kandev
</Link>
<span aria-hidden className="shrink-0 select-none text-muted-foreground/30">
/
</span>
<AppSidebarWorkspacePicker />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={COLLAPSE_BUTTON_CLASS}
onClick={onToggleCollapse}
aria-label="Collapse sidebar"
>
<IconChevronsLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Collapse sidebar</TooltipContent>
</Tooltip>
</div>
);
}
@@ -0,0 +1,126 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { Icon as TablerIcon } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import { SIDEBAR_ITEM_ACTIVE, SIDEBAR_ITEM_INACTIVE } from "./app-sidebar-constants";
type AppSidebarNavItemProps = {
icon: TablerIcon;
label: string;
href?: string;
badge?: number;
onClick?: () => void;
collapsed: boolean;
/** Override the auto-derived active-state from pathname. */
isActive?: boolean;
/** Suppress the default href-startsWith activation (use for "Home"). */
exactMatch?: boolean;
/** Render as visually disabled and ignore clicks. */
disabled?: boolean;
/** Optional data-testid placed on the button/link element. */
testId?: string;
};
type TriggerProps = {
onClick?: () => void;
disabled: boolean;
baseClass: string;
label: string;
href?: string;
inner: React.ReactNode;
testId?: string;
};
function renderTrigger({ onClick, disabled, baseClass, label, href, inner, testId }: TriggerProps) {
if (onClick) {
return (
<button
type="button"
onClick={disabled ? undefined : onClick}
className={baseClass}
aria-label={label}
aria-disabled={disabled || undefined}
disabled={disabled}
data-testid={testId}
>
{inner}
</button>
);
}
if (disabled) {
return (
<span className={baseClass} aria-label={label} aria-disabled="true" data-testid={testId}>
{inner}
</span>
);
}
return (
<Link href={href ?? "#"} className={baseClass} aria-label={label} data-testid={testId}>
{inner}
</Link>
);
}
function isPathActive(pathname: string, href: string | undefined, exactMatch: boolean): boolean {
if (!href) return false;
if (exactMatch) return pathname === href;
if (pathname === href) return true;
return href !== "/" && pathname.startsWith(`${href}/`);
}
export function AppSidebarNavItem({
icon: Icon,
label,
href,
badge,
onClick,
collapsed,
isActive,
exactMatch = false,
disabled = false,
testId,
}: AppSidebarNavItemProps) {
const pathname = usePathname();
const active = isActive ?? isPathActive(pathname, href, exactMatch);
const baseClass = cn(
"flex items-center rounded-md text-[13px] font-medium transition-colors",
collapsed ? "h-9 w-9 justify-center mx-auto" : "h-9 px-2.5 gap-2.5 w-full text-left",
disabled
? "cursor-not-allowed text-foreground/40"
: cn("cursor-pointer", active ? SIDEBAR_ITEM_ACTIVE : SIDEBAR_ITEM_INACTIVE),
);
const inner = (
<>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && (
<>
<span className="flex-1 truncate sidebar-fade-in">{label}</span>
{typeof badge === "number" && badge > 0 && (
<Badge className="rounded-full px-1.5 py-0.5 text-xs bg-primary text-primary-foreground">
{badge}
</Badge>
)}
</>
)}
</>
);
const buttonOrLink = renderTrigger({ onClick, disabled, baseClass, label, href, inner, testId });
if (!collapsed) return buttonOrLink;
return (
<Tooltip>
<TooltipTrigger asChild>{buttonOrLink}</TooltipTrigger>
<TooltipContent side="right">
{label}
{typeof badge === "number" && badge > 0 ? ` (${badge})` : ""}
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
function renderItem(collapsed: boolean) {
return render(
<TooltipProvider>
<AppSidebarNewTaskItem collapsed={collapsed} />
</TooltipProvider>,
);
}
const state = {
workspaces: { activeId: "ws-1" as string | null },
kanban: {
workflowId: "wf-1" as string | null,
steps: [{ id: "s1", title: "Todo" }],
tasks: [{ id: "t-1", title: "Parent task" }] as Array<{ id: string; title: string }>,
},
tasks: { activeTaskId: null as string | null },
};
let officeEnabled = false;
let pathname = "/";
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (s: typeof state) => unknown) => selector(state),
}));
vi.mock("@/hooks/domains/features/use-feature", () => ({
useFeature: () => officeEnabled,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => pathname,
}));
vi.mock("@/app/office/components/new-task-dialog", () => ({
NewTaskDialog: () => <div data-testid="office-new-task-dialog" />,
}));
vi.mock("@/components/task-create-dialog", () => ({
TaskCreateDialog: () => <div data-testid="regular-task-create-dialog" />,
}));
vi.mock("@/components/task/new-subtask-dialog", () => ({
NewSubtaskDialog: () => <div data-testid="new-subtask-dialog" />,
}));
import { AppSidebarNewTaskItem } from "./app-sidebar-new-task-item";
const SUBTASK_TESTID = "sidebar-new-subtask";
const OFFICE_DIALOG_TESTID = "office-new-task-dialog";
const REGULAR_DIALOG_TESTID = "regular-task-create-dialog";
describe("AppSidebarNewTaskItem", () => {
beforeEach(() => {
state.workspaces.activeId = "ws-1";
state.kanban.workflowId = "wf-1";
state.kanban.steps = [{ id: "s1", title: "Todo" }];
state.kanban.tasks = [{ id: "t-1", title: "Parent task" }];
state.tasks.activeTaskId = null;
officeEnabled = false;
pathname = "/";
});
afterEach(() => cleanup());
it("uses the regular task-create dialog when office is disabled", () => {
officeEnabled = false;
renderItem(false);
expect(screen.getByTestId(REGULAR_DIALOG_TESTID)).toBeTruthy();
expect(screen.queryByTestId(OFFICE_DIALOG_TESTID)).toBeNull();
});
it("uses the regular dialog when office is enabled but NOT on an office route", () => {
// The bug: office-on alone routed to the Office dialog even in Kanban mode.
// Gating is now on the actual /office route, so home stays on the Kanban dialog.
officeEnabled = true;
pathname = "/";
renderItem(false);
expect(screen.getByTestId(REGULAR_DIALOG_TESTID)).toBeTruthy();
expect(screen.queryByTestId(OFFICE_DIALOG_TESTID)).toBeNull();
});
it("uses the office new-issue dialog when inside an office route", async () => {
officeEnabled = true;
pathname = "/office";
renderItem(false);
// NewTaskDialog is lazy-loaded (next/dynamic), so it resolves asynchronously.
expect(await screen.findByTestId(OFFICE_DIALOG_TESTID)).toBeTruthy();
expect(screen.queryByTestId(REGULAR_DIALOG_TESTID)).toBeNull();
});
it("renders no dialog when there is no active workspace", () => {
state.workspaces.activeId = null;
renderItem(false);
expect(screen.queryByTestId(REGULAR_DIALOG_TESTID)).toBeNull();
expect(screen.queryByTestId(OFFICE_DIALOG_TESTID)).toBeNull();
});
it("offers a subtask affordance when a task is active in regular mode", () => {
state.tasks.activeTaskId = "t-1";
renderItem(false);
expect(screen.getByTestId(SUBTASK_TESTID)).toBeTruthy();
expect(screen.getByTestId("new-subtask-dialog")).toBeTruthy();
});
it("hides the subtask affordance when no task is active", () => {
state.tasks.activeTaskId = null;
renderItem(false);
expect(screen.queryByTestId(SUBTASK_TESTID)).toBeNull();
});
it("offers the subtask affordance in office mode too (compact subtask dialog)", async () => {
officeEnabled = true;
pathname = "/office";
state.tasks.activeTaskId = "t-1";
renderItem(false);
// Primary New Task uses the office dialog (lazy-loaded), but subtasks still
// go through the compact NewSubtaskDialog regardless of mode.
expect(await screen.findByTestId(OFFICE_DIALOG_TESTID)).toBeTruthy();
expect(screen.getByTestId(SUBTASK_TESTID)).toBeTruthy();
expect(screen.getByTestId("new-subtask-dialog")).toBeTruthy();
});
it("hides the subtask affordance when the rail is collapsed", () => {
state.tasks.activeTaskId = "t-1";
renderItem(true);
expect(screen.queryByTestId(SUBTASK_TESTID)).toBeNull();
});
});
@@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
import { IconSquarePlus, IconSubtask } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import { useInOffice } from "@/hooks/use-in-office";
import { TaskCreateDialog } from "@/components/task-create-dialog";
// The Office "New issue" dialog only renders on `/office` routes, but this item
// lives in the global sidebar (every page). Lazy-load it so its office-only
// dependencies aren't shipped in the bundle for non-office routes.
const NewTaskDialog = dynamic(
() => import("@/app/office/components/new-task-dialog").then((m) => m.NewTaskDialog),
{ ssr: false },
);
import { NewSubtaskDialog } from "@/components/task/new-subtask-dialog";
import { AppSidebarNavItem } from "./app-sidebar-nav-item";
type AppSidebarNewTaskItemProps = {
collapsed: boolean;
};
/**
* "New Task" entry in the sidebar primary nav. Inside Office (an `/office`
* route) it opens the richer "New issue" dialog (projects/assignees/stages);
* everywhere else — including regular Kanban while the office feature is merely
* enabled — it opens the standard task-create dialog wired to the active
* workflow. Gate on `useInOffice()` (route), not the bare `office` flag, so the
* Office dialog never leaks into Kanban mode.
*
* When the user is inside a task (an active task in regular mode), a trailing
* subtask affordance appears so a child task can be created against the current
* one — restoring the contextual action the retired dockview header dropdown
* used to provide.
*/
export function AppSidebarNewTaskItem({ collapsed }: AppSidebarNewTaskItemProps) {
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const workflowId = useAppStore((s) => s.kanban.workflowId);
const steps = useAppStore((s) => s.kanban.steps);
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
const activeTaskTitle = useAppStore((s) => {
const id = s.tasks.activeTaskId;
if (!id) return "";
return s.kanban.tasks.find((t) => t.id === id)?.title ?? "";
});
const inOffice = useInOffice();
const [open, setOpen] = useState(false);
const [subtaskOpen, setSubtaskOpen] = useState(false);
// The subtask affordance is available in both modes (office uses the richer
// dialog for the primary New Task, but subtasks always go through the compact
// NewSubtaskDialog, matching the retired dropdown). It needs an active task
// and the expanded rail to host the trailing button.
const canCreateSubtask = !collapsed && !!workspaceId && !!activeTaskId;
return (
<>
<div className="relative">
<AppSidebarNavItem
icon={IconSquarePlus}
label="New Task"
onClick={() => setOpen(true)}
collapsed={collapsed}
disabled={!workspaceId}
testId="create-task-button"
/>
{canCreateSubtask && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setSubtaskOpen(true)}
aria-label="New subtask of current task"
data-testid="sidebar-new-subtask"
className="absolute right-1.5 top-1/2 -translate-y-1/2 flex h-6 w-6 items-center justify-center rounded text-muted-foreground/70 hover:bg-muted hover:text-foreground cursor-pointer"
>
<IconSubtask className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">New subtask of current task</TooltipContent>
</Tooltip>
)}
</div>
{workspaceId &&
(inOffice ? (
<NewTaskDialog open={open} onOpenChange={setOpen} />
) : (
<TaskCreateDialog
open={open}
onOpenChange={setOpen}
mode="create"
workspaceId={workspaceId}
workflowId={workflowId}
defaultStepId={steps[0]?.id ?? null}
steps={steps}
onSuccess={() => setOpen(false)}
/>
))}
{canCreateSubtask && (
<NewSubtaskDialog
open={subtaskOpen}
onOpenChange={setSubtaskOpen}
parentTaskId={activeTaskId}
parentTaskTitle={activeTaskTitle}
/>
)}
</>
);
}
@@ -0,0 +1,43 @@
"use client";
import { IconHome, IconInbox, IconMessageCircle } from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import { useInOffice } from "@/hooks/use-in-office";
import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher";
import { AppSidebarNavItem } from "./app-sidebar-nav-item";
import { AppSidebarNewTaskItem } from "./app-sidebar-new-task-item";
type AppSidebarPrimaryNavProps = {
collapsed: boolean;
};
export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) {
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const inboxCount = useAppStore((s) => s.office.inboxCount);
const inOffice = useInOffice();
const handleOpenQuickChat = useQuickChatLauncher(workspaceId);
return (
<div className="flex flex-col gap-0.5">
<AppSidebarNavItem icon={IconHome} label="Home" href="/" collapsed={collapsed} exactMatch />
{inOffice && (
<AppSidebarNavItem
icon={IconInbox}
label="Inbox"
href="/office/inbox"
badge={inboxCount}
collapsed={collapsed}
/>
)}
{workspaceId && (
<AppSidebarNavItem
icon={IconMessageCircle}
label="Quick Chat"
onClick={handleOpenQuickChat}
collapsed={collapsed}
/>
)}
<AppSidebarNewTaskItem collapsed={collapsed} />
</div>
);
}
@@ -0,0 +1,25 @@
"use client";
type AppSidebarResizeHandleProps = {
onMouseDown: (e: React.MouseEvent) => void;
};
/**
* Hairline drag handle on the right edge of the expanded AppSidebar.
* Hover-visible only; widens slightly when active for affordance.
*/
export function AppSidebarResizeHandle({ onMouseDown }: AppSidebarResizeHandleProps) {
return (
<button
type="button"
aria-label="Resize sidebar"
onMouseDown={onMouseDown}
tabIndex={-1}
className="absolute right-0 top-0 h-full w-1.5 cursor-ew-resize group flex items-center justify-center"
>
{/* Transparent at rest — the aside's own border-r is the visible edge.
Colouring this too would double the hairline. Highlights on hover. */}
<div className="h-full w-px bg-transparent group-hover:bg-primary/60 transition-colors" />
</button>
);
}
@@ -0,0 +1,126 @@
"use client";
import { IconChevronRight } from "@tabler/icons-react";
import type { Icon as TablerIcon } from "@tabler/icons-react";
import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import { cn } from "@/lib/utils";
type AppSidebarSectionProps = {
id: string;
label: string;
collapsed: boolean;
icon: TablerIcon;
children: React.ReactNode;
/** Optional control rendered between the label and the collapse chevron. */
headerAction?: React.ReactNode;
/** Fills remaining sidebar height when expanded. Parent must be a flex column. */
grow?: boolean;
};
type SectionHeaderProps = {
label: string;
expanded: boolean;
headerAction?: React.ReactNode;
onToggle: () => void;
};
function SectionHeader({ label, expanded, headerAction, onToggle }: SectionHeaderProps) {
return (
<div className="group/section flex items-center px-2 h-7 shrink-0">
<button
type="button"
onClick={onToggle}
className="flex min-w-0 flex-1 items-center text-left cursor-pointer text-foreground/70 hover:text-foreground transition-colors"
aria-expanded={expanded}
>
<span className="text-[11px] font-semibold uppercase tracking-wider truncate">{label}</span>
</button>
{expanded && headerAction && (
<div className="shrink-0 mr-1 flex items-center">{headerAction}</div>
)}
<button
type="button"
onClick={onToggle}
tabIndex={-1}
aria-hidden="true"
className="shrink-0 flex h-5 w-5 items-center justify-center text-muted-foreground/60 hover:text-foreground/70 cursor-pointer transition-colors"
>
<IconChevronRight
className={cn("h-3.5 w-3.5 transition-transform", expanded && "rotate-90")}
/>
</button>
</div>
);
}
export function AppSidebarSection({
id,
label,
collapsed,
icon: Icon,
children,
headerAction,
grow,
}: AppSidebarSectionProps) {
const expanded = useAppStore((s) => s.appSidebar.sectionExpanded[id] ?? false);
const toggleSection = useAppStore((s) => s.toggleAppSidebarSection);
const setCollapsed = useAppStore((s) => s.setAppSidebarCollapsed);
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex h-9 w-9 mx-auto items-center justify-center rounded-md text-foreground/70 hover:bg-muted/60 cursor-pointer"
onClick={() => {
setCollapsed(false);
if (!expanded) toggleSection(id);
}}
aria-label={label}
>
<Icon className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
);
}
const handleToggle = () => toggleSection(id);
// The grow section (Tasks) absorbs remaining vertical space and scrolls
// internally, so it stays flex-driven rather than animating to content
// height like the fixed-size sections below.
if (grow) {
return (
<div className={cn(expanded && "flex-1 min-h-0 flex flex-col")}>
<SectionHeader
label={label}
expanded={expanded}
headerAction={headerAction}
onToggle={handleToggle}
/>
{expanded && (
<div className="flex flex-col gap-0.5 flex-1 min-h-0 sidebar-fade-in">{children}</div>
)}
</div>
);
}
return (
<Collapsible open={expanded}>
<SectionHeader
label={label}
expanded={expanded}
headerAction={headerAction}
onToggle={handleToggle}
/>
<CollapsibleContent className="sidebar-section-content">
<div className="flex flex-col gap-0.5">{children}</div>
</CollapsibleContent>
</Collapsible>
);
}
@@ -0,0 +1,43 @@
"use client";
import { usePathname } from "next/navigation";
import { IconSettings, IconChevronLeft } from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import { SettingsTree } from "./sections/settings/settings-tree";
/**
* Full-height settings takeover for the sidebar, shown while the footer gear is
* active. Replaces the normal primary nav + sections with just the settings
* tree, which fills the remaining height and scrolls internally.
*
* The header doubles as the exit affordance: the footer gear sits at the bottom
* of the rail, far from the tree, so clicking the "Settings" header at the top
* closes the takeover and returns to the normal sidebar — regardless of which
* group is currently expanded.
*/
export function AppSidebarSettingsMode() {
const pathname = usePathname();
const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode);
return (
<div
className="flex-1 min-h-0 flex flex-col gap-1 sidebar-fade-in"
data-testid="app-sidebar-settings-mode"
>
<button
type="button"
onClick={toggleSettingsMode}
aria-label="Close settings"
data-testid="app-sidebar-settings-mode-close"
className="group/close flex items-center gap-1.5 px-2 h-7 shrink-0 rounded-md text-foreground/70 hover:bg-muted/60 hover:text-foreground cursor-pointer transition-colors"
>
<IconSettings className="h-3.5 w-3.5 group-hover/close:hidden" />
<IconChevronLeft className="h-3.5 w-3.5 hidden group-hover/close:block" />
<span className="text-[11px] font-semibold uppercase tracking-wider">Settings</span>
</button>
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5">
<SettingsTree pathname={pathname} />
</div>
</div>
);
}
@@ -0,0 +1,105 @@
"use client";
import { forwardRef, useCallback, useState, type ComponentPropsWithoutRef } from "react";
import { useRouter } from "next/navigation";
import { IconCheck, IconChevronDown, IconPlus } from "@tabler/icons-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { useAppStore } from "@/components/state-provider";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { cn } from "@/lib/utils";
/**
* Compact, secondary workspace switcher inlined after the Kandev brand in the
* sidebar header. Muted by default so the brand stays primary; the active
* workspace name truncates with a small chevron hinting the dropdown. Only
* rendered while the sidebar is expanded — the collapsed rail omits it.
*
* forwardRef + prop spread so `DropdownMenuTrigger asChild` can wire the trigger
* (ref, onClick, aria-*, data-state) onto the underlying button.
*/
const WorkspaceTrigger = forwardRef<
HTMLButtonElement,
ComponentPropsWithoutRef<"button"> & { activeName: string }
>(function WorkspaceTrigger({ activeName, className, ...props }, ref) {
return (
<button
ref={ref}
type="button"
data-testid="sidebar-workspace-trigger"
aria-label="Switch workspace"
className={cn(
"group/ws flex min-w-0 flex-1 items-center gap-1 rounded-md px-1.5 py-1 text-sm font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground cursor-pointer transition-colors",
className,
)}
{...props}
>
<span className="min-w-0 flex-1 truncate text-left sidebar-fade-in">{activeName}</span>
<IconChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50 transition-opacity group-hover/ws:opacity-80" />
</button>
);
});
export function AppSidebarWorkspacePicker() {
const router = useRouter();
const officeEnabled = useFeature("office");
const workspaces = useAppStore((s) => s.workspaces);
const setActiveWorkspace = useAppStore((s) => s.setActiveWorkspace);
const [open, setOpen] = useState(false);
const activeWorkspace = workspaces.items.find((w) => w.id === workspaces.activeId);
const activeId = activeWorkspace?.id ?? null;
const activeName = activeWorkspace?.name ?? "Workspace";
const handleSelect = useCallback(
(id: string) => {
document.cookie = `office-active-workspace=${id}; path=/; max-age=86400; samesite=strict; secure`;
setActiveWorkspace(id);
if (officeEnabled) {
router.push(`/office?workspaceId=${id}`);
}
setOpen(false);
},
[router, setActiveWorkspace, officeEnabled],
);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<WorkspaceTrigger activeName={activeName} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60">
{workspaces.items.length === 0 ? (
<DropdownMenuItem disabled>No workspaces</DropdownMenuItem>
) : (
workspaces.items.map((ws) => (
<DropdownMenuItem
key={ws.id}
data-testid={`sidebar-workspace-item-${ws.id}`}
onSelect={() => handleSelect(ws.id)}
className="cursor-pointer gap-2"
>
<span className="flex-1 truncate">{ws.name}</span>
{ws.id === activeId && <IconCheck className="h-3.5 w-3.5" />}
</DropdownMenuItem>
))
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer gap-2"
onSelect={() => {
router.push("/office/setup?mode=new");
}}
>
<IconPlus className="h-3.5 w-3.5" />
<span>Add workspace</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
// The AppSidebar pulls in a lot of children that touch the dockview / kanban
// data layer. For unit testing the collapse + section toggle behaviour we stub
// the children to keep the test focused on the shell.
vi.mock("./app-sidebar-header", () => ({
AppSidebarHeader: ({
collapsed,
onToggleCollapse,
}: {
collapsed: boolean;
onToggleCollapse: () => void;
}) => (
<button
type="button"
onClick={onToggleCollapse}
data-testid="header-toggle"
data-collapsed={collapsed ? "true" : "false"}
>
header
</button>
),
}));
vi.mock("./app-sidebar-primary-nav", () => ({
AppSidebarPrimaryNav: () => <div data-testid="primary-nav" />,
}));
vi.mock("./sections/tasks-section", () => ({
TasksSection: ({ collapsed }: { collapsed: boolean }) => (
<div data-testid="tasks-section" data-collapsed={collapsed ? "true" : "false"}>
tasks
</div>
),
}));
vi.mock("./sections/projects-section", () => ({
ProjectsSection: () => <div data-testid="projects-section" />,
}));
vi.mock("./sections/agents-section", () => ({
AgentsSection: () => <div data-testid="agents-section" />,
}));
vi.mock("./sections/integrations-section", () => ({
IntegrationsSection: () => <div data-testid="integrations-section" />,
}));
vi.mock("./app-sidebar-footer", () => ({
AppSidebarFooter: () => <div data-testid="footer" />,
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/",
}));
const storeState = {
appSidebar: {
collapsed: false,
sectionExpanded: {
tasks: true,
projects: false,
agents: false,
integrations: false,
settings: false,
},
width: 240,
settingsMode: false,
},
toggleAppSidebar: vi.fn(),
setAppSidebarCollapsed: vi.fn(),
toggleAppSidebarSection: vi.fn(),
setAppSidebarWidth: vi.fn(),
toggleAppSidebarSettingsMode: vi.fn(),
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
import { AppSidebar } from "./app-sidebar";
describe("AppSidebar", () => {
beforeEach(() => {
storeState.appSidebar.collapsed = false;
storeState.toggleAppSidebar = vi.fn();
storeState.toggleAppSidebarSection = vi.fn();
});
afterEach(() => {
cleanup();
});
it("renders the nav sections when expanded (no Settings section — that's the footer gear)", () => {
render(<AppSidebar />);
expect(screen.getByTestId("app-sidebar").getAttribute("data-collapsed")).toBe("false");
expect(screen.getByTestId("tasks-section")).toBeTruthy();
expect(screen.getByTestId("projects-section")).toBeTruthy();
expect(screen.getByTestId("agents-section")).toBeTruthy();
expect(screen.queryByTestId("settings-section")).toBeNull();
});
it("renders collapsed when store reports collapsed=true", () => {
storeState.appSidebar.collapsed = true;
render(<AppSidebar />);
expect(screen.getByTestId("app-sidebar").getAttribute("data-collapsed")).toBe("true");
expect(screen.getByTestId("tasks-section").getAttribute("data-collapsed")).toBe("true");
});
it("invokes toggleAppSidebar when the header collapse button is clicked", () => {
render(<AppSidebar />);
fireEvent.click(screen.getByTestId("header-toggle"));
expect(storeState.toggleAppSidebar).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,143 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { useAppStore } from "@/components/state-provider";
import { cn } from "@/lib/utils";
import {
APP_SIDEBAR_COLLAPSED_WIDTH,
APP_SIDEBAR_EXPANDED_WIDTH,
APP_SIDEBAR_SECTION_IDS,
} from "./app-sidebar-constants";
import { AppSidebarFooter } from "./app-sidebar-footer";
import { AppSidebarHeader } from "./app-sidebar-header";
import { AppSidebarPrimaryNav } from "./app-sidebar-primary-nav";
import { AppSidebarResizeHandle } from "./app-sidebar-resize-handle";
import { AppSidebarSettingsMode } from "./app-sidebar-settings-mode";
import { AgentsSection } from "./sections/agents-section";
import { IntegrationsSection } from "./sections/integrations-section";
import { ProjectsSection } from "./sections/projects-section";
import { TasksSection } from "./sections/tasks-section";
const SECTION_ROUTE_MAP: Array<{ id: string; matches: (path: string) => boolean }> = [
{
id: APP_SIDEBAR_SECTION_IDS.tasks,
matches: (p) => p.startsWith("/office/tasks") || p.startsWith("/t/"),
},
{ id: APP_SIDEBAR_SECTION_IDS.projects, matches: (p) => p.startsWith("/office/projects") },
{ id: APP_SIDEBAR_SECTION_IDS.agents, matches: (p) => p.startsWith("/office/agents") },
];
/**
* Unified app sidebar mounted at the root layout. Replaces the legacy
* WorkspaceRail + OfficeSidebar + dockview-embedded sidebar surfaces.
*
* Width: w-60 expanded / w-14 collapsed, smooth 300ms transition. Desktop-only
* (`hidden md:flex`) — mobile surfaces carry their own nav (mobile headers and
* menu sheets), so the global rail never overlays mobile content.
*/
export function AppSidebar() {
const collapsed = useAppStore((s) => s.appSidebar.collapsed);
const settingsMode = useAppStore((s) => s.appSidebar.settingsMode);
const sectionExpanded = useAppStore((s) => s.appSidebar.sectionExpanded);
const storedWidth = useAppStore((s) => s.appSidebar.width);
const toggleSection = useAppStore((s) => s.toggleAppSidebarSection);
const toggleCollapsed = useAppStore((s) => s.toggleAppSidebar);
const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode);
const setWidth = useAppStore((s) => s.setAppSidebarWidth);
const pathname = usePathname();
const handleResize = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startWidth = storedWidth;
const maxWidth = Math.floor(window.innerWidth * 0.3);
const onMove = (moveEvent: MouseEvent) => {
const next = Math.min(
maxWidth,
Math.max(APP_SIDEBAR_EXPANDED_WIDTH, startWidth + (moveEvent.clientX - startX)),
);
setWidth(next);
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[storedWidth, setWidth],
);
const expandedWidth = Math.max(APP_SIDEBAR_EXPANDED_WIDTH, storedWidth);
// Close the settings takeover when the user navigates off the settings
// surface — e.g. the Home button, the Kandev brand, the Office/Stats footer
// buttons, or opening a task. Keyed on an actual pathname *change* so opening
// the gear from a non-settings page (no navigation) doesn't immediately close
// it, and clicking within the tree (`/settings/...`) keeps it open.
const prevPathnameRef = useRef(pathname);
useEffect(() => {
if (prevPathnameRef.current === pathname) return;
prevPathnameRef.current = pathname;
if (settingsMode && (!pathname || !pathname.startsWith("/settings"))) {
toggleSettingsMode();
}
}, [pathname, settingsMode, toggleSettingsMode]);
useEffect(() => {
if (!pathname) return;
for (const entry of SECTION_ROUTE_MAP) {
if (entry.matches(pathname) && !sectionExpanded[entry.id]) {
toggleSection(entry.id);
}
}
// Intentionally depend only on the pathname so user-collapses aren't
// immediately re-expanded by section state churn.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
return (
<aside
data-testid="app-sidebar"
data-collapsed={collapsed ? "true" : "false"}
className={cn(
// Desktop-only: mobile uses its own per-surface nav (mobile headers +
// menu sheets), so the global rail is hidden below md to avoid an
// always-on overlay covering page content. `md:relative` anchors the
// absolute resize handle.
"h-full min-h-0 border-r border-border bg-background hidden md:flex flex-col shrink-0",
"md:relative",
"transition-all duration-300 ease-out",
)}
style={{
width: collapsed ? APP_SIDEBAR_COLLAPSED_WIDTH : expandedWidth,
}}
>
<AppSidebarHeader collapsed={collapsed} onToggleCollapse={toggleCollapsed} />
<nav className="flex-1 min-h-0 flex flex-col gap-2 px-2 py-2 overflow-hidden">
{settingsMode && !collapsed ? (
<AppSidebarSettingsMode />
) : (
<>
<div className="shrink-0 flex flex-col gap-2 overflow-y-auto">
<AppSidebarPrimaryNav collapsed={collapsed} />
<ProjectsSection collapsed={collapsed} />
<AgentsSection collapsed={collapsed} />
<IntegrationsSection collapsed={collapsed} />
</div>
{/* Tasks is the flex-grow middle section so it absorbs remaining
vertical space and scrolls internally. Settings is intentionally
not a nav section — it's reached only via the footer gear, which
takes the whole sidebar over with the settings tree. */}
<TasksSection collapsed={collapsed} />
</>
)}
</nav>
<AppSidebarFooter collapsed={collapsed} />
{!collapsed && <AppSidebarResizeHandle onMouseDown={handleResize} />}
</aside>
);
}
@@ -0,0 +1,130 @@
"use client";
import { useCallback, useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { IconPlus, IconRobot } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import { useInOffice } from "@/hooks/use-in-office";
import { useOfficeRefetch } from "@/hooks/use-office-refetch";
import { listAgentProfiles } from "@/lib/api/domains/office-api";
import { cn } from "@/lib/utils";
import type { AgentProfile } from "@/lib/state/slices/office/types";
import { selectActiveSessionsForAgent } from "@/lib/state/slices/session/selectors";
import { AgentAvatar } from "@/app/office/components/agent-avatar";
import { AgentStatusDot } from "@/app/office/agents/components/agent-status-dot";
import { LiveAgentIndicator } from "@/app/office/agents/components/live-agent-indicator";
import {
APP_SIDEBAR_SECTION_IDS,
SIDEBAR_ITEM_ACTIVE,
SIDEBAR_ITEM_INACTIVE,
} from "../app-sidebar-constants";
import { AppSidebarSection } from "../app-sidebar-section";
type AgentsSectionProps = {
collapsed: boolean;
};
export function AgentsSection({ collapsed }: AgentsSectionProps) {
const router = useRouter();
const inOffice = useInOffice();
const agents = useAppStore((s) => s.office.agentProfiles);
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const setOfficeAgentProfiles = useAppStore((s) => s.setOfficeAgentProfiles);
const refetchAgents = useCallback(async () => {
if (!workspaceId || !inOffice) return;
const res = await listAgentProfiles(workspaceId).catch(() => ({ agents: [] }));
setOfficeAgentProfiles(res.agents ?? []);
}, [workspaceId, inOffice, setOfficeAgentProfiles]);
useEffect(() => {
refetchAgents();
}, [refetchAgents]);
useOfficeRefetch("agents", refetchAgents);
if (!inOffice) return null;
const headerAction = (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 cursor-pointer"
aria-label="Add agent"
onClick={() => router.push("/office/agents")}
>
<IconPlus className="h-3 w-3 text-muted-foreground/60" />
</Button>
</TooltipTrigger>
<TooltipContent>Add agent</TooltipContent>
</Tooltip>
);
return (
<AppSidebarSection
id={APP_SIDEBAR_SECTION_IDS.agents}
label="Agents"
collapsed={collapsed}
icon={IconRobot}
headerAction={headerAction}
>
{agents.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">No agents yet</p>
) : (
agents.map((agent) => <AgentRow key={agent.id} agent={agent} />)
)}
</AppSidebarSection>
);
}
function AgentRow({ agent }: { agent: AgentProfile }) {
const pathname = usePathname();
const href = `/office/agents/${agent.id}`;
const isActive = pathname === href;
const liveCount = useAppStore((s) => selectActiveSessionsForAgent(s, agent.id));
const errorCount = useAppStore((s) =>
s.office.inboxItems.reduce((acc, item) => {
if (item.type !== "agent_run_failed") return acc;
const payloadAgent =
typeof item.payload?.agent_profile_id === "string" ? item.payload.agent_profile_id : "";
return payloadAgent === agent.id ? acc + 1 : acc;
}, 0),
);
const isAutoPaused = (agent.pauseReason ?? "").startsWith("Auto-paused:");
return (
<Link
href={href}
className={cn(
"flex items-center gap-2.5 px-2.5 py-1.5 text-[13px] font-medium rounded-md cursor-pointer",
isActive ? SIDEBAR_ITEM_ACTIVE : SIDEBAR_ITEM_INACTIVE,
)}
>
<AgentAvatar role={agent.role} name={agent.name} size="sm" />
<span className="flex-1 truncate">{agent.name}</span>
{isAutoPaused ? (
<span
data-testid="sidebar-agent-paused-badge"
title={agent.pauseReason}
className="rounded-full bg-red-500/15 text-red-600 dark:text-red-400 px-1.5 py-0.5 text-[10px] font-medium"
>
paused
</span>
) : null}
{!isAutoPaused && errorCount > 0 ? (
<span className="rounded-full bg-red-500/15 text-red-600 dark:text-red-400 px-1.5 py-0.5 text-[10px] font-medium">
{errorCount} error{errorCount === 1 ? "" : "s"}
</span>
) : null}
{liveCount > 0 && <LiveAgentIndicator count={liveCount} />}
{liveCount === 0 && !isAutoPaused && errorCount === 0 && (
<AgentStatusDot status={agent.status} />
)}
</Link>
);
}
@@ -0,0 +1,65 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
IconBrandGithub,
IconBrandGitlab,
IconHexagon,
IconPlugConnected,
IconTicket,
} from "@tabler/icons-react";
import type { Icon as TablerIcon } from "@tabler/icons-react";
import { useConfiguredIntegrationLinks } from "@/components/integrations/integrations-menu";
import { cn } from "@/lib/utils";
import {
APP_SIDEBAR_SECTION_IDS,
SIDEBAR_ITEM_ACTIVE,
SIDEBAR_ITEM_INACTIVE,
} from "../app-sidebar-constants";
import { AppSidebarSection } from "../app-sidebar-section";
type IntegrationsSectionProps = {
collapsed: boolean;
};
const INTEGRATION_ICONS: Record<string, TablerIcon> = {
github: IconBrandGithub,
gitlab: IconBrandGitlab,
jira: IconTicket,
linear: IconHexagon,
};
export function IntegrationsSection({ collapsed }: IntegrationsSectionProps) {
const pathname = usePathname();
const links = useConfiguredIntegrationLinks();
if (links.length === 0) return null;
return (
<AppSidebarSection
id={APP_SIDEBAR_SECTION_IDS.integrations}
label="Integrations"
collapsed={collapsed}
icon={IconPlugConnected}
>
{links.map(({ id, label, href }) => {
const Icon = INTEGRATION_ICONS[id] ?? IconPlugConnected;
const isActive = pathname === href || pathname.startsWith(`${href}/`);
return (
<Link
key={id}
href={href}
className={cn(
"flex items-center gap-2.5 px-2.5 py-1.5 text-[13px] font-medium rounded-md cursor-pointer",
isActive ? SIDEBAR_ITEM_ACTIVE : SIDEBAR_ITEM_INACTIVE,
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{label}</span>
</Link>
);
})}
</AppSidebarSection>
);
}
@@ -0,0 +1,85 @@
"use client";
import { useRouter } from "next/navigation";
import { IconBoxMultiple, IconPlus } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useAppStore } from "@/components/state-provider";
import { useInOffice } from "@/hooks/use-in-office";
import { cn } from "@/lib/utils";
import { APP_SIDEBAR_SECTION_IDS } from "../app-sidebar-constants";
import { AppSidebarSection } from "../app-sidebar-section";
type ProjectsSectionProps = {
collapsed: boolean;
};
export function ProjectsSection({ collapsed }: ProjectsSectionProps) {
const router = useRouter();
const inOffice = useInOffice();
const projects = useAppStore((s) => s.office.projects);
const activeProjects = projects.filter((p) => p.status !== "archived");
if (!inOffice) return null;
const headerAction = (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 cursor-pointer"
onClick={() => router.push("/office/projects")}
>
<IconPlus className="h-3 w-3 text-muted-foreground/60" />
</Button>
</TooltipTrigger>
<TooltipContent>Add project</TooltipContent>
</Tooltip>
);
return (
<AppSidebarSection
id={APP_SIDEBAR_SECTION_IDS.projects}
label="Projects"
collapsed={collapsed}
icon={IconBoxMultiple}
headerAction={headerAction}
>
{activeProjects.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">No projects yet</p>
) : (
activeProjects.map((project) => {
const taskCount = project.taskCounts?.total ?? 0;
return (
<button
key={project.id}
type="button"
onClick={() => router.push(`/office/projects/${project.id}`)}
className={cn(
"flex items-center gap-2.5 px-2.5 py-1.5 text-[13px] font-medium rounded-md",
"cursor-pointer w-full text-left",
"text-foreground/80 hover:bg-muted/60",
)}
>
<span
className="h-3 w-3 rounded-sm shrink-0"
style={{ backgroundColor: project.color || "#6b7280" }}
/>
<span className="flex-1 truncate">{project.name}</span>
{taskCount > 0 && (
<Badge
variant="secondary"
className="rounded-full px-1.5 py-0 text-[10px] font-normal"
>
{taskCount}
</Badge>
)}
</button>
);
})
)}
</AppSidebarSection>
);
}
@@ -0,0 +1,49 @@
"use client";
import { IconRobot } from "@tabler/icons-react";
import { AgentLogo } from "@/components/agent-logo";
import { useAppStore } from "@/components/state-provider";
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives";
const ROOT_HREF = "/settings/agents";
type AgentsGroupProps = {
pathname: string;
expanded?: boolean;
onToggle?: () => void;
};
export function AgentsGroup({ pathname, expanded, onToggle }: AgentsGroupProps) {
const agents = useAppStore((s) => s.settingsAgents.items);
useAvailableAgents();
return (
<SettingsGroup
label="Agents"
icon={IconRobot}
href={ROOT_HREF}
isActive={pathname === ROOT_HREF}
expanded={expanded}
onToggle={onToggle}
>
{agents.flatMap((agent) =>
agent.profiles.map((profile) => {
const encodedAgent = encodeURIComponent(agent.name);
const profilePath = `${ROOT_HREF}/${encodedAgent}/profiles/${profile.id}`;
const agentLabel = profile.agentDisplayName || agent.name;
return (
<SettingsLeaf
key={profile.id}
href={profilePath}
label={`${agentLabel} • ${profile.name}`}
leadingIcon={<AgentLogo agentName={agent.name} className="h-3.5 w-3.5 shrink-0" />}
isActive={pathname === profilePath}
depth={1}
/>
);
}),
)}
</SettingsGroup>
);
}
@@ -0,0 +1,47 @@
"use client";
import { IconCpu } from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import { getExecutorIcon } from "@/lib/executor-icons";
import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives";
const ROOT_HREF = "/settings/executors";
type ExecutorsGroupProps = {
pathname: string;
expanded?: boolean;
onToggle?: () => void;
};
export function ExecutorsGroup({ pathname, expanded, onToggle }: ExecutorsGroupProps) {
const executors = useAppStore((s) => s.executors.items);
const allProfiles = executors.flatMap((executor) =>
(executor.profiles ?? []).map((profile) => ({ ...profile, executorType: executor.type })),
);
return (
<SettingsGroup
label="Executors"
icon={IconCpu}
href={ROOT_HREF}
isActive={pathname === ROOT_HREF}
expanded={expanded}
onToggle={onToggle}
>
{allProfiles.map((profile) => {
const Icon = getExecutorIcon(profile.executorType);
const profilePath = `${ROOT_HREF}/${profile.id}`;
return (
<SettingsLeaf
key={profile.id}
href={profilePath}
label={profile.name}
icon={Icon}
isActive={pathname === profilePath}
depth={1}
/>
);
})}
</SettingsGroup>
);
}
@@ -0,0 +1,42 @@
"use client";
import { IconBell, IconCode, IconSettings } from "@tabler/icons-react";
import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives";
const GENERAL_HREF = "/settings/general";
const NOTIFICATIONS_HREF = "/settings/general/notifications";
const EDITORS_HREF = "/settings/general/editors";
type GeneralGroupProps = {
pathname: string;
expanded?: boolean;
onToggle?: () => void;
};
export function GeneralGroup({ pathname, expanded, onToggle }: GeneralGroupProps) {
return (
<SettingsGroup
label="General"
icon={IconSettings}
href={GENERAL_HREF}
isActive={pathname === GENERAL_HREF}
expanded={expanded}
onToggle={onToggle}
>
<SettingsLeaf
href={NOTIFICATIONS_HREF}
label="Notifications"
icon={IconBell}
isActive={pathname === NOTIFICATIONS_HREF}
depth={1}
/>
<SettingsLeaf
href={EDITORS_HREF}
label="Editors"
icon={IconCode}
isActive={pathname === EDITORS_HREF}
depth={1}
/>
</SettingsGroup>
);
}
@@ -0,0 +1,52 @@
"use client";
import {
IconBrandGithub,
IconBrandGitlab,
IconBrandSlack,
IconHexagon,
IconPlugConnected,
IconTicket,
} from "@tabler/icons-react";
import type { Icon as TablerIcon } from "@tabler/icons-react";
import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives";
const ROOT_HREF = "/settings/integrations";
const ITEMS: Array<{ href: string; label: string; icon: TablerIcon }> = [
{ href: `${ROOT_HREF}/github`, label: "GitHub", icon: IconBrandGithub },
{ href: `${ROOT_HREF}/gitlab`, label: "GitLab", icon: IconBrandGitlab },
{ href: `${ROOT_HREF}/jira`, label: "Jira", icon: IconTicket },
{ href: `${ROOT_HREF}/linear`, label: "Linear", icon: IconHexagon },
{ href: `${ROOT_HREF}/slack`, label: "Slack", icon: IconBrandSlack },
];
type IntegrationsGroupProps = {
pathname: string;
expanded?: boolean;
onToggle?: () => void;
};
export function IntegrationsGroup({ pathname, expanded, onToggle }: IntegrationsGroupProps) {
return (
<SettingsGroup
label="Integrations"
icon={IconPlugConnected}
href={ROOT_HREF}
isActive={pathname === ROOT_HREF}
expanded={expanded}
onToggle={onToggle}
>
{ITEMS.map(({ href, label, icon }) => (
<SettingsLeaf
key={href}
href={href}
label={label}
icon={icon}
isActive={pathname === href}
depth={1}
/>
))}
</SettingsGroup>
);
}
@@ -0,0 +1,145 @@
"use client";
import Link from "next/link";
import { IconChevronRight } from "@tabler/icons-react";
import type { Icon as TablerIcon } from "@tabler/icons-react";
import { useState, type ReactNode } from "react";
import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible";
import { cn } from "@/lib/utils";
import { SIDEBAR_ITEM_ACTIVE, SIDEBAR_ITEM_INACTIVE } from "../../app-sidebar-constants";
const ACTIVE_CLASS = SIDEBAR_ITEM_ACTIVE;
const INACTIVE_CLASS = SIDEBAR_ITEM_INACTIVE;
type SettingsLeafProps = {
href: string;
label: string;
icon?: TablerIcon;
/** Pre-rendered leading visual (e.g. AgentLogo). Takes precedence over `icon`. */
leadingIcon?: ReactNode;
isActive: boolean;
/** Nesting level — used to add left padding. */
depth?: number;
};
const LEAF_DEPTH_PADDING = ["px-2.5", "pl-7 pr-2.5", "pl-10 pr-2.5"] as const;
const GROUP_DEPTH_PADDING = ["pl-2.5 pr-1", "pl-7 pr-1", "pl-10 pr-1"] as const;
function clampDepth(depth: number, max: number): number {
if (depth < 0) return 0;
if (depth > max) return max;
return depth;
}
export function SettingsLeaf({
href,
label,
icon: Icon,
leadingIcon,
isActive,
depth = 0,
}: SettingsLeafProps) {
return (
<Link
href={href}
className={cn(
"flex items-center gap-2 py-1.5 text-[13px] font-medium rounded-md cursor-pointer",
LEAF_DEPTH_PADDING[clampDepth(depth, LEAF_DEPTH_PADDING.length - 1)],
isActive ? ACTIVE_CLASS : INACTIVE_CLASS,
)}
>
{leadingIcon ?? (Icon && <Icon className="h-3.5 w-3.5 shrink-0" />)}
<span className="flex-1 truncate">{label}</span>
</Link>
);
}
type SettingsGroupProps = {
label: string;
icon?: TablerIcon;
/** When the group itself has a destination, the label area is also a link. */
href?: string;
isActive?: boolean;
defaultExpanded?: boolean;
depth?: number;
children: ReactNode;
/**
* Controlled expansion. When `expanded` is provided the group becomes a
* controlled accordion member (open/close driven by the parent SettingsTree)
* and `onToggle` fires on header/chevron clicks. Omit both for the legacy
* self-managed behavior used by nested (per-workspace) groups.
*/
expanded?: boolean;
onToggle?: () => void;
};
export function SettingsGroup({
label,
icon: Icon,
href,
isActive,
defaultExpanded = false,
depth = 0,
children,
expanded: controlledExpanded,
onToggle,
}: SettingsGroupProps) {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const isControlled = controlledExpanded !== undefined;
const expanded = isControlled ? controlledExpanded : internalExpanded;
const paddingClass = GROUP_DEPTH_PADDING[clampDepth(depth, GROUP_DEPTH_PADDING.length - 1)];
const toggle = () => {
if (isControlled) onToggle?.();
else setInternalExpanded((v) => !v);
};
const labelInner = (
<>
{Icon && <Icon className="h-3.5 w-3.5 shrink-0" />}
<span className="flex-1 truncate">{label}</span>
</>
);
return (
<Collapsible open={expanded}>
<div
className={cn(
"flex items-center gap-1 rounded-md",
isActive ? ACTIVE_CLASS : INACTIVE_CLASS,
paddingClass,
)}
>
{href ? (
<Link
href={href}
className="flex flex-1 min-w-0 items-center gap-2 py-1.5 text-[13px] font-medium cursor-pointer"
>
{labelInner}
</Link>
) : (
<button
type="button"
onClick={toggle}
className="flex flex-1 min-w-0 items-center gap-2 py-1.5 text-[13px] font-medium cursor-pointer text-left"
>
{labelInner}
</button>
)}
<button
type="button"
onClick={toggle}
aria-label={expanded ? `Collapse ${label}` : `Expand ${label}`}
aria-expanded={expanded}
className="shrink-0 flex h-5 w-5 items-center justify-center text-muted-foreground/60 hover:text-foreground/80 cursor-pointer transition-colors"
>
<IconChevronRight
className={cn("h-3 w-3 transition-transform", expanded && "rotate-90")}
/>
</button>
</div>
<CollapsibleContent className="sidebar-section-content">
<div className="flex flex-col gap-0.5">{children}</div>
</CollapsibleContent>
</Collapsible>
);
}
@@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import { settingsGroupIdForPath } from "./settings-tree";
describe("settingsGroupIdForPath", () => {
it("maps a group root to its id", () => {
expect(settingsGroupIdForPath("/settings/executors")).toBe("executors");
expect(settingsGroupIdForPath("/settings/agents")).toBe("agents");
expect(settingsGroupIdForPath("/settings/general")).toBe("general");
expect(settingsGroupIdForPath("/settings/integrations")).toBe("integrations");
expect(settingsGroupIdForPath("/settings/system")).toBe("system");
expect(settingsGroupIdForPath("/settings/workspace")).toBe("workspaces");
});
it("maps a nested path to its owning group", () => {
expect(settingsGroupIdForPath("/settings/executors/profile-123")).toBe("executors");
expect(settingsGroupIdForPath("/settings/workspace/ws-1/repositories")).toBe("workspaces");
expect(settingsGroupIdForPath("/settings/system/logs")).toBe("system");
// Editors/Secrets live under /settings/general so they belong to General.
expect(settingsGroupIdForPath("/settings/general/editors")).toBe("general");
});
it("returns null for standalone leaves with no owning group", () => {
expect(settingsGroupIdForPath("/settings/automations")).toBeNull();
expect(settingsGroupIdForPath("/settings/prompts")).toBeNull();
expect(settingsGroupIdForPath("/settings/utility-agents")).toBeNull();
expect(settingsGroupIdForPath("/settings/external-mcp")).toBeNull();
expect(settingsGroupIdForPath("/settings")).toBeNull();
});
});
@@ -0,0 +1,105 @@
"use client";
import { useEffect, useState } from "react";
import {
IconBolt,
IconKey,
IconMessageCircle,
IconPlugConnected,
IconWand,
} from "@tabler/icons-react";
import { AgentsGroup } from "./agents-group";
import { ExecutorsGroup } from "./executors-group";
import { GeneralGroup } from "./general-group";
import { IntegrationsGroup } from "./integrations-group";
import { SettingsLeaf } from "./settings-nav-primitives";
import { SystemGroup } from "./system-group";
import { WorkspacesGroup } from "./workspaces-group";
const AUTOMATIONS_HREF = "/settings/automations";
const PROMPTS_HREF = "/settings/prompts";
const UTILITY_HREF = "/settings/utility-agents";
const SECRETS_HREF = "/settings/general/secrets";
const EXT_MCP_HREF = "/settings/external-mcp";
// Single-open accordion: each top-level group owns a route prefix. The group
// whose prefix matches the current path is the open one. Prefixes are disjoint,
// so first match wins and ordering is irrelevant.
const GROUP_ROUTES = [
{ id: "general", prefix: "/settings/general" },
{ id: "workspaces", prefix: "/settings/workspace" },
{ id: "integrations", prefix: "/settings/integrations" },
{ id: "agents", prefix: "/settings/agents" },
{ id: "executors", prefix: "/settings/executors" },
{ id: "system", prefix: "/settings/system" },
] as const;
/** The settings accordion group that owns `pathname`, or null for a standalone leaf. */
export function settingsGroupIdForPath(pathname: string): string | null {
return GROUP_ROUTES.find((g) => pathname.startsWith(g.prefix))?.id ?? null;
}
/**
* The settings nav tree. Top-level groups behave as a single-open accordion:
* opening one closes the others and reveals its subsections. Navigating to a
* standalone leaf (Prompts, Automations, …) closes every group.
*
* Rendered both inside the collapsible "Settings" sidebar section and, when the
* footer gear is active, as the full-height sidebar takeover.
*/
export function SettingsTree({ pathname }: { pathname: string }) {
const [openGroup, setOpenGroup] = useState<string | null>(() => settingsGroupIdForPath(pathname));
// Re-sync when navigation lands on a different section so the open group
// always reflects the current page (a leaf with no owning group → all closed).
useEffect(() => {
setOpenGroup(settingsGroupIdForPath(pathname));
}, [pathname]);
const groupProps = (id: string) => ({
expanded: openGroup === id,
onToggle: () => setOpenGroup((prev) => (prev === id ? null : id)),
});
return (
<>
<GeneralGroup pathname={pathname} {...groupProps("general")} />
<WorkspacesGroup pathname={pathname} {...groupProps("workspaces")} />
<IntegrationsGroup pathname={pathname} {...groupProps("integrations")} />
<SettingsLeaf
href={AUTOMATIONS_HREF}
label="Automations"
icon={IconBolt}
isActive={pathname.startsWith(AUTOMATIONS_HREF)}
/>
<AgentsGroup pathname={pathname} {...groupProps("agents")} />
<SettingsLeaf
href={PROMPTS_HREF}
label="Prompts"
icon={IconMessageCircle}
isActive={pathname === PROMPTS_HREF}
/>
<SettingsLeaf
href={UTILITY_HREF}
label="Utility Agents"
icon={IconWand}
isActive={pathname === UTILITY_HREF}
/>
<ExecutorsGroup pathname={pathname} {...groupProps("executors")} />
{/* Editors lives under General (see GeneralGroup) — no duplicate top-level leaf. */}
<SettingsLeaf
href={SECRETS_HREF}
label="Secrets"
icon={IconKey}
isActive={pathname === SECRETS_HREF}
/>
<SettingsLeaf
href={EXT_MCP_HREF}
label="External MCP"
icon={IconPlugConnected}
isActive={pathname === EXT_MCP_HREF}
/>
<SystemGroup pathname={pathname} {...groupProps("system")} />
</>
);
}
@@ -0,0 +1,57 @@
"use client";
import {
IconActivity,
IconArchive,
IconDatabase,
IconFileText,
IconInfoCircle,
IconRefresh,
IconScale,
IconServerCog,
} from "@tabler/icons-react";
import type { Icon as TablerIcon } from "@tabler/icons-react";
import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives";
const ROOT_HREF = "/settings/system";
const DEFAULT_HREF = `${ROOT_HREF}/status`;
const ITEMS: Array<{ href: string; label: string; icon: TablerIcon }> = [
{ href: `${ROOT_HREF}/status`, label: "Status", icon: IconActivity },
{ href: `${ROOT_HREF}/database`, label: "Database", icon: IconDatabase },
{ href: `${ROOT_HREF}/backups`, label: "Backups", icon: IconArchive },
{ href: `${ROOT_HREF}/logs`, label: "Logs", icon: IconFileText },
{ href: `${ROOT_HREF}/updates`, label: "Updates", icon: IconRefresh },
{ href: `${ROOT_HREF}/about`, label: "About", icon: IconInfoCircle },
{ href: `${ROOT_HREF}/licenses`, label: "Licenses", icon: IconScale },
];
type SystemGroupProps = {
pathname: string;
expanded?: boolean;
onToggle?: () => void;
};
export function SystemGroup({ pathname, expanded, onToggle }: SystemGroupProps) {
return (
<SettingsGroup
label="System"
icon={IconServerCog}
href={DEFAULT_HREF}
isActive={pathname.startsWith(ROOT_HREF)}
expanded={expanded}
onToggle={onToggle}
>
{ITEMS.map(({ href, label, icon }) => (
<SettingsLeaf
key={href}
href={href}
label={label}
icon={icon}
isActive={pathname === href}
depth={1}
/>
))}
</SettingsGroup>
);
}
@@ -0,0 +1,59 @@
"use client";
import { IconArrowsShuffle, IconFolder, IconGitBranch } from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives";
const ROOT_HREF = "/settings/workspace";
type WorkspacesGroupProps = {
pathname: string;
expanded?: boolean;
onToggle?: () => void;
};
export function WorkspacesGroup({ pathname, expanded, onToggle }: WorkspacesGroupProps) {
const workspaces = useAppStore((s) => s.workspaces.items);
return (
<SettingsGroup
label="Workspaces"
icon={IconFolder}
href={ROOT_HREF}
isActive={pathname === ROOT_HREF}
expanded={expanded}
onToggle={onToggle}
>
{workspaces.map((workspace) => {
const workspacePath = `${ROOT_HREF}/${workspace.id}`;
const repositoriesPath = `${workspacePath}/repositories`;
const workflowsPath = `${workspacePath}/workflows`;
return (
<SettingsGroup
key={workspace.id}
label={workspace.name}
href={workspacePath}
isActive={pathname === workspacePath}
defaultExpanded={pathname.startsWith(workspacePath)}
depth={1}
>
<SettingsLeaf
href={repositoriesPath}
label="Repositories"
icon={IconGitBranch}
isActive={pathname === repositoriesPath}
depth={2}
/>
<SettingsLeaf
href={workflowsPath}
label="Workflows"
icon={IconArrowsShuffle}
isActive={pathname === workflowsPath}
depth={2}
/>
</SettingsGroup>
);
})}
</SettingsGroup>
);
}
@@ -0,0 +1,32 @@
"use client";
import { IconCircleDot } from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import { TaskSessionSidebar } from "@/components/task/task-session-sidebar";
import { APP_SIDEBAR_SECTION_IDS } from "../app-sidebar-constants";
import { AppSidebarSection } from "../app-sidebar-section";
import { TasksViewPicker } from "./tasks-view-picker";
type TasksSectionProps = {
collapsed: boolean;
};
export function TasksSection({ collapsed }: TasksSectionProps) {
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const workflowId = useAppStore((s) => s.kanban.workflowId);
return (
<AppSidebarSection
id={APP_SIDEBAR_SECTION_IDS.tasks}
label="Tasks"
collapsed={collapsed}
icon={IconCircleDot}
headerAction={<TasksViewPicker />}
grow
>
<div className="flex-1 min-h-0 [&_[data-testid=task-sidebar]]:bg-transparent [&_[data-testid=task-sidebar-scroll]]:bg-transparent">
<TaskSessionSidebar workspaceId={workspaceId} workflowId={workflowId} hideFilterBar />
</div>
</AppSidebarSection>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useMemo, useState } from "react";
import { IconChevronDown, IconAdjustments, IconCheck } from "@tabler/icons-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { useAppStore } from "@/components/state-provider";
import { SidebarFilterPopover } from "@/components/task/sidebar-filter/sidebar-filter-popover";
import { cn } from "@/lib/utils";
const TRIGGER_BUTTON_CLASS = cn(
"flex h-5 items-center justify-center rounded-sm px-1.5 cursor-pointer",
"text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors",
);
export function TasksViewPicker() {
const views = useAppStore((s) => s.sidebarViews.views);
const activeViewId = useAppStore((s) => s.sidebarViews.activeViewId);
const draft = useAppStore((s) => s.sidebarViews.draft);
const setActiveView = useAppStore((s) => s.setSidebarActiveView);
const [filterOpen, setFilterOpen] = useState(false);
const activeView = useMemo(
() => views.find((v) => v.id === activeViewId) ?? views[0],
[views, activeViewId],
);
const hasDraft = !!draft && draft.baseViewId === activeViewId;
const activeLabel = activeView?.name ?? "All";
return (
<div className="flex items-center gap-0.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-testid="tasks-view-picker"
className={cn(TRIGGER_BUTTON_CLASS, "max-w-[120px] gap-0.5")}
aria-label={`View: ${activeLabel}`}
>
<span className="truncate">{activeLabel}</span>
<IconChevronDown className="h-3 w-3 shrink-0 opacity-70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{views.map((view) => {
const isActive = view.id === activeViewId;
return (
<DropdownMenuItem
key={view.id}
onSelect={() => setActiveView(view.id)}
data-testid="sidebar-view-chip"
data-view-id={view.id}
data-active={isActive}
aria-pressed={isActive}
className="cursor-pointer gap-2 text-xs"
>
<IconCheck className={cn("h-3.5 w-3.5", isActive ? "opacity-100" : "opacity-0")} />
<span className="truncate">{view.name}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<SidebarFilterPopover
open={filterOpen}
onOpenChange={setFilterOpen}
trigger={
<button
type="button"
data-testid="sidebar-filter-gear"
aria-label="Filters and sort"
className={cn(TRIGGER_BUTTON_CLASS, "relative h-5 w-5 px-0")}
>
<IconAdjustments className="h-3.5 w-3.5" />
{hasDraft && (
<span
data-testid="sidebar-filter-gear-indicator"
aria-label="Unsaved filter changes"
className="absolute right-0.5 top-0.5 h-1 w-1 rounded-full bg-amber-500"
/>
)}
</button>
}
/>
</div>
);
}
@@ -0,0 +1,44 @@
"use client";
import { IntegrationScopeBar } from "@/components/integrations/presets-scope-bar-base";
import { PR_PRESETS, ISSUE_PRESETS, type PresetOption } from "./search-bar";
import type { SavedPreset } from "./use-saved-presets";
import type { SidebarSelection } from "./presets-sidebar";
type PresetsScopeBarProps = {
className?: string;
selected: SidebarSelection;
onSelect: (s: SidebarSelection) => void;
savedPresets: SavedPreset[];
onDeleteSaved: (id: string) => void;
canSaveCurrent: boolean;
onSaveCurrent: () => void;
prPresets?: PresetOption[];
issuePresets?: PresetOption[];
};
const KINDS = [
{ value: "pr", label: "Pull requests" },
{ value: "issue", label: "Issues" },
] as const;
/**
* Horizontal scope bar for the /github dashboard (desktop). Thin wrapper over
* the shared {@link IntegrationScopeBar}; mobile keeps the vertical
* PresetsSidebar in a sheet.
*/
export function PresetsScopeBar({
prPresets = PR_PRESETS,
issuePresets = ISSUE_PRESETS,
...props
}: PresetsScopeBarProps) {
return (
<IntegrationScopeBar
{...props}
testId="github-presets-scope-bar"
savedMenuTestId="github-saved-queries-menu"
kinds={KINDS}
presetsByKind={(kind) => (kind === "pr" ? prPresets : issuePresets)}
/>
);
}
@@ -0,0 +1,44 @@
"use client";
import { IntegrationScopeBar } from "@/components/integrations/presets-scope-bar-base";
import { MR_PRESETS, ISSUE_PRESETS, type PresetOption } from "./presets";
import type { SavedPreset } from "./use-saved-presets";
import type { SidebarSelection } from "./presets-sidebar";
type PresetsScopeBarProps = {
className?: string;
selected: SidebarSelection;
onSelect: (s: SidebarSelection) => void;
savedPresets: SavedPreset[];
onDeleteSaved: (id: string) => void;
canSaveCurrent: boolean;
onSaveCurrent: () => void;
mrPresets?: PresetOption[];
issuePresets?: PresetOption[];
};
const KINDS = [
{ value: "mr", label: "Merge requests" },
{ value: "issue", label: "Issues" },
] as const;
/**
* Horizontal scope bar for the /gitlab dashboard (desktop). Thin wrapper over
* the shared {@link IntegrationScopeBar}; mobile keeps the vertical
* PresetsSidebar in a sheet.
*/
export function PresetsScopeBar({
mrPresets = MR_PRESETS,
issuePresets = ISSUE_PRESETS,
...props
}: PresetsScopeBarProps) {
return (
<IntegrationScopeBar
{...props}
testId="gitlab-presets-scope-bar"
savedMenuTestId="gitlab-saved-queries-menu"
kinds={KINDS}
presetsByKind={(kind) => (kind === "mr" ? mrPresets : issuePresets)}
/>
);
}
@@ -83,7 +83,7 @@ export function getGitHubIntegrationStatus(status: GitHubStatus | null, loading:
return { ready: false, label: getStatusLabel(loading) };
}
function useConfiguredIntegrationLinks(): IntegrationLink[] {
export function useConfiguredIntegrationLinks(): IntegrationLink[] {
const { status, loading } = useGitHubStatus();
const gitlabAvailable = useGitLabAvailable();
const jiraAvailable = useJiraAvailable();
@@ -0,0 +1,248 @@
"use client";
import { IconBookmark, IconChevronDown, IconDeviceFloppy, IconX } from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { cn } from "@/lib/utils";
/**
* Shared, domain-agnostic scope bar for the integration dashboards (/github,
* /gitlab). It's the desktop replacement for the old `w-60` presets rail, which
* read as a redundant second sidebar next to the global AppSidebar. Bounded,
* common controls (kind + presets) stay visible as pills; the unbounded
* saved-queries list collapses into a dropdown so the bar never grows tall.
*
* Each integration wraps this with its own kinds/presets/types — see
* `my-github/presets-scope-bar.tsx` and `my-gitlab/presets-scope-bar.tsx`.
*/
export type ScopePreset = {
value: string;
label: string;
icon: Icon;
group: "inbox" | "created";
};
export type ScopeSelection<K extends string> = {
kind: K;
source: "preset" | "saved";
id: string;
};
export type ScopeSavedPreset<K extends string> = {
id: string;
kind: K;
label: string;
};
const PILL_BASE =
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs whitespace-nowrap cursor-pointer transition-colors shrink-0";
const PILL_ACTIVE = "bg-muted font-medium text-foreground";
const PILL_IDLE = "text-muted-foreground hover:bg-muted/50 hover:text-foreground";
function Divider() {
return <div className="mx-0.5 h-5 w-px shrink-0 bg-border" />;
}
function KindSegment<K extends string>({
kinds,
active,
onChange,
}: {
kinds: ReadonlyArray<{ value: K; label: string }>;
active: K;
onChange: (k: K) => void;
}) {
return (
<div className="flex shrink-0 items-center rounded-md border p-0.5 text-xs">
{kinds.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => onChange(value)}
className={cn(
"rounded px-2 py-0.5 cursor-pointer transition-colors",
active === value ? PILL_ACTIVE : "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
))}
</div>
);
}
function PresetPill({
label,
Icon,
active,
onClick,
}: {
label: string;
Icon: Icon;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(PILL_BASE, active ? PILL_ACTIVE : PILL_IDLE)}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span>{label}</span>
</button>
);
}
function SavedMenu<K extends string>({
testId,
selected,
saved,
onSelect,
onDeleteSaved,
canSaveCurrent,
onSaveCurrent,
}: {
testId: string;
selected: ScopeSelection<K>;
saved: ScopeSavedPreset<K>[];
onSelect: (s: ScopeSelection<K>) => void;
onDeleteSaved: (id: string) => void;
canSaveCurrent: boolean;
onSaveCurrent: () => void;
}) {
const activeSaved = selected.source === "saved";
const activeLabel = activeSaved ? saved.find((s) => s.id === selected.id)?.label : null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-testid={testId}
className={cn(PILL_BASE, activeSaved ? PILL_ACTIVE : PILL_IDLE)}
>
<IconBookmark className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-[140px] truncate">{activeLabel ?? "Saved"}</span>
<IconChevronDown className="h-3 w-3 shrink-0 opacity-60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{saved.length === 0 ? (
<DropdownMenuItem disabled>No saved queries yet</DropdownMenuItem>
) : (
saved.map((s) => (
<DropdownMenuItem
key={s.id}
onSelect={() => onSelect({ kind: s.kind, source: "saved", id: s.id })}
className="group/saved cursor-pointer gap-2"
>
<IconBookmark className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{s.label}</span>
<button
type="button"
// Stop the pointerdown so Radix doesn't treat the delete click
// as a select of the (about-to-be-deleted) saved query.
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onDeleteSaved(s.id);
}}
className="pointer-events-none cursor-pointer text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/saved:pointer-events-auto group-hover/saved:opacity-100"
title="Delete saved query"
>
<IconX className="h-3.5 w-3.5" />
</button>
</DropdownMenuItem>
))
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={!canSaveCurrent}
onSelect={onSaveCurrent}
className={cn("gap-2", canSaveCurrent && "cursor-pointer")}
>
<IconDeviceFloppy className="h-3.5 w-3.5 shrink-0" />
<span>Save current query</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export type IntegrationScopeBarProps<K extends string> = {
className?: string;
testId: string;
savedMenuTestId: string;
kinds: ReadonlyArray<{ value: K; label: string }>;
selected: ScopeSelection<K>;
onSelect: (s: ScopeSelection<K>) => void;
presetsByKind: (kind: K) => ScopePreset[];
savedPresets: ScopeSavedPreset<K>[];
onDeleteSaved: (id: string) => void;
canSaveCurrent: boolean;
onSaveCurrent: () => void;
};
export function IntegrationScopeBar<K extends string>({
className,
testId,
savedMenuTestId,
kinds,
selected,
onSelect,
presetsByKind,
savedPresets,
onDeleteSaved,
canSaveCurrent,
onSaveCurrent,
}: IntegrationScopeBarProps<K>) {
const presets = presetsByKind(selected.kind);
const saved = savedPresets.filter((p) => p.kind === selected.kind);
const inbox = presets.filter((p) => p.group === "inbox");
const created = presets.filter((p) => p.group === "created");
const onKindChange = (kind: K) => {
onSelect({ kind, source: "preset", id: presetsByKind(kind)[0]?.value ?? "" });
};
const renderPill = (p: ScopePreset) => (
<PresetPill
key={`${selected.kind}-${p.value}`}
label={p.label}
Icon={p.icon}
active={selected.source === "preset" && selected.id === p.value}
onClick={() => onSelect({ kind: selected.kind, source: "preset", id: p.value })}
/>
);
return (
<div
className={cn("flex items-center gap-1.5 overflow-x-auto px-4 py-2 sm:px-6", className)}
data-testid={testId}
>
<KindSegment kinds={kinds} active={selected.kind} onChange={onKindChange} />
<Divider />
{inbox.map(renderPill)}
{inbox.length > 0 && created.length > 0 && <Divider />}
{created.map(renderPill)}
<div className="ml-auto shrink-0 pl-2">
<SavedMenu
testId={savedMenuTestId}
selected={selected}
saved={saved}
onSelect={onSelect}
onDeleteSaved={onDeleteSaved}
canSaveCurrent={canSaveCurrent}
onSaveCurrent={onSaveCurrent}
/>
</div>
</div>
);
}
-1
View File
@@ -348,7 +348,6 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB
<div className="h-dvh w-full flex flex-col" data-testid="kanban-board">
<HomepageCommands onCreateTask={s.handleCreate} />
<KanbanHeader
onCreateTask={s.handleCreate}
workspaceId={s.workspaceState.activeId ?? undefined}
searchQuery={s.searchQuery}
onSearchChange={s.setSearchQuery}
@@ -13,9 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { IconAdjustmentsHorizontal } from "@tabler/icons-react";
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
import type { Repository } from "@/lib/types/http";
import type { WorkflowsState, WorkspaceState } from "@/lib/state/slices";
type WorkspaceItem = WorkspaceState["items"][number];
import type { WorkflowsState } from "@/lib/state/slices";
import type { ComponentProps } from "react";
type KanbanDisplayDropdownProps = {
@@ -31,41 +29,6 @@ function getRepositoryPlaceholder(
return "Select repository";
}
function WorkspaceSection({
activeWorkspaceId,
workspaces,
onWorkspaceChange,
}: {
activeWorkspaceId: string | null;
workspaces: WorkspaceItem[];
onWorkspaceChange: (id: string | null) => void;
}) {
return (
<div className="space-y-1.5">
<DropdownMenuLabel className="px-0 text-foreground">Workspace</DropdownMenuLabel>
<Select
value={activeWorkspaceId ?? ""}
onValueChange={(value) => onWorkspaceChange(value || null)}
>
<SelectTrigger className="w-full border-border" data-testid="workspace-select-trigger">
<SelectValue placeholder="Select workspace" />
</SelectTrigger>
<SelectContent>
{workspaces.map((workspace) => (
<SelectItem
key={workspace.id}
value={workspace.id}
data-testid={`workspace-select-item-${workspace.id}`}
>
{workspace.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function WorkflowSection({
activeWorkflowId,
workflows,
@@ -137,16 +100,13 @@ function RepositorySection({
export function KanbanDisplayDropdown({ triggerSize = "icon" }: KanbanDisplayDropdownProps) {
const {
workspaces,
workflows,
activeWorkspaceId,
activeWorkflowId,
repositories,
repositoriesLoading,
allRepositoriesSelected,
selectedRepositoryId,
enablePreviewOnClick,
onWorkspaceChange,
onWorkflowChange,
onRepositoryChange,
onTogglePreviewOnClick,
@@ -168,12 +128,6 @@ export function KanbanDisplayDropdown({ triggerSize = "icon" }: KanbanDisplayDro
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px] p-3">
<div className="space-y-3">
<WorkspaceSection
activeWorkspaceId={activeWorkspaceId}
workspaces={workspaces}
onWorkspaceChange={onWorkspaceChange}
/>
<DropdownMenuSeparator />
<WorkflowSection
activeWorkflowId={activeWorkflowId}
workflows={workflows}
@@ -11,9 +11,9 @@ type KanbanHeaderMobileProps = {
currentPage?: "kanban" | "tasks";
title: string;
workspaceLabel: string;
searchQuery?: string;
onSearchChange?: (query: string) => void;
showReleaseNotesButton: boolean;
onOpenReleaseNotes: () => void;
isSearchLoading?: boolean;
showHealthIndicator: boolean;
onOpenHealthDialog: () => void;
};
@@ -23,9 +23,9 @@ export function KanbanHeaderMobile({
currentPage = "kanban",
title,
workspaceLabel,
searchQuery = "",
onSearchChange,
showReleaseNotesButton,
onOpenReleaseNotes,
isSearchLoading = false,
showHealthIndicator,
onOpenHealthDialog,
}: KanbanHeaderMobileProps) {
@@ -46,6 +46,7 @@ export function KanbanHeaderMobile({
<PageTopbar
title={title}
subtitle={workspaceLabel}
backLabel={title === "Home" ? "" : "Kandev"}
className="h-10 px-3 py-1"
variant={title === "Home" ? "root" : "breadcrumb"}
actions={
@@ -80,8 +81,9 @@ export function KanbanHeaderMobile({
onOpenChange={setMenuOpen}
workspaceId={workspaceId}
currentPage={currentPage}
showReleaseNotesButton={showReleaseNotesButton}
onOpenReleaseNotes={onOpenReleaseNotes}
searchQuery={searchQuery}
onSearchChange={onSearchChange}
isSearchLoading={isSearchLoading}
showHealthIndicator={showHealthIndicator}
onOpenHealthDialog={onOpenHealthDialog}
/>
+13 -214
View File
@@ -1,35 +1,19 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@kandev/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import {
IconPlus,
IconSettings,
IconList,
IconLayoutKanban,
IconMenu2,
IconChartBar,
IconTimeline,
IconStethoscope,
IconBuildings,
} from "@tabler/icons-react";
import { ImproveKandevDialog } from "@/components/improve-kandev-dialog";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { IntegrationsTopbarLinks } from "@/components/integrations/integrations-menu";
import { IconList, IconLayoutKanban, IconMenu2, IconTimeline } from "@tabler/icons-react";
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 { linkToTask, linkToTasks } from "@/lib/links";
import { 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";
@@ -38,7 +22,6 @@ import { useSystemHealthIndicator } from "@/hooks/use-system-health-indicator";
import type { ComponentProps, RefObject } from "react";
type KanbanHeaderProps = {
onCreateTask: () => void;
workspaceId?: string;
currentPage?: "kanban" | "tasks";
searchQuery?: string;
@@ -73,111 +56,9 @@ function getHeaderTitle(currentPage: string): string {
return currentPage === "tasks" ? "Tasks" : "Home";
}
function SettingsTopbarButton({ size = "icon" }: { size?: ComponentProps<typeof Button>["size"] }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button asChild variant="outline" size={size} className="cursor-pointer">
<Link href="/settings" aria-label="Settings">
<IconSettings className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
);
}
function ImproveKandevTopbarButton({
workspaceId,
buttonSize = "icon-lg",
}: {
workspaceId: string | undefined;
buttonSize?: ComponentProps<typeof Button>["size"];
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size={buttonSize}
onClick={() => setOpen(true)}
className="cursor-pointer"
data-testid="improve-kandev-button"
>
<IconStethoscope className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Improve Kandev</TooltipContent>
</Tooltip>
<ImproveKandevDialog
open={open}
onOpenChange={setOpen}
workspaceId={workspaceId ?? null}
onSuccess={(task) => router.push(linkToTask(task.id))}
/>
</>
);
}
function StatsTopbarButton() {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button asChild variant="outline" size="icon-lg" className="cursor-pointer">
<Link href="/stats" aria-label="Stats">
<IconChartBar className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Stats</TooltipContent>
</Tooltip>
);
}
function OfficeTopbarButton() {
// Hidden in production where features.office=false. The hook reads SSR-
// hydrated state, so the button never appears for a single frame on
// first paint.
const officeEnabled = useFeature("office");
if (!officeEnabled) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button asChild variant="outline" size="icon-lg" className="cursor-pointer">
<Link href="/office" aria-label="Office">
<IconBuildings className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Office</TooltipContent>
</Tooltip>
);
}
function HomeLeftActions({ workspaceId }: { workspaceId?: string }) {
return (
<>
<IntegrationsTopbarLinks />
<StatsTopbarButton />
<OfficeTopbarButton />
<ImproveKandevTopbarButton workspaceId={workspaceId} />
</>
);
}
function WorkspaceLeftActions({ workspaceId }: { workspaceId?: string }) {
return (
<>
<IntegrationsTopbarLinks />
<OfficeTopbarButton />
<ImproveKandevTopbarButton workspaceId={workspaceId} />
</>
);
}
// Integrations / Stats / Office / Improve Kandev / Settings / Release notes
// have all moved to the unified AppSidebar (Fix 6). The kanban top bar now
// focuses on task-creation, view-toggle, kanban display, and search.
function ViewToggleGroup({
toggleValue,
@@ -245,8 +126,6 @@ function useIsHeaderNarrow(ref: RefObject<HTMLElement | null>): boolean {
}
function TabletHeader({
onCreateTask,
workspaceId,
title,
workspaceLabel,
searchQuery,
@@ -258,8 +137,6 @@ function TabletHeader({
showHealthIndicator,
onOpenHealthDialog,
}: {
onCreateTask: () => void;
workspaceId?: string;
title: string;
workspaceLabel: string;
searchQuery: string;
@@ -277,15 +154,9 @@ function TabletHeader({
<PageTopbar
title={title}
subtitle={workspaceLabel}
backLabel={isHome ? "" : "Kandev"}
className={WORKBENCH_TOPBAR_CLASSNAME}
variant={isHome ? "root" : "breadcrumb"}
leftActions={
isHome ? (
<HomeLeftActions workspaceId={workspaceId} />
) : (
<WorkspaceLeftActions workspaceId={workspaceId} />
)
}
actionsClassName="gap-2"
actions={
<>
@@ -298,16 +169,6 @@ function TabletHeader({
className="hidden md:flex w-48 lg:w-56 [&_input]:h-8"
/>
)}
<Button
onClick={onCreateTask}
size="lg"
className="cursor-pointer"
data-testid="create-task-button"
>
<IconPlus className="h-4 w-4" />
<span className="hidden sm:inline ml-1">Add task</span>
</Button>
<QuickChatButton workspaceId={workspaceId} size="lg" />
<TooltipProvider>
<ViewToggleGroup toggleValue={toggleValue} onValueChange={handleViewChange} size="lg" />
</TooltipProvider>
@@ -332,39 +193,7 @@ function TabletHeader({
);
}
function CreateTaskTopbarButton({
onCreateTask,
compact,
}: {
onCreateTask: () => void;
compact: boolean;
}) {
const button = (
<Button
onClick={onCreateTask}
size={compact ? "icon-lg" : "lg"}
className="cursor-pointer"
data-testid="create-task-button"
aria-label={compact ? "Add task" : undefined}
>
<IconPlus className="h-4 w-4" />
{!compact && "Add task"}
</Button>
);
if (!compact) return button;
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>Add task</TooltipContent>
</Tooltip>
);
}
function DesktopHeader({
onCreateTask,
workspaceId,
title,
workspaceLabel,
searchQuery,
@@ -372,14 +201,9 @@ function DesktopHeader({
isSearchLoading,
toggleValue,
handleViewChange,
showReleaseNotesButton,
releaseNotesHasUnseen,
onOpenReleaseNotes,
showHealthIndicator,
onOpenHealthDialog,
}: {
onCreateTask: () => void;
workspaceId?: string;
title: string;
workspaceLabel: string;
searchQuery: string;
@@ -387,9 +211,6 @@ function DesktopHeader({
isSearchLoading: boolean;
toggleValue: string;
handleViewChange: (value: string) => void;
showReleaseNotesButton: boolean;
releaseNotesHasUnseen: boolean;
onOpenReleaseNotes: () => void;
showHealthIndicator: boolean;
onOpenHealthDialog: () => void;
}) {
@@ -410,26 +231,19 @@ function DesktopHeader({
<div data-testid="kanban-header-search">{searchInput}</div>
) : null;
const actionsSearch = !isHome || isNarrow ? searchInput : null;
const leftActions = isHome ? (
<HomeLeftActions workspaceId={workspaceId} />
) : (
<WorkspaceLeftActions workspaceId={workspaceId} />
);
return (
<PageTopbar
ref={headerRef}
title={title}
subtitle={workspaceLabel}
backLabel={isHome ? "" : "Kandev"}
center={centerSearch}
className={WORKBENCH_TOPBAR_CLASSNAME}
variant={isHome ? "root" : "breadcrumb"}
leftActions={leftActions}
actions={
<>
{actionsSearch}
<CreateTaskTopbarButton onCreateTask={onCreateTask} compact={isNarrow} />
<QuickChatButton workspaceId={workspaceId} size="lg" compact={isNarrow} />
<TooltipProvider>
<ViewToggleGroup toggleValue={toggleValue} onValueChange={handleViewChange} size="lg" />
</TooltipProvider>
@@ -439,14 +253,6 @@ function DesktopHeader({
onClick={onOpenHealthDialog}
size="icon-lg"
/>
{showReleaseNotesButton && (
<ReleaseNotesButton
hasUnseen={releaseNotesHasUnseen}
onClick={onOpenReleaseNotes}
size="icon-lg"
/>
)}
<SettingsTopbarButton size="icon-lg" />
</>
}
/>
@@ -473,7 +279,6 @@ function useHeaderViewChange(
}
export function KanbanHeader({
onCreateTask,
workspaceId,
currentPage = "kanban",
searchQuery = "",
@@ -492,15 +297,11 @@ export function KanbanHeader({
const title = getHeaderTitle(currentPage);
const workspaceLabel = getWorkspaceLabel(workspaces, activeWorkspaceId);
const indicatorProps = {
showReleaseNotesButton: releaseNotes.showTopbarButton,
releaseNotesHasUnseen: releaseNotes.hasUnseen,
onOpenReleaseNotes: releaseNotes.openDialog,
const healthProps = {
showHealthIndicator: healthIndicator.hasIssues,
onOpenHealthDialog: healthIndicator.openDialog,
};
const sharedSearch = { searchQuery, onSearchChange, isSearchLoading };
const sharedActions = { onCreateTask, workspaceId };
const renderHeader = () => {
if (isMobile) {
@@ -510,8 +311,8 @@ export function KanbanHeader({
currentPage={currentPage}
title={title}
workspaceLabel={workspaceLabel}
onSearchChange={onSearchChange}
{...indicatorProps}
{...sharedSearch}
{...healthProps}
/>
);
}
@@ -519,14 +320,13 @@ export function KanbanHeader({
return (
<>
<TabletHeader
{...sharedActions}
title={title}
workspaceLabel={workspaceLabel}
{...sharedSearch}
toggleValue={toggleValue}
handleViewChange={handleViewChange}
setMenuOpen={setMenuOpen}
{...indicatorProps}
{...healthProps}
/>
<MobileMenuSheet
open={isMenuOpen}
@@ -534,20 +334,19 @@ export function KanbanHeader({
workspaceId={workspaceId}
currentPage={currentPage}
{...sharedSearch}
{...indicatorProps}
{...healthProps}
/>
</>
);
}
return (
<DesktopHeader
{...sharedActions}
title={title}
workspaceLabel={workspaceLabel}
{...sharedSearch}
toggleValue={toggleValue}
handleViewChange={handleViewChange}
{...indicatorProps}
{...healthProps}
/>
);
};
@@ -1,29 +1,17 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { Button } from "@kandev/ui/button";
import { Checkbox } from "@kandev/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group";
import {
IconAlertTriangle,
IconChartBar,
IconLayoutKanban,
IconList,
IconSettings,
IconSparkles,
IconTimeline,
} from "@tabler/icons-react";
import { MobileIntegrationsSection } from "@/components/integrations/integrations-menu";
import { IconAlertTriangle, IconLayoutKanban, IconList, IconTimeline } from "@tabler/icons-react";
import { TaskSearchInput } from "./task-search-input";
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
import { linkToTasks } from "@/lib/links";
import type { Repository } from "@/lib/types/http";
import type { WorkflowsState, WorkspaceState } from "@/lib/state/slices";
type WorkspaceItem = WorkspaceState["items"][number];
import type { WorkflowsState } from "@/lib/state/slices";
type MobileMenuSheetProps = {
open: boolean;
@@ -33,8 +21,6 @@ type MobileMenuSheetProps = {
searchQuery?: string;
onSearchChange?: (query: string) => void;
isSearchLoading?: boolean;
showReleaseNotesButton: boolean;
onOpenReleaseNotes: () => void;
showHealthIndicator: boolean;
onOpenHealthDialog: () => void;
};
@@ -52,9 +38,6 @@ function getMobileViewValue(currentPage: string, kanbanViewMode: string | null):
}
type MobileDisplayOptionsProps = {
activeWorkspaceId: string | null;
workspaces: WorkspaceItem[];
onWorkspaceChange: (id: string | null) => void;
activeWorkflowId: string | null;
workflows: WorkflowsState["items"];
onWorkflowChange: (id: string | null) => void;
@@ -67,9 +50,6 @@ type MobileDisplayOptionsProps = {
};
function MobileDisplaySelects({
activeWorkspaceId,
workspaces,
onWorkspaceChange,
activeWorkflowId,
workflows,
onWorkflowChange,
@@ -80,25 +60,6 @@ function MobileDisplaySelects({
}: Omit<MobileDisplayOptionsProps, "enablePreviewOnClick" | "onTogglePreviewOnClick">) {
return (
<>
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Workspace</label>
<Select
value={activeWorkspaceId ?? ""}
onValueChange={(value) => onWorkspaceChange(value || null)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select workspace" />
</SelectTrigger>
<SelectContent>
{workspaces.map((workspace) => (
<SelectItem key={workspace.id} value={workspace.id}>
{workspace.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Workflow</label>
<Select
@@ -235,64 +196,33 @@ function MobileViewSection({
}
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();
};
if (!showHealthIndicator) return null;
return (
<div className="mt-auto flex flex-col gap-3 pt-4 border-t border-border">
<div className="text-sm font-medium">Utilities</div>
{showReleaseNotesButton && (
<Button
type="button"
variant="outline"
className="w-full cursor-pointer justify-start gap-2"
onClick={openReleaseNotes}
>
<IconSparkles className="h-4 w-4" />
Release notes
</Button>
)}
{showHealthIndicator && (
<Button
type="button"
variant="outline"
className="w-full cursor-pointer justify-start gap-2"
onClick={openHealth}
>
<IconAlertTriangle className="h-4 w-4 text-warning" />
Health issues
</Button>
)}
<Button asChild variant="outline" className="w-full cursor-pointer justify-start gap-2">
<Link href="/stats" onClick={closeSheet}>
<IconChartBar className="h-4 w-4 mr-2" />
Stats
</Link>
</Button>
<Button asChild variant="outline" className="w-full cursor-pointer justify-start gap-2">
<Link href="/settings" onClick={closeSheet}>
<IconSettings className="h-4 w-4 mr-2" />
Settings
</Link>
<Button
type="button"
variant="outline"
className="w-full cursor-pointer justify-start gap-2"
onClick={openHealth}
>
<IconAlertTriangle className="h-4 w-4 text-warning" />
Health issues
</Button>
</div>
);
@@ -306,23 +236,18 @@ export function MobileMenuSheet({
searchQuery = "",
onSearchChange,
isSearchLoading = false,
showReleaseNotesButton,
onOpenReleaseNotes,
showHealthIndicator,
onOpenHealthDialog,
}: MobileMenuSheetProps) {
const router = useRouter();
const {
workspaces,
workflows,
activeWorkspaceId,
activeWorkflowId,
repositories,
repositoriesLoading,
allRepositoriesSelected,
selectedRepositoryId,
enablePreviewOnClick,
onWorkspaceChange,
onWorkflowChange,
onRepositoryChange,
onTogglePreviewOnClick,
@@ -364,9 +289,6 @@ export function MobileMenuSheet({
<MobileViewSection viewValue={viewValue} onViewChange={handleViewChange} />
<MobileDisplayOptions
activeWorkspaceId={activeWorkspaceId}
workspaces={workspaces}
onWorkspaceChange={onWorkspaceChange}
activeWorkflowId={activeWorkflowId}
workflows={workflows}
onWorkflowChange={onWorkflowChange}
@@ -378,11 +300,7 @@ export function MobileMenuSheet({
onTogglePreviewOnClick={onTogglePreviewOnClick}
/>
<MobileIntegrationsSection onNavigate={() => onOpenChange(false)} />
<MobileUtilityActions
showReleaseNotesButton={showReleaseNotesButton}
onOpenReleaseNotes={onOpenReleaseNotes}
showHealthIndicator={showHealthIndicator}
onOpenHealthDialog={onOpenHealthDialog}
onOpenChange={onOpenChange}
+62 -20
View File
@@ -78,15 +78,23 @@ function TopbarBreadcrumb({
subtitle?: string;
icon?: ReactNode;
}) {
// The Home prefix is redundant now that the AppSidebar always shows a Home
// nav item. Only render the back link when a page sets a non-root backHref
// (e.g. a true ancestor route within a section).
const showBackLink = backHref !== "/" && !!backLabel;
return (
<Breadcrumb className="relative z-10 min-w-0">
<BreadcrumbList className="flex-nowrap text-sm">
<BreadcrumbItem className="shrink-0">
<BreadcrumbLink asChild>
<BackLink href={backHref} label={backLabel} />
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="shrink-0" />
{showBackLink && (
<>
<BreadcrumbItem className="shrink-0">
<BreadcrumbLink asChild>
<BackLink href={backHref} label={backLabel} />
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="shrink-0" />
</>
)}
{parents?.flatMap((p) => [
<BreadcrumbItem key={`${p.href}-item`} className="shrink-0">
<BreadcrumbLink asChild>
@@ -119,6 +127,45 @@ function TopbarBreadcrumb({
);
}
type TopbarLeadingProps = {
variant: "breadcrumb" | "root";
backHref: string;
backLabel: string;
parents: Array<{ label: string; href: string }> | undefined;
title: string;
subtitle?: string;
icon?: ReactNode;
};
function TopbarLeading({
variant,
backHref,
backLabel,
parents,
title,
subtitle,
icon,
}: TopbarLeadingProps) {
if (variant === "root") {
if (!backLabel) return null;
return (
<div className="relative z-10 flex min-w-0 items-center">
<span className="truncate text-[15px] font-semibold leading-none">{backLabel}</span>
</div>
);
}
return (
<TopbarBreadcrumb
backHref={backHref}
backLabel={backLabel}
parents={parents}
title={title}
subtitle={subtitle}
icon={icon}
/>
);
}
export const PageTopbar = forwardRef<HTMLElement, PageTopbarProps>(function PageTopbar(
{
title,
@@ -144,20 +191,15 @@ export const PageTopbar = forwardRef<HTMLElement, PageTopbarProps>(function Page
className={cn("relative flex h-10 shrink-0 items-center gap-3 border-b px-3 py-1", className)}
>
{leading}
{variant === "root" ? (
<div className="relative z-10 flex min-w-0 items-center">
<span className="truncate text-[15px] font-semibold leading-none">{backLabel}</span>
</div>
) : (
<TopbarBreadcrumb
backHref={backHref}
backLabel={backLabel}
parents={parents}
title={title}
subtitle={subtitle}
icon={icon}
/>
)}
<TopbarLeading
variant={variant}
backHref={backHref}
backLabel={backLabel}
parents={parents}
title={title}
subtitle={subtitle}
icon={icon}
/>
{leftActions && (
<div className="relative z-10 flex shrink-0 items-center gap-1 [&:empty]:hidden">
{leftActions}
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { IconRotate } from "@tabler/icons-react";
import { IconRotate, IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Kbd } from "@kandev/ui/kbd";
@@ -9,6 +9,8 @@ import type { Key, KeyboardShortcut } from "@/lib/keyboard/constants";
import { formatShortcut } from "@/lib/keyboard/utils";
import {
CONFIGURABLE_SHORTCUTS,
UNBOUND_SHORTCUT,
isUnboundShortcut,
resolveAllShortcuts,
type ConfigurableShortcutId,
type StoredShortcutOverrides,
@@ -17,20 +19,28 @@ import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { updateUserSettings } from "@/lib/api/domains/settings-api";
type ShortcutRecorderProps = {
shortcutId: ConfigurableShortcutId;
current: KeyboardShortcut;
onChange: (id: ConfigurableShortcutId, shortcut: KeyboardShortcut) => void;
onReset: (id: ConfigurableShortcutId) => void;
// Optional: callers that don't support an explicit "unbind" (e.g. the voice
// settings recorder) omit this, and the Clear button is hidden for them.
onClear?: (id: ConfigurableShortcutId) => void;
};
export function ShortcutRecorder({
shortcutId,
current,
onChange,
onReset,
}: {
shortcutId: ConfigurableShortcutId;
current: KeyboardShortcut;
onChange: (id: ConfigurableShortcutId, shortcut: KeyboardShortcut) => void;
onReset: (id: ConfigurableShortcutId) => void;
}) {
onClear,
}: ShortcutRecorderProps) {
const [recording, setRecording] = useState(false);
const isDefault =
JSON.stringify(current) === JSON.stringify(CONFIGURABLE_SHORTCUTS[shortcutId].default);
const defaultShortcut = CONFIGURABLE_SHORTCUTS[shortcutId].default;
const isDefault = JSON.stringify(current) === JSON.stringify(defaultShortcut);
const isUnbound = isUnboundShortcut(current);
const defaultIsUnbound = isUnboundShortcut(defaultShortcut);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
@@ -85,19 +95,28 @@ export function ShortcutRecorder({
: "border-border bg-background hover:bg-accent"
}`}
>
{recording ? (
<span className="animate-pulse">Press a key combo...</span>
) : (
<Kbd>{formatShortcut(current)}</Kbd>
)}
{renderRecorderLabel({ recording, current, isUnbound })}
</button>
{onClear && !isUnbound && !defaultIsUnbound && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={() => onClear(shortcutId)}
aria-label="Clear shortcut"
title="Clear shortcut"
>
<IconX className="size-3.5" />
</Button>
)}
{!isDefault && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={() => onReset(shortcutId)}
title="Reset to default"
aria-label={defaultIsUnbound ? "Reset (clear shortcut)" : "Reset to default"}
title={defaultIsUnbound ? "Reset (clear shortcut)" : "Reset to default"}
>
<IconRotate className="size-3.5" />
</Button>
@@ -107,6 +126,20 @@ export function ShortcutRecorder({
);
}
function renderRecorderLabel({
recording,
current,
isUnbound,
}: {
recording: boolean;
current: KeyboardShortcut;
isUnbound: boolean;
}) {
if (recording) return <span className="animate-pulse">Press a key combo...</span>;
if (isUnbound) return <span className="text-muted-foreground italic">Unbound</span>;
return <Kbd>{formatShortcut(current)}</Kbd>;
}
export function KeyboardShortcutsCard() {
const storeOverrides = useAppStore((s) => s.userSettings.keyboardShortcuts);
const setUserSettings = useAppStore((s) => s.setUserSettings);
@@ -143,6 +176,14 @@ export function KeyboardShortcutsCard() {
[storeOverrides, persistOverrides],
);
const handleClear = useCallback(
(id: ConfigurableShortcutId) => {
const next = { ...storeOverrides, [id]: UNBOUND_SHORTCUT };
persistOverrides(next);
},
[storeOverrides, persistOverrides],
);
const ids = Object.keys(CONFIGURABLE_SHORTCUTS) as ConfigurableShortcutId[];
return (
@@ -159,6 +200,7 @@ export function KeyboardShortcutsCard() {
current={shortcuts[id]}
onChange={handleChange}
onReset={handleReset}
onClear={handleClear}
/>
))}
</div>
@@ -1,453 +0,0 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
IconSettings,
IconFolder,
IconRobot,
IconBell,
IconCode,
IconCpu,
IconKey,
IconMicrophone,
IconMessageCircle,
IconBrandGithub,
IconBrandGitlab,
IconBrandSentry,
IconBrandSlack,
IconHexagon,
IconWand,
IconGitBranch,
IconArrowsShuffle,
IconTicket,
IconPlugConnected,
IconBolt,
IconActivity,
IconDatabase,
IconArchive,
IconFileText,
IconRefresh,
IconScale,
IconInfoCircle,
IconServerCog,
} from "@tabler/icons-react";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarMenuSubButton,
SidebarHeader,
useSidebar,
} from "@kandev/ui/sidebar";
import { ScrollArea } from "@kandev/ui/scroll-area";
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
import { useAppStore } from "@/components/state-provider";
import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
import { AgentLogo } from "@/components/agent-logo";
import { getExecutorIcon } from "@/lib/executor-icons";
import { getCapabilityWarning } from "@/lib/capability-warning";
import type { Agent, AgentProfile, Executor } from "@/lib/types/http";
import type { WorkspaceState } from "@/lib/state/slices";
type WorkspaceItem = WorkspaceState["items"][number];
type GeneralSidebarSectionProps = {
pathname: string;
};
function GeneralSidebarSection({ pathname }: GeneralSidebarSectionProps) {
return (
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="General">
<Link href="/settings/general">
<IconSettings className="h-4 w-4" />
<span>General</span>
</Link>
</SidebarMenuButton>
<SidebarMenuSub className="ml-3 mt-1">
<SidebarMenuSubItem>
<SidebarMenuSubButton
asChild
size="sm"
isActive={pathname === "/settings/general/notifications"}
>
<Link href="/settings/general/notifications">
<IconBell className="h-4 w-4" />
<span>Notifications</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
asChild
size="sm"
isActive={pathname === "/settings/general/editors"}
>
<Link href="/settings/general/editors">
<IconCode className="h-4 w-4" />
<span>Editors</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</SidebarMenuItem>
);
}
type WorkspacesSidebarSectionProps = {
pathname: string;
workspaces: WorkspaceItem[];
};
function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSectionProps) {
return (
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Workspaces">
<Link href="/settings/workspace">
<IconFolder className="h-4 w-4" />
<span>Workspaces</span>
</Link>
</SidebarMenuButton>
{workspaces.length > 0 && (
<SidebarMenuSub className="ml-3 mt-1">
{workspaces.map((workspace) => {
const workspacePath = `/settings/workspace/${workspace.id}`;
const workflowsPath = `${workspacePath}/workflows`;
const repositoriesPath = `${workspacePath}/repositories`;
return (
<SidebarMenuSubItem key={workspace.id}>
<SidebarMenuSubButton asChild isActive={pathname === workspacePath}>
<Link href={workspacePath}>
<span>{workspace.name}</span>
</Link>
</SidebarMenuSubButton>
<SidebarMenuSub className="ml-3">
<SidebarMenuSubItem>
<SidebarMenuSubButton
asChild
size="sm"
isActive={pathname === repositoriesPath}
>
<Link href={repositoriesPath}>
<IconGitBranch className="h-3.5 w-3.5" />
<span>Repositories</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild size="sm" isActive={pathname === workflowsPath}>
<Link href={workflowsPath}>
<IconArrowsShuffle className="h-3.5 w-3.5" />
<span>Workflows</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
)}
</SidebarMenuItem>
);
}
function SystemSidebarSection({ pathname }: { pathname: string }) {
const items: Array<{ href: string; label: string; Icon: typeof IconBrandGithub }> = [
{ href: "/settings/system/status", label: "Status", Icon: IconActivity },
{ href: "/settings/system/database", label: "Database", Icon: IconDatabase },
{ href: "/settings/system/backups", label: "Backups", Icon: IconArchive },
{ href: "/settings/system/logs", label: "Logs", Icon: IconFileText },
{ href: "/settings/system/updates", label: "Updates", Icon: IconRefresh },
{ href: "/settings/system/about", label: "About", Icon: IconInfoCircle },
{ href: "/settings/system/licenses", label: "Licenses", Icon: IconScale },
];
const isSystem = pathname.startsWith("/settings/system");
return (
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="System" isActive={isSystem}>
<Link href="/settings/system/status">
<IconServerCog className="h-4 w-4" />
<span>System</span>
</Link>
</SidebarMenuButton>
<SidebarMenuSub className="ml-3 mt-1">
{items.map(({ href, label, Icon }) => (
<SidebarMenuSubItem key={href}>
<SidebarMenuSubButton asChild size="sm" isActive={pathname === href}>
<Link href={href}>
<Icon className="h-3.5 w-3.5" />
<span>{label}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</SidebarMenuItem>
);
}
function IntegrationsSidebarSection({ pathname }: { pathname: string }) {
const items: Array<{ href: string; label: string; Icon: typeof IconBrandGithub }> = [
{ href: "/settings/integrations/github", label: "GitHub", Icon: IconBrandGithub },
{ href: "/settings/integrations/gitlab", label: "GitLab", Icon: IconBrandGitlab },
{ href: "/settings/integrations/jira", label: "Jira", Icon: IconTicket },
{ href: "/settings/integrations/linear", label: "Linear", Icon: IconHexagon },
{ href: "/settings/integrations/sentry", label: "Sentry", Icon: IconBrandSentry },
{ href: "/settings/integrations/slack", label: "Slack", Icon: IconBrandSlack },
];
return (
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Integrations">
<Link href="/settings/integrations">
<IconPlugConnected className="h-4 w-4" />
<span>Integrations</span>
</Link>
</SidebarMenuButton>
<SidebarMenuSub className="ml-3 mt-1">
{items.map(({ href, label, Icon }) => (
<SidebarMenuSubItem key={href}>
<SidebarMenuSubButton asChild size="sm" isActive={pathname === href}>
<Link href={href}>
<Icon className="h-3.5 w-3.5" />
<span>{label}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</SidebarMenuItem>
);
}
type AgentsSidebarSectionProps = {
pathname: string;
agents: Agent[];
};
function AgentsSidebarSection({ pathname, agents }: AgentsSidebarSectionProps) {
return (
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Agents">
<Link href="/settings/agents">
<IconRobot className="h-4 w-4" />
<span>Agents</span>
</Link>
</SidebarMenuButton>
{agents.length > 0 && (
<SidebarMenuSub className="ml-3 mt-1">
{agents.flatMap((agent: Agent) =>
agent.profiles.map((profile: AgentProfile) => {
const encodedAgent = encodeURIComponent(agent.name);
const profilePath = `/settings/agents/${encodedAgent}/profiles/${profile.id}`;
const agentLabel = profile.agentDisplayName || agent.name;
const warning = getCapabilityWarning(agent.capability_status, agent.capability_error);
return (
<SidebarMenuSubItem key={profile.id} className="min-w-0">
<SidebarMenuSubButton asChild isActive={pathname === profilePath}>
<Link
href={profilePath}
className="!flex min-w-0 items-center gap-1.5"
title={warning?.title || `${agentLabel} • ${profile.name}`}
>
<AgentLogo agentName={agent.name} className="shrink-0" />
<ScrollOnOverflow className="min-w-0">
{agentLabel} • {profile.name}
</ScrollOnOverflow>
{warning && <warning.Icon className={`size-3.5 shrink-0 ${warning.color}`} />}
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
}),
)}
</SidebarMenuSub>
)}
</SidebarMenuItem>
);
}
type ExecutorsSidebarSectionProps = {
pathname: string;
executors: Executor[];
};
function ExecutorsSidebarSection({ pathname, executors }: ExecutorsSidebarSectionProps) {
const allProfiles = executors.flatMap((e) =>
(e.profiles ?? []).map((p) => ({ ...p, executorType: e.type })),
);
return (
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Executors" isActive={pathname === "/settings/executors"}>
<Link href="/settings/executors">
<IconCpu className="h-4 w-4" />
<span>Executors</span>
</Link>
</SidebarMenuButton>
{allProfiles.length > 0 && (
<SidebarMenuSub className="ml-3 mt-1">
{allProfiles.map((profile) => {
const Icon = getExecutorIcon(profile.executorType);
const profilePath = `/settings/executors/${profile.id}`;
return (
<SidebarMenuSubItem key={profile.id}>
<SidebarMenuSubButton asChild size="sm" isActive={pathname === profilePath}>
<Link href={profilePath} className="!flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>{profile.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
)}
</SidebarMenuItem>
);
}
type SimpleSidebarEntry = {
href: string;
label: string;
Icon: typeof IconBrandGithub;
};
/**
* A short row of single-link sidebar entries (Automations, Prompts, Voice
* Mode, Utility Agents, External MCP) — extracted from `SettingsAppSidebar`
* so the parent function stays under the 100-line lint limit.
*/
function SimpleSidebarRows({
pathname,
entries,
}: {
pathname: string;
entries: SimpleSidebarEntry[];
}) {
return (
<>
{entries.map(({ href, label, Icon }) => (
<SidebarMenuItem key={href}>
<SidebarMenuButton
asChild
isActive={
href === "/settings/automations"
? pathname.includes("/automations")
: pathname === href
}
>
<Link href={href}>
<Icon className="h-4 w-4" />
<span>{label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</>
);
}
function SecretsSidebarSection({ pathname }: { pathname: string }) {
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname === "/settings/general/secrets"}>
<Link href="/settings/general/secrets">
<IconKey className="h-4 w-4" />
<span>Secrets</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
export function SettingsAppSidebar() {
const pathname = usePathname();
const { setOpenMobile, isMobile } = useSidebar();
const workspaces = useAppStore((state) => state.workspaces.items);
const executors = useAppStore((state) => state.executors.items);
const agents = useAppStore((state) => state.settingsAgents.items);
useAvailableAgents();
// Close mobile sidebar when navigating to a new page
useEffect(() => {
if (isMobile) {
setOpenMobile(false);
}
}, [pathname, isMobile, setOpenMobile]);
return (
<Sidebar variant="inset">
<SidebarHeader className="h-16 justify-center">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link href="/">
<span className="text-2xl font-bold">Kandev</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent className="overflow-hidden">
<ScrollArea
className="h-full [&_[data-slot=scroll-area-viewport]>div]:!block [&_[data-slot=scroll-area-viewport]>div]:!min-w-0"
type="always"
>
<SidebarGroup>
<SidebarGroupLabel>Settings</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<GeneralSidebarSection pathname={pathname} />
<WorkspacesSidebarSection pathname={pathname} workspaces={workspaces} />
<IntegrationsSidebarSection pathname={pathname} />
<SimpleSidebarRows
pathname={pathname}
entries={[
{ href: "/settings/automations", label: "Automations", Icon: IconBolt },
]}
/>
<AgentsSidebarSection pathname={pathname} agents={agents} />
<SimpleSidebarRows
pathname={pathname}
entries={[
{ href: "/settings/prompts", label: "Prompts", Icon: IconMessageCircle },
{ href: "/settings/voice-mode", label: "Voice Mode", Icon: IconMicrophone },
{ href: "/settings/utility-agents", label: "Utility Agents", Icon: IconWand },
]}
/>
<ExecutorsSidebarSection pathname={pathname} executors={executors} />
<SecretsSidebarSection pathname={pathname} />
<SimpleSidebarRows
pathname={pathname}
entries={[
{
href: "/settings/external-mcp",
label: "External MCP",
Icon: IconPlugConnected,
},
]}
/>
{/* System */}
<SystemSidebarSection pathname={pathname} />
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</ScrollArea>
</SidebarContent>
</Sidebar>
);
}
@@ -1,10 +1,8 @@
"use client";
import { usePathname } from "next/navigation";
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";
// Brand/initialism overrides so the derived label matches how the rest of the
// app spells these (e.g. "github" → "GitHub", not "Github"). Anything not
@@ -108,20 +106,20 @@ function SettingsShell({
}) {
return (
<TooltipProvider>
<SidebarProvider>
<SettingsAppSidebar />
<SidebarInset>
<PageTopbar
title={title}
backHref={backHref}
backLabel={backLabel}
parents={parents}
className="h-16 border-b-0"
leading={<SidebarTrigger size="lg" className="md:hidden h-10 w-10 cursor-pointer" />}
/>
<div className="flex min-w-0 flex-1 flex-col gap-4 p-4 pt-0 mb-20">{children}</div>
</SidebarInset>
</SidebarProvider>
<main className="flex min-h-0 flex-1 flex-col">
<PageTopbar
title={title}
backHref={backHref}
backLabel={backLabel}
parents={parents}
className="h-16 border-b-0"
/>
{/* Scroll the content, not the topbar: min-h-0 lets this flex child
shrink below its content height so overflow-y-auto can take effect. */}
<div className="flex min-w-0 min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4 pt-0 pb-20">
{children}
</div>
</main>
</TooltipProvider>
);
}
@@ -28,7 +28,6 @@ import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
import { useActiveTaskHasRepos } from "@/hooks/domains/kanban/use-active-task-has-repos";
// Panel components (rendered via portals, not directly by dockview)
import { TaskSessionSidebar } from "./task-session-sidebar";
import { LeftHeaderActions, RightHeaderActions } from "./dockview-header-actions";
import { DockviewWatermark } from "./dockview-watermark";
import { TaskChatPanel } from "./task-chat-panel";
@@ -141,7 +140,6 @@ function PortalSlot(props: IDockviewPanelProps) {
// All panel types use the same PortalSlot wrapper — dockview only manages
// layout positioning. Actual rendering happens in PanelPortalHost below.
const components: Record<string, React.FunctionComponent<IDockviewPanelProps>> = {
sidebar: PortalSlot,
chat: PortalSlot,
"diff-viewer": PortalSlot,
"file-editor": PortalSlot,
@@ -180,7 +178,13 @@ function useSyncUserDefaultLayout() {
const state = defaultLayout?.layout as unknown as
| import("@/lib/state/layout-manager").LayoutState
| undefined;
setUserDefaultLayout(state?.columns ? state : null);
// Drop the obsolete "sidebar" column: the dockview-embedded sidebar pane was
// retired for the unified AppSidebar, but a default layout saved before that
// change still carries it. The default-build path applies this layout
// without the restore-time sanitize layer, so an orphaned sidebar column
// (its panel component is no longer registered) renders a broken grid.
const columns = state?.columns?.filter((c) => c.id !== "sidebar");
setUserDefaultLayout(columns && columns.length > 0 ? { ...state, columns } : null);
}, [savedLayouts, setUserDefaultLayout]);
}
@@ -203,22 +207,6 @@ const tabComponents: Record<string, React.FunctionComponent<IDockviewPanelHeader
// Each content component renders the real panel UI. They live permanently
// in the PanelPortalHost and survive dockview layout switches.
function SidebarContent({ panelId }: { panelId: string }) {
const workspaceId = useAppStore((state) => state.workspaces.activeId);
// Read kanban.workflowId (task snapshot), not workflows.activeId (homepage filter), to preserve "All Workflows" across task navigation.
const workflowId = useAppStore((state) => state.kanban.workflowId);
const workspaceName = useAppStore((state) => {
const ws = state.workspaces.items.find((w: { id: string }) => w.id === workspaceId);
return ws?.name ?? "Workspace";
});
useEffect(() => {
setPanelTitle(panelId, workspaceName);
}, [panelId, workspaceName]);
return <TaskSessionSidebar workspaceId={workspaceId} workflowId={workflowId} />;
}
export const CHAT_PANEL_FALLBACK_LABEL = "Agent";
export function resolveChatPanelTitle(agentLabel: string | null | undefined): string {
@@ -442,7 +430,7 @@ function renderPanel(
switch (resolved) {
case "sidebar":
return <SidebarContent panelId={panelId} />;
return null;
case "chat":
return <ChatContent panelId={panelId} params={params} />;
case "diff-viewer":
+110 -94
View File
@@ -4,62 +4,89 @@ import type { AppState } from "@/lib/state/store";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { getRootSplitview } from "@/lib/state/dockview-layout-builders";
import {
computeSidebarMaxPx,
computeRightMaxPx,
LAYOUT_PINNED_MIN_PX,
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
setPinnedTarget,
getPinnedTarget,
} from "@/lib/state/layout-manager";
import { setEnvLayout, setGlobalSidebarWidth } from "@/lib/local-storage";
import { setEnvLayout } from "@/lib/local-storage";
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
import { stopVscode } from "@/lib/api/domains/vscode-api";
import { stopUserShell } from "@/lib/api/domains/user-shell-api";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
import { snapshotColumnWidths, formatWidthsSnapshot } from "@/lib/state/dockview-widths-debug";
import { enforcePinnedTargets, setSashDragging } from "@/lib/state/dockview-pinned-enforce";
import { parkUserShell, stopUserShell } from "@/lib/api/domains/user-shell-api";
const debugWidths = createDebugLogger("dockview:widths");
// v2: bumped alongside DOCKVIEW_ENV_LAYOUT_PREFIX so the no-env fallback
// also invalidates layouts saved under the previous caps.
const LAYOUT_STORAGE_KEY = "dockview-layout-v2";
// v3: bumped alongside DOCKVIEW_ENV_LAYOUT_PREFIX so the no-env fallback
// also discards layouts captured with the now-removed dockview sidebar column.
const LAYOUT_STORAGE_KEY = "dockview-layout-v3";
const terminalTerminateClosePanelIds = new Set<string>();
export function markTerminalPanelTerminateClose(panelId: string): void {
terminalTerminateClosePanelIds.add(panelId);
}
// Pinned-column target enforcement and the `sashDragging` flag live in
// `lib/state/dockview-pinned-enforce.ts` so the store can call enforcement
// without importing this component-layer module. The sash mousedown/mouseup
// handlers below toggle the flag via `setSashDragging`.
/**
* Pinned-column target enforcement.
*
* Dockview's splitview rebalances proportionally on any `api.layout` call,
* which would otherwise grow pinned columns past their initial defaults on
* container expansion and shrink them on container contraction. We treat
* sidebar/right as having a *target width* (stored in `pinned-targets.ts`)
* that is updated only by explicit user actions (drag, initial layout,
* restore from saved); after every layout-change event we force the live
* columns back to their targets via `sv.resizeView`.
*/
/** Set the loose runtime cap so the user can drag the column past its target.
* Uses `api.width` (dockview's measured grid width) for the cap computation
* instead of `window.innerWidth`, which can briefly read stale during route
* transitions and devtools toggles - a vw=601 read yields cap=301 (=
* vw - VIEWPORT_RESERVE_PX) and squeezes the pinned column down to 301. */
/** Enforcement-in-progress guard to prevent infinite loops when our own
* `sv.resizeView` triggers `onDidLayoutChange`. */
let enforcing = false;
/** True while the user is actively dragging a `.dv-sash`. We pause target
* enforcement during the drag so the in-progress resize doesn't get
* reverted to the previous target on every intermediate layout change. */
let sashDragging = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function restoreColumnToTarget(sv: any, idx: number, target: number | undefined): void {
if (target === undefined) return;
const cur = sv.getViewSize(idx);
if (Math.abs(cur - target) <= 1) return;
try {
sv.resizeView(idx, target);
} catch {
/* dockview rejects unreachable sizes — ignore */
}
}
function enforcePinnedTargets(api: DockviewReadyEvent["api"]): void {
if (enforcing || sashDragging) return;
const store = useDockviewStore.getState();
if (store.isRestoringLayout) return;
if (api.hasMaximizedGroup() || store.preMaximizeLayout !== null) return;
const sv = getRootSplitview(api);
if (!sv || sv.length < 2) return;
enforcing = true;
try {
if (store.rightPanelsVisible) {
restoreColumnToTarget(sv, sv.length - 1, getPinnedTarget("right"));
}
} finally {
enforcing = false;
}
}
/** Set the loose runtime cap so the user can drag the column past its target. */
function setLooseConstraints(api: DockviewReadyEvent["api"]): void {
const store = useDockviewStore.getState();
if (store.isRestoringLayout) return;
if (api.hasMaximizedGroup() || store.preMaximizeLayout !== null) return;
const vw = api.width > 0 ? api.width : undefined;
const sb = api.getPanel("sidebar");
if (sb && store.sidebarVisible) {
sb.group.api.setConstraints({
maximumWidth: computeSidebarMaxPx(vw),
minimumWidth: LAYOUT_PINNED_MIN_PX,
});
}
if (store.rightPanelsVisible) {
for (const gid of [RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]) {
const group = api.groups.find((g) => g.id === gid);
if (group) {
group.api.setConstraints({
maximumWidth: computeRightMaxPx(vw),
maximumWidth: computeRightMaxPx(),
minimumWidth: LAYOUT_PINNED_MIN_PX,
});
}
@@ -80,62 +107,28 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo
// enforced post-hoc via `enforcePinnedTargets`.
setLooseConstraints(api);
const layoutSub = api.onDidLayoutChange(() => {
// Skip the reactive enforcement during a programmatic restore - the
// restore path itself calls `enforcePinnedTargets` synchronously inside
// its rAF so the user never sees a transient post-rebalance flicker.
// Without this gate, the in-flight restore (which fires its own
// layout-change events) would be re-entered before its rAF settled.
const s = useDockviewStore.getState();
if (s.isRestoringLayout) return;
enforcePinnedTargets(api, {
sidebarVisible: s.sidebarVisible,
rightPanelsVisible: s.rightPanelsVisible,
maximized: s.preMaximizeLayout !== null,
});
});
const layoutSub = api.onDidLayoutChange(() => enforcePinnedTargets(api));
if (typeof document === "undefined") {
return () => layoutSub.dispose();
}
// Local mirror of the enforcement-module flag so the mouseup handler can
// short-circuit when no drag was in progress without round-tripping through
// a getter. Both this local and the module flag (via setSashDragging) must
// stay in lockstep.
let dragging = false;
const onMouseDown = (e: MouseEvent): void => {
// Only track primary-button drags. A right/middle mousedown that didn't
// start a drag must not leave the flag permanently set (cubic P2).
// start a drag must not leave `sashDragging` permanently set (cubic P2).
if (e.button !== 0) return;
const t = e.target as HTMLElement | null;
if (t?.closest(".dv-sash")) {
dragging = true;
setSashDragging(true);
}
if (t?.closest(".dv-sash")) sashDragging = true;
};
const onMouseUp = (e: MouseEvent): void => {
if (e.button !== 0 || !dragging) return;
dragging = false;
setSashDragging(false);
if (e.button !== 0 || !sashDragging) return;
sashDragging = false;
// Capture the post-drag width as the new target.
requestAnimationFrame(() => {
const sv = getRootSplitview(api);
if (!sv) return;
const store = useDockviewStore.getState();
if (store.sidebarVisible) {
// A genuine drag is the ONLY writer of the global sidebar width pref.
// Persist it globally and mirror into the store's pinnedWidths so the
// override path (resolvePresetPinnedWidths / classifyColumns) agrees.
const sidebarW = sv.getViewSize(0);
setPinnedTarget("sidebar", sidebarW);
setGlobalSidebarWidth(sidebarW);
store.setPinnedWidth("sidebar", sidebarW);
}
if (store.rightPanelsVisible) setPinnedTarget("right", sv.getViewSize(sv.length - 1));
if (isDebug()) {
debugWidths(`sash-drag-end ${formatWidthsSnapshot(snapshotColumnWidths(api))}`);
}
});
};
document.addEventListener("mousedown", onMouseDown, true);
@@ -145,14 +138,37 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo
layoutSub.dispose();
document.removeEventListener("mousedown", onMouseDown, true);
document.removeEventListener("mouseup", onMouseUp, true);
// Reset both flags so an unmount mid-drag (e.g. user navigates away while
// holding a sash) doesn't leave enforcement permanently paused for the
// next mount.
dragging = false;
setSashDragging(false);
// Reset the module-scope flag so an unmount mid-drag (e.g. user navigates
// away while holding a sash) doesn't leave enforcement permanently paused
// for the next mount (claude).
sashDragging = false;
};
}
function trackPinnedWidths(api: DockviewReadyEvent["api"]): void {
const store = useDockviewStore.getState();
if (store.isRestoringLayout) return;
if (api.hasMaximizedGroup() || store.preMaximizeLayout !== null) return;
const sv = getRootSplitview(api);
if (!sv || sv.length < 2) return;
try {
// Right column is the last grid index when present. Skip when there is
// no right column (compact preset, rightPanelsVisible=false).
if (store.rightPanelsVisible) {
const rightIdx = sv.length - 1;
const rightW = sv.getViewSize(rightIdx);
if (rightW > 50) {
const current = store.pinnedWidths.get("right");
if (current !== rightW) {
store.setPinnedWidth("right", rightW);
}
}
}
} catch {
/* noop */
}
}
/**
* Keep dockview's internal grid width in sync with the live DOM container.
*
@@ -175,12 +191,6 @@ export function setupContainerResizeSync(api: DockviewReadyEvent["api"]): () =>
const h = parent.clientHeight;
if (w <= 0 || h <= 0) return;
if (w === api.width && h === api.height) return;
if (isDebug()) {
debugWidths(
`container-resize prev=${api.width}x${api.height} next=${w}x${h} ` +
`pre=${formatWidthsSnapshot(snapshotColumnWidths(api))}`,
);
}
api.layout(w, h);
// `enforcePinnedTargets` (wired in `setupSashDragCapToggle`) restores
// sidebar/right to their target widths via `onDidLayoutChange`, so we
@@ -195,8 +205,11 @@ export function setupGroupTracking(api: DockviewReadyEvent["api"]): () => void {
useDockviewStore.setState({ activeGroupId: group?.id ?? null });
});
useDockviewStore.setState({ activeGroupId: api.activeGroup?.id ?? null });
const d2 = api.onDidLayoutChange(() => trackPinnedWidths(api));
trackPinnedWidths(api);
return () => {
d1.dispose();
d2.dispose();
};
}
@@ -211,11 +224,6 @@ export function setupLayoutPersistence(
try {
const json = api.toJSON();
const envId = envIdRef.current;
// Global snapshot of the last layout. NOTE: restore is per-env (see
// tryRestoreLayout) — this key is no longer read on load, since
// restoring a cross-env layout flashed the previous task's proportions
// while a fresh task prepared. Kept as a debounced-save checkpoint that
// e2e polls to know a layout change has flushed before reloading.
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(json));
if (envId) {
setEnvLayout(envId, json);
@@ -314,8 +322,8 @@ function resolveSessionForEntry(
return match?.[0] ?? active;
}
/** Tab close → destroy the shell (PTY stopped, DB row removed). When the tab
* component already destroyed the shell it marks the panel id so we skip. */
/** Tab close → ordinary terminals park (PTY + DB row survive, reappear in
* the "+" menu); scripts/bottom-panel/legacy passthrough still destroy. */
function handleTerminalPanelClosed(
appStore: StoreApi<AppState>,
panelId: string,
@@ -331,9 +339,19 @@ function handleTerminalPanelClosed(
const fallbackEnv = active ? (state.environmentIdBySessionId[active] ?? null) : null;
const envForTerminal = stampedEnv || fallbackEnv;
if (!envForTerminal) return;
stopUserShell(envForTerminal, terminalId, stampedTaskID)
.then(() => state.removeUserShell(envForTerminal, terminalId))
.catch((err: unknown) => console.warn("stop terminal on tab close:", err));
const shell = state.userShells.byEnvironmentId[envForTerminal]?.find(
(s) => s.terminalId === terminalId,
);
if (shell?.kind === "ordinary") {
parkUserShell(terminalId, stampedTaskID).then(
() => state.updateUserShell(envForTerminal, terminalId, { state: "parked" }),
(err: unknown) => console.error("park terminal on tab close:", err),
);
} else {
stopUserShell(envForTerminal, terminalId, stampedTaskID).catch((err: unknown) =>
console.warn("stop terminal on tab close:", err),
);
}
}
export function setupPortalCleanup(
@@ -342,10 +360,8 @@ export function setupPortalCleanup(
): void {
api.onDidRemovePanel((panel) => {
if (useDockviewStore.getState().isRestoringLayout) return;
const nonSidebarRemaining = api.panels.filter(
(p) => p.id !== panel.id && p.api.component !== "sidebar",
).length;
handleMaximizeExitOnLastClose(api, panel.id, nonSidebarRemaining);
const remainingPanelCount = api.panels.filter((p) => p.id !== panel.id).length;
handleMaximizeExitOnLastClose(api, panel.id, remainingPanelCount);
const entry = panelPortalManager.get(panel.id);
const sessionForApi = resolveSessionForEntry(appStore, entry?.envId);
if (entry?.component === "vscode" && sessionForApi) stopVscode(sessionForApi);
@@ -129,10 +129,7 @@ export function setupChatPanelSafetyNet(
const hasChatPanel = api.panels.some((p) => p.id === "chat" || p.id.startsWith("session:"));
if (hasChatPanel) return;
const activeSessionId = appStore.getState().tasks.activeSessionId;
const sb = api.getPanel("sidebar");
const position = sb
? { direction: "right" as const, referencePanel: "sidebar" }
: undefined;
const position = undefined;
// Only recreate a panel if there's still an active session.
// If all sessions were deleted, leave the layout empty — the user
// can create a new session via the "+" menu.
@@ -160,7 +157,7 @@ export function setupChatPanelSafetyNet(
debug("setupChatPanelSafetyNet: recreating session panel", {
activeSessionId,
activeTaskId,
anchor: sb ? "rightOfSidebar" : "auto",
anchor: "auto",
});
}
api.addPanel({
@@ -308,6 +305,13 @@ export function findSessionAnchorGroupId(api: DockviewApi): string | null {
}
function resolveInitialPosition(api: DockviewApi): AddPanelOptions["position"] {
// Prefer the live "chat" placeholder's group. The session panel must be added
// INTO that group (so it's a tab beside chat) BEFORE chat is removed —
// otherwise removing chat empties and destroys the center group, the stored
// centerGroupId goes stale, and the session panel gets appended as a new row,
// collapsing the horizontal default layout into a vertical stack.
const chatGroupId = api.getPanel("chat")?.group?.id;
if (chatGroupId) return { referenceGroup: chatGroupId };
const { centerGroupId } = useDockviewStore.getState();
const centerGroupExists = centerGroupId && api.groups.some((g) => g.id === centerGroupId);
if (centerGroupExists) return { referenceGroup: centerGroupId };
@@ -319,8 +323,6 @@ function resolveInitialPosition(api: DockviewApi): AddPanelOptions["position"] {
// to the right of pr-detail, which contradicts the default placement
// every other code path produces. Pick the consistent default.
if (anchorGroupId) return { referenceGroup: anchorGroupId, index: 0 };
const sb = api.getPanel("sidebar");
if (sb) return { direction: "right" as const, referencePanel: "sidebar" };
return undefined;
}
@@ -402,20 +404,25 @@ export function reconcileRemovedSessionPanels(
const EMPTY_SESSION_IDS_KEY = "";
/**
* Drop the generic "chat" placeholder once a real session is active.
* Skips removal in maximized state to preserve the saved maximize layout.
* Returns true when callers should continue ensuring session panels;
* false when the maximized state intentionally suppresses them.
* Whether the layout is maximized — session panels are intentionally absent
* then (restored on exit-maximize). Returns true when callers should continue
* ensuring panels, false to skip.
*/
function prepareLayoutForSessionPanels(api: DockviewApi): boolean {
const preMaximizeLayout = useDockviewStore.getState().preMaximizeLayout;
function shouldEnsureSessionPanels(): boolean {
return useDockviewStore.getState().preMaximizeLayout === null;
}
/**
* Drop the generic "chat" placeholder once a real session panel exists.
*
* MUST run AFTER the session panel has been added to chat's group — removing
* chat while it's the only panel in the center group destroys that group and
* collapses the horizontal default layout into a vertical stack.
*/
function removeChatPlaceholder(api: DockviewApi): void {
if (useDockviewStore.getState().preMaximizeLayout) return;
const chatPanel = api.getPanel("chat");
if (chatPanel && !preMaximizeLayout) {
api.removePanel(chatPanel);
}
// In maximized state, session panels are intentionally absent from the
// layout — they'll be restored when the user exits maximize.
return preMaximizeLayout === null;
if (chatPanel) api.removePanel(chatPanel);
}
/**
@@ -548,7 +555,7 @@ function shouldSkipPanelEnsure(
}
return true;
}
if (!prepareLayoutForSessionPanels(api)) {
if (!shouldEnsureSessionPanels()) {
if (isDebug())
debug("useAutoSessionTab: skip body (maximized - panels suppressed)", { effectiveSessionId });
createdSet.add(effectiveSessionId);
@@ -625,6 +632,11 @@ function runAutoSessionTabEffect(
refs.sessionTabCreatedRef.current,
);
// Now that the session panel occupies the center group, drop the generic
// "chat" placeholder. Order matters: removing chat first would empty and
// destroy the center group, collapsing the horizontal layout.
removeChatPlaceholder(api);
const activePanel = activateSessionPanel(
api,
effectiveSessionId,
+1 -18
View File
@@ -14,7 +14,6 @@ import { useSessionCommits } from "@/hooks/domains/session/use-session-commits";
import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
// Panel components (rendered via portals, not directly by dockview)
import { TaskSessionSidebar } from "./task-session-sidebar";
import { TaskChatPanel } from "./task-chat-panel";
import { TaskChangesPanel } from "./task-changes-panel";
import { ChangesPanel } from "./changes-panel";
@@ -101,7 +100,6 @@ function PortalSlot(props: IDockviewPanelProps) {
// All panel types use the same PortalSlot wrapper — dockview only manages
// layout positioning. Actual rendering happens in PanelPortalHost below.
export const dockviewComponents: Record<string, React.FunctionComponent<IDockviewPanelProps>> = {
sidebar: PortalSlot,
chat: PortalSlot,
"diff-viewer": PortalSlot,
"file-editor": PortalSlot,
@@ -147,21 +145,6 @@ export { ContextMenuTab };
// Each content component renders the real panel UI. They live permanently
// in the PanelPortalHost and survive dockview layout switches.
function SidebarContent({ panelId }: { panelId: string }) {
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const workflowId = useAppStore((state) => state.workflows.activeId);
const workspaceName = useAppStore((state) => {
const ws = state.workspaces.items.find((w: { id: string }) => w.id === workspaceId);
return ws?.name ?? "Workspace";
});
useEffect(() => {
setPanelTitle(panelId, workspaceName);
}, [panelId, workspaceName]);
return <TaskSessionSidebar workspaceId={workspaceId} workflowId={workflowId} />;
}
function useChatSessionTitle(panelId: string, sessionId: string | null, isSessionTab: boolean) {
const agentLabel = useAppStore((state) => {
if (!sessionId) return null;
@@ -371,7 +354,7 @@ export function renderPanel(
switch (resolved) {
case "sidebar":
return <SidebarContent panelId={panelId} />;
return null;
case "chat":
return <ChatContent panelId={panelId} params={params} />;
case "diff-viewer":
+16 -8
View File
@@ -342,6 +342,8 @@ export const TaskItem = memo(function TaskItem({
role="button"
tabIndex={0}
data-testid="sidebar-task-item"
data-active={isSelected ? "true" : "false"}
aria-current={isSelected ? "true" : undefined}
onClick={onClick}
onKeyDown={(e) => handleTaskItemKeyDown(e, onClick)}
style={indent.depth > 0 ? { paddingLeft: indent.paddingLeftPx } : undefined}
@@ -353,14 +355,7 @@ export const TaskItem = memo(function TaskItem({
)}
>
<SelectionBar isSelected={isSelected} color={taskColor} />
{indent.depth > 0 && (
<span
style={{ left: indent.connectorLeftPx }}
className="absolute top-[10px] select-none text-[11px] text-muted-foreground/30"
>
↳
</span>
)}
<RowConnector depth={indent.depth} leftPx={indent.connectorLeftPx} />
<TaskStateIcon
sessionState={sessionState}
state={state}
@@ -404,6 +399,19 @@ export const TaskItem = memo(function TaskItem({
);
});
// Nested-subtask connector glyph. Renders nothing at the top level (depth 0).
function RowConnector({ depth, leftPx }: { depth: number; leftPx: number }) {
if (depth === 0) return null;
return (
<span
style={{ left: leftPx }}
className="absolute top-[10px] select-none text-[11px] text-muted-foreground/30"
>
↳
</span>
);
}
function SelectionBar({ isSelected, color }: { isSelected: boolean; color: TaskColor | null }) {
if (color) {
return (
@@ -1,6 +1,8 @@
"use client";
import { useCallback, useEffect, useMemo, useState, memo } from "react";
import { usePathname, useRouter } from "next/navigation";
import { linkToTask } from "@/lib/links";
import type {
Message,
TaskState,
@@ -177,6 +179,8 @@ function toSidebarItem(
type TaskSessionSidebarProps = {
workspaceId: string | null;
workflowId: string | null;
/** Hide the embedded filter bar when the host surface (e.g. AppSidebar) renders its own. */
hideFilterBar?: boolean;
};
type SidebarItem = Omit<ReturnType<typeof toSidebarItem>, "workflowId"> & { workflowId?: string };
@@ -456,6 +460,8 @@ function useSidebarActions(store: StoreApi) {
const setActiveSession = useAppStore((state) => state.setActiveSession);
const [preparingTaskId, setPreparingTaskId] = useState<string | null>(null);
const { renameTaskById } = useTaskActions();
const router = useRouter();
const pathname = usePathname();
const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({
store,
useLayoutSwitch: true,
@@ -468,6 +474,18 @@ function useSidebarActions(store: StoreApi) {
const handleSelectTask = useCallback(
(taskId: string) => {
// The AppSidebar is mounted globally. On a non-task route the dockview
// isn't mounted, so the in-place layout switch (which only rewrites the
// URL via history.replaceState) would change the address bar without
// ever showing the task. Navigate to the task page in that case; the
// in-place fast-switch is only correct once the dockview is on screen.
const onTaskRoute =
!!pathname && (pathname.startsWith("/t/") || pathname.startsWith("/office/tasks/"));
if (!onTaskRoute) {
setActiveTask(taskId);
router.push(linkToTask(taskId));
return;
}
const state = store.getState();
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
selectTaskWithLayout({
@@ -480,7 +498,7 @@ function useSidebarActions(store: StoreApi) {
setPreparingTaskId,
});
},
[loadTaskSessionsForTask, switchToSession, setActiveTask, store],
[loadTaskSessionsForTask, switchToSession, setActiveTask, store, router, pathname],
);
const archiveActions = useArchiveActions(store);
@@ -554,10 +572,12 @@ function useGroupedSidebarView(displayTasks: TaskSwitcherItem[]) {
export const TaskSessionSidebar = memo(function TaskSessionSidebar({
workspaceId,
hideFilterBar,
}: TaskSessionSidebarProps) {
const store = useAppStoreApi();
useRepositories(workspaceId);
useWorkspacePRs(workspaceId);
const pathname = usePathname();
const {
activeTaskId,
@@ -569,6 +589,14 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
primarySessionIds,
} = useSidebarData(workspaceId);
// The sidebar is global, so `activeTaskId` lingers after navigating Home.
// Only highlight a task while actually viewing a task route — otherwise the
// last-opened task stays visually "selected" on Home and elsewhere.
const onTaskRoute =
!!pathname && (pathname.startsWith("/t/") || pathname.startsWith("/office/tasks/"));
const highlightedTaskId = onTaskRoute ? activeTaskId : null;
const highlightedSelectedTaskId = onTaskRoute ? selectedTaskId : null;
useBulkGitStatusSubscription(primarySessionIds);
const sidebarActions = useSidebarActions(store);
@@ -598,14 +626,14 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
const { pinnedTaskIds, togglePinnedTask, handleReorderGroup, handleReorderSubtasks } = prefs;
return (
<PanelRoot data-testid="task-sidebar">
<SidebarFilterBar />
{!hideFilterBar && <SidebarFilterBar />}
<PanelBody className="space-y-4 p-0" data-testid="task-sidebar-scroll">
<TaskSwitcher
grouped={grouped}
workflows={workflows}
stepsByWorkflowId={stepsByWorkflowId}
activeTaskId={activeTaskId}
selectedTaskId={selectedTaskId}
activeTaskId={highlightedTaskId}
selectedTaskId={highlightedSelectedTaskId}
collapsedGroupKeys={effectiveView.collapsedGroups}
onToggleGroup={(groupKey) => toggleSidebarGroupCollapsed(effectiveView.id, groupKey)}
collapsedSubtaskParentIds={collapsedSubtaskParents}
@@ -80,16 +80,6 @@ vi.mock("@/components/task/quick-chat-button", () => ({
QuickChatButton: () => null,
}));
vi.mock("@/components/task/topbar-action-overflow", () => ({
TopbarActionOverflow: ({ items }: { items: Array<{ id: string; content: React.ReactNode }> }) => (
<div>
{items.map((item) => (
<div key={item.id}>{item.content}</div>
))}
</div>
),
}));
vi.mock("@/components/integrations/integrations-menu", () => ({
IntegrationsMenu: () => null,
}));
+28 -138
View File
@@ -2,24 +2,9 @@
import { memo, type ReactNode } from "react";
import Link from "next/link";
import { IconBug, IconDots, IconHome, IconSettings } from "@tabler/icons-react";
import { IconBug } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@kandev/ui/breadcrumb";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "@kandev/ui/breadcrumb";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { EditorsMenu } from "@/components/task/editors-menu";
import { LayoutPresetSelector } from "@/components/task/layout-preset-selector";
@@ -35,12 +20,6 @@ import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availabili
import { PortForwardButton } from "@/components/task/port-forward-dialog";
import { ExecutorSettingsButton } from "@/components/task/executor-settings-button";
import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper";
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 { isDebugUI } from "@/lib/config";
type TaskTopBarProps = {
@@ -91,7 +70,7 @@ const TaskTopBar = memo(function TaskTopBar({
return (
<header
data-testid="task-topbar"
className="@container/topbar grid grid-cols-[minmax(0,1fr)_minmax(0,auto)_minmax(0,1fr)] items-center gap-2 overflow-hidden px-3 py-1 border-b border-border"
className="@container/topbar grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_minmax(0,auto)_minmax(0,1fr)] items-center gap-2 overflow-hidden px-3 py-1 border-b border-border"
>
<TopBarLeft
taskId={taskId}
@@ -161,7 +140,8 @@ function IssueTrackerButtons({
);
}
/** Left section: home → task name breadcrumb, integrations menu, executor info */
/** Left section: task name breadcrumb + executor info. Home + integrations
* moved to the unified AppSidebar in the UI overhaul. */
function TopBarLeft({
taskId,
activeSessionId,
@@ -174,18 +154,6 @@ function TopBarLeft({
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap text-sm min-w-0">
<BreadcrumbItem className="shrink-0">
<BreadcrumbLink asChild>
<Link
href="/"
className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
data-testid="task-breadcrumb-home"
>
<IconHome className="h-4 w-4" />
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="shrink-0" />
<BreadcrumbItem className="min-w-0">
<Tooltip>
<TooltipTrigger asChild>
@@ -199,8 +167,6 @@ function TopBarLeft({
</BreadcrumbList>
</Breadcrumb>
<IntegrationsMenu />
{!isArchived && showExecutorSettings && (
<ExecutorSettingsButton taskId={taskId} sessionId={activeSessionId ?? null} />
)}
@@ -227,66 +193,28 @@ function TopbarCluster({
);
}
function MoreToolsMenu({
function DebugOverlayToggle({
showDebugOverlay,
onToggleDebugOverlay,
}: {
showDebugOverlay?: boolean;
onToggleDebugOverlay?: () => void;
onToggleDebugOverlay: () => void;
}) {
const showDebugItem = isDebugUI() && onToggleDebugOverlay;
const debugLabel = showDebugOverlay ? "Hide Debug Info" : "Show Debug Info";
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 cursor-pointer px-2"
aria-label="More task tools"
>
<IconDots className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>More tools</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs">More tools</DropdownMenuLabel>
{showDebugItem && (
<>
<DropdownMenuItem className="cursor-pointer gap-2" onClick={onToggleDebugOverlay}>
<IconBug className="h-4 w-4 text-muted-foreground" />
<span>{debugLabel}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild className="cursor-pointer gap-2">
<Link href="/settings/general">
<IconSettings className="h-4 w-4 text-muted-foreground" />
<span>Settings</span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function SettingsButton() {
const label = showDebugOverlay ? "Hide Debug Info" : "Show Debug Info";
return (
<Tooltip>
<TooltipTrigger asChild>
<Button asChild size="sm" variant="outline" className="h-8 cursor-pointer px-2">
<Link href="/settings/general" aria-label="Settings">
<IconSettings className="h-4 w-4" />
</Link>
<Button
size="sm"
variant="outline"
className="h-8 cursor-pointer px-2"
onClick={onToggleDebugOverlay}
aria-label={label}
>
<IconBug className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
@@ -341,7 +269,7 @@ function TopbarToolsGroup({
onToggleDebugOverlay?: () => void;
isArchived?: boolean;
}) {
const showDebugMenu = isDebugUI() && onToggleDebugOverlay;
const showDebugToggle = isDebugUI() && onToggleDebugOverlay;
return (
<TopbarCluster label="Task tools" className="[&_button]:h-8 [&_button]:text-xs">
@@ -351,19 +279,19 @@ function TopbarToolsGroup({
<EditorsMenu activeSessionId={activeSessionId ?? null} />
</>
)}
{showDebugMenu ? (
<MoreToolsMenu
{showDebugToggle && (
<DebugOverlayToggle
showDebugOverlay={showDebugOverlay}
onToggleDebugOverlay={onToggleDebugOverlay}
/>
) : (
<SettingsButton />
)}
</TopbarCluster>
);
}
/** Right section: status/attention, tools menu */
/** Right section: status/attention + tools rendered inline.
* The former overflow popover was removed in the UI overhaul — every cluster
* is always visible so users don't have to discover the dots menu. */
function TopBarRight({
taskId,
activeSessionId,
@@ -387,41 +315,15 @@ function TopBarRight({
taskTitle?: string;
officeTaskHref?: string | null;
}) {
const items: TopbarOverflowItem[] = [];
if (officeTaskHref) {
items.push({
id: "office-view",
label: "Open in office view",
priority: 90,
content: (
return (
<div className="flex items-center justify-self-end gap-2 [&_button]:whitespace-nowrap">
{officeTaskHref && (
<TopbarCluster label="Open in office view" className="[&_a]:h-8 [&_a]:text-xs">
<Button asChild size="sm" variant="outline" className="h-8 cursor-pointer px-2">
<Link href={officeTaskHref}>Open in office view</Link>
</Button>
</TopbarCluster>
),
});
}
if (!isArchived && workspaceId) {
items.push({
id: "quick-chat",
label: "Quick chat",
priority: 20,
content: (
<TopbarCluster label="Quick chat" className="[&_button]:h-8 [&_button]:text-xs">
<QuickChatButton workspaceId={workspaceId} />
</TopbarCluster>
),
});
}
items.push({
id: "attention",
label: "Task status and attention",
priority: 80,
content: (
)}
<AttentionStatusGroup
taskId={taskId}
activeSessionId={activeSessionId}
@@ -431,25 +333,13 @@ function TopBarRight({
isAgentctlReady={isAgentctlReady}
taskTitle={taskTitle}
/>
),
});
items.push({
id: "tools",
label: "Task tools",
priority: 10,
content: (
<TopbarToolsGroup
activeSessionId={activeSessionId}
showDebugOverlay={showDebugOverlay}
onToggleDebugOverlay={onToggleDebugOverlay}
isArchived={isArchived}
/>
),
});
return (
<TopbarActionOverflow items={items} className="justify-self-end [&_button]:whitespace-nowrap" />
</div>
);
}
@@ -1,56 +0,0 @@
import { describe, expect, it } from "vitest";
import { getHiddenTopbarActionIds } from "./topbar-action-overflow";
const QUICK_CHAT = "quick-chat";
const ATTENTION = "attention";
const TOOLS = "tools";
const items = [
{ id: QUICK_CHAT, priority: 20 },
{ id: ATTENTION, priority: 80 },
{ id: TOOLS, priority: 10 },
];
const widths = new Map([
[QUICK_CHAT, 88],
[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: 320,
itemWidths: widths,
gap: 8,
overflowTriggerWidth: 40,
}),
).toEqual([TOOLS]);
});
it("hides quick chat before contextual attention actions", () => {
expect(
getHiddenTopbarActionIds({
items,
availableWidth: 230,
itemWidths: widths,
gap: 8,
overflowTriggerWidth: 40,
}),
).toEqual([QUICK_CHAT, TOOLS]);
});
});
@@ -1,253 +0,0 @@
"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;
content: ReactNode;
};
type TopbarOverflowMetricItem = Pick<TopbarOverflowItem, "id" | "priority">;
type HiddenTopbarActionArgs = {
items: TopbarOverflowMetricItem[];
availableWidth: number;
itemWidths: ReadonlyMap<string, number>;
gap: number;
overflowTriggerWidth: number;
fallbackItemWidth?: number;
};
const DEFAULT_FALLBACK_ITEM_WIDTH = 88;
const DEFAULT_OVERFLOW_TRIGGER_WIDTH = 40;
function setsMatch(set: Set<string>, values: string[]) {
if (set.size !== values.length) return false;
return values.every((value) => set.has(value));
}
function itemWidth(
item: TopbarOverflowMetricItem,
widths: ReadonlyMap<string, number>,
fallback: number,
) {
return widths.get(item.id) ?? fallback;
}
function totalActionWidth({
items,
hiddenIds,
itemWidths,
gap,
overflowTriggerWidth,
fallbackItemWidth,
}: {
items: TopbarOverflowMetricItem[];
hiddenIds: Set<string>;
itemWidths: ReadonlyMap<string, number>;
gap: number;
overflowTriggerWidth: number;
fallbackItemWidth: number;
}) {
const visibleItems = items.filter((item) => !hiddenIds.has(item.id));
const visibleWidth = visibleItems.reduce(
(total, item) => total + itemWidth(item, itemWidths, fallbackItemWidth),
0,
);
const controlCount = visibleItems.length + (hiddenIds.size > 0 ? 1 : 0);
const gapWidth = Math.max(0, controlCount - 1) * gap;
return visibleWidth + (hiddenIds.size > 0 ? overflowTriggerWidth : 0) + gapWidth;
}
export function getHiddenTopbarActionIds({
items,
availableWidth,
itemWidths,
gap,
overflowTriggerWidth,
fallbackItemWidth = DEFAULT_FALLBACK_ITEM_WIDTH,
}: HiddenTopbarActionArgs): string[] {
const hiddenIds = new Set<string>();
const hideableItems = [...items].sort((a, b) => a.priority - b.priority);
while (
totalActionWidth({
items,
hiddenIds,
itemWidths,
gap,
overflowTriggerWidth,
fallbackItemWidth,
}) > availableWidth
) {
const nextItem = hideableItems.find((item) => !hiddenIds.has(item.id));
if (!nextItem) break;
hiddenIds.add(nextItem.id);
}
return items.filter((item) => hiddenIds.has(item.id)).map((item) => item.id);
}
function readFlexGap(element: HTMLElement) {
const styles = window.getComputedStyle(element);
const gap = Number.parseFloat(styles.columnGap || styles.gap || "0");
return Number.isFinite(gap) ? gap : 0;
}
type TopbarActionOverflowProps = {
items: TopbarOverflowItem[];
className?: string;
};
function measureVisibleItems(
itemRefs: Map<string, HTMLDivElement>,
itemWidths: Map<string, number>,
) {
for (const [id, element] of itemRefs) {
const width = Math.ceil(element.getBoundingClientRect().width);
if (width > 0) itemWidths.set(id, width);
}
}
function useTopbarOverflowState(items: TopbarOverflowItem[]) {
const containerRef = useRef<HTMLDivElement | null>(null);
const overflowTriggerRef = useRef<HTMLButtonElement | null>(null);
const itemRefs = useRef(new Map<string, HTMLDivElement>());
const itemWidths = useRef(new Map<string, number>());
const [hiddenIds, setHiddenIds] = useState<Set<string>>(() => new Set());
const [measuredWidth, setMeasuredWidth] = useState(0);
const registerItem = useCallback(
(id: string) => (element: HTMLDivElement | null) => {
if (element) {
itemRefs.current.set(id, element);
} else {
itemRefs.current.delete(id);
}
},
[],
);
useLayoutEffect(() => {
const element = containerRef.current;
if (!element) return;
const updateWidth = () => setMeasuredWidth(element.clientWidth);
updateWidth();
const observer = new ResizeObserver(updateWidth);
observer.observe(element);
return () => observer.disconnect();
}, []);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container || container.clientWidth <= 0) return;
measureVisibleItems(itemRefs.current, itemWidths.current);
const overflowWidth =
overflowTriggerRef.current?.getBoundingClientRect().width || DEFAULT_OVERFLOW_TRIGGER_WIDTH;
const nextHiddenIds = getHiddenTopbarActionIds({
items,
availableWidth: container.clientWidth,
itemWidths: itemWidths.current,
gap: readFlexGap(container),
overflowTriggerWidth: overflowWidth,
});
setHiddenIds((current) =>
setsMatch(current, nextHiddenIds) ? current : new Set(nextHiddenIds),
);
}, [items, measuredWidth]);
return { containerRef, overflowTriggerRef, registerItem, hiddenIds };
}
function OverflowTrigger({ triggerRef }: { triggerRef: Ref<HTMLButtonElement> }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
size="sm"
variant="outline"
className="h-8 cursor-pointer px-2"
aria-label="More top bar actions"
data-testid="topbar-action-overflow-trigger"
>
<IconDots className="h-4 w-4" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>More actions</TooltipContent>
</Tooltip>
);
}
function OverflowPopover({
items,
triggerRef,
}: {
items: TopbarOverflowItem[];
triggerRef: Ref<HTMLButtonElement>;
}) {
if (items.length === 0) return null;
return (
<Popover>
<OverflowTrigger triggerRef={triggerRef} />
<PopoverContent
align="end"
className="w-auto max-w-[min(520px,calc(100vw-1rem))] gap-0 rounded-md p-1.5"
>
<div className="flex max-w-[480px] flex-wrap items-center justify-end gap-2">
{items.map((item) => (
<div
key={item.id}
aria-label={item.label}
className="inline-flex shrink-0 items-center [&_button]:h-8 [&_button]:text-xs"
>
{item.content}
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}
export function TopbarActionOverflow({ items, className }: TopbarActionOverflowProps) {
const { containerRef, overflowTriggerRef, registerItem, hiddenIds } =
useTopbarOverflowState(items);
const hiddenItems = items.filter((item) => hiddenIds.has(item.id));
return (
<div
ref={containerRef}
className={cn(
"flex min-w-0 w-full items-center justify-end gap-2 overflow-hidden",
className,
)}
data-testid="topbar-action-overflow"
>
{items.map((item) =>
hiddenIds.has(item.id) ? null : (
<div key={item.id} ref={registerItem(item.id)} className="shrink-0">
{item.content}
</div>
),
)}
<OverflowPopover items={hiddenItems} triggerRef={overflowTriggerRef} />
</div>
);
}
+9 -1
View File
@@ -5,7 +5,15 @@ import { ReactNode } from "react";
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem={true}>
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem={true}
// Suppress per-element color transitions during a theme flip so every
// surface (buttons, panels, backgrounds) switches in the same instant
// instead of each animating at its own `transition-colors` duration.
disableTransitionOnChange
>
{children}
</NextThemesProvider>
);