From bae7b8b7de9eb7a3cd59539692604501e800d1ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:24:26 +0100 Subject: [PATCH] feat(ui): unify mobile navigation and task access (#3830) --- apps/web/AGENTS.md | 2 +- apps/web/app/threads/threads-page-client.tsx | 28 +- .../integrations/integrations-menu.tsx | 79 +++- .../mobile-integrations-section.test.tsx | 57 +++ .../kanban/kanban-header-mobile.test.tsx | 80 +++- .../kanban/kanban-header-mobile.tsx | 36 +- apps/web/components/kanban/kanban-header.tsx | 3 + .../kanban/mobile-listing-context.tsx | 6 +- .../kanban/mobile-listing-menu-actions.tsx | 113 +++-- .../kanban/mobile-listing-menu-button.tsx | 61 --- .../kanban/mobile-listing-options-context.tsx | 4 + .../components/kanban/mobile-menu-sheet.tsx | 97 ++-- .../navigation/app-nav-sections.tsx | 60 ++- .../navigation/app-nav-sheet.test.tsx | 99 +++- .../components/navigation/app-nav-sheet.tsx | 275 +++++++++-- .../components/navigation/app-nav-surface.tsx | 85 ++++ .../components/navigation/app-nav-trigger.tsx | 7 + .../navigation/destination-rows.test.tsx | 25 +- .../navigation/destination-rows.tsx | 31 +- .../mobile-automations-section.test.tsx | 59 +++ .../navigation/mobile-automations-section.tsx | 127 +++++ .../mobile-sidebar-layout-navigation.tsx | 31 +- .../mobile-task-navigation-provider.test.tsx | 62 +++ .../mobile-task-navigation-provider.tsx | 94 ++++ .../navigation/use-task-view-navigation.tsx | 10 +- apps/web/components/page-shell.test.tsx | 4 +- apps/web/components/page-shell.tsx | 7 +- .../add-workspace-sources-dialog.test.tsx | 19 + .../add-workspace-sources-dialog.tsx | 90 +--- .../repository-source-menu.tsx | 109 +++++ .../task/mobile/responsive-task-picker.tsx | 50 ++ .../task/mobile/session-mobile-layout.tsx | 18 +- ...session-mobile-top-bar-repository.test.tsx | 3 +- .../mobile/session-mobile-top-bar.test.tsx | 87 ++-- .../task/mobile/session-mobile-top-bar.tsx | 55 ++- .../task/mobile/session-tablet-layout.tsx | 12 - .../mobile/session-task-switcher-sheet.tsx | 217 +++++---- .../task/mobile/task-picker-surface.tsx | 84 ++++ .../task-sheet-selection-context.test.tsx | 51 ++ .../mobile/task-sheet-selection-context.tsx | 42 ++ .../port-forwarding-visibility-provider.tsx | 2 + .../task/task-layout-repository.test.tsx | 70 ++- apps/web/components/task/task-layout.tsx | 23 +- .../threads/threads-view-controls.tsx | 153 +++--- apps/web/e2e/helpers/api-client.ts | 5 + apps/web/e2e/pages/mobile-kanban-page.ts | 10 +- apps/web/e2e/pages/session-page.ts | 2 +- .../tests/canvas/mobile-plugin-canvas.spec.ts | 2 +- .../chat/message-queue-workflow-helpers.ts | 2 +- .../e2e/tests/chat/mobile-agent-goal.spec.ts | 2 +- .../chat/mobile-auto-scroll-toggle.spec.ts | 2 +- .../mobile-oversized-user-message.spec.ts | 4 +- .../mobile-quick-chat-cancel-palette.spec.ts | 4 +- .../chat/mobile-quick-chat-entry.spec.ts | 12 +- .../chat/mobile-quick-chat-idle-dot.spec.ts | 10 +- .../tests/chat/mobile-quick-chat-tabs.spec.ts | 2 +- .../tests/chat/mobile-unread-divider.spec.ts | 2 +- ...uick-chat-saved-prompt-delivery-helpers.ts | 2 +- .../changes-panel-multi-repo-indent.spec.ts | 5 +- .../mobile-empty-remote-repository.spec.ts | 5 +- .../mobile-fork-pr-comparison-target.spec.ts | 25 +- .../git/mobile-local-base-operations.spec.ts | 5 +- .../git/mobile-pr-checkout-drift.spec.ts | 10 +- .../mobile-pr-file-row-containment.spec.ts | 5 +- .../github/mobile-task-view-access.spec.ts | 22 +- .../tests/gitlab/mobile-gitlab-parity.spec.ts | 7 +- .../gitlab/mobile-mr-tasks-list-badge.spec.ts | 4 +- .../mobile-integrations-nav.spec.ts | 2 + .../mobile-auto-hide-empty-columns.spec.ts | 12 +- .../mobile-board-priority-sort-filter.spec.ts | 2 +- .../mobile-display-settings-groups.spec.ts | 2 +- .../tests/kanban/mobile-kanban-topbar.spec.ts | 34 +- .../e2e/tests/kanban/mobile-kanban.spec.ts | 22 +- .../mobile-step-visibility-filter.spec.ts | 2 +- .../mobile-task-title-hover-subtasks.spec.ts | 2 +- .../kanban/mobile-wip-overflow-queue.spec.ts | 8 +- .../layout/compact-desktop-responsive.spec.ts | 2 +- .../layout/mobile-app-status-bar.spec.ts | 2 +- .../layout/mobile-menu-backdrops.spec.ts | 2 +- .../layout/mobile-menu-hierarchy.spec.ts | 216 +++++++++ .../layout/mobile-navigation-tasks.spec.ts | 118 +++++ .../mobile-sidebar-hover-reveal.spec.ts | 2 +- .../mobile-sidebar-read-recovery.spec.ts | 4 +- .../layout/mobile-unified-navigation.spec.ts | 143 ++++++ .../lsp/mobile-lsp-file-intelligence.spec.ts | 2 +- .../e2e/tests/mobile-improve-kandev.spec.ts | 2 +- .../office/mobile-office-navigation.spec.ts | 4 +- .../mobile-bitbucket-plugin-contract.spec.ts | 2 +- .../tests/plugins/mobile-plugin-nav.spec.ts | 4 +- .../plugins/mobile-plugin-task-panel.spec.ts | 6 +- .../plugins/mobile-plugin-topbar.spec.ts | 13 +- .../mobile-prompt-history-plugin.spec.ts | 2 +- .../plugins/mobile-status-drawer.spec.ts | 2 +- .../plugins/mobile-task-list-facet.spec.ts | 4 +- ...e-pr-sidebar-automation-indicators.spec.ts | 2 +- .../review/mobile-review-file-status.spec.ts | 5 +- .../mobile-review-markdown-preview.spec.ts | 5 +- .../review/mobile-review-multi-pr.spec.ts | 5 +- .../review/mobile-submodule-review.spec.ts | 5 +- .../tests/review/mobile-walkthrough.spec.ts | 7 +- .../mobile-completed-session-resume.spec.ts | 4 +- ...le-completed-workspace-restoration.spec.ts | 11 +- ...mobile-kubernetes-task-environment.spec.ts | 2 +- .../mobile-resource-metrics-display.spec.ts | 4 +- .../mobile-sidebar-customization.spec.ts | 45 +- .../settings/mobile-startup-page.spec.ts | 4 +- .../mobile-message-queue-settings.spec.ts | 8 +- .../mobile-session-capacity-settings.spec.ts | 5 +- .../system/mobile-storage-maintenance.spec.ts | 2 +- .../tests/task/creation-auto-focus-helpers.ts | 4 +- .../task/mobile-action-confirmations.spec.ts | 4 +- .../task/mobile-add-workspace-sources.spec.ts | 9 +- ...mobile-agent-generated-task-titles.spec.ts | 2 +- ...le-archive-confirmation-preference.spec.ts | 2 +- .../task/mobile-archive-task-redirect.spec.ts | 4 +- .../tests/task/mobile-changes-panel.spec.ts | 5 +- ...obile-command-palette-task-actions.spec.ts | 2 +- ...bile-command-panel-task-navigation.spec.ts | 4 +- .../task/mobile-content-confirmations.spec.ts | 2 +- ...cutor-embedded-vscode-availability.spec.ts | 2 +- .../task/mobile-external-link-menu.spec.ts | 2 +- .../mobile-external-vcs-file-link.spec.ts | 2 +- .../task/mobile-file-confirmations.spec.ts | 2 +- .../mobile-file-tree-chat-context.spec.ts | 4 +- .../e2e/tests/task/mobile-file-viewer.spec.ts | 29 +- ...mobile-hidden-workflow-task-create.spec.ts | 2 +- .../tests/task/mobile-html-preview.spec.ts | 4 +- ...ile-large-file-tree-virtualization.spec.ts | 6 +- .../mobile-office-sidebar-nest-task.spec.ts | 2 +- .../task/mobile-prompt-history-panel.spec.ts | 6 +- .../mobile-sidebar-automatic-colors.spec.ts | 4 +- .../task/mobile-sidebar-queued-count.spec.ts | 2 +- .../task/mobile-sidebar-subtasks.spec.ts | 2 +- .../task/mobile-sidebar-task-actions.spec.ts | 26 +- .../tests/task/mobile-sidebar-views.spec.ts | 14 +- ...e-sidebar-workflow-completion-icon.spec.ts | 2 +- .../task/mobile-subtask-detachment.spec.ts | 4 +- .../mobile-subtask-reparent-drag-drop.spec.ts | 4 +- .../task/mobile-task-list-search.spec.ts | 10 +- .../task/mobile-task-listing-display.spec.ts | 8 +- .../task/mobile-task-loading-state.spec.ts | 2 +- .../task/mobile-task-status-summary.spec.ts | 2 +- .../mobile-task-topbar-long-title.spec.ts | 7 +- .../mobile-threads-display-settings.spec.ts | 14 +- .../tests/task/mobile-threads-view.spec.ts | 56 ++- .../mobile-workspace-sidebar-views.spec.ts | 5 +- ...workspace-switch-sidebar-isolation.spec.ts | 3 +- .../task/sidebar-immediate-archive-helpers.ts | 7 +- .../mobile-passthrough-rendering.spec.ts | 4 +- .../terminal/mobile-quick-terminal.spec.ts | 4 +- .../tests/terminal/mobile-terminal-helpers.ts | 4 +- .../mobile-queued-session-ownership.spec.ts | 2 +- .../mobile-workflow-session-focus.spec.ts | 2 +- ...obile-workflow-step-move-overrides.spec.ts | 2 +- .../mobile-workflow-step-progress.spec.ts | 2 +- apps/web/src/app-shell.test.tsx | 3 + apps/web/src/app-shell.tsx | 5 +- apps/web/src/locales/en/common.json | 5 +- apps/web/src/locales/en/task.json | 3 +- apps/web/src/locales/pseudo/common.json | 5 +- apps/web/src/locales/pseudo/task.json | 3 +- apps/web/src/locales/pt-pt/common.json | 5 +- apps/web/src/locales/pt-pt/task.json | 3 +- apps/web/src/locales/zh-cn/common.json | 5 +- apps/web/src/locales/zh-cn/task.json | 3 +- apps/web/src/locales/zh-hk/common.json | 5 +- apps/web/src/locales/zh-hk/task.json | 3 +- apps/web/src/locales/zh-tw/common.json | 5 +- apps/web/src/locales/zh-tw/task.json | 3 +- ...026-09-15-phone-navigation-entry-points.md | 54 +++ .../affected-mobile-specs.txt | 86 ++++ docs/plans/unified-mobile-navigation/plan.md | 444 ++++++++++++++++++ .../task-01-shared-navigation.md | 161 +++++++ .../task-02-pinned-shortcuts.md | 129 +++++ .../task-03-embedded-tasks.md | 256 ++++++++++ .../task-04-menu-hierarchy.md | 201 ++++++++ .../task-05-action-first-menu.md | 215 +++++++++ docs/public/developer-tools.md | 4 +- docs/public/sessions-and-review.md | 2 +- docs/public/tasks-and-workflows.md | 15 +- .../ui/requirements/mobile-task-chrome.md | 16 + .../requirements/mobile-task-view-access.md | 17 +- .../requirements/unified-mobile-navigation.md | 215 +++++++++ .../ui/system-design/mobile-task-chrome.md | 31 +- .../system-design/mobile-task-view-access.md | 21 +- .../unified-mobile-navigation.md | 332 +++++++++++++ 186 files changed, 5118 insertions(+), 934 deletions(-) create mode 100644 apps/web/components/integrations/mobile-integrations-section.test.tsx delete mode 100644 apps/web/components/kanban/mobile-listing-menu-button.tsx create mode 100644 apps/web/components/kanban/mobile-listing-options-context.tsx create mode 100644 apps/web/components/navigation/app-nav-surface.tsx create mode 100644 apps/web/components/navigation/mobile-automations-section.test.tsx create mode 100644 apps/web/components/navigation/mobile-automations-section.tsx create mode 100644 apps/web/components/navigation/mobile-task-navigation-provider.test.tsx create mode 100644 apps/web/components/navigation/mobile-task-navigation-provider.tsx create mode 100644 apps/web/components/task/add-workspace-sources/repository-source-menu.tsx create mode 100644 apps/web/components/task/mobile/responsive-task-picker.tsx create mode 100644 apps/web/components/task/mobile/task-picker-surface.tsx create mode 100644 apps/web/components/task/mobile/task-sheet-selection-context.test.tsx create mode 100644 apps/web/components/task/mobile/task-sheet-selection-context.tsx create mode 100644 apps/web/e2e/tests/layout/mobile-menu-hierarchy.spec.ts create mode 100644 apps/web/e2e/tests/layout/mobile-navigation-tasks.spec.ts create mode 100644 apps/web/e2e/tests/layout/mobile-unified-navigation.spec.ts create mode 100644 docs/decisions/2026-09-15-phone-navigation-entry-points.md create mode 100644 docs/plans/unified-mobile-navigation/affected-mobile-specs.txt create mode 100644 docs/plans/unified-mobile-navigation/plan.md create mode 100644 docs/plans/unified-mobile-navigation/task-01-shared-navigation.md create mode 100644 docs/plans/unified-mobile-navigation/task-02-pinned-shortcuts.md create mode 100644 docs/plans/unified-mobile-navigation/task-03-embedded-tasks.md create mode 100644 docs/plans/unified-mobile-navigation/task-04-menu-hierarchy.md create mode 100644 docs/plans/unified-mobile-navigation/task-05-action-first-menu.md create mode 100644 docs/specs/ui/requirements/unified-mobile-navigation.md create mode 100644 docs/specs/ui/system-design/unified-mobile-navigation.md diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 0c147eee0b..55e77514ad 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -31,7 +31,7 @@ import { Dialog } from "@kandev/ui/dialog"; - Use `useTouchDrawer` when a hover/popover disclosure needs a coarse-pointer `Drawer` alternative. Width-based phone composition and pointer-based disclosure behavior are related but not interchangeable. Apply the 44px minimum to coarse-pointer hit areas, touch rows, and mobile controls only; keep fine-pointer desktop controls at the surrounding design-system density, using 28px for ordinary buttons, inputs, and selectors and 24px only for deliberate compact inline controls, and do not reuse a touch-sized `h-11` class as the shared visual button size. See the [sizing guide](../../.agents/skills/mobile-parity/references/control-sizing.md) for exceptions. - Below 640px, shared Radix DropdownMenu/ContextMenu use inset, safe-area-aware bottom sheets in `app/globals.css`. Open `mobile-menu-root` content owns one decorative positioner backdrop matching Drawer dimming and blur; fade it with the sheet's exit motion. Never mark submenus; keep the backdrop outside scrolling content and pointer-transparent so Radix owns dismissal and non-modal interaction. Reuse these primitives and cover long/nested menus instead of adding parallel mobile menus. - Mobile capability parity does not require desktop layout parity. Load `/mobile-parity` for the Kandev surface decision guide, mobile design contract, and verification requirements; read [confirmation guidance](components/confirmation/AGENTS.md) when adopting phone confirmation surfaces. -- Phone listing chrome uses `KanbanHeaderMobile` and `MobileListingContext` across Kanban, List, and Threads. Threads supplies its saved-view control and inline pagination in the title slot. Phone search, tools, and plugin actions live in `MobileListingMenuActions`; keep activity/connection cues on the persistent menu button and restore the actual opener unless focus is moving into a launched surface. Tablet/desktop composition stays separate. +- Phone hamburger buttons use `AppNavSheet` for app navigation across listings, task workbenches, and page shells. The task title opens the task picker; `ResponsiveTaskPicker` owns its dialogs above responsive layout branches, and `TaskSheetSelectionProvider` shares cancellation with the embedded task sidebar. `MobileTaskNavigationProvider` retains the inline sidebar controller and action dialogs above responsive page headers, rendering the body into the shared menu outlet. Tasks collapses in place and shares the menu scroller. `KanbanHeaderMobile` opens listing-only `MobileMenuSheet` options from the Kanban/Threads/List title dropdown; Threads saved views render inline inside that surface. `MobileListingMenuActions` supplies search in options and workspace tools in app navigation. Keep activity/connection cues on `AppNavTrigger` and restore the actual opener unless focus moves into a launched surface. Tablet/desktop composition stays separate. Phone navigation uses Home for all listing modes; Tasks/Threads remain routes and palette destinations. The inline Tasks heading retains its independent plus. Phone Automations reuses workspace automation reads only while expanded, and Integrations retains a workspace settings entry even without configured links. Phone app navigation groups the built-in Quick Chat/Quick terminal actions below Home. `MobileQuickActions` shares launch and focus behavior; plugin actions and metrics retain separate slots. `MobileIntegrationsSection` opts into a local, initially collapsed disclosure only for phone app navigation; wider consumers retain expanded rendering. ## Data Flow Pattern (Critical) diff --git a/apps/web/app/threads/threads-page-client.tsx b/apps/web/app/threads/threads-page-client.tsx index 8457dcb0ec..5396e52e6b 100644 --- a/apps/web/app/threads/threads-page-client.tsx +++ b/apps/web/app/threads/threads-page-client.tsx @@ -39,21 +39,21 @@ function ThreadsPageHeader({ thread.taskId === activeMobileTaskId) + 1} + total={threads.length} + /> + } taskListingControls={ - <> - - thread.taskId === activeMobileTaskId) + 1} - total={threads.length} - /> - + } /> ); diff --git a/apps/web/components/integrations/integrations-menu.tsx b/apps/web/components/integrations/integrations-menu.tsx index 9a36eb656e..012aa6b76d 100644 --- a/apps/web/components/integrations/integrations-menu.tsx +++ b/apps/web/components/integrations/integrations-menu.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import Link from "@/components/routing/app-link"; import { Button } from "@kandev/ui/button"; import { @@ -11,16 +11,21 @@ import { DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { IconPlugConnected } from "@tabler/icons-react"; +import { IconChevronDown, IconChevronRight, IconPlugConnected } from "@tabler/icons-react"; import { DestinationRows } from "@/components/navigation/destination-rows"; import { useAppDestinations } from "@/hooks/use-app-destinations"; +import { useAppStore } from "@/components/state-provider"; +import { workspaceSettingsHref } from "@/lib/settings/workspace-settings-tabs"; import { useTranslation } from "react-i18next"; type MobileIntegrationsSectionProps = { onNavigate: () => void; + showSetup?: boolean; + collapsible?: boolean; }; const HOVER_CLOSE_DELAY_MS = 180; +const INTEGRATIONS_LABEL = "common:integrations"; /** * Configured integration destinations, in manifest order. Ids, labels, hrefs and @@ -73,7 +78,7 @@ export function IntegrationsMenu() { variant="ghost" size="icon-lg" className="cursor-pointer text-muted-foreground hover:text-foreground" - aria-label={t("common:integrations")} + aria-label={t(INTEGRATIONS_LABEL)} onPointerEnter={openOnHover} onPointerLeave={closeAfterHover} > @@ -86,7 +91,7 @@ export function IntegrationsMenu() { onPointerEnter={openOnHover} onPointerLeave={closeAfterHover} > - {t("common:integrations")} + {t(INTEGRATIONS_LABEL)} {links.map((link) => { const Icon = link.icon; return ( @@ -136,20 +141,68 @@ export function IntegrationsTopbarLinks() { * `IntegrationsSection`. Both come from the navigation manifest, so the two * surfaces cannot drift apart. */ -export function MobileIntegrationsSection({ onNavigate }: MobileIntegrationsSectionProps) { +export function MobileIntegrationsSection({ + onNavigate, + showSetup = false, + collapsible = false, +}: MobileIntegrationsSectionProps) { const { t } = useTranslation(); const destinations = useAppDestinations("mobileMenu", "integrations"); + const [expanded, setExpanded] = useState(false); + const bodyId = useId(); + const workspaceId = useAppStore((s) => s.workspaces.activeId); - if (destinations.length === 0) return null; + if (destinations.length === 0 && !showSetup) return null; return ( -
-
{t("common:integrations")}
- +
+ {collapsible ? ( + + ) : ( +
{t(INTEGRATIONS_LABEL)}
+ )} + {(!collapsible || expanded) && ( +
+ + {showSetup && ( + + )} +
+ )}
); } diff --git a/apps/web/components/integrations/mobile-integrations-section.test.tsx b/apps/web/components/integrations/mobile-integrations-section.test.tsx new file mode 100644 index 0000000000..95f2f08ab5 --- /dev/null +++ b/apps/web/components/integrations/mobile-integrations-section.test.tsx @@ -0,0 +1,57 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { defaultState } from "@/lib/state/default-state"; +import { MobileIntegrationsSection } from "./integrations-menu"; + +let workspaceId = "one"; +let github = true; +vi.mock("@/components/state-provider", () => ({ + useAppStore: (select: (state: unknown) => unknown) => + select({ + ...defaultState, + workspaces: { activeId: workspaceId, items: [] }, + }), +})); +vi.mock("@/hooks/use-nav-availability", () => ({ + useNavAvailability: () => ({ github }), +})); +vi.mock("@/lib/routing/client-router", () => ({ usePathname: () => "/" })); +afterEach(cleanup); +beforeEach(() => { + workspaceId = "one"; + github = true; +}); + +// @covers AC-UI-MOBILE-MENU-007.3 +it("starts collapsed and reveals eligible links and settings on expansion", () => { + const navigate = vi.fn(); + render(); + expect(screen.queryByRole("link", { name: "GitHub" })).toBeNull(); + const toggle = screen.getByRole("button", { name: "Integrations" }); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + fireEvent.click(screen.getByRole("link", { name: "GitHub" })); + expect(navigate).toHaveBeenCalledOnce(); + fireEvent.click(toggle); + expect(screen.queryByTestId("mobile-integration-settings")).toBeNull(); +}); + +// @covers AC-UI-MOBILE-MENU-007.4 +it("resolves current workspace availability after changes while collapsed", () => { + const host = render( {}} showSetup collapsible />); + workspaceId = "two"; + github = false; + host.rerender( {}} showSetup collapsible />); + fireEvent.click(screen.getByRole("button", { name: "Integrations" })); + expect(screen.queryByRole("link", { name: "GitHub" })).toBeNull(); + expect(screen.getByTestId("mobile-integration-settings").getAttribute("href")).toBe( + "/settings/workspaces/two/integrations", + ); +}); + +it("retains the default always-expanded presentation for other callers", () => { + render( {}} />); + expect(screen.getByRole("link", { name: "GitHub" })).not.toBeNull(); + expect(screen.queryByRole("button", { name: "Integrations" })).toBeNull(); +}); diff --git a/apps/web/components/kanban/kanban-header-mobile.test.tsx b/apps/web/components/kanban/kanban-header-mobile.test.tsx index a010551cbf..aa3e945cf6 100644 --- a/apps/web/components/kanban/kanban-header-mobile.test.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.test.tsx @@ -5,6 +5,22 @@ import { StateProvider } from "@/components/state-provider"; import { KanbanHeaderMobile } from "./kanban-header-mobile"; import { pluginRegistry } from "@/lib/plugins/registry"; +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isMobile: true }), +})); +vi.mock("@/hooks/use-select-workspace", () => ({ useSelectWorkspace: () => vi.fn() })); +vi.mock("@/hooks/use-system-health-indicator", () => ({ + useSystemHealthIndicator: () => ({ + hasIssues: false, + issues: [], + dialogOpen: false, + openDialog: vi.fn(), + }), +})); +vi.mock("@/hooks/use-nav-availability", () => ({ useNavAvailability: () => ({}) })); +vi.mock("@/components/theme/app-theme", () => ({ + useTheme: () => ({ resolvedTheme: "light", setTheme: vi.fn() }), +})); vi.mock("@/components/page-topbar", () => ({ PageTopbar: ({ title, @@ -28,8 +44,21 @@ vi.mock("@/components/page-topbar", () => ({ })); vi.mock("./mobile-menu-sheet", () => ({ - MobileMenuSheet: ({ open, pageActions }: { open: boolean; pageActions?: ReactNode }) => - open ?
{pageActions}
: null, + MobileMenuSheet: ({ + open, + pageActions, + listingControls, + }: { + open: boolean; + pageActions?: ReactNode; + listingControls?: ReactNode; + }) => + open ? ( +
+ {pageActions} + {listingControls} +
+ ) : null, })); const launchers = vi.hoisted(() => ({ @@ -57,7 +86,7 @@ vi.mock("@/components/quick-chat/use-quick-chat-activity", () => ({ useQuickChatActivity: () => chat, })); -const MENU = "mobile-topbar-menu"; +const MENU = "app-nav-trigger"; const CHAT = "mobile-quick-chat-button"; const TERMINAL = "mobile-quick-terminal-button"; const CONTEXT = "mobile-topbar-page-context"; @@ -79,7 +108,22 @@ afterEach(() => { function renderHeader(props: Partial> = {}) { return render( - + { }); describe("shared phone listing header", () => { + // @covers AC-UI-MOBILE-MENU-001.4 + it("exposes view options independently of app navigation", () => { + renderHeader(); + expect(screen.queryByRole("button", { name: "View options" })).toBeNull(); + const options = screen.getByTestId(CONTEXT); + expect(options === screen.getByTestId("app-nav-trigger")).toBe(false); + fireEvent.click(options); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); // @covers AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.5 AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.7 it.each([ ["kanban", "Kanban"], @@ -151,11 +204,16 @@ describe("shared phone listing header", () => { expect(screen.getByRole("dialog")).toBeTruthy(); }); - it("keeps the Threads-supplied view control in the title slot", () => { - renderHeader({ currentPage: "threads", taskListingControls: }); + it("opens Threads options from the mode title and includes its saved views", () => { + renderHeader({ + currentPage: "threads", + taskListingControls: , + mobileListingStatus: 2/3, + }); + expect(screen.getByTestId("deck-position").closest("header")).not.toBeNull(); + fireEvent.click(screen.getByTestId(CONTEXT)); const view = screen.getByRole("button", { name: "Review view" }); - expect(view.closest("header")).not.toBeNull(); - expect(screen.queryByTestId(CONTEXT)).toBeNull(); + expect(view.closest("header")).toBeNull(); expect(screen.queryByTestId("mobile-topbar-action-strip")).toBeNull(); }); @@ -168,7 +226,7 @@ describe("shared phone listing header", () => { expect(screen.queryByTestId(testId)).toBeNull(); openMenu(); fireEvent.click(screen.getByTestId(testId)); - expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.getByRole("dialog").getAttribute("data-state")).toBe("closed"); expect(launchers[launcher]).not.toHaveBeenCalled(); frame(); expect(launchers[launcher]).toHaveBeenCalledTimes(1); @@ -185,13 +243,13 @@ describe("shared phone listing header", () => { it("reveals search from the menu and clears its query on collapse", () => { const onSearchChange = vi.fn(); renderHeader({ currentPage: "tasks", searchQuery: "Alpha", onSearchChange }); - openMenu(); + fireEvent.click(screen.getByTestId("mobile-topbar-page-context")); expect(screen.getByTestId(SEARCH).getAttribute("aria-pressed")).toBe("false"); fireEvent.click(screen.getByTestId(SEARCH)); expect(screen.queryByRole("dialog")).toBeNull(); frame(); expect(onSearchChange).not.toHaveBeenCalled(); - openMenu(); + fireEvent.click(screen.getByTestId("mobile-topbar-page-context")); expect(screen.getByTestId(SEARCH).getAttribute("aria-pressed")).toBe("true"); fireEvent.click(screen.getByTestId(SEARCH)); frame(); diff --git a/apps/web/components/kanban/kanban-header-mobile.tsx b/apps/web/components/kanban/kanban-header-mobile.tsx index a1926eb149..1b0fa21145 100644 --- a/apps/web/components/kanban/kanban-header-mobile.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.tsx @@ -2,10 +2,11 @@ import { useRef, type MouseEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; +import { IconAlertTriangle } from "@tabler/icons-react"; import { PageTopbar } from "@/components/page-topbar"; import { MobileMenuSheet } from "./mobile-menu-sheet"; import { MobileListingContext } from "./mobile-listing-context"; -import { MobileListingMenuButton } from "./mobile-listing-menu-button"; +import { AppNavSheet } from "@/components/navigation/app-nav-sheet"; import { MobileListingMenuActions } from "./mobile-listing-menu-actions"; import type { TasksListDisplayOptions } from "./mobile-menu-task-list-options"; import { useAppStore } from "@/components/state-provider"; @@ -21,6 +22,7 @@ type KanbanHeaderMobileProps = { isSearchLoading?: boolean; tasksListOptions?: TasksListDisplayOptions; taskListingControls?: ReactNode; + mobileListingStatus?: ReactNode; }; const MODE_LABELS: Record = { @@ -39,6 +41,7 @@ export function KanbanHeaderMobile({ isSearchLoading = false, tasksListOptions, taskListingControls, + mobileListingStatus, }: KanbanHeaderMobileProps) { const { t } = useTranslation(); const isMenuOpen = useAppStore((state) => state.mobileKanban.isMenuOpen); @@ -79,28 +82,27 @@ export function KanbanHeaderMobile({ title={title} testId={currentPage === "threads" ? "threads-mobile-topbar" : undefined} titleSlot={ - currentPage === "threads" ? ( - taskListingControls - ) : ( +
: undefined} onClick={openMenu} aria-haspopup="dialog" aria-expanded={isMenuOpen} data-testid="mobile-topbar-page-context" /> - ) + {mobileListingStatus} +
} className="h-14 min-h-14" showStatusTrigger={false} homeAffordance="none" freeWidth="lead" - actions={ - - } + actions={} /> ); } + +function ThreadViewSyncStatus() { + const { t } = useTranslation(); + const error = useAppStore((state) => state.threadViews.syncError); + if (!error) return null; + return ( + + + ); +} diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index 41e046ba7b..4ffd1283c5 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -50,6 +50,7 @@ type KanbanHeaderProps = { isSearchLoading?: boolean; tasksListOptions?: TasksListDisplayOptions; taskListingControls?: ReactNode; + mobileListingStatus?: ReactNode; }; type ViewToggleItem = { @@ -411,6 +412,7 @@ export function KanbanHeader({ isSearchLoading = false, tasksListOptions, taskListingControls, + mobileListingStatus, }: KanbanHeaderProps) { const { t } = useTranslation(); const { isMobile, isTablet } = useResponsiveBreakpoint(); @@ -437,6 +439,7 @@ export function KanbanHeader({ currentPage={currentPage} title={title} workspaceLabel={workspaceLabel} + mobileListingStatus={mobileListingStatus} taskListingControls={taskListingControls} {...sharedSearch} tasksListOptions={tasksListOptions} diff --git a/apps/web/components/kanban/mobile-listing-context.tsx b/apps/web/components/kanban/mobile-listing-context.tsx index 59bf943992..054911bb88 100644 --- a/apps/web/components/kanban/mobile-listing-context.tsx +++ b/apps/web/components/kanban/mobile-listing-context.tsx @@ -1,14 +1,15 @@ "use client"; -import type { ComponentProps } from "react"; +import type { ComponentProps, ReactNode } from "react"; import { Button } from "@kandev/ui/button"; import { IconChevronDown } from "@tabler/icons-react"; export function MobileListingContext({ context, label, + status, ...props -}: ComponentProps & { context: string; label: string }) { +}: ComponentProps & { context: string; label: string; status?: ReactNode }) { return ( diff --git a/apps/web/components/kanban/mobile-listing-menu-actions.tsx b/apps/web/components/kanban/mobile-listing-menu-actions.tsx index e5653779a4..87197e966f 100644 --- a/apps/web/components/kanban/mobile-listing-menu-actions.tsx +++ b/apps/web/components/kanban/mobile-listing-menu-actions.tsx @@ -11,6 +11,7 @@ import { useQuickTerminalLauncher } from "@/hooks/use-quick-terminal-launcher"; import { MainTopBarPluginActions } from "./main-top-bar-plugin-actions"; import { useAppStore } from "@/components/state-provider"; import { StatusSurfaceMetrics } from "@/components/system-metrics/status-surface-metrics"; +import { cn } from "@/lib/utils"; import type { TaskListingPage } from "@/lib/task-listing/view-navigation"; export function MobileListingMenuActions({ @@ -22,7 +23,11 @@ export function MobileListingMenuActions({ onToggleSearch, isSearchOpen, returnFocusRef, + showWorkspaceActions = true, + showQuickActions = true, }: { + showWorkspaceActions?: boolean; + showQuickActions?: boolean; workspaceId?: string; workspaceLabel: string; currentPage: TaskListingPage; @@ -33,9 +38,6 @@ export function MobileListingMenuActions({ returnFocusRef: RefObject; }) { const { t } = useTranslation(); - const { activity, label } = useQuickChatActivity(workspaceId); - const openQuickChat = useQuickChatLauncher(workspaceId, "chat", { returnFocusRef }); - const openQuickTerminal = useQuickTerminalLauncher(workspaceId, { returnFocusRef }); const statusBarEnabled = useAppStore((state) => state.userSettings.appStatusBarEnabled); function launch(action: () => void, restoreFocus = false) { @@ -44,7 +46,12 @@ export function MobileListingMenuActions({ } return ( -
+
div:empty]:hidden [&_[data-slot=button]]:!min-h-11 [&_[data-slot=button]]:!min-w-11", + )} + > {onToggleSearch && ( - - + {showWorkspaceActions && showQuickActions && workspaceId && ( + )} - - {!statusBarEnabled && ( + {showWorkspaceActions && ( + + )} + {showWorkspaceActions && !statusBarEnabled && ( ); } + +export function MobileQuickActions({ + workspaceId, + closeMenu, + returnFocusRef, + inline = false, +}: { + workspaceId: string; + closeMenu: (restoreFocus?: boolean) => void; + returnFocusRef: RefObject; + inline?: boolean; +}) { + const { t } = useTranslation(); + const { activity, label } = useQuickChatActivity(workspaceId); + const openQuickChat = useQuickChatLauncher(workspaceId, "chat", { returnFocusRef }); + const openQuickTerminal = useQuickTerminalLauncher(workspaceId, { returnFocusRef }); + function launch(action: () => void) { + closeMenu(false); + requestAnimationFrame(action); + } + return ( +
+ + +
+ ); +} diff --git a/apps/web/components/kanban/mobile-listing-menu-button.tsx b/apps/web/components/kanban/mobile-listing-menu-button.tsx deleted file mode 100644 index 3f439d0ec9..0000000000 --- a/apps/web/components/kanban/mobile-listing-menu-button.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import type { MouseEventHandler } from "react"; -import { Button } from "@kandev/ui/button"; -import { IconMenu2 } from "@tabler/icons-react"; -import { useTranslation } from "react-i18next"; -import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider"; -import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-status-item"; -import { QuickChatActivityIndicator } from "@/components/quick-chat/quick-chat-activity-indicator"; -import { useQuickChatActivity } from "@/components/quick-chat/use-quick-chat-activity"; -import { cn } from "@/lib/utils"; - -export function MobileListingMenuButton({ - workspaceId, - open, - onClick, -}: { - workspaceId?: string; - open: boolean; - onClick: MouseEventHandler; -}) { - const { t } = useTranslation(); - const { issueSeverity } = useAppStatusDrawer(); - const issueDetails = useConnectionIssueCopy(issueSeverity); - const { activity, label } = useQuickChatActivity(workspaceId); - - return ( - - ); -} diff --git a/apps/web/components/kanban/mobile-listing-options-context.tsx b/apps/web/components/kanban/mobile-listing-options-context.tsx new file mode 100644 index 0000000000..80e6b04b6b --- /dev/null +++ b/apps/web/components/kanban/mobile-listing-options-context.tsx @@ -0,0 +1,4 @@ +import { createContext, useContext } from "react"; + +export const MobileListingOptionsContext = createContext<{ close: () => void } | null>(null); +export const useMobileListingOptions = () => useContext(MobileListingOptionsContext); diff --git a/apps/web/components/kanban/mobile-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index a90f116f62..d587b791a6 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -1,4 +1,9 @@ "use client"; +import { MobileListingOptionsContext } from "./mobile-listing-options-context"; +import { + MobileConfirmationHost, + MobileConfirmationHostBody, +} from "@/components/confirmation/mobile-confirmation-host"; import { type ReactNode, type RefObject } from "react"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer"; @@ -37,6 +42,8 @@ export type MobileMenuSheetProps = { isSearchLoading?: boolean; tasksListOptions?: TasksListDisplayOptions; pageActions?: ReactNode; + listingOnly?: boolean; + listingControls?: ReactNode; }; function MobileSearchSection({ @@ -140,6 +147,7 @@ function MobileViewSection({ function ResponsiveMenuSurface({ isMobile, + listingOnly, open, onOpenChange, contentRef, @@ -148,6 +156,7 @@ function ResponsiveMenuSurface({ children, }: { isMobile: boolean; + listingOnly?: boolean; open: boolean; onOpenChange: (open: boolean) => void; contentRef: RefObject; @@ -158,30 +167,39 @@ function ResponsiveMenuSurface({ const { t } = useTranslation(); if (isMobile) { return ( - - -
- - {t("kanban:menu")} - -
+ {({ contentProps }) => ( + + - {children} -
-
-
-
+ +
+ + + {t(listingOnly ? "common:viewOptions" : "kanban:menu")} + + +
+ {children} +
+
+
+ + + )} + ); } @@ -218,6 +236,8 @@ function MobileMenuContent({ displayOptions, navControls, pageActions, + listingOnly, + listingControls, }: Pick< MobileMenuSheetProps, | "workspaceId" @@ -226,6 +246,8 @@ function MobileMenuContent({ | "isSearchLoading" | "onOpenChange" | "pageActions" + | "listingOnly" + | "listingControls" > & { isMobile: boolean; open: boolean; @@ -244,21 +266,28 @@ function MobileMenuContent({ isSearchLoading={isSearchLoading ?? false} /> )} - + {!listingOnly && } + {listingControls && ( + onOpenChange(false) }}> + {listingControls} + + )} {/* Phone Home lives in this menu; the View toggle owns listing modes. */} - onOpenChange(false)} - omitSections={isMobile || viewValue === "threads" ? [] : ["primary"]} - omitDestinations={["tasks", "threads"]} - workspaceActions={} - controls={navControls} - /> + {!listingOnly && ( + onOpenChange(false)} + omitSections={isMobile || viewValue === "threads" ? [] : ["primary"]} + omitDestinations={["tasks", "threads"]} + workspaceActions={} + controls={navControls} + /> + )}
); } @@ -274,6 +303,8 @@ export function MobileMenuSheet({ isSearchLoading = false, tasksListOptions, pageActions, + listingOnly = false, + listingControls, }: MobileMenuSheetProps) { const navControls = useAppNavDialogs(() => onOpenChange(false)); const { contentRef, isMobile, viewValue, handleViewChange, displayOptions, focusMenu } = @@ -299,6 +330,8 @@ export function MobileMenuSheet({ displayOptions={displayOptions} navControls={navControls} pageActions={pageActions} + listingOnly={listingOnly} + listingControls={listingControls} /> ); } @@ -314,6 +347,8 @@ function MobileMenuRender( | "onSearchChange" | "isSearchLoading" | "pageActions" + | "listingOnly" + | "listingControls" > & { isMobile: boolean; contentRef: RefObject; diff --git a/apps/web/components/navigation/app-nav-sections.tsx b/apps/web/components/navigation/app-nav-sections.tsx index 513af5fa9d..8f165acf44 100644 --- a/apps/web/components/navigation/app-nav-sections.tsx +++ b/apps/web/components/navigation/app-nav-sections.tsx @@ -50,8 +50,11 @@ export type AppNavDialogControls = { dialogs: ReactNode; }; -export function useAppNavDialogs(closeMenu: () => void): AppNavDialogControls { - const taskViews = useTaskViewNavigation(closeMenu); +export function useAppNavDialogs( + closeMenu: () => void, + onOpenTaskViews?: () => void, +): AppNavDialogControls { + const taskViews = useTaskViewNavigation(closeMenu, onOpenTaskViews); const router = useRouter(); const workspaceId = useAppStore((s) => s.workspaces.activeId); const health = useSystemHealthIndicator(); @@ -121,6 +124,9 @@ type AppNavSectionsProps = { omitDestinations?: string[]; /** Optional workspace-scoped plugin actions for the current phone surface. */ workspaceActions?: ReactNode; + afterPrimary?: ReactNode; + quickActions?: ReactNode; + phoneNavigation?: boolean; controls: AppNavDialogControls; }; @@ -135,6 +141,9 @@ export function AppNavSections({ omitSections = [], omitDestinations = [], workspaceActions, + afterPrimary, + quickActions, + phoneNavigation = false, controls, }: AppNavSectionsProps) { const { t } = useTranslation(); @@ -142,16 +151,22 @@ export function AppNavSections({ const hasSavedSidebarLayout = useHasSavedSidebarLayout(); return ( <> - {workspaceActions} {hasSavedSidebarLayout ? ( ) : ( !omit.has("primary") && ( - + ) )} {controls.openTaskViews && ( @@ -164,13 +179,23 @@ export function AppNavSections({ {t("sidebar:taskViews")} )} + {afterPrimary} + {workspaceActions} {!hasSavedSidebarLayout && !omit.has("plugins") && ( )} {!hasSavedSidebarLayout && !omit.has("integrations") && ( - + )} - + ); } @@ -178,9 +203,13 @@ export function AppNavSections({ function PrimaryNavSection({ onNavigate, omitDestinations, + phoneNavigation, + quickActions, }: { onNavigate: () => void; omitDestinations: string[]; + phoneNavigation: boolean; + quickActions?: ReactNode; }) { const all = useStaticDestinations("mobileMenu", "primary"); const destinations = all.filter((destination) => !omitDestinations.includes(destination.id)); @@ -190,8 +219,10 @@ function PrimaryNavSection({ + {quickActions}
); } @@ -199,16 +230,29 @@ function PrimaryNavSection({ function UtilityNavSection({ onNavigate, controls, + phoneNavigation, }: { + phoneNavigation: boolean; onNavigate: () => void; controls: AppNavDialogControls; }) { const { t } = useTranslation(); - const destinations = useStaticDestinations("mobileMenu", MOBILE_MENU_UTILITY_SECTIONS); + const allDestinations = useStaticDestinations("mobileMenu", MOBILE_MENU_UTILITY_SECTIONS); + const destinations = phoneNavigation + ? [ + ...allDestinations.filter((item) => item.id === "settings"), + ...allDestinations.filter((item) => item.id !== "settings"), + ] + : allDestinations; const { resolvedTheme, setTheme } = useTheme(); const utilityRowClass = "h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm"; return ( -
+
{t("common:utilities")}
({ setTheme: vi.fn(), })); +let pathname = "/settings"; +let inOffice = false; + let resolvedTheme: "light" | "dark" = "light"; const THEME_TOGGLE_TEST_ID = "mobile-theme-toggle-button"; +const NAV_TRIGGER = "app-nav-trigger"; const ARIA_LABEL = "aria-label"; const state = { + ...defaultState, features: { canvases: false }, workspaces: { activeId: "ws-1" as string | null, @@ -26,6 +31,8 @@ const state = { }; beforeEach(() => { + pathname = "/settings"; + inOffice = false; state.userSettings = { ...defaultState.userSettings }; state.workspaces.items[0].office_workflow_id = null; }); @@ -44,15 +51,22 @@ let workspaceActionsRegistrations: Array<{ vi.mock("@/lib/routing/client-router", () => ({ useRouter: () => ({ push: mocks.routerPush }), - usePathname: () => "/settings", + usePathname: () => pathname, })); vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (s: typeof state) => unknown) => selector(state), })); +vi.mock("@/hooks/use-select-workspace", () => ({ useSelectWorkspace: () => vi.fn() })); +vi.mock("@/hooks/use-quick-chat-launcher", () => ({ useQuickChatLauncher: () => vi.fn() })); +vi.mock("@/hooks/use-quick-terminal-launcher", () => ({ useQuickTerminalLauncher: () => vi.fn() })); +vi.mock("@/components/quick-chat/use-quick-chat-activity", () => ({ + useQuickChatActivity: () => ({ activity: null, label: "Quick Chat" }), +})); + vi.mock("@/hooks/use-in-office", () => ({ - useInOffice: () => false, + useInOffice: () => inOffice, })); type NavRegistration = { @@ -145,7 +159,34 @@ function SectionsHost({ ); } +function verifyMenuOrder() { + render(} />); + fireEvent.click(screen.getByTestId(NAV_TRIGGER)); + const precedes = (first: Element, second: Element) => + Boolean(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING); + expect( + precedes( + screen.getByRole("link", { name: "Home" }), + screen.getByTestId("mobile-quick-chat-button"), + ), + ).toBe(true); + expect( + precedes(screen.getByTestId("mobile-quick-terminal-button"), screen.getByTestId("page-nav")), + ).toBe(true); + expect( + precedes( + screen.getByRole("link", { name: "Settings" }), + screen.getByRole("link", { name: "Stats" }), + ), + ).toBe(true); +} + describe("AppNavSheet", () => { + // @covers AC-UI-MOBILE-MENU-007.1 AC-UI-MOBILE-MENU-007.2 + it("puts quick actions before local navigation and Settings before Stats", () => { + verifyMenuOrder(); + }); + it("offers task views for Kanban, not Office workspaces", () => { const host = render(); expect(screen.getByRole("button", { name: "Task views" })).not.toBeNull(); @@ -165,20 +206,56 @@ describe("AppNavSheet", () => { it("opens from the trigger and offers the manifest destinations plus pageNav", () => { render(} />); - fireEvent.click(screen.getByTestId("app-nav-trigger")); + fireEvent.click(screen.getByTestId(NAV_TRIGGER)); const sheet = screen.getByTestId("app-nav-sheet"); expect(sheet).not.toBeNull(); expect(screen.getByTestId("page-nav")).not.toBeNull(); - for (const label of ["Home", "Tasks", "Stats", "Settings"]) { + for (const label of ["Home", "Stats", "Settings"]) { expect(screen.getByRole("link", { name: label })).not.toBeNull(); } - // pageNav renders above the global sections. - const nav = sheet.querySelector("nav"); - const pageNavIndex = [...(nav?.children ?? [])].findIndex((el) => - el.matches('[data-testid="page-nav"]'), + // Global destinations have a stable position before local navigation. + expect( + screen + .getByTestId("app-nav-primary") + .compareDocumentPosition(screen.getByTestId("page-nav")) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + // @covers AC-UI-MOBILE-MENU-001.1, AC-UI-MOBILE-MENU-001.5 + it("opens phone navigation in a bottom drawer", () => { + render(); + fireEvent.click(screen.getByTestId(NAV_TRIGGER)); + expect(screen.getByTestId("app-nav-sheet").getAttribute("data-vaul-drawer-direction")).toBe( + "bottom", ); - expect(pageNavIndex).toBe(0); + }); + + // @covers AC-UI-MOBILE-MENU-001.2 + it("identifies the active destination", () => { + render(); + fireEvent.click(screen.getByTestId(NAV_TRIGGER)); + expect(screen.getByRole("link", { name: "Settings" }).getAttribute("aria-current")).toBe( + "page", + ); + expect(screen.queryByRole("link", { name: "Tasks" })).toBeNull(); + }); + + it.each(["/", "/tasks", "/threads"])("marks Home current for %s", (path) => { + pathname = path; + render(); + fireEvent.click(screen.getByTestId(NAV_TRIGGER)); + expect(screen.getByRole("link", { name: "Home" }).getAttribute("aria-current")).toBe("page"); + }); + + it("does not offer Kanban destinations from an Office workspace on a shared page", () => { + inOffice = true; + state.workspaces.items[0].office_workflow_id = "office-workflow"; + render(); + fireEvent.click(screen.getByTestId(NAV_TRIGGER)); + expect(screen.queryByRole("link", { name: "Tasks" })).toBeNull(); + expect(screen.queryByRole("link", { name: "Threads" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Task views" })).toBeNull(); }); // @covers AC-UI-TASK-LISTING-DISPLAY-PREFERENCES-003.4 @@ -189,7 +266,7 @@ describe("AppNavSheet", () => { state.userSettings.startupPage = startupPage; render(); - fireEvent.click(screen.getByTestId("app-nav-trigger")); + fireEvent.click(screen.getByTestId(NAV_TRIGGER)); expect(screen.getByRole("link", { name: "Home" }).getAttribute("href")).toBe(href); }); @@ -208,7 +285,7 @@ describe("AppNavSheet", () => { ]; render(); - fireEvent.click(screen.getByTestId("app-nav-trigger")); + fireEvent.click(screen.getByTestId(NAV_TRIGGER)); expect(screen.getByTestId("mobile-workspace-action")).not.toBeNull(); expect(captured).toEqual({ workspaceId: "ws-1", presentation: "mobile" }); diff --git a/apps/web/components/navigation/app-nav-sheet.tsx b/apps/web/components/navigation/app-nav-sheet.tsx index 7b516e9576..6931f0a9df 100644 --- a/apps/web/components/navigation/app-nav-sheet.tsx +++ b/apps/web/components/navigation/app-nav-sheet.tsx @@ -1,73 +1,252 @@ "use client"; -import { useState, type MouseEvent, type ReactNode } from "react"; +import { useArchivedTaskState } from "@/components/task/task-archived-context"; +import { useOptionalPortForwardingVisibility } from "@/components/task/port-forwarding-visibility-provider"; + +import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@kandev/ui/sheet"; import { MobileWorkspaceActionsSection } from "@/components/app-sidebar/app-sidebar-workspace-actions"; +import { AppSidebarWorkspacePicker } from "@/components/app-sidebar/app-sidebar-workspace-picker"; +import { + MobileListingMenuActions, + MobileQuickActions, +} from "@/components/kanban/mobile-listing-menu-actions"; +import { useAppStore } from "@/components/state-provider"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useInOffice } from "@/hooks/use-in-office"; +import { usePathname } from "@/lib/routing/client-router"; +import { useHasSavedSidebarLayout } from "@/hooks/domains/sidebar/use-sidebar-layout-navigation"; import { AppNavSections, useAppNavDialogs } from "./app-nav-sections"; +import { MobileAutomationsSection } from "./mobile-automations-section"; import { AppNavTrigger } from "./app-nav-trigger"; +import { AppNavSurface } from "./app-nav-surface"; + +import { useMobileTaskNavigationOutlet } from "./mobile-task-navigation-provider"; +import { useWorkbenchTaskSelection } from "@/components/task/mobile/task-sheet-selection-context"; +import { useRouter } from "@/lib/routing/client-router"; +import { linkToTask, replaceTaskUrl } from "@/lib/links"; type AppNavSheetProps = { - /** - * In-section navigation rendered above the global sections — the settings - * tree on /settings, Office's sections on /office. Phone users get the - * page's own nav plus the app-wide destinations in one place. - */ pageNav?: ReactNode | ((close: () => void) => ReactNode); - /** Manifest destination ids `pageNav` already covers. See `AppNavSections`. */ omitDestinations?: string[]; + /** Reuse a workbench's existing task picker and selection controller. */ + onOpenTaskViews?: () => void; }; -/** - * The app navigation sheet behind every `PageShell` hamburger: one trigger, - * one left-side sheet, one meaning at every width below `md`. Replaces the - * per-surface menus that each hand-picked a different subset of destinations. - */ -export function AppNavSheet({ pageNav, omitDestinations }: AppNavSheetProps) { - const { t } = useTranslation(); +/** Shared phone app navigation; wider page shells retain their side sheet. */ +export function AppNavSheet({ pageNav, omitDestinations, onOpenTaskViews }: AppNavSheetProps) { + const { isMobile } = useResponsiveBreakpoint(); + const pathname = usePathname(); + const inOffice = useInOffice(); + const workspace = useAppStore((s) => + s.workspaces.items.find((w) => w.id === s.workspaces.activeId), + ); const [open, setOpen] = useState(false); - const close = () => setOpen(false); - const controls = useAppNavDialogs(close); + const opener = useRef(null); + const restoreFocus = useRef(true); + const close = useCallback(() => setOpen(false), []); + const controls = useAppNavDialogs(close, onOpenTaskViews); const renderedPageNav = typeof pageNav === "function" ? pageNav(close) : pageNav; - - // pageNav trees (SettingsTree, Office sections) render plain links that - // don't know about this sheet; close on any link click so navigation isn't - // hidden behind an open overlay. + const currentPage = listingPageForPath(pathname); const closeOnLinkClick = (event: MouseEvent) => { if (event.target instanceof Element && event.target.closest("a[href]")) close(); }; return ( <> - - - - - { + if (next) restoreFocus.current = true; + setOpen(next); + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + if (restoreFocus.current && opener.current?.isConnected) + opener.current.focus({ preventScroll: true }); + controls.onMenuCloseAutoFocus?.(event); + }} + trigger={} + > + + {controls.dialogs} ); } + +function NavigationWorkspacePicker({ close }: { close: () => void }) { + const { t } = useTranslation(); + return ( +
+

{t("common:workspace")}

+ +
+ ); +} + +function NavigationQuickActions({ + isMobile, + workspaceId, + ...props +}: { + isMobile: boolean; + workspaceId?: string; +} & Omit[0], "workspaceId">) { + return isMobile && workspaceId ? ( + + ) : null; +} + +function MobileNavigationExtras({ + localNav, + workspaceId, + showTasks, + close, +}: { + localNav: ReactNode; + workspaceId?: string; + showTasks: boolean; + close: () => void; +}) { + const { t } = useTranslation(); + return ( + <> + {localNav && ( +
+

{t("common:pageNavigation")}

+ {localNav} +
+ )} + {showTasks && workspaceId && } + + ); +} + +function listingPageForPath(pathname: string) { + if (pathname === "/tasks") return "tasks"; + if (pathname === "/threads") return "threads"; + return "kanban"; +} + +function MobileTaskOutlet({ close }: { close: () => void }) { + const register = useMobileTaskNavigationOutlet(); + const archivedState = useArchivedTaskState(); + const portForwarding = useOptionalPortForwardingVisibility(); + const selection = useWorkbenchTaskSelection(); + const router = useRouter(); + const element = useRef(null); + const navigate = useCallback( + (id: string) => { + if (selection) replaceTaskUrl(id); + else router.push(linkToTask(id)); + }, + [router, selection], + ); + useEffect(() => { + if (element.current) + register({ + element: element.current, + close, + navigate, + selection, + archivedState, + portForwarding, + }); + return () => register(null); + }, [register, close, navigate, selection, archivedState, portForwarding]); + return
; +} + +function NavigationAutomations({ + isMobile, + open, + inOffice, + workspaceId, + close, +}: { + isMobile: boolean; + open: boolean; + inOffice: boolean; + workspaceId?: string; + close: () => void; +}) { + const hasSavedSidebarLayout = useHasSavedSidebarLayout(); + if (!isMobile || !open || inOffice || !workspaceId || hasSavedSidebarLayout) return null; + return ( + + ); +} + +function navigationOmissions(isMobile: boolean, omitted: string[] = []) { + return isMobile ? [...omitted, "tasks", "threads"] : omitted; +} diff --git a/apps/web/components/navigation/app-nav-surface.tsx b/apps/web/components/navigation/app-nav-surface.tsx new file mode 100644 index 0000000000..a72a5bbafb --- /dev/null +++ b/apps/web/components/navigation/app-nav-surface.tsx @@ -0,0 +1,85 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { IconX } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerTrigger, + DrawerClose, +} from "@kandev/ui/drawer"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@kandev/ui/sheet"; + +export function AppNavSurface({ + isMobile, + open, + onOpenChange, + onCloseAutoFocus, + trigger, + children, +}: { + isMobile: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + onCloseAutoFocus: (event: Event) => void; + trigger: ReactNode; + children: ReactNode; +}) { + const { t } = useTranslation(); + if (isMobile) { + return ( + + {trigger} + { + event.preventDefault(); + (event.currentTarget as HTMLElement | null)?.focus(); + }} + tabIndex={-1} + className="h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] !max-h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] outline-none" + > +
+ + {t("common:menu")} + + + + + {children} +
+
+
+ ); + } + return ( + + {trigger} + + + {t("common:menu")} + + {children} + + + ); +} diff --git a/apps/web/components/navigation/app-nav-trigger.tsx b/apps/web/components/navigation/app-nav-trigger.tsx index 27cf6b3986..8e93d11581 100644 --- a/apps/web/components/navigation/app-nav-trigger.tsx +++ b/apps/web/components/navigation/app-nav-trigger.tsx @@ -6,6 +6,9 @@ import { IconMenu2 } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider"; import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-status-item"; +import { useAppStore } from "@/components/state-provider"; +import { useQuickChatActivity } from "@/components/quick-chat/use-quick-chat-activity"; +import { QuickChatActivityIndicator } from "@/components/quick-chat/quick-chat-activity-indicator"; import { cn } from "@/lib/utils"; type AppNavTriggerProps = ComponentProps; @@ -23,6 +26,8 @@ export const AppNavTrigger = forwardRef( const { t } = useTranslation(); const { issueSeverity } = useAppStatusDrawer(); const issue = useConnectionIssueCopy(issueSeverity); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { activity, label } = useQuickChatActivity(workspaceId ?? undefined); return ( + +
+ {expanded && ( + + )} + + ); +} + +function MobileAutomationRows({ + workspaceId, + onNavigate, +}: { + workspaceId: string; + onNavigate: () => void; +}) { + const { t } = useTranslation(); + const list = useWorkspaceAutomations(workspaceId); + const activity = useAutomationSummaries(workspaceId); + useLiveRefresh(true, activity.refresh); + const rows = buildAutomationRows(list.automations, activity.summaries); + const pending = list.loading || activity.loading; + const error = list.error || activity.error; + return ( +
+ {pending && ( +

+ {t("common:loading")} +

+ )} + {error && ( +
+

+ {list.error + ? t("automations:failedToLoadAutomations") + : t("automations:failedToLoadAutomationActivity")} +

+ +
+ )} + {!list.loading && + !list.error && + rows.map(({ automation, state }) => ( + + ))} + +
+ ); +} diff --git a/apps/web/components/navigation/mobile-sidebar-layout-navigation.tsx b/apps/web/components/navigation/mobile-sidebar-layout-navigation.tsx index 53da4d1b48..365d9c1bc9 100644 --- a/apps/web/components/navigation/mobile-sidebar-layout-navigation.tsx +++ b/apps/web/components/navigation/mobile-sidebar-layout-navigation.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { IconInbox, IconSquarePlus } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; @@ -31,12 +31,16 @@ import { DestinationRows } from "./destination-rows"; const INTEGRATION_DESTINATION_IDS = new Set(["azure-devops", "github", "gitlab", "jira", "linear"]); type MobileSidebarLayoutNavigationProps = { + quickActions?: ReactNode; + homeCoversListings?: boolean; onNavigate: () => void; omitSections: Set; omitDestinations: string[]; }; type MobileLayoutNodeProps = { + quickActions?: ReactNode; + homeCoversListings?: boolean; node: ProjectedSidebarNode; homeDestination?: ResolvedDestination; destinationHrefs: Map; @@ -204,6 +208,8 @@ function MobileRequiredRows({ function MobileBuiltinNode({ node, + quickActions, + homeCoversListings, homeDestination, integrationEntries, canvasEntries, @@ -218,11 +224,15 @@ function MobileBuiltinNode({ case "home": if (omitSections.has("primary") || omitDestinations.includes("home")) return null; return homeDestination ? ( - + <> + + {quickActions} + ) : null; case "new_task": return omitDestinations.includes("new_task") ? null : ( @@ -291,6 +301,8 @@ function MobileLayoutNode(props: MobileLayoutNodeProps) { } export function MobileSidebarLayoutNavigation({ + quickActions, + homeCoversListings, onNavigate, omitSections, omitDestinations, @@ -324,6 +336,10 @@ export function MobileSidebarLayoutNavigation({ // Office-specific required rows are added below, while user-selected // visibility and order still come from the layout projection. const visibleNodes = projection.nodes.filter((node) => node.visible); + const hasVisibleHome = + !omitSections.has("primary") && + !omitDestinations.includes("home") && + visibleNodes.some((node) => node.destinationId === "home"); const homeDestination = primary.find((destination) => destination.id === "home"); const integrationEntries = catalog.catalog.filter( (entry) => @@ -336,11 +352,14 @@ export function MobileSidebarLayoutNavigation({ return (
+ {!hasVisibleHome && quickActions} {visibleNodes.map((node) => ( ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ workspaces: { activeId: "workspace" }, workflows: { activeId: "workflow" } }), +})); +vi.mock("@/components/task/mobile/session-task-switcher-sheet", () => ({ + SessionTaskSwitcherSheet: () => { + const archived = useArchivedTaskState(); + const ports = useOptionalPortForwardingVisibility(); + return {`${archived.archivedTaskId ?? "none"}:${ports?.enabled ?? false}`}; + }, +})); +afterEach(cleanup); + +const portForwarding = { + enabled: true, + canToggle: true, + isUpdating: false, + dialogOpen: false, + setDialogOpen: vi.fn(), + togglePortForwarding: vi.fn(), +}; +function Outlet({ taskId }: { taskId: string }) { + const register = useMobileTaskNavigationOutlet(); + const element = useRef(null); + useEffect(() => { + const outlet = { + element: element.current!, + close: vi.fn(), + navigate: vi.fn(), + archivedState: { isArchived: true, archivedTaskId: taskId }, + portForwarding, + }; + register(outlet); + return () => register(null); + }, [register, taskId]); + return
; +} + +it("bridges current task context to the retained sidebar and updates it with the outlet", async () => { + const host = render( + + + , + ); + await expect.poll(() => screen.getByRole("status").textContent).toBe("archived-first:true"); + host.rerender( + + + , + ); + await expect.poll(() => screen.getByRole("status").textContent).toBe("archived-second:true"); +}); diff --git a/apps/web/components/navigation/mobile-task-navigation-provider.tsx b/apps/web/components/navigation/mobile-task-navigation-provider.tsx new file mode 100644 index 0000000000..70ba4582fd --- /dev/null +++ b/apps/web/components/navigation/mobile-task-navigation-provider.tsx @@ -0,0 +1,94 @@ +import { + createContext, + lazy, + Suspense, + useCallback, + useContext, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { Drawer } from "@kandev/ui/drawer"; +import { + TaskArchivedProvider, + useArchivedTaskState, +} from "@/components/task/task-archived-context"; +import { + PortForwardingVisibilityContextProvider, + type PortForwardingVisibility, +} from "@/components/task/port-forwarding-visibility-provider"; +import { useAppStore } from "@/components/state-provider"; +import type { TaskSheetSelectionController } from "@/components/task/mobile/session-task-switcher-sheet-selection"; + +const TaskSidebar = lazy(() => + import("@/components/task/mobile/session-task-switcher-sheet").then((m) => ({ + default: m.SessionTaskSwitcherSheet, + })), +); + +type Outlet = { + element: HTMLDivElement; + close: () => void; + navigate: (taskId: string) => void; + selection?: TaskSheetSelectionController | null; + archivedState?: ReturnType; + portForwarding?: PortForwardingVisibility; +}; +const Context = createContext<(outlet: Outlet | null) => void>(() => {}); +export const useMobileTaskNavigationOutlet = () => useContext(Context); + +/** Task action dialogs outlive the menu portal and responsive page headers. */ +export function MobileTaskNavigationProvider({ children }: { children: ReactNode }) { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const workflowId = useAppStore((s) => s.workflows.activeId); + const [outlet, setOutlet] = useState(null); + const configuration = useRef(null); + const [requested, setRequested] = useState(false); + const register = useCallback((next: Outlet | null) => { + setOutlet(next); + if (next) { + configuration.current = next; + setRequested(true); + } + }, []); + return ( + + {children} + {requested && workspaceId && ( + + + + { + if (!open) configuration.current?.close(); + }} + navigate={configuration.current?.navigate} + selection={configuration.current?.selection ?? undefined} + presentation="drawer" + renderInline={(body) => + outlet + ? createPortal( + + {body} + , + outlet.element, + ) + : null + } + /> + + + + )} + + ); +} diff --git a/apps/web/components/navigation/use-task-view-navigation.tsx b/apps/web/components/navigation/use-task-view-navigation.tsx index 7d762aedea..346f3d3266 100644 --- a/apps/web/components/navigation/use-task-view-navigation.tsx +++ b/apps/web/components/navigation/use-task-view-navigation.tsx @@ -40,7 +40,7 @@ function TaskViewSurface({ ); } -export function useTaskViewNavigation(closeMenu: () => void) { +export function useTaskViewNavigation(closeMenu: () => void, onOpenTaskViews?: () => void) { const workspace = useAppStore(selectActiveWorkspace); const { isMobile } = useResponsiveBreakpoint(); const kanbanWorkspace = workspace && !isOfficeWorkspace(workspace); @@ -65,8 +65,12 @@ export function useTaskViewNavigation(closeMenu: () => void) { frame.current = requestAnimationFrame(() => { opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; - setMounted(true); - setOpen(true); + if (onOpenTaskViews) { + onOpenTaskViews(); + } else { + setMounted(true); + setOpen(true); + } }); }; const restoreFocus = (event: Event) => { diff --git a/apps/web/components/page-shell.test.tsx b/apps/web/components/page-shell.test.tsx index 8462fb4cec..df7be28903 100644 --- a/apps/web/components/page-shell.test.tsx +++ b/apps/web/components/page-shell.test.tsx @@ -44,7 +44,7 @@ describe("PageShell", () => { expect(screen.getByTestId("tree")).not.toBeNull(); }); - it("keeps the nav sheet ahead of page-supplied leading content", () => { + it("keeps app navigation after page-supplied content", () => { render( }> content @@ -55,7 +55,7 @@ describe("PageShell", () => { const order = [...header.querySelectorAll("[data-testid]")].map((el) => el.getAttribute("data-testid"), ); - expect(order.indexOf("nav-sheet-mock")).toBeLessThan(order.indexOf("slot")); + expect(order.indexOf("nav-sheet-mock")).toBeGreaterThan(order.indexOf("slot")); }); it("applies the resolved home affordance to the topbar crumb", () => { diff --git a/apps/web/components/page-shell.tsx b/apps/web/components/page-shell.tsx index c70143d487..756437b805 100644 --- a/apps/web/components/page-shell.tsx +++ b/apps/web/components/page-shell.tsx @@ -48,7 +48,7 @@ type PageShellProps = { /** * The one page chrome for top-level routes: `PageTopbar` with the app nav - * trigger injected ahead of any page `leading`, the home affordance resolved by + * trigger following the page actions, the home affordance resolved by * `useHomeAffordance` (instead of each shell hand-rolling an escape hatch), and * a scroll container below. * @@ -75,12 +75,13 @@ export function PageShell({ testId={topbarTestId} homeAffordance={home.mode} homeHref={home.href} - leading={ + leading={leading} + actions={ <> + {forwarded.actions} {showNavTrigger && ( )} - {leading} } /> diff --git a/apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.test.tsx b/apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.test.tsx index b58c65eaa8..7ae852b68a 100644 --- a/apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.test.tsx +++ b/apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.test.tsx @@ -206,6 +206,25 @@ describe("AddWorkspaceSourcesDialog repository discovery", () => { }); }); +describe("AddWorkspaceSourcesDialog touch activation", () => { + it("opens the repository menu after touch release without selecting a source", async () => { + isMobile = true; + render(); + fireEvent.click(screen.getByRole("button", { name: ADD_SOURCES_LABEL })); + const trigger = screen.getByRole("button", { name: "Add repository" }); + const workspaceRepository = { name: "Workspace repository" }; + fireEvent.pointerDown(trigger, { button: 0, pointerType: "touch" }); + expect(screen.queryByRole("menuitem", workspaceRepository)).toBeNull(); + fireEvent.pointerUp(trigger, { button: 0, pointerType: "touch" }); + expect(screen.queryByRole("menuitem", workspaceRepository)).toBeNull(); + fireEvent.click(trigger); + expect(await screen.findByRole("menuitem", workspaceRepository)).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Remove source" })).toBeNull(); + fireEvent.click(await screen.findByRole("menuitem", { name: "Local Git repository" })); + expect(screen.getByRole("button", { name: "Remove source" })).toBeTruthy(); + }); +}); + describe("AddWorkspaceSourcesDialog", () => { it("uses a touch-sized repository menu without tabs or discarded mixed source rows", async () => { isMobile = true; diff --git a/apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.tsx b/apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.tsx index 637d577e6c..4218e17d4f 100644 --- a/apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.tsx +++ b/apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.tsx @@ -1,14 +1,7 @@ "use client"; import { useCallback, useEffect, useState, type RefObject, type ReactNode } from "react"; -import { - IconChevronDown, - IconCloudDownload, - IconGitBranch, - IconPlus, - IconStack2, - IconX, -} from "@tabler/icons-react"; +import { IconX } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Dialog, @@ -25,12 +18,6 @@ import { DrawerHeader, DrawerTitle, } from "@kandev/ui/drawer"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@kandev/ui/dropdown-menu"; import { Input } from "@kandev/ui/input"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useAppStore } from "@/components/state-provider"; @@ -46,6 +33,7 @@ import { getWorkspaceSourceCapabilities, hasCloneableSavedRepository, } from "@/components/workspace-source-picker/executor-capabilities"; +import { RepositorySourceMenu } from "./repository-source-menu"; import { AddFolderButton } from "./add-folder-button"; import { SavedRepositorySourceRow } from "./saved-repository-source-row"; import { useDialogOpenerFocus } from "./use-dialog-opener-focus"; @@ -53,7 +41,6 @@ import { useSubmitWorkspaceSources } from "./use-submit-workspace-sources"; import { useWorkspaceRepositoryOptions } from "./use-workspace-repository-options"; import { useWorkspaceSourceRows } from "./use-workspace-source-rows"; import { WorkspaceChangeConsequences } from "./workspace-change-consequences"; -import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; @@ -344,79 +331,6 @@ function SourceForm({ ); } -function RepositorySourceMenu({ - isMobile, - onAdd, -}: { - isMobile: boolean; - onAdd: (kind: "saved_repository" | "local_repository" | "remote_repository") => void; -}) { - const { t } = useTranslation(); - const itemClass = cn("cursor-pointer items-start gap-3", isMobile ? "min-h-11" : "py-2"); - return ( - - - - - - } - className={itemClass} - onSelect={() => onAdd("saved_repository")} - /> - } - className={itemClass} - onSelect={() => onAdd("local_repository")} - /> - } - className={itemClass} - onSelect={() => onAdd("remote_repository")} - /> - - - ); -} - -function RepositorySourceMenuItem({ - label, - description, - icon, - className, - onSelect, -}: { - label: string; - description: string; - icon: ReactNode; - className: string; - onSelect: () => void; -}) { - return ( - - {icon} - - {label} - {description} - - - ); -} - function SourceRow({ row, repositories, diff --git a/apps/web/components/task/add-workspace-sources/repository-source-menu.tsx b/apps/web/components/task/add-workspace-sources/repository-source-menu.tsx new file mode 100644 index 0000000000..b4481880f1 --- /dev/null +++ b/apps/web/components/task/add-workspace-sources/repository-source-menu.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useRef, useState, type ReactNode } from "react"; +import { + IconChevronDown, + IconCloudDownload, + IconGitBranch, + IconPlus, + IconStack2, +} from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; + +export function RepositorySourceMenu({ + isMobile, + onAdd, +}: { + isMobile: boolean; + onAdd: (kind: "saved_repository" | "local_repository" | "remote_repository") => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const touchPending = useRef(false); + const itemClass = cn("cursor-pointer items-start gap-3", isMobile ? "min-h-11" : "py-2"); + return ( + + { + // Defer opening until the touch's synthetic click has a stable target. + touchPending.current = event.pointerType === "touch"; + if (touchPending.current) event.preventDefault(); + }} + onPointerCancel={() => { + touchPending.current = false; + }} + onClick={() => { + if (!touchPending.current) return; + touchPending.current = false; + setOpen((value) => !value); + }} + > + + + + } + className={itemClass} + onSelect={() => onAdd("saved_repository")} + /> + } + className={itemClass} + onSelect={() => onAdd("local_repository")} + /> + } + className={itemClass} + onSelect={() => onAdd("remote_repository")} + /> + + + ); +} + +function RepositorySourceMenuItem({ + label, + description, + icon, + className, + onSelect, +}: { + label: string; + description: string; + icon: ReactNode; + className: string; + onSelect: () => void; +}) { + return ( + + {icon} + + {label} + {description} + + + ); +} diff --git a/apps/web/components/task/mobile/responsive-task-picker.tsx b/apps/web/components/task/mobile/responsive-task-picker.tsx new file mode 100644 index 0000000000..118ef067a7 --- /dev/null +++ b/apps/web/components/task/mobile/responsive-task-picker.tsx @@ -0,0 +1,50 @@ +import { lazy, Suspense, useState } from "react"; +import { useAppStore } from "@/components/state-provider"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; + +const TaskPicker = lazy(() => + import("./session-task-switcher-sheet").then((module) => ({ + default: module.SessionTaskSwitcherSheet, + })), +); + +/** Owns task dialogs above layout changes so rotating a phone preserves drafts. */ +export function ResponsiveTaskPicker({ + workspaceId, + workflowId, +}: { + workspaceId: string | null; + workflowId: string | null; +}) { + const { isMobile } = useResponsiveBreakpoint(); + const open = useAppStore((state) => state.mobileSession.isTaskSwitcherOpen); + const setOpen = useAppStore((state) => state.setMobileSessionTaskSwitcherOpen); + const [requested, setRequested] = useState(false); + const [entry, setEntry] = useState({ open: false, opener: null as HTMLElement | null }); + if (open && !requested) setRequested(true); + if (entry.open !== open) { + setEntry({ + open, + opener: + open && document.activeElement instanceof HTMLElement + ? document.activeElement + : entry.opener, + }); + } + if (!requested && !open) return null; + return ( + + { + event.preventDefault(); + if (entry.opener?.isConnected) entry.opener.focus({ preventScroll: true }); + }} + /> + + ); +} diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx index b69e242e89..be78c3a786 100644 --- a/apps/web/components/task/mobile/session-mobile-layout.tsx +++ b/apps/web/components/task/mobile/session-mobile-layout.tsx @@ -5,7 +5,6 @@ import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { SessionMobileTopBar } from "./session-mobile-top-bar"; import { SessionMobileBottomNav } from "./session-mobile-bottom-nav"; -import { SessionTaskSwitcherSheet } from "./session-task-switcher-sheet"; import { MobileFileViewerPanel } from "./mobile-file-viewer-panel"; import { TaskChatPanel, type PendingMessageScrollTarget } from "../task-chat-panel"; import { TaskPlanPanel } from "../task-plan-panel"; @@ -420,7 +419,8 @@ type MobileTopBarStickyProps = { effectiveSessionId: string | null; baseBranch?: string; worktreeBranch?: string | null; - onMenuClick: () => void; + onTaskPickerClick: () => void; + taskPickerOpen: boolean; showApproveButton: boolean; onApprove: () => void; isRemoteExecutor?: boolean; @@ -448,7 +448,8 @@ function MobileTopBarSticky(props: MobileTopBarStickyProps) { sessionId={props.effectiveSessionId} baseBranch={props.baseBranch} worktreeBranch={props.worktreeBranch} - onMenuClick={props.onMenuClick} + onTaskPickerClick={props.onTaskPickerClick} + taskPickerOpen={props.taskPickerOpen} showApproveButton={props.showApproveButton} onApprove={props.onApprove} isRemoteExecutor={props.isRemoteExecutor} @@ -655,7 +656,6 @@ export const SessionMobileLayout = memo(function SessionMobileLayout( handlePanelChange, isTaskSwitcherOpen, handleMenuClick, - setMobileSessionTaskSwitcherOpen, } = useSessionLayoutState({ sessionId: props.sessionId }); const { selectedFile, @@ -737,7 +737,8 @@ export const SessionMobileLayout = memo(function SessionMobileLayout( {...props} activeTaskId={activeTaskId} effectiveSessionId={effectiveSessionId} - onMenuClick={handleMenuClick} + onTaskPickerClick={handleMenuClick} + taskPickerOpen={isTaskSwitcherOpen} showApproveButton={showApproveButton} onApprove={handleApprove} /> @@ -775,13 +776,6 @@ export const SessionMobileLayout = memo(function SessionMobileLayout( taskCanvases={props.taskCanvases} onOpenCanvas={props.onOpenCanvas} /> - = {}) { workspaceId="ws-1" taskTitle="Pin the RDS engine version" sessionId="session-1" - onMenuClick={vi.fn()} + onTaskPickerClick={vi.fn()} + taskPickerOpen={false} {...props} /> diff --git a/apps/web/components/task/mobile/session-mobile-top-bar.test.tsx b/apps/web/components/task/mobile/session-mobile-top-bar.test.tsx index 86e73c0c3a..9b232427a1 100644 --- a/apps/web/components/task/mobile/session-mobile-top-bar.test.tsx +++ b/apps/web/components/task/mobile/session-mobile-top-bar.test.tsx @@ -1,53 +1,58 @@ -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { SessionMobileTopBar } from "./session-mobile-top-bar"; -vi.mock("@/components/task/executor-settings-button", () => ({ - ExecutorSettingsButton: ({ taskId, sessionId }: { taskId: string; sessionId: string }) => ( - - ), +vi.mock("@/hooks/domains/session/use-session-git-status", () => ({ + useSessionGitStatus: () => null, })); - -vi.mock("@/components/task/remote-cloud-tooltip", () => ({ - RemoteCloudTooltip: () => , +vi.mock("@/hooks/domains/session/use-session-commits", () => ({ + useSessionCommits: () => ({ commits: [] }), +})); +vi.mock("@/components/gitlab/mr-topbar-button", () => ({ MRTopbarButton: () => null })); +vi.mock("@/components/task/port-forward-dialog", () => ({ PortForwardButton: () => null })); +vi.mock("@/components/task/task-top-bar-plugin-actions", () => ({ + TaskTopBarPluginActions: () => null, +})); +vi.mock("@/components/navigation/app-nav-sheet", () => ({ + AppNavSheet: () =>
-
+ ); } @@ -183,7 +204,7 @@ type MobileTopBarActionsProps = { taskTitle?: string; isArchived?: boolean; onTaskUnarchived?: (taskId: string) => void; - onMenuClick: () => void; + onTaskPickerClick: () => void; }; function MobileTopBarActions({ @@ -202,11 +223,10 @@ function MobileTopBarActions({ taskTitle, isArchived, onTaskUnarchived, - onMenuClick, + onTaskPickerClick, }: MobileTopBarActionsProps) { - const { t } = useTranslation(); return ( -
+
{isArchived && } {!isArchived && } @@ -231,16 +251,7 @@ function MobileTopBarActions({ /> )} {showApproveButton && onApprove && } - +
); } @@ -255,7 +266,7 @@ export const SessionMobileTopBar = memo(function SessionMobileTopBar( props.baseBranch, ); return ( -
+
); diff --git a/apps/web/components/task/mobile/session-tablet-layout.tsx b/apps/web/components/task/mobile/session-tablet-layout.tsx index 871e4dfdae..bdd08ee15b 100644 --- a/apps/web/components/task/mobile/session-tablet-layout.tsx +++ b/apps/web/components/task/mobile/session-tablet-layout.tsx @@ -2,7 +2,6 @@ import { memo, useMemo } from "react"; import { Group, Panel } from "react-resizable-panels"; -import { SessionTaskSwitcherSheet } from "./session-task-switcher-sheet"; import { TaskCenterPanel } from "../task-center-panel"; import { TaskRightPanel } from "../task-right-panel"; import { TaskFilesPanel } from "../task-files-panel"; @@ -97,8 +96,6 @@ function TabletLeftPanel({ } export const SessionTabletLayout = memo(function SessionTabletLayout({ - workspaceId, - workflowId, sessionId = null, repository = null, defaultLayouts = {}, @@ -113,8 +110,6 @@ export const SessionTabletLayout = memo(function SessionTabletLayout({ openFileRequest, handleOpenFile, handleFileOpenHandled, - isTaskSwitcherOpen, - setMobileSessionTaskSwitcherOpen, } = useSessionLayoutState({ sessionId }); const layoutBySession = useLayoutStore((state) => state.columnsBySessionId); @@ -185,13 +180,6 @@ export const SessionTabletLayout = memo(function SessionTabletLayout({ )} - {/* Task Switcher Sheet - same as mobile */} -
); diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 70d16f30cd..1c62e5ba01 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -1,11 +1,14 @@ "use client"; -import { useCallback, useEffect, useMemo, useState, memo } from "react"; +import { useCallback, useMemo, useState, memo, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { IconCheck, IconNetwork, IconPlus } from "@tabler/icons-react"; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; +import { SheetHeader, SheetTitle } from "@kandev/ui/sheet"; import { DrawerHeader, DrawerTitle } from "@kandev/ui/drawer"; -import { TaskSwitcherDrawer } from "./task-switcher-drawer"; +import { useTaskSheetSelectionController } from "./task-sheet-selection-context"; +export { useTaskSheetSelectionController } from "./task-sheet-selection-context"; +import type { TaskSheetSelectionController } from "./session-task-switcher-sheet-selection"; +import { TaskPickerSurface, InlineTaskHeader } from "./task-picker-surface"; import { Button } from "@kandev/ui/button"; import { QuickChatSheetButton } from "./quick-chat-sheet-button"; import { TaskSwitcher } from "../task-switcher"; @@ -26,10 +29,7 @@ import { } from "../task-session-sidebar-link-actions"; import { useSidebarTaskLinking } from "../task-session-sidebar-task-linking"; import { useSheetData, useSheetActions } from "./session-task-switcher-sheet-hooks"; -import { - createTaskSheetSelectionController, - handleTaskSheetOpenChange, -} from "./session-task-switcher-sheet-selection"; +import { handleTaskSheetOpenChange } from "./session-task-switcher-sheet-selection"; import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; import { useMobileTaskRename } from "./use-mobile-task-rename"; import { useSidebarTaskEdit } from "../task-session-sidebar-edit"; @@ -44,6 +44,8 @@ type SessionTaskSwitcherSheetProps = { presentation?: "sheet" | "drawer"; navigate?: (taskId: string) => void; onCloseAutoFocus?: (event: Event) => void; + renderInline?: (body: ReactNode) => ReactNode; + selection?: TaskSheetSelectionController; }; function useTaskSheetOpener(open: boolean) { const [opener, setOpener] = useState({ open: false, current: null as HTMLElement | null }); @@ -59,16 +61,6 @@ function useTaskSheetOpener(open: boolean) { return opener; } -export function useTaskSheetSelectionController() { - const [selectionController] = useState(createTaskSheetSelectionController); - useEffect( - () => () => { - selectionController.invalidate(); - }, - [selectionController], - ); - return selectionController; -} export function useMobileTaskLinking(workspaceId: string | null) { const store = useAppStoreApi(); const actions = useSidebarLinkActions(store); @@ -305,6 +297,9 @@ type TaskSwitcherSurfaceContentProps = { onOpenChange: (open: boolean) => void; onQuickChat: () => void; onNewTask: () => void; + inline?: boolean; + expanded: boolean; + onExpandedChange: (expanded: boolean) => void; onCreateSubtask: (taskId: string, taskTitle: string) => void; data: ReturnType; actions: ReturnType; @@ -313,6 +308,19 @@ type TaskSwitcherSurfaceContentProps = { linking: ReturnType; }; +function mobileMoveActions( + presentation: "sheet" | "drawer", + moveOptions: ReturnType, + onOpenChange: (open: boolean) => void, +) { + if (presentation !== "drawer") return {}; + return { + onMoveToStep: moveOptions.handleMove, + onRequestMoveOptions: moveOptions.handleRequest, + onBeforeMoveOptionsOpen: () => onOpenChange(false), + }; +} + // eslint-disable-next-line max-lines-per-function -- this surface keeps the existing mobile scroll owner intact function TaskSwitcherSurfaceContent({ open, @@ -327,19 +335,12 @@ function TaskSwitcherSurfaceContent({ rename, edit, linking, + inline = false, + expanded, + onExpandedChange, }: TaskSwitcherSurfaceContentProps) { const { t } = useTranslation(); - let taskLoadError: string | null = null; - if (data.workspaceContextError) { - taskLoadError = data.workspaceContextAccessDenied - ? t("sidebar:workspaceContextAccessDenied") - : t("sidebar:workspaceContextRefreshFailed"); - } else if (data.archivedError) { - taskLoadError = t("sidebar:archivedLoadFailed"); - } - const retryTaskLoad = data.workspaceContextError - ? data.retryWorkspaceContext - : data.retryArchivedTasks; + const { taskLoadError, retryTaskLoad } = useTaskReadStatus(data); const moveOptions = useMobileTaskMoveOptions({ open, activeTaskId: data.activeTaskId, @@ -366,54 +367,67 @@ function TaskSwitcherSurfaceContent({ }); return ( <> - ({ id: w.id, name: w.name }))} - onWorkspaceChange={actions.handleWorkspaceChange} - onQuickChat={onQuickChat} - onNewTask={onNewTask} - /> - {data.activeTaskId && ( - onOpenChange(false)} - activeTaskId={data.activeTaskId} + {inline ? ( + + ) : ( + ({ id: w.id, name: w.name }))} + onWorkspaceChange={actions.handleWorkspaceChange} + onQuickChat={onQuickChat} + onNewTask={onNewTask} /> )} -
- -
-
- onOpenChange(false) : undefined} - > - + )} +
+ +
+
+ onOpenChange(false) : undefined} + > + + +
); @@ -460,13 +474,17 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ presentation = "sheet", navigate, onCloseAutoFocus, + renderInline, + selection, }: SessionTaskSwitcherSheetProps) { const [dialogOpen, setDialogOpen] = useState(false); + const [expanded, setExpanded] = useState(true); const opener = useTaskSheetOpener(open); const autoFocusNewTasks = useAppStore((state) => state.userSettings.autoFocusNewTasks) !== false; const [subtaskTarget, setSubtaskTarget] = useState<{ id: string; title: string } | null>(null); const data = useSheetData(workspaceId); - const selectionController = useTaskSheetSelectionController(); + const localSelection = useTaskSheetSelectionController(); + const selectionController = selection ?? localSelection; const handleOpenChange = useCallback( (nextOpen: boolean) => handleTaskSheetOpenChange(selectionController, nextOpen, onOpenChange), [onOpenChange, selectionController], @@ -491,6 +509,9 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ const surfaceContent = ( ); - const surface = - presentation === "drawer" ? ( - - {surfaceContent} - - ) : ( - - - {surfaceContent} - - - ); + const surface = renderInline ? ( + renderInline(surfaceContent) + ) : ( + + {surfaceContent} + + ); return ( <> @@ -550,3 +563,19 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ ); }); + +function useTaskReadStatus(data: ReturnType) { + const { t } = useTranslation(); + let taskLoadError: string | null = null; + if (data.workspaceContextError) { + taskLoadError = data.workspaceContextAccessDenied + ? t("sidebar:workspaceContextAccessDenied") + : t("sidebar:workspaceContextRefreshFailed"); + } else if (data.archivedError) { + taskLoadError = t("sidebar:archivedLoadFailed"); + } + const retryTaskLoad = data.workspaceContextError + ? data.retryWorkspaceContext + : data.retryArchivedTasks; + return { taskLoadError, retryTaskLoad }; +} diff --git a/apps/web/components/task/mobile/task-picker-surface.tsx b/apps/web/components/task/mobile/task-picker-surface.tsx new file mode 100644 index 0000000000..fd6ffb6c55 --- /dev/null +++ b/apps/web/components/task/mobile/task-picker-surface.tsx @@ -0,0 +1,84 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { IconChevronDown, IconChevronRight, IconPlus } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Sheet, SheetContent } from "@kandev/ui/sheet"; +import { TaskSwitcherDrawer } from "./task-switcher-drawer"; + +export function InlineTaskHeader({ + expanded, + onExpandedChange, + onNewTask, +}: { + expanded: boolean; + onExpandedChange: (expanded: boolean) => void; + onNewTask: () => void; +}) { + const { t } = useTranslation(); + return ( +
+ + +
+ ); +} + +export function TaskPickerSurface({ + presentation, + workspaceId, + open, + onOpenChange, + onCloseAutoFocus, + children, +}: { + presentation?: "sheet" | "drawer"; + workspaceId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onCloseAutoFocus?: (event: Event) => void; +} & { children: ReactNode }) { + if (presentation === "drawer") + return ( + + {children} + + ); + return ( + + + {children} + + + ); +} diff --git a/apps/web/components/task/mobile/task-sheet-selection-context.test.tsx b/apps/web/components/task/mobile/task-sheet-selection-context.test.tsx new file mode 100644 index 0000000000..6eeef2d9c1 --- /dev/null +++ b/apps/web/components/task/mobile/task-sheet-selection-context.test.tsx @@ -0,0 +1,51 @@ +import { cleanup, render } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { + TaskSheetSelectionProvider, + useTaskSheetSelectionController, +} from "./task-sheet-selection-context"; +import type { TaskSheetSelectionController } from "./session-task-switcher-sheet-selection"; +afterEach(cleanup); + +it("invalidates pending selection when workspace scope changes without replacing the controller", () => { + const controllers: TaskSheetSelectionController[] = []; + function Consumer() { + controllers.push(useTaskSheetSelectionController()); + return null; + } + const view = render( + + + , + ); + const token = controllers[0].beginSelection(); + view.rerender( + + + , + ); + expect(controllers[1]).toBe(controllers[0]); + expect(controllers[0].isCurrent(token)).toBe(false); +}); + +// @covers AC-UI-MOBILE-MENU-003.3 +it("shares selection cancellation between pins and the task picker until their owner unmounts", () => { + const controllers: TaskSheetSelectionController[] = []; + function Consumer() { + controllers.push(useTaskSheetSelectionController()); + return null; + } + const view = render( + + + + , + ); + expect(controllers[0]).toBe(controllers[1]); + const first = controllers[0].beginSelection(); + const second = controllers[1].beginSelection(); + expect(controllers[0].isCurrent(first)).toBe(false); + expect(controllers[0].isCurrent(second)).toBe(true); + view.unmount(); + expect(controllers[0].isCurrent(second)).toBe(false); +}); diff --git a/apps/web/components/task/mobile/task-sheet-selection-context.tsx b/apps/web/components/task/mobile/task-sheet-selection-context.tsx new file mode 100644 index 0000000000..0830f5c33b --- /dev/null +++ b/apps/web/components/task/mobile/task-sheet-selection-context.tsx @@ -0,0 +1,42 @@ +import { + createContext, + useContext, + useEffect, + useLayoutEffect, + useState, + type ReactNode, +} from "react"; +import { + createTaskSheetSelectionController, + type TaskSheetSelectionController, +} from "./session-task-switcher-sheet-selection"; + +const TaskSheetSelectionContext = createContext(null); + +export function useTaskSheetSelectionController() { + const shared = useContext(TaskSheetSelectionContext); + const [local] = useState(createTaskSheetSelectionController); + useEffect(() => () => local.invalidate(), [local]); + return shared ?? local; +} + +export function useWorkbenchTaskSelection() { + return useContext(TaskSheetSelectionContext); +} + +/** The embedded sidebar and title picker must supersede each other's pending selection requests. */ +export function TaskSheetSelectionProvider({ + children, + workspaceId, +}: { + children: ReactNode; + workspaceId?: string | null; +}) { + const controller = useTaskSheetSelectionController(); + useLayoutEffect(() => () => controller.invalidate(), [controller, workspaceId]); + return ( + + {children} + + ); +} diff --git a/apps/web/components/task/port-forwarding-visibility-provider.tsx b/apps/web/components/task/port-forwarding-visibility-provider.tsx index d371624624..6742107052 100644 --- a/apps/web/components/task/port-forwarding-visibility-provider.tsx +++ b/apps/web/components/task/port-forwarding-visibility-provider.tsx @@ -40,6 +40,8 @@ type PortForwardingVisibilityProviderProps = { const PortForwardingVisibilityContext = createContext(null); +export const PortForwardingVisibilityContextProvider = PortForwardingVisibilityContext.Provider; + export function useOptionalPortForwardingVisibility(): PortForwardingVisibility | undefined { return useContext(PortForwardingVisibilityContext) ?? undefined; } diff --git a/apps/web/components/task/task-layout-repository.test.tsx b/apps/web/components/task/task-layout-repository.test.tsx index 97e8dd0494..1607d9ace7 100644 --- a/apps/web/components/task/task-layout-repository.test.tsx +++ b/apps/web/components/task/task-layout-repository.test.tsx @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { StateProvider } from "@/components/state-provider"; import { TaskLayout } from "./task-layout"; afterEach(cleanup); @@ -9,12 +11,38 @@ afterEach(cleanup); // repository label reaches the phone layout. `session-mobile-top-bar-repository` // covers what the header does with it. vi.mock("./mobile", () => ({ - SessionMobileLayout: ({ repositoryLabel }: { repositoryLabel?: string | null }) => ( -
- ), + SessionMobileLayout: function MobileLayout({ + repositoryLabel, + }: { + repositoryLabel?: string | null; + }) { + const [draft, setDraft] = useState(""); + return ( +
+ setDraft(event.target.value)} + /> +
+ ); + }, SessionTabletLayout: () =>
, })); +vi.mock("./mobile/responsive-task-picker", () => ({ + ResponsiveTaskPicker: function Picker() { + const [draft, setDraft] = useState(""); + return ( + setDraft(event.target.value)} + /> + ); + }, +})); + vi.mock("@/hooks/use-responsive-breakpoint", () => ({ useResponsiveBreakpoint: () => ({ isMobile: true, usesDesktopWorkbench: false }), })); @@ -28,8 +56,38 @@ vi.mock("./task-launch-error-context", () => ({ })); describe("TaskLayout repository label", () => { + it("keeps layout state while task workspace metadata refreshes", () => { + const view = render(, { + wrapper: StateProvider, + }); + fireEvent.change(screen.getByRole("textbox", { name: "Layout draft" }), { + target: { value: "Keep this draft" }, + }); + view.rerender(); + view.rerender(); + expect((screen.getByRole("textbox", { name: "Layout draft" }) as HTMLInputElement).value).toBe( + "Keep this draft", + ); + }); + + it("retains picker drafts through missing workspace metadata and resets for a different workspace", () => { + const view = render(, { + wrapper: StateProvider, + }); + const draft = () => screen.getByRole("textbox", { name: "Picker draft" }) as HTMLInputElement; + fireEvent.change(draft(), { target: { value: "Keep picker draft" } }); + view.rerender(); + expect(draft().value).toBe("Keep picker draft"); + view.rerender(); + expect(draft().value).toBe("Keep picker draft"); + view.rerender(); + expect(draft().value).toBe(""); + }); + it("hands the repository label to the phone layout", () => { - render(); + render(, { + wrapper: StateProvider, + }); expect(screen.getByTestId("mobile-layout").getAttribute("data-repository-label")).toBe( "kdlbs/kandev", @@ -37,7 +95,7 @@ describe("TaskLayout repository label", () => { }); it("passes nothing along for a task with no repository", () => { - render(); + render(, { wrapper: StateProvider }); expect(screen.getByTestId("mobile-layout").getAttribute("data-repository-label")).toBe(""); }); diff --git a/apps/web/components/task/task-layout.tsx b/apps/web/components/task/task-layout.tsx index 50feef888d..7a8e7234cb 100644 --- a/apps/web/components/task/task-layout.tsx +++ b/apps/web/components/task/task-layout.tsx @@ -1,10 +1,12 @@ "use client"; -import { memo, useCallback } from "react"; +import { memo, useCallback, useState } from "react"; import dynamic from "@/lib/routing/client-dynamic"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useRouter } from "@/lib/routing/client-router"; import { canvasHref, type Canvas } from "@/lib/api/domains/canvas-api"; +import { TaskSheetSelectionProvider } from "./mobile/task-sheet-selection-context"; +import { ResponsiveTaskPicker } from "./mobile/responsive-task-picker"; import { SessionMobileLayout, SessionTabletLayout } from "./mobile"; import type { Repository, RepositoryScript } from "@/lib/types/http"; import type { Terminal } from "@/hooks/domains/session/use-terminals"; @@ -49,7 +51,24 @@ type TaskLayoutProps = { taskCanvases?: Canvas[]; }; -export const TaskLayout = memo(function TaskLayout({ +export const TaskLayout = memo(function TaskLayout(props: TaskLayoutProps) { + const [pickerWorkspaceId, setPickerWorkspaceId] = useState(props.workspaceId); + if (props.workspaceId && props.workspaceId !== pickerWorkspaceId) { + setPickerWorkspaceId(props.workspaceId); + } + return ( + + + + + ); +}); + +const ResponsiveTaskLayout = memo(function ResponsiveTaskLayout({ taskId = null, workspaceId, workflowId, diff --git a/apps/web/components/threads/threads-view-controls.tsx b/apps/web/components/threads/threads-view-controls.tsx index 8cc7869b88..840b39046f 100644 --- a/apps/web/components/threads/threads-view-controls.tsx +++ b/apps/web/components/threads/threads-view-controls.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMobileListingOptions } from "@/components/kanban/mobile-listing-options-context"; import { useMemo, useRef, useState } from "react"; import { IconAdjustments, @@ -319,6 +320,7 @@ function MobileThreadsViewControls({ onDismissSyncError, }: MobileThreadsViewControlsProps) { const { t } = useTranslation(); + const inlineOptions = useMobileListingOptions(); const { isMobile } = useResponsiveBreakpoint(); const activeViewName = threadViewName(activeView, t); const [open, setOpen] = useState(false); @@ -336,6 +338,10 @@ function MobileThreadsViewControls({ } function closeDrawer() { + if (inlineOptions) { + inlineOptions.close(); + return; + } setOpen(false); triggerRef.current?.focus(); } @@ -345,6 +351,81 @@ function MobileThreadsViewControls({ closeDrawer(); } + const viewBody = ( + <> + +
+ {page === "editor" && ( + + )} + + {page === "editor" ? t("threads:viewSettings") : t("threads:title")} + +
+
+
+ {syncError && isMobile && ( + + )} + {page === "views" ? ( + setPage("editor")} + /> + ) : ( + { + onDelete(viewId); + closeDrawer(); + }} + onDuplicate={onDuplicate} + onReapplySort={onReapplySort} + /> + )} +
+ + ); + if (inlineOptions) return
{viewBody}
; + return ( <> {syncError && !isMobile && ( @@ -415,77 +496,7 @@ function MobileThreadsViewControls({ } }} > - - -
- {page === "editor" && ( - - )} - - {page === "editor" ? t("threads:viewSettings") : t("threads:title")} - -
-
-
- {syncError && isMobile && ( - - )} - {page === "views" ? ( - setPage("editor")} - /> - ) : ( - { - onDelete(viewId); - closeDrawer(); - }} - onDuplicate={onDuplicate} - onReapplySort={onReapplySort} - /> - )} -
-
+ {viewBody} )} diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index f270160921..4b522bb95f 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1269,6 +1269,11 @@ export class ApiClient { default_utility_agent_id?: string; default_utility_model?: string; default_utility_agent_profile_id?: string; + sidebar_task_prefs?: { + pinned_task_ids?: string[]; + ordered_task_ids?: string[]; + subtask_order_by_parent_id?: Record; + }; sidebar_view_state?: { workspace_id: string; views?: unknown[]; diff --git a/apps/web/e2e/pages/mobile-kanban-page.ts b/apps/web/e2e/pages/mobile-kanban-page.ts index 89ae10efe1..121a993468 100644 --- a/apps/web/e2e/pages/mobile-kanban-page.ts +++ b/apps/web/e2e/pages/mobile-kanban-page.ts @@ -7,6 +7,8 @@ export class MobileKanbanPage { readonly mobileSearchToggle: Locator; readonly mobileMenuButton: Locator; readonly menuCard: Locator; + readonly viewOptionsButton: Locator; + readonly optionsCard: Locator; readonly swimlaneContainer: Locator; readonly boardNavigator: Locator; @@ -15,8 +17,10 @@ export class MobileKanbanPage { this.mobileFab = page.getByTestId("mobile-fab"); this.mobileSearchBar = page.getByTestId("mobile-search-bar"); this.mobileSearchToggle = page.getByTestId("mobile-search-toggle"); - this.mobileMenuButton = page.getByRole("button", { name: "Open menu" }); - this.menuCard = page.getByTestId("mobile-home-menu-card"); + this.mobileMenuButton = page.getByTestId("app-nav-trigger"); + this.menuCard = page.getByTestId("app-nav-sheet"); + this.viewOptionsButton = page.getByTestId("mobile-topbar-page-context"); + this.optionsCard = page.getByTestId("mobile-home-menu-card"); this.swimlaneContainer = page.getByTestId("swimlane-container"); this.boardNavigator = page.getByTestId("mobile-board-navigator"); } @@ -55,7 +59,7 @@ export class MobileKanbanPage { } async openSearch() { - await this.mobileMenuButton.click(); + await this.viewOptionsButton.click(); await this.mobileSearchToggle.click(); await this.mobileSearchBar.waitFor({ state: "visible" }); } diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 6f7983e09e..959c923e5c 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -49,7 +49,7 @@ export class SessionPage { return this.page.getByTestId("port-forwarding-menu-item"); } get mobileSessionMenu() { - return this.page.getByTestId("mobile-session-menu"); + return this.page.getByTestId("mobile-task-picker-trigger"); } get mobilePortForwardingToggle() { return this.page.getByTestId("mobile-port-forwarding-toggle"); diff --git a/apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts b/apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts index 56caf5c6d1..536a90804d 100644 --- a/apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts +++ b/apps/web/e2e/tests/canvas/mobile-plugin-canvas.spec.ts @@ -422,7 +422,7 @@ test.describe("Plugin-backed canvases on mobile", () => { await testPage.goto("/"); await expect(testPage.getByTestId("kanban-board")).toBeVisible({ timeout: 20_000 }); - const menuButton = testPage.getByRole("button", { name: "Open menu" }); + const menuButton = testPage.getByTestId("app-nav-trigger"); await expect(menuButton).toBeVisible(); await menuButton.tap(); diff --git a/apps/web/e2e/tests/chat/message-queue-workflow-helpers.ts b/apps/web/e2e/tests/chat/message-queue-workflow-helpers.ts index fe22811e0c..9b1344ed3a 100644 --- a/apps/web/e2e/tests/chat/message-queue-workflow-helpers.ts +++ b/apps/web/e2e/tests/chat/message-queue-workflow-helpers.ts @@ -82,7 +82,7 @@ export async function expectSendNowWorkflowRunning( expect(runningTask.workflow_step_id).toBe(working.id); await expect(session.cancelAgentButton()).toBeVisible(); - if (mobile) await page.getByTestId("mobile-session-menu").tap(); + if (mobile) await page.getByTestId("mobile-task-picker-trigger").tap(); const surface = mobile ? page.getByRole("dialog", { name: "Tasks", exact: true }) : session.sidebar; diff --git a/apps/web/e2e/tests/chat/mobile-agent-goal.spec.ts b/apps/web/e2e/tests/chat/mobile-agent-goal.spec.ts index 85072763d9..67378092b2 100644 --- a/apps/web/e2e/tests/chat/mobile-agent-goal.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-agent-goal.spec.ts @@ -10,7 +10,7 @@ import { async function openMobileQuickChat(page: Page): Promise { await page.goto("/"); await page.waitForLoadState("networkidle"); - await page.getByTestId("mobile-topbar-menu").tap(); + await page.getByTestId("app-nav-trigger").tap(); await page.getByTestId("mobile-quick-chat-button").tap(); const dialog = page.getByRole("dialog", { name: "Quick Chat" }); await expect(dialog).toBeVisible({ timeout: 10_000 }); diff --git a/apps/web/e2e/tests/chat/mobile-auto-scroll-toggle.spec.ts b/apps/web/e2e/tests/chat/mobile-auto-scroll-toggle.spec.ts index 9f1a95fa26..ef7baa521f 100644 --- a/apps/web/e2e/tests/chat/mobile-auto-scroll-toggle.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-auto-scroll-toggle.spec.ts @@ -20,7 +20,7 @@ function mobileOverflowScript(prefix: string): string { } async function switchMobileTask(testPage: Page, title: string): Promise { - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const sheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = sheet.getByTestId("sidebar-task-item").filter({ hasText: title }); await expect(taskRow).toBeVisible({ timeout: 15_000 }); diff --git a/apps/web/e2e/tests/chat/mobile-oversized-user-message.spec.ts b/apps/web/e2e/tests/chat/mobile-oversized-user-message.spec.ts index 2ff8b21ce0..532592541a 100644 --- a/apps/web/e2e/tests/chat/mobile-oversized-user-message.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-oversized-user-message.spec.ts @@ -75,7 +75,7 @@ async function createTask(apiClient: ApiClient, seedData: SeedData, title: strin } async function switchMobileTask(testPage: Page, title: string) { - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const sheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = sheet.getByTestId("sidebar-task-item").filter({ hasText: title }); await expect(taskRow).toBeVisible({ timeout: 15_000 }); @@ -161,6 +161,8 @@ test("mobile oversized previews stay bounded, downloadable, and touch-sized", as await waitForComposerQueueMode(testPage); const queued = oversizedMessage("MOBILE-QUEUED"); const identity = await apiClient.getQueueSessionIdentity(task.id, task.session_id); + // Keep the preview available even if the agent finishes during the touch checks. + await apiClient.setQueueAutoRun(identity, false); await apiClient.queueMessage(identity, queued.source); await expect(chat.getByTestId("queue-chip")).toBeVisible({ timeout: 15_000 }); await chat.getByTestId("queue-chip").tap(); diff --git a/apps/web/e2e/tests/chat/mobile-quick-chat-cancel-palette.spec.ts b/apps/web/e2e/tests/chat/mobile-quick-chat-cancel-palette.spec.ts index 8ca4d34b6c..8a141acf1f 100644 --- a/apps/web/e2e/tests/chat/mobile-quick-chat-cancel-palette.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-quick-chat-cancel-palette.spec.ts @@ -37,7 +37,7 @@ test.describe.serial("Mobile Quick Chat cancellation", () => { }) => { test.setTimeout(120_000); await testPage.goto("/"); - await testPage.getByTestId("mobile-topbar-menu").tap(); + await testPage.getByTestId("app-nav-trigger").tap(); await testPage.getByTestId("mobile-quick-chat-button").tap(); const quickChat = testPage.getByRole("dialog", { name: "Quick Chat" }); @@ -66,7 +66,7 @@ test.describe.serial("Mobile Quick Chat cancellation", () => { }) => { test.setTimeout(120_000); await testPage.goto("/"); - await testPage.getByTestId("mobile-topbar-menu").tap(); + await testPage.getByTestId("app-nav-trigger").tap(); await testPage.getByTestId("mobile-quick-chat-button").tap(); const quickChat = testPage.getByRole("dialog", { name: "Quick Chat" }); diff --git a/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts b/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts index 8620822d05..589c5b30cd 100644 --- a/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-quick-chat-entry.spec.ts @@ -25,7 +25,7 @@ test.describe("Quick Chat entry points on mobile", () => { // @covers AC-UI-QUICK-CHAT-VIEWPORT-LAYOUT-001.5 AC-UI-QUICK-CHAT-VIEWPORT-LAYOUT-001.6 test("preserves composer containment at the bottom of the phone dialog", async ({ testPage }) => { await testPage.goto("/"); - await testPage.getByTestId("mobile-topbar-menu").tap(); + await testPage.getByTestId("app-nav-trigger").tap(); await testPage.getByTestId("mobile-quick-chat-button").tap(); const dialog = testPage.getByRole("dialog", { name: "Quick Chat" }); @@ -64,7 +64,7 @@ test.describe("Quick Chat entry points on mobile", () => { await testPage.waitForLoadState("networkidle"); await assertNoDocumentHorizontalOverflow(testPage); - await testPage.getByTestId("mobile-topbar-menu").tap(); + await testPage.getByTestId("app-nav-trigger").tap(); await testPage.getByTestId("mobile-quick-chat-button").tap(); const dialog = testPage.getByRole("dialog", { name: "Quick Chat" }); @@ -84,12 +84,12 @@ test.describe("Quick Chat entry points on mobile", () => { await dialog.getByTestId("quick-chat-close").tap(); await expect(dialog).not.toBeVisible(); - await expect(testPage.getByTestId("mobile-topbar-menu")).toBeFocused(); + await expect(testPage.getByTestId("app-nav-trigger")).toBeFocused(); }); test("chooses configuration mode from the setup panel", async ({ testPage }) => { await testPage.goto("/"); - const context = testPage.getByTestId("mobile-topbar-page-context"); + const context = testPage.getByTestId("app-nav-trigger"); await context.tap(); await testPage.getByTestId("mobile-quick-chat-button").tap(); @@ -119,7 +119,7 @@ test.describe("Quick Chat entry points on mobile", () => { const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const sheet = testPage.getByRole("dialog", { name: "Tasks" }); await expect(sheet).toBeVisible(); await testPage.getByTestId("mobile-sheet-quick-chat").tap(); @@ -142,7 +142,7 @@ test.describe("Quick Chat entry points on mobile", () => { await testPage.goto(`/tasks?workspace=${seedData.workspaceId}`); await testPage.waitForLoadState("networkidle"); - await testPage.getByTestId("mobile-topbar-menu").tap(); + await testPage.getByTestId("app-nav-trigger").tap(); const homeLink = testPage.getByRole("link", { name: "Home", exact: true }); await expect(homeLink).toHaveAttribute( "href", diff --git a/apps/web/e2e/tests/chat/mobile-quick-chat-idle-dot.spec.ts b/apps/web/e2e/tests/chat/mobile-quick-chat-idle-dot.spec.ts index 8666f6dec6..7db398e5af 100644 --- a/apps/web/e2e/tests/chat/mobile-quick-chat-idle-dot.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-quick-chat-idle-dot.spec.ts @@ -16,7 +16,7 @@ test.describe("quick chat activity indicators", () => { }) => { const ws = watchWs(testPage); await testPage.goto("/"); - await testPage.getByTestId("mobile-topbar-menu").tap(); + await testPage.getByTestId("app-nav-trigger").tap(); await testPage.getByTestId("mobile-quick-chat-button").tap(); const dialog = testPage.getByRole("dialog", { name: "Quick Chat" }); const created = testPage.waitForResponse( @@ -32,7 +32,7 @@ test.describe("quick chat activity indicators", () => { task_id: string; }; const tab = dialog.getByTestId("quick-chat-tab"); - const button = testPage.getByTestId("mobile-topbar-menu"); + const button = testPage.getByTestId("app-nav-trigger"); const indicator = button.getByTestId("quick-chat-activity-indicator"); await expect(tab).toHaveCount(1); @@ -74,7 +74,7 @@ test.describe("quick chat activity indicators", () => { await testPage.goto(`/t/${seeded.task_id}`); await new SessionPage(testPage).waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const sheet = testPage.getByRole("dialog", { name: "Tasks" }); const entry = sheet.getByTestId("mobile-sheet-quick-chat"); await expect(entry.getByTestId("quick-chat-activity-indicator")).toHaveCount(0); @@ -99,7 +99,7 @@ test.describe("quick chat activity indicators", () => { await expect(dialog.getByTestId("quick-chat-tab").getByRole("status")).toBeVisible(); await dialog.getByTestId("quick-chat-close").tap(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const openEntry = testPage .getByRole("dialog", { name: "Tasks" }) .getByTestId("mobile-sheet-quick-chat"); @@ -110,7 +110,7 @@ test.describe("quick chat activity indicators", () => { await testPage.keyboard.press("Escape"); await Promise.all([completed, settled]); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const finishedEntry = testPage .getByRole("dialog", { name: "Tasks" }) .getByTestId("mobile-sheet-quick-chat"); diff --git a/apps/web/e2e/tests/chat/mobile-quick-chat-tabs.spec.ts b/apps/web/e2e/tests/chat/mobile-quick-chat-tabs.spec.ts index 0b31b6f3c0..23e4ebaf18 100644 --- a/apps/web/e2e/tests/chat/mobile-quick-chat-tabs.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-quick-chat-tabs.spec.ts @@ -6,7 +6,7 @@ import { quickChatTabReferences, startQuickChatFromSetup } from "./quick-chat-he async function openMobileQuickChat(page: Page): Promise { await page.goto("/"); await page.waitForLoadState("networkidle"); - await page.getByTestId("mobile-topbar-menu").tap(); + await page.getByTestId("app-nav-trigger").tap(); await page.getByTestId("mobile-quick-chat-button").tap(); const dialog = page.getByRole("dialog", { name: "Quick Chat" }); await expect(dialog).toBeVisible({ timeout: 10_000 }); diff --git a/apps/web/e2e/tests/chat/mobile-unread-divider.spec.ts b/apps/web/e2e/tests/chat/mobile-unread-divider.spec.ts index a5216f0eea..8c07e8bdfc 100644 --- a/apps/web/e2e/tests/chat/mobile-unread-divider.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-unread-divider.spec.ts @@ -8,7 +8,7 @@ import { waitForStableActiveSession } from "../../helpers/session-store"; const MOBILE_END_TOLERANCE_PX = 10; async function switchMobileTask(testPage: Page, title: string): Promise { - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const sheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = sheet.getByTestId("sidebar-task-item").filter({ hasText: title }); await expect(taskRow).toBeVisible({ timeout: 15_000 }); diff --git a/apps/web/e2e/tests/chat/quick-chat-saved-prompt-delivery-helpers.ts b/apps/web/e2e/tests/chat/quick-chat-saved-prompt-delivery-helpers.ts index 1d07164412..b59f355381 100644 --- a/apps/web/e2e/tests/chat/quick-chat-saved-prompt-delivery-helpers.ts +++ b/apps/web/e2e/tests/chat/quick-chat-saved-prompt-delivery-helpers.ts @@ -21,7 +21,7 @@ type SavedPromptDeliveryScenarioOptions = { export async function openMobileQuickChatSetup(page: Page): Promise { await page.goto("/"); await page.waitForLoadState("networkidle"); - await page.getByTestId("mobile-topbar-menu").tap(); + await page.getByTestId("app-nav-trigger").tap(); await page.getByTestId("mobile-quick-chat-button").tap(); const dialog = page.getByRole("dialog", { name: "Quick Chat" }); diff --git a/apps/web/e2e/tests/git/changes-panel-multi-repo-indent.spec.ts b/apps/web/e2e/tests/git/changes-panel-multi-repo-indent.spec.ts index 5452d1201f..eb6246cf7f 100644 --- a/apps/web/e2e/tests/git/changes-panel-multi-repo-indent.spec.ts +++ b/apps/web/e2e/tests/git/changes-panel-multi-repo-indent.spec.ts @@ -78,7 +78,10 @@ test("nests multi-repository changes below each repository on desktop and mobile await expectTreeNestedUnderRepository(session.changes); await testPage.setViewportSize({ width: 393, height: 851 }); - await testPage.getByRole("button", { name: "Changes" }).click(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .click(); const mobilePanel = testPage.getByTestId("mobile-changes-panel"); await expect(mobilePanel).toBeVisible(); await expectTreeNestedUnderRepository(mobilePanel); diff --git a/apps/web/e2e/tests/git/mobile-empty-remote-repository.spec.ts b/apps/web/e2e/tests/git/mobile-empty-remote-repository.spec.ts index 85754b4d66..92f99702a4 100644 --- a/apps/web/e2e/tests/git/mobile-empty-remote-repository.spec.ts +++ b/apps/web/e2e/tests/git/mobile-empty-remote-repository.spec.ts @@ -52,7 +52,10 @@ test.describe("Mobile empty remote repository", () => { const taskCommit = git.commit("Add empty remote mobile fixture"); const taskBranch = git.exec("git branch --show-current").trim(); - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); const changesPanel = testPage.getByTestId("mobile-changes-panel"); await expect(changesPanel).toBeVisible({ timeout: 15_000 }); const commitsToggle = changesPanel.getByTestId("commits-section-collapse-toggle"); diff --git a/apps/web/e2e/tests/git/mobile-fork-pr-comparison-target.spec.ts b/apps/web/e2e/tests/git/mobile-fork-pr-comparison-target.spec.ts index b694b7e843..d312532a55 100644 --- a/apps/web/e2e/tests/git/mobile-fork-pr-comparison-target.spec.ts +++ b/apps/web/e2e/tests/git/mobile-fork-pr-comparison-target.spec.ts @@ -38,10 +38,15 @@ test.describe("Mobile fork pull-request comparison target", () => { const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 45_000 }); - await expect(testPage.getByRole("button", { name: "Changes" })).toBeVisible({ + await expect( + testPage.getByRole("navigation").getByRole("button", { name: /Changes$/ }), + ).toBeVisible({ timeout: 15_000, }); - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); const changes = testPage.getByTestId("mobile-changes-panel"); await expect(changes).toBeVisible({ timeout: 15_000 }); @@ -87,10 +92,15 @@ test.describe("Mobile fork pull-request comparison target", () => { const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 45_000 }); - await expect(testPage.getByRole("button", { name: "Changes" })).toBeVisible({ + await expect( + testPage.getByRole("navigation").getByRole("button", { name: /Changes$/ }), + ).toBeVisible({ timeout: 15_000, }); - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); const changes = testPage.getByTestId("mobile-changes-panel"); await expect(changes).toBeVisible({ timeout: 15_000 }); @@ -108,8 +118,11 @@ test.describe("Mobile fork pull-request comparison target", () => { ).toBeVisible(); comparisonTargetFixture?.setAvailable(true); - await testPage.getByRole("button", { name: "Chat" }).tap(); - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage.getByRole("button", { name: "Chat", exact: true }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); await expect(changes.getByTestId("comparison-target-notice")).toHaveCount(0, { timeout: 30_000, }); diff --git a/apps/web/e2e/tests/git/mobile-local-base-operations.spec.ts b/apps/web/e2e/tests/git/mobile-local-base-operations.spec.ts index e3f8ec8eba..66b6863894 100644 --- a/apps/web/e2e/tests/git/mobile-local-base-operations.spec.ts +++ b/apps/web/e2e/tests/git/mobile-local-base-operations.spec.ts @@ -43,7 +43,10 @@ test.describe("Mobile local-only Git operations", () => { const session = await openTaskSession(testPage, task.id); await session.waitForChatIdle({ timeout: 45_000 }); - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); const changes = testPage.getByTestId("mobile-changes-panel"); const pullMenuTrigger = changes.getByRole("button", { name: /^Pull/ }); await expect(pullMenuTrigger).toBeVisible(); diff --git a/apps/web/e2e/tests/git/mobile-pr-checkout-drift.spec.ts b/apps/web/e2e/tests/git/mobile-pr-checkout-drift.spec.ts index 06e45e66c5..3e3e6544f6 100644 --- a/apps/web/e2e/tests/git/mobile-pr-checkout-drift.spec.ts +++ b/apps/web/e2e/tests/git/mobile-pr-checkout-drift.spec.ts @@ -320,7 +320,10 @@ test.describe("Mobile rewritten contribution history", () => { await session.waitForChatIdle({ timeout: 45_000 }); git.exec(`git checkout -B ${providerBranch} ${localHead}`); git.exec(`git branch --set-upstream-to=origin/${providerBranch} ${providerBranch}`); - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); const changes = testPage.getByTestId("mobile-changes-panel"); await expect(changes.getByTestId("remote-contribution-drift-status")).toHaveCount(0); @@ -567,7 +570,10 @@ test.describe("Mobile rewritten contribution history", () => { await testPage.reload(); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 45_000 }); - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); const changes = testPage.getByTestId("mobile-changes-panel"); const providerSection = changes.getByTestId("current-pr-commits-section"); diff --git a/apps/web/e2e/tests/git/mobile-pr-file-row-containment.spec.ts b/apps/web/e2e/tests/git/mobile-pr-file-row-containment.spec.ts index ccdaff5ffd..2eedc4fb81 100644 --- a/apps/web/e2e/tests/git/mobile-pr-file-row-containment.spec.ts +++ b/apps/web/e2e/tests/git/mobile-pr-file-row-containment.spec.ts @@ -23,7 +23,10 @@ test.describe("Mobile PR file row containment", () => { const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 45_000 }); - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); const changes = testPage.getByTestId("mobile-changes-panel"); await expect(changes).toBeVisible(); diff --git a/apps/web/e2e/tests/github/mobile-task-view-access.spec.ts b/apps/web/e2e/tests/github/mobile-task-view-access.spec.ts index 33848fc0a3..e403043b5f 100644 --- a/apps/web/e2e/tests/github/mobile-task-view-access.spec.ts +++ b/apps/web/e2e/tests/github/mobile-task-view-access.spec.ts @@ -39,13 +39,8 @@ test("GitHub app navigation opens shared task views and preserves browser Back", await github.goto(); const opener = testPage.getByTestId("app-nav-trigger"); await opener.tap(); - await testPage - .getByTestId("app-nav-sheet") - .getByRole("button", { name: "Task views", exact: true }) - .tap(); - const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true }); + const drawer = testPage.getByTestId("app-nav-sheet"); await expect(drawer).toBeVisible(); - await expect(testPage.getByTestId("app-nav-sheet")).toBeHidden(); await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(1); await expect( drawer.getByTestId("sidebar-view-chip").filter({ hasText: view.name }), @@ -55,10 +50,6 @@ test("GitHub app navigation opens shared task views and preserves browser Back", await expect(drawer).toBeHidden(); await expect(opener).toBeFocused(); await opener.tap(); - await testPage - .getByTestId("app-nav-sheet") - .getByRole("button", { name: "Task views", exact: true }) - .tap(); await drawer.locator(`[data-task-row-id="${task.task_id}"]`).tap(); await expect(testPage).toHaveURL(new RegExp(`/t/${task.task_id}`)); await testPage.goBack(); @@ -66,12 +57,8 @@ test("GitHub app navigation opens shared task views and preserves browser Back", await expect(github.mobileMenuButton).toBeVisible(); // The task remains selected in shared state, but GitHub has no task-only providers. await opener.tap(); - await testPage - .getByTestId("app-nav-sheet") - .getByRole("button", { name: "Task views", exact: true }) - .tap(); await expect(drawer.getByTestId("sidebar-filter-bar")).toBeVisible(); - await drawer.getByRole("button", { name: "New", exact: true }).tap(); + await drawer.getByRole("button", { name: "New task", exact: true }).tap(); await expect(drawer).toBeHidden(); await expect(testPage.getByRole("dialog")).toBeVisible(); await expect(testPage.getByRole("dialog")).not.toHaveAccessibleName("Tasks"); @@ -97,11 +84,10 @@ test("Kanban navigation can open empty task views and return focus", async ({ te const kanban = new MobileKanbanPage(testPage); await kanban.goto(); await kanban.mobileMenuButton.tap(); - await kanban.menuCard.getByRole("button", { name: "Task views", exact: true }).tap(); - const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true }); + const drawer = testPage.getByTestId("app-nav-sheet"); await expect(drawer.getByTestId("sidebar-filter-bar")).toBeVisible(); await expect(drawer.locator('[data-slot="task-switcher-empty-state"]')).toBeVisible(); - await expect(kanban.menuCard).toBeHidden(); + await expect(kanban.menuCard).toBeVisible(); await testPage.keyboard.press("Escape"); await expect(drawer).toBeHidden(); await expect(kanban.mobileMenuButton).toBeFocused(); diff --git a/apps/web/e2e/tests/gitlab/mobile-gitlab-parity.spec.ts b/apps/web/e2e/tests/gitlab/mobile-gitlab-parity.spec.ts index 2d5d2202cd..52d497dc59 100644 --- a/apps/web/e2e/tests/gitlab/mobile-gitlab-parity.spec.ts +++ b/apps/web/e2e/tests/gitlab/mobile-gitlab-parity.spec.ts @@ -302,7 +302,7 @@ test.describe("Mobile GitLab parity", () => { await session.waitForLoad(); await expect(testPage.getByTestId("mr-topbar-button")).toHaveCount(0); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const taskDrawer = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = taskDrawer.getByTestId("sidebar-task-item").filter({ hasText: title }); await expect(taskRow).toBeVisible({ timeout: 10_000 }); @@ -477,7 +477,10 @@ test.describe("Mobile GitLab parity", () => { ).toBeVisible({ timeout: 45_000, }); - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); const changes = testPage.getByTestId("mobile-changes-panel"); const createMR = changes.getByTestId("commits-repo-create-pr"); await expect(createMR).toBeVisible(); diff --git a/apps/web/e2e/tests/gitlab/mobile-mr-tasks-list-badge.spec.ts b/apps/web/e2e/tests/gitlab/mobile-mr-tasks-list-badge.spec.ts index ec7687df98..01d6ed2f3b 100644 --- a/apps/web/e2e/tests/gitlab/mobile-mr-tasks-list-badge.spec.ts +++ b/apps/web/e2e/tests/gitlab/mobile-mr-tasks-list-badge.spec.ts @@ -96,8 +96,8 @@ test.describe("mobile GitLab MR badge on the /tasks list rows", () => { // Mobile has no `display-button`; the display menu lives behind the // "Open menu" drawer (see mobile-task-listing-display.spec.ts). - await testPage.getByRole("button", { name: "Open menu" }).tap(); - const menu = testPage.getByRole("dialog", { name: "Menu" }); + await testPage.getByTestId("mobile-topbar-page-context").tap(); + const menu = testPage.getByRole("dialog", { name: "View options" }); await expandDisplaySettingsGroup(testPage, "list-rows", "mobile"); await menu.getByText("Show task details", { exact: true }).click(); await testPage.keyboard.press("Escape"); diff --git a/apps/web/e2e/tests/integrations/mobile-integrations-nav.spec.ts b/apps/web/e2e/tests/integrations/mobile-integrations-nav.spec.ts index a0c5a0e6ba..b79c746192 100644 --- a/apps/web/e2e/tests/integrations/mobile-integrations-nav.spec.ts +++ b/apps/web/e2e/tests/integrations/mobile-integrations-nav.spec.ts @@ -31,6 +31,8 @@ test.describe("Integrations section on mobile", () => { const sheet = testPage.getByRole("dialog"); await expect(sheet.getByText("Integrations", { exact: true })).toBeVisible(); + await sheet.getByRole("button", { name: "Integrations", exact: true }).click(); + const githubLink = sheet.getByRole("link", { name: "GitHub" }); await expect(githubLink).toBeVisible({ timeout: 10_000 }); expect(await githubLink.getAttribute("href")).toBe("/github"); diff --git a/apps/web/e2e/tests/kanban/mobile-auto-hide-empty-columns.spec.ts b/apps/web/e2e/tests/kanban/mobile-auto-hide-empty-columns.spec.ts index 65fa7c6554..5c4c415a93 100644 --- a/apps/web/e2e/tests/kanban/mobile-auto-hide-empty-columns.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-auto-hide-empty-columns.spec.ts @@ -5,16 +5,16 @@ import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; const TASK_TITLE = "Mobile auto-hide move source"; async function openMobileMenu(mobile: MobileKanbanPage) { - await mobile.mobileMenuButton.click(); - await mobile.menuCard.waitFor({ state: "visible" }); + await mobile.viewOptionsButton.click(); + await mobile.optionsCard.waitFor({ state: "visible" }); } async function closeMobileMenu(page: Page, mobile: MobileKanbanPage) { await expect(async () => { - if ((await mobile.menuCard.count()) > 0) { + if ((await mobile.optionsCard.count()) > 0) { await page.keyboard.press("Escape"); } - await expect(mobile.menuCard).toHaveCount(0, { timeout: 1_000 }); + await expect(mobile.optionsCard).toHaveCount(0, { timeout: 1_000 }); }).toPass({ timeout: 15_000 }); } @@ -23,7 +23,7 @@ async function openColumnsMenu( mobile: MobileKanbanPage, workflowId: string, ): Promise { - const trigger = mobile.menuCard.getByTestId(`columns-menu-${workflowId}`); + const trigger = mobile.optionsCard.getByTestId(`columns-menu-${workflowId}`); await trigger.click(); await expect(trigger).toHaveAttribute("data-state", "open"); const menu = page.locator('[role="menu"]:visible'); @@ -72,7 +72,7 @@ test("keeps auto-hide scoped and moves through the mobile card menu", async ({ const mobile = new MobileKanbanPage(testPage); await mobile.goto(); await openMobileMenu(mobile); - await expect(mobile.menuCard.getByTestId(`columns-menu-${otherWorkflow.id}`)).toHaveCount(0); + await expect(mobile.optionsCard.getByTestId(`columns-menu-${otherWorkflow.id}`)).toHaveCount(0); const columnsMenu = await openColumnsMenu(testPage, mobile, workflow.id); const autoHideToggle = columnsMenu.getByTestId(`columns-menu-auto-hide-empty-${workflow.id}`); diff --git a/apps/web/e2e/tests/kanban/mobile-board-priority-sort-filter.spec.ts b/apps/web/e2e/tests/kanban/mobile-board-priority-sort-filter.spec.ts index 641097e4c0..c4ce56c8af 100644 --- a/apps/web/e2e/tests/kanban/mobile-board-priority-sort-filter.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-board-priority-sort-filter.spec.ts @@ -11,7 +11,7 @@ const TASK_CRITICAL = "Mobile sort task critical"; const TASK_MEDIUM = "Mobile sort task medium"; async function openMobileMenu(testPage: import("@playwright/test").Page) { - await testPage.getByRole("button", { name: "Open menu" }).tap(); + await testPage.getByTestId("mobile-topbar-page-context").tap(); await testPage.getByTestId("mobile-home-menu-card").waitFor({ state: "visible" }); } diff --git a/apps/web/e2e/tests/kanban/mobile-display-settings-groups.spec.ts b/apps/web/e2e/tests/kanban/mobile-display-settings-groups.spec.ts index 332414fa7f..f4178dbef7 100644 --- a/apps/web/e2e/tests/kanban/mobile-display-settings-groups.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-display-settings-groups.spec.ts @@ -10,7 +10,7 @@ import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; const PRIORITY_TOKENS = ["critical", "high", "medium", "low"] as const; async function openMobileMenu(page: import("@playwright/test").Page) { - await page.getByRole("button", { name: "Open menu" }).tap(); + await page.getByTestId("mobile-topbar-page-context").tap(); const card = page.getByTestId("mobile-home-menu-card"); await card.waitFor({ state: "visible" }); return card; diff --git a/apps/web/e2e/tests/kanban/mobile-kanban-topbar.spec.ts b/apps/web/e2e/tests/kanban/mobile-kanban-topbar.spec.ts index 707c458b24..7120b2102f 100644 --- a/apps/web/e2e/tests/kanban/mobile-kanban-topbar.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-kanban-topbar.spec.ts @@ -7,8 +7,8 @@ import { waitForLatestSessionDone } from "../../helpers/session"; test.describe("Shared phone listing topbar", () => { test("returns focus to the menu button when search is hidden", async ({ testPage }) => { await testPage.goto("/tasks"); - const opener = testPage.getByTestId("mobile-topbar-menu"); - const menu = testPage.getByRole("dialog", { name: "Menu", exact: true }); + const opener = testPage.getByTestId("mobile-topbar-page-context"); + const menu = testPage.getByRole("dialog", { name: "View options", exact: true }); const search = testPage.getByPlaceholder("Search tasks..."); await opener.tap(); await menu.getByTestId("mobile-search-toggle").tap(); @@ -72,7 +72,7 @@ test.describe("Shared phone listing topbar", () => { }); const header = testPage.getByTestId("threads-mobile-topbar"); - const trigger = header.getByTestId("threads-mobile-view-trigger"); + const trigger = header.getByTestId("mobile-topbar-page-context"); const drawer = testPage.getByTestId("threads-mobile-view-drawer"); await trigger.tap(); const failedWrite = waitForHttp(testPage, "PATCH", /\/api\/v1\/user\/settings$/, { @@ -89,7 +89,7 @@ test.describe("Shared phone listing topbar", () => { expect((await requireBox(header, "header during sync failure")).height).toBe(56); const cue = header.getByTestId("thread-swipe-cue"); await expect(cue).toHaveText("1/2"); - for (const target of [trigger, header.getByTestId("mobile-topbar-menu")]) { + for (const target of [trigger, header.getByTestId("app-nav-trigger")]) { expect( await target.evaluate((element) => { const box = element.getBoundingClientRect(); @@ -100,7 +100,7 @@ test.describe("Shared phone listing topbar", () => { ).toBe(true); } const cueBox = await requireBox(cue, "pagination during sync failure"); - const menuBox = await requireBox(header.getByTestId("mobile-topbar-menu"), "menu"); + const menuBox = await requireBox(header.getByTestId("app-nav-trigger"), "menu"); expect(cueBox.x + cueBox.width).toBeLessThanOrEqual(menuBox.x); await assertNoDocumentHorizontalOverflow(testPage, "saved-view sync failure"); @@ -127,7 +127,9 @@ test.describe("Shared phone listing topbar", () => { await retried; await expect(recovery).toHaveCount(0); await testPage.reload(); - await expect(trigger).toContainText("New view"); + await trigger.tap(); + await expect(drawer).toContainText("New view"); + await testPage.keyboard.press("Escape"); await expect(header.getByTestId("threads-mobile-view-sync-status")).toHaveCount(0); }); @@ -140,21 +142,19 @@ test.describe("Shared phone listing topbar", () => { for (const [route, label] of [ ["/?home=overview", "Kanban"], ["/tasks", "List"], - ["/threads", "All threads"], + ["/threads", "Threads"], ]) { if (label === "Kanban") { await testPage.goto("/tasks"); - await testPage.getByTestId("mobile-topbar-menu").tap(); + await testPage.getByTestId("mobile-topbar-page-context").tap(); await testPage.getByRole("radio", { name: "Kanban", exact: true }).tap(); await expect(testPage.getByRole("dialog")).toHaveCount(0); } else { await testPage.goto(route); } const header = testPage.locator("header").first(); - const context = header.getByTestId( - label === "All threads" ? "threads-mobile-view-trigger" : "mobile-topbar-page-context", - ); - const menu = header.getByTestId("mobile-topbar-menu"); + const context = header.getByTestId("mobile-topbar-page-context"); + const menu = header.getByTestId("app-nav-trigger"); await expect(context).toContainText(label); await expect(header.getByTestId("mobile-topbar-brand")).toHaveCount(0); await expect(header.getByTestId("mobile-topbar-action-strip")).toHaveCount(0); @@ -193,7 +193,7 @@ test.describe("Shared phone listing topbar", () => { testPage, }) => { await testPage.goto("/tasks"); - await testPage.getByTestId("mobile-topbar-menu").tap(); + await testPage.getByTestId("app-nav-trigger").tap(); const dialog = testPage.getByRole("dialog", { name: "Menu", exact: true }); await dialog.getByRole("link", { name: "Home", exact: true }).tap(); await expect(testPage).toHaveURL( @@ -208,7 +208,7 @@ test.describe("Shared phone listing topbar", () => { await testPage.goto("/?home=overview"); const header = testPage.locator("header").first(); await expect(header.getByTestId("mobile-topbar-page-context")).toHaveCount(0); - await expect(header.getByTestId("mobile-topbar-menu")).toHaveCount(0); + await expect(header.getByTestId("app-nav-trigger")).toHaveCount(0); await expect(header).toBeVisible(); await assertNoDocumentHorizontalOverflow(testPage, "tablet listing header"); const menu = header.getByRole("button", { name: "Open menu", exact: true }); @@ -239,13 +239,15 @@ test.describe("Shared phone listing topbar", () => { expect(await label.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe( true, ); - const menu = testPage.getByTestId("mobile-topbar-menu"); + const menu = testPage.getByTestId("app-nav-trigger"); const box = await requireBox(menu, "menu beside long workspace"); expect(box.width).toBe(44); expect(box.x + box.width).toBeLessThanOrEqual(360); await assertNoDocumentHorizontalOverflow(testPage, "long workspace context"); await context.tap(); - await expect(testPage.getByRole("dialog", { name: "Menu", exact: true })).toBeVisible(); + await expect( + testPage.getByRole("dialog", { name: "View options", exact: true }), + ).toBeVisible(); } finally { await apiClient.deleteWorkspace(workspace.id, name); } diff --git a/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts b/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts index c5427f3863..661eabf26f 100644 --- a/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts @@ -24,7 +24,7 @@ test.describe("Mobile kanban view", () => { await mobile.goto(); await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0); - await testPage.getByRole("button", { name: "Open menu" }).click(); + await testPage.getByTestId("app-nav-trigger").click(); await testPage.getByTestId("mobile-home-status-button").click(); await expect(testPage.getByTestId("app-status-drawer")).toBeVisible(); await expect(testPage.getByTestId("app-status-metrics")).toBeVisible(); @@ -373,7 +373,7 @@ test.describe("Mobile kanban view", () => { await mobile.goto(); await expect(mobile.mobileKanbanLayout()).toBeVisible(); - await mobile.mobileMenuButton.click(); + await mobile.viewOptionsButton.click(); const menuCard = testPage.getByTestId("mobile-home-menu-card"); await expect(menuCard).toBeVisible(); await expect(menuCard.getByText("Pipeline", { exact: true })).toHaveCount(0); @@ -409,7 +409,7 @@ test.describe("Mobile kanban view", () => { kanban_view_mode: "graph2", }); await testPage.goto("/tasks"); - await testPage.getByRole("button", { name: "Open menu" }).click(); + await testPage.getByTestId("mobile-topbar-page-context").click(); const menuCard = testPage.getByTestId("mobile-home-menu-card"); await menuCard.getByText("Kanban", { exact: true }).click(); @@ -447,7 +447,7 @@ test.describe("Mobile kanban view", () => { await expect(mobile.searchInput()).toBeFocused(); // Toggling search in the menu again collapses the search bar. - await mobile.mobileMenuButton.click(); + await mobile.viewOptionsButton.click(); await mobile.mobileSearchToggle.click(); await expect(mobile.mobileSearchBar).not.toBeVisible(); }); @@ -470,7 +470,7 @@ test.describe("Mobile kanban view", () => { await expect(mobile.taskCardByTitle("Other Beta")).not.toBeVisible({ timeout: 5000 }); // Collapsing clears the query so the full list is shown again - await mobile.mobileMenuButton.click(); + await mobile.viewOptionsButton.click(); await mobile.mobileSearchToggle.click(); await expect(mobile.mobileSearchBar).not.toBeVisible(); await expect(mobile.taskCardByTitle("Clearable Alpha")).toBeVisible({ timeout: 5000 }); @@ -484,9 +484,12 @@ test.describe("Mobile kanban view", () => { await expect(mobile.mobileMenuButton).toBeVisible(); await mobile.mobileMenuButton.click(); - // Menu sheet should open with display options + // App navigation has one meaning; listing options have a separate entry. await expect(testPage.getByRole("heading", { name: "Menu" })).toBeVisible(); - await expect(testPage.getByText("Display Options")).toBeVisible(); + await expect( + testPage.getByTestId("app-nav-sheet").getByRole("link", { name: "Home", exact: true }), + ).toBeVisible(); + await expect(testPage.getByText("Display Options")).toHaveCount(0); }); test("switches workspaces from the mobile menu", async ({ testPage, apiClient }) => { @@ -517,7 +520,7 @@ test.describe("Mobile kanban view", () => { await mobile.goto(); await mobile.mobileMenuButton.click(); - await testPage.getByRole("link", { name: "Settings" }).click(); + await testPage.getByRole("link", { name: "Settings", exact: true }).click(); await expect(testPage).toHaveURL(/\/settings(?:\/general)?$/); await expect(testPage.getByRole("link", { name: /Appearance/ })).toBeVisible(); @@ -532,8 +535,7 @@ test.describe("Mobile kanban view", () => { const searchInput = dialog.getByPlaceholder("Search tasks..."); await expect(searchInput).toHaveCount(0); - await expect(mobile.mobileSearchToggle).toBeVisible(); - await expect(mobile.mobileSearchToggle).not.toBeFocused(); + await expect(mobile.mobileSearchToggle).toHaveCount(0); await expect(mobile.mobileSearchBar).not.toBeVisible(); }); diff --git a/apps/web/e2e/tests/kanban/mobile-step-visibility-filter.spec.ts b/apps/web/e2e/tests/kanban/mobile-step-visibility-filter.spec.ts index b19169d36d..35c378fa26 100644 --- a/apps/web/e2e/tests/kanban/mobile-step-visibility-filter.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-step-visibility-filter.spec.ts @@ -12,7 +12,7 @@ const ORPHAN_STEP_ID = "__kandev_orphan__"; const MIN_TOUCH_TARGET_PX = 44; async function openMobileMenu(testPage: Page) { - await testPage.getByRole("button", { name: "Open menu" }).click(); + await testPage.getByTestId("mobile-topbar-page-context").click(); await testPage.getByTestId("mobile-home-menu-card").waitFor({ state: "visible" }); } diff --git a/apps/web/e2e/tests/kanban/mobile-task-title-hover-subtasks.spec.ts b/apps/web/e2e/tests/kanban/mobile-task-title-hover-subtasks.spec.ts index 8407dfd359..86c0fa3a4a 100644 --- a/apps/web/e2e/tests/kanban/mobile-task-title-hover-subtasks.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-task-title-hover-subtasks.spec.ts @@ -45,7 +45,7 @@ test.describe("mobile task title hover on the Kanban card", () => { const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const taskSwitcher = testPage.getByRole("dialog", { name: "Tasks" }); await expect(taskSwitcher.getByText(childTitle)).toBeVisible({ timeout: 15_000 }); diff --git a/apps/web/e2e/tests/kanban/mobile-wip-overflow-queue.spec.ts b/apps/web/e2e/tests/kanban/mobile-wip-overflow-queue.spec.ts index 711a3b456c..fb7557846c 100644 --- a/apps/web/e2e/tests/kanban/mobile-wip-overflow-queue.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-wip-overflow-queue.spec.ts @@ -83,7 +83,7 @@ test("moves through the mobile task switcher and shows touch queue status", asyn await testPage.goto(`/t/${anchor.task_id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const sheet = testPage.getByRole("dialog", { name: "Tasks" }); const sourceRow = sheet.getByTestId("sidebar-task-item").filter({ hasText: "Mobile Moved Queue", @@ -98,7 +98,7 @@ test("moves through the mobile task switcher and shows touch queue status", asyn .toBe(reviewStep.id); await testPage.keyboard.press("Escape"); await expect(testPage.getByRole("dialog", { name: "Tasks" })).toHaveCount(0); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const queuedSourceRow = testPage .getByRole("dialog", { name: "Tasks" }) .getByTestId("sidebar-task-item") @@ -122,7 +122,7 @@ test("moves through the mobile task switcher and shows touch queue status", asyn await testPage.goto(`/t/${anchor.task_id}`); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const admittedRow = testPage .getByRole("dialog", { name: "Tasks" }) .getByTestId("sidebar-task-item") @@ -136,7 +136,7 @@ test("moves through the mobile task switcher and shows touch queue status", asyn .toBe(reviewStep.id); await testPage.keyboard.press("Escape"); await expect(testPage.getByRole("dialog", { name: "Tasks" })).toHaveCount(0); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); await expect( testPage .getByRole("dialog", { name: "Tasks" }) 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 a3b988e56c..06693226fa 100644 --- a/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts +++ b/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts @@ -61,7 +61,7 @@ test.describe("compact desktop responsive layout", () => { await expect(testPage.getByTestId("dockview-task-layout")).toHaveCount(0); await expect(testPage.getByTestId("tablet-task-layout")).toHaveCount(0); await expect(testPage.getByTestId("app-sidebar")).toBeHidden(); - await expect(testPage.getByTestId("mobile-session-menu")).toBeVisible(); + await expect(testPage.getByTestId("mobile-task-picker-trigger")).toBeVisible(); await expect(session.activeChat()).toBeVisible(); await testPage.getByRole("button", { name: "Status", exact: true }).click(); diff --git a/apps/web/e2e/tests/layout/mobile-app-status-bar.spec.ts b/apps/web/e2e/tests/layout/mobile-app-status-bar.spec.ts index 185a5d045d..ff7d9c945b 100644 --- a/apps/web/e2e/tests/layout/mobile-app-status-bar.spec.ts +++ b/apps/web/e2e/tests/layout/mobile-app-status-bar.spec.ts @@ -53,7 +53,7 @@ test.describe("Mobile App status bar preference", () => { .toBe(false); await testPage.goto("/"); - await testPage.getByRole("button", { name: "Open menu" }).tap(); + await testPage.getByTestId("app-nav-trigger").tap(); await expect(testPage.getByTestId("mobile-home-status-button")).toHaveCount(0); await testPage.keyboard.press("Escape"); await testPage.goto("/stats"); diff --git a/apps/web/e2e/tests/layout/mobile-menu-backdrops.spec.ts b/apps/web/e2e/tests/layout/mobile-menu-backdrops.spec.ts index 58e67146c0..b70a5b6d8e 100644 --- a/apps/web/e2e/tests/layout/mobile-menu-backdrops.spec.ts +++ b/apps/web/e2e/tests/layout/mobile-menu-backdrops.spec.ts @@ -123,7 +123,7 @@ test.describe("Mobile menu backdrops", () => { await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true }); const row = drawer.getByTestId("sidebar-task-item").filter({ hasText: task.title }); const actions = row.getByRole("button", { name: "Task actions" }); diff --git a/apps/web/e2e/tests/layout/mobile-menu-hierarchy.spec.ts b/apps/web/e2e/tests/layout/mobile-menu-hierarchy.spec.ts new file mode 100644 index 0000000000..48564091c5 --- /dev/null +++ b/apps/web/e2e/tests/layout/mobile-menu-hierarchy.spec.ts @@ -0,0 +1,216 @@ +import { test, expect } from "../../fixtures/test-base"; + +// @covers AC-UI-MOBILE-MENU-006.1 AC-UI-MOBILE-MENU-006.2 +for (const route of ["/", "/tasks", "/threads"]) { + test(`Home owns ${route} and Tasks keeps an independent create action`, async ({ testPage }) => { + await testPage.goto(route); + await testPage.getByTestId("app-nav-trigger").tap(); + const menu = testPage.getByTestId("app-nav-sheet"); + await expect(menu.getByRole("link", { name: "Tasks", exact: true })).toHaveCount(0); + await expect(menu.getByRole("link", { name: "Threads", exact: true })).toHaveCount(0); + await expect(menu.getByRole("link", { name: "Home", exact: true })).toHaveAttribute( + "aria-current", + "page", + ); + const toggle = menu.getByTestId("mobile-navigation-tasks-toggle"); + await toggle.tap(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await menu.getByRole("button", { name: "New task", exact: true }).tap(); + await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + }); +} + +// @covers AC-UI-MOBILE-MENU-006.3 AC-UI-MOBILE-MENU-006.4 +test("empty sections retain automation and integration setup", async ({ testPage, seedData }) => { + await testPage.goto("/stats"); + await testPage.getByTestId("app-nav-trigger").tap(); + const menu = testPage.getByTestId("app-nav-sheet"); + await menu.getByRole("button", { name: "Automations", exact: true }).tap(); + await expect(menu.getByRole("link", { name: "Set up an automation", exact: true })).toBeVisible(); + const toggle = menu.getByTestId("mobile-navigation-tasks-toggle"); + const utilities = menu.getByText("Utilities", { exact: true }); + const headingStyle = async (element: typeof toggle) => + element.evaluate((el) => { + const style = getComputedStyle(el); + return { + x: el.getBoundingClientRect().x, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + }; + }); + expect(await headingStyle(toggle)).toEqual(await headingStyle(utilities)); + for (const width of [393, 767]) { + await testPage.setViewportSize({ width, height: 851 }); + for (const theme of ["first", "second"]) { + await testPage.getByTestId("mobile-theme-toggle-button").tap(); + await toggle.scrollIntoViewIfNeeded(); + await testPage.screenshot({ path: test.info().outputPath(`menu-${width}-${theme}.png`) }); + expect( + await testPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + ).toBe(true); + } + } + const integrations = menu.getByRole("button", { name: "Integrations", exact: true }); + await expect(integrations).toHaveAttribute("aria-expanded", "false"); + await integrations.focus(); + await testPage.keyboard.press("Enter"); + await expect(integrations).toHaveAttribute("aria-expanded", "true"); + const settings = menu.getByTestId("mobile-integration-settings"); + await expect(settings).toBeVisible(); + await settings.tap(); + await expect(testPage).toHaveURL( + new RegExp(`/settings/workspaces/${seedData.workspaceId}/integrations`), + ); +}); + +// @covers AC-UI-MOBILE-MENU-006.3 +test("opens an automation from the shared menu", async ({ testPage, apiClient, seedData }) => { + const automation = await apiClient.seedAutomation({ + workspaceId: seedData.workspaceId, + name: "Daily navigation check", + workflowId: seedData.workflowId, + workflowStepId: seedData.startStepId, + prompt: "Review changes", + }); + await testPage.goto("/stats"); + await testPage.getByTestId("app-nav-trigger").tap(); + const menu = testPage.getByTestId("app-nav-sheet"); + await menu.getByRole("button", { name: "Automations", exact: true }).tap(); + await menu.getByRole("link", { name: /Daily navigation check/ }).tap(); + await expect(testPage).toHaveURL(new RegExp(`/automations/${automation.id}`)); + await expect(menu).toBeHidden(); +}); + +// @covers AC-UI-MOBILE-MENU-007.1 AC-UI-MOBILE-MENU-007.2 AC-UI-MOBILE-MENU-007.4 +for (const surface of ["home", "workbench"]) { + test(`quick actions precede expandable content on ${surface}`, async ({ + testPage, + apiClient, + seedData, + }) => { + const task = + surface === "workbench" + ? await apiClient.seedTask(seedData.workspaceId, "Menu ordering", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }) + : null; + await testPage.goto(task ? `/t/${task.task_id}` : "/"); + await testPage.getByTestId("app-nav-trigger").tap(); + const menu = testPage.getByTestId("app-nav-sheet"); + const chat = menu.getByTestId("mobile-quick-chat-button"); + const terminal = menu.getByTestId("mobile-quick-terminal-button"); + const tasks = menu.getByTestId("mobile-navigation-tasks-toggle"); + for (const width of [393, 767]) { + await testPage.setViewportSize({ width, height: 851 }); + await menu.evaluate(async (element) => { + await Promise.all( + element + .getAnimations({ subtree: true }) + .filter((animation) => + Number.isFinite(animation.effect?.getComputedTiming().iterations), + ) + .map((animation) => animation.finished.catch(() => undefined)), + ); + }); + const boxes = await Promise.all( + [menu.getByRole("link", { name: "Home", exact: true }), chat, terminal, tasks].map((item) => + item.boundingBox(), + ), + ); + const [home, quickChat, quickTerminal, heading] = boxes.map((box) => { + expect(box).not.toBeNull(); + return box!; + }); + expect(home.y + home.height).toBeLessThanOrEqual(quickChat.y); + expect(quickChat.y).toBeCloseTo(quickTerminal.y); + expect(quickChat.x + quickChat.width).toBeLessThanOrEqual(quickTerminal.x); + expect(quickChat.y + quickChat.height).toBeLessThanOrEqual(heading.y); + for (const box of [quickChat, quickTerminal]) { + expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.width).toBeGreaterThanOrEqual(44); + } + for (const button of [chat, terminal]) { + expect(await button.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true); + } + } + await tasks.tap(); + const settings = menu.getByRole("link", { name: "Settings", exact: true }); + const stats = menu.getByRole("link", { name: "Stats", exact: true }); + expect((await settings.boundingBox())!.y).toBeLessThan((await stats.boundingBox())!.y); + await chat.tap(); + await expect(menu).toBeHidden(); + await expect(testPage.getByRole("dialog", { name: "Quick Chat", exact: true })).toBeVisible(); + }); +} + +// @covers AC-UI-MOBILE-MENU-007.3 +test("configured integrations collapse and retain navigation", async ({ testPage, apiClient }) => { + await apiClient.mockGitHubSetUser("menu-demo"); + await testPage.goto("/"); + await testPage.getByTestId("app-nav-trigger").tap(); + const menu = testPage.getByTestId("app-nav-sheet"); + const toggle = menu.getByRole("button", { name: "Integrations", exact: true }); + const github = menu.getByRole("link", { name: "GitHub", exact: true }); + await expect(github).toBeHidden(); + await toggle.tap(); + await expect(github).toBeVisible(); + await toggle.tap(); + await expect(github).toBeHidden(); + await toggle.tap(); + await github.tap(); + await expect(testPage).toHaveURL(/\/github$/); + await expect(menu).toBeHidden(); +}); + +// @covers AC-UI-MOBILE-MENU-007.4 +test("translated quick actions fit their phone targets", async ({ testPage }) => { + await testPage.goto("/"); + await testPage.evaluate(() => { + document.cookie = "kandev_locale=pt-pt; path=/; SameSite=Lax"; + }); + await testPage.reload(); + await expect(testPage.locator("html")).toHaveAttribute("lang", "pt-pt"); + await testPage.getByTestId("app-nav-trigger").tap(); + for (const width of [393, 767]) { + await testPage.setViewportSize({ width, height: 851 }); + for (const id of ["mobile-quick-chat-button", "mobile-quick-terminal-button"]) { + const button = testPage.getByTestId(id); + await expect(button).toBeVisible(); + expect( + await button.evaluate( + (el) => el.scrollWidth <= el.clientWidth && el.scrollHeight <= el.clientHeight, + ), + ).toBe(true); + expect((await button.boundingBox())!.height).toBeGreaterThanOrEqual(44); + } + await testPage.screenshot({ path: test.info().outputPath(`quick-actions-pt-${width}.png`) }); + } +}); + +// @covers AC-UI-MOBILE-MENU-007.2 AC-UI-MOBILE-MENU-007.4 +test("collapsed sections do not leave flexible space before Utilities", async ({ testPage }) => { + await testPage.setViewportSize({ width: 393, height: 1200 }); + await testPage.goto("/"); + await testPage.getByTestId("app-nav-trigger").tap(); + const menu = testPage.getByTestId("app-nav-sheet"); + await menu.getByTestId("mobile-navigation-tasks-toggle").tap(); + await expect(menu.getByRole("button", { name: "Integrations", exact: true })).toHaveAttribute( + "aria-expanded", + "false", + ); + const utilities = menu.getByText("Utilities", { exact: true }).locator(".."); + for (const width of [393, 767]) { + await testPage.setViewportSize({ width, height: 1200 }); + await expect + .poll(() => + utilities.evaluate((el) => { + const preceding = el.previousElementSibling!; + return Math.round( + el.getBoundingClientRect().top - preceding.getBoundingClientRect().bottom, + ); + }), + ) + .toBe(24); + } +}); diff --git a/apps/web/e2e/tests/layout/mobile-navigation-tasks.spec.ts b/apps/web/e2e/tests/layout/mobile-navigation-tasks.spec.ts new file mode 100644 index 0000000000..c0820b1299 --- /dev/null +++ b/apps/web/e2e/tests/layout/mobile-navigation-tasks.spec.ts @@ -0,0 +1,118 @@ +import { test, expect } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; + +// @covers AC-UI-MOBILE-MENU-004.1, 004.2, 004.3 +test("shared navigation embeds the complete collapsible task sidebar", async ({ + testPage, + apiClient, + seedData, +}) => { + const task = await apiClient.seedTask(seedData.workspaceId, "Embedded navigation task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + for (let index = 0; index < 5; index++) { + await apiClient.seedTask(seedData.workspaceId, `Another sidebar task ${index}`, { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + } + await testPage.goto("/stats"); + await testPage.getByTestId("app-nav-trigger").tap(); + const menu = testPage.getByTestId("app-nav-sheet"); + const toggle = menu.getByTestId("mobile-navigation-tasks-toggle"); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(menu.getByRole("button", { name: "Task views", exact: true })).toHaveCount(0); + await expect(menu.getByTestId("mobile-pinned-tasks")).toHaveCount(0); + await expect(menu.getByText("Embedded navigation task", { exact: true })).toBeVisible(); + await toggle.tap(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(menu.getByText("Embedded navigation task", { exact: true })).toBeHidden(); + await expect(menu.getByRole("link", { name: "Settings", exact: true })).toBeVisible(); + await testPage.keyboard.press("Escape"); + await testPage.getByTestId("app-nav-trigger").tap(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await toggle.tap(); + await expect(menu.locator("[data-task-row-id]")).toHaveCount(6); + expect( + await menu + .getByTestId("mobile-task-switcher-list") + .evaluate((el) => getComputedStyle(el).overflowY), + ).toBe("visible"); + await assertNoDocumentHorizontalOverflow(testPage); + await testPage.screenshot({ path: test.info().outputPath("embedded-tasks-393.png") }); + await testPage.setViewportSize({ width: 767, height: 851 }); + await assertNoDocumentHorizontalOverflow(testPage); + await testPage.screenshot({ path: test.info().outputPath("embedded-tasks-767.png") }); + await menu.getByText("Embedded navigation task", { exact: true }).tap(); + await expect(testPage).toHaveURL(new RegExp(`/t/${task.task_id}`)); + await testPage.goBack(); + await expect(testPage).toHaveURL(/\/stats$/); +}); + +// @covers AC-UI-MOBILE-MENU-005.1, 005.2, 005.3 +for (const route of ["/", "/tasks", "/threads"]) { + test(`listing title opens all view options on ${route}`, async ({ testPage }) => { + await testPage.goto(route); + const trigger = testPage.getByTestId("mobile-topbar-page-context"); + await expect(trigger).toBeVisible(); + await expect(testPage.getByTestId("mobile-view-options-trigger")).toHaveCount(0); + await trigger.tap(); + const options = testPage.getByRole("dialog", { name: "View options", exact: true }); + await expect(options).toBeVisible(); + await expect(options.getByRole("radio", { name: "Kanban", exact: true })).toBeVisible(); + await expect(options.getByRole("radio", { name: "Threads", exact: true })).toBeVisible(); + await expect(options.getByRole("radio", { name: "List", exact: true })).toBeVisible(); + if (route === "/threads") + await expect(options.getByTestId("threads-mobile-view-list")).toBeVisible(); + await testPage.keyboard.press("Escape"); + await expect(trigger).toBeFocused(); + }); +} + +// @covers AC-UI-MOBILE-MENU-004.3 +for (const status of [503, 403]) { + test(`embedded Tasks recovers from a ${status} workspace read`, async ({ + testPage, + apiClient, + seedData, + }) => { + await apiClient.seedTask(seedData.workspaceId, "Workspace-scoped task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + let unavailable = true; + await testPage.route("**/api/v1/workflows?*", async (route) => { + if (unavailable) await route.fulfill({ status, json: { error: "unavailable" } }); + else await route.continue(); + }); + await testPage.goto("/stats"); + await testPage.getByTestId("app-nav-trigger").tap(); + const menu = testPage.getByTestId("app-nav-sheet"); + await expect(menu.getByTestId("sidebar-task-load-error")).toBeVisible(); + await expect(menu.getByRole("link", { name: "Settings", exact: true })).toBeVisible(); + if (status === 403) + await expect(menu.getByText("Workspace-scoped task", { exact: true })).toHaveCount(0); + unavailable = false; + await menu.getByRole("button", { name: "Retry", exact: true }).tap(); + await expect(menu.getByTestId("sidebar-task-load-error")).toHaveCount(0); + await expect(menu.getByText("Workspace-scoped task", { exact: true })).toBeVisible(); + }); +} + +// @covers AC-UI-MOBILE-MENU-004.1 +test("embedded Tasks retains the current archived task outside archived views", async ({ + testPage, + apiClient, + seedData, +}) => { + const task = await apiClient.seedTask(seedData.workspaceId, "Current archived navigation task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await apiClient.archiveTask(task.task_id); + await testPage.goto(`/t/${task.task_id}`); + await testPage.getByTestId("app-nav-trigger").tap(); + const menu = testPage.getByTestId("app-nav-sheet"); + await expect(menu.getByText("Current archived navigation task", { exact: true })).toBeVisible(); +}); diff --git a/apps/web/e2e/tests/layout/mobile-sidebar-hover-reveal.spec.ts b/apps/web/e2e/tests/layout/mobile-sidebar-hover-reveal.spec.ts index 864becac2b..2217b73a49 100644 --- a/apps/web/e2e/tests/layout/mobile-sidebar-hover-reveal.spec.ts +++ b/apps/web/e2e/tests/layout/mobile-sidebar-hover-reveal.spec.ts @@ -18,7 +18,7 @@ test("phone navigation stays available by tap with no hover rail", async ({ await testPage.goto(`/t/${first.id}`); await new SessionPage(testPage).waitForLoad(); await expect(testPage.getByTestId("app-sidebar")).toBeHidden(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true }); await expect(drawer).toBeVisible(); await drawer.evaluate(async (element) => { diff --git a/apps/web/e2e/tests/layout/mobile-sidebar-read-recovery.spec.ts b/apps/web/e2e/tests/layout/mobile-sidebar-read-recovery.spec.ts index 85bc969dfe..d0f9110c13 100644 --- a/apps/web/e2e/tests/layout/mobile-sidebar-read-recovery.spec.ts +++ b/apps/web/e2e/tests/layout/mobile-sidebar-read-recovery.spec.ts @@ -49,7 +49,7 @@ test("mobile task drawer retains rows while workspace context refresh recovers", await failedWorkflowRead; await session.waitForLoad(); const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true }); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); await expect(drawer).toBeVisible(); await expect(drawer.getByText("Mobile retained task", { exact: true })).toBeVisible(); await expect(drawer.getByTestId("sidebar-task-load-error")).toBeVisible(); @@ -111,7 +111,7 @@ test("mobile task drawer surfaces a failed workflow snapshot and recovers", asyn await failedSnapshotRead; const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true }); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); await expect(drawer).toBeVisible(); await expect(drawer.getByTestId("sidebar-task-load-error")).toBeVisible(); await expect(drawer.getByText("No tasks yet.", { exact: true })).toHaveCount(0); diff --git a/apps/web/e2e/tests/layout/mobile-unified-navigation.spec.ts b/apps/web/e2e/tests/layout/mobile-unified-navigation.spec.ts new file mode 100644 index 0000000000..458fadb6c7 --- /dev/null +++ b/apps/web/e2e/tests/layout/mobile-unified-navigation.spec.ts @@ -0,0 +1,143 @@ +import { test, expect } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; + +// @covers AC-UI-MOBILE-MENU-001.1, AC-UI-MOBILE-MENU-001.2, AC-UI-MOBILE-MENU-001.5 +test("same menu from listings and page shells", async ({ testPage }) => { + for (const path of ["/?home=overview", "/tasks", "/threads", "/stats"]) { + await testPage.goto(path); + const trigger = testPage.getByTestId("app-nav-trigger"); + await trigger.tap(); + const menu = testPage.getByTestId("app-nav-sheet"); + await expect(menu).toHaveAttribute("data-vaul-drawer-direction", "bottom"); + await expect(menu.getByTestId("mobile-workspace-trigger")).toBeVisible(); + for (const name of ["Home", "Settings"]) { + await expect(menu.getByRole("link", { name, exact: true })).toBeVisible(); + } + await expect(menu.getByTestId("mobile-search-toggle")).toHaveCount(0); + await assertNoDocumentHorizontalOverflow(testPage); + await testPage.keyboard.press("Escape"); + await expect(menu).toBeHidden(); + await expect(trigger).toBeFocused(); + } +}); + +// @covers AC-UI-MOBILE-MENU-001.4, AC-UI-MOBILE-MENU-001.6 +test("separate listing controls preserve search behavior", async ({ testPage }) => { + await testPage.goto("/tasks"); + const options = testPage.getByTestId("mobile-topbar-page-context"); + await options.tap(); + const sheet = testPage.getByRole("dialog", { name: "View options", exact: true }); + await expect(sheet.getByRole("link", { name: "Settings", exact: true })).toHaveCount(0); + await testPage.screenshot({ path: test.info().outputPath("view-options-393.png") }); + await sheet.getByTestId("mobile-search-toggle").tap(); + const search = testPage.getByPlaceholder("Search tasks..."); + await expect(search).toBeFocused(); + await search.fill("checkout"); + await options.tap(); + await sheet.getByTestId("mobile-search-toggle").tap(); + await expect(search).toBeHidden(); + await expect(options).toBeFocused(); + await options.tap(); + await sheet.getByTestId("mobile-search-toggle").tap(); + await expect(search).toHaveValue(""); + await testPage.setViewportSize({ width: 767, height: 851 }); + await options.tap(); + await expect(sheet).toBeVisible(); + await assertNoDocumentHorizontalOverflow(testPage); + await testPage.screenshot({ path: test.info().outputPath("view-options-767.png") }); +}); + +// @covers AC-UI-MOBILE-MENU-002.1, AC-UI-MOBILE-MENU-002.2, AC-UI-MOBILE-MENU-002.3 +test("title picker switches tasks without changing hamburger meaning", async ({ + testPage, + apiClient, + seedData, +}) => { + const first = await apiClient.seedTask(seedData.workspaceId, "First navigation task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + const second = await apiClient.seedTask(seedData.workspaceId, "Second navigation task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await testPage.goto(`/t/${first.task_id}`); + const title = testPage.getByTestId("mobile-task-picker-trigger"); + await expect(title).toContainText("First navigation task"); + await testPage.screenshot({ path: test.info().outputPath("task-title-393.png") }); + await testPage.getByTestId("app-nav-trigger").tap(); + const menu = testPage.getByTestId("app-nav-sheet"); + await expect(menu.getByRole("link", { name: "Tasks", exact: true })).toHaveCount(0); + await expect(menu.getByTestId("mobile-navigation-tasks-toggle")).toBeVisible(); + await testPage.keyboard.press("Escape"); + await title.tap(); + const picker = testPage.getByRole("dialog", { name: "Tasks", exact: true }); + await expect(picker).toBeVisible(); + await testPage.keyboard.press("Escape"); + await expect(title).toBeFocused(); + await title.tap(); + await picker.locator(`[data-task-row-id="${second.task_id}"]`).tap(); + await expect(testPage).toHaveURL(new RegExp(`/t/${second.task_id}`)); + await expect(title).toContainText("Second navigation task"); + await assertNoDocumentHorizontalOverflow(testPage); + await testPage.setViewportSize({ width: 767, height: 851 }); + await assertNoDocumentHorizontalOverflow(testPage); + await testPage.screenshot({ path: test.info().outputPath("task-title-767.png") }); +}); + +// @covers AC-UI-MOBILE-MENU-001.5, AC-UI-MOBILE-MENU-001.6 +test("drawer geometry and focus survive breakpoint changes", async ({ testPage }) => { + for (const width of [393, 767]) { + await testPage.setViewportSize({ width, height: 851 }); + await testPage.goto("/stats"); + const trigger = testPage.getByTestId("app-nav-trigger"); + await trigger.tap(); + const menu = testPage.getByTestId("app-nav-sheet"); + await expect(menu).toHaveAttribute("data-vaul-drawer-direction", "bottom"); + await menu.evaluate(async (element) => { + await Promise.all( + element.getAnimations({ subtree: true }).map((animation) => animation.finished), + ); + }); + const box = await menu.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.y).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(width); + expect(box!.y + box!.height).toBeLessThanOrEqual(851); + const target = await trigger.boundingBox(); + expect(target!.height).toBeGreaterThanOrEqual(44); + expect(target!.width).toBeGreaterThanOrEqual(44); + await testPage.screenshot({ path: test.info().outputPath(`navigation-${width}.png`) }); + await testPage.keyboard.press("Escape"); + await expect(trigger).toBeFocused(); + await assertNoDocumentHorizontalOverflow(testPage); + } + for (const width of [768, 820]) { + await testPage.setViewportSize({ width, height: 851 }); + await expect(testPage.getByTestId("app-nav-trigger")).toBeHidden(); + await expect(testPage.getByTestId("app-nav-sheet")).toBeHidden(); + } +}); + +// @covers AC-UI-MOBILE-MENU-001.6 +test("a task-picker creation draft survives phone rotation", async ({ + testPage, + apiClient, + seedData, +}) => { + const task = await apiClient.seedTask(seedData.workspaceId, "Task with a rotation draft", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await testPage.goto(`/t/${task.task_id}`); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); + await testPage + .getByRole("dialog", { name: "Tasks", exact: true }) + .getByRole("button", { name: "New", exact: true }) + .tap(); + const title = testPage.getByTestId("create-task-dialog").getByTestId("task-title-input"); + await title.fill("Draft survives task rotation"); + await testPage.setViewportSize({ width: 851, height: 393 }); + await expect(title).toHaveValue("Draft survives task rotation"); +}); diff --git a/apps/web/e2e/tests/lsp/mobile-lsp-file-intelligence.spec.ts b/apps/web/e2e/tests/lsp/mobile-lsp-file-intelligence.spec.ts index 11f797f997..59213474e1 100644 --- a/apps/web/e2e/tests/lsp/mobile-lsp-file-intelligence.spec.ts +++ b/apps/web/e2e/tests/lsp/mobile-lsp-file-intelligence.spec.ts @@ -45,7 +45,7 @@ test.describe("Mobile LSP boundaries", () => { title: "Mobile Kotlin LSP Boundary", }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const fileNode = testPage.locator( `[data-testid="file-tree-node"][data-path="${task.filePaths[0]}"]`, ); diff --git a/apps/web/e2e/tests/mobile-improve-kandev.spec.ts b/apps/web/e2e/tests/mobile-improve-kandev.spec.ts index 27eede633f..2baa2a10ef 100644 --- a/apps/web/e2e/tests/mobile-improve-kandev.spec.ts +++ b/apps/web/e2e/tests/mobile-improve-kandev.spec.ts @@ -38,7 +38,7 @@ test.describe("Improve Kandev on mobile", () => { ); await testPage.goto("/"); - await testPage.getByRole("button", { name: "Open menu" }).tap(); + await testPage.getByTestId("app-nav-trigger").tap(); const improveButton = testPage.getByTestId("mobile-improve-kandev-button"); await expect(improveButton).toBeVisible(); await improveButton.tap(); diff --git a/apps/web/e2e/tests/office/mobile-office-navigation.spec.ts b/apps/web/e2e/tests/office/mobile-office-navigation.spec.ts index 0525140931..d629d0447a 100644 --- a/apps/web/e2e/tests/office/mobile-office-navigation.spec.ts +++ b/apps/web/e2e/tests/office/mobile-office-navigation.spec.ts @@ -41,7 +41,7 @@ test.describe("Office mobile navigation", () => { await expect(sheet.getByRole("link", { name: /Skills/ })).toBeVisible(); // Global destinations come from the navigation manifest. await expect(sheet.getByRole("link", { name: "Stats" })).toBeVisible(); - await expect(sheet.getByRole("link", { name: "Settings" })).toBeVisible(); + await expect(sheet.getByRole("link", { name: "Settings", exact: true })).toBeVisible(); // Home from inside Office lands on the office dashboard, not kanban. await sheet.getByRole("link", { name: "Home", exact: true }).tap(); @@ -97,7 +97,7 @@ test.describe("Office mobile navigation", () => { try { await backend.restart({ KANDEV_FEATURES_OFFICE: "false" }); await testPage.goto(`/?home=overview&workspaceId=${officeSeed.workspaceId}`); - await testPage.getByTestId("mobile-topbar-menu").tap(); + await testPage.getByTestId("app-nav-trigger").tap(); const home = testPage .getByRole("dialog", { name: "Menu" }) .getByRole("link", { name: "Home", exact: true }); diff --git a/apps/web/e2e/tests/plugins/mobile-bitbucket-plugin-contract.spec.ts b/apps/web/e2e/tests/plugins/mobile-bitbucket-plugin-contract.spec.ts index eebd242d14..7aaddccfb8 100644 --- a/apps/web/e2e/tests/plugins/mobile-bitbucket-plugin-contract.spec.ts +++ b/apps/web/e2e/tests/plugins/mobile-bitbucket-plugin-contract.spec.ts @@ -75,7 +75,7 @@ test.describe("mobile Bitbucket plugin contract", () => { await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const taskDrawer = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = taskDrawer .getByTestId("sidebar-task-item") diff --git a/apps/web/e2e/tests/plugins/mobile-plugin-nav.spec.ts b/apps/web/e2e/tests/plugins/mobile-plugin-nav.spec.ts index 284df33ada..863c1c0c0e 100644 --- a/apps/web/e2e/tests/plugins/mobile-plugin-nav.spec.ts +++ b/apps/web/e2e/tests/plugins/mobile-plugin-nav.spec.ts @@ -112,7 +112,7 @@ test.describe("Mobile plugin navigation", () => { // The desktop rail carries the same item but is hidden on a phone. await expect(testPage.getByTestId("app-sidebar")).toBeHidden(); - await testPage.getByRole("button", { name: "Open menu" }).click(); + await testPage.getByTestId("app-nav-trigger").click(); const navItem = testPage.getByTestId(`mobile-plugin-nav-item-${NAV_ITEM_ID}`); await expect(navItem).toBeVisible(); await expect(navItem).toHaveText(/Hello E2E/); @@ -302,7 +302,7 @@ test.describe("Mobile plugin navigation", () => { await testPage.goto("/"); await testPage.reload(); - await testPage.getByRole("button", { name: "Open menu" }).click(); + await testPage.getByTestId("app-nav-trigger").click(); // Utilities rows carry no data-testid (see spec's Rendered identity // section) — select by the visible label instead. diff --git a/apps/web/e2e/tests/plugins/mobile-plugin-task-panel.spec.ts b/apps/web/e2e/tests/plugins/mobile-plugin-task-panel.spec.ts index b3c4737b46..f4706fabd0 100644 --- a/apps/web/e2e/tests/plugins/mobile-plugin-task-panel.spec.ts +++ b/apps/web/e2e/tests/plugins/mobile-plugin-task-panel.spec.ts @@ -47,7 +47,7 @@ test.describe("Mobile plugin task panel", () => { await session.waitForLoad(); // Mobile groups all plugin panels behind one bounded bottom-nav action. - const panelsNavButton = testPage.getByRole("button", { name: "Panels" }); + const panelsNavButton = testPage.getByRole("button", { name: "Panels", exact: true }); await expect(panelsNavButton).toBeVisible({ timeout: 15_000 }); expect((await panelsNavButton.boundingBox())?.height).toBeGreaterThanOrEqual(44); await panelsNavButton.tap(); @@ -106,7 +106,9 @@ test.describe("Mobile plugin task panel", () => { await expect(testPage.getByTestId("mobile-prompt-history-option")).toBeVisible(); await testPage.keyboard.press("Escape"); await expect(testPage.getByRole("dialog", { name: "Panels" })).toHaveCount(0); - await expect(testPage.getByRole("button", { name: "Chat" })).toHaveClass(/text-primary/); + await expect(testPage.getByRole("button", { name: "Chat", exact: true })).toHaveClass( + /text-primary/, + ); expect( await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), ).toBe(true); diff --git a/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts b/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts index 7dffc6de8f..be412fe08c 100644 --- a/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts +++ b/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { waitForFiniteAnimations } from "../../helpers/animations"; import type { Page } from "@playwright/test"; import { expect, test } from "../../fixtures/test-base"; import { @@ -68,13 +69,14 @@ test.describe("Mobile listing menu actions", () => { await testPage.goto(route); await expect(testPage.getByTestId("mobile-topbar-action-strip")).toHaveCount(0); await expect(testPage.getByTestId("app-status-metrics")).toHaveCount(0); - await testPage.getByTestId("mobile-topbar-menu").tap(); + await testPage.getByTestId("app-nav-trigger").tap(); const menu = testPage.getByRole("dialog", { name: "Menu", exact: true }); const plugin = menu.locator("#hello-main-top-bar"); const metrics = menu.getByTestId("app-status-metrics"); await expect(plugin).toHaveAccessibleName(`Hello ${pluginPage}`); await expect(metrics).toBeVisible(); await expect(metrics.getByLabel(/^CPU /)).toBeVisible(); + await waitForFiniteAnimations(menu); for (const target of [ plugin, menu.getByTestId("mobile-quick-chat-button"), @@ -89,15 +91,16 @@ test.describe("Mobile listing menu actions", () => { expect(icon.width).toBeCloseTo(16, 0); expect(icon.height).toBeCloseTo(16, 0); await assertNoDocumentHorizontalOverflow(testPage, `menu tools on ${route}`); + await expect(menu.getByRole("textbox")).toHaveCount(0); + await testPage.keyboard.press("Escape"); if (route !== "/threads") { - await expect(menu.getByRole("textbox")).toHaveCount(0); - await menu.getByTestId("mobile-search-toggle").tap(); + await testPage.getByTestId("mobile-topbar-page-context").tap(); + const options = testPage.getByRole("dialog", { name: "View options", exact: true }); + await options.getByTestId("mobile-search-toggle").tap(); const search = testPage.getByTestId("mobile-search-bar"); await expect(menu).toHaveCount(0); await expect(search.getByRole("textbox")).toBeFocused(); await assertNoDocumentHorizontalOverflow(testPage, "phone search"); - } else { - await testPage.keyboard.press("Escape"); } await expect(testPage.getByTestId("app-status-metrics")).toHaveCount(0); } diff --git a/apps/web/e2e/tests/plugins/mobile-prompt-history-plugin.spec.ts b/apps/web/e2e/tests/plugins/mobile-prompt-history-plugin.spec.ts index 1b0cd5d493..8e4c4486f2 100644 --- a/apps/web/e2e/tests/plugins/mobile-prompt-history-plugin.spec.ts +++ b/apps/web/e2e/tests/plugins/mobile-prompt-history-plugin.spec.ts @@ -43,7 +43,7 @@ test.describe("Mobile prompt-history fixture", () => { const session = new SessionPage(testPage); await session.waitForLoad(); - const panelsButton = testPage.getByRole("button", { name: "Panels" }); + const panelsButton = testPage.getByRole("button", { name: "Panels", exact: true }); await expect(panelsButton).toBeVisible({ timeout: 15_000 }); expect((await panelsButton.boundingBox())?.height).toBeGreaterThanOrEqual(44); await panelsButton.tap(); diff --git a/apps/web/e2e/tests/plugins/mobile-status-drawer.spec.ts b/apps/web/e2e/tests/plugins/mobile-status-drawer.spec.ts index a39c292500..f947a5c972 100644 --- a/apps/web/e2e/tests/plugins/mobile-status-drawer.spec.ts +++ b/apps/web/e2e/tests/plugins/mobile-status-drawer.spec.ts @@ -69,7 +69,7 @@ test.describe("Mobile Status drawer", () => { await testPage.goto("/"); await testPage.reload(); - await testPage.getByRole("button", { name: "Open menu" }).click(); + await testPage.getByTestId("app-nav-trigger").click(); const statusTrigger = testPage.getByTestId("mobile-home-status-button"); await expect(statusTrigger).toBeVisible(); const triggerBox = await statusTrigger.boundingBox(); diff --git a/apps/web/e2e/tests/plugins/mobile-task-list-facet.spec.ts b/apps/web/e2e/tests/plugins/mobile-task-list-facet.spec.ts index 6b43d7c256..fb42671834 100644 --- a/apps/web/e2e/tests/plugins/mobile-task-list-facet.spec.ts +++ b/apps/web/e2e/tests/plugins/mobile-task-list-facet.spec.ts @@ -30,8 +30,8 @@ test.describe("Mobile plugin task-list facet", () => { { taskId }, ); - await testPage.getByRole("button", { name: "Open menu" }).tap(); - const menu = testPage.getByRole("dialog", { name: "Menu" }); + await testPage.getByTestId("mobile-topbar-page-context").tap(); + const menu = testPage.getByRole("dialog", { name: "View options" }); const groupSelect = menu.getByTestId("mobile-tasks-list-group"); await groupSelect.evaluate((element) => element.scrollIntoView({ block: "center" })); await expect(groupSelect).toBeInViewport(); diff --git a/apps/web/e2e/tests/pr/mobile-pr-sidebar-automation-indicators.spec.ts b/apps/web/e2e/tests/pr/mobile-pr-sidebar-automation-indicators.spec.ts index 930bcfd688..dacf4370f7 100644 --- a/apps/web/e2e/tests/pr/mobile-pr-sidebar-automation-indicators.spec.ts +++ b/apps/web/e2e/tests/pr/mobile-pr-sidebar-automation-indicators.spec.ts @@ -78,7 +78,7 @@ test.describe("Mobile sidebar PR automation indicators", () => { await testPage.goto(`/t/${navigationTaskId}`); await new SessionPage(testPage).waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const sheet = testPage.getByRole("dialog", { name: "Tasks" }); const targetRow = sheet.locator(`[data-task-row-id="${targetTaskId}"]`); diff --git a/apps/web/e2e/tests/review/mobile-review-file-status.spec.ts b/apps/web/e2e/tests/review/mobile-review-file-status.spec.ts index 2055972612..00b8eab987 100644 --- a/apps/web/e2e/tests/review/mobile-review-file-status.spec.ts +++ b/apps/web/e2e/tests/review/mobile-review-file-status.spec.ts @@ -81,7 +81,10 @@ test.describe("Review file status on mobile", () => { git.createFile(MOBILE_FILE, "mobile added file\n"); git.stageFile(MOBILE_FILE); - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); const changesPanel = testPage.getByTestId("mobile-changes-panel"); await expect(changesPanel).toBeVisible(); await expect( diff --git a/apps/web/e2e/tests/review/mobile-review-markdown-preview.spec.ts b/apps/web/e2e/tests/review/mobile-review-markdown-preview.spec.ts index 5956517767..119450d8ae 100644 --- a/apps/web/e2e/tests/review/mobile-review-markdown-preview.spec.ts +++ b/apps/web/e2e/tests/review/mobile-review-markdown-preview.spec.ts @@ -41,7 +41,10 @@ test.describe("Review Markdown preview on mobile", () => { const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir)); git.stageFile(MARKDOWN_FILE); - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); const changesPanel = testPage.getByTestId("mobile-changes-panel"); await expect(changesPanel).toBeVisible(); await expect( diff --git a/apps/web/e2e/tests/review/mobile-review-multi-pr.spec.ts b/apps/web/e2e/tests/review/mobile-review-multi-pr.spec.ts index 65afdf6116..5624c7ac35 100644 --- a/apps/web/e2e/tests/review/mobile-review-multi-pr.spec.ts +++ b/apps/web/e2e/tests/review/mobile-review-multi-pr.spec.ts @@ -64,7 +64,10 @@ async function openMobileReview( for (let attempt = 0; attempt < 2; attempt += 1) { try { await waitForMultiPRFixture(testPage, session, taskId); - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); const changesPanel = testPage.getByTestId("mobile-changes-panel"); await expect(changesPanel).toBeVisible({ timeout: 15_000 }); const prFiles = changesPanel.getByTestId("pr-files-section"); diff --git a/apps/web/e2e/tests/review/mobile-submodule-review.spec.ts b/apps/web/e2e/tests/review/mobile-submodule-review.spec.ts index 843681e507..12006fefcd 100644 --- a/apps/web/e2e/tests/review/mobile-submodule-review.spec.ts +++ b/apps/web/e2e/tests/review/mobile-submodule-review.spec.ts @@ -30,7 +30,10 @@ test.describe("Nested submodule Review on mobile", () => { const worktreePath = await fixture.waitForWorktree(apiClient); await fixture.applyNestedChanges(worktreePath); - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); const changesPanel = testPage.getByTestId("mobile-changes-panel"); await expect(changesPanel).toBeVisible({ timeout: 15_000 }); await expect(changesPanel.getByText("README.md").first()).toBeVisible({ diff --git a/apps/web/e2e/tests/review/mobile-walkthrough.spec.ts b/apps/web/e2e/tests/review/mobile-walkthrough.spec.ts index 48664cad95..e6d976a7a3 100644 --- a/apps/web/e2e/tests/review/mobile-walkthrough.spec.ts +++ b/apps/web/e2e/tests/review/mobile-walkthrough.spec.ts @@ -95,7 +95,10 @@ test.describe("Mobile code walkthrough", () => { }) => { await seedWalkthroughTask(testPage, apiClient, seedData, "walkthrough-setup", "changes ready"); - await testPage.getByRole("button", { name: "Changes" }).click(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .click(); const changes = testPage.getByTestId("mobile-changes-panel"); await expect(changes).toBeVisible({ timeout: 15_000 }); const request = changes.getByTestId("changes-request-walkthrough"); @@ -120,7 +123,7 @@ test.describe("Mobile code walkthrough", () => { }); await request.click(); - await testPage.getByRole("button", { name: "Chat" }).click(); + await testPage.getByRole("button", { name: "Chat", exact: true }).click(); const session = new SessionPage(testPage); await session.waitForLoad(); await expect(session.activeChat()).toContainText("Walkthrough: Tour of the change", { diff --git a/apps/web/e2e/tests/session/mobile-completed-session-resume.spec.ts b/apps/web/e2e/tests/session/mobile-completed-session-resume.spec.ts index 62fcb5fd06..453671ccf9 100644 --- a/apps/web/e2e/tests/session/mobile-completed-session-resume.spec.ts +++ b/apps/web/e2e/tests/session/mobile-completed-session-resume.spec.ts @@ -30,7 +30,7 @@ test.describe("Completed conversation resume on mobile", () => { await session.waitForLoad(); await expect(session.completedSessionBanner()).toBeVisible({ timeout: 30_000 }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const fileNode = session.fileTreeNode(RETAINED_WORKSPACE_FILE); await expect(fileNode).toBeVisible({ timeout: 60_000 }); await fileNode.tap(); @@ -43,7 +43,7 @@ test.describe("Completed conversation resume on mobile", () => { .filter({ hasText: RETAINED_WORKSPACE_CONTENT }), ).toBeVisible(); await viewer.getByRole("button", { name: "Close" }).tap(); - await testPage.getByRole("button", { name: "Chat" }).tap(); + await testPage.getByRole("button", { name: "Chat", exact: true }).tap(); const resume = session.completedSessionResumeButton(); const newAgent = session.completedSessionNewAgentButton(); diff --git a/apps/web/e2e/tests/session/mobile-completed-workspace-restoration.spec.ts b/apps/web/e2e/tests/session/mobile-completed-workspace-restoration.spec.ts index cb509e4d63..ff5350e9c8 100644 --- a/apps/web/e2e/tests/session/mobile-completed-workspace-restoration.spec.ts +++ b/apps/web/e2e/tests/session/mobile-completed-workspace-restoration.spec.ts @@ -48,7 +48,7 @@ test.describe("Completed workspace restoration on mobile", () => { await session.waitForLoad(); await expect(session.completedSessionBanner()).toBeVisible({ timeout: 30_000 }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const workspaceUnavailable = testPage.getByTestId("workspace-unavailable"); await expect(workspaceUnavailable).toBeVisible({ timeout: 30_000 }); await expect(testPage.getByTestId("file-tree-waiting")).toHaveCount(0); @@ -81,7 +81,10 @@ test.describe("Completed workspace restoration on mobile", () => { await viewer.getByRole("button", { name: "Close" }).tap(); await expect(viewer).toHaveCount(0); - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); const changes = testPage.getByTestId("mobile-changes-panel"); await expect(changes).toBeVisible(); await expect(changes.getByTestId("workspace-unavailable")).toHaveCount(0); @@ -124,9 +127,9 @@ test.describe("Completed workspace restoration on mobile", () => { }); await testPage.reload(); - await testPage.getByRole("button", { name: "Chat" }).tap(); + await testPage.getByRole("button", { name: "Chat", exact: true }).tap(); await session.waitForLoad(); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); await expect(session.fileTreeNode(RETAINED_WORKSPACE_FILE)).toBeVisible({ timeout: 60_000 }); await expect(testPage.getByTestId("workspace-unavailable")).toHaveCount(0); diff --git a/apps/web/e2e/tests/settings/mobile-kubernetes-task-environment.spec.ts b/apps/web/e2e/tests/settings/mobile-kubernetes-task-environment.spec.ts index 310a339d57..eee8084640 100644 --- a/apps/web/e2e/tests/settings/mobile-kubernetes-task-environment.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-kubernetes-task-environment.spec.ts @@ -124,7 +124,7 @@ test("Kubernetes task disclosure exposes live Pod details and safe actions by to await testPage.goto(`/t/${navigationTaskId}`); await new SessionPage(testPage).waitForLoad(30_000); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const taskSwitcher = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = taskSwitcher.locator(`[data-task-row-id="${task.id}"]`); const statusTrigger = taskRow.getByTestId("remote-executor-status-trigger"); diff --git a/apps/web/e2e/tests/settings/mobile-resource-metrics-display.spec.ts b/apps/web/e2e/tests/settings/mobile-resource-metrics-display.spec.ts index c45c713fcb..3c1046ffe1 100644 --- a/apps/web/e2e/tests/settings/mobile-resource-metrics-display.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-resource-metrics-display.spec.ts @@ -71,7 +71,7 @@ test.describe("Mobile resource metrics display", () => { expect(displayUpdate.ok).toBe(true); await testPage.goto("/"); - await testPage.getByRole("button", { name: "Open menu" }).click(); + await testPage.getByTestId("app-nav-trigger").click(); await testPage.getByTestId("mobile-home-status-button").click(); const drawer = testPage.getByTestId("app-status-drawer"); @@ -134,7 +134,7 @@ test.describe("Mobile resource metrics display", () => { await observeMetricsUnavailable(testPage); await testPage.goto("/"); - await testPage.getByRole("button", { name: "Open menu" }).click(); + await testPage.getByTestId("app-nav-trigger").click(); await testPage.getByTestId("mobile-home-status-button").click(); const drawer = testPage.getByTestId("app-status-drawer"); diff --git a/apps/web/e2e/tests/settings/mobile-sidebar-customization.spec.ts b/apps/web/e2e/tests/settings/mobile-sidebar-customization.spec.ts index 57316fdc7e..74324d5287 100644 --- a/apps/web/e2e/tests/settings/mobile-sidebar-customization.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-sidebar-customization.spec.ts @@ -14,7 +14,11 @@ type MobileSidebarLayoutNode = { }>; }; -async function seedMobileLayout(apiClient: ApiClient, workspaceId: string): Promise { +async function seedMobileLayout( + apiClient: ApiClient, + workspaceId: string, + homeVisible = true, +): Promise { const current = await apiClient.getUserSettings(); const expectedRevision = current.settings.sidebar_layouts_by_workspace?.[workspaceId]?.revision ?? 0; @@ -26,7 +30,7 @@ async function seedMobileLayout(apiClient: ApiClient, workspaceId: string): Prom version: 1, revision: expectedRevision, nodes: [ - { id: "home", kind: "builtin", visible: true, destination_id: "home" }, + { id: "home", kind: "builtin", visible: homeVisible, destination_id: "home" }, { id: "new-task", kind: "builtin", visible: true, destination_id: "new_task" }, { id: "automations", kind: "builtin", visible: true, destination_id: "automations" }, { id: "canvases", kind: "builtin", visible: true, destination_id: "canvases" }, @@ -104,3 +108,40 @@ test.describe("Sidebar customization on phone", () => { ).toBe(true); }); }); + +for (const homeVisible of [true, false]) { + test(`saved phone layout preserves unified task access with Home visible=${homeVisible}`, async ({ + testPage, + apiClient, + seedData, + }) => { + await seedMobileLayout(apiClient, seedData.workspaceId, homeVisible); + await testPage.goto("/tasks"); + await testPage.getByTestId("app-nav-trigger").tap(); + const menu = testPage.getByTestId("app-nav-sheet"); + const layout = menu.getByTestId("mobile-sidebar-layout-navigation"); + await expect(layout).toBeVisible(); + const chat = layout.getByTestId("mobile-quick-chat-button"); + await expect(chat).toBeVisible(); + await expect(layout.getByTestId("mobile-quick-terminal-button")).toBeVisible(); + const home = layout.getByRole("link", { name: "Home", exact: true }); + await expect(home).toHaveCount(homeVisible ? 1 : 0); + if (homeVisible) { + await expect(home).toHaveAttribute("aria-current", "page"); + const order = await layout + .locator("a, button") + .evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-testid") ?? node.textContent?.trim()), + ); + expect(order.indexOf("mobile-quick-chat-button")).toBe(order.indexOf("Home") + 1); + } + await expect(menu.getByRole("link", { name: /^(Tasks|Threads)$/ })).toHaveCount(0); + await expect(menu.getByTestId("mobile-navigation-tasks-toggle")).toBeVisible(); + await expect(menu.getByTestId("mobile-automations-section")).toHaveCount(0); + const integrations = layout.getByTestId("mobile-shortcut-section-toggle-integrations"); + await expect(integrations).toHaveAttribute("aria-expanded", "false"); + await integrations.tap(); + await expect(integrations).toHaveAttribute("aria-expanded", "true"); + await expect(menu.getByTestId("mobile-customize-sidebar-button")).toBeVisible(); + }); +} diff --git a/apps/web/e2e/tests/settings/mobile-startup-page.spec.ts b/apps/web/e2e/tests/settings/mobile-startup-page.spec.ts index de1fb26814..d88be8ca20 100644 --- a/apps/web/e2e/tests/settings/mobile-startup-page.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-startup-page.spec.ts @@ -66,9 +66,9 @@ test.describe("Mobile Threads Home default", () => { await expect(radio).toBeChecked(); await testPage.getByRole("link", { name: "Home", exact: true }).tap(); await expectThreadsHome(testPage, seedData.workspaceId); - await mobile.mobileMenuButton.tap(); + await mobile.viewOptionsButton.tap(); await testPage - .getByRole("dialog", { name: "Menu" }) + .getByRole("dialog", { name: "View options" }) .getByRole("radio", { name: "List", exact: true }) .tap(); await expectEmptyList(testPage); diff --git a/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts b/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts index 979d6408fd..70fdd8d3df 100644 --- a/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts +++ b/apps/web/e2e/tests/system/mobile-message-queue-settings.spec.ts @@ -26,8 +26,8 @@ test("mobile navigation reaches the Message Queue section with touch-safe shared const mobile = new MobileKanbanPage(testPage); await mobile.goto(); await mobile.mobileMenuButton.click(); - const homeMenu = testPage.getByTestId("mobile-home-menu-card"); - await homeMenu.getByRole("link", { name: "Settings" }).click(); + const homeMenu = testPage.getByTestId("app-nav-sheet"); + await homeMenu.getByRole("link", { name: "Settings", exact: true }).click(); // Settings lands on the /settings index; the queue lives on Task behavior. const index = testPage.getByTestId("settings-index"); await index.getByRole("link", { name: /^Task Behavior/ }).click(); @@ -117,8 +117,8 @@ test("mobile configuration lock keeps the source and accessible controls consist await mobile.goto(); await mobile.mobileMenuButton.click(); await testPage - .getByTestId("mobile-home-menu-card") - .getByRole("link", { name: "Settings" }) + .getByTestId("app-nav-sheet") + .getByRole("link", { name: "Settings", exact: true }) .click(); await testPage .getByTestId("settings-index") diff --git a/apps/web/e2e/tests/system/mobile-session-capacity-settings.spec.ts b/apps/web/e2e/tests/system/mobile-session-capacity-settings.spec.ts index a39c201bac..10fd91b457 100644 --- a/apps/web/e2e/tests/system/mobile-session-capacity-settings.spec.ts +++ b/apps/web/e2e/tests/system/mobile-session-capacity-settings.spec.ts @@ -28,10 +28,7 @@ test("reaches Task Behavior and keeps the form touch-safe", async ({ testPage }) const mobile = new MobileKanbanPage(testPage); await mobile.goto(); await mobile.mobileMenuButton.click(); - await testPage - .getByTestId("mobile-home-menu-card") - .getByRole("link", { name: "Settings" }) - .click(); + await testPage.getByTestId("app-nav-sheet").getByRole("link", { name: "Settings" }).click(); await testPage .getByTestId("settings-index") .getByRole("link", { name: /^Task Behavior/ }) diff --git a/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts b/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts index 9e1a90f86a..81a13ca111 100644 --- a/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts +++ b/apps/web/e2e/tests/system/mobile-storage-maintenance.spec.ts @@ -118,7 +118,7 @@ test.describe("Mobile storage maintenance", () => { const mobile = new MobileKanbanPage(testPage); await mobile.goto(); await mobile.mobileMenuButton.click(); - await testPage.getByRole("link", { name: "Settings" }).click(); + await testPage.getByRole("link", { name: "Settings", exact: true }).click(); const index = testPage.getByTestId("settings-index"); await index.locator('a[href="/settings/system/storage"]').click(); diff --git a/apps/web/e2e/tests/task/creation-auto-focus-helpers.ts b/apps/web/e2e/tests/task/creation-auto-focus-helpers.ts index 1ff2749a3f..4f7ba3528d 100644 --- a/apps/web/e2e/tests/task/creation-auto-focus-helpers.ts +++ b/apps/web/e2e/tests/task/creation-auto-focus-helpers.ts @@ -74,7 +74,7 @@ async function openCreationDialog(page: Page, open: () => Promise) { async function openFromTask(page: Page, mobile: boolean) { if (mobile) { - await page.getByTestId("mobile-session-menu").click(); + await page.getByTestId("mobile-task-picker-trigger").click(); await page .getByRole("dialog", { name: "Tasks", exact: true }) .getByRole("button", { name: "New", exact: true }) @@ -130,7 +130,7 @@ export async function verifyCreationAutoFocus( await openCreationDialog(page, () => openFromTask(page, mobile)); await submitTask(page, "Background task with agent", true, mobile); await expect(page).toHaveURL(activeURL); - if (mobile) await expect(page.getByTestId("mobile-session-menu")).toBeFocused(); + if (mobile) await expect(page.getByTestId("mobile-task-picker-trigger")).toBeFocused(); const secondID = await taskID(api, workspaceID, "Background task with agent"); await expect .poll( diff --git a/apps/web/e2e/tests/task/mobile-action-confirmations.spec.ts b/apps/web/e2e/tests/task/mobile-action-confirmations.spec.ts index c4936ba356..dc827b868d 100644 --- a/apps/web/e2e/tests/task/mobile-action-confirmations.spec.ts +++ b/apps/web/e2e/tests/task/mobile-action-confirmations.spec.ts @@ -142,7 +142,7 @@ for (const presentation of presentations) { await testPage.reload(); await new SessionPage(testPage).waitForLoad(); await expect(testPage.locator("html")).toHaveAttribute("lang", presentation.locale); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const row = testPage .getByTestId("mobile-task-switcher-list") .getByTestId("sidebar-task-item") @@ -221,7 +221,7 @@ test("archive is a step in the Tasks drawer and Cancel restores the scrolled lis } await testPage.goto(`/t/${active.id}`); await new SessionPage(testPage).waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const sheet = testPage .locator('[data-slot="drawer-content"]') .filter({ has: testPage.getByRole("heading", { name: "Tasks", exact: true }) }); diff --git a/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts b/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts index 57db3ded94..2c67fd548f 100644 --- a/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts +++ b/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts @@ -1,3 +1,4 @@ +import { waitForFiniteAnimations } from "../../helpers/animations"; import { mockFolderAvailability } from "../../helpers/open-task-folder"; import { expect, test } from "../../fixtures/test-base"; import type { Locator, Page } from "@playwright/test"; @@ -91,7 +92,7 @@ test("mobile Files drawer attaches sources with fixed controls and persisted wor const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 30_000 }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const entryPoint = testPage.getByTestId("files-workspace-actions"); await expect(entryPoint).toBeVisible(); await expect(entryPoint).toBeEnabled(); @@ -145,6 +146,7 @@ test("mobile Files drawer attaches sources with fixed controls and persisted wor const drawer = testPage.getByTestId("add-workspace-sources-drawer"); await expect(drawer).toBeVisible(); + await waitForFiniteAnimations(drawer); const consequences = drawer.getByTestId("workspace-change-consequences"); await expect(consequences).toBeVisible(); await expect(consequences).toContainText("This restarts the task workspace"); @@ -160,6 +162,7 @@ test("mobile Files drawer attaches sources with fixed controls and persisted wor consequences.getByText(/The task root becomes the agent's working directory/), ).toBeVisible(); await fullImpactDetails.tap(); + await waitForFiniteAnimations(drawer); const [drawerBox, viewport] = await Promise.all([ drawer.boundingBox(), testPage.evaluate(() => ({ width: innerWidth, height: innerHeight })), @@ -272,7 +275,7 @@ test("mobile Files drawer attaches sources with fixed controls and persisted wor ).toBeVisible(); await testPage.reload(); await session.waitForLoad(); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); await expect( files.getByTestId("file-tree-node").filter({ hasText: "mobile-local-repository-main" }), ).toBeVisible({ timeout: 30_000 }); @@ -306,7 +309,7 @@ test("mobile Files drawer attaches sources with fixed controls and persisted wor await testPage.reload(); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 30_000 }); - await testPage.getByRole("button", { name: "Chat" }).tap(); + await testPage.getByRole("button", { name: "Chat", exact: true }).tap(); const chatLink = session.activeChat().getByRole("link", { name: "mobile source" }); await expect(chatLink).toBeVisible({ timeout: 15_000 }); await chatLink.tap(); diff --git a/apps/web/e2e/tests/task/mobile-agent-generated-task-titles.spec.ts b/apps/web/e2e/tests/task/mobile-agent-generated-task-titles.spec.ts index 3e414c7d49..08bbcf5dcc 100644 --- a/apps/web/e2e/tests/task/mobile-agent-generated-task-titles.spec.ts +++ b/apps/web/e2e/tests/task/mobile-agent-generated-task-titles.spec.ts @@ -32,7 +32,7 @@ test.describe("Agent-generated task titles on mobile", () => { expect( await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), ).toBe(true); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const taskSheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = taskSheet .getByTestId("sidebar-task-item") diff --git a/apps/web/e2e/tests/task/mobile-archive-confirmation-preference.spec.ts b/apps/web/e2e/tests/task/mobile-archive-confirmation-preference.spec.ts index d74312d716..585ebcb2e8 100644 --- a/apps/web/e2e/tests/task/mobile-archive-confirmation-preference.spec.ts +++ b/apps/web/e2e/tests/task/mobile-archive-confirmation-preference.spec.ts @@ -39,7 +39,7 @@ test.describe("Archive confirmation preference on mobile", () => { await testPage.goto(`/t/${navTask.task_id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const sheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = sheet diff --git a/apps/web/e2e/tests/task/mobile-archive-task-redirect.spec.ts b/apps/web/e2e/tests/task/mobile-archive-task-redirect.spec.ts index ca155b1c91..6c02d3f487 100644 --- a/apps/web/e2e/tests/task/mobile-archive-task-redirect.spec.ts +++ b/apps/web/e2e/tests/task/mobile-archive-task-redirect.spec.ts @@ -89,7 +89,7 @@ test.describe("Mobile archive task redirect", () => { }); const documentRequestsBeforeArchive = documentRequests.length; - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const drawer = testPage.getByRole("dialog", { name: "Tasks" }); const parentRow = drawer.getByTestId("sidebar-task-item").filter({ hasText: parentTitle }); await expect(parentRow).toBeVisible({ timeout: 15_000 }); @@ -108,7 +108,7 @@ test.describe("Mobile archive task redirect", () => { }); expect(documentRequests).toHaveLength(documentRequestsBeforeArchive); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const postArchiveDrawer = testPage.getByRole("dialog", { name: "Tasks" }); await expect( postArchiveDrawer.getByTestId("sidebar-task-item").filter({ hasText: parentTitle }), diff --git a/apps/web/e2e/tests/task/mobile-changes-panel.spec.ts b/apps/web/e2e/tests/task/mobile-changes-panel.spec.ts index 0c2d222518..ca0af06d88 100644 --- a/apps/web/e2e/tests/task/mobile-changes-panel.spec.ts +++ b/apps/web/e2e/tests/task/mobile-changes-panel.spec.ts @@ -5,7 +5,10 @@ import type { Page } from "@playwright/test"; import path from "node:path"; async function openMobileChangesPanel(testPage: Page) { - await testPage.getByRole("button", { name: "Changes" }).tap(); + await testPage + .getByRole("navigation") + .getByRole("button", { name: /Changes$/ }) + .tap(); await expect(testPage.getByTestId("mobile-changes-panel")).toBeVisible({ timeout: 15_000 }); } diff --git a/apps/web/e2e/tests/task/mobile-command-palette-task-actions.spec.ts b/apps/web/e2e/tests/task/mobile-command-palette-task-actions.spec.ts index 7a559f2ff7..4c8c860229 100644 --- a/apps/web/e2e/tests/task/mobile-command-palette-task-actions.spec.ts +++ b/apps/web/e2e/tests/task/mobile-command-palette-task-actions.spec.ts @@ -18,7 +18,7 @@ test("uses nested task commands and the move drawer on a phone", async ({ seedData, "Mobile palette actions", ); - await expect(testPage.getByTestId("mobile-session-menu")).toBeVisible(); + await expect(testPage.getByTestId("mobile-task-picker-trigger")).toBeVisible(); await testPage.keyboard.press("Control+k"); const palette = testPage.getByRole("dialog").filter({ has: testPage.getByRole("combobox") }); const search = palette.getByRole("combobox"); diff --git a/apps/web/e2e/tests/task/mobile-command-panel-task-navigation.spec.ts b/apps/web/e2e/tests/task/mobile-command-panel-task-navigation.spec.ts index 649b5f3895..ace0c17c89 100644 --- a/apps/web/e2e/tests/task/mobile-command-panel-task-navigation.spec.ts +++ b/apps/web/e2e/tests/task/mobile-command-panel-task-navigation.spec.ts @@ -24,7 +24,7 @@ test.describe("mobile: command panel task navigation", () => { await testPage.goto(`/t/${source.task_id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await expect(testPage.getByTestId("mobile-session-menu")).toBeVisible(); + await expect(testPage.getByTestId("mobile-task-picker-trigger")).toBeVisible(); await expect(testPage.getByTestId("app-sidebar-layout")).toBeHidden(); const modifier = process.platform === "darwin" ? "Meta" : "Control"; @@ -40,7 +40,7 @@ test.describe("mobile: command panel task navigation", () => { await expect(testPage).toHaveURL(new RegExp(`/t/${destination.task_id}$`)); const mobileTopBar = testPage - .getByTestId("mobile-session-menu") + .getByTestId("mobile-task-picker-trigger") .locator("xpath=ancestor::header"); await expect( mobileTopBar.getByText("Mobile command destination task", { exact: true }), diff --git a/apps/web/e2e/tests/task/mobile-content-confirmations.spec.ts b/apps/web/e2e/tests/task/mobile-content-confirmations.spec.ts index 294b03229b..a554f49201 100644 --- a/apps/web/e2e/tests/task/mobile-content-confirmations.spec.ts +++ b/apps/web/e2e/tests/task/mobile-content-confirmations.spec.ts @@ -7,7 +7,7 @@ test("Quick Chat hosts context reset without losing its draft or dialog", async prCapture, }) => { await testPage.goto("/"); - await testPage.getByTestId("mobile-topbar-menu").tap(); + await testPage.getByTestId("app-nav-trigger").tap(); await testPage.getByTestId("mobile-quick-chat-button").tap(); const chat = testPage.getByRole("dialog", { name: "Quick Chat" }); await startQuickChatFromSetup(chat, testPage); diff --git a/apps/web/e2e/tests/task/mobile-executor-embedded-vscode-availability.spec.ts b/apps/web/e2e/tests/task/mobile-executor-embedded-vscode-availability.spec.ts index d34a071262..7ec2d8c3fc 100644 --- a/apps/web/e2e/tests/task/mobile-executor-embedded-vscode-availability.spec.ts +++ b/apps/web/e2e/tests/task/mobile-executor-embedded-vscode-availability.spec.ts @@ -37,7 +37,7 @@ test("executor capability keeps the intentional mobile topbar without desktop ed }) => { await seedTask(testPage, apiClient, seedData); - await expect(testPage.getByTestId("mobile-session-menu")).toBeVisible(); + await expect(testPage.getByTestId("mobile-task-picker-trigger")).toBeVisible(); await expect(testPage.getByTestId("editors-menu-list")).toHaveCount(0); const width = await testPage.evaluate(() => ({ client: document.documentElement.clientWidth, diff --git a/apps/web/e2e/tests/task/mobile-external-link-menu.spec.ts b/apps/web/e2e/tests/task/mobile-external-link-menu.spec.ts index f4e32bf380..01e9e17ca4 100644 --- a/apps/web/e2e/tests/task/mobile-external-link-menu.spec.ts +++ b/apps/web/e2e/tests/task/mobile-external-link-menu.spec.ts @@ -34,7 +34,7 @@ test.describe("Mobile sidebar — external link menu", () => { const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const sheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = sheet.getByTestId("sidebar-task-item").filter({ hasText: "Mobile external link task", diff --git a/apps/web/e2e/tests/task/mobile-external-vcs-file-link.spec.ts b/apps/web/e2e/tests/task/mobile-external-vcs-file-link.spec.ts index 1e20b3309f..c1d74db080 100644 --- a/apps/web/e2e/tests/task/mobile-external-vcs-file-link.spec.ts +++ b/apps/web/e2e/tests/task/mobile-external-vcs-file-link.spec.ts @@ -79,7 +79,7 @@ test.describe("Mobile external VCS file link", () => { const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle(); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${MOBILE_FILE}"]`); await expect(fileNode).toBeVisible({ timeout: 15_000 }); await fileNode.tap(); diff --git a/apps/web/e2e/tests/task/mobile-file-confirmations.spec.ts b/apps/web/e2e/tests/task/mobile-file-confirmations.spec.ts index 2358bfd373..25dc181479 100644 --- a/apps/web/e2e/tests/task/mobile-file-confirmations.spec.ts +++ b/apps/web/e2e/tests/task/mobile-file-confirmations.spec.ts @@ -70,7 +70,7 @@ test.describe("Mobile file confirmations", () => { restoreRepository = fixture.restoreRepository; expect(await testPage.evaluate(() => matchMedia("(any-pointer: coarse)").matches)).toBe(true); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const file = session.fileTreeNode(filePath); await expect(file).toBeVisible({ timeout: 15_000 }); diff --git a/apps/web/e2e/tests/task/mobile-file-tree-chat-context.spec.ts b/apps/web/e2e/tests/task/mobile-file-tree-chat-context.spec.ts index 9f29c6c183..dc048025a7 100644 --- a/apps/web/e2e/tests/task/mobile-file-tree-chat-context.spec.ts +++ b/apps/web/e2e/tests/task/mobile-file-tree-chat-context.spec.ts @@ -70,7 +70,7 @@ test.describe("Mobile file tree chat context", () => { { width: 393, height: 851 }, ); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const directoryNode = session.fileTreeNode(directoryPath); await expect(directoryNode).toBeVisible({ timeout: 15_000 }); @@ -197,7 +197,7 @@ test.describe("Mobile file tree chat context", () => { await session.fileSearchInput().press("Escape"); await expect(testPage.getByTestId("mobile-file-viewer-panel")).toHaveCount(0); - await testPage.getByRole("button", { name: "Chat" }).tap(); + await testPage.getByRole("button", { name: "Chat", exact: true }).tap(); await expect(session.chatContextFile(directoryPath)).toHaveCount(1); await expect(session.chatContextFile(directoryPath)).toHaveAttribute( "data-is-directory", diff --git a/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts b/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts index a782a20a6f..35dbafc788 100644 --- a/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts +++ b/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts @@ -84,7 +84,7 @@ test.describe("Mobile file tree keyboard shortcuts", () => { taskTitle: "Mobile File Create Select All", }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); await expect(session.fileTreeNode(filePath)).toBeVisible({ timeout: 15_000 }); const directNewFile = testPage.getByRole("button", { name: "New file" }); @@ -138,7 +138,7 @@ test.describe("Mobile file viewer panel", () => { }); // Navigate to the Files panel via the bottom nav - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); // Wait for the file to appear in the browser tree const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`); @@ -168,7 +168,7 @@ test.describe("Mobile file viewer panel", () => { taskTitle: "Mobile FV Close", }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`); await expect(fileNode).toBeVisible({ timeout: 15_000 }); @@ -199,7 +199,7 @@ test.describe("Mobile file viewer panel", () => { options: { extension: "bin", content: Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff, 0x10]) }, }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`); await expect(fileNode).toBeVisible({ timeout: 15_000 }); @@ -228,7 +228,7 @@ test.describe("Mobile file viewer panel", () => { options: { extension: "bin", content: binary }, }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); await session.fileTreeNode(filePath).tap(); const viewer = testPage.getByTestId("mobile-file-viewer-panel"); await expect(viewer).toBeVisible({ timeout: 5_000 }); @@ -262,7 +262,7 @@ test.describe("Mobile file viewer panel", () => { taskTitle: "Mobile FV Upload Menu", }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const createMenu = testPage.getByTestId("files-create-menu"); const triggerBox = await createMenu.boundingBox(); expect(triggerBox?.height).toBeGreaterThanOrEqual(44); @@ -298,7 +298,7 @@ test.describe("Mobile file viewer panel", () => { options: { extension: "ts", content: createLongFileContent(1_000) }, }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`); await expect(fileNode).toBeVisible({ timeout: 15_000 }); @@ -391,7 +391,7 @@ test.describe("Mobile file viewer panel", () => { }, }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`); await expect(fileNode).toBeVisible({ timeout: 15_000 }); @@ -427,7 +427,7 @@ test.describe("Mobile file viewer panel", () => { await testPage.getByRole("button", { name: "Update", exact: true }).tap(); await expect(testPage.getByText("Updated mobile comment.")).toBeVisible({ timeout: 5_000 }); - await testPage.getByRole("button", { name: "Chat" }).tap(); + await testPage.getByRole("button", { name: "Chat", exact: true }).tap(); const fileName = filePath.split("/").pop() ?? filePath; await expect(session.activeChat().getByText(`${fileName} (1)`)).toBeVisible({ timeout: 5_000, @@ -459,7 +459,7 @@ test.describe("Mobile file viewer panel", () => { }, }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`); await expect(fileNode).toBeVisible({ timeout: 15_000 }); await fileNode.tap(); @@ -504,7 +504,7 @@ test.describe("Mobile file viewer panel", () => { options: { directory }, }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const dirNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${directory}"]`); await expect(dirNode).toBeVisible({ timeout: 15_000 }); @@ -625,7 +625,7 @@ test.describe("Mobile file viewer panel", () => { }) => { test.setTimeout(90_000); - const { filePath } = await setupMobileFileViewerTest({ + const { session, filePath } = await setupMobileFileViewerTest({ testPage, apiClient, seedData, @@ -634,9 +634,8 @@ test.describe("Mobile file viewer panel", () => { options: { extension: "ts", content: createLongFileContent(500) }, }); - await testPage.getByRole("button", { name: "Files" }).tap(); - const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`); - await expect(fileNode).toBeVisible({ timeout: 15_000 }); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); + const fileNode = await session.fileTree.waitForFileTreeNode(filePath, 15_000); await fileNode.tap(); const viewer = testPage.getByTestId("mobile-file-viewer-panel"); diff --git a/apps/web/e2e/tests/task/mobile-hidden-workflow-task-create.spec.ts b/apps/web/e2e/tests/task/mobile-hidden-workflow-task-create.spec.ts index bbad04d46e..85c33e9d95 100644 --- a/apps/web/e2e/tests/task/mobile-hidden-workflow-task-create.spec.ts +++ b/apps/web/e2e/tests/task/mobile-hidden-workflow-task-create.spec.ts @@ -20,7 +20,7 @@ test.describe("Mobile task creation from hidden workflow context", () => { }); await testPage.goto(`/t/${sourceTask.id}`); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const taskDrawer = testPage.getByRole("dialog", { name: "Tasks" }); await expect(taskDrawer).toBeVisible(); await taskDrawer.getByRole("button", { name: "New", exact: true }).tap(); diff --git a/apps/web/e2e/tests/task/mobile-html-preview.spec.ts b/apps/web/e2e/tests/task/mobile-html-preview.spec.ts index 724aa10a7c..181e86d2b7 100644 --- a/apps/web/e2e/tests/task/mobile-html-preview.spec.ts +++ b/apps/web/e2e/tests/task/mobile-html-preview.spec.ts @@ -106,7 +106,7 @@ test.describe("Mobile HTML preview", () => { backend, }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`); await expect(fileNode).toBeVisible({ timeout: 15_000 }); await fileNode.tap(); @@ -157,7 +157,7 @@ test.describe("Mobile HTML preview", () => { backend, }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`); await expect(fileNode).toBeVisible({ timeout: 15_000 }); await fileNode.tap(); diff --git a/apps/web/e2e/tests/task/mobile-large-file-tree-virtualization.spec.ts b/apps/web/e2e/tests/task/mobile-large-file-tree-virtualization.spec.ts index 4150421efe..f0baa1a7c2 100644 --- a/apps/web/e2e/tests/task/mobile-large-file-tree-virtualization.spec.ts +++ b/apps/web/e2e/tests/task/mobile-large-file-tree-virtualization.spec.ts @@ -28,7 +28,7 @@ test.describe("Mobile large file tree virtualization", () => { title: "Mobile large file tree virtualization", }); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); const folder = session.fileTreeNode(LARGE_FILE_TREE_FOLDER); const viewport = session.fileTreeScrollViewport(); await expect(folder).toBeVisible({ timeout: 15_000 }); @@ -37,9 +37,9 @@ test.describe("Mobile large file tree virtualization", () => { await expectContiguousVisibleFileTreeRows(viewport); const collapsedPaths = await visibleFileTreePaths(viewport); - await testPage.getByRole("button", { name: "Chat" }).tap(); + await testPage.getByRole("button", { name: "Chat", exact: true }).tap(); await expect(viewport).toBeHidden(); - await testPage.getByRole("button", { name: "Files" }).tap(); + await testPage.getByRole("button", { name: "Files", exact: true }).tap(); await expect(folder).toBeVisible({ timeout: 15_000 }); await expectContiguousVisibleFileTreeRows(viewport); await expectVisibleFileTreePaths(viewport, collapsedPaths); diff --git a/apps/web/e2e/tests/task/mobile-office-sidebar-nest-task.spec.ts b/apps/web/e2e/tests/task/mobile-office-sidebar-nest-task.spec.ts index ff4c508ee6..153146a1c3 100644 --- a/apps/web/e2e/tests/task/mobile-office-sidebar-nest-task.spec.ts +++ b/apps/web/e2e/tests/task/mobile-office-sidebar-nest-task.spec.ts @@ -64,7 +64,7 @@ test.describe("Mobile Office sidebar Nest under", () => { await testPage.goto(`/t/${subject.id}`); await new SessionPage(testPage).waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const taskSheet = testPage.getByRole("dialog", { name: "Tasks" }); const subjectRow = taskSheet diff --git a/apps/web/e2e/tests/task/mobile-prompt-history-panel.spec.ts b/apps/web/e2e/tests/task/mobile-prompt-history-panel.spec.ts index 3afddd72f4..07871d50b8 100644 --- a/apps/web/e2e/tests/task/mobile-prompt-history-panel.spec.ts +++ b/apps/web/e2e/tests/task/mobile-prompt-history-panel.spec.ts @@ -164,7 +164,7 @@ test.describe("Prompt history panel on mobile", () => { const session = new SessionPage(testPage); await session.waitForLoad(); - const panelsButton = testPage.getByRole("button", { name: "Panels" }); + const panelsButton = testPage.getByRole("button", { name: "Panels", exact: true }); await expect(panelsButton).toBeVisible({ timeout: 15_000 }); expect((await panelsButton.boundingBox())?.height).toBeGreaterThanOrEqual(44); await panelsButton.tap(); @@ -215,7 +215,7 @@ test.describe("Prompt history panel on mobile", () => { const session = new SessionPage(testPage); await session.waitForLoad(); - const panelsButton = testPage.getByRole("button", { name: "Panels" }); + const panelsButton = testPage.getByRole("button", { name: "Panels", exact: true }); await expect(panelsButton).toBeVisible({ timeout: 15_000 }); await panelsButton.tap(); const historyOption = testPage.getByTestId("mobile-prompt-history-option"); @@ -382,7 +382,7 @@ test.describe("Prompt history panel on mobile", () => { await testPage.goto(`/t/${taskId}`); const session = new SessionPage(testPage); await session.waitForLoad(); - const panelsButton = testPage.getByRole("button", { name: "Panels" }); + const panelsButton = testPage.getByRole("button", { name: "Panels", exact: true }); await panelsButton.tap(); await testPage.getByTestId("mobile-prompt-history-option").tap(); const panel = testPage.getByTestId("prompt-history-panel"); diff --git a/apps/web/e2e/tests/task/mobile-sidebar-automatic-colors.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-automatic-colors.spec.ts index 331623938d..79d576d51e 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-automatic-colors.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-automatic-colors.spec.ts @@ -43,7 +43,7 @@ test.describe("Mobile sidebar automatic task colors", () => { await testPage.goto(`/t/${navTask.task_id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const sheet = testPage.getByRole("dialog", { name: "Tasks" }); await expect(sheet.getByTestId("sidebar-filter-bar")).toBeVisible(); @@ -108,7 +108,7 @@ test.describe("Mobile sidebar automatic task colors", () => { await testPage.reload(); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const reloadedSheet = testPage.getByRole("dialog", { name: "Tasks" }); await expect(reloadedSheet.getByTestId("sidebar-filter-bar")).toBeVisible(); await expect( diff --git a/apps/web/e2e/tests/task/mobile-sidebar-queued-count.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-queued-count.spec.ts index 837ade2931..1e6d20c120 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-queued-count.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-queued-count.spec.ts @@ -32,7 +32,7 @@ test.describe("Mobile sidebar — queued prompt count", () => { await session.waitForLoad(); // Open the task switcher sheet from the mobile session top bar. - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const sheet = testPage.getByRole("dialog"); await expect(sheet.getByText("Mobile Queued Badge Task")).toBeVisible({ timeout: 10_000 }); diff --git a/apps/web/e2e/tests/task/mobile-sidebar-subtasks.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-subtasks.spec.ts index 77de0841d7..8808913351 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-subtasks.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-subtasks.spec.ts @@ -39,7 +39,7 @@ test.describe("Mobile sidebar — nested subtasks", () => { // Open the task switcher sheet from the mobile session top bar. Scope all // assertions to the sheet — task titles also appear in the session header. - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const sheet = testPage.getByRole("dialog"); // All three levels are listed in the sheet. diff --git a/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts index a422c6b245..ed926d215a 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts @@ -33,7 +33,7 @@ test.describe("Mobile sidebar task actions", () => { await testPage.goto(`/t/${source.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const drawer = testPage.getByRole("dialog", { name: "Tasks" }); const targetRow = drawer.getByTestId("sidebar-task-item").filter({ hasText: target.title }); await expect(targetRow).toBeVisible(); @@ -198,7 +198,7 @@ test.describe("Mobile sidebar task actions", () => { timeout: 30_000, }); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const drawer = testPage.getByRole("dialog", { name: "Tasks" }); const destinationRow = drawer .getByTestId("sidebar-task-item") @@ -209,7 +209,7 @@ test.describe("Mobile sidebar task actions", () => { await expect(drawer).toBeHidden(); await expect(testPage).toHaveURL(new RegExp(`/t/${destination.id}$`)); const mobileTopBar = testPage - .getByTestId("mobile-session-menu") + .getByTestId("mobile-task-picker-trigger") .locator("xpath=ancestor::header"); await expect(mobileTopBar.getByText(destinationTitle, { exact: true })).toBeVisible(); await expect(session.chat.getByText(destinationResponse).last()).toBeVisible({ @@ -263,7 +263,7 @@ test.describe("Mobile sidebar task actions", () => { await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const drawer = testPage.getByRole("dialog", { name: "Tasks" }); const row = drawer.getByTestId("sidebar-task-item").filter({ hasText: task.title }); @@ -285,7 +285,7 @@ test.describe("Mobile sidebar task actions", () => { await testPage.goto(`/t/${task.task_id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const surface = testPage.getByRole("dialog", { name: "Tasks" }); await expect(surface).toBeVisible(); @@ -322,7 +322,7 @@ test.describe("Mobile sidebar task actions", () => { await testPage.goto(`/t/${task.task_id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const drawer = testPage.getByRole("dialog", { name: "Tasks" }); const drawerId = await drawer.getAttribute("id"); @@ -387,7 +387,7 @@ test.describe("Mobile sidebar task actions", () => { const session = new SessionPage(testPage); await session.waitForLoad(); await waitForActiveSessionForegroundActivity(testPage, "generating"); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const drawer = testPage.getByRole("dialog", { name: "Tasks" }); const drawerId = await drawer.getAttribute("id"); @@ -533,7 +533,7 @@ test.describe("Mobile sidebar task actions", () => { timeout: 60_000, }); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const sheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = sheet.getByTestId("sidebar-task-item").filter({ hasText: taskTitle }); const diffStats = taskRow.getByTestId("sidebar-task-diff-stats"); @@ -656,7 +656,7 @@ test.describe("Mobile sidebar task actions", () => { await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const drawer = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = drawer @@ -745,7 +745,7 @@ test.describe("Mobile sidebar task actions", () => { const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 30_000 }); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const taskSheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = taskSheet.getByTestId("sidebar-task-item").filter({ hasText: parentTitle }); @@ -843,7 +843,7 @@ test.describe("Mobile sidebar task actions", () => { await testPage.goto(`/t/${activeTask.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const taskSheet = testPage.getByRole("dialog", { name: "Tasks" }); const parentRow = taskSheet @@ -877,7 +877,7 @@ test.describe("Mobile sidebar task actions", () => { await testPage.goto(`/t/${task.task_id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const sheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = sheet @@ -963,7 +963,7 @@ test.describe("Mobile sidebar task actions", () => { const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 30_000 }); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const taskSheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = taskSheet.getByTestId("sidebar-task-item").filter({ hasText: parentTitle }); await taskRow.getByRole("button", { name: "Task actions" }).tap(); diff --git a/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts index 8fbb335f36..2682bfd4e3 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-views.spec.ts @@ -44,7 +44,7 @@ async function seedAndOpenSheet( await session.waitForLoad(); // Open the task-switcher sheet from the mobile session top bar. - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const sheet = testPage.getByRole("dialog", { name: "Tasks" }); await expect(sheet.getByTestId("sidebar-filter-bar")).toBeVisible({ timeout: 10_000 }); return sheet; @@ -233,7 +233,7 @@ test.describe("Mobile sidebar — view system", () => { await testPage.reload(); await new SessionPage(testPage).waitForLoad(); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const reloadedSheet = testPage.getByRole("dialog", { name: "Tasks" }); await expect( reloadedSheet @@ -396,7 +396,9 @@ test.describe("Mobile sidebar — view system", () => { await expect(testPage).toHaveURL((url) => url.pathname === `/t/${archivedTask.task_id}`); // The mobile detail header uses the session top bar; desktop renders the // unarchive button directly in its task top bar. - await expect(testPage.getByTestId("mobile-session-menu")).toBeVisible({ timeout: 10_000 }); + await expect(testPage.getByTestId("mobile-task-picker-trigger")).toBeVisible({ + timeout: 10_000, + }); await expect( testPage .getByTestId("mobile-task-layout") @@ -522,7 +524,7 @@ test.describe("Mobile sidebar — view system", () => { await testPage.reload(); await new SessionPage(testPage).waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const reloadedSheet = testPage.getByRole("dialog", { name: "Tasks" }); await expect( reloadedSheet.getByTestId("sidebar-view-chip").filter({ hasText: "Mobile last activity" }), @@ -739,7 +741,7 @@ test.describe("Mobile sidebar — view system", () => { await testPage.reload(); await new SessionPage(testPage).waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const reloadedSheet = testPage.getByRole("dialog", { name: "Tasks" }); await expect( reloadedSheet.getByTestId("sidebar-view-chip").filter({ hasText: "Mobile task rows" }), @@ -780,7 +782,7 @@ test.describe("Mobile sidebar — view system", () => { await testPage.reload(); await new SessionPage(testPage).waitForLoad(); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const sheet = testPage.getByRole("dialog"); const chipRow = sheet.getByTestId("sidebar-view-chip-row"); await expect(chipRow).toBeVisible(); diff --git a/apps/web/e2e/tests/task/mobile-sidebar-workflow-completion-icon.spec.ts b/apps/web/e2e/tests/task/mobile-sidebar-workflow-completion-icon.spec.ts index 235e91a9c1..1c094b2107 100644 --- a/apps/web/e2e/tests/task/mobile-sidebar-workflow-completion-icon.spec.ts +++ b/apps/web/e2e/tests/task/mobile-sidebar-workflow-completion-icon.spec.ts @@ -38,7 +38,7 @@ test.describe("Mobile sidebar workflow completion icons", () => { await testPage.goto(`/t/${workflowCompleteTask.task_id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const drawer = testPage.getByRole("dialog", { name: "Tasks" }); const turnFinishedRow = drawer.getByTestId("sidebar-task-item").filter({ diff --git a/apps/web/e2e/tests/task/mobile-subtask-detachment.spec.ts b/apps/web/e2e/tests/task/mobile-subtask-detachment.spec.ts index d260914ee9..0556658d02 100644 --- a/apps/web/e2e/tests/task/mobile-subtask-detachment.spec.ts +++ b/apps/web/e2e/tests/task/mobile-subtask-detachment.spec.ts @@ -26,7 +26,7 @@ test.describe("Mobile subtask detachment", () => { await testPage.goto(`/t/${parent.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const taskSheet = testPage.getByRole("dialog", { name: "Tasks" }); const childRow = taskSheet @@ -55,7 +55,7 @@ test.describe("Mobile subtask detachment", () => { workspaceMode: "shared_group", }); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const promotedBlock = taskSheet.locator( `[data-testid="sortable-task-block"][data-task-id="${child.id}"]`, ); diff --git a/apps/web/e2e/tests/task/mobile-subtask-reparent-drag-drop.spec.ts b/apps/web/e2e/tests/task/mobile-subtask-reparent-drag-drop.spec.ts index 02f48e450c..c8bc7d6555 100644 --- a/apps/web/e2e/tests/task/mobile-subtask-reparent-drag-drop.spec.ts +++ b/apps/web/e2e/tests/task/mobile-subtask-reparent-drag-drop.spec.ts @@ -81,7 +81,7 @@ test.describe("Mobile subtask re-parenting by drag and drop", () => { await testPage.goto(`/t/${newParent.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const taskSheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskBlock = (taskId: string) => @@ -132,7 +132,7 @@ test.describe("Mobile subtask re-parenting by drag and drop", () => { await testPage.goto(`/t/${parent.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const taskSheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskList = taskSheet.getByTestId("mobile-task-switcher-list"); diff --git a/apps/web/e2e/tests/task/mobile-task-list-search.spec.ts b/apps/web/e2e/tests/task/mobile-task-list-search.spec.ts index 9228c97aa1..473c466286 100644 --- a/apps/web/e2e/tests/task/mobile-task-list-search.spec.ts +++ b/apps/web/e2e/tests/task/mobile-task-list-search.spec.ts @@ -26,7 +26,7 @@ test.describe("Mobile task list search", () => { await expect(taskList.getByText("List Beta Task")).toBeVisible(); await expect(searchBar).not.toBeVisible(); - await testPage.getByTestId("mobile-topbar-menu").tap(); + await testPage.getByTestId("mobile-topbar-page-context").tap(); await searchToggle.click(); await expect(searchBar).toBeVisible(); await expect(searchBar.getByPlaceholder("Search tasks...")).toBeFocused(); @@ -35,7 +35,7 @@ test.describe("Mobile task list search", () => { await expect(taskList.getByText("List Alpha Task")).toBeVisible({ timeout: 5000 }); await expect(taskList.getByText("List Beta Task")).not.toBeVisible({ timeout: 5000 }); - await testPage.getByTestId("mobile-topbar-menu").tap(); + await testPage.getByTestId("mobile-topbar-page-context").tap(); await searchToggle.click(); await expect(searchBar).not.toBeVisible(); await expect(taskList.getByText("List Alpha Task")).toBeVisible({ timeout: 5000 }); @@ -63,7 +63,7 @@ test.describe("Mobile task list search", () => { await testPage.goto("/tasks?group=none"); await testPage.waitForLoadState("networkidle"); - await testPage.getByTestId("mobile-topbar-menu").tap(); + await testPage.getByTestId("mobile-topbar-page-context").tap(); await testPage.getByTestId("mobile-search-toggle").click(); await testPage .getByTestId("mobile-search-bar") @@ -71,8 +71,8 @@ test.describe("Mobile task list search", () => { .fill("mobile sort"); await expect(testPage.getByTestId("tasks-list-sort")).not.toBeVisible(); - await testPage.getByRole("button", { name: "Open menu" }).tap(); - const menu = testPage.getByRole("dialog", { name: "Menu" }); + await testPage.getByTestId("mobile-topbar-page-context").tap(); + const menu = testPage.getByRole("dialog", { name: "View options" }); await menu.getByTestId("mobile-tasks-list-sort").tap(); await testPage.getByRole("listbox").getByRole("option", { name: "Title Z-A" }).tap(); diff --git a/apps/web/e2e/tests/task/mobile-task-listing-display.spec.ts b/apps/web/e2e/tests/task/mobile-task-listing-display.spec.ts index 995f60f4dc..387edc9faa 100644 --- a/apps/web/e2e/tests/task/mobile-task-listing-display.spec.ts +++ b/apps/web/e2e/tests/task/mobile-task-listing-display.spec.ts @@ -51,8 +51,8 @@ test.describe("Mobile task listing display preferences", () => { const mobile = new MobileKanbanPage(testPage); await mobile.goto(); - await mobile.mobileMenuButton.click(); - const menu = testPage.getByRole("dialog", { name: "Menu" }); + await mobile.viewOptionsButton.click(); + const menu = testPage.getByRole("dialog", { name: "View options" }); await menu.getByRole("radio", { name: "List", exact: true }).click(); await expect(testPage).toHaveURL(/\/tasks/); await expect(testPage.getByTestId("tasks-list")).toBeVisible(); @@ -80,8 +80,8 @@ test.describe("Mobile task listing display preferences", () => { await testPage.goto("/"); await taskListLoaded; await expect(testPage).toHaveURL(/\/tasks/); - await testPage.getByRole("button", { name: "Open menu" }).tap(); - const tasksMenu = testPage.getByRole("dialog", { name: "Menu" }); + await testPage.getByTestId("mobile-topbar-page-context").tap(); + const tasksMenu = testPage.getByRole("dialog", { name: "View options" }); await expandDisplaySettingsGroup(testPage, "list-rows", "mobile"); await tasksMenu.getByText("Show task details", { exact: true }).click(); await expect diff --git a/apps/web/e2e/tests/task/mobile-task-loading-state.spec.ts b/apps/web/e2e/tests/task/mobile-task-loading-state.spec.ts index b859bdea51..adfe8e4bb1 100644 --- a/apps/web/e2e/tests/task/mobile-task-loading-state.spec.ts +++ b/apps/web/e2e/tests/task/mobile-task-loading-state.spec.ts @@ -105,7 +105,7 @@ test.describe("Mobile task loading state", () => { await expect(routeLoading).toBeHidden(); const session = new SessionPage(testPage); const mobileTopBar = testPage - .getByTestId("mobile-session-menu") + .getByTestId("mobile-task-picker-trigger") .locator("xpath=ancestor::header"); await expect(mobileTopBar.getByText(title, { exact: true })).toBeVisible(); await expect(session.activeChat().getByText(responseText).last()).toBeVisible({ diff --git a/apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts b/apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts index f4abdb6572..7bce534dda 100644 --- a/apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts +++ b/apps/web/e2e/tests/task/mobile-task-status-summary.spec.ts @@ -58,7 +58,7 @@ test.describe("Mobile task status summary", () => { await testPage.goto(`/t/${navTask.task_id}`); const session = new SessionPage(testPage); await session.waitForLoad(); - await testPage.getByTestId("mobile-session-menu").click(); + await testPage.getByTestId("mobile-task-picker-trigger").click(); const sheet = testPage.getByRole("dialog"); const targetRow = sheet.getByTestId("sidebar-task-item").filter({ hasText: TARGET_TITLE, diff --git a/apps/web/e2e/tests/task/mobile-task-topbar-long-title.spec.ts b/apps/web/e2e/tests/task/mobile-task-topbar-long-title.spec.ts index a9f63cd75a..42c067e357 100644 --- a/apps/web/e2e/tests/task/mobile-task-topbar-long-title.spec.ts +++ b/apps/web/e2e/tests/task/mobile-task-topbar-long-title.spec.ts @@ -24,7 +24,7 @@ async function readMobileTopbarMetrics( node.textContent?.includes(titleText), ) as HTMLElement | undefined; const title = Array.from(header?.querySelectorAll("span") ?? []).find( - (node) => node.textContent?.trim() === titleText, + (node) => node.childElementCount === 0 && node.textContent?.trim() === titleText, ) as HTMLElement | undefined; const actions = header?.querySelector('[data-testid="mobile-topbar-actions"]') as | HTMLElement @@ -57,7 +57,7 @@ test.describe("Mobile task topbar long title layout", () => { testPage, apiClient, seedData, - }) => { + }, testInfo) => { test.setTimeout(90_000); const task = await apiClient.createTaskWithAgent( @@ -78,7 +78,7 @@ test.describe("Mobile task topbar long title layout", () => { const header = testPage.locator("header").filter({ hasText: LONG_TASK_TITLE }).first(); await expect(header).toBeVisible({ timeout: 10_000 }); - const taskDrawer = testPage.getByTestId("mobile-session-menu"); + const taskDrawer = testPage.getByTestId("mobile-task-picker-trigger"); await expect(taskDrawer).toBeVisible(); await expect(testPage.getByTestId("layout-preset-trigger")).toHaveCount(0); await expect(testPage.getByTestId("mobile-git-actions")).toHaveCount(0); @@ -97,5 +97,6 @@ test.describe("Mobile task topbar long title layout", () => { expect(metrics.titleRight).toBeLessThanOrEqual(metrics.actionsLeft + 1); expect(metrics.headerLeft).toBeGreaterThanOrEqual(0); expect(metrics.actionsRight).toBeLessThanOrEqual(metrics.headerRight + 1); + await testPage.screenshot({ path: testInfo.outputPath("long-task-title-phone.png") }); }); }); diff --git a/apps/web/e2e/tests/task/mobile-threads-display-settings.spec.ts b/apps/web/e2e/tests/task/mobile-threads-display-settings.spec.ts index 4c599903c6..0a84de161a 100644 --- a/apps/web/e2e/tests/task/mobile-threads-display-settings.spec.ts +++ b/apps/web/e2e/tests/task/mobile-threads-display-settings.spec.ts @@ -25,7 +25,7 @@ for (const action of ["save", "discard"] as const) { ], }); await testPage.goto("/threads"); - const trigger = testPage.getByTestId("threads-mobile-view-trigger"); + const trigger = testPage.getByTestId("mobile-topbar-page-context"); await trigger.tap(); const drawer = testPage.getByTestId("threads-mobile-view-drawer"); await drawer.getByTestId("threads-mobile-view-settings").tap(); @@ -59,9 +59,13 @@ for (const action of ["save", "discard"] as const) { await drawer.getByTestId("threads-mobile-view-back").tap(); await expect(otherView).toBeEnabled(); await otherView.tap(); - await expect(trigger).toContainText("Reviews"); + await expect + .poll(async () => (await captureThreadSettings(apiClient)).thread_active_view_id) + .toBe("view-reviews"); await testPage.reload(); - await expect(trigger).toContainText("Reviews"); + await expect + .poll(async () => (await captureThreadSettings(apiClient)).thread_active_view_id) + .toBe("view-reviews"); await trigger.tap(); await drawer.getByTestId("threads-mobile-view-option-view-presentation").tap(); await trigger.tap(); @@ -91,7 +95,7 @@ test("edits wider-screen preferences in one touch drawer and keeps the phone sin await startPresentationThread(testPage, apiClient, seedData, "B phone display"); await seedThreadPresentation(apiClient, { layout: "columns" }); await testPage.goto("/threads"); - await testPage.getByTestId("threads-mobile-view-trigger").tap(); + await testPage.getByTestId("mobile-topbar-page-context").tap(); await testPage.getByTestId("threads-mobile-view-settings").tap(); const drawer = testPage.getByTestId("threads-mobile-view-drawer"); await expect(drawer).toHaveCount(1); @@ -143,7 +147,7 @@ test("edits wider-screen preferences in one touch drawer and keeps the phone sin }); await testPage.setViewportSize({ width: 320, height: 740 }); await testPage.reload(); - await testPage.getByTestId("threads-mobile-view-trigger").tap(); + await testPage.getByTestId("mobile-topbar-page-context").tap(); await testPage.getByTestId("threads-mobile-view-settings").tap(); await expect(layout).toContainText(/[À-ž]/); await drawer.getByTestId("threads-max-columns").scrollIntoViewIfNeeded(); diff --git a/apps/web/e2e/tests/task/mobile-threads-view.spec.ts b/apps/web/e2e/tests/task/mobile-threads-view.spec.ts index 06751af0ef..0edcb5f281 100644 --- a/apps/web/e2e/tests/task/mobile-threads-view.spec.ts +++ b/apps/web/e2e/tests/task/mobile-threads-view.spec.ts @@ -43,8 +43,8 @@ async function expectSwipeCue(page: Page, column: Locator, total: number) { "true", ); const topbarBox = await topbar.boundingBox(); - const viewBox = await topbar.getByTestId("threads-mobile-view-trigger").boundingBox(); - const menuBox = await topbar.getByTestId("mobile-topbar-menu").boundingBox(); + const viewBox = await topbar.getByTestId("mobile-topbar-page-context").boundingBox(); + const menuBox = await topbar.getByTestId("app-nav-trigger").boundingBox(); const cueBox = await cue.boundingBox(); expect(topbarBox!.height).toBe(56); expect(cueBox!.x).toBeGreaterThanOrEqual(viewBox!.x + viewBox!.width); @@ -136,8 +136,8 @@ test.describe("Mobile Threads view", () => { const mobile = new MobileKanbanPage(testPage); await mobile.goto(); - await mobile.mobileMenuButton.click(); - const menu = testPage.getByRole("dialog", { name: "Menu" }); + await mobile.viewOptionsButton.click(); + const menu = testPage.getByRole("dialog", { name: "View options" }); await menu.getByRole("radio", { name: "Threads", exact: true }).click(); await expect(testPage).toHaveURL(/\/threads/); @@ -161,12 +161,13 @@ test.describe("Mobile Threads view", () => { expect(columnWidth).toBeLessThanOrEqual(viewportWidth); await expect(testPage.getByTestId("thread-swipe-cue")).toHaveCount(0); - const viewControl = testPage.getByTestId("threads-mobile-view-trigger"); + const viewControl = testPage.getByTestId("mobile-topbar-page-context"); await testPage.setViewportSize({ width: 820, height: 1180 }); - await expect(viewControl).toBeVisible(); - await expect(viewControl.getByText("Threads", { exact: true })).toHaveCount(0); + const tabletViewControl = testPage.getByTestId("threads-mobile-view-trigger"); + await expect(tabletViewControl).toBeVisible(); + await expect(tabletViewControl.getByText("Threads", { exact: true })).toHaveCount(0); await expect(column.getByTestId("thread-picker-trigger")).toHaveCount(0); - expect((await viewControl.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44); + expect((await tabletViewControl.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44); await testPage.setViewportSize({ width: 360, height: 740 }); await expect(viewControl.getByText("Threads", { exact: true })).toBeVisible(); await expect(column.getByTestId("thread-picker-trigger")).toBeVisible(); @@ -221,10 +222,10 @@ test.describe("Mobile Threads view", () => { ); }); expect(titleLines).toBeCloseTo(2, 1); - const viewControl = testPage.getByTestId("threads-mobile-view-trigger"); + const viewControl = testPage.getByTestId("mobile-topbar-page-context"); const pageLabel = await viewControl.getByText("Threads", { exact: true }).boundingBox(); - const viewLabel = await viewControl.getByText("All threads", { exact: true }).boundingBox(); - expect(viewLabel!.y).toBeGreaterThanOrEqual(pageLabel!.y + pageLabel!.height); + const viewLabel = await viewControl.getByText("E2E Workspace", { exact: true }).boundingBox(); + expect(pageLabel!.y).toBeGreaterThanOrEqual(viewLabel!.y + viewLabel!.height); await firstPicker.tap(); const picker = testPage.getByRole("dialog", { name: "Choose thread", exact: true }); await expect(picker).toBeVisible(); @@ -256,8 +257,8 @@ test.describe("Mobile Threads view", () => { await expect(selectedPicker).toBeFocused(); await testPage.setViewportSize({ width: 360, height: 740 }); - const viewPicker = testPage.getByTestId("threads-mobile-view-trigger"); - const menu = testPage.getByTestId("mobile-topbar-menu"); + const viewPicker = testPage.getByTestId("mobile-topbar-page-context"); + const menu = testPage.getByTestId("app-nav-trigger"); for (const control of [viewPicker, menu, selected.getByTestId("thread-picker-trigger")]) { const box = await control.boundingBox(); expect(box).not.toBeNull(); @@ -315,7 +316,7 @@ test.describe("Mobile Threads view", () => { await apiClient.saveUserSettings({ app_status_bar_enabled: true }); await testPage.goto("/threads"); await expect(testPage.getByTestId("thread-swipe-cue")).toHaveCount(0); - const trigger = testPage.getByTestId("mobile-topbar-menu"); + const trigger = testPage.getByTestId("app-nav-trigger"); const menu = testPage.getByRole("dialog", { name: "Menu", exact: true }); await trigger.tap(); await expect(menu.getByRole("link", { name: "Home", exact: true })).toBeVisible(); @@ -450,7 +451,7 @@ test.describe("Mobile Threads view", () => { await waitForLatestSessionDone(apiClient, secondTask.id, 1, "second saved view agent turn"); await testPage.goto("/threads"); - const trigger = testPage.getByTestId("threads-mobile-view-trigger"); + const trigger = testPage.getByTestId("mobile-topbar-page-context"); await expect(trigger).toBeVisible(); await trigger.tap(); @@ -521,12 +522,12 @@ test.describe("Mobile Threads view", () => { await expect(board.locator("[data-thread-column-id]")).toHaveCount(1); await testPage.reload(); - await expect(testPage.getByTestId("threads-mobile-view-trigger")).toContainText("New view"); + await expect(testPage.getByTestId("mobile-topbar-page-context")).toContainText("Threads"); await expect( testPage.getByTestId("threads-board").locator("[data-thread-column-id]"), ).toHaveCount(1); - const reloadedTrigger = testPage.getByTestId("threads-mobile-view-trigger"); + const reloadedTrigger = testPage.getByTestId("mobile-topbar-page-context"); await reloadedTrigger.tap(); const reloadedDrawer = testPage.getByTestId("threads-mobile-view-drawer"); await reloadedDrawer.getByTestId("threads-mobile-view-option-view-all-threads").tap(); @@ -545,6 +546,7 @@ test.describe("Mobile Threads view", () => { const geometryDrawer = testPage.getByTestId("threads-mobile-view-drawer"); await expect(geometryDrawer).toBeVisible(); await expect(geometryDrawer.getByTestId("threads-mobile-view-list")).toBeVisible(); + await waitForFiniteAnimations(geometryDrawer); const buttons = geometryDrawer.locator("button:visible"); const buttonCount = await buttons.count(); for (let index = 0; index < buttonCount; index += 1) { @@ -552,7 +554,9 @@ test.describe("Mobile Threads view", () => { expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); } await assertNoHorizontalOverflow(testPage, "mobile Threads saved views"); - await expect(geometryDrawer).toHaveClass(/safe-area-inset-bottom/); + await expect(testPage.getByTestId("mobile-home-menu-scroll")).toHaveClass( + /safe-area-inset-bottom/, + ); await geometryDrawer.getByTestId("threads-mobile-view-option-view-all-threads").tap(); await expect(geometryDrawer).toBeHidden(); @@ -587,17 +591,18 @@ test.describe("Mobile Threads view", () => { expect(seedResponse.ok).toBe(true); await testPage.goto("/threads"); - const trigger = testPage.getByTestId("threads-mobile-view-trigger"); - await expect(trigger).toContainText("Release threads"); + const trigger = testPage.getByTestId("mobile-topbar-page-context"); + await expect(trigger).toContainText("Threads"); await trigger.tap(); - const drawer = testPage.getByTestId("threads-mobile-view-drawer"); + const drawer = testPage.locator('[data-slot="drawer-content"]'); + await expect(drawer.getByTestId("threads-mobile-view-drawer")).toContainText("Release threads"); await drawer.getByTestId("threads-mobile-view-settings").tap(); const editor = drawer.getByTestId("threads-view-editor"); // Invalid input stays local; a valid persisted draft intentionally hides Delete. await editor.getByTestId("threads-max-columns").fill("0"); await expect(editor.getByTestId("threads-max-columns")).toHaveAttribute("aria-invalid", "true"); await editor.getByTestId("threads-view-delete").scrollIntoViewIfNeeded(); - const scrollRegion = drawer.getByTestId("threads-mobile-view-drawer-scroll-region"); + const scrollRegion = drawer.getByTestId("mobile-home-menu-scroll"); const scrollTop = await scrollRegion.evaluate((element) => element.scrollTop); expect(scrollTop).toBeGreaterThan(0); await waitForFiniteAnimations(drawer); @@ -657,7 +662,12 @@ test.describe("Mobile Threads view", () => { .tap(); await deletedViewResponse; await expect(drawer).toBeHidden(); - await expect(trigger).toContainText("All threads"); + await expect + .poll(async () => (await apiClient.getUserSettings()).settings.thread_active_view_id) + .toBe("view-all-threads"); await expect(trigger).toBeFocused(); + await trigger.tap(); + await expect(drawer.getByTestId("threads-mobile-view-list")).toBeVisible(); + await expect(drawer.getByTestId("threads-view-editor")).toHaveCount(0); }); }); diff --git a/apps/web/e2e/tests/task/mobile-workspace-sidebar-views.spec.ts b/apps/web/e2e/tests/task/mobile-workspace-sidebar-views.spec.ts index 05db1e789f..2e79826656 100644 --- a/apps/web/e2e/tests/task/mobile-workspace-sidebar-views.spec.ts +++ b/apps/web/e2e/tests/task/mobile-workspace-sidebar-views.spec.ts @@ -12,8 +12,7 @@ test("phone task views restore their workspace collection", async ({ const mobile = new MobileKanbanPage(testPage); await mobile.goto(); await mobile.mobileMenuButton.tap(); - await mobile.menuCard.getByRole("button", { name: "Task views", exact: true }).tap(); - const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true }); + const drawer = mobile.menuCard; await drawer.getByTestId("sidebar-new-view").tap(); const editor = testPage.getByTestId("sidebar-filter-popover"); await editor.getByTestId("view-rename-input").fill("Phone A view"); @@ -34,7 +33,6 @@ test("phone task views restore their workspace collection", async ({ await testPage.getByTestId("mobile-workspace-trigger").tap(); await testPage.getByTestId(`mobile-workspace-item-${other.id}`).tap(); await mobile.mobileMenuButton.tap(); - await mobile.menuCard.getByRole("button", { name: "Task views", exact: true }).tap(); await expect(drawer.getByTestId("sidebar-view-chip")).toHaveCount(1); await expect(drawer.getByTestId("sidebar-view-chip")).toContainText("All tasks"); await drawer.getByTestId("sidebar-new-view").tap(); @@ -52,7 +50,6 @@ test("phone task views restore their workspace collection", async ({ await testPage.getByTestId("mobile-workspace-trigger").tap(); await testPage.getByTestId(`mobile-workspace-item-${seedData.workspaceId}`).tap(); await mobile.mobileMenuButton.tap(); - await mobile.menuCard.getByRole("button", { name: "Task views", exact: true }).tap(); await expect( drawer.getByTestId("sidebar-view-chip").filter({ hasText: "Phone A view" }), ).toHaveAttribute("data-active", "true"); diff --git a/apps/web/e2e/tests/task/mobile-workspace-switch-sidebar-isolation.spec.ts b/apps/web/e2e/tests/task/mobile-workspace-switch-sidebar-isolation.spec.ts index 441eb0916d..8b59028726 100644 --- a/apps/web/e2e/tests/task/mobile-workspace-switch-sidebar-isolation.spec.ts +++ b/apps/web/e2e/tests/task/mobile-workspace-switch-sidebar-isolation.spec.ts @@ -33,8 +33,7 @@ test("mobile creates in the selected workspace after leaving an open task", asyn testPage.locator("header").getByText("Mobile Workspace A Task", { exact: true }), ).toBeVisible(); - // The phone task workbench has no workspace picker. Its existing back action - // returns to the board, whose responsive Menu drawer owns workspace switching. + // Return to the board before checking its workspace-specific creation context. await testPage.getByRole("link", { name: "Task overview" }).tap(); await expect(mobile.mobileKanbanLayout()).toBeVisible(); await mobile.mobileMenuButton.tap(); diff --git a/apps/web/e2e/tests/task/sidebar-immediate-archive-helpers.ts b/apps/web/e2e/tests/task/sidebar-immediate-archive-helpers.ts index d9ec0575cb..af6b776291 100644 --- a/apps/web/e2e/tests/task/sidebar-immediate-archive-helpers.ts +++ b/apps/web/e2e/tests/task/sidebar-immediate-archive-helpers.ts @@ -34,7 +34,8 @@ export async function checkImmediateArchive(options: { else await locator.click(); }; const openPicker = async () => { - if (mobile && !(await sheet.isVisible())) await page.getByTestId("mobile-session-menu").tap(); + if (mobile && !(await sheet.isVisible())) + await page.getByTestId("mobile-task-picker-trigger").tap(); }; const openArchive = async () => { await openPicker(); @@ -58,7 +59,7 @@ export async function checkImmediateArchive(options: { await openArchive(); await press(page.getByTestId("archive-task-confirm")); await expect.poll(() => pending !== null).toBe(true); - if (mobile) await page.getByTestId("mobile-session-menu").tap(); + if (mobile) await page.getByTestId("mobile-task-picker-trigger").tap(); await expect(targetRow()).toBeVisible(); await expect(targetRow()).toHaveAttribute("aria-busy", "true"); await expect(targetRow()).toHaveClass(/opacity-60/); @@ -86,7 +87,7 @@ export async function checkImmediateArchive(options: { await openArchive(); await press(page.getByTestId("archive-task-confirm")); await expect.poll(() => pending !== null).toBe(true); - if (mobile) await page.getByTestId("mobile-session-menu").tap(); + if (mobile) await page.getByTestId("mobile-task-picker-trigger").tap(); await expect(targetRow()).toBeVisible(); await expect(targetRow()).toHaveAttribute("aria-busy", "true"); await expect(targetRow().getByTestId("task-state-archive-pending")).toBeVisible(); diff --git a/apps/web/e2e/tests/terminal/mobile-passthrough-rendering.spec.ts b/apps/web/e2e/tests/terminal/mobile-passthrough-rendering.spec.ts index 0e1ed1fbd8..52ffb033b2 100644 --- a/apps/web/e2e/tests/terminal/mobile-passthrough-rendering.spec.ts +++ b/apps/web/e2e/tests/terminal/mobile-passthrough-rendering.spec.ts @@ -59,8 +59,8 @@ test.describe("Mobile passthrough rendering", () => { // Switch away from Chat and back — mirrors the user flow that re-evaluates // isPassthroughMode from the (now snapshot-less) session row. - await testPage.getByRole("button", { name: "Plan" }).tap(); - await testPage.getByRole("button", { name: "Chat" }).tap(); + await testPage.getByRole("button", { name: "Plan", exact: true }).tap(); + await testPage.getByRole("button", { name: "Chat", exact: true }).tap(); await expect(session.passthroughTerminal).toBeVisible({ timeout: 15_000 }); await expect(session.chat).toBeHidden(); diff --git a/apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts b/apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts index fda26dac9d..cb7c47ea19 100644 --- a/apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts +++ b/apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts @@ -42,7 +42,7 @@ async function closeSurvivingQuickTerminals(page: Page) { const dialog = page.getByRole("dialog", { name: "Quick Chat" }); if (!(await dialog.isVisible().catch(() => false))) { const launcher = page.getByTestId("mobile-quick-chat-button"); - const menu = page.getByTestId("mobile-topbar-menu"); + const menu = page.getByTestId("app-nav-trigger"); if (!(await menu.isVisible())) return; if ((await menu.getAttribute("aria-expanded")) !== "true") await menu.tap(); await expect(menu).toHaveAttribute("aria-expanded", "true"); @@ -69,7 +69,7 @@ test.describe("mobile quick terminal tabs", () => { try { const terminalButton = testPage.getByTestId("mobile-quick-terminal-button"); const quickChatButton = testPage.getByTestId("mobile-quick-chat-button"); - const menuButton = testPage.getByTestId("mobile-topbar-menu"); + const menuButton = testPage.getByTestId("app-nav-trigger"); await expect(menuButton).toBeVisible(); const headerMenuBox = await menuButton.boundingBox(); expect(headerMenuBox).not.toBeNull(); diff --git a/apps/web/e2e/tests/terminal/mobile-terminal-helpers.ts b/apps/web/e2e/tests/terminal/mobile-terminal-helpers.ts index 1be916b4ae..cc072a5056 100644 --- a/apps/web/e2e/tests/terminal/mobile-terminal-helpers.ts +++ b/apps/web/e2e/tests/terminal/mobile-terminal-helpers.ts @@ -6,7 +6,7 @@ import { type Page, expect } from "@playwright/test"; import { dwell } from "../../helpers/causal-waits"; export async function tapTerminalTab(testPage: Page): Promise { - await testPage.getByRole("button", { name: "Terminal" }).tap(); + await testPage.getByRole("button", { name: "Terminal", exact: true }).tap(); } export async function switchToTerminalPanel(testPage: Page): Promise { @@ -39,7 +39,7 @@ export async function focusTerminalForTyping(testPage: Page): Promise { } async function remountTerminalPanel(testPage: Page): Promise { - const chatTab = testPage.getByRole("button", { name: "Chat" }); + const chatTab = testPage.getByRole("button", { name: "Chat", exact: true }); if (await chatTab.isVisible()) { await chatTab.tap(); // The point of the detour through Chat is to unmount the terminal panel, so diff --git a/apps/web/e2e/tests/workflow/mobile-queued-session-ownership.spec.ts b/apps/web/e2e/tests/workflow/mobile-queued-session-ownership.spec.ts index dda5c68339..4eb9bb0250 100644 --- a/apps/web/e2e/tests/workflow/mobile-queued-session-ownership.spec.ts +++ b/apps/web/e2e/tests/workflow/mobile-queued-session-ownership.spec.ts @@ -42,7 +42,7 @@ test.describe("mobile: queued session ownership", () => { await expect(queueStatus).toContainText(scenario.destinationProfileName); await assertNoDocumentHorizontalOverflow(testPage, "queued launch mobile detail"); - await mobileLayout.getByTestId("mobile-session-menu").tap(); + await mobileLayout.getByTestId("mobile-task-picker-trigger").tap(); const taskDrawer = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = taskDrawer.getByTestId("sidebar-task-item").filter({ hasText: scenario.taskTitle, diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-session-focus.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-session-focus.spec.ts index 0e12372deb..1132abaa53 100644 --- a/apps/web/e2e/tests/workflow/mobile-workflow-session-focus.spec.ts +++ b/apps/web/e2e/tests/workflow/mobile-workflow-session-focus.spec.ts @@ -22,7 +22,7 @@ test.describe("Mobile workflow session focus", () => { response.request().method() === "POST" && new URL(response.url()).pathname === `/api/v1/tasks/${scenario.task.id}/move`, ); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const sheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = sheet.locator(`[data-task-row-id="${scenario.task.id}"]`); await expect(taskRow).toBeVisible(); diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-step-move-overrides.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-step-move-overrides.spec.ts index c0cec105d4..9a44d1e003 100644 --- a/apps/web/e2e/tests/workflow/mobile-workflow-step-move-overrides.spec.ts +++ b/apps/web/e2e/tests/workflow/mobile-workflow-step-move-overrides.spec.ts @@ -135,7 +135,7 @@ test("opens move options from the mobile task-switcher context menu", async ({ seedData, "Mobile Task Switcher Move", ); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const taskSheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = taskSheet diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-step-progress.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-step-progress.spec.ts index 0395e8fce8..8c6bbd9f10 100644 --- a/apps/web/e2e/tests/workflow/mobile-workflow-step-progress.spec.ts +++ b/apps/web/e2e/tests/workflow/mobile-workflow-step-progress.spec.ts @@ -24,7 +24,7 @@ test("shows the current agent status in the existing phone Move to drawer", asyn await testPage.setViewportSize({ width: 360, height: 780 }); await testPage.goto(`/t/${task.id}`); await new SessionPage(testPage).waitForLoad(); - await testPage.getByTestId("mobile-session-menu").tap(); + await testPage.getByTestId("mobile-task-picker-trigger").tap(); const taskSheet = testPage.getByRole("dialog", { name: "Tasks" }); const taskRow = taskSheet diff --git a/apps/web/src/app-shell.test.tsx b/apps/web/src/app-shell.test.tsx index eb3857a157..d68379aa65 100644 --- a/apps/web/src/app-shell.test.tsx +++ b/apps/web/src/app-shell.test.tsx @@ -8,6 +8,9 @@ const mocks = vi.hoisted(() => ({ passthrough: ({ children }: { children: React.ReactNode }) => children, })); +vi.mock("@/components/navigation/mobile-task-navigation-provider", () => ({ + MobileTaskNavigationProvider: mocks.passthrough, +})); vi.mock("@/components/app-sidebar/app-sidebar", () => ({ AppSidebar: () => null })); vi.mock("@/components/app-status-bar/app-status-surface-provider", () => ({ AppStatusSurfaceProvider: mocks.passthrough, diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index 4c8fb59a36..9abf72fc1e 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -1,6 +1,7 @@ import type { CSSProperties } from "react"; import { useTranslation } from "react-i18next"; +import { MobileTaskNavigationProvider } from "@/components/navigation/mobile-task-navigation-provider"; import { AppSidebar } from "@/components/app-sidebar/app-sidebar"; import { AppStatusSurfaceProvider } from "@/components/app-status-bar/app-status-surface-provider"; import { CommandPanel } from "@/components/command-panel"; @@ -97,7 +98,9 @@ export function AppShell({ children }: AppShellProps) { > -
{children}
+ +
{children}
+
diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json index 2dc23f790a..5ee1599ca0 100644 --- a/apps/web/src/locales/en/common.json +++ b/apps/web/src/locales/en/common.json @@ -540,5 +540,8 @@ "sidebarWeeks_other": "{{count}}w", "sidebarYears_one": "{{count}}y", "sidebarYears_other": "{{count}}y", - "sidebarYearsMax": "99+y" + "sidebarYearsMax": "99+y", + "integrationSettings": "Integration settings", + "pageNavigation": "On this page", + "viewOptions": "View options" } diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 1f8e8a6c92..f78806eee1 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -2326,5 +2326,6 @@ "checkoutOptions.invalidLine": "Invalid directory on line {{line}}.", "checkoutOptions.credentialsUnsupported": "Use Kandev-managed Git credentials for advanced checkout options.", "toolPayloadRemovedAt": "Tool details removed on {{date}}.", - "toolPayloadRemoved": "Tool details removed." + "toolPayloadRemoved": "Tool details removed.", + "openTaskSwitcherFor": "Open task switcher: {{title}}" } diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json index 42f222402b..caec444d08 100644 --- a/apps/web/src/locales/pseudo/common.json +++ b/apps/web/src/locales/pseudo/common.json @@ -540,5 +540,8 @@ "sidebarWeeks_other": "{{count}}ŵ", "sidebarYears_one": "{{count}}ŷ", "sidebarYears_other": "{{count}}ŷ", - "sidebarYearsMax": "99+ŷ" + "sidebarYearsMax": "99+ŷ", + "integrationSettings": "Ĩńţēĝŕàţĩōń śēţţĩńĝś", + "pageNavigation": "Ōń ţĥĩś ƥàĝē", + "viewOptions": "Vĩēŵ ōƥţĩōńś" } diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 454f92b7f2..e0cea045b3 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -2326,5 +2326,6 @@ "checkoutOptions.invalidLine": "Ĩńvàĺĩď ďĩŕēćţōŕŷ ōń ĺĩńē {{line}}.", "checkoutOptions.credentialsUnsupported": "Ũśē Ķàńďēv-ḿàńàĝēď Ĝĩţ ćŕēďēńţĩàĺś ƒōŕ àďvàńćēď ćĥēćķōũţ ōƥţĩōńś.", "toolPayloadRemovedAt": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď ōń {{date}}.", - "toolPayloadRemoved": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď." + "toolPayloadRemoved": "Ţōōĺ ďēţàĩĺś ŕēḿōvēď.", + "openTaskSwitcherFor": "Ōƥēń ţàśķ śŵĩţćĥēŕ: {{title}}" } diff --git a/apps/web/src/locales/pt-pt/common.json b/apps/web/src/locales/pt-pt/common.json index dcdae1485e..8ef05571c1 100644 --- a/apps/web/src/locales/pt-pt/common.json +++ b/apps/web/src/locales/pt-pt/common.json @@ -540,5 +540,8 @@ "sidebarWeeks_other": "{{count}}sem", "sidebarYears_one": "{{count}}a", "sidebarYears_other": "{{count}}a", - "sidebarYearsMax": "99+a" + "sidebarYearsMax": "99+a", + "integrationSettings": "Definições de integrações", + "pageNavigation": "Nesta página", + "viewOptions": "Opções de visualização" } diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 017e9d6447..0e41cbf824 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -2326,5 +2326,6 @@ "checkoutOptions.invalidLine": "Diretório inválido na linha {{line}}.", "checkoutOptions.credentialsUnsupported": "Utilize credenciais Git geridas pelo Kandev para as opções avançadas de obtenção.", "toolPayloadRemovedAt": "Detalhes da ferramenta removidos em {{date}}.", - "toolPayloadRemoved": "Detalhes da ferramenta removidos." + "toolPayloadRemoved": "Detalhes da ferramenta removidos.", + "openTaskSwitcherFor": "Abrir seletor de tarefas: {{title}}" } diff --git a/apps/web/src/locales/zh-cn/common.json b/apps/web/src/locales/zh-cn/common.json index 75219bea76..40f06ce7d3 100644 --- a/apps/web/src/locales/zh-cn/common.json +++ b/apps/web/src/locales/zh-cn/common.json @@ -540,5 +540,8 @@ "sidebarWeeks_other": "{{count}}周", "sidebarYears_one": "{{count}}年", "sidebarYears_other": "{{count}}年", - "sidebarYearsMax": "99+年" + "sidebarYearsMax": "99+年", + "integrationSettings": "集成设置", + "pageNavigation": "本页导航", + "viewOptions": "视图选项" } diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 29482fde3b..455d259c3c 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -2326,5 +2326,6 @@ "checkoutOptions.invalidLine": "第 {{line}} 行的目录无效。", "checkoutOptions.credentialsUnsupported": "请使用由 Kandev 管理的 Git 凭据启用高级检出选项。", "toolPayloadRemovedAt": "工具详细数据已于 {{date}} 删除。", - "toolPayloadRemoved": "工具详细数据已删除。" + "toolPayloadRemoved": "工具详细数据已删除。", + "openTaskSwitcherFor": "打开任务切换器:{{title}}" } diff --git a/apps/web/src/locales/zh-hk/common.json b/apps/web/src/locales/zh-hk/common.json index 5b154275a1..68187e5bc8 100644 --- a/apps/web/src/locales/zh-hk/common.json +++ b/apps/web/src/locales/zh-hk/common.json @@ -540,5 +540,8 @@ "sidebarWeeks_other": "{{count}}週", "sidebarYears_one": "{{count}}年", "sidebarYears_other": "{{count}}年", - "sidebarYearsMax": "99+年" + "sidebarYearsMax": "99+年", + "integrationSettings": "集成設定", + "pageNavigation": "本頁導航", + "viewOptions": "視圖選項" } diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 59adc195c0..84d52405bf 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -2326,5 +2326,6 @@ "checkoutOptions.invalidLine": "第 {{line}} 行的目錄無效。", "checkoutOptions.credentialsUnsupported": "請使用由 Kandev 管理的 Git 認證資料啓用高級檢出選項。", "toolPayloadRemovedAt": "工具詳細數據已於 {{date}} 刪除。", - "toolPayloadRemoved": "工具詳細數據已刪除。" + "toolPayloadRemoved": "工具詳細數據已刪除。", + "openTaskSwitcherFor": "開啟任務切換器:{{title}}" } diff --git a/apps/web/src/locales/zh-tw/common.json b/apps/web/src/locales/zh-tw/common.json index 574fecd1cc..5fab7e4f9b 100644 --- a/apps/web/src/locales/zh-tw/common.json +++ b/apps/web/src/locales/zh-tw/common.json @@ -540,5 +540,8 @@ "sidebarWeeks_other": "{{count}}週", "sidebarYears_one": "{{count}}年", "sidebarYears_other": "{{count}}年", - "sidebarYearsMax": "99+年" + "sidebarYearsMax": "99+年", + "integrationSettings": "整合設定", + "pageNavigation": "本頁導覽", + "viewOptions": "檢視選項" } diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 2199047a91..53fc24ffd0 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -2326,5 +2326,6 @@ "checkoutOptions.invalidLine": "第 {{line}} 行的目錄無效。", "checkoutOptions.credentialsUnsupported": "請使用由 Kandev 管理的 Git 認證資訊啟用高階檢出選項。", "toolPayloadRemovedAt": "工具詳細資料已於 {{date}} 刪除。", - "toolPayloadRemoved": "工具詳細資料已刪除。" + "toolPayloadRemoved": "工具詳細資料已刪除。", + "openTaskSwitcherFor": "開啟任務切換器:{{title}}" } diff --git a/docs/decisions/2026-09-15-phone-navigation-entry-points.md b/docs/decisions/2026-09-15-phone-navigation-entry-points.md new file mode 100644 index 0000000000..847430e25f --- /dev/null +++ b/docs/decisions/2026-09-15-phone-navigation-entry-points.md @@ -0,0 +1,54 @@ +# ADR-2026-09-15-phone-navigation-entry-points: Phone navigation entry-point ownership + +**Status:** accepted +**Date:** 2026-09-15 +**Area:** frontend + +## Context + +The same phone hamburger opens app navigation plus display controls on Home, +but task switching on the task workbench. Other page shells also differ in +presentation. The user chose a shared menu with a task-title switcher after +considering a combined menu and global bottom navigation. + +## Decision + +Phone hamburgers open app navigation through a shared navigation-owned shell. +The task title opens the existing task picker. Page display controls remain +local to the listing. Existing navigation manifests, workspace gates, task +selection controllers, and saved-view owners retain authority. The shared +phone menu uses the established inset bottom-drawer pattern. + +## Consequences + +Future phone pages reuse the shared navigation entry instead of assigning +page-specific meanings to its hamburger. Task switching remains two taps. +Existing task actions stay in task rows, and the workbench bottom navigation +continues to select content panels. A compact pinned-task section offers +cross-page shortcuts without changing task ordering or creating recency state. +The first iteration was implemented and user-tested. + +## Alternatives Considered + +- A Navigate/Tasks tabbed menu centralizes controls but adds an intermediate + choice before switching tasks. +- Global bottom navigation improves destination visibility but competes with + the workbench's panel navigation and consumes phone content height. +- Making every hamburger open the full task list weakens access to non-task + destinations and mixes task filtering with app navigation. + +## References + +- [Requirements](../specs/ui/requirements/unified-mobile-navigation.md) +- [Design](../specs/ui/system-design/unified-mobile-navigation.md) +- [Plan](../plans/unified-mobile-navigation/plan.md) + +## Revision after user testing (2026-09-17) + +The shared hamburger and task-title picker remain. The user rejected the separate +pinned shortcuts, Task views action, and View options button. The revised menu +embeds the existing Tasks sidebar in a collapsible middle section. The top-level +Kanban/Threads/List dropdown owns view options, including Threads saved views. +Existing sidebar pin preferences are retained within the task sidebar. This +supersedes the compact-shortcut composition above; implementation is tracked by +Task 03 in the linked package. diff --git a/docs/plans/unified-mobile-navigation/affected-mobile-specs.txt b/docs/plans/unified-mobile-navigation/affected-mobile-specs.txt new file mode 100644 index 0000000000..53de7e584b --- /dev/null +++ b/docs/plans/unified-mobile-navigation/affected-mobile-specs.txt @@ -0,0 +1,86 @@ +tests/canvas/mobile-plugin-canvas.spec.ts +tests/chat/mobile-agent-goal.spec.ts +tests/chat/mobile-auto-scroll-toggle.spec.ts +tests/chat/mobile-message-queue-management.spec.ts +tests/chat/mobile-quick-chat-entry.spec.ts +tests/chat/mobile-quick-chat-idle-dot.spec.ts +tests/chat/mobile-quick-chat-saved-prompt-delivery.spec.ts +tests/chat/mobile-quick-chat-tabs.spec.ts +tests/chat/mobile-unread-divider.spec.ts +tests/github/mobile-github-results.spec.ts +tests/github/mobile-github-sidebar.spec.ts +tests/github/mobile-github-view-recovery.spec.ts +tests/github/mobile-issue-list-task-indicator.spec.ts +tests/github/mobile-task-view-access.spec.ts +tests/gitlab/mobile-gitlab-parity.spec.ts +tests/gitlab/mobile-mr-tasks-list-badge.spec.ts +tests/integrations/mobile-integrations-nav.spec.ts +tests/kanban/mobile-auto-hide-empty-columns.spec.ts +tests/kanban/mobile-board-priority-sort-filter.spec.ts +tests/kanban/mobile-display-settings-groups.spec.ts +tests/kanban/mobile-kanban-topbar.spec.ts +tests/kanban/mobile-kanban.spec.ts +tests/kanban/mobile-menu-theme-toggle.spec.ts +tests/kanban/mobile-step-visibility-filter.spec.ts +tests/kanban/mobile-task-title-hover-subtasks.spec.ts +tests/kanban/mobile-wip-overflow-queue.spec.ts +tests/layout/mobile-app-status-bar.spec.ts +tests/layout/mobile-menu-backdrops.spec.ts +tests/layout/mobile-menu-hierarchy.spec.ts +tests/layout/mobile-navigation-tasks.spec.ts +tests/layout/mobile-sidebar-hover-reveal.spec.ts +tests/layout/mobile-sidebar-read-recovery.spec.ts +tests/layout/mobile-stats-nav.spec.ts +tests/layout/mobile-unified-navigation.spec.ts +tests/mobile-improve-kandev.spec.ts +tests/mobile-system-health-dialog.spec.ts +tests/mobile-zoom.spec.ts +tests/office/mobile-office-navigation.spec.ts +tests/plugins/mobile-bitbucket-plugin-contract.spec.ts +tests/plugins/mobile-plugin-nav.spec.ts +tests/plugins/mobile-plugin-sidebar-workspace-actions.spec.ts +tests/plugins/mobile-plugin-task-row-metadata.spec.ts +tests/plugins/mobile-plugin-topbar.spec.ts +tests/plugins/mobile-status-drawer.spec.ts +tests/plugins/mobile-task-list-facet.spec.ts +tests/pr/mobile-pr-sidebar-automation-indicators.spec.ts +tests/session/mobile-port-forwarding.spec.ts +tests/settings/mobile-kubernetes-task-environment.spec.ts +tests/settings/mobile-resource-metrics-display.spec.ts +tests/settings/mobile-startup-page.spec.ts +tests/system/mobile-message-queue-settings.spec.ts +tests/system/mobile-storage-maintenance.spec.ts +tests/task/mobile-action-confirmations.spec.ts +tests/task/mobile-agent-generated-task-titles.spec.ts +tests/task/mobile-archive-confirmation-preference.spec.ts +tests/task/mobile-archive-task-redirect.spec.ts +tests/task/mobile-autopilot-mode.spec.ts +tests/task/mobile-command-panel-task-navigation.spec.ts +tests/task/mobile-confirmation-text-hierarchy.spec.ts +tests/task/mobile-content-confirmations.spec.ts +tests/task/mobile-creation-auto-focus.spec.ts +tests/task/mobile-delete-discard-consent.spec.ts +tests/task/mobile-delete-task-redirect.spec.ts +tests/task/mobile-executor-embedded-vscode-availability.spec.ts +tests/task/mobile-external-link-menu.spec.ts +tests/task/mobile-hidden-workflow-task-create.spec.ts +tests/task/mobile-sidebar-automatic-colors.spec.ts +tests/task/mobile-sidebar-queued-count.spec.ts +tests/task/mobile-sidebar-subtasks.spec.ts +tests/task/mobile-sidebar-task-actions.spec.ts +tests/task/mobile-sidebar-task-color-sync.spec.ts +tests/task/mobile-sidebar-views.spec.ts +tests/task/mobile-sidebar-workflow-completion-icon.spec.ts +tests/task/mobile-subtask-detachment.spec.ts +tests/task/mobile-subtask-reparent-drag-drop.spec.ts +tests/task/mobile-task-list-search.spec.ts +tests/task/mobile-task-listing-display.spec.ts +tests/task/mobile-task-loading-state.spec.ts +tests/task/mobile-task-status-summary.spec.ts +tests/task/mobile-task-topbar-long-title.spec.ts +tests/task/mobile-threads-display-settings.spec.ts +tests/task/mobile-threads-view.spec.ts +tests/task/mobile-workspace-switch-sidebar-isolation.spec.ts +tests/terminal/mobile-quick-terminal.spec.ts +tests/workflow/mobile-workflow-step-move-overrides.spec.ts +tests/workflow/mobile-workflow-step-progress.spec.ts diff --git a/docs/plans/unified-mobile-navigation/plan.md b/docs/plans/unified-mobile-navigation/plan.md new file mode 100644 index 0000000000..a63e4dd8eb --- /dev/null +++ b/docs/plans/unified-mobile-navigation/plan.md @@ -0,0 +1,444 @@ +--- +created: 2026-09-15 +status: implemented +requirements: + - REQ-UI-MOBILE-MENU-001 + - REQ-UI-MOBILE-MENU-002 + - REQ-UI-MOBILE-MENU-003 + - REQ-UI-MOBILE-MENU-004 + - REQ-UI-MOBILE-MENU-005 + - REQ-UI-MOBILE-MENU-006 + - REQ-UI-MOBILE-MENU-007 +system_design: + - ../../specs/ui/system-design/unified-mobile-navigation.md +legacy_specs: [] +--- + +# Unified mobile navigation implementation plan + +## Current revision: quick actions and collapsible integrations + +[Task 05](task-05-action-first-menu.md) is implemented and verified after Task 04. The user +accepted the proposed ordering and requested a collapsible Integrations section. +No material question remains. UI owns the shared presentation; domain owners +retain state and availability. Earlier task results remain historical evidence. + +### ASCII UI preview + +UI-05 phone, hamburger open in a regular workspace: + +```text +[Workspace v] +[Home] +[Quick Chat] [Quick terminal] +---------------------------- +Tasks v + + Existing task sidebar +---------------------------- +Automations > [Open list] +---------------------------- +Integrations > +---------------------------- +Utilities + Settings / Stats / Theme + Support / conditional health +``` + +Before: quick actions follow Tasks and integration links are always expanded. +After: quick actions precede Tasks; Integrations starts collapsed. Expanded +Integrations reveals available providers and Integration settings; empty reveals +settings alone. Existing optional local/plugin/canvas/status content retains +its slots described in the system design. Fixed title and one content scroller +remain. Geometry is illustrative; order, grouping, 44px targets and availability +are required. UI-05D: desktop/tablet retain current composition. + +Task 05 maps AC-UI-MOBILE-MENU-007.1 through .4 to focused unit and phone browser +checks, plus the existing compact-desktop regression. No new persistent setting, +backend change or delegation. Preserve the populated preview, refresh only its +frontend after validation, and reapply mock integration seed if it is restarted. + +## Prior revision: menu hierarchy (2026-09-19) + +[Task 04](task-04-menu-hierarchy.md) is implemented and verified. Tasks 01-03 remain completed; +their results below are historical. User feedback removes the redundant global +Tasks/Threads links, aligns the Tasks disclosure with Utilities, preserves its +plus action, and restores Automations and empty-state Integrations discovery. +The UI system owns this reusable presentation; domain state remains shared. +Implementation follows the reviewed Task 04 scope. + +### ASCII UI preview + +UI-04: Phone hamburger in a regular workspace. + +```text +Workspace +Home +--------------------------- +Tasks v + + Saved views / task list +Quick Chat / Quick terminal +--------------------------- +Automations > [Open list] +--------------------------- +Integrations + Providers / settings +--------------------------- +Utilities +``` + +Tasks collapsed retains its plus. Empty Automations offers setup; read failures +offer retry. Empty Integrations retains settings. Fixed menu title, one scroller, +44px touch controls. UI-04D: desktop/tablet sidebar composition stays as shipped. +The Tasks heading matches Utilities in weight, alignment, divider and spacing. +Task 04 contains acceptance mapping, exact checks, files, risks and implementation +results: 71 focused unit tests and 62 distinct browser cases passed. + +## Current revision: user feedback on 2026-09-17 + +The completed first iteration below is historical context. Its pinned shortcuts +and separate View options button are superseded by the revised requirement/design +sections and [Task 03](task-03-embedded-tasks.md). Tasks 01/02 remain done; Task 03 +is implemented. Existing test counts below apply only to the first iteration; +Task 03 records the revision validation. + +Target: retain the unified hamburger, put the actual Tasks sidebar in its middle +as an initially expanded collapsible section, remove separate navigation pins and +Task views, and use Kanban/Threads/List title dropdowns to open view options. +No material product question blocks the revision. Collapse persistence is local +to the mounted host; existing sidebar pin behavior stays within the normal list. + +```text +Phone: [Workspace / Kanban v] [Menu] + Listing content + +Menu: Workspace / global navigation + Tasks v [+] -> saved view, filters, task tree + Workspace actions / utilities + +Kanban / Threads / List v -> view options and saved views +Desktop/tablet: existing presentation +``` + +Delivery: Task 03 extracts the shared sidebar body/controller, embeds it without +a nested scroll region, moves Threads saved-view controls into the title-opened +options surface, and migrates relevant tests. Its work order contains exact +validation commands, affected files, and handoff/rotation risks. The user +authorized implementation on 2026-09-18; the revision is now implemented. + +## Overview + +Deliver one phone hamburger meaning, with task switching on the task title and +listing controls beside the content. First migrate shared navigation and all +entry points together; then add bounded pinned-task shortcuts. These are +sequential work orders in the primary session. + +The user accepted the shared-menu/title-picker direction. Three pinned tasks +and preservation of the existing Task views label are bounded design choices. +Both work orders are implemented in the primary session. + +## Scope + +### In scope + +- Phone Home, Kanban, List, Threads, task workbench, and shared page shells. +- Shared app Drawer, title-triggered task picker, local View options. +- Existing workspace/Office/Settings gates and navigation/dialog behavior. +- Up to three eligible existing pins, translations, entry-point test migration, + focused rendered checks, and public navigation instructions. + +### Out of scope + +- New bottom navigation, task mutation flows, recency storage, backend or SDK + contracts, desktop/tablet composition changes, or automatic publication. + +## Technical approach + +Follow the [design](../../specs/ui/system-design/unified-mobile-navigation.md) +and [entry-point ADR](../../decisions/2026-09-15-phone-navigation-entry-points.md). +`AppNavSheet` owns the shared phone shell; `AppNavSections` and the navigation +manifest own destinations. `KanbanHeaderMobile` separates app navigation from +listing options. `SessionMobileTopBar` exposes the existing task-switcher +controller through its title. Hoisted dialog state must survive drawer closure +and orientation changes. Existing task selection and workspace read owners +retain authority. + +Task 02 adds the small pin projection using `useSidebarTaskPrefs` and the +workspace-scoped sidebar read boundary. No full task picker is mounted just to +display shortcuts. + +## ASCII UI preview + +Spacing and sample names are illustrative. Control meaning, hierarchy, fixed +versus scrolling regions, and separate task/session pickers are structural. +All rendered labels are localized. + +### UI-00: Current phone behavior (source inspection) + +```text +Home: [Listing context] [hamburger] -> navigation + display options +Task: [Back] [Task title] [hamburger] -> task list +Other page hamburger -> left-side navigation sheet +``` + +### UI-01: Shared phone app navigation, open + +Entry: any phone hamburger. Covers AC-UI-MOBILE-MENU-001.1 through .3 and .5. + +```text +Underlying page dimmed + +--------------------------------+ + | Menu [X] | fixed heading + |--------------------------------| + | Workspace: Product v | + | Home | + | Tasks | + | Threads | + | Task views | + | [Local navigation, if present] | + | [Pinned tasks: up to 3] | Task 02 + | Workspace actions | + | Integrations / Plugins | + | Utilities / Settings | + +--------------------------------+ + bottom safe area +``` + +One internal scroller below the heading. Existing mode/availability rules select +the actual destinations; Office does not show Kanban Task views or pins. + +### UI-02: Phone task workbench, closed and picker open + +Covers AC-UI-MOBILE-MENU-002.1 through .3 and -001.6. + +```text +[Back] [Fix checkout... v] [status] [Menu] + repo / branch / diff summary +[Agent + current session v] separate session picker + Active panel +[Chat] [Plan] [Files] [Changes] [...] existing bottom nav + +Tap task title: + +--------------------------------+ + | Tasks [New] | + | Workspace v / Saved view v | + | Filters | + | Fix checkout [*] [...]| + | Refine onboarding [...]| + +--------------------------------+ +``` + +Task rows retain their visible actions. Title and Menu are separate 44px touch +targets. Existing task-list loading/error/empty states remain in the picker. + +### UI-03: Phone listing options + +Covers AC-UI-MOBILE-MENU-001.4 and .6. + +```text +[Workspace / listing context] [Menu] +[View options] [existing page controls] + Listing content + +Tap View options: + +--------------------------------+ + | View options [X] | + | Kanban / List / Threads | + | Search | + | Sort / Filter / Group | + | Applicable display settings | + | Saved-view actions / recovery | + +--------------------------------+ +``` + +Only applicable controls render; Threads retains its saved-view picker. The +options body is the only vertical scroller. Hidden search clears the filter. +Pipeline stays unavailable on phone without changing the saved desktop choice. + +### UI-04: Phone pins and unavailable data + +Covers AC-UI-MOBILE-MENU-003.1 through .3. + +```text +Loaded: Loading / failed read: +Pinned tasks [Loading tasks...] + Fix checkout or [Could not load tasks] [Retry] + Update docs Global destinations remain usable + Review onboarding +``` + +No eligible pins: omit the section. Access denied: no cached task names. +Long names truncate; there is no nested pin-list scroller. Task views remains +the full task-list path regardless of shortcut state. + +### UI-05: Desktop/tablet compatibility + +```text +Desktop: +[Existing sidebar] | [Existing task header / layout controls] + | [Dockview panels] + +Tablet: +[Existing topbar and menu presentation] +[Existing tablet task composition] +``` + +No new global bottom navigation or touch sizing on desktop. At 768px and 820px, +retain wider presentation; a requested task-dialog draft survives rotation. + +## Tests + +The following suites cover the implemented entry points and recovery behavior. + +| Acceptance | Unit/component evidence | +| --- | --- | +| -001.1-.3, .5-.6 | `components/navigation/app-nav-sheet.test.tsx`: shared drawer, route indication, mode gates, handoff and breakpoint controller lifetime | +| -001.4, .6 | `components/kanban/kanban-header-mobile.test.tsx`, `mobile-menu-sheet.test.tsx`: separate controls, preserved options and search focus | +| -002.1-.3 | `components/task/mobile/session-mobile-top-bar.test.tsx`: title opens picker, hamburger opens navigation, loading name and state | +| -003.1-.3 | `components/navigation/mobile-pinned-task-items.test.ts`: eligibility/order/deduplication/cap; `mobile-pinned-tasks.test.tsx`: read states and dispatch | + +## E2E tests + +- `tests/layout/mobile-unified-navigation.spec.ts` (mobile-chrome): + `same menu from listings, task and page shells` (-001.1-.3), + `separate listing controls preserve preferences` (-001.4, .6), + `drawer geometry and focus survive breakpoint changes` (-001.5-.6), + `title picker switches tasks without changing hamburger meaning` (-002.1-.3). +- `tests/layout/mobile-navigation-pins.spec.ts` (mobile-chrome): + `pins are ordered and workspace isolated`, `shortcut history matches origin`, + `read failure leaves navigation usable` (-003.1-.3). +- Migrate and run [affected mobile specs](affected-mobile-specs.txt), collected + from current hamburger/menu selectors. Inspect helper/page-object usages too; + preserve every task-action assertion and add any transitive affected specs + to this manifest before the implementation check. This is a regression set, + not a request for the full suite. +- Run `tests/layout/compact-desktop-responsive.spec.ts` (chromium) for wider + chrome; the new phone suite checks computed presentation at 767/768/820px. + +## Work orders + +- [x] [Task 05: Prioritize quick actions and collapse Integrations](task-05-action-first-menu.md) + +- [x] [Task 04: Simplify Home and complete menu sections](task-04-menu-hierarchy.md) + +- [x] [Task 03: Embed Tasks and use listing-title options](task-03-embedded-tasks.md) + +- [x] [Task 01: Unify navigation and preserve entry points](task-01-shared-navigation.md) +- [x] [Task 02: Add bounded pinned-task shortcuts](task-02-pinned-shortcuts.md) + +## Compatibility documents + +The completed [GitHub mobile parity package](../github-mobile-parity/plan.md) +and its Task 03 remain historical evidence. Its task-view access and rotation +tests are retained in Task 01. Existing mobile-task-chrome and listing contracts +remain authoritative except for the explicitly superseded hamburger entry. +Do not rewrite historical verification counts or mark old packages pending. + +Public task-navigation instructions are updated in +`docs/public/sessions-and-review.md`. This is a how-to update. + +## Verification results + +### Unit and static checks + +- One focused Vitest run: 13 files, 109 tests passed. Suites cover app navigation, + destination rows, pin projection/read states, page shell, phone listing header, + listing menu, shared selection context, task-switcher sheet/hooks, phone task + title/repository metadata, and task-layout state preservation. +- Web typecheck and targeted ESLint passed. Final edited files are formatted. +- `pnpm run i18n:check` and `pnpm run i18n:ratchet` passed, including all five + languages, generated pseudo locale, and new-file copy checks. +- Public documentation validation and its unit test passed. + +Focused unit command (from `apps/web`): + +```bash +pnpm exec vitest run components/navigation/app-nav-sheet.test.tsx components/navigation/destination-rows.test.tsx components/navigation/mobile-pinned-task-items.test.ts components/navigation/mobile-pinned-tasks.test.tsx components/page-shell.test.tsx components/kanban/kanban-header-mobile.test.tsx components/kanban/mobile-menu-sheet.test.tsx components/task/mobile/task-sheet-selection-context.test.tsx components/task/mobile/session-task-switcher-sheet.test.tsx components/task/mobile/session-task-switcher-sheet-hooks.test.ts components/task/mobile/session-mobile-top-bar.test.tsx components/task/mobile/session-mobile-top-bar-repository.test.tsx components/task/task-layout-repository.test.tsx +``` + +### Browser checks + +The affected regression manifest contains 82 existing mobile specs. Together +with the two new navigation specs, the serial run exercised 242 scenarios: +237 passed initially; five failures were investigated. The final build fixes +workbench remounting during workspace metadata refreshes; the cached-chat +scroll-restoration case now passes. Four existing tests now address the correct +View options surface or title text node without weakening behavior assertions. + +The focused rerun passed all nine new navigation scenarios plus cached-chat, +Quick Chat configuration, tablet transition, and long-title truncation. The workspace-switch test now deletes its temporary workspace and restores +the seed workspace preference; its four pin cases and the Home-preference +case pass together (5/5). Thus all 242 mobile scenarios have passing evidence +across the broad run and focused reruns. The two compact-desktop Chromium +scenarios also passed (2/2). + +Phone app navigation, task-title chrome, and View options screenshots were +inspected at 393px and 767px. Pin, long-name truncation, error/denial and recovery +screenshots were inspected. Browser assertions cover 44px targets, viewport +containment, focus return, 768px/820px wider presentation, navigation history, +workspace isolation, and task-creation drafts surviving rotation. + +Commands (from `apps/web`, after a fresh `pnpm run build:e2e`): + +```bash +E2E_PORT_OFFSET=29 xargs -d '\n' pnpm e2e:run --host --no-build --project mobile-chrome tests/layout/mobile-unified-navigation.spec.ts tests/layout/mobile-navigation-pins.spec.ts < ../../docs/plans/unified-mobile-navigation/affected-mobile-specs.txt +E2E_PORT_OFFSET=29 pnpm e2e:run --host --no-build --project mobile-chrome tests/chat/mobile-auto-scroll-toggle.spec.ts:109 tests/chat/mobile-quick-chat-entry.spec.ts:90 tests/kanban/mobile-display-settings-groups.spec.ts:132 tests/settings/mobile-startup-page.spec.ts:26 tests/task/mobile-task-topbar-long-title.spec.ts tests/layout/mobile-unified-navigation.spec.ts tests/layout/mobile-navigation-pins.spec.ts +E2E_PORT_OFFSET=29 pnpm e2e:run --host --no-build --project mobile-chrome tests/layout/mobile-navigation-pins.spec.ts tests/settings/mobile-startup-page.spec.ts:26 +E2E_PORT_OFFSET=29 pnpm e2e:run --host --no-build --project chromium --config e2e/navigation-check.playwright.config.ts tests/layout/compact-desktop-responsive.spec.ts +``` + +The desktop command used a temporary config importing `e2e/playwright.config.ts` +and replacing only Chromium's first `testIgnore` expression with +`/\/mobile-[^/]*\.spec\.ts$/`. The normal expression matched `mobile-` in this +worktree's parent directory and selected zero tests. All remaining project and +runner settings were retained; the temporary config was removed afterward. + +Final documentation checks passed: catalog validation (273 decisions, 936 specs), +full specification lint, public-docs validation (46 pages) and its unit test, +and `git diff --check`. + +Runs are serial, with one worker. An initial two-shard attempt was discarded +because a restarted worker collided with the other shard's backend port; its +fixture failures are not counted as product results. No freshness guard was +bypassed. No commit, push, PR, or deployment was performed. + +## Risks + +- Generic Task views push navigation must not replace in-workbench switching. +- Duplicate drawer controllers can lose focus or task-dialog drafts. +- Extracting listing options can lose search, saved-view recovery, or plugin + actions; each existing action must retain a visible home. +- Old workspace caches must never leak task shortcuts after a switch or denial. +- Many existing tests encode the old hamburger meaning; update entry points + and retain behavior assertions rather than aliasing the old meaning. + +## Revision completion (2026-09-18) + +Task 03 is done: 100 distinct focused unit tests and 66 distinct browser cases +have passing results. Typecheck, targeted ESLint, build, i18n validation/ratchet, +specification/public-doc checks, and diff checks passed. The existing isolated +Tailscale preview was refreshed and smoke-tested with its seed preserved. +See Task 03 for commands, logs, regression fixes, and shutdown instructions. + +## Task 04 completion (2026-09-19) + +Home is the only phone listing destination. The collapsible Tasks heading +matches Utilities and retains its independent plus. Automations and integration +settings remain discoverable, including empty workspaces. 71 focused unit tests, +60 mobile browser cases and two desktop cases passed, with startup/navigation +timeouts resolved by focused reruns. Typecheck, scoped lint, i18n, production +build and documentation checks passed. Task 04 records exact cases and logs. + +The isolated preview at http://100.105.155.17:48439 contains the final build +and passed direct Tailscale smoke checks. Main :9998 was untouched. Stop only +this preview with `python3 /tmp/kandev-mobile-menu-test-hw9njt_n/stop.py`. + +## Task 05 completion (2026-09-19) + +Quick Chat and Quick terminal now form a labelled two-column row directly under +Home. Integrations starts collapsed, exposes providers/settings on expansion, +and follows Automations before Utilities. Settings precedes Stats on phones. +Plugin controls, metrics, workspace gates and wider composition retain their +existing behavior. 46 focused units and 29 distinct browser cases passed, along +with typecheck, scoped lint, i18n, build and docs gates. Task 05 records the +animation-aware test correction and exact verification results. The populated +preview remains at http://100.105.155.17:48439; main :9998 was not changed. diff --git a/docs/plans/unified-mobile-navigation/task-01-shared-navigation.md b/docs/plans/unified-mobile-navigation/task-01-shared-navigation.md new file mode 100644 index 0000000000..5d6539423d --- /dev/null +++ b/docs/plans/unified-mobile-navigation/task-01-shared-navigation.md @@ -0,0 +1,161 @@ +--- +id: "01-shared-navigation" +title: "Unify navigation and preserve entry points" +status: done +wave: 1 +depends_on: [] +plan: plan.md +requirements: + - REQ-UI-MOBILE-MENU-001 + - REQ-UI-MOBILE-MENU-002 +acceptance_criteria: + - AC-UI-MOBILE-MENU-001.1 + - AC-UI-MOBILE-MENU-001.2 + - AC-UI-MOBILE-MENU-001.3 + - AC-UI-MOBILE-MENU-001.4 + - AC-UI-MOBILE-MENU-001.5 + - AC-UI-MOBILE-MENU-001.6 + - AC-UI-MOBILE-MENU-002.1 + - AC-UI-MOBILE-MENU-002.2 + - AC-UI-MOBILE-MENU-002.3 +system_design: + - ../../specs/ui/system-design/unified-mobile-navigation.md +--- + +# Task 01: Unify navigation and preserve entry points + +## Summary + +Deliver one functional phone navigation contract across current shells. +Integrate the shared app menu, task-title picker, and listing-options entry +together so no capability loses its entry point between changes. + +## In scope + +- Shared phone Drawer with manifest-based navigation, workspace picker, local + navigation sections, existing workspace actions, and hoisted dialogs. +- Task-title trigger and explicit selection/handoff callbacks to the existing + task-switcher owner; no duplicate full task picker on the task workbench. +- Local View options and existing listing/search/saved-view behaviors. +- Localization, affected test-entry migration, rendered checks, public docs. + +## Out of scope + +Pinned shortcut content (Task 02), task mutation redesign, new persistence, +backend changes, desktop/tablet redesign, publication. + +## Acceptance + +- All covered menu/title/options criteria pass through their actual phone + entry points while existing desktop/tablet behavior remains available. +- Existing dialogs retain drafts/focus, task history retains origin policy, + and workspace-mode/permission gates remain enforced. +- Required checks pass; screenshots match the structural previews; all changed + regression scenarios run and results are recorded. + +## ASCII UI preview + +### UI-01 / UI-02 / UI-03 / UI-05: Shared shell and retained contexts + +```text +Phone listing: [Context] [Menu] Phone task: [Back] [Task v] [Menu] + [View options] [Session v] + +Menu -> Workspace / Destinations / Local nav / Actions / Utilities +Task v -> existing task drawer and row actions +View options -> listing modes / search / display / saved-view actions + +Desktop: [Existing sidebar] | [Existing header and Dockview] +Tablet: existing composition +``` + +Use the full [UI-01 through UI-05 previews](plan.md#ascii-ui-preview), except +UI-04 and the optional pins block. Maps to every criterion in this work order. +Menus have a fixed heading and one internal scroller. Phone hit targets are +44px minimum; long title truncation cannot hide the hamburger or picker. + +## Verification + +From the repository root. Bootstrap once in a fresh worktree. Use TDD for changed +logic and new E2E; initially run the narrow new test to observe the intended +failure. Commands below are the final checks after implementation. The managed +runner builds current assets and enforces bounded workers. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/web && pnpm exec vitest run components/navigation/app-nav-sheet.test.tsx components/kanban/kanban-header-mobile.test.tsx components/kanban/mobile-menu-sheet.test.tsx components/task/mobile/session-mobile-top-bar.test.tsx) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/layout/mobile-unified-navigation.spec.ts) +python3 - <<'PY' +from pathlib import Path +import subprocess +paths = Path('docs/plans/unified-mobile-navigation/affected-mobile-specs.txt').read_text().splitlines() +assert paths and all((Path('apps/web/e2e') / p).is_file() for p in paths) +subprocess.run(['pnpm', 'e2e:run', '--project', 'mobile-chrome', *paths], cwd='apps/web', check=True) +PY +(cd apps/web && pnpm e2e:run --project chromium tests/layout/compact-desktop-responsive.spec.ts) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check +``` + +Run targeted ESLint on changed TS/TSX files, and any additional changed unit +suite beyond the four named above. Record their exact paths and commands in +Results. Do not overlap E2E runs. Render phone menu/title/options at the +configured mobile device and 767px; check wider presentation at 768px and 820px. +Inspect screenshots and focus/scroll behavior. Include task title loading, +long names, Office/Settings navigation, and task-dialog rotation. + +## Implementation files + +Paths relative to `apps/web/`: + +- `components/navigation/app-nav-sheet.tsx`, `app-nav-trigger.tsx`, + `app-nav-sections.tsx`, `use-task-view-navigation.tsx`, `destination-rows.tsx`. +- `components/kanban/kanban-header-mobile.tsx`, `mobile-menu-sheet.tsx`, + `mobile-listing-menu-actions.tsx`; obsolete `mobile-listing-menu-button.tsx` + is removed. `MobileMenuSheet` retains wider behavior and has a phone listing-only mode. +- `components/task/mobile/session-mobile-top-bar.tsx`, + `session-mobile-layout.tsx`, `session-tablet-layout.tsx`, `responsive-task-picker.tsx`, + `task-sheet-selection-context.tsx`, `session-task-switcher-sheet.tsx`, and + `components/task/task-layout.tsx`. +- Relevant tests and E2E helpers/page objects; locale catalogs in + `src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/` and generated pseudo locale. +- Repository `docs/public/sessions-and-review.md`; compatibility guidance in + `apps/web/AGENTS.md` if its phone-navigation description changes. + +## Dependencies + +None. Existing GitHub Task views behavior is a compatibility input, not an +unfinished dependency. + +## Risks + +Menu and task controllers compete for focus; wrong callback changes browser +history; option extraction hides recovery/creation controls. Search all old +selectors before changing them and preserve the old tests' user outcomes. + +## Parallelism + +sequential + +## Inputs + +- [Requirements](../../specs/ui/requirements/unified-mobile-navigation.md), + [design](../../specs/ui/system-design/unified-mobile-navigation.md). +- Existing `AppNavSheet`, `MobileMenuSheet`, `SessionTaskSwitcherSheet`, + `mobile-kanban-topbar.spec.ts`, and `mobile-task-view-access.spec.ts`. +- Root/scoped AGENTS, mobile-parity, tdd, e2e, and docs-maintainer skills. + +## Results + +Implemented. The [plan verification results](plan.md#verification-results) record +exact commands and outcomes: 109 focused unit tests pass, and all 242 affected +mobile scenarios have passing evidence across the broad run and focused reruns. Both compact-desktop browser checks +also pass. +Typecheck, targeted ESLint, translation completeness, and i18n ratchet pass. +Phone screenshots were inspected at 393px and 767px, including menu/options +containment and pin loading/error/denial states. Task creation survives rotation; +workspace metadata refreshes preserve the workbench and cached chat scroll state. +No backend contract or preference storage was added. diff --git a/docs/plans/unified-mobile-navigation/task-02-pinned-shortcuts.md b/docs/plans/unified-mobile-navigation/task-02-pinned-shortcuts.md new file mode 100644 index 0000000000..312bc33166 --- /dev/null +++ b/docs/plans/unified-mobile-navigation/task-02-pinned-shortcuts.md @@ -0,0 +1,129 @@ +--- +id: "02-pinned-shortcuts" +title: "Add bounded pinned-task shortcuts" +status: done +wave: 2 +depends_on: ["01-shared-navigation"] +plan: plan.md +requirements: + - REQ-UI-MOBILE-MENU-003 +acceptance_criteria: + - AC-UI-MOBILE-MENU-003.1 + - AC-UI-MOBILE-MENU-003.2 + - AC-UI-MOBILE-MENU-003.3 +system_design: + - ../../specs/ui/system-design/unified-mobile-navigation.md +--- + +# Task 02: Add bounded pinned-task shortcuts + +## Summary + +Add up to three workspace-authorized pinned tasks to the shared phone menu. +Reuse existing pin order and task data, preserve route history, and keep app +navigation usable when task reads fail. + +## In scope + +- Pure eligible-pin projection and lazy menu content with current workspace + read state, localized loading/error/retry, and no-pin omission. +- Shortcuts use Task 01's surface-aware selection callback; Task views remains + available for saved filters and full task-list access. +- Unit and E2E coverage for isolation, read races/errors, cap/order, pin updates, + history, and containment; update public navigation instructions. + +## Out of scope + +Task row redesign, pin mutation controls, new storage or APIs, Office task pins, +new recency tracking, new navigation ownership, and publication. + +## Acceptance + +- Projection excludes every ineligible candidate before applying the three-row + cap, preserves pin order, and never writes preferences. +- Workspace pending/denied/error states cannot expose foreign or stale task + names; global navigation and the existing Task views path remain usable. +- Shortcut history and focus match the originating surface; focused checks and + rendered preview comparison pass. + +## ASCII UI preview + +### UI-04: Phone pins and unavailable data + +```text +Global destinations +Pinned tasks [Loading tasks...] + Fix checkout or [Could not load tasks] [Retry] + Update docs + Review onboarding +Workspace actions / Utilities +``` + +See [UI-04 and UI-01](plan.md#ascii-ui-preview). Maps to -003.1 through .3. +The menu retains one scroller. Empty pins omit the section; denial reveals no +cached titles. Desktop/tablet stay as UI-05 with no new shortcut section. + +## Verification + +From repository root, after Task 01's dependency bootstrap. Use TDD for the +projection and recovery behavior. The files below are created in this task. + +```bash +(cd apps/web && pnpm exec vitest run components/navigation/mobile-pinned-task-items.test.ts components/navigation/mobile-pinned-tasks.test.tsx components/navigation/app-nav-sheet.test.tsx) +(cd apps/web && pnpm exec eslint components/navigation/mobile-pinned-task-items.ts components/navigation/mobile-pinned-tasks.tsx components/navigation/app-nav-sheet.tsx) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/layout/mobile-navigation-pins.spec.ts tests/layout/mobile-unified-navigation.spec.ts tests/github/mobile-task-view-access.spec.ts tests/layout/mobile-sidebar-read-recovery.spec.ts) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Cover more than three pins, duplicate/missing/deleted/archived/foreign IDs, +workspace switching with delayed responses, denial and retry, unpinning while +open, current-task shortcut selection, and non-task browser Back. Inspect the +loaded/empty/error phone screenshots against UI-04 and verify safe-area and +single-scroll containment with a long task title. + +## Implementation files + +- `apps/web/components/navigation/mobile-pinned-task-items.ts`, + `mobile-pinned-tasks.tsx`, `mobile-task-shortcuts.tsx`, and their tests. +- `apps/web/components/navigation/app-nav-sheet.tsx`, `app-nav-sections.tsx` + and callback types introduced by Task 01. +- `apps/web/e2e/tests/layout/mobile-navigation-pins.spec.ts`, relevant locales, + `docs/public/sessions-and-review.md`, and this package's result/status fields. + +## Dependencies + +Task 01 supplies the shared shell and origin-aware selection contract. + +## Risks + +Cached task presence is not authorization. Share workspace read freshness and +identity checks instead of introducing a parallel task fetch or inferred scope. + +## Parallelism + +sequential + +## Inputs + +- [Requirements](../../specs/ui/requirements/unified-mobile-navigation.md), + [design](../../specs/ui/system-design/unified-mobile-navigation.md). +- `useSidebarTaskPrefs`, `useWorkspaceSidebarTasks`, existing task-switcher + workspace read recovery, and Task 01's callbacks. + +## Results + +Implemented. The [plan verification results](plan.md#verification-results) record +exact commands and outcomes: 109 focused unit tests pass, and all 242 affected +mobile scenarios have passing evidence across the broad run and focused reruns. Both compact-desktop browser checks +also pass. +Typecheck, targeted ESLint, translation completeness, and i18n ratchet pass. +Phone screenshots were inspected at 393px and 767px, including menu/options +containment and pin loading/error/denial states. Task creation survives rotation; +workspace metadata refreshes preserve the workbench and cached chat scroll state. +No backend contract or preference storage was added. diff --git a/docs/plans/unified-mobile-navigation/task-03-embedded-tasks.md b/docs/plans/unified-mobile-navigation/task-03-embedded-tasks.md new file mode 100644 index 0000000000..e7ba138e5c --- /dev/null +++ b/docs/plans/unified-mobile-navigation/task-03-embedded-tasks.md @@ -0,0 +1,256 @@ +--- +id: "03-embedded-tasks" +title: "Embed Tasks and move options to listing titles" +status: done +wave: 3 +depends_on: ["01-shared-navigation", "02-pinned-shortcuts"] +plan: plan.md +requirements: + - REQ-UI-MOBILE-MENU-004 + - REQ-UI-MOBILE-MENU-005 +acceptance_criteria: + - AC-UI-MOBILE-MENU-004.1 + - AC-UI-MOBILE-MENU-004.2 + - AC-UI-MOBILE-MENU-004.3 + - AC-UI-MOBILE-MENU-005.1 + - AC-UI-MOBILE-MENU-005.2 + - AC-UI-MOBILE-MENU-005.3 +system_design: + - ../../specs/ui/system-design/unified-mobile-navigation.md +--- + +# Task 03: Embed Tasks and move options to listing titles + +## Summary + +Apply user feedback from the seeded first iteration: embed the actual collapsible +Tasks sidebar in the middle of the shared hamburger; remove dedicated navigation +pins and Task views. Open listing options only from the top-level mode dropdown. + +## Scope + +- Reuse sidebar task data, views, filters, actions, and dialog controllers through + an inline presentation. Preserve the standalone title picker and navigation + history. Default expanded, with disclosure state local to the mounted host. +- Remove the separate View options button; give Kanban/List/Threads the same + listing-context entry and include Threads saved views in the options flow. +- Replace shortcut-specific tests with embedded-sidebar scenarios, migrate + affected selectors, update translations and public instructions, and refresh + the isolated test instance when implementation is validated. + +## Exclusions + +No backend/schema changes, new collapse persistence, global destination redesign, +desktop sidebar redesign, or change to existing pin preferences. Do not touch the +main instance on port 9998. Preserve completed Tasks 01/02 results as history. + +## Acceptance + +1. AC-UI-MOBILE-MENU-004.1-.3: the full sidebar appears inline in the shared + menu, collapses without side effects, retains actions/recovery, and shares + the outer scroller. No separate pinned shortcuts or Task views row remains. +2. AC-UI-MOBILE-MENU-005.1-.3: all three listing-title dropdowns open the + appropriate options; the separate button is absent, and Threads retains all + saved-view editing/recovery through the options surface. +3. Focus, browser history, task dialogs across closure/rotation, workspace + isolation, long-title geometry, and desktop/tablet composition remain intact. + +## ASCII UI preview + +```text +Phone listing: +[Workspace / Kanban v] [Menu] +[Listing content, without an options toolbar row] + +Kanban / Threads / List v -> View options + Modes | applicable search/display | saved views + +Shared hamburger: + Menu [X] + Workspace v + Global and page navigation + Tasks v [+] + Saved view v / filters + Task tree and existing row actions + Workspace actions + Plugins / integrations + Utilities + +Tasks > hides the inline body, leaving the heading and other sections. +Desktop/tablet: existing composition. Phone task title: existing task picker. +``` + +The menu owns one content scroller. Standalone controls have 44px phone targets. + +## Implementation sequence + +1. Add failing component/browser coverage for inline collapse, absence of the + two removed sections, header options entry, and Threads saved-view access. +2. Extract the reusable sidebar controller/body boundary and add inline menu + presentation. Keep action dialogs outside unmounting drawer content; keep + requested controllers above responsive branch changes. +3. Rewire listing titles, embed Threads view controls, remove obsolete shortcut + code/locales, migrate entry-point tests, and validate the rendered result. + +## Verification + +From `apps/web`, use the managed runner, one worker, no overlapping browser runs. +Create `mobile-navigation-tasks.spec.ts` and remove the superseded pins-only suite. + +```bash +pnpm exec vitest run components/navigation/app-nav-sheet.test.tsx components/kanban/kanban-header-mobile.test.tsx components/kanban/mobile-menu-sheet.test.tsx components/threads/threads-view-controls.test.tsx components/task/mobile/session-task-switcher-sheet.test.tsx components/task/mobile/session-task-switcher-sheet-hooks.test.ts components/task/mobile/task-sheet-selection-context.test.tsx components/task/task-layout-repository.test.tsx +pnpm run typecheck +pnpm run i18n:check +pnpm run i18n:ratchet +pnpm e2e:run --host --project mobile-chrome tests/layout/mobile-unified-navigation.spec.ts tests/layout/mobile-navigation-tasks.spec.ts tests/layout/mobile-sidebar-read-recovery.spec.ts tests/github/mobile-task-view-access.spec.ts tests/task/mobile-sidebar-task-actions.spec.ts tests/task/mobile-threads-view.spec.ts tests/task/mobile-task-list-search.spec.ts tests/kanban/mobile-display-settings-groups.spec.ts tests/settings/mobile-startup-page.spec.ts tests/chat/mobile-auto-scroll-toggle.spec.ts +``` + +Run targeted ESLint and any new extracted-controller unit suites. Inspect actual +393px and 767px screenshots, collapse/long-list scroll behavior, and 768px/820px +presentation. Run `compact-desktop-responsive.spec.ts` in Chromium using the +filename-anchored temporary config workaround documented in the parent plan if +the worktree path still matches the project's broad `mobile-` exclusion. +Update the affected-spec manifest for moved controls; preserve behavior assertions. +Run spec/public-doc validation and `git diff --check` from the repository root. + +## Files and dependencies + +- `apps/web/components/navigation/app-nav-sheet.tsx`, `app-nav-sections.tsx`, + `use-task-view-navigation.tsx`, and obsolete `mobile-pinned-*`/shortcut adapters. +- `apps/web/components/task/mobile/session-task-switcher-sheet.tsx`, its + controller/dialog helpers, `responsive-task-picker.tsx`, and `task-layout.tsx`. +- `apps/web/components/kanban/kanban-header-mobile.tsx`, `mobile-menu-sheet.tsx`; + `apps/web/components/threads/threads-view-controls.tsx` and view-list/editor. +- Related unit/E2E tests, locales, web AGENTS, public navigation instructions. + +## Risks + +A body rendered inside the drawer must not own persistent child dialogs. +Nested task scrolling can strand utilities; sidebar reads cannot show old +workspace titles. Threads currently owns a separate saved-view picker, so merely +changing its label would lose mode/display access. Preserve that functionality +through the shared options surface instead. + +## Parallelism + +Sequential in the primary session; no delegation authorized. + +## Results + +Implemented on 2026-09-18 following the user's explicit implementation request. + +- Embedded the full sidebar through the persistent app-shell controller and a + menu portal. Collapse survives menu closure; task dialogs survive rotation. +- Removed dedicated navigation pins and Task views. Listing dropdowns open + options; Threads saved views share that surface. Board pagination remains + beside the dropdown, with sync status and recovery preserved. +- Red: 2 header unit cases and 4 browser cases failed before implementation. + Green: 100 distinct focused unit tests (95 in the main run, 5 additional + Threads-page cases), typecheck, targeted ESLint, production build, i18n checks + and new-code ratchet passed. No new public copy was required. +- Browser validation initially found missing Threads pagination and stale + saved-view trigger assertions. Restored the separate pagination slot and + moved view-name assertions into the options surface without dropping behavior + checks. Final browser results follow below. +- Inspected 393px and 767px screenshots, full-list scrolling, and the live seeded + workbench. Public-doc tests/validator and specification validators passed. +- Refreshed the existing isolated preview at http://100.105.155.17:48439, + preserving its seeded workspaces/tasks and separate database. Direct Tailscale + browser smoke passed for menu collapse, header options, and task workbench. + The main instance on port 9998 was not modified. Shutdown: + `python3 /tmp/kandev-mobile-menu-test-hw9njt_n/stop.py`. + +Revision logs: `/tmp/mobile-menu-revision-*`, `/tmp/mobile-menu-pagination-*`, +`/tmp/mobile-menu-final-*`, and `/tmp/mobile-menu-preview-smoke.log`. + +Final browser result: 64 distinct mobile scenarios passed across the main run +and the final 14-case Threads/header rerun (60/64 initially, with all four +failures corrected). Both compact-desktop Chromium cases passed. The temporary +filename-anchored browser config was removed. Total: 66 distinct browser cases. + + +## PR review follow-up + +- Reconciled current-main task move callbacks and workspace-scoped saved-view + settings with the embedded task menu. +- Bridged archived-task and port-forwarding page context through the app-menu + outlet, preserving the current archived task in views that exclude archives. +- Retained the task-picker key during transient null workspace metadata; a + different resolved workspace still resets its drafts. +- Added explicit pointer styling to the new interactive section controls and + reconciled the older task-view/chrome documents with the embedded menu. +- Regression unit tests failed first for lost context and picker draft loss, + then passed after the fixes. The focused task picker/menu run passed 30 tests, + and the layout regression suite passed 4 tests. Merge validation passed 57 + unit tests and 20 phone browser scenarios covering embedded navigation, + sidebar saved views, and task moves. Typecheck, focused ESLint, specification, + documentation, and merged harness checks passed. +- Final managed phone regressions passed (2 tests): the embedded current archived + task remains visible, and deleting a Threads saved view then reopening options + starts on the view list. The latter disproves the reported stale editor: + closing the outer drawer unmounts its controls, resetting local editor state. +- Removed the duplicate integration spec entry and completed requirement mapping. + +Review verification logs: `/tmp/pr3830-merge-unit.log`, +`/tmp/pr3830-merge-e2e.log`, `/tmp/pr3830-context-red.log`, +`/tmp/pr3830-final-unit.log`, `/tmp/pr3830-picker-red.log`, +`/tmp/pr3830-picker-green.log`, and `/tmp/pr3830-review-e2e.log`. + +## CI follow-up + +- Updated phone tests that still targeted the retired hamburger task picker, + separate Task views action, or home-only menu container. +- Matched bottom navigation independently of the task title. Changes selectors + retain support for the numeric badge in their accessible name. +- Waited for drawer animations before checking touch geometry, used the existing + virtual-tree reveal helper for offscreen files, and disabled queue auto-run in + the oversized-message preview test so completion cannot remove its subject. +- Browser verification exposed a repository-source menu touch race: opening on + pointer-down or pointer-up could select the first item with the same touch's + synthetic click. The menu now opens on that click; desktop pointer and keyboard + activation retain Radix behavior. Extracted the existing menu into its own + component to keep the dialog within the file-size limit. No public contract or + copy changes are needed: choosing a source still requires selecting an item. +- Final focused navigation run: 15 phone tests passed, including every reported + obsolete-picker failure, Changes panel cases, workspace views, archive recovery, + Office nesting, and workflow session focus/queue ownership. +- Additional final browser cases passed: oversized-message previews, source + attachment and persistence, no-cursor file opening, saved-view geometry, and + terminal bottom spacing. The new touch regression failed before both event + ordering corrections; the final component suite passed all 15 tests. +- TypeScript, focused ESLint, and the frontend E2E build passed. + +Evidence: `/tmp/pr3830-ci-mobile-final.log`, `/tmp/pr3830-ci-extra.log` (the final +three cases), `/tmp/pr3830-touch-browser.log` (oversized message), +`/tmp/pr3830-touch-click-browser.log`, `/tmp/pr3830-touch-click-red.log`, and +`/tmp/pr3830-touch-extract-unit.log`. Earlier failures remain in these logs; +final cases are identified above rather than treating those runs as wholly green. + +### Second CI pass + +The full matrix found two remaining test entry points: a desktop spec that +switches to phone width still used an ambiguous Changes button, and Quick Chat +cancellation still used the retired home menu trigger. Scoped the Changes button +to navigation and moved both Quick Chat cases to `app-nav-trigger`. The responsive +case failed locally with the CI assertion before correction, then passed; both +Quick Chat cases passed. Logs: `/tmp/pr3830-responsive-red.log`, +`/tmp/pr3830-responsive-green.log`, and `/tmp/pr3830-quick-chat-green.log`. +A temporary filename-anchored config avoided the worktree path's `mobile-` +substring matching Playwright's desktop exclusion; it was removed after the run. + +### Sidebar customization merge reconciliation + +Merged main at `9e171c837` after GitHub reported a navigation composition conflict. +Saved sidebar layouts retain their configured order and visibility; phone quick +actions follow Home or remain at the top when Home is hidden. Embedded Tasks and +Utilities remain reachable, Home covers listing modes, and custom layouts do not +duplicate the default automation/integration sections. Added browser coverage for +both Home visibility states and updated the paired system design. + +Validation: 37 focused unit tests and all 10 phone browser cases passed; typecheck, +focused ESLint, documentation catalog and specification lint passed. The first +backend build failed because the shared Go cache lost object files. Retrying with +an isolated task cache completed the build and browser checks without retries. +Evidence: `/tmp/pr3830-merge2-unit.log`, `/tmp/pr3830-merge2-types.log`, +`/tmp/pr3830-merge2-lint.log`, `/tmp/pr3830-merge2-docs.log`, and +`/tmp/pr3830-merge2-e2e-retry.log`. diff --git a/docs/plans/unified-mobile-navigation/task-04-menu-hierarchy.md b/docs/plans/unified-mobile-navigation/task-04-menu-hierarchy.md new file mode 100644 index 0000000000..c23a1539b2 --- /dev/null +++ b/docs/plans/unified-mobile-navigation/task-04-menu-hierarchy.md @@ -0,0 +1,201 @@ +--- +id: "04-menu-hierarchy" +title: "Simplify Home and complete menu sections" +status: done +wave: 4 +depends_on: ["03-embedded-tasks"] +plan: plan.md +requirements: + - REQ-UI-MOBILE-MENU-006 +acceptance_criteria: + - AC-UI-MOBILE-MENU-006.1 + - AC-UI-MOBILE-MENU-006.2 + - AC-UI-MOBILE-MENU-006.3 + - AC-UI-MOBILE-MENU-006.4 +system_design: + - ../../specs/ui/system-design/unified-mobile-navigation.md +--- + +# Task 04: Simplify Home and complete menu sections + +## Summary + +Remove redundant phone Tasks/Threads route buttons. Make the embedded Tasks +heading match Utilities while retaining its plus action. Expose Automations and +keep Integrations discoverable in unconfigured workspaces. + +## Scope and exclusions + +Own phone navigation presentation, workspace-scoped automation read reuse, +integration setup discoverability, tests, localized copy, and public instructions. +Preserve desktop/tablet composition, Office local navigation, palette/routes, +provider gates, task sidebar state and dialogs. No credential changes, automatic +runs, new persistence, or backend APIs. Main instance :9998 is excluded. + +## Acceptance + +1. Home alone represents listing routes; mode selection and current state work + across Kanban/List/Threads without altering saved preferences (.1). +2. Tasks visually matches Utilities, with collapse and a separate plus that + preserve drafts, touch size, focus and one-scroller geometry (.2). +3. Automations list/detail/setup and integration setup/provider navigation work + with configured, empty, loading, failed and workspace-switch states (.3-.4). + +## ASCII UI preview + +UI-04: Phone hamburger, regular workspace; Tasks expanded. + +```text +Menu x +Workspace [Pocket Planner v] +[Home] +--------------------------------------- +Tasks v + + [Saved views / filters] + Task rows +[Quick Chat] [Quick terminal] +--------------------------------------- +Automations > [Open list] +--------------------------------------- +Integrations + Configured provider links + [Integration settings] +--------------------------------------- +Utilities + [Stats] [Settings] [Theme] ... +``` + +Tasks collapsed: heading and plus remain, body hidden. Automations expanded: +existing names/status rows and setup action; empty shows setup, failed shows +retry. Integrations empty: setup/settings remains, provider links absent. +The Tasks and Utilities heading text aligns; dividers/spacing share tokens. +The menu heading stays fixed; all sections share one vertical scroller. + +UI-04D: Desktop/tablet keep their current sidebar and palette composition. +This is a phone-only menu revision. See the full preview in [plan](plan.md). + +## Implementation sequence + +1. Add failing navigation tests for redundant links, Home current state, + collapsible heading plus behavior, and missing/empty sections. +2. Implement phone presentation using shared domain hooks, verify permission and + workspace boundaries, and migrate affected navigation tests. +3. Validate screenshots and behavior, update public instructions, then refresh + only the existing isolated preview and smoke-test its Tailscale URL. + +## Verification + +Run from repository root; the browser runner builds current assets and uses one +worker. Add the new mobile scenario file before running its command. + +```bash +(cd apps/web && pnpm exec vitest run components/navigation/app-nav-sheet.test.tsx components/navigation/destination-rows.test.tsx components/navigation/mobile-automations-section.test.tsx components/integrations/integrations-menu.test.ts components/app-sidebar/sections/automations-section.test.tsx lib/navigation/core-destinations.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && E2E_PORT_OFFSET=29 pnpm e2e:run --host --project mobile-chrome tests/layout/mobile-menu-hierarchy.spec.ts tests/layout/mobile-navigation-tasks.spec.ts tests/layout/mobile-unified-navigation.spec.ts tests/github/mobile-task-view-access.spec.ts tests/settings/mobile-startup-page.spec.ts tests/mobile-automation-detail.spec.ts) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Run targeted ESLint on changed production files and any newly extracted section +unit tests. Search all phone tests for global Tasks/Threads links and migrate +only those entry points to the Home dropdown. Include every changed suite in +recorded checks. Use the established temporary filename-anchored Chromium config +for `compact-desktop-responsive.spec.ts`; remove it afterward. Inspect dark/light +393px and 767px screenshots and 768px composition, hit areas, overflow, plus +behavior while collapsed, and reachable Utilities after long lists. + +## Files likely touched + +- `apps/web/components/navigation/app-nav-sheet.tsx`, `app-nav-sections.tsx`, + `destination-rows.tsx` and their tests. +- `apps/web/components/task/mobile/task-picker-surface.tsx`. +- `apps/web/components/integrations/integrations-menu.tsx` and tests. +- A mobile automation section alongside navigation, reusing + `components/runs/use-workspace-automations.ts`, `use-automation-summaries.ts`, + `automation-rows.ts`, and `runs-view.ts`. +- Relevant mobile E2E files, locales, public docs and scoped guidance. + +## Dependencies and risks + +Task 03 is done. Do not remove manifest routes: desktop, palette, and deep links +still need them. Desktop automation reads are phone-gated, so direct component +reuse would miss live summaries. Preserve integration availability rules and +use setup navigation for unavailable providers. Distinguish an empty automation +list from a failed request; stale reads must not leak across workspaces. + +## Parallelism + +Sequential in the primary session. No delegation authorized. + +## Results + +Implemented and verified on 2026-09-19 after explicit user authorization. +Final results: 71 focused unit tests and 62 distinct browser cases passed. + +- Home-only phone destination with existing listing/palette routes retained. + Tasks and Automations use Utilities-aligned headings with transparent expanded + state, independent 44px actions, and the existing outer scroller. +- Phone Automations defers workspace reads until expansion, shares existing + domain hooks and status derivation, masks stale workspace rows, and offers + retry without exposing internal errors. Integrations retains settings while + configured-provider links keep existing availability filtering. +- Three new browser cases failed against Task 03 before implementation. + All 71 focused unit tests passed. Final changed navigation and automation + suites were rerun successfully (22 and 4 cases respectively). +- Typecheck, targeted ESLint, i18n validation/ratchet, production build, public + documentation checks, specification validation, and diff checks passed. + Translation generators' unrelated rewrites were removed. +- Backend sources were unchanged. Localization generation touched embedded + catalogs, requiring rebuilt host test binaries and the fixture plugin package + for E2E freshness. The unnecessary cross-platform build was stopped after + verifying its process identity; the host-only build completed. +- Refreshed http://100.105.155.17:48439 with the final build and preserved seed. + Direct Tailscale smoke passed after startup: Home-only menu, Tasks collapse, + Automations/setup, Integrations settings, dropdown options, seeded workbench. + Inspected the phone screenshots and corrected inherited expanded-button fill. + Main :9998 was not modified. Shutdown command remains + `python3 /tmp/kandev-mobile-menu-test-hw9njt_n/stop.py`. + +### Browser results + +60 distinct mobile cases passed across the initial batch and focused reruns. +The initial 54-case batch passed 47: four failures were backend readiness, +two were page/navigation timeouts, and one was the now-ambiguous partial +Settings selector after adding Integration settings. Exact accessible-name +matching corrected the affected settings-entry tests. The new combined +Home-route test was split into three independently bounded route cases. +The 13-case rerun passed 12; the GitHub case stalled on a blank page before +navigation UI appeared and passed alone (9.6 seconds). No product workaround +or timeout increase was introduced. Four additional Office/system selector +regressions passed in the focused run. + +All browser commands use `E2E_PORT_OFFSET=29 pnpm e2e:run --host --no-build` +from `apps/web`, one worker, after the successful final frontend build: + +- Initial mobile-chrome batch: `tests/layout/mobile-menu-hierarchy.spec.ts`, + `tests/layout/mobile-navigation-tasks.spec.ts`, + `tests/layout/mobile-unified-navigation.spec.ts`, + `tests/github/mobile-task-view-access.spec.ts`, + `tests/settings/mobile-startup-page.spec.ts`, + `tests/mobile-automation-detail.spec.ts`, `tests/kanban/mobile-kanban.spec.ts`. +- Focused mobile-chrome rerun: GitHub, Kanban, hierarchy and unified-navigation + files above, plus `tests/system/mobile-storage-maintenance.spec.ts`, + `tests/system/mobile-message-queue-settings.spec.ts`, + `tests/office/mobile-office-navigation.spec.ts`; grep + `Home owns|GitHub app navigation|Kanban navigation can|mobile menu exposes settings navigation|opens Storage from mobile navigation|mobile navigation reaches|mobile configuration lock|offers office sections|same menu from listings|separate listing controls|title picker switches`. +- Final mobile-chrome GitHub run: `tests/github/mobile-task-view-access.spec.ts` + with `--grep 'GitHub app navigation'`. + +Logs: `/tmp/menu4-browser.log`, `/tmp/menu4-rerun.log`, +`/tmp/menu4-github-final.log`; other checks use `/tmp/menu4-*.log`. + +Desktop: both cases in `tests/layout/compact-desktop-responsive.spec.ts` passed +with `--config e2e/navigation-check.playwright.config.ts --project chromium` +(`/tmp/menu4-desktop.log`). The temporary config anchored the existing mobile +filename exclusion because this worktree path contains `on-mobile-`; it was +removed after verification. Final E2E-file ESLint also passed. diff --git a/docs/plans/unified-mobile-navigation/task-05-action-first-menu.md b/docs/plans/unified-mobile-navigation/task-05-action-first-menu.md new file mode 100644 index 0000000000..7a1e8a9573 --- /dev/null +++ b/docs/plans/unified-mobile-navigation/task-05-action-first-menu.md @@ -0,0 +1,215 @@ +--- +id: "05-action-first-menu" +title: "Prioritize quick actions and collapse Integrations" +status: done +wave: 5 +depends_on: ["04-menu-hierarchy"] +plan: plan.md +requirements: + - REQ-UI-MOBILE-MENU-007 +acceptance_criteria: + - AC-UI-MOBILE-MENU-007.1 + - AC-UI-MOBILE-MENU-007.2 + - AC-UI-MOBILE-MENU-007.3 + - AC-UI-MOBILE-MENU-007.4 +system_design: + - ../../specs/ui/system-design/unified-mobile-navigation.md +--- + +# Task 05: Prioritize quick actions and collapse Integrations + +## Summary + +Group the two built-in quick actions directly under Home and make Integrations +collapsible like Automations. Keep the existing shared menu/controller and +workspace/provider boundaries. The populated isolated preview is already seeded. + +## In scope + +Phone-only action grouping, section ordering, Integrations disclosure, Settings +before Stats, localized label fit, focused regressions and refreshed preview. + +## Out of scope + +Backend/provider changes, saved disclosure preferences, new navigation routes, +desktop redesign, workspace-picker redesign, changing task/sidebar domain logic, +commits, pushes or delegation. + +## Acceptance + +1. Home precedes the two-column quick-action row, which precedes local navigation + and Tasks. Both launchers retain workspace, focus and activity behavior. + Named sections and utilities follow the agreed order (AC .1 and .2). +2. Integrations starts collapsed, toggles accessibly, and reveals configured + providers and settings without stale workspace entries. Empty still offers + settings. Wider default behavior is retained (AC .3 and .4). +3. Phone geometry, task/dialog lifetime and provider gates pass targeted tests; + final populated Tailscale preview demonstrates both integration and automation + links. Main :9998 and the seeded workspaces/tasks are untouched (AC .4). + +## ASCII UI preview + +UI-05 phone, excerpt from the [full preview](plan.md#ascii-ui-preview): + +```text +[Home] +[Quick Chat] [Quick terminal] +Tasks v + + Task sidebar +Automations > [Open list] +Integrations > +Utilities + Settings / Stats / Theme / Support +``` + +Expanded Integrations: provider links then Integration settings. Empty expanded: +Integration settings only. Collapsed: heading/chevron only. UI-05D wider: retain +existing sidebar/menu composition. Order/grouping and touch sizes are required; +ASCII spacing is illustrative. The fixed title and sole content scroller remain. + +## Implementation sequence + +1. Add focused failing unit checks for ordering and disclosure. Add phone + scenarios to the existing hierarchy suite for quick actions and populated/ + empty Integrations, preserving existing browser assertions by expanding the + section before querying descendants. +2. Separate built-in quick-action presentation from the other content in + MobileListingMenuActions; retain all launch hooks and caller defaults. Wire + phone grouping, explicit Integrations disclosure and local utility ordering. +3. Run checks, inspect 393px/767px screenshots in both themes and translated + label wrapping, refresh only the existing isolated frontend and smoke test. + +## Files likely touched + +- `apps/web/components/navigation/app-nav-sheet.tsx` and `.test.tsx`. +- `apps/web/components/navigation/app-nav-sections.tsx`. +- `apps/web/components/kanban/mobile-listing-menu-actions.tsx` and a focused test + for any extracted quick-action boundary. +- `apps/web/components/integrations/integrations-menu.tsx`, existing `.test.ts`, + and a new `mobile-integrations-section.test.tsx` for rendered disclosure. +- `apps/web/e2e/tests/layout/mobile-menu-hierarchy.spec.ts`. +- `apps/web/e2e/tests/layout/mobile-unified-navigation.spec.ts`, existing quick + chat/terminal and plugin tests only where their navigation assumptions change. +- `docs/public/sessions-and-review.md` and scoped web guidance where affected. + +## Tests and verification + +Unit mapping: app-nav-sheet tests own AC .1/.2, rendered integrations tests own +AC .3/.4, existing integration resolver tests retain permission behavior. Use +real hooks/resolvers where practical and mock API/provider boundaries. + +Browser mapping: hierarchy suite owns AC .1-.4 with ordered bounding boxes, +44px targets, collapsed/expanded populated and empty integrations, keyboard +activation, and actual link/quick-action outcomes. Verify phone Home and seeded +workbench, workspace changes and 767/768 boundary. Include 393px/767px dark/light +screenshots and a long-label/pseudo-locale geometry check. Preserve plugin and +fallback metric access; only built-in quick actions enter the two-column row. + +Exact commands from repository root (add planned test before its command): + +```bash +(cd apps/web && pnpm exec vitest run components/navigation/app-nav-sheet.test.tsx components/navigation/destination-rows.test.tsx components/navigation/mobile-automations-section.test.tsx components/integrations/integrations-menu.test.ts components/integrations/mobile-integrations-section.test.tsx) +(cd apps/web && pnpm exec eslint components/navigation/app-nav-sheet.tsx components/navigation/app-nav-sections.tsx components/kanban/mobile-listing-menu-actions.tsx components/integrations/integrations-menu.tsx components/integrations/mobile-integrations-section.test.tsx e2e/tests/layout/mobile-menu-hierarchy.spec.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm run build:vite) +(cd apps/web && E2E_PORT_OFFSET=29 pnpm e2e:run --host --no-build --project mobile-chrome tests/layout/mobile-menu-hierarchy.spec.ts tests/layout/mobile-unified-navigation.spec.ts tests/chat/mobile-quick-chat-entry.spec.ts tests/terminal/mobile-quick-terminal.spec.ts tests/plugins/mobile-plugin-nav.spec.ts tests/plugins/mobile-plugin-topbar.spec.ts tests/integrations/mobile-integrations-nav.spec.ts) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Run the existing two compact-desktop cases with the Task 04 temporary +filename-anchored exclusion workaround, then delete that temporary config: + +```bash +(cd apps/web && E2E_PORT_OFFSET=29 pnpm e2e:run --host --no-build --config e2e/navigation-check.playwright.config.ts --project chromium tests/layout/compact-desktop-responsive.spec.ts) +``` + +Keep one browser worker; do not overlap suites or rebuild during a run. Add +exact commands/results for any newly extracted test or migrated suite before +completion. Refresh the preview from final assets; adapt its temporary smoke +script to expand Integrations and assert both sample automation links and GitHub. + +## Inputs and continuation context + +REQ-UI-MOBILE-MENU-007 and the paired design's Action-first composition section. +Task 04 is done (71 units, 62 browser cases); its results remain historical. +User confirmed order and explicitly requested collapsible Integrations. Initial +collapse is a routine consistency choice matching Automations, not new persistence. +No material unresolved question. UI owns reusable navigation presentation; +integration configuration and availability remain integration-owned. + +Preview: http://100.105.155.17:48439. Root: +`/tmp/kandev-mobile-menu-test-hw9njt_n`. Reapply only mock provider fixtures after +restart with `python3 /tmp/kandev-mobile-menu-test-hw9njt_n/enrich-menu.py` (it +retains existing automation IDs). Main :9998 must never be used. Shutdown: +`python3 /tmp/kandev-mobile-menu-test-hw9njt_n/stop.py`. + +## Dependencies and risks + +Task 04 complete. Moving the entire actions slot would move plugin controls and +metrics into the quick row; separate built-ins. Shared integrations also has +wider consumers; opt in explicitly. Partial Settings selectors can match +Integration settings; use exact names. Keep 44px targets and wrapping translated +labels. Do not remount task controllers or duplicate quick launch state. + +## Parallelism + +Sequential in the primary session; no delegation authorized. + +## Results + +Implementation authorized by user on 2026-09-19. Phone composition and +Integrations disclosure implemented. Three unit regressions failed before +production changes (quick-action ordering and collapsed integration behavior). +Final focused unit run: 46 tests passed across five files. After correcting +unit-only Testing Library option types, the two changed unit suites passed +again (26 tests). Typecheck, targeted ESLint, i18n validation/ratchet, production +build, public-doc tests/validation, specification validation and diff checks passed. + +27 distinct mobile browser cases and two compact-desktop cases passed. The +initial mobile batch passed 25; two new geometry checks measured different +frames of the opening drawer animation. Waiting for finite Web Animations +before reading bounds fixed the tests without changing product code or timeouts. +The focused rerun command was the same mobile runner with +`tests/layout/mobile-menu-hierarchy.spec.ts --grep 'quick actions precede'`. +The temporary desktop config was removed after both desktop cases passed. + +Screenshots and browser assertions cover 393px/767px, dark/light and Portuguese +label fit. Quick Chat and terminal flows, workspace context, task rotation, +provider navigation and plugin/metric access passed. The direct Tailscale preview +smoke passed on the final production bundle with the seeded GitHub connection +and both automations. Only frontend assets were refreshed, publishing index.html +last; the process and mock provider state remained intact. Main :9998 untouched. + +Browser logs: `/tmp/menu5-browser.log`, `/tmp/menu5-rerun.log`, +`/tmp/menu5-desktop.log`, `/tmp/menu5-preview.log`. Preview screenshots: +`/tmp/kandev-mobile-menu-test-hw9njt_n/action-first-menu.png` and +`/tmp/kandev-mobile-menu-test-hw9njt_n/populated-menu.png`. + +Logs: `/tmp/menu5-*.log`. The existing integrations browser test now explicitly +expands its section before navigating. Its suite is included above. + +### Collapsed spacing follow-up + +User screenshot exposed Utilities retaining desktop footer anchoring (`mt-auto`) +inside the growing phone drawer. A direct Tailscale browser reproduction measured +349px instead of the normal 24px at 393px width / 1200px height. The permanent +hierarchy regression checks 393px and 767px. Two initial managed runner attempts +stopped at backend readiness before executing the test; the already-running +isolated preview supplied the behavioral red evidence. The fix scopes the +automatic margin to non-phone menus. Final scoped ESLint and production build +passed. The managed browser regression passed (1 case, 393px and 767px), and +the direct Tailscale check measured the expected 24px gap at both widths. +Visual inspection at 393px confirmed consistent section spacing. Specification +catalog/lint and diff checks passed. Logs: `/tmp/menu-gap-{red,red2,green}.log`, +`/tmp/menu-gap-preview-{red,green}.log`, `/tmp/menu-gap-build.log`. + +Exact browser command from `apps/web`: +`E2E_PORT_OFFSET=29 pnpm e2e:run --host --no-build --project mobile-chrome tests/layout/mobile-menu-hierarchy.spec.ts --grep 'flexible space'`. +The isolated frontend was refreshed; seeded providers/automations and main :9998 +were untouched. diff --git a/docs/public/developer-tools.md b/docs/public/developer-tools.md index 8b93116292..33fc33a358 100644 --- a/docs/public/developer-tools.md +++ b/docs/public/developer-tools.md @@ -44,8 +44,8 @@ Use `@` for files, saved prompts, and the current plan. New task lookup is under Select **Quick Chat** beside **New Task** in the expanded sidebar, or select its standalone row in the collapsed sidebar. -On a phone, open the topbar menu in **Kanban**, **List**, or **Threads**, then -select **Quick Chat** or **Quick terminal**. The menu closes before the tool +On a phone, open the hamburger app menu from a listing, task workbench, or +shared page, then select **Quick Chat** or **Quick terminal**. The menu closes before the tool opens. The topbar menu button shows Quick Chat activity: a blue dot while a chat is running and a green dot when a reply is ready to read. Terminal tabs do not contribute to this activity indicator. diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md index d3a3d1130e..7cd9f58b7a 100644 --- a/docs/public/sessions-and-review.md +++ b/docs/public/sessions-and-review.md @@ -201,7 +201,7 @@ Messages show peer attribution, and Kandev gives the receiving agent hidden repl ## Use the workbench -Desktop panel groups can host agent chat, files, terminals, Changes, the task plan, previews, and GitHub pull-request detail. Use **+** to add a panel. Mobile exposes sessions, files, terminal, and changes through task navigation and sheets. Its task switcher opens as an inset bottom card, and the current-session control shows the active agent's icon and name. +Desktop panel groups can host agent chat, files, terminals, Changes, the task plan, previews, and GitHub pull-request detail. Use **+** to add a panel. Mobile exposes sessions, files, terminal, and changes through task navigation and sheets. On a phone, the hamburger opens the same app menu from Home, listings, and the workbench. Tap the task title and chevron to switch tasks; the picker opens as an inset bottom card. Tap the **Kanban**, **Threads**, or **List** title dropdown for view options, search, filters, and display settings. The app menu uses **Home** for all listing modes, with **Quick Chat** and **Quick terminal** directly below it. Its collapsible **Tasks** section contains saved views, filters, and task actions; the adjacent **+** creates a task even when the section is collapsed. **Automations** and **Integrations** start collapsed; expand their headings to browse automations or connected providers. Integrations includes settings even before a provider is connected. **Utilities** follows these sections, with Settings before Stats. The current-session control shows the active agent's icon and name. Press **Cmd+Shift+F** on macOS or **Ctrl+Shift+F** elsewhere to search the contents of every file in the active task workspace. Results are grouped by diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 90c23e9d6a..4729567ea5 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -550,16 +550,17 @@ On desktop and tablet, the header switches between **Kanban**, **Pipeline**, **Threads**, and **List**. Kanban and Pipeline show the same workflow steps in different layouts. Threads shows agent conversations side by side. Kandev remembers the last selected view in that browser on the current device. Phones -offer **Kanban**, **Threads**, and **List** in the topbar menu, with a native +offer **Kanban**, **Threads**, and **List** under **View options**, with a native Threads deck showing one conversation at a time. A saved desktop Pipeline preference is kept but shown as Kanban on the phone. Kanban and List share a compact phone header showing the workspace and current -mode. Tap that context or the menu button to change views, workspaces, or display -options, or return **Home**. In Threads, tap the view name to choose a saved -Threads view. Phone **Search tasks** lives in the menu: selecting it reveals -and focuses the search field below the header. Selecting it again hides the -field and clears the query. +mode. Tap the **Kanban**, **Threads**, or **List** title dropdown to open view options and change display settings, or tap the +Kanban/List context to open the same controls. The hamburger opens app +navigation, including the workspace picker and **Home**. In Threads, tap the +view name to choose a saved Threads view. Phone **Search tasks** lives under +**View options**: selecting it reveals and focuses the search field below the +header. Selecting it again hides the field and clears the query. Under **Settings → Preferences → Appearance → Startup Page**, choose a destination, then select **Save changes**: @@ -585,7 +586,7 @@ The **TASKS** list in the left sidebar has two time-based sort choices. These ch Choose **Last activity** when you want to review tasks by the least recent user or agent interaction. - Search matches tasks without changing their state. -- The display menu groups its controls into collapsible **Filters**, **Sort**, **Preview panel**, and, in **List**, **List rows** sections. Each section shows its current values while collapsed. Filters cover **Workflow**, **Repository**, and, in Kanban, **Priority**; registered plugin filters appear there when available. In Kanban/Pipeline, each workflow lane has a **Columns** menu outside these groups to hide individual steps. Unticking a step hides its column and tasks on that board, scoped to its own workflow, until you re-tick it. The optional **Auto-hide empty columns** setting collapses unoccupied steps without changing those manual choices; auto-hidden empty steps return as move destinations while a task is being moved, while manually hidden steps remain unavailable for pointer and bulk moves. On phones, open the existing menu drawer to expand the same display groups and change columns for the focused workflow. +- The display menu groups its controls into collapsible **Filters**, **Sort**, **Preview panel**, and, in **List**, **List rows** sections. Each section shows its current values while collapsed. Filters cover **Workflow**, **Repository**, and, in Kanban, **Priority**; registered plugin filters appear there when available. In Kanban/Pipeline, each workflow lane has a **Columns** menu outside these groups to hide individual steps. Unticking a step hides its column and tasks on that board, scoped to its own workflow, until you re-tick it. The optional **Auto-hide empty columns** setting collapses unoccupied steps without changing those manual choices; auto-hidden empty steps return as move destinations while a task is being moved, while manually hidden steps remain unavailable for pointer and bulk moves. On phones, tap the listing-title dropdown to open **View options** and expand the same display groups and change columns for the focused workflow. - In **List**, the display menu can enable **Show task details** to include available repository, description, pull-request, session, parent, review, and archive context in each row. This option is off by default and follows the user across devices. - **List** can group by **State**, **Workflow**, **Repository**, or **None**. - **List** can sort by updated time, created time, or title in either direction. diff --git a/docs/specs/ui/requirements/mobile-task-chrome.md b/docs/specs/ui/requirements/mobile-task-chrome.md index 432a67228c..aacf442ec0 100644 --- a/docs/specs/ui/requirements/mobile-task-chrome.md +++ b/docs/specs/ui/requirements/mobile-task-chrome.md @@ -53,3 +53,19 @@ viewport. - Changing backend task movement, Git operations, APIs, persistence, or permissions. - Removing or reorganizing other phone task-top-bar actions. + +## Navigation entry points + +The [unified phone navigation package](../../../plans/unified-mobile-navigation/plan.md) +changes the hamburger to app navigation and the task-title button to the existing +task picker. Its [design](../system-design/unified-mobile-navigation.md) +owns that entry-point change; existing task actions, saved-view controller +lifetime, history, and desktop/tablet guarantees here remain compatibility +requirements. Historical hamburger descriptions above describe the preceding composition; +the unified navigation design defines the current entry points. + +The September 2026 revision embeds the collapsible Tasks sidebar directly in +the shared phone menu, replacing the Task views action and dedicated pinned +shortcuts. Kanban/Threads/List title dropdowns open display options; Threads +saved-view editing remains inside that surface. See REQ-UI-MOBILE-MENU-004/005 +in the unified navigation requirements for the current composition. diff --git a/docs/specs/ui/requirements/mobile-task-view-access.md b/docs/specs/ui/requirements/mobile-task-view-access.md index 94a7afd6e5..699f21eafe 100644 --- a/docs/specs/ui/requirements/mobile-task-view-access.md +++ b/docs/specs/ui/requirements/mobile-task-view-access.md @@ -19,7 +19,7 @@ The shared phone navigation should expose the task-sidebar views available from #### Acceptance criteria -- **AC-UI-MOBILE-TASK-VIEWS-001.1:** Shared phone navigation in a Kanban workspace shall expose Task views. Selecting it shall close navigation and open the existing mobile task-view surface with its saved views and editor. Task dialogs opened from that surface shall retain their drafts across phone orientation changes. +- **AC-UI-MOBILE-TASK-VIEWS-001.1:** Shared phone navigation in a Kanban workspace shall embed a collapsible Tasks sidebar with saved views, filters, editor, task selection, and row actions. The task title shall open the separate task picker; no separate Task views action shall appear. Task dialogs opened from that surface shall retain their drafts across phone orientation changes. - **AC-UI-MOBILE-TASK-VIEWS-001.2:** Choosing a task shall navigate to that task and preserve browser Back to the originating page. Dismissal shall return focus to a visible navigation opener. GitHub queries, Threads views, and sidebar task views shall remain separate collections. ## Out of scope @@ -29,3 +29,18 @@ Office navigation changes, new task-view storage, and a global navigation redesi ## Implementation plans - [GitHub mobile parity](../../../plans/github-mobile-parity/plan.md) + +## Navigation entry points + +The [unified phone navigation package](../../../plans/unified-mobile-navigation/plan.md) +changes the hamburger to app navigation and the task-title button to the existing +task picker. Its [design](../system-design/unified-mobile-navigation.md) +owns that entry-point change; existing task actions, saved-view controller +lifetime, history, and desktop/tablet guarantees here remain compatibility +requirements. The unified navigation design defines the current entry points. + +The September 2026 revision embeds the collapsible Tasks sidebar directly in +the shared phone menu, replacing the Task views action and dedicated pinned +shortcuts. Kanban/Threads/List title dropdowns open display options; Threads +saved-view editing remains inside that surface. See REQ-UI-MOBILE-MENU-004/005 +in the unified navigation requirements for the current composition. diff --git a/docs/specs/ui/requirements/unified-mobile-navigation.md b/docs/specs/ui/requirements/unified-mobile-navigation.md new file mode 100644 index 0000000000..c946e7c457 --- /dev/null +++ b/docs/specs/ui/requirements/unified-mobile-navigation.md @@ -0,0 +1,215 @@ +--- +status: active +system: ui +created: 2026-09-15 +owners: + - Kandev +--- + +# Unified mobile navigation requirements + +## Overview + +The phone hamburger consistently opens app navigation. Task switching remains +a two-tap interaction through the task title. Listing-title dropdowns open page display controls. UI owns this reusable cross-page interaction; tasks, +workspaces, permissions, saved views, and navigation destinations retain their +existing domain owners. + +## Requirements + +### REQ-UI-MOBILE-MENU-001: Consistent app navigation + +**Intent:** Make the same navigation control predictable across phone pages. + +#### Acceptance criteria + +- **AC-UI-MOBILE-MENU-001.1:** Below 768 CSS pixels, the hamburger on Home, + Kanban, List, Threads, task workbenches, and shared page shells shall open + the same app-navigation surface with the same accessible name and relative + section order. It shall not directly open the task picker or display settings. +- **AC-UI-MOBILE-MENU-001.2:** The menu shall show the active workspace first, + followed by global destinations, a collapsible Tasks sidebar, workspace actions, + integrations/plugin destinations, and utilities. Available destinations and + their relative order shall depend on workspace mode and permissions, not the + originating page. The current destination shall be visibly and semantically + identified. Home shall represent all three listing modes; task selection is identified + inside the embedded Tasks section. +- **AC-UI-MOBILE-MENU-001.3:** Existing Office and Settings local navigation + shall remain reachable in a labelled section after global navigation. Office + workspaces shall not gain Kanban-only task views or task shortcuts. Workspace + selection shall retain existing access checks and route-recovery behavior. +- **AC-UI-MOBILE-MENU-001.4:** Phone listing pages shall open View + options from their top-level Kanban, Threads, or List dropdown, without a + separate View options button. It shall retain the applicable listing + modes, search toggle, sorting, grouping, display options, saved-view actions, + and their error/retry flows. Existing primary creation actions shall remain + reachable. The menu shall not duplicate those display controls. +- **AC-UI-MOBILE-MENU-001.5:** The phone menu shall use an inset bottom drawer, + a fixed heading, one vertical content scroller, bottom safe-area clearance, + and at least 44-by-44 CSS-pixel standalone controls. Long content shall remain + contained at phone widths including 767 CSS pixels, without document + horizontal overflow. Dismissal shall restore focus to its visible opener; + a subsequent dialog shall open after the menu closes without losing its draft. +- **AC-UI-MOBILE-MENU-001.6:** Tablet and desktop navigation and layout controls + shall retain their existing composition. Phone changes shall not overwrite + saved desktop listing preferences, including the Pipeline fallback, or + discard an already-open task dialog on a breakpoint change. + +### REQ-UI-MOBILE-MENU-002: Discoverable task switching + +**Intent:** Keep task switching fast after the hamburger becomes global. + +#### Acceptance criteria + +- **AC-UI-MOBILE-MENU-002.1:** The phone task title shall be a labelled button + with a visible downward chevron, expanded state, and a minimum 44 CSS-pixel + height and width. Tapping it shall open the existing task picker. Selecting + a visible task shall switch tasks in two taps from the workbench. +- **AC-UI-MOBILE-MENU-002.2:** Task selection shall retain the existing + workbench history policy, selected-session handling, panel fallback, saved + task views, filters, workspace context, and row-level task actions. Opening + or dismissing either menu shall not start a session or change the active task. +- **AC-UI-MOBILE-MENU-002.3:** A long or loading task title shall leave both + title picker and hamburger reachable without overlap. Task-list loading, + empty, and error states shall remain distinguishable, with existing retry + behavior. Dismissing the picker shall restore focus to its title trigger. + +### REQ-UI-MOBILE-MENU-003: Bounded pinned-task shortcuts (superseded) + +Superseded on 2026-09-17 by REQ-UI-MOBILE-MENU-004 after user testing. +The criteria below record the first iteration, not the revised target. + +**Intent:** Reach frequently used tasks from app navigation without browsing +the full task list. + +#### Acceptance criteria + +- **AC-UI-MOBILE-MENU-003.1:** In a Kanban workspace, the menu shall show up to + three distinct pinned, unarchived tasks belonging to the active workspace, + in existing pin order. Foreign, missing, deleted, and archived tasks shall + not appear. With no eligible pins, the shortcut section shall be absent. +- **AC-UI-MOBILE-MENU-003.2:** A shortcut shall close navigation and open its + task using the same navigation policy as the task picker for the originating + surface. Selecting a shortcut from a non-task page shall preserve browser Back + to that page. The existing Task views entry shall remain the full-list path, + preserving saved-view selection and editing. +- **AC-UI-MOBILE-MENU-003.3:** While workspace data is loading or inaccessible, + no shortcut from the preceding workspace shall appear. A failed task read + shall leave global navigation usable and offer a labelled retry; task-read + denial shall not expose cached task titles. Pin changes shall update the + section without resetting saved task filters or persisting a new order. + +### REQ-UI-MOBILE-MENU-004: Embedded task sidebar + +**Intent:** Browse and manage tasks directly within the shared navigation menu. + +#### Acceptance criteria + +- **AC-UI-MOBILE-MENU-004.1:** The middle of the phone hamburger menu shall + contain a collapsible Tasks section using the existing task sidebar. It shall + start expanded, collapse in place, and retain its disclosure state while its + host remains mounted. A separate pinned-shortcuts section and Task views + navigation action shall not appear. Existing sidebar pin behavior remains + part of the normal task list, not a second navigation section. +- **AC-UI-MOBILE-MENU-004.2:** The embedded sidebar shall retain saved views, + filters, task grouping, task selection, creation, and row actions. Its rows + shall share the menu's content scroller rather than create a nested vertical + task scroller. Collapsing Tasks shall leave app destinations and utilities + accessible without changing task/view state. The task-title picker remains. +- **AC-UI-MOBILE-MENU-004.3:** Embedded selection shall preserve push navigation + from non-task pages and replacement within the workbench. Workspace changes, + loading, denial, retry, focus return, action dialogs, and rotation shall retain + existing protections. Office shall not gain a Kanban task sidebar. + +### REQ-UI-MOBILE-MENU-005: Listing-title view options + +**Intent:** Use the existing listing context to configure that listing. + +#### Acceptance criteria + +- **AC-UI-MOBILE-MENU-005.1:** Tapping the top-level Kanban, Threads, or List + label and chevron shall open the corresponding phone view-options surface. + No separate View options button or toolbar row shall consume content height. +- **AC-UI-MOBILE-MENU-005.2:** Applicable mode selection, search, sorting, + grouping, display settings, saved views, editing, and error recovery shall + remain reachable through that surface. Threads saved-view controls shall be + included there, rather than intercept the top-level dropdown. +- **AC-UI-MOBILE-MENU-005.3:** Closing options shall restore focus to the + listing-title dropdown unless transferring focus to search or another dialog. + Desktop/tablet composition, saved preferences, and search-hide clearing remain + unchanged. Both dropdown and Tasks disclosure have 44px touch targets. + +## Menu hierarchy revision (2026-09-19) + +### REQ-UI-MOBILE-MENU-006: One Home destination and coherent sections + +- **AC-UI-MOBILE-MENU-006.1:** The phone menu shall expose Home as its only + global task-listing destination, with no separate Tasks or Threads route rows. + Kanban/List/Threads remain selectable through the listing-title dropdown. + Home shall be current on all three listing routes and preserve the existing + workspace-aware Home destination and saved listing preference behavior. +- **AC-UI-MOBILE-MENU-006.2:** The embedded Tasks disclosure shall use the same + heading typography, left alignment, divider treatment, and section spacing as + Utilities. A subordinate chevron shall communicate expansion; the adjacent + create-task plus shall remain visible in both states, independently usable, + and accessible. Both controls retain separate 44px hit areas. Collapsing shall + preserve existing data, selection, and dialog behavior. +- **AC-UI-MOBILE-MENU-006.3:** The phone menu shall expose an Automations section + in regular workspaces, including the existing automation list/detail and setup + paths, even when no automation exists. Available entries shall reflect the + active workspace, with distinguishable loading, empty, and failed reads. +- **AC-UI-MOBILE-MENU-006.4:** Integrations shall remain discoverable when no + configured provider links exist by exposing integration settings/setup. Actual + provider links shall retain credential, permission, enabled-visibility, and + plugin availability rules. The menu shall not label unconfigured providers as + connected or change credentials. Switching workspace shall not show stale + automation or integration entries from the previous workspace. + +## Action-first menu revision (2026-09-19) + +### REQ-UI-MOBILE-MENU-007: Quick actions and collapsible integrations + +- **AC-UI-MOBILE-MENU-007.1:** In the phone menu, Home shall be immediately + followed by side-by-side Quick Chat and Quick terminal actions, before any + page-local navigation or expandable task content. Expanding Tasks shall not + move those actions below the task list. Existing workspace context, activity + indicators, launch behavior, and focus handoff shall be retained. +- **AC-UI-MOBILE-MENU-007.2:** Regular workspace sections shall follow Tasks, + Automations, Integrations, Utilities order. Existing eligible plugin actions, + canvases, and page-local destinations shall remain reachable. Utilities shall + place Settings before Stats, followed by theme and support actions; existing + conditional status and health affordances shall retain their behavior. +- **AC-UI-MOBILE-MENU-007.3:** Integrations shall use a labelled, keyboard- and + touch-operable disclosure matching Tasks and Automations, initially collapsed. + Expanding shall reveal eligible provider/plugin links and integration settings, + including settings when no providers are configured. Collapsing shall hide + those rows without changing configuration, route, or workspace. Its state is + temporary for the mounted menu; no new saved preference is required. +- **AC-UI-MOBILE-MENU-007.4:** Phone quick actions and disclosures shall have + at least 44px touch targets, remain readable without clipping at 393px and + 767px, and share the existing menu scroller. Integration availability and + workspace isolation shall remain authoritative even while collapsed. Wider + navigation composition, Tasks' initial expanded state, and Automations' + initial collapsed state shall remain unchanged. + +## Out of scope + +- Global bottom navigation, a second top-bar task-actions menu, or redesigned + task rows and task mutations. +- New visit-history persistence, pin settings, task APIs, feature toggles, or + changes to Office task ownership. +- Desktop/tablet layout redesign and provider-specific local navigation redesign. + +## Related contracts + +- [Task chrome](mobile-task-chrome.md): the title becomes its retained + task-drawer entry; task actions stay on task rows. +- [Task-view access](mobile-task-view-access.md): preserves controller lifetime, history, and focus guarantees; the embedded + Tasks section supersedes its separate Task views navigation label. +- [Listing preferences](task-listing-display-preferences.md): retains storage, + explicit Home semantics, and phone Pipeline fallback. +- [Workspace read recovery](../../workspaces/requirements/workspace-read-recovery.md). + +## Implementation plans + +- [Unified mobile navigation](../../../plans/unified-mobile-navigation/plan.md) diff --git a/docs/specs/ui/system-design/mobile-task-chrome.md b/docs/specs/ui/system-design/mobile-task-chrome.md index dceed6d3c4..5c29105c53 100644 --- a/docs/specs/ui/system-design/mobile-task-chrome.md +++ b/docs/specs/ui/system-design/mobile-task-chrome.md @@ -34,7 +34,7 @@ chrome. The same top bar also renders `GitActionsDropdown` on every phone panel. Its commit, change-request, pull, push, rebase, merge, and contribution-recovery commands duplicate capabilities already owned by `MobileChangesPanel`. The -task drawer opened by `mobile-session-menu` already exposes task actions, +shared app menu and title-triggered task picker expose task-row actions, including **Move to**, so replacing the Git ellipsis with another task menu would create a second path to the same commands. @@ -42,10 +42,10 @@ would create a second path to the same commands. - `apps/web/components/task/mobile/session-mobile-top-bar.tsx` keeps task title, repository/branch summary, applicable status/plugin controls, approval, and - the task-drawer trigger. It stops mounting layout and Git action surfaces. - The retained task-drawer trigger becomes a 44-by-44 CSS-pixel touch target. + the shared app-navigation trigger and title-triggered task picker. It does not + mount layout or Git action surfaces. Phone triggers retain 44px touch targets. - `apps/web/components/task/mobile/session-task-switcher-sheet.tsx` remains the - only phone top-chrome path to the task list and its row-level actions. + shared task-list and row-action owner for embedded Tasks and the title picker. - `apps/web/components/task/mobile/mobile-changes-panel.tsx` continues to compose the shared `ChangesPanelHeader` and `ChangesPanelBody`. Those shared components retain commit, push, change-request, pull, rebase, merge, and @@ -68,13 +68,14 @@ not retain action ownership. ### Task action -1. User taps retained hamburger control. -2. `SessionTaskSwitcherSheet` opens its existing inset bottom drawer. +1. User taps the hamburger to open shared app navigation and its embedded Tasks + section, or taps the task title to open the separate inset task picker. +2. The existing task list presents saved views, filters, and task rows. 3. User opens active task row's visible action menu. 4. Existing `TaskMoveContextMenuItems` moves the task to another permitted workflow step. -No new top-bar overflow, picker, state, or mutation path is introduced. +Both entries reuse the same task-row actions and mutation path. ### Git action @@ -143,3 +144,19 @@ phone panel preference and desktop/tablet layout state remain unchanged. - [Remote contribution tasks](../../tasks/system-design/remote-contribution-tasks.md) - [Remote contribution head drift](../../../decisions/2026-08-10-remote-contribution-head-drift.md) - [Local-first contribution replacement](../../../decisions/2026-08-12-local-first-contribution-replacement.md) + +## Navigation entry points + +The [unified phone navigation package](../../../plans/unified-mobile-navigation/plan.md) +changes the hamburger to app navigation and the task-title button to the existing +task picker. Its [design](../system-design/unified-mobile-navigation.md) +owns that entry-point change; existing task actions, saved-view controller +lifetime, history, and desktop/tablet guarantees here remain compatibility +requirements. Historical hamburger descriptions above describe the preceding composition; +the unified navigation design defines the current entry points. + +The September 2026 revision embeds the collapsible Tasks sidebar directly in +the shared phone menu, replacing the Task views action and dedicated pinned +shortcuts. Kanban/Threads/List title dropdowns open display options; Threads +saved-view editing remains inside that surface. See REQ-UI-MOBILE-MENU-004/005 +in the unified navigation requirements for the current composition. diff --git a/docs/specs/ui/system-design/mobile-task-view-access.md b/docs/specs/ui/system-design/mobile-task-view-access.md index b4727300f0..9523914c8a 100644 --- a/docs/specs/ui/system-design/mobile-task-view-access.md +++ b/docs/specs/ui/system-design/mobile-task-view-access.md @@ -12,12 +12,27 @@ requirements: ## Components and flow -Extend `AppNavSections` and its hoisted `useAppNavDialogs` controls with a localized Task views action for phone Kanban workspaces. Close the navigation surface before opening the task drawer. Mount the existing `SessionTaskSwitcherSheet` lazily in the hoisted dialogs, with the current workspace and workflow context and presentation=drawer. Do not mount it before the first request. Once requested, retain its controller until navigation or workspace changes so closing the drawer can hand off to task creation, editing, and confirmation dialogs without losing their state. +`AppNavSheet` embeds the collapsible Tasks body from `SessionTaskSwitcherSheet` through `MobileTaskNavigationProvider`. Mount the controller lazily on the first menu request, then retain it across menu closure and responsive changes so creation, editing, and confirmation dialogs keep their drafts. Pass workspace, workflow, archived-task, port-forwarding, and selection context through the outlet. The task title opens the separate picker through `ResponsiveTaskPicker`; there is no separate Task views action. -The existing drawer's `SidebarFilterBar` provides saved view selection/editing. Task navigation from a non-task route pushes the task route so Back returns to the origin; in-workbench task switching retains its established navigation policy. Dismissal restores a visible app-navigation trigger. Do not add Office task controls to this Kanban surface. +The embedded `SidebarFilterBar` provides saved view selection, filters, and editing. Task navigation from a non-task route pushes the task route so Back returns to the origin; in-workbench task switching retains its established replace policy. Dismissal restores the actual visible opener unless focus enters a launched dialog. Keep one menu scroller and independent Tasks collapse and New controls. Do not add Office task controls to this Kanban surface. Only the navigation entry is gated by phone width. A controller already requested by the user remains mounted across breakpoints, so turning the phone sideways cannot discard a draft in a task dialog. ## Verification -A phone E2E starts at /github, opens app navigation, selects Task views, chooses a saved sidebar view, opens a matching task, and returns with Back. Cover dismissal and a workspace without tasks. Existing task-workbench switching tests protect its navigation policy. +A phone E2E starts at /github, opens app navigation, expands Tasks if needed, chooses a saved sidebar view, opens a matching task, and returns with Back. Cover dismissal and a workspace without tasks. Existing task-workbench switching tests protect its navigation policy. + +## Navigation entry points + +The [unified phone navigation package](../../../plans/unified-mobile-navigation/plan.md) +changes the hamburger to app navigation and the task-title button to the existing +task picker. Its [design](../system-design/unified-mobile-navigation.md) +owns that entry-point change; existing task actions, saved-view controller +lifetime, history, and desktop/tablet guarantees here remain compatibility +requirements. The unified navigation design defines the current entry points. + +The September 2026 revision embeds the collapsible Tasks sidebar directly in +the shared phone menu, replacing the Task views action and dedicated pinned +shortcuts. Kanban/Threads/List title dropdowns open display options; Threads +saved-view editing remains inside that surface. See REQ-UI-MOBILE-MENU-004/005 +in the unified navigation requirements for the current composition. diff --git a/docs/specs/ui/system-design/unified-mobile-navigation.md b/docs/specs/ui/system-design/unified-mobile-navigation.md new file mode 100644 index 0000000000..dcf0cdc1e0 --- /dev/null +++ b/docs/specs/ui/system-design/unified-mobile-navigation.md @@ -0,0 +1,332 @@ +--- +status: current +system: ui +requirements: + - REQ-UI-MOBILE-MENU-001 + - REQ-UI-MOBILE-MENU-002 + - REQ-UI-MOBILE-MENU-003 + - REQ-UI-MOBILE-MENU-004 + - REQ-UI-MOBILE-MENU-005 + - REQ-UI-MOBILE-MENU-006 + - REQ-UI-MOBILE-MENU-007 +--- + +# Unified mobile navigation design + +## Revised target after user testing (2026-09-17) + +This section supersedes the original pinned-shortcut and separate View options +composition below. The earlier sections describe the first implemented iteration; +its verification results do not validate this revision. Task 03 implements this revision; its validation is recorded in the work order. + +### Embedded Tasks (REQ-UI-MOBILE-MENU-004) + +Reuse `SessionTaskSwitcherSheet`'s data/actions/dialog controller and +`MobileTaskList`, `SidebarFilterBar`, and task creation/action handling. Extract a +controller/body boundary instead of copying sidebar business logic. Supply an +inline body adapter for the shared menu, with a semantic Tasks disclosure and +New action; the menu already owns workspace selection and Quick Chat, so do not +repeat those inside the embedded body. Preserve the standalone title picker. + +`MobileTaskNavigationProvider` lives in the app shell above responsive page +headers. It owns the lazy, workspace-keyed sidebar/controller after first use; +`AppNavSheet` registers a body outlet and origin-specific selection/navigation +callbacks. The outlet also forwards archived-task and port-forwarding context +from the task page to the retained sidebar; React portals do not inherit target +context. Only the body is portalled into the menu; action dialogs stay mounted +when the outlet closes or the header unmounts on rotation. +`AppNavSheet` supplies this Tasks body after primary/local +navigation and before workspace actions. Start expanded; preserve disclosure +state for the mounted host without new persisted preferences. Remove the +`MobileTaskShortcuts` projection and dedicated navigation pins. Suppress the +Task views action on phone shared menus. Preserve existing global route links +unless duplicating the same entry is demonstrably unnecessary; this revision +changes the embedded body, not the navigation manifest's destination contract. + +The app menu owns the only vertical scroller. The embedded list grows naturally; +remove its standalone `flex-1/overflow-y-auto` wrapper in inline presentation. +Keep loading/empty/error/denied states inside Tasks, with global navigation usable. +Task rows retain existing pin controls/grouping as part of the real sidebar. + +Keep the task action/dialog controller outside the drawer's unmounting portal. +After first request, retain that controller across menu closure and breakpoint +changes so creation/edit/confirmation drafts survive. The workbench shares +selection cancellation with its title picker; non-task pages use push navigation. +Do not key/remount the workbench when workspace metadata briefly refreshes. +Closing the menu must not cancel a selection that successfully transferred +ownership, and pending selections from an old workspace must remain invalidated. + +### Listing-title options (REQ-UI-MOBILE-MENU-005) + +Remove `ViewOptionsButton` and its toolbar row from `KanbanHeaderMobile`. +Always render `MobileListingContext` for the top-level mode label, including +Threads; each opens the existing listing-only `MobileMenuSheet` and owns focus +return. Kanban/List already have this wiring alongside the redundant button. + +Move Threads saved-view controls into the options content via an explicit body +slot/controller boundary in `ThreadsViewControls`. Preserve saved-view selection, +editing, save/discard, delete confirmation, and sync retry in the same phone +options flow. Do not nest a second drawer for the same level of view choices. +Keep the controller mounted during surface handoffs. Desktop Threads controls +and wider menu composition remain unchanged. + +### Mobile composition and verification + +Reuse the shipped inset app drawer and 44px disclosure/listing-context targets. +Order: workspace, global/local navigation, collapsible Tasks, workspace actions, +plugins/integrations, utilities. Test the real inline task list, child action +handoffs, workspace-denied reads, long-list scrolling, task-title picker, and +rotation. Verify each of Kanban/List/Threads enters its options from its header +and returns focus there. Inspect 393px/767px and wider 768px/820px behavior. + +## Boundary and evidence + +This is phone presentation and entry-point work. No backend, schema, plugin SDK, +or permission changes are required. Existing source currently has three shells: +`MobileMenuSheet` mixes listing controls and app navigation, +`SessionMobileTopBar` uses `onMenuClick` for `SessionTaskSwitcherSheet`, and +`AppNavSheet` uses a left Sheet for other pages. `AppNavSections` already shares +the manifest destinations and hoisted dialog controls between some of them. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| REQ-UI-MOBILE-MENU-001 | Shared shell and page controls; Mobile composition | +| REQ-UI-MOBILE-MENU-002 | Task title and navigation handoff | +| REQ-UI-MOBILE-MENU-003 | Pinned shortcuts and recovery | +| REQ-UI-MOBILE-MENU-004 | Embedded Tasks | +| REQ-UI-MOBILE-MENU-005 | Listing-title options | +| REQ-UI-MOBILE-MENU-006 | Menu hierarchy revision | +| REQ-UI-MOBILE-MENU-007 | Action-first composition | + +## Shared shell and page controls + +Extend `components/navigation/app-nav-sheet.tsx` as the app-level entry point. +Use `useResponsiveBreakpoint` to choose a phone Drawer while retaining its +existing wider Sheet. Reuse `AppNavTrigger` and its connection-health accessible +name on all phone shells. Retain a controlled-open adapter where existing +listing state is externally controlled; do not create a global modal store. + +Extract only the reusable inset geometry from `MobileMenuSheet` if needed; +the navigation shell belongs in `components/navigation`, not in the task domain. +Use `AppSidebarWorkspacePicker`, `AppNavSections`, +`MobileWorkspaceActionsSection`, and the existing manifest resolver. Keep +availability-gated hooks distinct from static destinations, following the +[navigation manifest ADR](../../../decisions/2026-08-04-navigation-manifest-boundaries.md). +Do not hardcode a new destination catalog. Preserve the relative order of +eligible destinations. Update selected-route presentation to map task detail to +Tasks without changing href resolution or Home/startup rules. + +The phone shell stops using listing-only `omitDestinations` to hide Tasks and +Threads. Office-specific suppression still prevents a Kanban Tasks link from +being mistaken for Office Tasks. Keep `pageNav` for existing Settings/Office +navigation, grouped below global links with a localized section heading. +The drawer body order is workspace, global destinations, page-local navigation +when present, optional pins, workspace actions, plugins/integrations, utilities. + +`KanbanHeaderMobile` renders the shared hamburger and a separate labelled View +options entry beside listing context. Keep Threads' existing saved-view picker. +Its View options control sits in the content toolbar rather than taking the +picker's title slot. Use `MobileMenuSheet` in listing-only mode, reusing `MobileDisplayOptions`, +`useMobileMenuSheetState`'s view and display wiring, and +`MobileListingMenuActions` after separating page actions from navigation. +The page-context button on Kanban/List may open the same options surface, but +the labelled control remains visible. Avoid two simultaneously open overlays. + +Keep phone New Task/Quick Chat/Quick Terminal and plugin workspace actions +available through existing primary affordances and shared workspace actions. +Do not move a workspace-wide action into listing display settings. Preserve +search-hide clearing and focus restoration to the originating options control. +Wider `KanbanHeader` callers retain their combined menu behavior. + +## Task title and navigation handoff + +In `SessionMobileTopBar`, make the title text plus chevron a semantic button. +Keep branch/diff metadata out of its accessible name; do not nest controls. +Add explicit task-picker callback/open-state props and reuse the current +mobile-session task-switcher state. `ResponsiveTaskPicker` owns the requested +`SessionTaskSwitcherSheet` above the phone/tablet layout branches. +Replace the hamburger action with the shared app navigation shell. Preserve +the existing back-to-task-overview link and current-session picker separately. + +The title picker keeps the existing task selection controller and URL-replace +policy. A non-task entry uses the existing `useTaskViewNavigation` push callback. +Extend navigation dialog controls to accept the workbench's task-picker opener +and share `TaskSheetSelectionProvider` between the workbench picker and +shortcuts, so Task views and shortcuts on the workbench do not +instantiate a competing task drawer or use the non-task push policy. + +Retain `useAppNavDialogs` outside the Drawer's unmounting body. Close navigation +before opening Task views, status, task creation, or other dialogs. Use the +existing close-auto-focus handoff pattern rather than a timed delay. Remember +the real opener separately for the title picker and app menu. Keep a requested +task controller mounted across a breakpoint change so child dialog drafts live. +Workspace identity changes invalidate pending selection without remounting the +workbench. The picker remains keyed by workspace, while its shared selection +provider keeps a stable identity across metadata refreshes. + +## Pinned shortcuts and recovery + +Use `mobile-pinned-tasks.tsx` and the pure `mobile-pinned-task-items.ts` +projection. `mobile-task-shortcuts.tsx` adapts the existing `useSheetActions` +selection behavior and uses the shared workbench cancellation controller; +non-task surfaces use a local controller and push navigation. Read pins from +`useSidebarTaskPrefs`; read task data through the existing workspace-scoped +`useWorkspaceSidebarTasks` boundary. Mount the shortcut reader only after the +menu is requested. Reuse its cache/request leases rather than adding API calls +or mounting the full task picker merely to render links. + +Project eligible tasks before applying pin order and taking three. Deduplicate +pin IDs, exclude archived/deleted/foreign tasks, and preserve pin order without +writing preferences. The compact list is independent of a saved task-view +filter: a filtered-out pin is still a shortcut if workspace-authorized. +Use the workspace read boundary's identity/pending/access-denied signals; +never infer authorization from the presence of an old cached task. While the +new workspace is pending, withhold shortcuts. On a recoverable read error show +existing localized error/retry behavior in that section while keeping global +links enabled. On denial hide all task titles. Task deletion during selection +uses the existing missing-task recovery route, without auto-starting a session. + +Pins are optional; the existing Task views action remains available even if +there are no pins or shortcut reads fail. Keep that label distinct from the +manifest's Tasks route. No new user setting, recency store, or telemetry is added. + +## Mobile composition + +- **Entry points:** hamburger at the trailing edge on phone page/task headers; + title chevron for workbench task choice; View options beside listing content. + Desktop sidebar, desktop Dockview, and tablet composition retain their layout. +- **Exemplars:** `MobileMenuSheet` supplies inset Drawer geometry and internal + scroll; `SessionTaskSwitcherSheet` supplies task-choice behavior and recovery. + `AppNavSheet` supplies app destinations and hoisted dialogs, but its current + phone side-sheet geometry is replaced. +- **Hierarchy:** active workspace and destinations first; bounded shortcuts + second; workspace actions and utilities later. The focused page owns dense + content and display controls. A temporary navigation choice merits a drawer; + the existing bottom panel navigation remains reserved for workbench content. +- **Geometry:** use dynamic viewport bounds, a fixed heading/close control, + one `min-h-0 flex-1 overflow-y-auto overscroll-contain` body, inset rounded + surface, dimmed backdrop, and bottom safe-area padding. Touch controls are + at least 44 CSS pixels; do not impose phone sizing on desktop controls. +- **Accessibility:** localized names and visible section labels, current-route + indication, title chevron and expanded state, normal keyboard activation, + Escape/dismiss and focus restoration. Long titles truncate without reducing + hamburger or picker hitboxes. Missing title uses existing loading copy. +- **State:** selection, saved views, request freshness, task mutations and + plugin contributions remain shared domain behavior. Only presentation and + temporary overlay state change. + +## Verification and compatibility + +The [plan](../../../plans/unified-mobile-navigation/plan.md) maps criteria to +unit tests and phone E2E. Cover the same hamburger contract from listings, task, +Settings, Office, and an integration page; separately exercise title switching, +local options, pin isolation, dialog handoff, and workspace errors. Check phone +geometry at the configured device and 767px, plus retained wider behavior at +768px and 820px. Keep tests for original task actions; migrate their entry +selectors rather than deleting scenarios. + +New copy uses all five locale catalogs and generated Traditional Chinese values. +Public navigation instructions are updated with implementation, not in advance. + +## Related decisions + +- [Phone navigation entry-point ownership](../../../decisions/2026-09-15-phone-navigation-entry-points.md) + +The Threads page supplies its board-derived position as `mobileListingStatus`, +separately from saved-view controls. The header keeps pagination and sync status +visible while the options drawer owns the view editor. + +## Menu hierarchy revision (2026-09-19) + +REQ-UI-MOBILE-MENU-006 owns the reusable navigation composition, not integration +credentials or automation lifecycle. This section supersedes earlier primary +Tasks/Threads destination descriptions. Task 04 implements this revision and +the isolated preview contains its final production build. Verification is +recorded in the Task 04 work order. + +Use phone-scoped `omitDestinations` at `AppNavSheet` for tasks/threads rather +than removing routes or palette entries from the manifest. Adjust phone Home +active treatment for `/`, `/tasks`, and `/threads`; preserve Office local Tasks +and desktop/tablet semantics. Use the existing Home href resolver, including +workspace and startup preferences; do not introduce navigation persistence. + +Restyle `InlineTaskHeader` around the Utilities exemplar in `app-nav-sections`: +section divider and padding, `text-sm font-medium`, label aligned with Utilities, +small trailing chevron, and a restrained ghost plus action at the right edge. +The heading hit area remains full-width excluding the 44px plus. Remove the +persistent filled disclosure treatment and excess indentation; keep visible +focus/hover feedback. Do not move or duplicate the task controller. + +Add a phone Automations section after workspace actions and before integrations. +Reuse `useWorkspaceAutomations`, `useAutomationSummaries`, `buildAutomationRows`, +and the existing automation route constants. Do not mount the desktop sidebar +component directly: its summaries explicitly disable reads on phones and its +controls use desktop geometry. Use touch rows, a collapsed initial disclosure, +a list link and setup action. Read summaries only when expanded; preserve domain +errors/retry and workspace isolation. Setup uses existing settings routing. + +`MobileIntegrationsSection` currently returns null for an empty resolved list. +Keep a labelled section and an integration settings entry in that state; retain +`useAppDestinations` filtering for actual provider/plugin links. Reuse the +existing workspace settings links; do not invent a new integrations page. + +Phone hierarchy: workspace, Home, existing local navigation, Tasks, workspace +quick actions, Automations, plugin/integration sections, Utilities. The fixed +menu header and single safe-area-aware scroller remain. Desktop/tablet stay as +shipped. Validate 393px, 767px and the 768px boundary; compare dark and light +screenshots to the Utilities section. No new animation or dependency is needed. + +## Action-first composition (REQ-UI-MOBILE-MENU-007) + +Task 05 supersedes Task 04's section ordering only. The UI system owns this +reusable composition and disclosure contract; integrations, workspaces and +quick-action launchers retain all domain state and availability rules. + +In `AppNavSheet`/`AppNavSections`, supply a phone-only quick-actions slot grouped +with `PrimaryNavSection` rather than moving the entire `workspaceActions` slot. +`MobileListingMenuActions` also contains plugin actions and fallback metrics; +separate the two built-in quick actions into a reusable render boundary with the +existing launch hooks, activity indicator and close/focus handoff. Retain default +composition for other callers. Only the phone navigation variant gets a two-column +quick-action row; use min-width zero and wrapping labels with minimum 44px height. +Do not move plugin content, search or metrics into that row. + +Phone sequence: workspace picker; Home and quick actions; existing page-local +navigation; Tasks; existing workspace plugin/canvas and fallback metric content; +Automations; existing plugin navigation groups; Integrations; Utilities. Optional +extension slots remain reachable without breaking the adjacency of Home/quick +actions or the relative order of the named sections. Office retains its local +navigation and suppression of the Kanban task and automation sections. + +A saved sidebar layout owns its configured section order and visibility instead +of the default sequence. Keep phone quick actions immediately after its Home row, +or at the top when Home is hidden. Home still covers all listing modes. Retain +the embedded Tasks section and Utilities outside the configurable projection; +do not duplicate default Automations, plugin groups, or Integrations when the +saved layout renders those sections. + +Add an explicit phone disclosure option to `MobileIntegrationsSection`; leave +its default/wider behavior unchanged. Use a local initially-false expanded state, +semantic Button, aria-expanded/aria-controls and stable body ID, using existing +Tasks/Automations heading tokens. Both provider rows and settings belong inside +the collapsed body. Keep the heading even for an empty provider list. Do not +snapshot destination arrays into local state or gate availability subscriptions +on expansion. The shared resolver remains responsible for permission, plugin and +workspace changes. No domain mutation, credential changes, persistent preference, +API or database change is needed. + +Use `phoneNavigation` to order Settings before Stats in the utility projection; +do not change the global destination manifest. Conditional Status remains above +those links; theme, Improve Kandev and conditional Health issues follow them. +On phones, Utilities follows Integrations with the normal section gap; it must +not use an automatic top margin to consume unused drawer height. Wider menus +retain their footer anchoring. Keep the fixed drawer header, one scroll body and +safe-area inset. Keyboard +activation toggles sections; Escape still closes the menu and restores its trigger. + +Validate ordered geometry and both quick launch outcomes from Home and task +workbench, disclosure keyboard/touch behavior, empty/configured integrations, +workspace switches, long translated labels, and unchanged 768px/wider composition. +Reuse the populated isolated preview and reapply its mock seed after any restart.