-
-
-
-
-
-
-
-
@@ -199,8 +167,6 @@ function TopBarLeft({
-
-
{!isArchived && showExecutorSettings && (
)}
@@ -227,66 +193,28 @@ function TopbarCluster({
);
}
-function MoreToolsMenu({
+function DebugOverlayToggle({
showDebugOverlay,
onToggleDebugOverlay,
}: {
showDebugOverlay?: boolean;
- onToggleDebugOverlay?: () => void;
+ onToggleDebugOverlay: () => void;
}) {
- const showDebugItem = DEBUG_UI && onToggleDebugOverlay;
- const debugLabel = showDebugOverlay ? "Hide Debug Info" : "Show Debug Info";
-
- return (
-
-
-
-
-
-
-
-
-
- More tools
-
-
- More tools
- {showDebugItem && (
- <>
-
-
- {debugLabel}
-
-
- >
- )}
-
-
-
- Settings
-
-
-
-
- );
-}
-
-function SettingsButton() {
+ const label = showDebugOverlay ? "Hide Debug Info" : "Show Debug Info";
return (
-
-
-
-
+
+
- Settings
+ {label}
);
}
@@ -341,7 +269,7 @@ function TopbarToolsGroup({
onToggleDebugOverlay?: () => void;
isArchived?: boolean;
}) {
- const showDebugMenu = DEBUG_UI && onToggleDebugOverlay;
+ const showDebugToggle = DEBUG_UI && onToggleDebugOverlay;
return (
@@ -351,19 +279,19 @@ function TopbarToolsGroup({
>
)}
- {showDebugMenu ? (
-
- ) : (
-
)}
);
}
-/** Right section: status/attention, tools menu */
+/** Right section: status/attention + tools rendered inline.
+ * The former overflow popover was removed in the UI overhaul — every cluster
+ * is always visible so users don't have to discover the dots menu. */
function TopBarRight({
taskId,
activeSessionId,
@@ -387,41 +315,15 @@ function TopBarRight({
taskTitle?: string;
officeTaskHref?: string | null;
}) {
- const items: TopbarOverflowItem[] = [];
-
- if (officeTaskHref) {
- items.push({
- id: "office-view",
- label: "Open in office view",
- priority: 90,
- content: (
+ return (
+
+ {officeTaskHref && (
Open in office view
- ),
- });
- }
-
- if (!isArchived && workspaceId) {
- items.push({
- id: "quick-chat",
- label: "Quick chat",
- priority: 20,
- content: (
-
-
-
- ),
- });
- }
-
- items.push({
- id: "attention",
- label: "Task status and attention",
- priority: 80,
- content: (
+ )}
- ),
- });
-
- items.push({
- id: "tools",
- label: "Task tools",
- priority: 10,
- content: (
- ),
- });
-
- return (
-
+
);
}
diff --git a/apps/web/components/task/topbar-action-overflow.test.ts b/apps/web/components/task/topbar-action-overflow.test.ts
deleted file mode 100644
index d0436853a..000000000
--- a/apps/web/components/task/topbar-action-overflow.test.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { getHiddenTopbarActionIds } from "./topbar-action-overflow";
-
-const QUICK_CHAT = "quick-chat";
-const ATTENTION = "attention";
-const TOOLS = "tools";
-
-const items = [
- { id: QUICK_CHAT, priority: 20 },
- { id: ATTENTION, priority: 80 },
- { id: TOOLS, priority: 10 },
-];
-
-const widths = new Map([
- [QUICK_CHAT, 88],
- [ATTENTION, 160],
- [TOOLS, 120],
-]);
-
-describe("getHiddenTopbarActionIds", () => {
- it("keeps every action visible when there is enough width", () => {
- expect(
- getHiddenTopbarActionIds({
- items,
- availableWidth: 600,
- itemWidths: widths,
- gap: 8,
- overflowTriggerWidth: 40,
- }),
- ).toEqual([]);
- });
-
- it("hides low-priority tools before quick chat or contextual actions", () => {
- expect(
- getHiddenTopbarActionIds({
- items,
- availableWidth: 320,
- itemWidths: widths,
- gap: 8,
- overflowTriggerWidth: 40,
- }),
- ).toEqual([TOOLS]);
- });
-
- it("hides quick chat before contextual attention actions", () => {
- expect(
- getHiddenTopbarActionIds({
- items,
- availableWidth: 230,
- itemWidths: widths,
- gap: 8,
- overflowTriggerWidth: 40,
- }),
- ).toEqual([QUICK_CHAT, TOOLS]);
- });
-});
diff --git a/apps/web/components/task/topbar-action-overflow.tsx b/apps/web/components/task/topbar-action-overflow.tsx
deleted file mode 100644
index 52de62e6e..000000000
--- a/apps/web/components/task/topbar-action-overflow.tsx
+++ /dev/null
@@ -1,253 +0,0 @@
-"use client";
-
-import { useCallback, useLayoutEffect, useRef, useState, type ReactNode, type Ref } from "react";
-import { IconDots } from "@tabler/icons-react";
-import { Button } from "@kandev/ui/button";
-import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
-import { cn } from "@kandev/ui/lib/utils";
-
-export type TopbarOverflowItem = {
- id: string;
- label: string;
- priority: number;
- content: ReactNode;
-};
-
-type TopbarOverflowMetricItem = Pick
;
-
-type HiddenTopbarActionArgs = {
- items: TopbarOverflowMetricItem[];
- availableWidth: number;
- itemWidths: ReadonlyMap;
- gap: number;
- overflowTriggerWidth: number;
- fallbackItemWidth?: number;
-};
-
-const DEFAULT_FALLBACK_ITEM_WIDTH = 88;
-const DEFAULT_OVERFLOW_TRIGGER_WIDTH = 40;
-
-function setsMatch(set: Set, values: string[]) {
- if (set.size !== values.length) return false;
- return values.every((value) => set.has(value));
-}
-
-function itemWidth(
- item: TopbarOverflowMetricItem,
- widths: ReadonlyMap,
- fallback: number,
-) {
- return widths.get(item.id) ?? fallback;
-}
-
-function totalActionWidth({
- items,
- hiddenIds,
- itemWidths,
- gap,
- overflowTriggerWidth,
- fallbackItemWidth,
-}: {
- items: TopbarOverflowMetricItem[];
- hiddenIds: Set;
- itemWidths: ReadonlyMap;
- gap: number;
- overflowTriggerWidth: number;
- fallbackItemWidth: number;
-}) {
- const visibleItems = items.filter((item) => !hiddenIds.has(item.id));
- const visibleWidth = visibleItems.reduce(
- (total, item) => total + itemWidth(item, itemWidths, fallbackItemWidth),
- 0,
- );
- const controlCount = visibleItems.length + (hiddenIds.size > 0 ? 1 : 0);
- const gapWidth = Math.max(0, controlCount - 1) * gap;
-
- return visibleWidth + (hiddenIds.size > 0 ? overflowTriggerWidth : 0) + gapWidth;
-}
-
-export function getHiddenTopbarActionIds({
- items,
- availableWidth,
- itemWidths,
- gap,
- overflowTriggerWidth,
- fallbackItemWidth = DEFAULT_FALLBACK_ITEM_WIDTH,
-}: HiddenTopbarActionArgs): string[] {
- const hiddenIds = new Set();
- const hideableItems = [...items].sort((a, b) => a.priority - b.priority);
-
- while (
- totalActionWidth({
- items,
- hiddenIds,
- itemWidths,
- gap,
- overflowTriggerWidth,
- fallbackItemWidth,
- }) > availableWidth
- ) {
- const nextItem = hideableItems.find((item) => !hiddenIds.has(item.id));
- if (!nextItem) break;
- hiddenIds.add(nextItem.id);
- }
-
- return items.filter((item) => hiddenIds.has(item.id)).map((item) => item.id);
-}
-
-function readFlexGap(element: HTMLElement) {
- const styles = window.getComputedStyle(element);
- const gap = Number.parseFloat(styles.columnGap || styles.gap || "0");
- return Number.isFinite(gap) ? gap : 0;
-}
-
-type TopbarActionOverflowProps = {
- items: TopbarOverflowItem[];
- className?: string;
-};
-
-function measureVisibleItems(
- itemRefs: Map,
- itemWidths: Map,
-) {
- for (const [id, element] of itemRefs) {
- const width = Math.ceil(element.getBoundingClientRect().width);
- if (width > 0) itemWidths.set(id, width);
- }
-}
-
-function useTopbarOverflowState(items: TopbarOverflowItem[]) {
- const containerRef = useRef(null);
- const overflowTriggerRef = useRef(null);
- const itemRefs = useRef(new Map());
- const itemWidths = useRef(new Map());
- const [hiddenIds, setHiddenIds] = useState>(() => new Set());
- const [measuredWidth, setMeasuredWidth] = useState(0);
-
- const registerItem = useCallback(
- (id: string) => (element: HTMLDivElement | null) => {
- if (element) {
- itemRefs.current.set(id, element);
- } else {
- itemRefs.current.delete(id);
- }
- },
- [],
- );
-
- useLayoutEffect(() => {
- const element = containerRef.current;
- if (!element) return;
-
- const updateWidth = () => setMeasuredWidth(element.clientWidth);
- updateWidth();
-
- const observer = new ResizeObserver(updateWidth);
- observer.observe(element);
- return () => observer.disconnect();
- }, []);
-
- useLayoutEffect(() => {
- const container = containerRef.current;
- if (!container || container.clientWidth <= 0) return;
-
- measureVisibleItems(itemRefs.current, itemWidths.current);
-
- const overflowWidth =
- overflowTriggerRef.current?.getBoundingClientRect().width || DEFAULT_OVERFLOW_TRIGGER_WIDTH;
- const nextHiddenIds = getHiddenTopbarActionIds({
- items,
- availableWidth: container.clientWidth,
- itemWidths: itemWidths.current,
- gap: readFlexGap(container),
- overflowTriggerWidth: overflowWidth,
- });
-
- setHiddenIds((current) =>
- setsMatch(current, nextHiddenIds) ? current : new Set(nextHiddenIds),
- );
- }, [items, measuredWidth]);
-
- return { containerRef, overflowTriggerRef, registerItem, hiddenIds };
-}
-
-function OverflowTrigger({ triggerRef }: { triggerRef: Ref }) {
- return (
-
-
-
-
-
-
-
-
- More actions
-
- );
-}
-
-function OverflowPopover({
- items,
- triggerRef,
-}: {
- items: TopbarOverflowItem[];
- triggerRef: Ref;
-}) {
- if (items.length === 0) return null;
-
- return (
-
-
-
-
- {items.map((item) => (
-
- {item.content}
-
- ))}
-
-
-
- );
-}
-
-export function TopbarActionOverflow({ items, className }: TopbarActionOverflowProps) {
- const { containerRef, overflowTriggerRef, registerItem, hiddenIds } =
- useTopbarOverflowState(items);
- const hiddenItems = items.filter((item) => hiddenIds.has(item.id));
-
- return (
-
- {items.map((item) =>
- hiddenIds.has(item.id) ? null : (
-
- {item.content}
-
- ),
- )}
-
-
- );
-}
diff --git a/apps/web/components/theme-provider.tsx b/apps/web/components/theme-provider.tsx
index 4a0f8bc47..d095b6a8d 100644
--- a/apps/web/components/theme-provider.tsx
+++ b/apps/web/components/theme-provider.tsx
@@ -5,7 +5,15 @@ import { ReactNode } from "react";
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
-
+
{children}
);
diff --git a/apps/web/e2e/helpers/regular-mode.ts b/apps/web/e2e/helpers/regular-mode.ts
new file mode 100644
index 000000000..02d312c5b
--- /dev/null
+++ b/apps/web/e2e/helpers/regular-mode.ts
@@ -0,0 +1,22 @@
+import { test } from "../fixtures/test-base";
+
+/**
+ * Run the current spec file's tests with the `office` feature DISABLED.
+ *
+ * The e2e profile (`profiles.yaml`) enables `office` for the whole suite, which
+ * makes the sidebar "New Task" open the richer Office "New issue" dialog. Specs
+ * that exercise the regular task-create flow (the `create-task-dialog` with
+ * `task-title-input` / `submit-start-agent`) need the non-office experience, so
+ * they call this at the top of their `describe`/module to restart the
+ * worker-scoped backend with `KANDEV_FEATURES_OFFICE=false` and revert to the
+ * profile default afterwards. `workers: 1` runs files sequentially, so the
+ * override is cleanly scoped to this file.
+ */
+export function useRegularMode(): void {
+ test.beforeAll(async ({ backend }) => {
+ await backend.restart({ KANDEV_FEATURES_OFFICE: "false" });
+ });
+ test.afterAll(async ({ backend }) => {
+ await backend.restart();
+ });
+}
diff --git a/apps/web/e2e/tests/chat/clarification.spec.ts b/apps/web/e2e/tests/chat/clarification.spec.ts
index 5ea40b967..9c5968af6 100644
--- a/apps/web/e2e/tests/chat/clarification.spec.ts
+++ b/apps/web/e2e/tests/chat/clarification.spec.ts
@@ -1,5 +1,6 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
@@ -38,6 +39,9 @@ async function seedClarificationTask(
return session;
}
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("Clarification flow", () => {
test.describe.configure({ retries: 1 });
diff --git a/apps/web/e2e/tests/cli-mode/create-prompt-enabled.spec.ts b/apps/web/e2e/tests/cli-mode/create-prompt-enabled.spec.ts
index 8edadafcf..99abf44ef 100644
--- a/apps/web/e2e/tests/cli-mode/create-prompt-enabled.spec.ts
+++ b/apps/web/e2e/tests/cli-mode/create-prompt-enabled.spec.ts
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
/**
@@ -8,6 +9,10 @@ import { KanbanPage } from "../../pages/kanban-page";
* has been removed; the backend now auto-injects the prompt into the
* running CLI after the first idle window.
*/
+
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("CLI mode: create-task dialog prompt", () => {
test("prompt textarea is enabled, no 'prompt ignored' warning, and submit works", async ({
testPage,
diff --git a/apps/web/e2e/tests/integrations/jira-import.spec.ts b/apps/web/e2e/tests/integrations/jira-import.spec.ts
index 898c32e5a..06a683bc8 100644
--- a/apps/web/e2e/tests/integrations/jira-import.spec.ts
+++ b/apps/web/e2e/tests/integrations/jira-import.spec.ts
@@ -1,6 +1,10 @@
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("Jira import bar", () => {
test.beforeEach(async ({ apiClient }) => {
await apiClient.setJiraConfig({
diff --git a/apps/web/e2e/tests/integrations/linear-import.spec.ts b/apps/web/e2e/tests/integrations/linear-import.spec.ts
index 2d75008bb..6a982fb34 100644
--- a/apps/web/e2e/tests/integrations/linear-import.spec.ts
+++ b/apps/web/e2e/tests/integrations/linear-import.spec.ts
@@ -1,6 +1,10 @@
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("Linear import bar", () => {
test.beforeEach(async ({ apiClient }) => {
await apiClient.setLinearConfig({
diff --git a/apps/web/e2e/tests/kanban/kanban-board.spec.ts b/apps/web/e2e/tests/kanban/kanban-board.spec.ts
index 8242ceabe..7a40dfe50 100644
--- a/apps/web/e2e/tests/kanban/kanban-board.spec.ts
+++ b/apps/web/e2e/tests/kanban/kanban-board.spec.ts
@@ -1,6 +1,10 @@
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("Kanban board", () => {
test("displays a seeded task card", async ({ testPage, apiClient, seedData }) => {
const task = await apiClient.createTask(seedData.workspaceId, "E2E Kanban Test Task", {
diff --git a/apps/web/e2e/tests/kanban/workflow-filter.spec.ts b/apps/web/e2e/tests/kanban/workflow-filter.spec.ts
index b53d3c0e0..b5bd3b957 100644
--- a/apps/web/e2e/tests/kanban/workflow-filter.spec.ts
+++ b/apps/web/e2e/tests/kanban/workflow-filter.spec.ts
@@ -144,8 +144,8 @@ test.describe("Kanban workflow filter", () => {
await testPage.goto(`/t/${betaTaskId}`);
await expect(testPage).toHaveURL(new RegExp(`/t/${betaTaskId}`));
- // Breadcrumb = client-side nav: goto("/") re-runs SSR and re-resolves activeId, masking the bug.
- await testPage.getByTestId("task-breadcrumb-home").click();
+ // AppSidebar Home link = client-side nav: goto("/") re-runs SSR and re-resolves activeId, masking the bug.
+ await testPage.getByTestId("app-sidebar").getByRole("link", { name: "Home" }).click();
await expect(testPage).toHaveURL(/\/$|\?/);
await expect(kanban.board).toBeVisible();
diff --git a/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts b/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts
index 3e58a8c71..68a4954dc 100644
--- a/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts
+++ b/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts
@@ -1,9 +1,13 @@
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
const COMPACT_DESKTOP_VIEWPORT = { width: 900, height: 800 };
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("compact desktop responsive layout", () => {
test("task page keeps the Dockview workbench at half-screen width", async ({
testPage,
diff --git a/apps/web/e2e/tests/layout/pane-resize-right.spec.ts b/apps/web/e2e/tests/layout/pane-resize-right.spec.ts
index 8f6ac1011..d2fe6ed78 100644
--- a/apps/web/e2e/tests/layout/pane-resize-right.spec.ts
+++ b/apps/web/e2e/tests/layout/pane-resize-right.spec.ts
@@ -25,7 +25,7 @@ test.describe("Right pane resize — viewport-proportional cap", () => {
expect(actual).toBeLessThanOrEqual(cap + 10);
});
- test("user width survives reload (localStorage dockview-layout-v2 round-trip)", async ({
+ test("user width survives reload (localStorage dockview-layout-v3 round-trip)", async ({
testPage,
apiClient,
seedData,
diff --git a/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts b/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts
index 0942372f1..391aefed0 100644
--- a/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts
+++ b/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts
@@ -173,7 +173,7 @@ test.describe("Plan panel auto-open + indicator", () => {
// otherwise the restore will not bring it back.
await testPage.waitForFunction(
() => {
- const raw = localStorage.getItem("dockview-layout-v2");
+ const raw = localStorage.getItem("dockview-layout-v3");
return !!raw && raw.includes('"id":"plan"');
},
null,
diff --git a/apps/web/e2e/tests/office/sidebar-office-gating.spec.ts b/apps/web/e2e/tests/office/sidebar-office-gating.spec.ts
new file mode 100644
index 000000000..5fe0e5392
--- /dev/null
+++ b/apps/web/e2e/tests/office/sidebar-office-gating.spec.ts
@@ -0,0 +1,24 @@
+import { test, expect } from "../../fixtures/office-fixture";
+
+// Office-specific sidebar items (Inbox, Projects, Agents) must appear only while
+// the user is inside the Office surface (any /office route, reached via the
+// footer "Office" button) — not in the regular workspace, even with office on.
+test.describe("Sidebar office gating", () => {
+ test("office sections appear only inside Office", async ({ testPage, officeSeed }) => {
+ expect(officeSeed.workspaceId).toBeTruthy();
+ const sidebar = testPage.getByTestId("app-sidebar");
+
+ // Inside Office: the office sections render.
+ await testPage.goto("/office");
+ await expect(sidebar.getByText("Projects", { exact: true })).toBeVisible({ timeout: 15_000 });
+ await expect(sidebar.getByText("Agents", { exact: true })).toBeVisible();
+ await expect(sidebar.getByRole("link", { name: "Inbox" })).toBeVisible();
+
+ // Regular workspace home: the office sections are gone.
+ await testPage.goto("/");
+ await expect(sidebar).toBeVisible();
+ await expect(sidebar.getByText("Projects", { exact: true })).toHaveCount(0);
+ await expect(sidebar.getByText("Agents", { exact: true })).toHaveCount(0);
+ await expect(sidebar.getByRole("link", { name: "Inbox" })).toHaveCount(0);
+ });
+});
diff --git a/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts b/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts
index 80551bb75..237d21293 100644
--- a/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts
+++ b/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts
@@ -53,7 +53,11 @@ test.describe("Keyboard Shortcuts Settings", () => {
await expect(recorderAfterReload).toContainText("T");
});
- test("can reset a customized shortcut to default", async ({ testPage, apiClient, seedData }) => {
+ test("can reset a customized shortcut back to unbound", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
// Set a custom shortcut via API
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
@@ -70,16 +74,14 @@ test.describe("Keyboard Shortcuts Settings", () => {
// Should show the custom shortcut (X)
await expect(recorder).toContainText("X", { timeout: 3_000 });
- // Should have a reset button since it's customized
+ // Click reset (TOGGLE_SIDEBAR has no default binding, so reset clears it)
const row = recorder.locator("..");
- const resetButton = row.getByTitle("Reset to default");
+ const resetButton = row.getByTitle(/Reset/);
await expect(resetButton).toBeVisible();
-
- // Click reset
await resetButton.click();
- // Should now show the default (B for Cmd/Ctrl+B)
- await expect(recorder).toContainText("B", { timeout: 3_000 });
+ // Should now show "Unbound"
+ await expect(recorder).toContainText("Unbound", { timeout: 3_000 });
});
test("customized command panel shortcut opens the panel", async ({
diff --git a/apps/web/e2e/tests/settings/settings-gear-only.spec.ts b/apps/web/e2e/tests/settings/settings-gear-only.spec.ts
new file mode 100644
index 000000000..8178592c9
--- /dev/null
+++ b/apps/web/e2e/tests/settings/settings-gear-only.spec.ts
@@ -0,0 +1,31 @@
+import { test, expect } from "../../fixtures/test-base";
+
+// Settings is reachable ONLY via the footer gear (no nav "Settings" section),
+// and the gear closes the settings-tree takeover even while on a settings page.
+test.describe("Settings sidebar takeover", () => {
+ test("gear opens the tree, and closes it even on a settings page", async ({ testPage }) => {
+ await testPage.goto("/settings");
+
+ const gear = testPage.getByTestId("sidebar-settings-gear");
+ const takeover = testPage.getByTestId("app-sidebar-settings-mode");
+
+ // No standalone "Settings" nav section: the tree is not shown until the gear
+ // is toggled, even though we're sitting on a settings page.
+ await expect(gear).toBeVisible();
+ await expect(takeover).toHaveCount(0);
+
+ // Gear opens the takeover.
+ await gear.click();
+ await expect(takeover).toBeVisible();
+
+ // Enter a section — navigates to a settings sub-page; takeover stays open.
+ await takeover.locator('a[href="/settings/agents"]').first().click();
+ await expect(testPage).toHaveURL(/\/settings\/agents/);
+ await expect(takeover).toBeVisible();
+
+ // Clicking the gear again must close the tree — even though we're still on a
+ // settings page (the previous bug left it open).
+ await gear.click();
+ await expect(takeover).toHaveCount(0);
+ });
+});
diff --git a/apps/web/e2e/tests/setup-timeouts.spec.ts b/apps/web/e2e/tests/setup-timeouts.spec.ts
index 05a83885a..3fa953a97 100644
--- a/apps/web/e2e/tests/setup-timeouts.spec.ts
+++ b/apps/web/e2e/tests/setup-timeouts.spec.ts
@@ -1,6 +1,10 @@
import { test, expect } from "../fixtures/test-base";
+import { useRegularMode } from "../helpers/regular-mode";
import { KanbanPage } from "../pages/kanban-page";
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("First-time setup: timeouts and error handling", () => {
// Allow one retry for transient cold-start timing issues on first test.
test.describe.configure({ retries: 1 });
diff --git a/apps/web/e2e/tests/system/sidebar-navigation.spec.ts b/apps/web/e2e/tests/system/sidebar-navigation.spec.ts
index 55d1bc6be..f90d73aa2 100644
--- a/apps/web/e2e/tests/system/sidebar-navigation.spec.ts
+++ b/apps/web/e2e/tests/system/sidebar-navigation.spec.ts
@@ -16,7 +16,10 @@ test.describe("System sidebar navigation", () => {
}) => {
test.setTimeout(120_000);
- await testPage.goto("/settings/general/notifications");
+ // The settings nav is a single-open accordion: a group's sub-entries are
+ // only mounted while that group is the open one. Landing on a System page
+ // opens the System group (route-synced), so its sub-entries are visible.
+ await testPage.goto("/settings/system/status");
// Each sub-entry is present in the settings sidebar.
for (const entry of SYSTEM_ENTRIES) {
diff --git a/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts b/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts
index cac3af889..65365b8fa 100644
--- a/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts
+++ b/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts
@@ -2,6 +2,7 @@ import path from "node:path";
import fs from "node:fs";
import { execSync } from "node:child_process";
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
import { makeGitEnv } from "../../helpers/git-helper";
@@ -9,6 +10,9 @@ function escapeRe(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("Branch selector behavior with executor types", () => {
test.describe.configure({ retries: 1 });
diff --git a/apps/web/e2e/tests/task/create-task-disabled-tooltip.spec.ts b/apps/web/e2e/tests/task/create-task-disabled-tooltip.spec.ts
index 49982bcbb..63b71c51e 100644
--- a/apps/web/e2e/tests/task/create-task-disabled-tooltip.spec.ts
+++ b/apps/web/e2e/tests/task/create-task-disabled-tooltip.spec.ts
@@ -1,10 +1,14 @@
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
const START_AGENT_TEST_ID = "submit-start-agent";
const WRAPPER_TEST_ID = "submit-start-agent-wrapper";
const START_ENABLED_TIMEOUT = 30_000;
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("Create task button: disabled-reason tooltip", () => {
test("shows 'Add a task title' when title is empty", async ({ testPage }) => {
const kanban = new KanbanPage(testPage);
diff --git a/apps/web/e2e/tests/task/create-task-github-url.spec.ts b/apps/web/e2e/tests/task/create-task-github-url.spec.ts
index 4340f115d..764d69f95 100644
--- a/apps/web/e2e/tests/task/create-task-github-url.spec.ts
+++ b/apps/web/e2e/tests/task/create-task-github-url.spec.ts
@@ -1,7 +1,11 @@
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("Task creation from GitHub URL", () => {
// Allow one retry for transient backend port-allocation issues on cold start.
test.describe.configure({ retries: 1 });
diff --git a/apps/web/e2e/tests/task/create-task-url-reopen-no-branches.spec.ts b/apps/web/e2e/tests/task/create-task-url-reopen-no-branches.spec.ts
index 6c0b2de76..cce26ad80 100644
--- a/apps/web/e2e/tests/task/create-task-url-reopen-no-branches.spec.ts
+++ b/apps/web/e2e/tests/task/create-task-url-reopen-no-branches.spec.ts
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
// Regression test for the user-reported bug:
@@ -18,6 +19,10 @@ import { KanbanPage } from "../../pages/kanban-page";
// owner/repo so the orchestrator's background `gh repo clone` fails - that
// keeps local_path empty, proving the remote-first path is what's serving
// the branches.
+
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("Create-task URL flow - branches after reopen", () => {
test.describe.configure({ retries: 1 });
diff --git a/apps/web/e2e/tests/task/create-task.spec.ts b/apps/web/e2e/tests/task/create-task.spec.ts
index e19f73d19..7b6b5abd4 100644
--- a/apps/web/e2e/tests/task/create-task.spec.ts
+++ b/apps/web/e2e/tests/task/create-task.spec.ts
@@ -1,7 +1,12 @@
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
+// Exercises the regular task-create dialog (New Task in the sidebar), so run
+// with the office feature disabled.
+useRegularMode();
+
const START_AGENT_TEST_ID = "submit-start-agent";
const START_ENABLED_TIMEOUT = 30_000;
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
diff --git a/apps/web/e2e/tests/task/enhance-prompt.spec.ts b/apps/web/e2e/tests/task/enhance-prompt.spec.ts
index a702cc64c..30135a57f 100644
--- a/apps/web/e2e/tests/task/enhance-prompt.spec.ts
+++ b/apps/web/e2e/tests/task/enhance-prompt.spec.ts
@@ -1,6 +1,10 @@
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("Enhance prompt button in task creation", () => {
test("enhance button is visible and enabled when utility agent is configured", async ({
testPage,
diff --git a/apps/web/e2e/tests/task/local-executor-branch-split.spec.ts b/apps/web/e2e/tests/task/local-executor-branch-split.spec.ts
index 7d75aa280..1a37abce3 100644
--- a/apps/web/e2e/tests/task/local-executor-branch-split.spec.ts
+++ b/apps/web/e2e/tests/task/local-executor-branch-split.spec.ts
@@ -2,6 +2,7 @@ import path from "node:path";
import fs from "node:fs";
import { execSync } from "node:child_process";
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
import { makeGitEnv } from "../../helpers/git-helper";
@@ -32,6 +33,10 @@ import { makeGitEnv } from "../../helpers/git-helper";
* permanently pin their repo's default to that feature branch, and every
* downstream merge-base lookup would be anchored wrong.
*/
+
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("Local executor branch split", () => {
test.describe.configure({ retries: 1 });
diff --git a/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts b/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts
index 0edce8beb..bd381cafb 100644
--- a/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts
+++ b/apps/web/e2e/tests/task/repository-selector-scroll.spec.ts
@@ -3,6 +3,7 @@ import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
// Regression: Combobox popovers (Repository / Base Branch / Agent Profile)
@@ -11,6 +12,10 @@ import { KanbanPage } from "../../pages/kanban-page";
// wheel events were swallowed and the list could not be scrolled. The fix
// renders these popovers inline (portal={false}) so they live inside the
// Dialog content tree.
+
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("repository selector scroll inside dialog", () => {
test("wheel scrolls the repository list and the list is inside the dialog", async ({
testPage,
diff --git a/apps/web/e2e/tests/task/sidebar-task-open.spec.ts b/apps/web/e2e/tests/task/sidebar-task-open.spec.ts
new file mode 100644
index 000000000..4de45b955
--- /dev/null
+++ b/apps/web/e2e/tests/task/sidebar-task-open.spec.ts
@@ -0,0 +1,72 @@
+import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
+import { KanbanPage } from "../../pages/kanban-page";
+import { SessionPage } from "../../pages/session-page";
+
+// Regression: the AppSidebar is mounted globally, so clicking a task in its
+// Tasks list from a non-task page (the board) must NAVIGATE to the task route
+// and mount the dockview. A prior refactor left this doing an in-place layout
+// switch that only rewrote the URL (history.replaceState) without ever
+// mounting the dockview, so the click appeared to do nothing.
+useRegularMode();
+
+test.describe("Sidebar task open", () => {
+ test("clicking a sidebar task from the board opens the dockview", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Sidebar Open Target",
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ const session = new SessionPage(testPage);
+ const item = session.sidebarTaskItem("Sidebar Open Target").first();
+ await expect(item).toBeVisible({ timeout: 10_000 });
+ await item.click();
+
+ // Must reach the task route and mount the dockview — not just rewrite the URL.
+ await expect(testPage).toHaveURL(new RegExp(`/t/${task.id}`), { timeout: 15_000 });
+ await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible({ timeout: 15_000 });
+ });
+
+ test("returning Home clears the selected-task highlight in the sidebar", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Home Deselect Target",
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+
+ const session = new SessionPage(testPage);
+ await testPage.goto(`/t/${task.id}`);
+ await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible({ timeout: 15_000 });
+
+ const item = session.sidebarTaskItem("Home Deselect Target").first();
+ await expect(item).toHaveAttribute("data-active", "true", { timeout: 10_000 });
+
+ // Back to Home — the global sidebar must drop the selection highlight.
+ await testPage.getByRole("link", { name: "Home", exact: true }).click();
+ await expect(item).toHaveAttribute("data-active", "false", { timeout: 10_000 });
+ });
+});
diff --git a/apps/web/e2e/tests/task/subtask.spec.ts b/apps/web/e2e/tests/task/subtask.spec.ts
index 7c7269912..684ffef3e 100644
--- a/apps/web/e2e/tests/task/subtask.spec.ts
+++ b/apps/web/e2e/tests/task/subtask.spec.ts
@@ -3,9 +3,14 @@ import fs from "node:fs";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import { makeGitEnv } from "../../helpers/git-helper";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
+// The parent-task setup and the sidebar New Task button exercise the regular
+// task-create dialog, so run this file with the office feature disabled.
+useRegularMode();
+
const START_AGENT_TEST_ID = "submit-start-agent";
const START_ENABLED_TIMEOUT = 30_000;
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
@@ -73,17 +78,11 @@ test.describe("Subtask basics", () => {
// Wait for agent to complete
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
- // Open the Task split-button chevron and click "New Subtask"
- const primary = testPage.getByTestId("new-task-primary");
- const chevron = testPage.getByTestId("new-task-chevron");
- await expect(primary).toBeVisible({ timeout: 5_000 });
- await expect(chevron).toBeVisible({ timeout: 5_000 });
- await expect(primary).toHaveCSS("border-top-right-radius", "0px");
- await expect(primary).toHaveCSS("border-bottom-right-radius", "0px");
- await expect(chevron).toHaveCSS("border-top-left-radius", "0px");
- await expect(chevron).toHaveCSS("border-bottom-left-radius", "0px");
- await chevron.click();
- await testPage.getByTestId("new-subtask-button").click();
+ // Open the New Subtask dialog from the sidebar New Task row's trailing
+ // subtask affordance (shown while viewing a task).
+ const subtaskButton = testPage.getByTestId("sidebar-new-subtask");
+ await expect(subtaskButton).toBeVisible({ timeout: 5_000 });
+ await subtaskButton.click();
// The compact NewSubtaskDialog should open with pre-filled title containing numeric suffix
const titleInput = testPage.getByTestId("subtask-title-input");
@@ -255,8 +254,7 @@ test.describe("MCP subtask creation", () => {
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// 4. Open the Task split-button chevron and click "New Subtask"
- await testPage.getByTestId("new-task-chevron").click();
- await testPage.getByTestId("new-subtask-button").click();
+ await testPage.getByTestId("sidebar-new-subtask").click();
const subtaskTitleInput = testPage.getByTestId("subtask-title-input");
await expect(subtaskTitleInput).toBeVisible();
@@ -317,8 +315,7 @@ test.describe("MCP subtask creation", () => {
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// 3. Open the New Subtask dialog from the split-button.
- await testPage.getByTestId("new-task-chevron").click();
- await testPage.getByTestId("new-subtask-button").click();
+ await testPage.getByTestId("sidebar-new-subtask").click();
const titleInput = testPage.getByTestId("subtask-title-input");
await expect(titleInput).toBeVisible({ timeout: 5_000 });
@@ -652,8 +649,7 @@ test.describe("Subtask dialog feature parity", () => {
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
// 3. Open the subtask dialog and toggle to GitHub URL mode.
- await testPage.getByTestId("new-task-chevron").click();
- await testPage.getByTestId("new-subtask-button").click();
+ await testPage.getByTestId("sidebar-new-subtask").click();
const titleInput = testPage.getByTestId("subtask-title-input");
await expect(titleInput).toBeVisible({ timeout: 5_000 });
@@ -757,8 +753,7 @@ test.describe("Subtask dialog feature parity", () => {
// reopening the popover each tick because cmdk's listbox snapshots
// options at open time and won't update if discovery resolves while
// the popover is already showing.
- await testPage.getByTestId("new-task-chevron").click();
- await testPage.getByTestId("new-subtask-button").click();
+ await testPage.getByTestId("sidebar-new-subtask").click();
await expect(testPage.getByTestId("subtask-title-input")).toBeVisible({ timeout: 5_000 });
const discoveredOption = testPage
@@ -822,8 +817,7 @@ test.describe("Subtask dialog feature parity", () => {
// 3. Open the subtask dialog. The first chip is seeded with the parent's
// repo. Click the "+ add repository" button to append a second chip,
// then point that chip at repo B.
- await testPage.getByTestId("new-task-chevron").click();
- await testPage.getByTestId("new-subtask-button").click();
+ await testPage.getByTestId("sidebar-new-subtask").click();
const titleInput = testPage.getByTestId("subtask-title-input");
await expect(titleInput).toBeVisible({ timeout: 5_000 });
diff --git a/apps/web/e2e/tests/task/task-default-layout.spec.ts b/apps/web/e2e/tests/task/task-default-layout.spec.ts
new file mode 100644
index 000000000..e89bfb896
--- /dev/null
+++ b/apps/web/e2e/tests/task/task-default-layout.spec.ts
@@ -0,0 +1,55 @@
+import { test, expect } from "../../fixtures/test-base";
+import { SessionPage } from "../../pages/session-page";
+
+// Regression: entering a task must lay out the DEFAULT layout horizontally —
+// chat/agent in the center (left), files/changes + terminal in a right column.
+// A bug stacked them vertically (chat on top, files/changes in the middle,
+// terminal at the bottom): the chat→session-tab swap removed the "chat"
+// placeholder before adding the session panel, which destroyed the center
+// group and collapsed the horizontal split into a single vertical column.
+test.describe("Task default layout shape", () => {
+ test("entering a task is horizontal (right column beside chat, not stacked)", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Default Layout Shape",
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await session.waitForDockviewReady();
+ await testPage.waitForTimeout(500); // let the session-tab swap settle
+
+ const containerW = await testPage
+ .getByTestId("dockview-task-layout")
+ .evaluate((el) => el.getBoundingClientRect().width);
+
+ const groups = await testPage.evaluate(() => {
+ const els = Array.from(document.querySelectorAll(".dv-groupview")) as HTMLElement[];
+ return els.map((el) => {
+ const r = el.getBoundingClientRect();
+ return { x: Math.round(r.x), w: Math.round(r.width) };
+ });
+ });
+
+ // The right column (files/changes + terminal) must sit beside chat — i.e.
+ // at least one group starts in the right portion of the layout. If the
+ // layout collapsed to a vertical stack, every group shares the same left x.
+ const rightColumnGroups = groups.filter((g) => g.x > containerW * 0.4);
+ expect(
+ rightColumnGroups.length,
+ `expected a right-side column; groups=${JSON.stringify(groups)} containerW=${containerW}`,
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/apps/web/e2e/tests/task/workspace-switch-sidebar-isolation.spec.ts b/apps/web/e2e/tests/task/workspace-switch-sidebar-isolation.spec.ts
index a77b5b389..39370c05c 100644
--- a/apps/web/e2e/tests/task/workspace-switch-sidebar-isolation.spec.ts
+++ b/apps/web/e2e/tests/task/workspace-switch-sidebar-isolation.spec.ts
@@ -55,13 +55,17 @@ test.describe("Sidebar — cross-workspace isolation", () => {
await expect(kanban.taskCard(taskA.id)).toBeVisible({ timeout: 10_000 });
await expect(kanban.taskCard(taskB.id)).not.toBeVisible();
- // --- Switch to workspace B via the display dropdown (SPA, no full reload) ---
- await testPage.getByTestId("display-button").click();
- await testPage.getByTestId("workspace-select-trigger").click();
- await testPage.getByTestId(`workspace-select-item-${workspaceB.id}`).click();
-
- // Close the dropdown so task cards are interactable.
- await testPage.keyboard.press("Escape");
+ // --- Switch to workspace B via the sidebar workspace picker ---
+ // The picker (top of the sidebar) is now the only workspace switcher — the
+ // Home display dropdown no longer offers one. In office mode it routes to
+ // /office, but that is a client-side navigation (no full reload), so the
+ // in-memory store must already reflect workspace B with no leaked
+ // workspace-A tasks. We return to the board via the Home nav link, still
+ // without a full reload, to keep the isolation assertions on the kanban.
+ await testPage.getByTestId("sidebar-workspace-trigger").click();
+ await testPage.getByTestId(`sidebar-workspace-item-${workspaceB.id}`).click();
+ await expect(testPage).toHaveURL(/\/office/);
+ await testPage.getByRole("link", { name: "Home", exact: true }).click();
await expect(kanban.taskCard(taskB.id)).toBeVisible({ timeout: 10_000 });
await expect(kanban.taskCard(taskA.id)).not.toBeVisible();
diff --git a/apps/web/e2e/tests/workflow/workflow-agent-profile.spec.ts b/apps/web/e2e/tests/workflow/workflow-agent-profile.spec.ts
index ebbcbd8f1..5270683fb 100644
--- a/apps/web/e2e/tests/workflow/workflow-agent-profile.spec.ts
+++ b/apps/web/e2e/tests/workflow/workflow-agent-profile.spec.ts
@@ -1,7 +1,11 @@
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { WorkflowSettingsPage } from "../../pages/workflow-settings-page";
import { KanbanPage } from "../../pages/kanban-page";
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("Workflow agent profile", () => {
test("set workflow-level agent profile in settings and persist after save", async ({
testPage,
diff --git a/apps/web/e2e/tests/workflow/workflow-start-step.spec.ts b/apps/web/e2e/tests/workflow/workflow-start-step.spec.ts
index f5df5b5c1..dac9949a7 100644
--- a/apps/web/e2e/tests/workflow/workflow-start-step.spec.ts
+++ b/apps/web/e2e/tests/workflow/workflow-start-step.spec.ts
@@ -1,7 +1,11 @@
import { test, expect } from "../../fixtures/test-base";
+import { useRegularMode } from "../../helpers/regular-mode";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
+// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
+useRegularMode();
+
test.describe("Workflow start step placement", () => {
test.describe("without explicit start step (Todo -> In Progress -> Done)", () => {
test("task lands in first step by position (Todo)", async ({
diff --git a/apps/web/hooks/use-editor-keybinds.ts b/apps/web/hooks/use-editor-keybinds.ts
index bd4ac2ea4..8bfe65456 100644
--- a/apps/web/hooks/use-editor-keybinds.ts
+++ b/apps/web/hooks/use-editor-keybinds.ts
@@ -84,13 +84,14 @@ function isEditableTarget(e: KeyboardEvent): boolean {
function handleLayoutToggle(
e: KeyboardEvent,
overrides: StoredShortcutOverrides | undefined,
+ appStore: ReturnType,
): boolean {
if (isEditableTarget(e)) return false;
if (matchesShortcut(e, getShortcut("TOGGLE_SIDEBAR", overrides))) {
e.preventDefault();
e.stopPropagation();
- useDockviewStore.getState().toggleSidebar();
+ appStore.getState().toggleAppSidebar();
return true;
}
@@ -187,7 +188,7 @@ export function useEditorKeybinds() {
return;
}
- if (handleLayoutToggle(e, overrides)) return;
+ if (handleLayoutToggle(e, overrides, appStore)) return;
handleBottomTerminal(e, appStore, previousFocusRef, overrides);
};
diff --git a/apps/web/hooks/use-in-office.test.ts b/apps/web/hooks/use-in-office.test.ts
new file mode 100644
index 000000000..5b3b6d827
--- /dev/null
+++ b/apps/web/hooks/use-in-office.test.ts
@@ -0,0 +1,59 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { renderHook } from "@testing-library/react";
+
+let officeEnabled = false;
+let pathname = "/";
+
+vi.mock("@/hooks/domains/features/use-feature", () => ({
+ useFeature: () => officeEnabled,
+}));
+vi.mock("next/navigation", () => ({
+ usePathname: () => pathname,
+}));
+
+import { useInOffice } from "./use-in-office";
+
+function run(): boolean {
+ return renderHook(() => useInOffice()).result.current;
+}
+
+describe("useInOffice", () => {
+ afterEach(() => {
+ officeEnabled = false;
+ pathname = "/";
+ });
+
+ it("is false in the regular workspace even when the office feature is enabled", () => {
+ officeEnabled = true;
+ pathname = "/";
+ expect(run()).toBe(false);
+ });
+
+ it("is false on a task route (/t/...) with office enabled", () => {
+ officeEnabled = true;
+ pathname = "/t/abc123";
+ expect(run()).toBe(false);
+ });
+
+ it("is true on the office dashboard and office sub-routes", () => {
+ officeEnabled = true;
+ pathname = "/office";
+ expect(run()).toBe(true);
+ pathname = "/office/projects/p1";
+ expect(run()).toBe(true);
+ pathname = "/office/agents";
+ expect(run()).toBe(true);
+ });
+
+ it("is false on /office routes when the office feature is disabled", () => {
+ officeEnabled = false;
+ pathname = "/office";
+ expect(run()).toBe(false);
+ });
+
+ it("does not match a route that merely starts with the word office", () => {
+ officeEnabled = true;
+ pathname = "/officers"; // not an /office route
+ expect(run()).toBe(false);
+ });
+});
diff --git a/apps/web/hooks/use-in-office.ts b/apps/web/hooks/use-in-office.ts
new file mode 100644
index 000000000..e69bc8015
--- /dev/null
+++ b/apps/web/hooks/use-in-office.ts
@@ -0,0 +1,18 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import { useFeature } from "@/hooks/domains/features/use-feature";
+
+/**
+ * True only while the user is actually inside the Office surface — i.e. on an
+ * `/office` route, which is reached via the footer "Office" button.
+ *
+ * Office-specific sidebar sections (Inbox, Projects, Agents) gate on this rather
+ * than just the `office` feature flag, so they stay hidden in the regular
+ * workspace and only appear once the user enters Office.
+ */
+export function useInOffice(): boolean {
+ const officeEnabled = useFeature("office");
+ const pathname = usePathname();
+ return officeEnabled && !!pathname && (pathname === "/office" || pathname.startsWith("/office/"));
+}
diff --git a/apps/web/lib/keyboard/shortcut-overrides.test.ts b/apps/web/lib/keyboard/shortcut-overrides.test.ts
index 6453bc902..131d82798 100644
--- a/apps/web/lib/keyboard/shortcut-overrides.test.ts
+++ b/apps/web/lib/keyboard/shortcut-overrides.test.ts
@@ -2,7 +2,9 @@ import { describe, it, expect } from "vitest";
import { SHORTCUTS } from "./constants";
import {
CONFIGURABLE_SHORTCUTS,
+ UNBOUND_SHORTCUT,
getShortcut,
+ isUnboundShortcut,
resolveAllShortcuts,
type ConfigurableShortcutId,
} from "./shortcut-overrides";
@@ -28,7 +30,7 @@ describe("CONFIGURABLE_SHORTCUTS", () => {
expect(CONFIGURABLE_SHORTCUTS.BOTTOM_TERMINAL.default).toBe(SHORTCUTS.BOTTOM_TERMINAL);
expect(CONFIGURABLE_SHORTCUTS.TOGGLE_SIDEBAR.label).toBe("Toggle Sidebar");
- expect(CONFIGURABLE_SHORTCUTS.TOGGLE_SIDEBAR.default).toBe(SHORTCUTS.TOGGLE_SIDEBAR);
+ expect(CONFIGURABLE_SHORTCUTS.TOGGLE_SIDEBAR.default).toBe(UNBOUND_SHORTCUT);
expect(CONFIGURABLE_SHORTCUTS.COMMAND_PANEL.label).toBe("Command Panel (Alt)");
expect(CONFIGURABLE_SHORTCUTS.COMMAND_PANEL.default).toBe(SHORTCUTS.COMMAND_PANEL);
@@ -50,7 +52,7 @@ describe("CONFIGURABLE_SHORTCUTS", () => {
describe("getShortcut", () => {
it("returns default when no overrides provided", () => {
expect(getShortcut("BOTTOM_TERMINAL")).toBe(SHORTCUTS.BOTTOM_TERMINAL);
- expect(getShortcut("TOGGLE_SIDEBAR")).toBe(SHORTCUTS.TOGGLE_SIDEBAR);
+ expect(getShortcut("TOGGLE_SIDEBAR")).toBe(UNBOUND_SHORTCUT);
});
it("returns default when override does not contain the ID", () => {
@@ -65,7 +67,19 @@ describe("getShortcut", () => {
it("does not affect other shortcuts when one is overridden", () => {
const overrides = { BOTTOM_TERMINAL: { key: "x", modifiers: { ctrlOrCmd: true } } };
- expect(getShortcut("TOGGLE_SIDEBAR", overrides)).toBe(SHORTCUTS.TOGGLE_SIDEBAR);
+ expect(getShortcut("TOGGLE_SIDEBAR", overrides)).toBe(UNBOUND_SHORTCUT);
+ });
+});
+
+describe("isUnboundShortcut", () => {
+ it("returns true for the sentinel and null/undefined", () => {
+ expect(isUnboundShortcut(UNBOUND_SHORTCUT)).toBe(true);
+ expect(isUnboundShortcut(null)).toBe(true);
+ expect(isUnboundShortcut(undefined)).toBe(true);
+ });
+
+ it("returns false for a real shortcut", () => {
+ expect(isUnboundShortcut(SHORTCUTS.BOTTOM_TERMINAL)).toBe(false);
});
});
diff --git a/apps/web/lib/keyboard/shortcut-overrides.ts b/apps/web/lib/keyboard/shortcut-overrides.ts
index 8ac1b7a37..dd984ba22 100644
--- a/apps/web/lib/keyboard/shortcut-overrides.ts
+++ b/apps/web/lib/keyboard/shortcut-overrides.ts
@@ -17,6 +17,17 @@ export type StoredShortcutOverrides = Record<
{ key: string; modifiers?: Record }
>;
+/**
+ * Sentinel "no shortcut" value. `matchesShortcut` never matches a real key
+ * event against an empty key, so using this as a default makes a shortcut
+ * unbound until the user records one.
+ */
+export const UNBOUND_SHORTCUT: KeyboardShortcut = { key: "" as KeyboardShortcut["key"] };
+
+export function isUnboundShortcut(shortcut: KeyboardShortcut | undefined | null): boolean {
+ return !shortcut || (shortcut.key as string) === "";
+}
+
export const CONFIGURABLE_SHORTCUTS: Record<
ConfigurableShortcutId,
{ label: string; default: KeyboardShortcut }
@@ -25,7 +36,7 @@ export const CONFIGURABLE_SHORTCUTS: Record<
FILE_SEARCH: { label: "File Search", default: SHORTCUTS.FILE_SEARCH },
QUICK_CHAT: { label: "Quick Chat", default: SHORTCUTS.QUICK_CHAT },
BOTTOM_TERMINAL: { label: "Toggle Bottom Terminal", default: SHORTCUTS.BOTTOM_TERMINAL },
- TOGGLE_SIDEBAR: { label: "Toggle Sidebar", default: SHORTCUTS.TOGGLE_SIDEBAR },
+ TOGGLE_SIDEBAR: { label: "Toggle Sidebar", default: UNBOUND_SHORTCUT },
COMMAND_PANEL: { label: "Command Panel (Alt)", default: SHORTCUTS.COMMAND_PANEL },
NEW_TASK: { label: "New Task", default: SHORTCUTS.NEW_TASK },
FOCUS_INPUT: { label: "Focus Chat Input", default: SHORTCUTS.FOCUS_INPUT },
diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts
index 30d526481..216185c79 100644
--- a/apps/web/lib/local-storage.ts
+++ b/apps/web/lib/local-storage.ts
@@ -260,8 +260,9 @@ export function setFilesPanelScrollPosition(sessionId: string, position: number)
// new initial-default caps; loading them would resurface the very behaviour
// users complained about (sidebar "maxed out" by default after upgrade).
// Bumping the prefix invalidates legacy saves so every env opens at the
-// preset defaults once, then resumes per-env persistence under v2.
-const DOCKVIEW_ENV_LAYOUT_PREFIX = "kandev.dockview.env-layout-v2.";
+// preset defaults once, then resumes per-env persistence. Bumped to v3 to
+// discard layouts captured with the now-removed dockview sidebar column.
+const DOCKVIEW_ENV_LAYOUT_PREFIX = "kandev.dockview.env-layout-v3.";
/**
* Get the saved dockview layout for a task environment.
@@ -754,6 +755,52 @@ export function pruneSubtaskOrder(map: Record, taskId: string)
return changed;
}
+// --- Unified AppSidebar collapse + section expand state (localStorage, global) ---
+
+const APP_SIDEBAR_COLLAPSED_KEY = "kandev.appSidebar.collapsed";
+const APP_SIDEBAR_SECTION_EXPANDED_KEY = "kandev.appSidebar.sectionExpanded";
+
+export function getStoredAppSidebarCollapsed(fallback: boolean): boolean {
+ return getLocalStorage(APP_SIDEBAR_COLLAPSED_KEY, fallback);
+}
+
+export function setStoredAppSidebarCollapsed(collapsed: boolean): void {
+ setLocalStorage(APP_SIDEBAR_COLLAPSED_KEY, collapsed);
+}
+
+export function getStoredAppSidebarSectionExpanded(
+ fallback: Record,
+): Record {
+ const raw = getLocalStorage>(
+ APP_SIDEBAR_SECTION_EXPANDED_KEY,
+ fallback,
+ ) as unknown;
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ...fallback };
+ const out: Record = { ...fallback };
+ for (const [key, value] of Object.entries(raw as Record)) {
+ if (typeof key === "string" && typeof value === "boolean") {
+ out[key] = value;
+ }
+ }
+ return out;
+}
+
+export function setStoredAppSidebarSectionExpanded(map: Record): void {
+ setLocalStorage(APP_SIDEBAR_SECTION_EXPANDED_KEY, map);
+}
+
+const APP_SIDEBAR_WIDTH_KEY = "kandev.appSidebar.width";
+
+export function getStoredAppSidebarWidth(fallback: number): number {
+ const raw = getLocalStorage(APP_SIDEBAR_WIDTH_KEY, fallback) as unknown;
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return fallback;
+ return raw;
+}
+
+export function setStoredAppSidebarWidth(width: number): void {
+ setLocalStorage(APP_SIDEBAR_WIDTH_KEY, width);
+}
+
// --- Sidebar collapsed subtask parents (sessionStorage, tab-scoped) ---
const COLLAPSED_SUBTASKS_KEY = "kandev.sidebar.collapsedSubtasks";
diff --git a/apps/web/lib/state/dockview-measure.test.ts b/apps/web/lib/state/dockview-measure.test.ts
new file mode 100644
index 000000000..d95f2b9f7
--- /dev/null
+++ b/apps/web/lib/state/dockview-measure.test.ts
@@ -0,0 +1,38 @@
+import { afterEach, describe, expect, it } from "vitest";
+import type { DockviewApi } from "dockview-react";
+import { measureDockviewContainer } from "./dockview-measure";
+
+function fakeApi(width: number, height: number): DockviewApi {
+ return { width, height } as unknown as DockviewApi;
+}
+
+describe("measureDockviewContainer", () => {
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("uses the live container size when it is laid out", () => {
+ const parent = document.createElement("div");
+ Object.defineProperty(parent, "clientWidth", { value: 1500, configurable: true });
+ Object.defineProperty(parent, "clientHeight", { value: 700, configurable: true });
+ const dv = document.createElement("div");
+ dv.className = "dv-dockview";
+ parent.appendChild(dv);
+ document.body.appendChild(parent);
+
+ expect(measureDockviewContainer(fakeApi(0, 0))).toEqual({ width: 1500, height: 700 });
+ });
+
+ it("never returns a zero size on a fresh mount (no container, api not laid out yet)", () => {
+ // Regression: a 0×0 measurement builds the default layout at zero width, so
+ // dockview collapses the horizontal columns into a vertical stack (chat /
+ // files+changes / terminal). Fall back to the viewport instead so the
+ // default builds horizontally; the resize observer then snaps it to the
+ // exact container size.
+ const { width, height } = measureDockviewContainer(fakeApi(0, 0));
+ expect(width).toBeGreaterThan(0);
+ expect(height).toBeGreaterThan(0);
+ expect(width).toBe(window.innerWidth);
+ expect(height).toBe(window.innerHeight);
+ });
+});
diff --git a/apps/web/lib/state/dockview-measure.ts b/apps/web/lib/state/dockview-measure.ts
index 7820bec43..3b050ab4f 100644
--- a/apps/web/lib/state/dockview-measure.ts
+++ b/apps/web/lib/state/dockview-measure.ts
@@ -11,13 +11,32 @@ import type { DockviewApi } from "dockview-react";
* source of truth and lets us recover from a drifted internal width.
*/
export function measureDockviewContainer(api: DockviewApi): { width: number; height: number } {
- if (typeof document === "undefined") {
- return { width: api.width, height: api.height };
- }
+ const live = liveContainerSize();
+ if (live) return live;
+ // No laid-out container (e.g. a fresh client-side navigation mounts dockview
+ // before the grid has painted). `api.width/height` are 0 at that point, and
+ // building the default layout at width 0 makes dockview collapse the
+ // horizontal columns into a vertical stack (chat / files+changes / terminal).
+ // Fall back to the viewport so the default builds horizontally; the resize
+ // observer then snaps the grid to the exact container size.
+ return {
+ width: api.width > 0 ? api.width : viewportWidth(),
+ height: api.height > 0 ? api.height : viewportHeight(),
+ };
+}
+
+function liveContainerSize(): { width: number; height: number } | null {
+ if (typeof document === "undefined") return null;
const dv = document.querySelector(".dv-dockview") as HTMLElement | null;
const parent = dv?.parentElement;
- if (!parent || parent.clientWidth <= 0 || parent.clientHeight <= 0) {
- return { width: api.width, height: api.height };
- }
+ if (!parent || parent.clientWidth <= 0 || parent.clientHeight <= 0) return null;
return { width: parent.clientWidth, height: parent.clientHeight };
}
+
+function viewportWidth(): number {
+ return typeof window !== "undefined" && window.innerWidth > 0 ? window.innerWidth : 1280;
+}
+
+function viewportHeight(): number {
+ return typeof window !== "undefined" && window.innerHeight > 0 ? window.innerHeight : 800;
+}
diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts
index 0e9604db6..9babab2af 100644
--- a/apps/web/lib/state/dockview-store.ts
+++ b/apps/web/lib/state/dockview-store.ts
@@ -16,7 +16,6 @@ import {
RIGHT_BOTTOM_GROUP,
TERMINAL_DEFAULT_ID,
getPresetLayout,
- getPresetSidebarColumn,
applyLayout,
getRootSplitview,
fromDockviewApi,
@@ -288,38 +287,11 @@ function removeRightPanelTabs(state: LayoutState): LayoutState {
function buildVisibilityActions(set: StoreSet, get: StoreGet) {
return {
+ // Legacy dockview-embedded sidebar is gone after the unified AppSidebar
+ // landed; the keybinding redirects to the AppSidebar toggle elsewhere.
+ // We keep these on the store as no-ops so any stragglers compile cleanly.
toggleSidebar: () => {
- const { api, sidebarVisible } = get();
- if (!api) return;
- const liveWidths = captureLiveWidths(api, set);
- preserveChatScrollDuringLayout();
- const { width: safeWidth, height: safeHeight } = measureDockviewContainer(api);
- if (sidebarVisible) {
- const current = fromDockviewApi(api);
- const withoutSidebar: LayoutState = {
- columns: current.columns.filter((c) => c.id !== "sidebar"),
- };
- set({ isRestoringLayout: true, sidebarVisible: false });
- applyLayoutAndSet(api, withoutSidebar, liveWidths, set);
- requestAnimationFrame(() => {
- api.layout(safeWidth, safeHeight);
- syncPinnedWidthsFromApi(api, set);
- set({ isRestoringLayout: false });
- });
- } else {
- const current = fromDockviewApi(api);
- const sidebarCol = getPresetSidebarColumn(get().defaultPreset);
- const withSidebar: LayoutState = {
- columns: [sidebarCol, ...current.columns],
- };
- set({ isRestoringLayout: true, sidebarVisible: true });
- applyLayoutAndSet(api, withSidebar, liveWidths, set);
- requestAnimationFrame(() => {
- api.layout(safeWidth, safeHeight);
- syncPinnedWidthsFromApi(api, set);
- set({ isRestoringLayout: false });
- });
- }
+ /* moved to UI slice: toggleAppSidebar */
},
toggleRightPanels: () => {
const { api, rightPanelsVisible, defaultPreset } = get();
@@ -361,10 +333,8 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) {
}
},
- setSidebarVisible: (visible: boolean) => {
- const { sidebarVisible } = get();
- if (sidebarVisible === visible) return;
- get().toggleSidebar();
+ setSidebarVisible: (_visible: boolean) => {
+ /* moved to UI slice: setAppSidebarCollapsed */
},
setRightPanelsVisible: (visible: boolean) => {
const { rightPanelsVisible } = get();
@@ -783,8 +753,11 @@ export const useDockviewStore = create((set, get) => ({
centerGroupId: CENTER_GROUP,
rightTopGroupId: RIGHT_TOP_GROUP,
rightBottomGroupId: RIGHT_BOTTOM_GROUP,
+ // Legacy fields preserved for backwards compatibility with code that still
+ // reads them; the embedded dockview sidebar pane was removed in favour of
+ // the unified AppSidebar. Treated as inert: sidebarVisible is always false.
sidebarGroupId: SIDEBAR_GROUP,
- sidebarVisible: true,
+ sidebarVisible: false,
rightPanelsVisible: true,
pinnedWidths: new Map(),
setPinnedWidth: (columnId, width) => {
diff --git a/apps/web/lib/state/layout-manager/constants.ts b/apps/web/lib/state/layout-manager/constants.ts
index 2e4de3388..e589689e2 100644
--- a/apps/web/lib/state/layout-manager/constants.ts
+++ b/apps/web/lib/state/layout-manager/constants.ts
@@ -23,7 +23,6 @@ export const SIDEBAR_LOCK = "no-drop-target" as const;
/** Fixed panel IDs that can be saved in layout configs. */
export const KNOWN_PANEL_IDS = new Set([
- "sidebar",
"chat",
"plan",
TERMINAL_DEFAULT_ID,
@@ -37,7 +36,6 @@ export const KNOWN_PANEL_IDS = new Set([
/** Components whose panels are structural and should survive filterEphemeral,
* even when the panel ID is dynamically generated. */
export const STRUCTURAL_COMPONENTS = new Set([
- "sidebar",
"chat",
"plan",
"changes",
@@ -50,7 +48,6 @@ export const STRUCTURAL_COMPONENTS = new Set([
/** Default panel configurations for known panels. */
export const PANEL_REGISTRY: Record> = {
- sidebar: { component: "sidebar", title: "Sidebar" },
chat: { component: "chat", title: "Agent", tabComponent: "permanentTab" },
plan: { component: "plan", title: "Plan", tabComponent: "planTab" },
changes: { component: "changes", title: "Changes", tabComponent: "changesTab" },
diff --git a/apps/web/lib/state/layout-manager/index.ts b/apps/web/lib/state/layout-manager/index.ts
index 2c153e5c3..0b98e85ac 100644
--- a/apps/web/lib/state/layout-manager/index.ts
+++ b/apps/web/lib/state/layout-manager/index.ts
@@ -49,7 +49,6 @@ export {
planLayout,
previewLayout,
getPresetLayout,
- getPresetSidebarColumn,
} from "./presets";
export type { BuiltInPreset } from "./presets";
diff --git a/apps/web/lib/state/layout-manager/presets.test.ts b/apps/web/lib/state/layout-manager/presets.test.ts
index aea2254ed..3d6919c42 100644
--- a/apps/web/lib/state/layout-manager/presets.test.ts
+++ b/apps/web/lib/state/layout-manager/presets.test.ts
@@ -1,32 +1,27 @@
import { describe, expect, it } from "vitest";
-import { compactLayout, defaultLayout, getPresetSidebarColumn } from "./presets";
-import { computeSidebarMaxPx } from "./caps";
+import { compactLayout, defaultLayout, planLayout, previewLayout, vscodeLayout } from "./presets";
describe("layout presets", () => {
- it("keeps the compact workbench on Dockview while prioritizing the center panel", () => {
- const compact = compactLayout();
- const compactSidebar = compact.columns.find((column) => column.id === "sidebar");
- // Default sidebar inherits the runtime cap (no per-column maxWidth);
- // compact pins itself tighter.
- const defaultSidebarCap =
- defaultLayout().columns.find((column) => column.id === "sidebar")?.maxWidth ??
- computeSidebarMaxPx();
+ it("default preset has center + right columns (no embedded sidebar)", () => {
+ const layout = defaultLayout();
+ expect(layout.columns.map((c) => c.id)).toEqual(["center", "right"]);
+ });
- expect(compact.columns.map((column) => column.id)).toEqual(["sidebar", "center"]);
- const compactSidebarWidth = compactSidebar?.width ?? Number.POSITIVE_INFINITY;
- const compactSidebarMaxWidth = compactSidebar?.maxWidth ?? Number.POSITIVE_INFINITY;
- expect(compactSidebarWidth).toBeLessThan(defaultSidebarCap);
- expect(compactSidebarMaxWidth).toBeLessThan(defaultSidebarCap);
- expect(compact.columns.find((column) => column.id === "center")?.groups[0].panels[0].id).toBe(
+ it("compact preset is a single center column with everything tabbed", () => {
+ const layout = compactLayout();
+ expect(layout.columns.map((c) => c.id)).toEqual(["center"]);
+ expect(layout.columns[0].groups[0].panels.map((p) => p.id)).toEqual([
"chat",
- );
+ "files",
+ "changes",
+ "terminal-default",
+ ]);
});
- it("returns compact sidebar sizing for compact preset restoration", () => {
- const compactSidebar = compactLayout().columns.find((column) => column.id === "sidebar");
-
- expect(getPresetSidebarColumn("compact")).toEqual(compactSidebar);
- expect(getPresetSidebarColumn("compact").width).toBe(220);
- expect(getPresetSidebarColumn("compact").maxWidth).toBe(260);
+ it("plan/preview/vscode presets drop the legacy sidebar column", () => {
+ for (const preset of [planLayout(), previewLayout(), vscodeLayout()]) {
+ expect(preset.columns.some((c) => c.id === "sidebar")).toBe(false);
+ expect(preset.columns.some((c) => c.id === "center")).toBe(true);
+ }
});
});
diff --git a/apps/web/lib/state/layout-manager/presets.ts b/apps/web/lib/state/layout-manager/presets.ts
index ba0072202..9c40a6de0 100644
--- a/apps/web/lib/state/layout-manager/presets.ts
+++ b/apps/web/lib/state/layout-manager/presets.ts
@@ -1,25 +1,9 @@
-import type { LayoutColumn, LayoutState } from "./types";
-import {
- SIDEBAR_GROUP,
- CENTER_GROUP,
- RIGHT_TOP_GROUP,
- RIGHT_BOTTOM_GROUP,
- panel,
-} from "./constants";
-
-const COMPACT_SIDEBAR_WIDTH = 220;
-// Compact preset intentionally caps the sidebar tight (small toolbar look),
-// so it overrides the runtime cap rather than inheriting it.
-const COMPACT_SIDEBAR_MAX_PX = 260;
+import type { LayoutState } from "./types";
+import { CENTER_GROUP, RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP, panel } from "./constants";
export function defaultLayout(): LayoutState {
return {
columns: [
- {
- id: "sidebar",
- pinned: true,
- groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }],
- },
{
id: "center",
groups: [{ id: CENTER_GROUP, panels: [panel("chat")] }],
@@ -40,13 +24,6 @@ export function defaultLayout(): LayoutState {
export function compactLayout(): LayoutState {
return {
columns: [
- {
- id: "sidebar",
- pinned: true,
- width: COMPACT_SIDEBAR_WIDTH,
- maxWidth: COMPACT_SIDEBAR_MAX_PX,
- groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }],
- },
{
id: "center",
groups: [
@@ -63,11 +40,6 @@ export function compactLayout(): LayoutState {
export function planLayout(): LayoutState {
return {
columns: [
- {
- id: "sidebar",
- pinned: true,
- groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }],
- },
{
id: "center",
groups: [{ id: CENTER_GROUP, panels: [panel("chat")] }],
@@ -83,11 +55,6 @@ export function planLayout(): LayoutState {
export function previewLayout(): LayoutState {
return {
columns: [
- {
- id: "sidebar",
- pinned: true,
- groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }],
- },
{
id: "center",
groups: [{ id: CENTER_GROUP, panels: [panel("chat")] }],
@@ -103,11 +70,6 @@ export function previewLayout(): LayoutState {
export function vscodeLayout(): LayoutState {
return {
columns: [
- {
- id: "sidebar",
- pinned: true,
- groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }],
- },
{
id: "center",
groups: [{ id: CENTER_GROUP, panels: [panel("chat")] }],
@@ -133,10 +95,3 @@ const PRESET_MAP: Record LayoutState> = {
export function getPresetLayout(preset: BuiltInPreset): LayoutState {
return PRESET_MAP[preset]();
}
-
-export function getPresetSidebarColumn(preset: BuiltInPreset): LayoutColumn {
- return (
- getPresetLayout(preset).columns.find((column) => column.id === "sidebar") ??
- defaultLayout().columns[0]
- );
-}
diff --git a/apps/web/lib/state/slices/ui/app-sidebar-actions.ts b/apps/web/lib/state/slices/ui/app-sidebar-actions.ts
new file mode 100644
index 000000000..d65722746
--- /dev/null
+++ b/apps/web/lib/state/slices/ui/app-sidebar-actions.ts
@@ -0,0 +1,74 @@
+import type { StateCreator } from "zustand";
+import {
+ getStoredAppSidebarCollapsed,
+ getStoredAppSidebarSectionExpanded,
+ getStoredAppSidebarWidth,
+ setStoredAppSidebarCollapsed,
+ setStoredAppSidebarSectionExpanded,
+ setStoredAppSidebarWidth,
+} from "@/lib/local-storage";
+import { APP_SIDEBAR_EXPANDED_WIDTH } from "@/components/app-sidebar/app-sidebar-constants";
+import type { AppSidebarState, UISlice } from "./types";
+
+/** Tasks expanded by default; other sections collapsed. Mirrors the
+ * "open question / risks" note in the spec: keep the unified sidebar from
+ * defaulting too tall on first open. */
+export const DEFAULT_SECTION_EXPANDED: Record = {
+ tasks: true,
+ projects: false,
+ agents: false,
+ integrations: false,
+ settings: false,
+};
+
+export function loadAppSidebarState(): AppSidebarState {
+ return {
+ collapsed: getStoredAppSidebarCollapsed(false),
+ sectionExpanded: getStoredAppSidebarSectionExpanded(DEFAULT_SECTION_EXPANDED),
+ width: getStoredAppSidebarWidth(APP_SIDEBAR_EXPANDED_WIDTH),
+ // Transient — always starts off, never read from / written to storage.
+ settingsMode: false,
+ };
+}
+
+type ImmerSet = Parameters>[0];
+
+export function buildAppSidebarActions(set: ImmerSet) {
+ return {
+ toggleAppSidebar: () =>
+ set((draft) => {
+ const next = !draft.appSidebar.collapsed;
+ draft.appSidebar.collapsed = next;
+ setStoredAppSidebarCollapsed(next);
+ }),
+ setAppSidebarCollapsed: (collapsed: boolean) =>
+ set((draft) => {
+ if (draft.appSidebar.collapsed === collapsed) return;
+ draft.appSidebar.collapsed = collapsed;
+ setStoredAppSidebarCollapsed(collapsed);
+ }),
+ toggleAppSidebarSection: (sectionId: string) =>
+ set((draft) => {
+ const current = draft.appSidebar.sectionExpanded[sectionId] ?? false;
+ draft.appSidebar.sectionExpanded[sectionId] = !current;
+ setStoredAppSidebarSectionExpanded({ ...draft.appSidebar.sectionExpanded });
+ }),
+ setAppSidebarWidth: (width: number) =>
+ set((draft) => {
+ if (draft.appSidebar.width === width) return;
+ draft.appSidebar.width = width;
+ setStoredAppSidebarWidth(width);
+ }),
+ toggleAppSidebarSettingsMode: () =>
+ set((draft) => {
+ const next = !draft.appSidebar.settingsMode;
+ draft.appSidebar.settingsMode = next;
+ // Entering settings mode while collapsed would render an empty rail —
+ // the tree needs the expanded width — so force-expand on the way in.
+ if (next && draft.appSidebar.collapsed) {
+ draft.appSidebar.collapsed = false;
+ setStoredAppSidebarCollapsed(false);
+ }
+ }),
+ };
+}
diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts
index 9a74e8ea8..8e8485b28 100644
--- a/apps/web/lib/state/slices/ui/types.ts
+++ b/apps/web/lib/state/slices/ui/types.ts
@@ -115,6 +115,21 @@ export type SidebarTaskPrefsState = {
subtaskOrderByParentId: Record;
};
+/** Unified AppSidebar collapse + per-section expand state (localStorage). */
+export type AppSidebarState = {
+ collapsed: boolean;
+ /** Keyed by section id: "tasks", "projects", "agents", "settings". */
+ sectionExpanded: Record;
+ /** User-resized expanded width in pixels. */
+ width: number;
+ /**
+ * When true the whole sidebar is taken over by the settings tree (toggled by
+ * the footer gear). Transient view mode — intentionally NOT persisted so a
+ * reload never traps the user in settings.
+ */
+ settingsMode: boolean;
+};
+
export type UISliceState = {
previewPanel: PreviewPanelState;
rightPanel: RightPanelState;
@@ -136,6 +151,8 @@ export type UISliceState = {
kanbanPreviewedTaskId: string | null;
/** Sidebar pin + manual-order. Per-browser, persisted to localStorage. */
sidebarTaskPrefs: SidebarTaskPrefsState;
+ /** Unified AppSidebar collapse + section expand state (localStorage). */
+ appSidebar: AppSidebarState;
};
export type UISliceActions = {
@@ -199,6 +216,11 @@ export type UISliceActions = {
* deleted ID back.
*/
removeTaskFromSidebarPrefs: (taskId: string) => void;
+ toggleAppSidebar: () => void;
+ setAppSidebarCollapsed: (collapsed: boolean) => void;
+ toggleAppSidebarSection: (sectionId: string) => void;
+ setAppSidebarWidth: (width: number) => void;
+ toggleAppSidebarSettingsMode: () => void;
};
export type { SidebarView, SidebarViewDraft };
diff --git a/apps/web/lib/state/slices/ui/ui-slice.test.ts b/apps/web/lib/state/slices/ui/ui-slice.test.ts
index 4b379572e..598f95495 100644
--- a/apps/web/lib/state/slices/ui/ui-slice.test.ts
+++ b/apps/web/lib/state/slices/ui/ui-slice.test.ts
@@ -213,6 +213,92 @@ describe("setSubtaskOrder", () => {
});
});
+describe("appSidebar actions", () => {
+ const COLLAPSED_KEY = "kandev.appSidebar.collapsed";
+ const SECTION_KEY = "kandev.appSidebar.sectionExpanded";
+
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it("hydrates default state when localStorage is empty", () => {
+ const store = makeStore();
+ expect(store.getState().appSidebar.collapsed).toBe(false);
+ expect(store.getState().appSidebar.sectionExpanded.tasks).toBe(true);
+ expect(store.getState().appSidebar.sectionExpanded.projects).toBe(false);
+ });
+
+ it("hydrates collapsed flag from localStorage", () => {
+ window.localStorage.setItem(COLLAPSED_KEY, JSON.stringify(true));
+ const store = makeStore();
+ expect(store.getState().appSidebar.collapsed).toBe(true);
+ });
+
+ it("toggleAppSidebar flips the collapsed flag and persists it", () => {
+ const store = makeStore();
+ store.getState().toggleAppSidebar();
+ expect(store.getState().appSidebar.collapsed).toBe(true);
+ expect(JSON.parse(window.localStorage.getItem(COLLAPSED_KEY) ?? "null")).toBe(true);
+
+ store.getState().toggleAppSidebar();
+ expect(store.getState().appSidebar.collapsed).toBe(false);
+ expect(JSON.parse(window.localStorage.getItem(COLLAPSED_KEY) ?? "null")).toBe(false);
+ });
+
+ it("setAppSidebarCollapsed writes the requested value and persists it", () => {
+ const store = makeStore();
+ store.getState().setAppSidebarCollapsed(true);
+ expect(store.getState().appSidebar.collapsed).toBe(true);
+ expect(JSON.parse(window.localStorage.getItem(COLLAPSED_KEY) ?? "null")).toBe(true);
+ });
+
+ it("toggleAppSidebarSection flips per-section state and persists the map", () => {
+ const store = makeStore();
+ store.getState().toggleAppSidebarSection("projects");
+ expect(store.getState().appSidebar.sectionExpanded.projects).toBe(true);
+ const persisted = JSON.parse(window.localStorage.getItem(SECTION_KEY) ?? "{}");
+ expect(persisted.projects).toBe(true);
+
+ store.getState().toggleAppSidebarSection("projects");
+ expect(store.getState().appSidebar.sectionExpanded.projects).toBe(false);
+ });
+
+ it("settingsMode defaults off and is never read from storage", () => {
+ window.localStorage.setItem("kandev.appSidebar.settingsMode", JSON.stringify(true));
+ const store = makeStore();
+ expect(store.getState().appSidebar.settingsMode).toBe(false);
+ });
+
+ it("toggleAppSidebarSettingsMode flips the flag without persisting it", () => {
+ const store = makeStore();
+ store.getState().toggleAppSidebarSettingsMode();
+ expect(store.getState().appSidebar.settingsMode).toBe(true);
+ expect(window.localStorage.getItem("kandev.appSidebar.settingsMode")).toBeNull();
+
+ store.getState().toggleAppSidebarSettingsMode();
+ expect(store.getState().appSidebar.settingsMode).toBe(false);
+ });
+
+ it("entering settings mode while collapsed force-expands the rail", () => {
+ window.localStorage.setItem(COLLAPSED_KEY, JSON.stringify(true));
+ const store = makeStore();
+ expect(store.getState().appSidebar.collapsed).toBe(true);
+
+ store.getState().toggleAppSidebarSettingsMode();
+ expect(store.getState().appSidebar.settingsMode).toBe(true);
+ expect(store.getState().appSidebar.collapsed).toBe(false);
+ expect(JSON.parse(window.localStorage.getItem(COLLAPSED_KEY) ?? "null")).toBe(false);
+ });
+
+ it("leaving settings mode leaves the collapsed flag untouched", () => {
+ const store = makeStore();
+ store.getState().toggleAppSidebarSettingsMode(); // on (expands)
+ store.getState().toggleAppSidebarSettingsMode(); // off
+ expect(store.getState().appSidebar.settingsMode).toBe(false);
+ expect(store.getState().appSidebar.collapsed).toBe(false);
+ });
+});
+
describe("reorderSidebarViews", () => {
beforeEach(() => {
window.localStorage.clear();
diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts
index a4bb47621..617a869be 100644
--- a/apps/web/lib/state/slices/ui/ui-slice.ts
+++ b/apps/web/lib/state/slices/ui/ui-slice.ts
@@ -16,6 +16,11 @@ import {
setStoredSidebarDraft,
setStoredSidebarUserViews,
} from "@/lib/local-storage";
+import {
+ DEFAULT_SECTION_EXPANDED,
+ buildAppSidebarActions,
+ loadAppSidebarState,
+} from "./app-sidebar-actions";
import { buildSidebarTaskPrefsActions } from "./sidebar-task-prefs-actions";
import { DEFAULT_ACTIVE_VIEW_ID, DEFAULT_VIEW } from "./sidebar-view-builtins";
import type {
@@ -122,6 +127,12 @@ export const defaultUIState: UISliceState = {
collapsedSubtaskParents: [],
kanbanPreviewedTaskId: null,
sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} },
+ appSidebar: {
+ collapsed: false,
+ sectionExpanded: { ...DEFAULT_SECTION_EXPANDED },
+ width: 240,
+ settingsMode: false,
+ },
};
type ImmerSet = Parameters[0];
@@ -528,6 +539,8 @@ export const createUISlice: StateCreator) => void;
@@ -476,6 +477,11 @@ export type AppState = {
setSidebarTaskOrder: UIA["setSidebarTaskOrder"];
setSubtaskOrder: UIA["setSubtaskOrder"];
removeTaskFromSidebarPrefs: UIA["removeTaskFromSidebarPrefs"];
+ toggleAppSidebar: UIA["toggleAppSidebar"];
+ setAppSidebarCollapsed: UIA["setAppSidebarCollapsed"];
+ toggleAppSidebarSection: UIA["toggleAppSidebarSection"];
+ setAppSidebarWidth: UIA["setAppSidebarWidth"];
+ toggleAppSidebarSettingsMode: UIA["toggleAppSidebarSettingsMode"];
// Office actions
setOfficeAgentProfiles: (agents: AgentProfile[]) => void;
addOfficeAgentProfile: (agent: AgentProfile) => void;