(function Page
className={cn("relative flex h-10 shrink-0 items-center gap-3 border-b px-3 py-1", className)}
>
{leading}
- {variant === "root" ? (
-
- {backLabel}
-
- ) : (
-
- )}
+
{leftActions && (
{leftActions}
diff --git a/apps/web/components/settings/keyboard-shortcuts-card.tsx b/apps/web/components/settings/keyboard-shortcuts-card.tsx
index adb484e27c..01b821bb1e 100644
--- a/apps/web/components/settings/keyboard-shortcuts-card.tsx
+++ b/apps/web/components/settings/keyboard-shortcuts-card.tsx
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
-import { IconRotate } from "@tabler/icons-react";
+import { IconRotate, IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Kbd } from "@kandev/ui/kbd";
@@ -9,6 +9,8 @@ import type { Key, KeyboardShortcut } from "@/lib/keyboard/constants";
import { formatShortcut } from "@/lib/keyboard/utils";
import {
CONFIGURABLE_SHORTCUTS,
+ UNBOUND_SHORTCUT,
+ isUnboundShortcut,
resolveAllShortcuts,
type ConfigurableShortcutId,
type StoredShortcutOverrides,
@@ -17,20 +19,28 @@ import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { updateUserSettings } from "@/lib/api/domains/settings-api";
+type ShortcutRecorderProps = {
+ shortcutId: ConfigurableShortcutId;
+ current: KeyboardShortcut;
+ onChange: (id: ConfigurableShortcutId, shortcut: KeyboardShortcut) => void;
+ onReset: (id: ConfigurableShortcutId) => void;
+ // Optional: callers that don't support an explicit "unbind" (e.g. the voice
+ // settings recorder) omit this, and the Clear button is hidden for them.
+ onClear?: (id: ConfigurableShortcutId) => void;
+};
+
export function ShortcutRecorder({
shortcutId,
current,
onChange,
onReset,
-}: {
- shortcutId: ConfigurableShortcutId;
- current: KeyboardShortcut;
- onChange: (id: ConfigurableShortcutId, shortcut: KeyboardShortcut) => void;
- onReset: (id: ConfigurableShortcutId) => void;
-}) {
+ onClear,
+}: ShortcutRecorderProps) {
const [recording, setRecording] = useState(false);
- const isDefault =
- JSON.stringify(current) === JSON.stringify(CONFIGURABLE_SHORTCUTS[shortcutId].default);
+ const defaultShortcut = CONFIGURABLE_SHORTCUTS[shortcutId].default;
+ const isDefault = JSON.stringify(current) === JSON.stringify(defaultShortcut);
+ const isUnbound = isUnboundShortcut(current);
+ const defaultIsUnbound = isUnboundShortcut(defaultShortcut);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
@@ -85,19 +95,28 @@ export function ShortcutRecorder({
: "border-border bg-background hover:bg-accent"
}`}
>
- {recording ? (
- Press a key combo...
- ) : (
- {formatShortcut(current)}
- )}
+ {renderRecorderLabel({ recording, current, isUnbound })}
+ {onClear && !isUnbound && !defaultIsUnbound && (
+
+ )}
{!isDefault && (
@@ -107,6 +126,20 @@ export function ShortcutRecorder({
);
}
+function renderRecorderLabel({
+ recording,
+ current,
+ isUnbound,
+}: {
+ recording: boolean;
+ current: KeyboardShortcut;
+ isUnbound: boolean;
+}) {
+ if (recording) return Press a key combo...;
+ if (isUnbound) return Unbound;
+ return {formatShortcut(current)};
+}
+
export function KeyboardShortcutsCard() {
const storeOverrides = useAppStore((s) => s.userSettings.keyboardShortcuts);
const setUserSettings = useAppStore((s) => s.setUserSettings);
@@ -143,6 +176,14 @@ export function KeyboardShortcutsCard() {
[storeOverrides, persistOverrides],
);
+ const handleClear = useCallback(
+ (id: ConfigurableShortcutId) => {
+ const next = { ...storeOverrides, [id]: UNBOUND_SHORTCUT };
+ persistOverrides(next);
+ },
+ [storeOverrides, persistOverrides],
+ );
+
const ids = Object.keys(CONFIGURABLE_SHORTCUTS) as ConfigurableShortcutId[];
return (
@@ -159,6 +200,7 @@ export function KeyboardShortcutsCard() {
current={shortcuts[id]}
onChange={handleChange}
onReset={handleReset}
+ onClear={handleClear}
/>
))}
diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx
deleted file mode 100644
index e58ecd4ed4..0000000000
--- a/apps/web/components/settings/settings-app-sidebar.tsx
+++ /dev/null
@@ -1,453 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-import Link from "next/link";
-import { usePathname } from "next/navigation";
-import {
- IconSettings,
- IconFolder,
- IconRobot,
- IconBell,
- IconCode,
- IconCpu,
- IconKey,
- IconMicrophone,
- IconMessageCircle,
- IconBrandGithub,
- IconBrandGitlab,
- IconBrandSentry,
- IconBrandSlack,
- IconHexagon,
- IconWand,
- IconGitBranch,
- IconArrowsShuffle,
- IconTicket,
- IconPlugConnected,
- IconBolt,
- IconActivity,
- IconDatabase,
- IconArchive,
- IconFileText,
- IconRefresh,
- IconScale,
- IconInfoCircle,
- IconServerCog,
-} from "@tabler/icons-react";
-import {
- Sidebar,
- SidebarContent,
- SidebarGroup,
- SidebarGroupLabel,
- SidebarGroupContent,
- SidebarMenu,
- SidebarMenuItem,
- SidebarMenuButton,
- SidebarMenuSub,
- SidebarMenuSubItem,
- SidebarMenuSubButton,
- SidebarHeader,
- useSidebar,
-} from "@kandev/ui/sidebar";
-import { ScrollArea } from "@kandev/ui/scroll-area";
-import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
-import { useAppStore } from "@/components/state-provider";
-import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents";
-
-import { AgentLogo } from "@/components/agent-logo";
-import { getExecutorIcon } from "@/lib/executor-icons";
-import { getCapabilityWarning } from "@/lib/capability-warning";
-import type { Agent, AgentProfile, Executor } from "@/lib/types/http";
-import type { WorkspaceState } from "@/lib/state/slices";
-
-type WorkspaceItem = WorkspaceState["items"][number];
-
-type GeneralSidebarSectionProps = {
- pathname: string;
-};
-
-function GeneralSidebarSection({ pathname }: GeneralSidebarSectionProps) {
- return (
-
-
-
-
- General
-
-
-
-
-
-
-
- Notifications
-
-
-
-
-
-
-
- Editors
-
-
-
-
-
- );
-}
-
-type WorkspacesSidebarSectionProps = {
- pathname: string;
- workspaces: WorkspaceItem[];
-};
-
-function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSectionProps) {
- return (
-
-
-
-
- Workspaces
-
-
- {workspaces.length > 0 && (
-
- {workspaces.map((workspace) => {
- const workspacePath = `/settings/workspace/${workspace.id}`;
- const workflowsPath = `${workspacePath}/workflows`;
- const repositoriesPath = `${workspacePath}/repositories`;
-
- return (
-
-
-
- {workspace.name}
-
-
-
-
-
-
-
- Repositories
-
-
-
-
-
-
-
- Workflows
-
-
-
-
-
- );
- })}
-
- )}
-
- );
-}
-
-function SystemSidebarSection({ pathname }: { pathname: string }) {
- const items: Array<{ href: string; label: string; Icon: typeof IconBrandGithub }> = [
- { href: "/settings/system/status", label: "Status", Icon: IconActivity },
- { href: "/settings/system/database", label: "Database", Icon: IconDatabase },
- { href: "/settings/system/backups", label: "Backups", Icon: IconArchive },
- { href: "/settings/system/logs", label: "Logs", Icon: IconFileText },
- { href: "/settings/system/updates", label: "Updates", Icon: IconRefresh },
- { href: "/settings/system/about", label: "About", Icon: IconInfoCircle },
- { href: "/settings/system/licenses", label: "Licenses", Icon: IconScale },
- ];
- const isSystem = pathname.startsWith("/settings/system");
- return (
-
-
-
-
- System
-
-
-
- {items.map(({ href, label, Icon }) => (
-
-
-
-
- {label}
-
-
-
- ))}
-
-
- );
-}
-
-function IntegrationsSidebarSection({ pathname }: { pathname: string }) {
- const items: Array<{ href: string; label: string; Icon: typeof IconBrandGithub }> = [
- { href: "/settings/integrations/github", label: "GitHub", Icon: IconBrandGithub },
- { href: "/settings/integrations/gitlab", label: "GitLab", Icon: IconBrandGitlab },
- { href: "/settings/integrations/jira", label: "Jira", Icon: IconTicket },
- { href: "/settings/integrations/linear", label: "Linear", Icon: IconHexagon },
- { href: "/settings/integrations/sentry", label: "Sentry", Icon: IconBrandSentry },
- { href: "/settings/integrations/slack", label: "Slack", Icon: IconBrandSlack },
- ];
- return (
-
-
-
-
- Integrations
-
-
-
- {items.map(({ href, label, Icon }) => (
-
-
-
-
- {label}
-
-
-
- ))}
-
-
- );
-}
-
-type AgentsSidebarSectionProps = {
- pathname: string;
- agents: Agent[];
-};
-
-function AgentsSidebarSection({ pathname, agents }: AgentsSidebarSectionProps) {
- return (
-
-
-
-
- Agents
-
-
- {agents.length > 0 && (
-
- {agents.flatMap((agent: Agent) =>
- agent.profiles.map((profile: AgentProfile) => {
- const encodedAgent = encodeURIComponent(agent.name);
- const profilePath = `/settings/agents/${encodedAgent}/profiles/${profile.id}`;
- const agentLabel = profile.agentDisplayName || agent.name;
- const warning = getCapabilityWarning(agent.capability_status, agent.capability_error);
- return (
-
-
-
-
-
- {agentLabel} • {profile.name}
-
- {warning && }
-
-
-
- );
- }),
- )}
-
- )}
-
- );
-}
-
-type ExecutorsSidebarSectionProps = {
- pathname: string;
- executors: Executor[];
-};
-
-function ExecutorsSidebarSection({ pathname, executors }: ExecutorsSidebarSectionProps) {
- const allProfiles = executors.flatMap((e) =>
- (e.profiles ?? []).map((p) => ({ ...p, executorType: e.type })),
- );
-
- return (
-
-
-
-
- Executors
-
-
- {allProfiles.length > 0 && (
-
- {allProfiles.map((profile) => {
- const Icon = getExecutorIcon(profile.executorType);
- const profilePath = `/settings/executors/${profile.id}`;
- return (
-
-
-
-
- {profile.name}
-
-
-
- );
- })}
-
- )}
-
- );
-}
-
-type SimpleSidebarEntry = {
- href: string;
- label: string;
- Icon: typeof IconBrandGithub;
-};
-
-/**
- * A short row of single-link sidebar entries (Automations, Prompts, Voice
- * Mode, Utility Agents, External MCP) — extracted from `SettingsAppSidebar`
- * so the parent function stays under the 100-line lint limit.
- */
-function SimpleSidebarRows({
- pathname,
- entries,
-}: {
- pathname: string;
- entries: SimpleSidebarEntry[];
-}) {
- return (
- <>
- {entries.map(({ href, label, Icon }) => (
-
-
-
-
- {label}
-
-
-
- ))}
- >
- );
-}
-
-function SecretsSidebarSection({ pathname }: { pathname: string }) {
- return (
-
-
-
-
- Secrets
-
-
-
- );
-}
-
-export function SettingsAppSidebar() {
- const pathname = usePathname();
- const { setOpenMobile, isMobile } = useSidebar();
- const workspaces = useAppStore((state) => state.workspaces.items);
- const executors = useAppStore((state) => state.executors.items);
- const agents = useAppStore((state) => state.settingsAgents.items);
- useAvailableAgents();
-
- // Close mobile sidebar when navigating to a new page
- useEffect(() => {
- if (isMobile) {
- setOpenMobile(false);
- }
- }, [pathname, isMobile, setOpenMobile]);
-
- return (
-
-
-
-
-
-
- Kandev
-
-
-
-
-
-
-
-
- Settings
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* System */}
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/components/settings/settings-layout-client.tsx b/apps/web/components/settings/settings-layout-client.tsx
index f4e98be1c2..e57c20a6d0 100644
--- a/apps/web/components/settings/settings-layout-client.tsx
+++ b/apps/web/components/settings/settings-layout-client.tsx
@@ -1,10 +1,8 @@
"use client";
import { usePathname } from "next/navigation";
-import { SidebarInset, SidebarProvider, SidebarTrigger } from "@kandev/ui/sidebar";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { PageTopbar } from "@/components/page-topbar";
-import { SettingsAppSidebar } from "@/components/settings/settings-app-sidebar";
// Brand/initialism overrides so the derived label matches how the rest of the
// app spells these (e.g. "github" → "GitHub", not "Github"). Anything not
@@ -108,20 +106,20 @@ function SettingsShell({
}) {
return (
-
-
-
- }
- />
- {children}
-
-
+
+
+ {/* Scroll the content, not the topbar: min-h-0 lets this flex child
+ shrink below its content height so overflow-y-auto can take effect. */}
+
+ {children}
+
+
);
}
diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx
index 5f8a92656d..3a447bcf30 100644
--- a/apps/web/components/task/dockview-desktop-layout.tsx
+++ b/apps/web/components/task/dockview-desktop-layout.tsx
@@ -28,7 +28,6 @@ import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
import { useActiveTaskHasRepos } from "@/hooks/domains/kanban/use-active-task-has-repos";
// Panel components (rendered via portals, not directly by dockview)
-import { TaskSessionSidebar } from "./task-session-sidebar";
import { LeftHeaderActions, RightHeaderActions } from "./dockview-header-actions";
import { DockviewWatermark } from "./dockview-watermark";
import { TaskChatPanel } from "./task-chat-panel";
@@ -141,7 +140,6 @@ function PortalSlot(props: IDockviewPanelProps) {
// All panel types use the same PortalSlot wrapper — dockview only manages
// layout positioning. Actual rendering happens in PanelPortalHost below.
const components: Record> = {
- sidebar: PortalSlot,
chat: PortalSlot,
"diff-viewer": PortalSlot,
"file-editor": PortalSlot,
@@ -180,7 +178,13 @@ function useSyncUserDefaultLayout() {
const state = defaultLayout?.layout as unknown as
| import("@/lib/state/layout-manager").LayoutState
| undefined;
- setUserDefaultLayout(state?.columns ? state : null);
+ // Drop the obsolete "sidebar" column: the dockview-embedded sidebar pane was
+ // retired for the unified AppSidebar, but a default layout saved before that
+ // change still carries it. The default-build path applies this layout
+ // without the restore-time sanitize layer, so an orphaned sidebar column
+ // (its panel component is no longer registered) renders a broken grid.
+ const columns = state?.columns?.filter((c) => c.id !== "sidebar");
+ setUserDefaultLayout(columns && columns.length > 0 ? { ...state, columns } : null);
}, [savedLayouts, setUserDefaultLayout]);
}
@@ -203,22 +207,6 @@ const tabComponents: Record state.workspaces.activeId);
- // Read kanban.workflowId (task snapshot), not workflows.activeId (homepage filter), to preserve "All Workflows" across task navigation.
- const workflowId = useAppStore((state) => state.kanban.workflowId);
- const workspaceName = useAppStore((state) => {
- const ws = state.workspaces.items.find((w: { id: string }) => w.id === workspaceId);
- return ws?.name ?? "Workspace";
- });
-
- useEffect(() => {
- setPanelTitle(panelId, workspaceName);
- }, [panelId, workspaceName]);
-
- return ;
-}
-
export const CHAT_PANEL_FALLBACK_LABEL = "Agent";
export function resolveChatPanelTitle(agentLabel: string | null | undefined): string {
@@ -442,7 +430,7 @@ function renderPanel(
switch (resolved) {
case "sidebar":
- return ;
+ return null;
case "chat":
return ;
case "diff-viewer":
diff --git a/apps/web/components/task/dockview-layout-setup.ts b/apps/web/components/task/dockview-layout-setup.ts
index 236ba622ce..7bfc4731e7 100644
--- a/apps/web/components/task/dockview-layout-setup.ts
+++ b/apps/web/components/task/dockview-layout-setup.ts
@@ -4,62 +4,89 @@ import type { AppState } from "@/lib/state/store";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { getRootSplitview } from "@/lib/state/dockview-layout-builders";
import {
- computeSidebarMaxPx,
computeRightMaxPx,
LAYOUT_PINNED_MIN_PX,
RIGHT_TOP_GROUP,
RIGHT_BOTTOM_GROUP,
setPinnedTarget,
+ getPinnedTarget,
} from "@/lib/state/layout-manager";
-import { setEnvLayout, setGlobalSidebarWidth } from "@/lib/local-storage";
+import { setEnvLayout } from "@/lib/local-storage";
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
import { stopVscode } from "@/lib/api/domains/vscode-api";
-import { stopUserShell } from "@/lib/api/domains/user-shell-api";
-import { createDebugLogger, isDebug } from "@/lib/debug/log";
-import { snapshotColumnWidths, formatWidthsSnapshot } from "@/lib/state/dockview-widths-debug";
-import { enforcePinnedTargets, setSashDragging } from "@/lib/state/dockview-pinned-enforce";
+import { parkUserShell, stopUserShell } from "@/lib/api/domains/user-shell-api";
-const debugWidths = createDebugLogger("dockview:widths");
-
-// v2: bumped alongside DOCKVIEW_ENV_LAYOUT_PREFIX so the no-env fallback
-// also invalidates layouts saved under the previous caps.
-const LAYOUT_STORAGE_KEY = "dockview-layout-v2";
+// v3: bumped alongside DOCKVIEW_ENV_LAYOUT_PREFIX so the no-env fallback
+// also discards layouts captured with the now-removed dockview sidebar column.
+const LAYOUT_STORAGE_KEY = "dockview-layout-v3";
const terminalTerminateClosePanelIds = new Set();
export function markTerminalPanelTerminateClose(panelId: string): void {
terminalTerminateClosePanelIds.add(panelId);
}
-// Pinned-column target enforcement and the `sashDragging` flag live in
-// `lib/state/dockview-pinned-enforce.ts` so the store can call enforcement
-// without importing this component-layer module. The sash mousedown/mouseup
-// handlers below toggle the flag via `setSashDragging`.
+/**
+ * Pinned-column target enforcement.
+ *
+ * Dockview's splitview rebalances proportionally on any `api.layout` call,
+ * which would otherwise grow pinned columns past their initial defaults on
+ * container expansion and shrink them on container contraction. We treat
+ * sidebar/right as having a *target width* (stored in `pinned-targets.ts`)
+ * that is updated only by explicit user actions (drag, initial layout,
+ * restore from saved); after every layout-change event we force the live
+ * columns back to their targets via `sv.resizeView`.
+ */
-/** Set the loose runtime cap so the user can drag the column past its target.
- * Uses `api.width` (dockview's measured grid width) for the cap computation
- * instead of `window.innerWidth`, which can briefly read stale during route
- * transitions and devtools toggles - a vw=601 read yields cap=301 (=
- * vw - VIEWPORT_RESERVE_PX) and squeezes the pinned column down to 301. */
+/** Enforcement-in-progress guard to prevent infinite loops when our own
+ * `sv.resizeView` triggers `onDidLayoutChange`. */
+let enforcing = false;
+
+/** True while the user is actively dragging a `.dv-sash`. We pause target
+ * enforcement during the drag so the in-progress resize doesn't get
+ * reverted to the previous target on every intermediate layout change. */
+let sashDragging = false;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function restoreColumnToTarget(sv: any, idx: number, target: number | undefined): void {
+ if (target === undefined) return;
+ const cur = sv.getViewSize(idx);
+ if (Math.abs(cur - target) <= 1) return;
+ try {
+ sv.resizeView(idx, target);
+ } catch {
+ /* dockview rejects unreachable sizes — ignore */
+ }
+}
+
+function enforcePinnedTargets(api: DockviewReadyEvent["api"]): void {
+ if (enforcing || sashDragging) return;
+ const store = useDockviewStore.getState();
+ if (store.isRestoringLayout) return;
+ if (api.hasMaximizedGroup() || store.preMaximizeLayout !== null) return;
+ const sv = getRootSplitview(api);
+ if (!sv || sv.length < 2) return;
+ enforcing = true;
+ try {
+ if (store.rightPanelsVisible) {
+ restoreColumnToTarget(sv, sv.length - 1, getPinnedTarget("right"));
+ }
+ } finally {
+ enforcing = false;
+ }
+}
+
+/** Set the loose runtime cap so the user can drag the column past its target. */
function setLooseConstraints(api: DockviewReadyEvent["api"]): void {
const store = useDockviewStore.getState();
if (store.isRestoringLayout) return;
if (api.hasMaximizedGroup() || store.preMaximizeLayout !== null) return;
- const vw = api.width > 0 ? api.width : undefined;
- const sb = api.getPanel("sidebar");
- if (sb && store.sidebarVisible) {
- sb.group.api.setConstraints({
- maximumWidth: computeSidebarMaxPx(vw),
- minimumWidth: LAYOUT_PINNED_MIN_PX,
- });
- }
-
if (store.rightPanelsVisible) {
for (const gid of [RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]) {
const group = api.groups.find((g) => g.id === gid);
if (group) {
group.api.setConstraints({
- maximumWidth: computeRightMaxPx(vw),
+ maximumWidth: computeRightMaxPx(),
minimumWidth: LAYOUT_PINNED_MIN_PX,
});
}
@@ -80,62 +107,28 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo
// enforced post-hoc via `enforcePinnedTargets`.
setLooseConstraints(api);
- const layoutSub = api.onDidLayoutChange(() => {
- // Skip the reactive enforcement during a programmatic restore - the
- // restore path itself calls `enforcePinnedTargets` synchronously inside
- // its rAF so the user never sees a transient post-rebalance flicker.
- // Without this gate, the in-flight restore (which fires its own
- // layout-change events) would be re-entered before its rAF settled.
- const s = useDockviewStore.getState();
- if (s.isRestoringLayout) return;
- enforcePinnedTargets(api, {
- sidebarVisible: s.sidebarVisible,
- rightPanelsVisible: s.rightPanelsVisible,
- maximized: s.preMaximizeLayout !== null,
- });
- });
+ const layoutSub = api.onDidLayoutChange(() => enforcePinnedTargets(api));
if (typeof document === "undefined") {
return () => layoutSub.dispose();
}
- // Local mirror of the enforcement-module flag so the mouseup handler can
- // short-circuit when no drag was in progress without round-tripping through
- // a getter. Both this local and the module flag (via setSashDragging) must
- // stay in lockstep.
- let dragging = false;
const onMouseDown = (e: MouseEvent): void => {
// Only track primary-button drags. A right/middle mousedown that didn't
- // start a drag must not leave the flag permanently set (cubic P2).
+ // start a drag must not leave `sashDragging` permanently set (cubic P2).
if (e.button !== 0) return;
const t = e.target as HTMLElement | null;
- if (t?.closest(".dv-sash")) {
- dragging = true;
- setSashDragging(true);
- }
+ if (t?.closest(".dv-sash")) sashDragging = true;
};
const onMouseUp = (e: MouseEvent): void => {
- if (e.button !== 0 || !dragging) return;
- dragging = false;
- setSashDragging(false);
+ if (e.button !== 0 || !sashDragging) return;
+ sashDragging = false;
// Capture the post-drag width as the new target.
requestAnimationFrame(() => {
const sv = getRootSplitview(api);
if (!sv) return;
const store = useDockviewStore.getState();
- if (store.sidebarVisible) {
- // A genuine drag is the ONLY writer of the global sidebar width pref.
- // Persist it globally and mirror into the store's pinnedWidths so the
- // override path (resolvePresetPinnedWidths / classifyColumns) agrees.
- const sidebarW = sv.getViewSize(0);
- setPinnedTarget("sidebar", sidebarW);
- setGlobalSidebarWidth(sidebarW);
- store.setPinnedWidth("sidebar", sidebarW);
- }
if (store.rightPanelsVisible) setPinnedTarget("right", sv.getViewSize(sv.length - 1));
- if (isDebug()) {
- debugWidths(`sash-drag-end ${formatWidthsSnapshot(snapshotColumnWidths(api))}`);
- }
});
};
document.addEventListener("mousedown", onMouseDown, true);
@@ -145,14 +138,37 @@ export function setupSashDragCapToggle(api: DockviewReadyEvent["api"]): () => vo
layoutSub.dispose();
document.removeEventListener("mousedown", onMouseDown, true);
document.removeEventListener("mouseup", onMouseUp, true);
- // Reset both flags so an unmount mid-drag (e.g. user navigates away while
- // holding a sash) doesn't leave enforcement permanently paused for the
- // next mount.
- dragging = false;
- setSashDragging(false);
+ // Reset the module-scope flag so an unmount mid-drag (e.g. user navigates
+ // away while holding a sash) doesn't leave enforcement permanently paused
+ // for the next mount (claude).
+ sashDragging = false;
};
}
+function trackPinnedWidths(api: DockviewReadyEvent["api"]): void {
+ const store = useDockviewStore.getState();
+ if (store.isRestoringLayout) return;
+ if (api.hasMaximizedGroup() || store.preMaximizeLayout !== null) return;
+ const sv = getRootSplitview(api);
+ if (!sv || sv.length < 2) return;
+ try {
+ // Right column is the last grid index when present. Skip when there is
+ // no right column (compact preset, rightPanelsVisible=false).
+ if (store.rightPanelsVisible) {
+ const rightIdx = sv.length - 1;
+ const rightW = sv.getViewSize(rightIdx);
+ if (rightW > 50) {
+ const current = store.pinnedWidths.get("right");
+ if (current !== rightW) {
+ store.setPinnedWidth("right", rightW);
+ }
+ }
+ }
+ } catch {
+ /* noop */
+ }
+}
+
/**
* Keep dockview's internal grid width in sync with the live DOM container.
*
@@ -175,12 +191,6 @@ export function setupContainerResizeSync(api: DockviewReadyEvent["api"]): () =>
const h = parent.clientHeight;
if (w <= 0 || h <= 0) return;
if (w === api.width && h === api.height) return;
- if (isDebug()) {
- debugWidths(
- `container-resize prev=${api.width}x${api.height} next=${w}x${h} ` +
- `pre=${formatWidthsSnapshot(snapshotColumnWidths(api))}`,
- );
- }
api.layout(w, h);
// `enforcePinnedTargets` (wired in `setupSashDragCapToggle`) restores
// sidebar/right to their target widths via `onDidLayoutChange`, so we
@@ -195,8 +205,11 @@ export function setupGroupTracking(api: DockviewReadyEvent["api"]): () => void {
useDockviewStore.setState({ activeGroupId: group?.id ?? null });
});
useDockviewStore.setState({ activeGroupId: api.activeGroup?.id ?? null });
+ const d2 = api.onDidLayoutChange(() => trackPinnedWidths(api));
+ trackPinnedWidths(api);
return () => {
d1.dispose();
+ d2.dispose();
};
}
@@ -211,11 +224,6 @@ export function setupLayoutPersistence(
try {
const json = api.toJSON();
const envId = envIdRef.current;
- // Global snapshot of the last layout. NOTE: restore is per-env (see
- // tryRestoreLayout) — this key is no longer read on load, since
- // restoring a cross-env layout flashed the previous task's proportions
- // while a fresh task prepared. Kept as a debounced-save checkpoint that
- // e2e polls to know a layout change has flushed before reloading.
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(json));
if (envId) {
setEnvLayout(envId, json);
@@ -314,8 +322,8 @@ function resolveSessionForEntry(
return match?.[0] ?? active;
}
-/** Tab close → destroy the shell (PTY stopped, DB row removed). When the tab
- * component already destroyed the shell it marks the panel id so we skip. */
+/** Tab close → ordinary terminals park (PTY + DB row survive, reappear in
+ * the "+" menu); scripts/bottom-panel/legacy passthrough still destroy. */
function handleTerminalPanelClosed(
appStore: StoreApi,
panelId: string,
@@ -331,9 +339,19 @@ function handleTerminalPanelClosed(
const fallbackEnv = active ? (state.environmentIdBySessionId[active] ?? null) : null;
const envForTerminal = stampedEnv || fallbackEnv;
if (!envForTerminal) return;
- stopUserShell(envForTerminal, terminalId, stampedTaskID)
- .then(() => state.removeUserShell(envForTerminal, terminalId))
- .catch((err: unknown) => console.warn("stop terminal on tab close:", err));
+ const shell = state.userShells.byEnvironmentId[envForTerminal]?.find(
+ (s) => s.terminalId === terminalId,
+ );
+ if (shell?.kind === "ordinary") {
+ parkUserShell(terminalId, stampedTaskID).then(
+ () => state.updateUserShell(envForTerminal, terminalId, { state: "parked" }),
+ (err: unknown) => console.error("park terminal on tab close:", err),
+ );
+ } else {
+ stopUserShell(envForTerminal, terminalId, stampedTaskID).catch((err: unknown) =>
+ console.warn("stop terminal on tab close:", err),
+ );
+ }
}
export function setupPortalCleanup(
@@ -342,10 +360,8 @@ export function setupPortalCleanup(
): void {
api.onDidRemovePanel((panel) => {
if (useDockviewStore.getState().isRestoringLayout) return;
- const nonSidebarRemaining = api.panels.filter(
- (p) => p.id !== panel.id && p.api.component !== "sidebar",
- ).length;
- handleMaximizeExitOnLastClose(api, panel.id, nonSidebarRemaining);
+ const remainingPanelCount = api.panels.filter((p) => p.id !== panel.id).length;
+ handleMaximizeExitOnLastClose(api, panel.id, remainingPanelCount);
const entry = panelPortalManager.get(panel.id);
const sessionForApi = resolveSessionForEntry(appStore, entry?.envId);
if (entry?.component === "vscode" && sessionForApi) stopVscode(sessionForApi);
diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts
index da127963ed..47d0f93ec3 100644
--- a/apps/web/components/task/dockview-session-tabs.ts
+++ b/apps/web/components/task/dockview-session-tabs.ts
@@ -129,10 +129,7 @@ export function setupChatPanelSafetyNet(
const hasChatPanel = api.panels.some((p) => p.id === "chat" || p.id.startsWith("session:"));
if (hasChatPanel) return;
const activeSessionId = appStore.getState().tasks.activeSessionId;
- const sb = api.getPanel("sidebar");
- const position = sb
- ? { direction: "right" as const, referencePanel: "sidebar" }
- : undefined;
+ const position = undefined;
// Only recreate a panel if there's still an active session.
// If all sessions were deleted, leave the layout empty — the user
// can create a new session via the "+" menu.
@@ -160,7 +157,7 @@ export function setupChatPanelSafetyNet(
debug("setupChatPanelSafetyNet: recreating session panel", {
activeSessionId,
activeTaskId,
- anchor: sb ? "rightOfSidebar" : "auto",
+ anchor: "auto",
});
}
api.addPanel({
@@ -308,6 +305,13 @@ export function findSessionAnchorGroupId(api: DockviewApi): string | null {
}
function resolveInitialPosition(api: DockviewApi): AddPanelOptions["position"] {
+ // Prefer the live "chat" placeholder's group. The session panel must be added
+ // INTO that group (so it's a tab beside chat) BEFORE chat is removed —
+ // otherwise removing chat empties and destroys the center group, the stored
+ // centerGroupId goes stale, and the session panel gets appended as a new row,
+ // collapsing the horizontal default layout into a vertical stack.
+ const chatGroupId = api.getPanel("chat")?.group?.id;
+ if (chatGroupId) return { referenceGroup: chatGroupId };
const { centerGroupId } = useDockviewStore.getState();
const centerGroupExists = centerGroupId && api.groups.some((g) => g.id === centerGroupId);
if (centerGroupExists) return { referenceGroup: centerGroupId };
@@ -319,8 +323,6 @@ function resolveInitialPosition(api: DockviewApi): AddPanelOptions["position"] {
// to the right of pr-detail, which contradicts the default placement
// every other code path produces. Pick the consistent default.
if (anchorGroupId) return { referenceGroup: anchorGroupId, index: 0 };
- const sb = api.getPanel("sidebar");
- if (sb) return { direction: "right" as const, referencePanel: "sidebar" };
return undefined;
}
@@ -402,20 +404,25 @@ export function reconcileRemovedSessionPanels(
const EMPTY_SESSION_IDS_KEY = "";
/**
- * Drop the generic "chat" placeholder once a real session is active.
- * Skips removal in maximized state to preserve the saved maximize layout.
- * Returns true when callers should continue ensuring session panels;
- * false when the maximized state intentionally suppresses them.
+ * Whether the layout is maximized — session panels are intentionally absent
+ * then (restored on exit-maximize). Returns true when callers should continue
+ * ensuring panels, false to skip.
*/
-function prepareLayoutForSessionPanels(api: DockviewApi): boolean {
- const preMaximizeLayout = useDockviewStore.getState().preMaximizeLayout;
+function shouldEnsureSessionPanels(): boolean {
+ return useDockviewStore.getState().preMaximizeLayout === null;
+}
+
+/**
+ * Drop the generic "chat" placeholder once a real session panel exists.
+ *
+ * MUST run AFTER the session panel has been added to chat's group — removing
+ * chat while it's the only panel in the center group destroys that group and
+ * collapses the horizontal default layout into a vertical stack.
+ */
+function removeChatPlaceholder(api: DockviewApi): void {
+ if (useDockviewStore.getState().preMaximizeLayout) return;
const chatPanel = api.getPanel("chat");
- if (chatPanel && !preMaximizeLayout) {
- api.removePanel(chatPanel);
- }
- // In maximized state, session panels are intentionally absent from the
- // layout — they'll be restored when the user exits maximize.
- return preMaximizeLayout === null;
+ if (chatPanel) api.removePanel(chatPanel);
}
/**
@@ -548,7 +555,7 @@ function shouldSkipPanelEnsure(
}
return true;
}
- if (!prepareLayoutForSessionPanels(api)) {
+ if (!shouldEnsureSessionPanels()) {
if (isDebug())
debug("useAutoSessionTab: skip body (maximized - panels suppressed)", { effectiveSessionId });
createdSet.add(effectiveSessionId);
@@ -625,6 +632,11 @@ function runAutoSessionTabEffect(
refs.sessionTabCreatedRef.current,
);
+ // Now that the session panel occupies the center group, drop the generic
+ // "chat" placeholder. Order matters: removing chat first would empty and
+ // destroy the center group, collapsing the horizontal layout.
+ removeChatPlaceholder(api);
+
const activePanel = activateSessionPanel(
api,
effectiveSessionId,
diff --git a/apps/web/components/task/dockview-shared.tsx b/apps/web/components/task/dockview-shared.tsx
index 4311adcf99..c54c5edb51 100644
--- a/apps/web/components/task/dockview-shared.tsx
+++ b/apps/web/components/task/dockview-shared.tsx
@@ -14,7 +14,6 @@ import { useSessionCommits } from "@/hooks/domains/session/use-session-commits";
import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
// Panel components (rendered via portals, not directly by dockview)
-import { TaskSessionSidebar } from "./task-session-sidebar";
import { TaskChatPanel } from "./task-chat-panel";
import { TaskChangesPanel } from "./task-changes-panel";
import { ChangesPanel } from "./changes-panel";
@@ -101,7 +100,6 @@ function PortalSlot(props: IDockviewPanelProps) {
// All panel types use the same PortalSlot wrapper — dockview only manages
// layout positioning. Actual rendering happens in PanelPortalHost below.
export const dockviewComponents: Record> = {
- sidebar: PortalSlot,
chat: PortalSlot,
"diff-viewer": PortalSlot,
"file-editor": PortalSlot,
@@ -147,21 +145,6 @@ export { ContextMenuTab };
// Each content component renders the real panel UI. They live permanently
// in the PanelPortalHost and survive dockview layout switches.
-function SidebarContent({ panelId }: { panelId: string }) {
- const workspaceId = useAppStore((state) => state.workspaces.activeId);
- const workflowId = useAppStore((state) => state.workflows.activeId);
- const workspaceName = useAppStore((state) => {
- const ws = state.workspaces.items.find((w: { id: string }) => w.id === workspaceId);
- return ws?.name ?? "Workspace";
- });
-
- useEffect(() => {
- setPanelTitle(panelId, workspaceName);
- }, [panelId, workspaceName]);
-
- return ;
-}
-
function useChatSessionTitle(panelId: string, sessionId: string | null, isSessionTab: boolean) {
const agentLabel = useAppStore((state) => {
if (!sessionId) return null;
@@ -371,7 +354,7 @@ export function renderPanel(
switch (resolved) {
case "sidebar":
- return ;
+ return null;
case "chat":
return ;
case "diff-viewer":
diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx
index 36f15f381e..a37ba561e4 100644
--- a/apps/web/components/task/task-item.tsx
+++ b/apps/web/components/task/task-item.tsx
@@ -342,6 +342,8 @@ export const TaskItem = memo(function TaskItem({
role="button"
tabIndex={0}
data-testid="sidebar-task-item"
+ data-active={isSelected ? "true" : "false"}
+ aria-current={isSelected ? "true" : undefined}
onClick={onClick}
onKeyDown={(e) => handleTaskItemKeyDown(e, onClick)}
style={indent.depth > 0 ? { paddingLeft: indent.paddingLeftPx } : undefined}
@@ -353,14 +355,7 @@ export const TaskItem = memo(function TaskItem({
)}
>
- {indent.depth > 0 && (
-
- ↳
-
- )}
+
+ ↳
+
+ );
+}
+
function SelectionBar({ isSelected, color }: { isSelected: boolean; color: TaskColor | null }) {
if (color) {
return (
diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx
index fe5416f510..52f8dd572a 100644
--- a/apps/web/components/task/task-session-sidebar.tsx
+++ b/apps/web/components/task/task-session-sidebar.tsx
@@ -1,6 +1,8 @@
"use client";
import { useCallback, useEffect, useMemo, useState, memo } from "react";
+import { usePathname, useRouter } from "next/navigation";
+import { linkToTask } from "@/lib/links";
import type {
Message,
TaskState,
@@ -177,6 +179,8 @@ function toSidebarItem(
type TaskSessionSidebarProps = {
workspaceId: string | null;
workflowId: string | null;
+ /** Hide the embedded filter bar when the host surface (e.g. AppSidebar) renders its own. */
+ hideFilterBar?: boolean;
};
type SidebarItem = Omit, "workflowId"> & { workflowId?: string };
@@ -456,6 +460,8 @@ function useSidebarActions(store: StoreApi) {
const setActiveSession = useAppStore((state) => state.setActiveSession);
const [preparingTaskId, setPreparingTaskId] = useState(null);
const { renameTaskById } = useTaskActions();
+ const router = useRouter();
+ const pathname = usePathname();
const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({
store,
useLayoutSwitch: true,
@@ -468,6 +474,18 @@ function useSidebarActions(store: StoreApi) {
const handleSelectTask = useCallback(
(taskId: string) => {
+ // The AppSidebar is mounted globally. On a non-task route the dockview
+ // isn't mounted, so the in-place layout switch (which only rewrites the
+ // URL via history.replaceState) would change the address bar without
+ // ever showing the task. Navigate to the task page in that case; the
+ // in-place fast-switch is only correct once the dockview is on screen.
+ const onTaskRoute =
+ !!pathname && (pathname.startsWith("/t/") || pathname.startsWith("/office/tasks/"));
+ if (!onTaskRoute) {
+ setActiveTask(taskId);
+ router.push(linkToTask(taskId));
+ return;
+ }
const state = store.getState();
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
selectTaskWithLayout({
@@ -480,7 +498,7 @@ function useSidebarActions(store: StoreApi) {
setPreparingTaskId,
});
},
- [loadTaskSessionsForTask, switchToSession, setActiveTask, store],
+ [loadTaskSessionsForTask, switchToSession, setActiveTask, store, router, pathname],
);
const archiveActions = useArchiveActions(store);
@@ -554,10 +572,12 @@ function useGroupedSidebarView(displayTasks: TaskSwitcherItem[]) {
export const TaskSessionSidebar = memo(function TaskSessionSidebar({
workspaceId,
+ hideFilterBar,
}: TaskSessionSidebarProps) {
const store = useAppStoreApi();
useRepositories(workspaceId);
useWorkspacePRs(workspaceId);
+ const pathname = usePathname();
const {
activeTaskId,
@@ -569,6 +589,14 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
primarySessionIds,
} = useSidebarData(workspaceId);
+ // The sidebar is global, so `activeTaskId` lingers after navigating Home.
+ // Only highlight a task while actually viewing a task route — otherwise the
+ // last-opened task stays visually "selected" on Home and elsewhere.
+ const onTaskRoute =
+ !!pathname && (pathname.startsWith("/t/") || pathname.startsWith("/office/tasks/"));
+ const highlightedTaskId = onTaskRoute ? activeTaskId : null;
+ const highlightedSelectedTaskId = onTaskRoute ? selectedTaskId : null;
+
useBulkGitStatusSubscription(primarySessionIds);
const sidebarActions = useSidebarActions(store);
@@ -598,14 +626,14 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
const { pinnedTaskIds, togglePinnedTask, handleReorderGroup, handleReorderSubtasks } = prefs;
return (
-
+ {!hideFilterBar && }
toggleSidebarGroupCollapsed(effectiveView.id, groupKey)}
collapsedSubtaskParentIds={collapsedSubtaskParents}
diff --git a/apps/web/components/task/task-top-bar.test.tsx b/apps/web/components/task/task-top-bar.test.tsx
index 9643627410..f79e9e8a27 100644
--- a/apps/web/components/task/task-top-bar.test.tsx
+++ b/apps/web/components/task/task-top-bar.test.tsx
@@ -80,16 +80,6 @@ vi.mock("@/components/task/quick-chat-button", () => ({
QuickChatButton: () => null,
}));
-vi.mock("@/components/task/topbar-action-overflow", () => ({
- TopbarActionOverflow: ({ items }: { items: Array<{ id: string; content: React.ReactNode }> }) => (
-
- {items.map((item) => (
-
{item.content}
- ))}
-
- ),
-}));
-
vi.mock("@/components/integrations/integrations-menu", () => ({
IntegrationsMenu: () => null,
}));
diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx
index f41299e1b7..920f134283 100644
--- a/apps/web/components/task/task-top-bar.tsx
+++ b/apps/web/components/task/task-top-bar.tsx
@@ -2,24 +2,9 @@
import { memo, type ReactNode } from "react";
import Link from "next/link";
-import { IconBug, IconDots, IconHome, IconSettings } from "@tabler/icons-react";
+import { IconBug } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
-import {
- Breadcrumb,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbList,
- BreadcrumbPage,
- BreadcrumbSeparator,
-} from "@kandev/ui/breadcrumb";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@kandev/ui/dropdown-menu";
+import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from "@kandev/ui/breadcrumb";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { EditorsMenu } from "@/components/task/editors-menu";
import { LayoutPresetSelector } from "@/components/task/layout-preset-selector";
@@ -35,12 +20,6 @@ import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availabili
import { PortForwardButton } from "@/components/task/port-forward-dialog";
import { ExecutorSettingsButton } from "@/components/task/executor-settings-button";
import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper";
-import { QuickChatButton } from "@/components/task/quick-chat-button";
-import {
- TopbarActionOverflow,
- type TopbarOverflowItem,
-} from "@/components/task/topbar-action-overflow";
-import { IntegrationsMenu } from "@/components/integrations/integrations-menu";
import { isDebugUI } from "@/lib/config";
type TaskTopBarProps = {
@@ -91,7 +70,7 @@ const TaskTopBar = memo(function TaskTopBar({
return (
-
-
-
-
-
-
-
-
@@ -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 = isDebugUI() && 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 = isDebugUI() && onToggleDebugOverlay;
+ const showDebugToggle = isDebugUI() && 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 d0436853a4..0000000000
--- 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 52de62e6eb..0000000000
--- 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 4a0f8bc47f..d095b6a8d8 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 0000000000..02d312c5b9
--- /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/pages/app-sidebar-page.ts b/apps/web/e2e/pages/app-sidebar-page.ts
new file mode 100644
index 0000000000..118d59468a
--- /dev/null
+++ b/apps/web/e2e/pages/app-sidebar-page.ts
@@ -0,0 +1,19 @@
+import { type Locator, type Page } from "@playwright/test";
+
+/** Page object for the unified AppSidebar; office nav (Agents/Projects) lives in collapsible sections that default closed on /office, so tests expand them via expandSection. */
+export class AppSidebarPage {
+ readonly root: Locator;
+
+ constructor(private readonly page: Page) {
+ this.root = page.getByTestId("app-sidebar");
+ }
+
+ /** Expand a collapsible section by label if collapsed. Idempotent. */
+ async expandSection(label: string): Promise {
+ const header = this.root.getByRole("button", { name: label, exact: true }).first();
+ await header.waitFor({ state: "visible", timeout: 10_000 });
+ if ((await header.getAttribute("aria-expanded")) !== "true") {
+ await header.click();
+ }
+ }
+}
diff --git a/apps/web/e2e/pages/mobile-github-page.ts b/apps/web/e2e/pages/mobile-github-page.ts
index a4503076da..e3dc3f5361 100644
--- a/apps/web/e2e/pages/mobile-github-page.ts
+++ b/apps/web/e2e/pages/mobile-github-page.ts
@@ -9,7 +9,9 @@ export class MobileGitHubPage {
constructor(private page: Page) {
this.mobileMenuButton = page.getByTestId("github-mobile-menu-button");
this.mobileSidebar = page.getByTestId("github-mobile-sidebar");
- this.inlineSidebar = page.getByTestId("github-presets-sidebar-inline");
+ // Desktop scope bar (replaces the old inline presets rail). Hidden on
+ // mobile, where the presets live in the hamburger sheet instead.
+ this.inlineSidebar = page.getByTestId("github-presets-scope-bar");
this.toolbarTitle = page.getByTestId("github-list-toolbar-title");
}
diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts
index 6b60318362..e0e5c493ef 100644
--- a/apps/web/e2e/pages/session-page.ts
+++ b/apps/web/e2e/pages/session-page.ts
@@ -96,6 +96,28 @@ export class SessionPage {
await this.activeChat().waitFor({ state: "visible", timeout });
}
+ /**
+ * Foreground the session chat and wait for it to be visible.
+ *
+ * After the unified AppSidebar overhaul, switching tasks via the sidebar
+ * restores each task's saved dockview env layout. That restored layout can
+ * land the chat panel as a *non-active* background tab in the right-column
+ * group (e.g. behind Files/Changes), so the chat is mounted but not visible
+ * and a plain `waitForLoad()` (which gates on `session-chat:visible`) times
+ * out. Clicking the session tab brings the chat to the foreground — exactly
+ * what a user does to read the conversation after switching tasks — and then
+ * we wait for the now-visible chat. No-op (still waits) when the chat is
+ * already foregrounded.
+ */
+ async showSessionContext(timeout = 15_000): Promise {
+ const tab = this.page.locator("[data-testid^='session-tab-']").first();
+ await tab.waitFor({ state: "visible", timeout });
+ // Clicking a tab that's already active is harmless; clicking a background
+ // one promotes its panel to the foreground.
+ await tab.click();
+ await this.activeChat().waitFor({ state: "visible", timeout });
+ }
+
/**
* Wait for the chat to be idle (input placeholder visible, agent not busy).
*
diff --git a/apps/web/e2e/pages/sidebar-filter-popover.ts b/apps/web/e2e/pages/sidebar-filter-popover.ts
index 640e3c6c35..06b3db5307 100644
--- a/apps/web/e2e/pages/sidebar-filter-popover.ts
+++ b/apps/web/e2e/pages/sidebar-filter-popover.ts
@@ -1,20 +1,46 @@
import { type Locator, type Page, expect } from "@playwright/test";
+/**
+ * Page object for the sidebar filter / view system.
+ *
+ * After the unified AppSidebar overhaul, the filter UI no longer renders the
+ * legacy `sidebar-filter-bar`. It now lives in the AppSidebar Tasks section
+ * header as the `TasksViewPicker`:
+ * - `tasks-view-picker` — a dropdown trigger button labelled with the active
+ * view name. Opening it lists the saved views as `sidebar-view-chip` items
+ * (radix DropdownMenu, portaled). Selecting a chip switches the active view.
+ * - `sidebar-filter-gear` — opens the `sidebar-filter-popover` (filters, sort,
+ * group, save/rename/delete). Unchanged from before the overhaul.
+ *
+ * Notes on what changed vs. the old bar:
+ * - The old always-visible `sidebar-view-chip-row` is gone; chips are only in
+ * the open view-picker dropdown. Chip-reading helpers open it first.
+ * - The active-view label is shown on the picker trigger button.
+ * - Drag-to-reorder views was removed (the dropdown items are not sortable).
+ */
export class SidebarFilterPopoverPage {
+ /** The always-visible filter affordance (view picker) in the section header. */
readonly bar: Locator;
readonly gear: Locator;
- readonly chipRow: Locator;
+ /** The view-picker dropdown trigger (shows the active view name). */
+ readonly viewPicker: Locator;
constructor(private readonly page: Page) {
- this.bar = page.getByTestId("sidebar-filter-bar");
+ this.bar = page.getByTestId("tasks-view-picker");
+ this.viewPicker = page.getByTestId("tasks-view-picker");
this.gear = page.getByTestId("sidebar-filter-gear");
- this.chipRow = page.getByTestId("sidebar-view-chip-row");
}
get popover(): Locator {
return this.page.getByTestId("sidebar-filter-popover");
}
+ /** Open radix dropdown menu hosting the view chips. Scoped to the menu that
+ * actually contains the view chips so it can't match another sidebar menu. */
+ get chipMenu(): Locator {
+ return this.page.getByRole("menu").filter({ has: this.page.getByTestId("sidebar-view-chip") });
+ }
+
async open(): Promise {
if (!(await this.popover.isVisible())) {
await this.gear.click();
@@ -29,58 +55,53 @@ export class SidebarFilterPopoverPage {
}
}
+ /** Open the view-picker dropdown (no-op if already open). */
+ async openViewPicker(): Promise {
+ if (!(await this.chipMenu.isVisible())) {
+ await this.viewPicker.click();
+ await expect(this.chipMenu).toBeVisible();
+ }
+ }
+
+ async closeViewPicker(): Promise {
+ if (await this.chipMenu.isVisible()) {
+ await this.page.keyboard.press("Escape");
+ await expect(this.chipMenu).toBeHidden();
+ }
+ }
+
+ /** Select a view by name from the picker dropdown. */
async selectViewByName(name: string): Promise {
+ await this.openViewPicker();
await this.chipByName(name).click();
+ // Selecting closes the menu.
+ await expect(this.chipMenu).toBeHidden();
}
+ /**
+ * Assert the active view by name. The active view label is rendered on the
+ * picker trigger button, so this works without opening the dropdown.
+ */
async expectActiveViewChip(name: string): Promise {
- const chip = this.chipRow
- .locator(`[data-testid='sidebar-view-chip'][data-active='true']`)
- .first();
- await expect(chip).toContainText(name);
+ await this.closeViewPicker();
+ await expect(this.viewPicker).toContainText(name);
}
+ /** A view chip in the (open) picker dropdown by name. */
chipByName(name: string): Locator {
- return this.chipRow.getByTestId("sidebar-view-chip").filter({ hasText: name }).first();
+ return this.chipMenu.getByTestId("sidebar-view-chip").filter({ hasText: name }).first();
}
+ /** Read the chip names in dropdown order (opens the picker to read them). */
async expectChipOrder(names: string[]): Promise {
+ await this.openViewPicker();
await expect
.poll(async () => {
- const texts = await this.chipRow.getByTestId("sidebar-view-chip").allTextContents();
+ const texts = await this.chipMenu.getByTestId("sidebar-view-chip").allTextContents();
return texts.map((text) => text.trim());
})
.toEqual(names);
- }
-
- async dragViewBefore(sourceName: string, targetName: string): Promise {
- const source = this.chipByName(sourceName);
- const target = this.chipByName(targetName);
- await source.scrollIntoViewIfNeeded();
- await target.scrollIntoViewIfNeeded();
- await expect(source).toBeVisible();
-
- const sourceBox = await source.boundingBox();
- const targetBox = await target.boundingBox();
- if (!sourceBox || !targetBox) throw new Error("Missing sidebar view chip drag geometry");
- const targetLeftHalfX = targetBox.x + targetBox.width * 0.25;
-
- await this.page.mouse.move(
- sourceBox.x + sourceBox.width / 2,
- sourceBox.y + sourceBox.height / 2,
- );
- await this.page.mouse.down();
- // Move far enough to exceed the 8px PointerSensor activation threshold,
- // then slide toward the target.
- await this.page.mouse.move(
- sourceBox.x + sourceBox.width / 2 - 16,
- sourceBox.y + sourceBox.height / 2,
- { steps: 4 },
- );
- await this.page.mouse.move(targetLeftHalfX, targetBox.y + targetBox.height / 2, {
- steps: 20,
- });
- await this.page.mouse.up();
+ await this.closeViewPicker();
}
async addFilterRow(): Promise {
diff --git a/apps/web/e2e/tests/chat/clarification.spec.ts b/apps/web/e2e/tests/chat/clarification.spec.ts
index 11002e5251..bc7a02c5fe 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/chat/mobile-voice-mode.spec.ts b/apps/web/e2e/tests/chat/mobile-voice-mode.spec.ts
index 4ed1389cd7..b6be4a80bc 100644
--- a/apps/web/e2e/tests/chat/mobile-voice-mode.spec.ts
+++ b/apps/web/e2e/tests/chat/mobile-voice-mode.spec.ts
@@ -42,12 +42,9 @@ test.describe("Mobile voice mode", () => {
await expect(testPage.getByText("Behavior")).toBeVisible();
await expect(testPage.getByText("Whisper Web Model")).toBeVisible();
await expect(testPage.getByText(/Shortcut$/)).toBeVisible();
-
- // The mobile sidebar trigger (md:hidden) lives in the topbar; assert it
- // exists so we know the sidebar entry chain that exposed this page is
- // not silently removed by a future layout change.
- const sidebarTrigger = testPage.locator('button[data-sidebar="trigger"]');
- await expect(sidebarTrigger).toBeVisible();
+ // (The legacy `button[data-sidebar="trigger"]` mobile settings-sidebar
+ // trigger was removed by the unified-AppSidebar overhaul — settings nav is
+ // now the footer-gear takeover, and the global rail is hidden on mobile.)
});
test("voice input mic button mounts on the mobile chat composer with the coarse-pointer touch target and effective toggle mode", async ({
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 8edadafcfe..99abf44ef5 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/github/github-scope-bar.spec.ts b/apps/web/e2e/tests/github/github-scope-bar.spec.ts
new file mode 100644
index 0000000000..6f2ee09b8d
--- /dev/null
+++ b/apps/web/e2e/tests/github/github-scope-bar.spec.ts
@@ -0,0 +1,47 @@
+import { test, expect } from "../../fixtures/test-base";
+
+// The /github dashboard used to render its own w-60 presets rail next to the
+// global AppSidebar (a redundant "double sidebar"). On desktop that rail is now
+// a horizontal scope bar; this guards that it drives presets/kind and that the
+// old inline rail is gone. Mobile keeps the sheet (see mobile-github-sidebar).
+test.describe("Desktop /github scope bar", () => {
+ test("scope bar drives presets and kind, with no second sidebar", async ({
+ testPage,
+ apiClient,
+ }) => {
+ await apiClient.mockGitHubReset();
+ await apiClient.mockGitHubSetUser("test-user");
+ await apiClient.mockGitHubAddPRs([
+ {
+ number: 11,
+ title: "Scope bar PR",
+ state: "open",
+ head_branch: "feat/scope",
+ base_branch: "main",
+ author_login: "test-user",
+ repo_owner: "testorg",
+ repo_name: "testrepo",
+ },
+ ]);
+
+ await testPage.goto("/github");
+
+ const scopeBar = testPage.getByTestId("github-presets-scope-bar");
+ const title = testPage.getByTestId("github-list-toolbar-title");
+
+ // The horizontal scope bar is present; the old inline rail is gone.
+ await expect(scopeBar).toBeVisible();
+ await expect(testPage.getByTestId("github-presets-sidebar-inline")).toHaveCount(0);
+
+ // Default PR preset is active.
+ await expect(title).toContainText("Review requested");
+
+ // Clicking a preset pill updates the active query.
+ await scopeBar.getByRole("button", { name: "Mentions" }).click();
+ await expect(title).toContainText("Mentions");
+
+ // Switching kind to Issues falls back to the first issue preset.
+ await scopeBar.getByRole("button", { name: "Issues" }).click();
+ await expect(title).toContainText("Assigned");
+ });
+});
diff --git a/apps/web/e2e/tests/improve-kandev.spec.ts b/apps/web/e2e/tests/improve-kandev.spec.ts
index b5ab9cfb22..cf18391ff4 100644
--- a/apps/web/e2e/tests/improve-kandev.spec.ts
+++ b/apps/web/e2e/tests/improve-kandev.spec.ts
@@ -93,7 +93,8 @@ test.describe("Improve Kandev dialog", () => {
await testPage.goto("/");
- await testPage.getByTestId("improve-kandev-button").first().click();
+ // Post-overhaul: the Improve Kandev opener moved to the AppSidebar footer.
+ await testPage.getByTestId("sidebar-improve-kandev-button").click();
// Intro screen
const introDialog = testPage.getByRole("dialog", { name: "Improve Kandev" });
@@ -144,7 +145,8 @@ test.describe("Improve Kandev dialog", () => {
});
await testPage.goto("/");
- await testPage.getByTestId("improve-kandev-button").first().click();
+ // Post-overhaul: the Improve Kandev opener moved to the AppSidebar footer.
+ await testPage.getByTestId("sidebar-improve-kandev-button").click();
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
@@ -175,7 +177,8 @@ test.describe("Improve Kandev dialog", () => {
});
await testPage.goto("/");
- await testPage.getByTestId("improve-kandev-button").first().click();
+ // Post-overhaul: the Improve Kandev opener moved to the AppSidebar footer.
+ await testPage.getByTestId("sidebar-improve-kandev-button").click();
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
@@ -209,7 +212,8 @@ test.describe("Improve Kandev dialog", () => {
await mockImproveKandevApis(testPage, seedData);
await testPage.goto("/");
- await testPage.getByTestId("improve-kandev-button").first().click();
+ // Post-overhaul: the Improve Kandev opener moved to the AppSidebar footer.
+ await testPage.getByTestId("sidebar-improve-kandev-button").click();
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
@@ -243,7 +247,8 @@ test.describe("Improve Kandev dialog", () => {
await mockImproveKandevApis(testPage, seedData, { bootstrapHold });
await testPage.goto("/");
- await testPage.getByTestId("improve-kandev-button").first().click();
+ // Post-overhaul: the Improve Kandev opener moved to the AppSidebar footer.
+ await testPage.getByTestId("sidebar-improve-kandev-button").click();
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
diff --git a/apps/web/e2e/tests/integrations/jira-import.spec.ts b/apps/web/e2e/tests/integrations/jira-import.spec.ts
index 898c32e5a6..06a683bc83 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 2d75008bbb..6a982fb344 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 8242ceabec..97e0209ea4 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", {
@@ -48,11 +52,20 @@ test.describe("Kanban board", () => {
// previewWidth`); the header narrows along with it. Below ~1100px there is
// no longer room between the left/right action groups for the centered
// search, so the header hides it (see useIsHeaderNarrow in kanban-header).
+ //
+ // Post-overhaul: the always-on AppSidebar (~240px expanded) permanently eats
+ // horizontal space, so the header's own clientWidth is viewport − sidebar.
+ // The default Desktop Chrome 1280px viewport now leaves the header below the
+ // 1100px narrow threshold even with no preview open, so this test forces a
+ // viewport wide enough that the centered search shows with the sidebar
+ // present (1400 − ~240 = ~1160 ≥ 1100), and opening the 500px preview drops
+ // it below the threshold (1400 − ~240 − 500 = ~660 < 1100).
test("hides header search when preview panel narrows the kanban area", async ({
testPage,
apiClient,
seedData,
}) => {
+ await testPage.setViewportSize({ width: 1400, height: 800 });
await apiClient.saveUserSettings({ enable_preview_on_click: true });
const task = await apiClient.createTask(seedData.workspaceId, "Header Squeeze Task", {
@@ -62,13 +75,13 @@ test.describe("Kanban board", () => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
- // With no preview open, the kanban area uses the full viewport width
- // (Desktop Chrome = 1280px) so the centered search is visible.
+ // With no preview open, the header is wide enough (viewport − sidebar ≥
+ // 1100px) that the centered search is visible.
const search = testPage.getByTestId("kanban-header-search");
await expect(search).toBeVisible();
- // Open the preview — kanban width drops below 1100px (1280 - 500 default
- // preview width = 780px) and the search must hide.
+ // Open the preview — the header width drops below 1100px and the centered
+ // search must hide.
const card = kanban.taskCard(task.id);
await expect(card).toBeVisible();
await card.click();
diff --git a/apps/web/e2e/tests/kanban/kanban-topbar-utilities.spec.ts b/apps/web/e2e/tests/kanban/kanban-topbar-utilities.spec.ts
index e2daffbd74..2268490e8f 100644
--- a/apps/web/e2e/tests/kanban/kanban-topbar-utilities.spec.ts
+++ b/apps/web/e2e/tests/kanban/kanban-topbar-utilities.spec.ts
@@ -2,17 +2,26 @@ import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
test.describe("Kanban topbar utilities", () => {
- test("settings is reachable directly from the topbar (no dropdown)", async ({ testPage }) => {
- // Lock to desktop width — on tablet/mobile Settings lives inside the hamburger sheet.
+ // Post-overhaul: Settings is no longer a topbar link/dropdown. It lives behind
+ // the AppSidebar footer gear, which toggles a full-height settings takeover
+ // (the settings tree). From there each leaf navigates to a /settings/... page.
+ test("settings is reachable from the AppSidebar footer gear", async ({ testPage }) => {
+ // Lock to desktop width — the AppSidebar is desktop-only (`hidden md:flex`).
await testPage.setViewportSize({ width: 1280, height: 800 });
const kanban = new KanbanPage(testPage);
await kanban.goto();
- const settingsButton = testPage.getByRole("link", { name: "Settings" });
- await expect(settingsButton).toBeVisible();
+ const sidebar = testPage.getByTestId("app-sidebar");
+ await expect(sidebar).toBeVisible();
- await settingsButton.click();
- await expect(testPage).toHaveURL(/\/settings/);
+ // The footer gear toggles the settings takeover (the settings tree).
+ await sidebar.getByTestId("sidebar-settings-gear").click();
+ const settingsMode = testPage.getByTestId("app-sidebar-settings-mode");
+ await expect(settingsMode).toBeVisible();
+
+ // Clicking a settings leaf navigates to its /settings/... page.
+ await settingsMode.getByRole("link", { name: "Automations" }).click();
+ await expect(testPage).toHaveURL(/\/settings\//);
});
test("system health button is hidden when there are no issues", async ({ testPage, backend }) => {
diff --git a/apps/web/e2e/tests/kanban/workflow-filter.spec.ts b/apps/web/e2e/tests/kanban/workflow-filter.spec.ts
index b53d3c0e05..6813c2b425 100644
--- a/apps/web/e2e/tests/kanban/workflow-filter.spec.ts
+++ b/apps/web/e2e/tests/kanban/workflow-filter.spec.ts
@@ -144,8 +144,13 @@ 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.
+ // `exact: true` disambiguates from the brand "Kandev home" link, which also
+ // points at "/" and would otherwise match the accessible-name substring.
+ await testPage
+ .getByTestId("app-sidebar")
+ .getByRole("link", { name: "Home", exact: true })
+ .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 3e58a8c713..68a4954dc6 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-edge-cases.spec.ts b/apps/web/e2e/tests/layout/pane-resize-edge-cases.spec.ts
index e6f548dba1..0dccbc8ebd 100644
--- a/apps/web/e2e/tests/layout/pane-resize-edge-cases.spec.ts
+++ b/apps/web/e2e/tests/layout/pane-resize-edge-cases.spec.ts
@@ -3,24 +3,16 @@ import {
WIDE_VIEWPORT,
openWideTask,
expectApproxWidth,
- getColumnSashIndex,
getDockviewGroupWidth,
resizeColumnViaSplitview,
} from "../../helpers/dockview-resize";
+// The "double-click on sidebar sash does not crash dockview" test was removed:
+// the dockview-embedded sidebar column was retired in the unified AppSidebar
+// overhaul, so there is no sidebar sash to interact with anymore. The remaining
+// edge cases exercise the RIGHT (files/changes/terminal) column, which is still
+// resizable.
test.describe("Pane resize edge cases", () => {
- test("double-click on sidebar sash does not crash dockview", async ({
- testPage,
- apiClient,
- seedData,
- }) => {
- const session = await openWideTask(testPage, apiClient, seedData, "Edge dblclick");
- const sashIdx = await getColumnSashIndex(testPage, "sidebar");
- const sash = testPage.locator(".dv-sash").nth(sashIdx);
- await sash.dblclick();
- await session.expectLayoutHealthy();
- });
-
test("resize during maximize does not corrupt the pre-maximize width", async ({
testPage,
apiClient,
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 8f6ac1011f..d2fe6ed784 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/pane-resize-sidebar.spec.ts b/apps/web/e2e/tests/layout/pane-resize-sidebar.spec.ts
deleted file mode 100644
index 6391c76204..0000000000
--- a/apps/web/e2e/tests/layout/pane-resize-sidebar.spec.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import { test, expect } from "../../fixtures/test-base";
-import {
- openWideTask,
- expectApproxWidth,
- getDockviewGroupWidth,
- resizeColumnViaSplitview,
-} from "../../helpers/dockview-resize";
-
-test.describe("Sidebar resize — viewport-proportional cap", () => {
- test("resizes past the old 350px hard cap", async ({ testPage, apiClient, seedData }) => {
- await openWideTask(testPage, apiClient, seedData, "Sidebar past cap");
- const actual = await resizeColumnViaSplitview(testPage, "sidebar", 480);
- expect(actual).toBeGreaterThan(420);
- });
-
- test("respects the viewport-proportional cap on narrower screens", async ({
- testPage,
- apiClient,
- seedData,
- }) => {
- await openWideTask(testPage, apiClient, seedData, "Sidebar cap narrow", {
- width: 1200,
- height: 800,
- });
- const actual = await resizeColumnViaSplitview(testPage, "sidebar", 5000);
- expect(actual).toBeLessThanOrEqual(Math.max(350, Math.round(1200 * 0.3)) + 10);
- });
-
- test("user width survives reload", async ({ testPage, apiClient, seedData }) => {
- const session = await openWideTask(testPage, apiClient, seedData, "Sidebar reload");
- const before = await resizeColumnViaSplitview(testPage, "sidebar", 440);
-
- await testPage.reload();
- await session.waitForLoad();
- await session.waitForDockviewReady();
-
- const after = await getDockviewGroupWidth(testPage, "sidebar");
- expectApproxWidth(after, before, 12);
- });
-
- test("toggle sidebar off+on preserves the last user width", async ({
- testPage,
- apiClient,
- seedData,
- }) => {
- const session = await openWideTask(testPage, apiClient, seedData, "Sidebar toggle");
- const before = await resizeColumnViaSplitview(testPage, "sidebar", 430);
-
- // TOGGLE_SIDEBAR shortcut (Ctrl/Cmd+B). Move focus out of any input first.
- await testPage.locator("body").click({ position: { x: 5, y: 5 } });
- const mod = process.platform === "darwin" ? "Meta" : "Control";
- await testPage.keyboard.press(`${mod}+b`);
- await testPage.waitForTimeout(250);
- await testPage.keyboard.press(`${mod}+b`);
- await session.waitForDockviewReady();
-
- const after = await getDockviewGroupWidth(testPage, "sidebar");
- expectApproxWidth(after, before, 20);
- });
-});
-
-test.describe("Sidebar width — global across tasks", () => {
- test("a width set in one task applies to a different task", async ({
- testPage,
- apiClient,
- seedData,
- }) => {
- // Task A: drag the sidebar to a distinctive width.
- const sessionA = await openWideTask(testPage, apiClient, seedData, "Sidebar global A");
- const widthA = await resizeColumnViaSplitview(testPage, "sidebar", 440);
-
- // Task B is a separate task (separate env). It must open at task A's width
- // because the left sidebar width is now a single global preference.
- const taskB = await apiClient.createTaskWithAgent(
- seedData.workspaceId,
- "Sidebar global B",
- seedData.agentProfileId,
- {
- description: "/e2e:simple-message",
- workflow_id: seedData.workflowId,
- workflow_step_id: seedData.startStepId,
- repository_ids: [seedData.repositoryId],
- },
- );
- await testPage.goto(`/t/${taskB.id}`);
- await sessionA.waitForLoad();
- await sessionA.waitForDockviewReady();
-
- const widthB = await getDockviewGroupWidth(testPage, "sidebar");
- expectApproxWidth(widthB, widthA, 12);
- });
-
- test("clamps to fit a smaller screen, then restores on a wider one", async ({
- testPage,
- apiClient,
- seedData,
- }) => {
- // Set a wide sidebar on a 1600px screen.
- const session = await openWideTask(testPage, apiClient, seedData, "Sidebar clamp");
- const wide = await resizeColumnViaSplitview(testPage, "sidebar", 520);
- expect(wide).toBeGreaterThan(450);
-
- // Shrink the viewport. The sidebar must settle within the smaller screen's
- // cap (max(350, 30% of 1100) = 350, bounded by viewport-300) — not stay at
- // 520 and not drift.
- await testPage.setViewportSize({ width: 1100, height: 800 });
- await session.waitForDockviewReady();
- await testPage.waitForTimeout(400);
- const small = await getDockviewGroupWidth(testPage, "sidebar");
- const smallCap = Math.max(350, Math.round(1100 * 0.3));
- expect(small).toBeLessThanOrEqual(smallCap + 12);
-
- // Back to a wide screen: the raw 520 preference is restored (clamp does not
- // overwrite stored width).
- await testPage.setViewportSize({ width: 1600, height: 900 });
- await session.waitForDockviewReady();
- await testPage.waitForTimeout(400);
- const restored = await getDockviewGroupWidth(testPage, "sidebar");
- expectApproxWidth(restored, wide, 16);
- });
-
- test("Default layout preset resets the custom width", async ({
- testPage,
- apiClient,
- seedData,
- }) => {
- const session = await openWideTask(testPage, apiClient, seedData, "Sidebar reset");
- const custom = await resizeColumnViaSplitview(testPage, "sidebar", 480);
- expect(custom).toBeGreaterThan(420);
-
- // Apply the "Default" layout preset (resetWidths=true).
- await testPage.getByTestId("layout-preset-trigger").click();
- await testPage.locator('[data-testid="layout-preset-item"][data-preset-id="default"]').click();
- await session.waitForDockviewReady();
- await testPage.waitForTimeout(400);
-
- // Sidebar returns to the ratio default (1600*0.25=400, clamped to 350).
- const afterReset = await getDockviewGroupWidth(testPage, "sidebar");
- expectApproxWidth(afterReset, 350, 16);
- });
-});
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 970ff40688..d023674359 100644
--- a/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts
+++ b/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts
@@ -178,7 +178,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/layout/topbar-alignment.spec.ts b/apps/web/e2e/tests/layout/topbar-alignment.spec.ts
new file mode 100644
index 0000000000..3d61950261
--- /dev/null
+++ b/apps/web/e2e/tests/layout/topbar-alignment.spec.ts
@@ -0,0 +1,68 @@
+import { type Page } from "@playwright/test";
+import { test, expect } from "../../fixtures/test-base";
+import type { SeedData } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { SessionPage } from "../../pages/session-page";
+
+/**
+ * The sidebar header (h-10) must line up exactly with the surface top bar so the
+ * bottom border reads as one continuous line across the sidebar/content seam.
+ * On a task page that surface bar is the TaskTopBar — which previously had no
+ * explicit height and so sat a couple of px off from the 40px sidebar header.
+ */
+async function bottomOf(page: Page, testId: string): Promise {
+ const box = await page.getByTestId(testId).boundingBox();
+ if (!box) throw new Error(`no bounding box for ${testId}`);
+ return box.y + box.height;
+}
+
+test.describe("Sidebar header / top bar alignment", () => {
+ test.describe.configure({ retries: 1 });
+
+ test("task top bar bottom edge aligns with the sidebar header", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }: {
+ testPage: Page;
+ apiClient: ApiClient;
+ seedData: SeedData;
+ }) => {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Alignment Task",
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
+
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+
+ const topbar = testPage.getByTestId("task-topbar");
+ await expect(topbar).toBeVisible({ timeout: 30_000 });
+
+ // The sidebar header is the first child of the sidebar aside; tag-agnostic,
+ // we measure the aside-relative header via its testid-less first row by
+ // reading the task-topbar against the sidebar header height (both 40px).
+ const headerBottom = await testPage.evaluate(() => {
+ const aside = document.querySelector('[data-testid="app-sidebar"]');
+ const header = aside?.firstElementChild as HTMLElement | null;
+ if (!header) return null;
+ const r = header.getBoundingClientRect();
+ return r.y + r.height;
+ });
+ expect(headerBottom).not.toBeNull();
+
+ const topbarBottom = await bottomOf(testPage, "task-topbar");
+
+ // Allow 1px for sub-pixel rounding of the shared border line.
+ expect(Math.abs((headerBottom as number) - topbarBottom)).toBeLessThanOrEqual(1);
+ });
+});
diff --git a/apps/web/e2e/tests/office/agent-error-handling.spec.ts b/apps/web/e2e/tests/office/agent-error-handling.spec.ts
index b40ce8bdb1..4e00d1911b 100644
--- a/apps/web/e2e/tests/office/agent-error-handling.spec.ts
+++ b/apps/web/e2e/tests/office/agent-error-handling.spec.ts
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/office-fixture";
+import { AppSidebarPage } from "../../pages/app-sidebar-page";
/**
* E2E coverage for office-agent-error-handling (the v1 design):
@@ -74,7 +75,9 @@ test.describe("Office agent error handling", () => {
// The per-task entries are hidden once the agent is paused
// (consolidation rule).
await expect(testPage.getByTestId("inbox-item-agent_run_failed")).toHaveCount(0);
- // Sidebar reflects the paused state on the agent row.
+ // Sidebar reflects the paused state on the agent row. The Agents section is
+ // a collapsible AppSidebar section (collapsed by default) — expand it first.
+ await new AppSidebarPage(testPage).expandSection("Agents");
await expect(testPage.getByTestId("sidebar-agent-paused-badge").first()).toBeVisible();
});
diff --git a/apps/web/e2e/tests/office/execution-indicator.spec.ts b/apps/web/e2e/tests/office/execution-indicator.spec.ts
index d5b2ad7bc3..611688f9de 100644
--- a/apps/web/e2e/tests/office/execution-indicator.spec.ts
+++ b/apps/web/e2e/tests/office/execution-indicator.spec.ts
@@ -6,17 +6,30 @@ test.describe("Execution indicator", () => {
workflow_id: officeSeed.workflowId,
});
await testPage.goto("/office/tasks");
- await expect(testPage.getByText("Indicator Test Task")).toBeVisible({ timeout: 10_000 });
+ // Post-overhaul: the unified AppSidebar's Tasks section also lists tasks, so
+ // the title text appears in BOTH the global rail and the page table. Scope
+ // to `` (the office layout's page content, which excludes the
+ // `