From b001aa1eb8d472630ce02a00a789c19fd69b0200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Sat, 6 Jun 2026 21:58:39 +0100 Subject: [PATCH] feat(web): unified app sidebar (#1165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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
. [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
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
-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
. * 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. --- apps/web/app/github/github-page-client.tsx | 95 ++-- apps/web/app/gitlab/gitlab-page-client.tsx | 81 ++-- apps/web/app/globals.css | 52 ++ apps/web/app/layout.tsx | 8 +- .../app/office/components/new-task-dialog.tsx | 5 +- .../app/office/components/office-sidebar.tsx | 138 ------ .../sidebar-collapsible-section.tsx | 60 --- .../components/sidebar-nav-item.test.tsx | 40 -- .../office/components/sidebar-nav-item.tsx | 69 --- .../app/office/components/sidebar-section.tsx | 15 - .../app/office/components/workspace-rail.tsx | 136 ------ apps/web/app/office/layout.tsx | 16 +- apps/web/app/tasks/tasks-page-client.tsx | 1 - .../app-sidebar/app-sidebar-constants.ts | 24 + .../app-sidebar/app-sidebar-footer.tsx | 171 +++++++ .../app-sidebar/app-sidebar-header.tsx | 89 ++++ .../app-sidebar/app-sidebar-nav-item.tsx | 126 +++++ .../app-sidebar-new-task-item.test.tsx | 127 +++++ .../app-sidebar/app-sidebar-new-task-item.tsx | 111 +++++ .../app-sidebar/app-sidebar-primary-nav.tsx | 43 ++ .../app-sidebar/app-sidebar-resize-handle.tsx | 25 + .../app-sidebar/app-sidebar-section.tsx | 126 +++++ .../app-sidebar/app-sidebar-settings-mode.tsx | 43 ++ .../app-sidebar-workspace-picker.tsx | 105 ++++ .../app-sidebar/app-sidebar.test.tsx | 112 +++++ .../components/app-sidebar/app-sidebar.tsx | 143 ++++++ .../app-sidebar/sections/agents-section.tsx} | 79 ++- .../sections/integrations-section.tsx | 65 +++ .../sections/projects-section.tsx} | 44 +- .../sections/settings/agents-group.tsx | 49 ++ .../sections/settings/executors-group.tsx | 47 ++ .../sections/settings/general-group.tsx | 42 ++ .../sections/settings/integrations-group.tsx | 52 ++ .../settings/settings-nav-primitives.tsx | 145 ++++++ .../sections/settings/settings-tree.test.ts | 29 ++ .../sections/settings/settings-tree.tsx | 105 ++++ .../sections/settings/system-group.tsx | 57 +++ .../sections/settings/workspaces-group.tsx | 59 +++ .../app-sidebar/sections/tasks-section.tsx | 32 ++ .../sections/tasks-view-picker.tsx | 91 ++++ .../github/my-github/presets-scope-bar.tsx | 44 ++ .../gitlab/my-gitlab/presets-scope-bar.tsx | 44 ++ .../integrations/integrations-menu.tsx | 2 +- .../integrations/presets-scope-bar-base.tsx | 248 ++++++++++ apps/web/components/kanban-board.tsx | 1 - .../components/kanban-display-dropdown.tsx | 48 +- .../kanban/kanban-header-mobile.tsx | 14 +- apps/web/components/kanban/kanban-header.tsx | 227 +-------- .../components/kanban/mobile-menu-sheet.tsx | 106 +--- apps/web/components/page-topbar.tsx | 82 +++- .../settings/keyboard-shortcuts-card.tsx | 72 ++- .../settings/settings-app-sidebar.tsx | 453 ------------------ .../settings/settings-layout-client.tsx | 30 +- .../task/dockview-desktop-layout.tsx | 28 +- .../components/task/dockview-layout-setup.ts | 204 ++++---- .../components/task/dockview-session-tabs.ts | 52 +- apps/web/components/task/dockview-shared.tsx | 19 +- apps/web/components/task/task-item.tsx | 24 +- .../components/task/task-session-sidebar.tsx | 36 +- .../web/components/task/task-top-bar.test.tsx | 10 - apps/web/components/task/task-top-bar.tsx | 166 ++----- .../task/topbar-action-overflow.test.ts | 56 --- .../task/topbar-action-overflow.tsx | 253 ---------- apps/web/components/theme-provider.tsx | 10 +- apps/web/e2e/helpers/regular-mode.ts | 22 + apps/web/e2e/pages/app-sidebar-page.ts | 19 + apps/web/e2e/pages/mobile-github-page.ts | 4 +- apps/web/e2e/pages/session-page.ts | 22 + apps/web/e2e/pages/sidebar-filter-popover.ts | 99 ++-- apps/web/e2e/tests/chat/clarification.spec.ts | 4 + .../e2e/tests/chat/mobile-voice-mode.spec.ts | 9 +- .../cli-mode/create-prompt-enabled.spec.ts | 5 + .../e2e/tests/github/github-scope-bar.spec.ts | 47 ++ apps/web/e2e/tests/improve-kandev.spec.ts | 15 +- .../tests/integrations/jira-import.spec.ts | 4 + .../tests/integrations/linear-import.spec.ts | 4 + .../web/e2e/tests/kanban/kanban-board.spec.ts | 21 +- .../kanban/kanban-topbar-utilities.spec.ts | 21 +- .../e2e/tests/kanban/workflow-filter.spec.ts | 9 +- .../layout/compact-desktop-responsive.spec.ts | 4 + .../layout/pane-resize-edge-cases.spec.ts | 18 +- .../tests/layout/pane-resize-right.spec.ts | 2 +- .../tests/layout/pane-resize-sidebar.spec.ts | 141 ------ .../tests/layout/plan-panel-indicator.spec.ts | 2 +- .../e2e/tests/layout/topbar-alignment.spec.ts | 68 +++ .../tests/office/agent-error-handling.spec.ts | 5 +- .../tests/office/execution-indicator.spec.ts | 25 +- .../e2e/tests/office/live-presence.spec.ts | 25 +- apps/web/e2e/tests/office/onboarding.spec.ts | 7 +- .../tests/office/realtime-dashboard.spec.ts | 17 +- .../tests/office/sidebar-live-agents.spec.ts | 26 +- .../tests/office/sidebar-navigation.spec.ts | 38 +- .../office/sidebar-office-gating.spec.ts | 49 ++ apps/web/e2e/tests/office/task-chat.spec.ts | 38 +- .../web/e2e/tests/office/task-sorting.spec.ts | 24 +- apps/web/e2e/tests/pr/pr-status-badge.spec.ts | 16 +- .../tests/session/multi-session-ux.spec.ts | 26 +- .../e2e/tests/session/session-layout.spec.ts | 18 + .../session/session-tab-management.spec.ts | 8 +- .../tests/settings/keyboard-shortcuts.spec.ts | 16 +- .../tests/settings/settings-gear-only.spec.ts | 67 +++ apps/web/e2e/tests/setup-timeouts.spec.ts | 4 + .../tests/system/sidebar-navigation.spec.ts | 8 +- .../task/create-task-branch-selector.spec.ts | 4 + .../task/create-task-disabled-tooltip.spec.ts | 4 + .../tests/task/create-task-github-url.spec.ts | 4 + ...create-task-url-reopen-no-branches.spec.ts | 16 + apps/web/e2e/tests/task/create-task.spec.ts | 5 + .../web/e2e/tests/task/enhance-prompt.spec.ts | 4 + .../task/local-executor-branch-split.spec.ts | 5 + .../task/mobile-task-list-search.spec.ts | 20 +- .../task/repository-selector-scroll.spec.ts | 5 + .../web/e2e/tests/task/sidebar-filter.spec.ts | 88 ++-- .../e2e/tests/task/sidebar-task-open.spec.ts | 72 +++ apps/web/e2e/tests/task/subtask.spec.ts | 40 +- .../tests/task/task-default-layout.spec.ts | 58 +++ .../e2e/tests/task/task-list-filters.spec.ts | 65 +-- apps/web/e2e/tests/task/task-list.spec.ts | 4 +- ...workspace-switch-sidebar-isolation.spec.ts | 21 +- .../workflow/workflow-agent-profile.spec.ts | 4 + .../workflow/workflow-start-step.spec.ts | 4 + apps/web/hooks/use-editor-keybinds.ts | 5 +- apps/web/hooks/use-in-office.test.ts | 59 +++ apps/web/hooks/use-in-office.ts | 18 + .../lib/keyboard/shortcut-overrides.test.ts | 20 +- apps/web/lib/keyboard/shortcut-overrides.ts | 13 +- apps/web/lib/local-storage-app-sidebar.ts | 47 ++ apps/web/lib/local-storage.ts | 12 +- apps/web/lib/state/dockview-measure.test.ts | 38 ++ apps/web/lib/state/dockview-measure.ts | 31 +- apps/web/lib/state/dockview-store.ts | 50 +- .../web/lib/state/layout-manager/constants.ts | 3 - apps/web/lib/state/layout-manager/index.ts | 1 - .../lib/state/layout-manager/presets.test.ts | 43 +- apps/web/lib/state/layout-manager/presets.ts | 49 +- .../state/slices/ui/app-sidebar-actions.ts | 74 +++ apps/web/lib/state/slices/ui/types.ts | 22 + apps/web/lib/state/slices/ui/ui-slice.test.ts | 86 ++++ apps/web/lib/state/slices/ui/ui-slice.ts | 13 + apps/web/lib/state/store.ts | 6 + 140 files changed, 4420 insertions(+), 2636 deletions(-) delete mode 100644 apps/web/app/office/components/office-sidebar.tsx delete mode 100644 apps/web/app/office/components/sidebar-collapsible-section.tsx delete mode 100644 apps/web/app/office/components/sidebar-nav-item.test.tsx delete mode 100644 apps/web/app/office/components/sidebar-nav-item.tsx delete mode 100644 apps/web/app/office/components/sidebar-section.tsx delete mode 100644 apps/web/app/office/components/workspace-rail.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-constants.ts create mode 100644 apps/web/components/app-sidebar/app-sidebar-footer.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-header.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-nav-item.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-resize-handle.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-section.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-settings-mode.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar.test.tsx create mode 100644 apps/web/components/app-sidebar/app-sidebar.tsx rename apps/web/{app/office/components/sidebar-agents-list.tsx => components/app-sidebar/sections/agents-section.tsx} (57%) create mode 100644 apps/web/components/app-sidebar/sections/integrations-section.tsx rename apps/web/{app/office/components/sidebar-projects-list.tsx => components/app-sidebar/sections/projects-section.tsx} (53%) create mode 100644 apps/web/components/app-sidebar/sections/settings/agents-group.tsx create mode 100644 apps/web/components/app-sidebar/sections/settings/executors-group.tsx create mode 100644 apps/web/components/app-sidebar/sections/settings/general-group.tsx create mode 100644 apps/web/components/app-sidebar/sections/settings/integrations-group.tsx create mode 100644 apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx create mode 100644 apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts create mode 100644 apps/web/components/app-sidebar/sections/settings/settings-tree.tsx create mode 100644 apps/web/components/app-sidebar/sections/settings/system-group.tsx create mode 100644 apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx create mode 100644 apps/web/components/app-sidebar/sections/tasks-section.tsx create mode 100644 apps/web/components/app-sidebar/sections/tasks-view-picker.tsx create mode 100644 apps/web/components/github/my-github/presets-scope-bar.tsx create mode 100644 apps/web/components/gitlab/my-gitlab/presets-scope-bar.tsx create mode 100644 apps/web/components/integrations/presets-scope-bar-base.tsx delete mode 100644 apps/web/components/settings/settings-app-sidebar.tsx delete mode 100644 apps/web/components/task/topbar-action-overflow.test.ts delete mode 100644 apps/web/components/task/topbar-action-overflow.tsx create mode 100644 apps/web/e2e/helpers/regular-mode.ts create mode 100644 apps/web/e2e/pages/app-sidebar-page.ts create mode 100644 apps/web/e2e/tests/github/github-scope-bar.spec.ts delete mode 100644 apps/web/e2e/tests/layout/pane-resize-sidebar.spec.ts create mode 100644 apps/web/e2e/tests/layout/topbar-alignment.spec.ts create mode 100644 apps/web/e2e/tests/office/sidebar-office-gating.spec.ts create mode 100644 apps/web/e2e/tests/settings/settings-gear-only.spec.ts create mode 100644 apps/web/e2e/tests/task/sidebar-task-open.spec.ts create mode 100644 apps/web/e2e/tests/task/task-default-layout.spec.ts create mode 100644 apps/web/hooks/use-in-office.test.ts create mode 100644 apps/web/hooks/use-in-office.ts create mode 100644 apps/web/lib/local-storage-app-sidebar.ts create mode 100644 apps/web/lib/state/dockview-measure.test.ts create mode 100644 apps/web/lib/state/slices/ui/app-sidebar-actions.ts diff --git a/apps/web/app/github/github-page-client.tsx b/apps/web/app/github/github-page-client.tsx index 5065c89af8..df30c96037 100644 --- a/apps/web/app/github/github-page-client.tsx +++ b/apps/web/app/github/github-page-client.tsx @@ -17,6 +17,7 @@ import { PresetsSidebar, type SidebarSelection, } from "@/components/github/my-github/presets-sidebar"; +import { PresetsScopeBar } from "@/components/github/my-github/presets-scope-bar"; import { PR_PRESETS, ISSUE_PRESETS, @@ -310,58 +311,52 @@ function AuthenticatedLayout({ const prKeyToTasks = usePRKeyToTasks(workspaceId ?? null); useAllWorkflowSnapshots(workspaceId ?? null); return ( -
- -
- + + + {!workspaceId && } +
+ - {!workspaceId && } -
- -
- -
-
+ + +
); } diff --git a/apps/web/app/gitlab/gitlab-page-client.tsx b/apps/web/app/gitlab/gitlab-page-client.tsx index 7cc177847c..a97734e608 100644 --- a/apps/web/app/gitlab/gitlab-page-client.tsx +++ b/apps/web/app/gitlab/gitlab-page-client.tsx @@ -15,6 +15,7 @@ import { PresetsSidebar, type SidebarSelection, } from "@/components/gitlab/my-gitlab/presets-sidebar"; +import { PresetsScopeBar } from "@/components/gitlab/my-gitlab/presets-scope-bar"; import { MR_PRESETS, ISSUE_PRESETS } from "@/components/gitlab/my-gitlab/presets"; import { useGitLabSearch } from "@/components/gitlab/my-gitlab/use-gitlab-search"; import { useSavedPresets, type SavedPreset } from "@/components/gitlab/my-gitlab/use-saved-presets"; @@ -230,51 +231,45 @@ function AuthenticatedLayout({ state }: { state: GitLabPageState }) { // later pages that may contain more matches. const displayedCount = state.projectFilter ? search.items.length : search.total; return ( -
- -
- + + +
+ -
- -
- -
-
+ + +
); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index f9395fd408..963961a744 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1484,3 +1484,55 @@ .animate-queue-close { animation: queue-panel-close 180ms cubic-bezier(0.32, 0.72, 0, 1); } + +/* Unified AppSidebar — header/text content fades in when the rail expands + * from its collapsed (icon-only) state. */ +@keyframes app-sidebar-fade-in { + from { + opacity: 0; + transform: translateX(-4px); + } + to { + opacity: 1; + transform: translateX(0); + } +} +.sidebar-fade-in { + animation: app-sidebar-fade-in 200ms ease-out; +} + +/* AppSidebar section expand/collapse — a quick height animation that pushes + * sibling sections, replacing the old opacity fade. Driven by the Radix + * Collapsible `data-state` + content-height CSS var. */ +@keyframes sidebar-section-expand { + from { + height: 0; + } + to { + height: var(--radix-collapsible-content-height); + } +} +@keyframes sidebar-section-collapse { + from { + height: var(--radix-collapsible-content-height); + } + to { + height: 0; + } +} +.sidebar-section-content { + overflow: hidden; +} +.sidebar-section-content[data-state="open"] { + animation: sidebar-section-expand 180ms cubic-bezier(0.32, 0.72, 0, 1); +} +.sidebar-section-content[data-state="closed"] { + animation: sidebar-section-collapse 160ms cubic-bezier(0.32, 0.72, 0, 1); +} +@media (prefers-reduced-motion: reduce) { + .sidebar-fade-in, + .sidebar-section-content[data-state="open"], + .sidebar-section-content[data-state="closed"] { + animation: none; + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 5ae28be09a..d2de6c1cb7 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -11,6 +11,7 @@ import { CommandPanel } from "@/components/command-panel"; import { GlobalCommands } from "@/components/global-commands"; import { RecentTaskSwitcher } from "@/components/task/recent-task-switcher"; import { DiffWorkerPoolProvider } from "@/components/diff-worker-pool-provider"; +import { AppSidebar } from "@/components/app-sidebar/app-sidebar"; import { QuickChatProvider } from "@/components/quick-chat/quick-chat-provider"; import { ConfigChatProvider } from "@/components/config-chat/config-chat-provider"; import { SessionFailureToastBridge } from "@/components/session-failure-toast-bridge"; @@ -94,7 +95,12 @@ export default async function RootLayout({ - {children} + +
+ +
{children}
+
+
diff --git a/apps/web/app/office/components/new-task-dialog.tsx b/apps/web/app/office/components/new-task-dialog.tsx index 60a8ba5368..a9497ce157 100644 --- a/apps/web/app/office/components/new-task-dialog.tsx +++ b/apps/web/app/office/components/new-task-dialog.tsx @@ -89,7 +89,10 @@ export function NewTaskDialog({ return ( - + s.workspaces); - const inboxCount = useAppStore((s) => s.office.inboxCount); - const totalLiveSessions = useAppStore(selectTotalLiveSessions); - const dashboard = useAppStore((s) => s.office.dashboard); - const taskCount = dashboard?.task_count ?? 0; - const skillCount = dashboard?.skill_count ?? 0; - const routineCount = dashboard?.routine_count ?? 0; - const [newTaskOpen, setNewTaskOpen] = useState(false); - - // Use store if hydrated, fall back to SSR prop - const activeWorkspace = workspaces.items.find((w) => w.id === workspaces.activeId); - const workspaceName = activeWorkspace?.name || ssrName || "Workspace"; - - return ( - - ); -} diff --git a/apps/web/app/office/components/sidebar-collapsible-section.tsx b/apps/web/app/office/components/sidebar-collapsible-section.tsx deleted file mode 100644 index 9ce62a628b..0000000000 --- a/apps/web/app/office/components/sidebar-collapsible-section.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { IconChevronRight, IconPlus } from "@tabler/icons-react"; -import { Button } from "@kandev/ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { cn } from "@/lib/utils"; - -type SidebarCollapsibleSectionProps = { - label: string; - children: React.ReactNode; - onAdd?: () => void; - defaultOpen?: boolean; -}; - -export function SidebarCollapsibleSection({ - label, - children, - onAdd, - defaultOpen = true, -}: SidebarCollapsibleSectionProps) { - const [open, setOpen] = useState(defaultOpen); - - return ( - -
- - - - {label} - - - {onAdd && ( - - - - - Add {label.toLowerCase()} - - )} -
- -
{children}
-
-
- ); -} diff --git a/apps/web/app/office/components/sidebar-nav-item.test.tsx b/apps/web/app/office/components/sidebar-nav-item.test.tsx deleted file mode 100644 index 3422de0bc0..0000000000 --- a/apps/web/app/office/components/sidebar-nav-item.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect, afterEach, vi } from "vitest"; -import { render, screen, cleanup } from "@testing-library/react"; -import { IconLayoutDashboard } from "@tabler/icons-react"; - -// next/navigation pathname stub — not core to these assertions. -vi.mock("next/navigation", () => ({ - usePathname: () => "/office/other", -})); - -import { SidebarNavItem } from "./sidebar-nav-item"; - -afterEach(() => cleanup()); - -describe("SidebarNavItem live badge", () => { - it("does not render the live badge when liveCount is 0", () => { - render( - , - ); - expect(screen.queryByText(/live/)).toBeNull(); - }); - - it("does not render the live badge when liveCount is undefined", () => { - render(); - expect(screen.queryByText(/live/)).toBeNull(); - }); - - it("renders '1 live' (LiveAgentIndicator) when liveCount is 1", () => { - render( - , - ); - expect(screen.getByText("1 live")).toBeTruthy(); - }); - - it("renders '4 live' when liveCount is 4", () => { - render( - , - ); - expect(screen.getByText("4 live")).toBeTruthy(); - }); -}); diff --git a/apps/web/app/office/components/sidebar-nav-item.tsx b/apps/web/app/office/components/sidebar-nav-item.tsx deleted file mode 100644 index ee23bbe4fb..0000000000 --- a/apps/web/app/office/components/sidebar-nav-item.tsx +++ /dev/null @@ -1,69 +0,0 @@ -"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 { cn } from "@/lib/utils"; -import { LiveAgentIndicator } from "../agents/components/live-agent-indicator"; - -type SidebarNavItemProps = { - icon: TablerIcon; - label: string; - href: string; - badge?: number; - liveCount?: number; - onClick?: () => void; -}; - -export function SidebarNavItem({ - icon: Icon, - label, - href, - badge, - liveCount, - onClick, -}: SidebarNavItemProps) { - const pathname = usePathname(); - const isActive = pathname === href || (href !== "/office" && pathname.startsWith(href + "/")); - - const content = ( - <> - - {label} - {typeof liveCount === "number" && liveCount > 0 && } - {typeof badge === "number" && badge > 0 && ( - - {badge} - - )} - - ); - - if (onClick) { - return ( - - ); - } - - return ( - - {content} - - ); -} diff --git a/apps/web/app/office/components/sidebar-section.tsx b/apps/web/app/office/components/sidebar-section.tsx deleted file mode 100644 index c19ceae2d0..0000000000 --- a/apps/web/app/office/components/sidebar-section.tsx +++ /dev/null @@ -1,15 +0,0 @@ -type SidebarSectionProps = { - label: string; - children: React.ReactNode; -}; - -export function SidebarSection({ label, children }: SidebarSectionProps) { - return ( -
-
- {label} -
-
{children}
-
- ); -} diff --git a/apps/web/app/office/components/workspace-rail.tsx b/apps/web/app/office/components/workspace-rail.tsx deleted file mode 100644 index fd8a0f6efc..0000000000 --- a/apps/web/app/office/components/workspace-rail.tsx +++ /dev/null @@ -1,136 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useCallback } from "react"; -import { IconPlus, IconArrowLeft } from "@tabler/icons-react"; -import Link from "next/link"; -import { Button } from "@kandev/ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { useAppStore } from "@/components/state-provider"; - -const GRADIENTS = [ - "linear-gradient(135deg, #6366f1, #8b5cf6)", - "linear-gradient(135deg, #3b82f6, #06b6d4)", - "linear-gradient(135deg, #10b981, #06b6d4)", - "linear-gradient(135deg, #f59e0b, #ef4444)", - "linear-gradient(135deg, #ec4899, #8b5cf6)", - "linear-gradient(135deg, #14b8a6, #3b82f6)", - "linear-gradient(135deg, #f97316, #facc15)", - "linear-gradient(135deg, #84cc16, #10b981)", -]; - -function getWorkspaceGradient(id: string): string { - let hash = 0; - for (let i = 0; i < id.length; i++) { - hash = (hash * 31 + id.charCodeAt(i)) >>> 0; - } - return GRADIENTS[hash % GRADIENTS.length]; -} - -function getInitials(name: string): string { - const words = (name || "W").trim().split(/\s+/); - if (words.length >= 2) { - return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase(); - } - return words[0].charAt(0).toUpperCase(); -} - -interface WorkspaceItem { - id: string; - name: string; -} - -interface WorkspaceRailProps { - workspaces: WorkspaceItem[]; - activeWorkspaceId: string | null; -} - -export function WorkspaceRail({ - workspaces: ssrWorkspaces, - activeWorkspaceId: ssrActiveId, -}: WorkspaceRailProps) { - const router = useRouter(); - const storeWorkspaces = useAppStore((s) => s.workspaces); - const setActiveWorkspace = useAppStore((s) => s.setActiveWorkspace); - - // Use store if hydrated, fall back to SSR props - const items = storeWorkspaces.items.length > 0 ? storeWorkspaces.items : ssrWorkspaces; - const activeId = storeWorkspaces.activeId ?? ssrActiveId; - - const handleSelect = useCallback( - (id: string) => { - document.cookie = `office-active-workspace=${id}; path=/; max-age=86400; samesite=strict; secure`; - setActiveWorkspace(id); - router.push(`/office?workspaceId=${id}`); - }, - [setActiveWorkspace, router], - ); - - return ( -
- {/* Back to homepage */} - - - - - - - Back to board - - - {/* Workspace avatars + add button */} -
- {items.map((ws) => { - const isActive = ws.id === activeId; - const initials = getInitials(ws.name); - const gradient = getWorkspaceGradient(ws.id); - return ( - - -
- {/* Left edge indicator pill */} -
- -
- - {ws.name} - - ); - })} - - {/* Add workspace button */} - - - - - Add workspace - -
-
- ); -} diff --git a/apps/web/app/office/layout.tsx b/apps/web/app/office/layout.tsx index f618a2b28a..0f1f1ec057 100644 --- a/apps/web/app/office/layout.tsx +++ b/apps/web/app/office/layout.tsx @@ -13,8 +13,6 @@ import { } from "@/lib/api/domains/office-api"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import type { AppState } from "@/lib/state/store"; -import { WorkspaceRail } from "./components/workspace-rail"; -import { OfficeSidebar } from "./components/office-sidebar"; import { OfficeTopbar } from "./components/office-topbar"; function resolveActiveOfficeWorkspaceId( @@ -163,17 +161,9 @@ export default async function OfficeLayout({ children }: { children: React.React return ( -
- - w.id === activeWorkspaceId)?.name || "Workspace" - } - /> -
- -
{children}
-
+
+ +
{children}
); diff --git a/apps/web/app/tasks/tasks-page-client.tsx b/apps/web/app/tasks/tasks-page-client.tsx index fe02b910e1..07898a9ca1 100644 --- a/apps/web/app/tasks/tasks-page-client.tsx +++ b/apps/web/app/tasks/tasks-page-client.tsx @@ -423,7 +423,6 @@ export function TasksPageClient(props: TasksPageClientProps) { return (
s.setCreateDialogOpen(true)} workspaceId={s.activeWorkspaceId ?? undefined} currentPage="tasks" searchQuery={s.searchQuery} diff --git a/apps/web/components/app-sidebar/app-sidebar-constants.ts b/apps/web/components/app-sidebar/app-sidebar-constants.ts new file mode 100644 index 0000000000..6f904b241d --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-constants.ts @@ -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"; diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.tsx new file mode 100644 index 0000000000..84870e4532 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-footer.tsx @@ -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 = ( + <> + + {badge && ( + + )} + + ); + + const trigger = href ? ( + + ) : ( + + ); + + return ( + + {trigger} + {label} + + ); +} + +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 ( +
+ + + setImproveOpen(true)} + testId="sidebar-improve-kandev-button" + /> + {releaseNotes.showTopbarButton && ( + + )} + {officeEnabled && ( + + )} + + router.push(linkToTask(task.id))} + /> + {releaseNotes.hasNotes && ( + + )} +
+ ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-header.tsx b/apps/web/components/app-sidebar/app-sidebar-header.tsx new file mode 100644 index 0000000000..9c832fc9c6 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-header.tsx @@ -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 ( +
+ + + + K + + + Kandev + + + + + + Expand sidebar + +
+ ); + } + + // 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 ( +
+ + Kandev + + + / + + + + + + + Collapse sidebar + +
+ ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx new file mode 100644 index 0000000000..491e117f6a --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx @@ -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 ( + + ); + } + if (disabled) { + return ( + + {inner} + + ); + } + return ( + + {inner} + + ); +} + +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 = ( + <> + + {!collapsed && ( + <> + {label} + {typeof badge === "number" && badge > 0 && ( + + {badge} + + )} + + )} + + ); + + const buttonOrLink = renderTrigger({ onClick, disabled, baseClass, label, href, inner, testId }); + + if (!collapsed) return buttonOrLink; + return ( + + {buttonOrLink} + + {label} + {typeof badge === "number" && badge > 0 ? ` (${badge})` : ""} + + + ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx b/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx new file mode 100644 index 0000000000..9a86220687 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx @@ -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( + + + , + ); +} + +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: () =>
, +})); +vi.mock("@/components/task-create-dialog", () => ({ + TaskCreateDialog: () =>
, +})); +vi.mock("@/components/task/new-subtask-dialog", () => ({ + NewSubtaskDialog: () =>
, +})); + +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(); + }); +}); diff --git a/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx b/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx new file mode 100644 index 0000000000..0fa5fbf480 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx @@ -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 ( + <> +
+ setOpen(true)} + collapsed={collapsed} + disabled={!workspaceId} + testId="create-task-button" + /> + {canCreateSubtask && ( + + + + + New subtask of current task + + )} +
+ {workspaceId && + (inOffice ? ( + + ) : ( + setOpen(false)} + /> + ))} + {canCreateSubtask && ( + + )} + + ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx new file mode 100644 index 0000000000..50f0528f14 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx @@ -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 ( +
+ + {inOffice && ( + + )} + {workspaceId && ( + + )} + +
+ ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-resize-handle.tsx b/apps/web/components/app-sidebar/app-sidebar-resize-handle.tsx new file mode 100644 index 0000000000..28d6388f83 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-resize-handle.tsx @@ -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 ( + + ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-section.tsx b/apps/web/components/app-sidebar/app-sidebar-section.tsx new file mode 100644 index 0000000000..5dae3a2d88 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-section.tsx @@ -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 ( +
+ + {expanded && headerAction && ( +
{headerAction}
+ )} + +
+ ); +} + +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 ( + + + + + {label} + + ); + } + + 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 ( +
+ + {expanded && ( +
{children}
+ )} +
+ ); + } + + return ( + + + +
{children}
+
+
+ ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-settings-mode.tsx b/apps/web/components/app-sidebar/app-sidebar-settings-mode.tsx new file mode 100644 index 0000000000..6c50be230e --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-settings-mode.tsx @@ -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 ( +
+ +
+ +
+
+ ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx new file mode 100644 index 0000000000..e193cd25be --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx @@ -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 ( + + ); +}); + +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 ( + + + + + + {workspaces.items.length === 0 ? ( + No workspaces + ) : ( + workspaces.items.map((ws) => ( + handleSelect(ws.id)} + className="cursor-pointer gap-2" + > + {ws.name} + {ws.id === activeId && } + + )) + )} + + { + router.push("/office/setup?mode=new"); + }} + > + + Add workspace + + + + ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar.test.tsx b/apps/web/components/app-sidebar/app-sidebar.test.tsx new file mode 100644 index 0000000000..f897d5fdbf --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar.test.tsx @@ -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; + }) => ( + + ), +})); + +vi.mock("./app-sidebar-primary-nav", () => ({ + AppSidebarPrimaryNav: () =>
, +})); + +vi.mock("./sections/tasks-section", () => ({ + TasksSection: ({ collapsed }: { collapsed: boolean }) => ( +
+ tasks +
+ ), +})); +vi.mock("./sections/projects-section", () => ({ + ProjectsSection: () =>
, +})); +vi.mock("./sections/agents-section", () => ({ + AgentsSection: () =>
, +})); +vi.mock("./sections/integrations-section", () => ({ + IntegrationsSection: () =>
, +})); +vi.mock("./app-sidebar-footer", () => ({ + AppSidebarFooter: () =>
, +})); + +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(); + 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(); + 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(); + fireEvent.click(screen.getByTestId("header-toggle")); + expect(storeState.toggleAppSidebar).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/components/app-sidebar/app-sidebar.tsx b/apps/web/components/app-sidebar/app-sidebar.tsx new file mode 100644 index 0000000000..921022e3b4 --- /dev/null +++ b/apps/web/components/app-sidebar/app-sidebar.tsx @@ -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 ( + + ); +} diff --git a/apps/web/app/office/components/sidebar-agents-list.tsx b/apps/web/components/app-sidebar/sections/agents-section.tsx similarity index 57% rename from apps/web/app/office/components/sidebar-agents-list.tsx rename to apps/web/components/app-sidebar/sections/agents-section.tsx index 30eabf2f48..bb20e072f7 100644 --- a/apps/web/app/office/components/sidebar-agents-list.tsx +++ b/apps/web/components/app-sidebar/sections/agents-section.tsx @@ -3,32 +3,42 @@ 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 { SidebarCollapsibleSection } from "./sidebar-collapsible-section"; -import { AgentAvatar } from "./agent-avatar"; -import { AgentStatusDot } from "../agents/components/agent-status-dot"; -import { LiveAgentIndicator } from "../agents/components/live-agent-indicator"; +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"; -export function SidebarAgentsList() { +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); - // Refetch agents on mount and on WS "agents" events. This ensures the - // sidebar (and any page that reads agentProfiles from the store, such - // as the org chart and agent detail layout) recovers from stale SSR - // hydration without waiting for a user action or WS event to arrive. const refetchAgents = useCallback(async () => { - if (!workspaceId) return; + if (!workspaceId || !inOffice) return; const res = await listAgentProfiles(workspaceId).catch(() => ({ agents: [] })); setOfficeAgentProfiles(res.agents ?? []); - }, [workspaceId, setOfficeAgentProfiles]); + }, [workspaceId, inOffice, setOfficeAgentProfiles]); useEffect(() => { refetchAgents(); @@ -36,23 +46,43 @@ export function SidebarAgentsList() { useOfficeRefetch("agents", refetchAgents); + if (!inOffice) return null; + + const headerAction = ( + + + + + Add agent + + ); + return ( - router.push("/office/agents")}> + {agents.length === 0 ? (

No agents yet

) : ( - agents.map((agent) => ) + agents.map((agent) => ) )} -
+ ); } -// Row is its own component so each one can subscribe to the live-session -// count selector independently. For typical office workspaces (under ~20 -// agents) the per-row subscription is cheap; if the list ever grows much -// larger, refactor into a single bulk selector that returns a map keyed -// by agent id. -function SidebarAgentRow({ agent }: { agent: AgentProfile }) { +function AgentRow({ agent }: { agent: AgentProfile }) { const pathname = usePathname(); const href = `/office/agents/${agent.id}`; const isActive = pathname === href; @@ -71,8 +101,8 @@ function SidebarAgentRow({ agent }: { agent: AgentProfile }) { @@ -87,10 +117,7 @@ function SidebarAgentRow({ agent }: { agent: AgentProfile }) { ) : null} {!isAutoPaused && errorCount > 0 ? ( - + {errorCount} error{errorCount === 1 ? "" : "s"} ) : null} diff --git a/apps/web/components/app-sidebar/sections/integrations-section.tsx b/apps/web/components/app-sidebar/sections/integrations-section.tsx new file mode 100644 index 0000000000..f514a6d639 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/integrations-section.tsx @@ -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 = { + 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 ( + + {links.map(({ id, label, href }) => { + const Icon = INTEGRATION_ICONS[id] ?? IconPlugConnected; + const isActive = pathname === href || pathname.startsWith(`${href}/`); + return ( + + + {label} + + ); + })} + + ); +} diff --git a/apps/web/app/office/components/sidebar-projects-list.tsx b/apps/web/components/app-sidebar/sections/projects-section.tsx similarity index 53% rename from apps/web/app/office/components/sidebar-projects-list.tsx rename to apps/web/components/app-sidebar/sections/projects-section.tsx index f006933011..302fcf1ced 100644 --- a/apps/web/app/office/components/sidebar-projects-list.tsx +++ b/apps/web/components/app-sidebar/sections/projects-section.tsx @@ -1,18 +1,52 @@ "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 { SidebarCollapsibleSection } from "./sidebar-collapsible-section"; +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"; -export function SidebarProjectsList() { +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 = ( + + + + + Add project + + ); + return ( - router.push("/office/projects")}> + {activeProjects.length === 0 ? (

No projects yet

) : ( @@ -24,7 +58,7 @@ export function SidebarProjectsList() { type="button" onClick={() => router.push(`/office/projects/${project.id}`)} className={cn( - "flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium rounded-md", + "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", )} @@ -46,6 +80,6 @@ export function SidebarProjectsList() { ); }) )} -
+ ); } diff --git a/apps/web/components/app-sidebar/sections/settings/agents-group.tsx b/apps/web/components/app-sidebar/sections/settings/agents-group.tsx new file mode 100644 index 0000000000..a5a3fbb293 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/agents-group.tsx @@ -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 ( + + {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 ( + } + isActive={pathname === profilePath} + depth={1} + /> + ); + }), + )} + + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/executors-group.tsx b/apps/web/components/app-sidebar/sections/settings/executors-group.tsx new file mode 100644 index 0000000000..9e33565843 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/executors-group.tsx @@ -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 ( + + {allProfiles.map((profile) => { + const Icon = getExecutorIcon(profile.executorType); + const profilePath = `${ROOT_HREF}/${profile.id}`; + return ( + + ); + })} + + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/general-group.tsx b/apps/web/components/app-sidebar/sections/settings/general-group.tsx new file mode 100644 index 0000000000..ac0bad8e37 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/general-group.tsx @@ -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 ( + + + + + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/integrations-group.tsx b/apps/web/components/app-sidebar/sections/settings/integrations-group.tsx new file mode 100644 index 0000000000..08a4e5afb4 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/integrations-group.tsx @@ -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 ( + + {ITEMS.map(({ href, label, icon }) => ( + + ))} + + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx new file mode 100644 index 0000000000..e685bd2ece --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx @@ -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 ( + + {leadingIcon ?? (Icon && )} + {label} + + ); +} + +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 && } + {label} + + ); + + return ( + +
+ {href ? ( + + {labelInner} + + ) : ( + + )} + +
+ +
{children}
+
+
+ ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts b/apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts new file mode 100644 index 0000000000..3571953c8d --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree.test.ts @@ -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(); + }); +}); diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx new file mode 100644 index 0000000000..621c109620 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx @@ -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(() => 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 ( + <> + + + + + + + + + {/* Editors lives under General (see GeneralGroup) — no duplicate top-level leaf. */} + + + + + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/system-group.tsx b/apps/web/components/app-sidebar/sections/settings/system-group.tsx new file mode 100644 index 0000000000..153d35b5bc --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/system-group.tsx @@ -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 ( + + {ITEMS.map(({ href, label, icon }) => ( + + ))} + + ); +} diff --git a/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx new file mode 100644 index 0000000000..157d763c1b --- /dev/null +++ b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx @@ -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 ( + + {workspaces.map((workspace) => { + const workspacePath = `${ROOT_HREF}/${workspace.id}`; + const repositoriesPath = `${workspacePath}/repositories`; + const workflowsPath = `${workspacePath}/workflows`; + return ( + + + + + ); + })} + + ); +} diff --git a/apps/web/components/app-sidebar/sections/tasks-section.tsx b/apps/web/components/app-sidebar/sections/tasks-section.tsx new file mode 100644 index 0000000000..a501dc348c --- /dev/null +++ b/apps/web/components/app-sidebar/sections/tasks-section.tsx @@ -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 ( + } + grow + > +
+ +
+
+ ); +} diff --git a/apps/web/components/app-sidebar/sections/tasks-view-picker.tsx b/apps/web/components/app-sidebar/sections/tasks-view-picker.tsx new file mode 100644 index 0000000000..4252014d76 --- /dev/null +++ b/apps/web/components/app-sidebar/sections/tasks-view-picker.tsx @@ -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 ( +
+ + + + + + {views.map((view) => { + const isActive = view.id === activeViewId; + return ( + 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" + > + + {view.name} + + ); + })} + + + + + {hasDraft && ( + + )} + + } + /> +
+ ); +} diff --git a/apps/web/components/github/my-github/presets-scope-bar.tsx b/apps/web/components/github/my-github/presets-scope-bar.tsx new file mode 100644 index 0000000000..0e130987e7 --- /dev/null +++ b/apps/web/components/github/my-github/presets-scope-bar.tsx @@ -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 ( + (kind === "pr" ? prPresets : issuePresets)} + /> + ); +} diff --git a/apps/web/components/gitlab/my-gitlab/presets-scope-bar.tsx b/apps/web/components/gitlab/my-gitlab/presets-scope-bar.tsx new file mode 100644 index 0000000000..4a87c12141 --- /dev/null +++ b/apps/web/components/gitlab/my-gitlab/presets-scope-bar.tsx @@ -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 ( + (kind === "mr" ? mrPresets : issuePresets)} + /> + ); +} diff --git a/apps/web/components/integrations/integrations-menu.tsx b/apps/web/components/integrations/integrations-menu.tsx index fa8d7be713..5ca0a2eaae 100644 --- a/apps/web/components/integrations/integrations-menu.tsx +++ b/apps/web/components/integrations/integrations-menu.tsx @@ -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(); diff --git a/apps/web/components/integrations/presets-scope-bar-base.tsx b/apps/web/components/integrations/presets-scope-bar-base.tsx new file mode 100644 index 0000000000..dc166ac5b1 --- /dev/null +++ b/apps/web/components/integrations/presets-scope-bar-base.tsx @@ -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 = { + kind: K; + source: "preset" | "saved"; + id: string; +}; + +export type ScopeSavedPreset = { + 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
; +} + +function KindSegment({ + kinds, + active, + onChange, +}: { + kinds: ReadonlyArray<{ value: K; label: string }>; + active: K; + onChange: (k: K) => void; +}) { + return ( +
+ {kinds.map(({ value, label }) => ( + + ))} +
+ ); +} + +function PresetPill({ + label, + Icon, + active, + onClick, +}: { + label: string; + Icon: Icon; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function SavedMenu({ + testId, + selected, + saved, + onSelect, + onDeleteSaved, + canSaveCurrent, + onSaveCurrent, +}: { + testId: string; + selected: ScopeSelection; + saved: ScopeSavedPreset[]; + onSelect: (s: ScopeSelection) => 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 ( + + + + + + {saved.length === 0 ? ( + No saved queries yet + ) : ( + saved.map((s) => ( + onSelect({ kind: s.kind, source: "saved", id: s.id })} + className="group/saved cursor-pointer gap-2" + > + + {s.label} + + + )) + )} + + + + Save current query + + + + ); +} + +export type IntegrationScopeBarProps = { + className?: string; + testId: string; + savedMenuTestId: string; + kinds: ReadonlyArray<{ value: K; label: string }>; + selected: ScopeSelection; + onSelect: (s: ScopeSelection) => void; + presetsByKind: (kind: K) => ScopePreset[]; + savedPresets: ScopeSavedPreset[]; + onDeleteSaved: (id: string) => void; + canSaveCurrent: boolean; + onSaveCurrent: () => void; +}; + +export function IntegrationScopeBar({ + className, + testId, + savedMenuTestId, + kinds, + selected, + onSelect, + presetsByKind, + savedPresets, + onDeleteSaved, + canSaveCurrent, + onSaveCurrent, +}: IntegrationScopeBarProps) { + 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) => ( + onSelect({ kind: selected.kind, source: "preset", id: p.value })} + /> + ); + + return ( +
+ + + {inbox.map(renderPill)} + {inbox.length > 0 && created.length > 0 && } + {created.map(renderPill)} +
+ +
+
+ ); +} diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 528568e857..038188329a 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -348,7 +348,6 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB
void; -}) { - return ( -
- Workspace - -
- ); -} - 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
- - 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({ diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index bfc9104131..5ed045331c 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -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["size"] }) { - return ( - - - - - Settings - - ); -} - -function ImproveKandevTopbarButton({ - workspaceId, - buttonSize = "icon-lg", -}: { - workspaceId: string | undefined; - buttonSize?: ComponentProps["size"]; -}) { - const router = useRouter(); - const [open, setOpen] = useState(false); - return ( - <> - - - - - Improve Kandev - - router.push(linkToTask(task.id))} - /> - - ); -} - -function StatsTopbarButton() { - return ( - - - - - Stats - - ); -} - -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 ( - - - - - Office - - ); -} - -function HomeLeftActions({ workspaceId }: { workspaceId?: string }) { - return ( - <> - - - - - - ); -} - -function WorkspaceLeftActions({ workspaceId }: { workspaceId?: string }) { - return ( - <> - - - - - ); -} +// 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): 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({ - ) : ( - - ) - } actionsClassName="gap-2" actions={ <> @@ -298,16 +169,6 @@ function TabletHeader({ className="hidden md:flex w-48 lg:w-56 [&_input]:h-8" /> )} - - @@ -332,39 +193,7 @@ function TabletHeader({ ); } -function CreateTaskTopbarButton({ - onCreateTask, - compact, -}: { - onCreateTask: () => void; - compact: boolean; -}) { - const button = ( - - ); - - if (!compact) return button; - - return ( - - {button} - Add task - - ); -} - 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({
{searchInput}
) : null; const actionsSearch = !isHome || isNarrow ? searchInput : null; - const leftActions = isHome ? ( - - ) : ( - - ); return ( {actionsSearch} - - @@ -439,14 +253,6 @@ function DesktopHeader({ onClick={onOpenHealthDialog} size="icon-lg" /> - {showReleaseNotesButton && ( - - )} - } /> @@ -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 ( <> ); } return ( ); }; diff --git a/apps/web/components/kanban/mobile-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index f6de0b891f..4c197516e3 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -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) { return ( <> -
- - -
-