diff --git a/apps/packages/plugin-sdk/src/index.ts b/apps/packages/plugin-sdk/src/index.ts index 3c105effd3..2b74d3aaf4 100644 --- a/apps/packages/plugin-sdk/src/index.ts +++ b/apps/packages/plugin-sdk/src/index.ts @@ -27,7 +27,10 @@ export type PluginIcon = string | Component; /** Placement for a registered nav item; see `PluginRegistry.registerNavItem`. */ export type PluginNavSection = "main" | "settings" | "integrations" | "sidebar-footer"; -/** Context passed to components registered for the `main-top-bar` slot. */ +/** + * Context for the `main-top-bar` slot. Phone listing contributions live in the + * menu with 44px touch targets; interactions retain the slot's local state. + */ export interface MainTopBarSlotProps { workspaceId: string | null; workspaceLabel?: string; diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 2db0c2c37e..600e934b49 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -14,7 +14,6 @@ For plugin UI work, begin with the [canonical plugin authoring guide](../../docs import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Dialog } from "@kandev/ui/dialog"; -// etc... ``` **Do NOT** import from `@/components/ui/*` - always use `@kandev/ui` package. @@ -32,6 +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. +- 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. ## 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 a23f94616c..f942dfb1c5 100644 --- a/apps/web/app/threads/threads-page-client.tsx +++ b/apps/web/app/threads/threads-page-client.tsx @@ -1,24 +1,61 @@ "use client"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, type ComponentProps } from "react"; import { useRouter, useSearchParams } from "@/lib/routing/client-router"; import { KanbanHeader } from "@/components/kanban/kanban-header"; import { ThreadsBoard } from "@/components/threads/threads-board"; import { ThreadsViewControls } from "@/components/threads/threads-view-controls"; +import { MobileThreadPagination } from "@/components/threads/mobile-thread-pagination"; import { useAppStore } from "@/components/state-provider"; import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import { useTaskListingView } from "@/hooks/use-task-listing-view"; import { linkToTask } from "@/lib/links"; -import { resolveFocusedThreadId } from "@/lib/threads/active-threads"; +import { resolveFocusedThreadId, type ActiveThread } from "@/lib/threads/active-threads"; import { useStableThreadOrder } from "@/lib/threads/stable-order"; import { DEFAULT_THREAD_VIEW } from "@/lib/state/slices/ui/thread-view-builtins"; -import { queryThreadView } from "@/lib/threads/thread-view-query"; +import { queryThreadView, type ThreadViewQueryResult } from "@/lib/threads/thread-view-query"; import { useKanbanRouteBootstrap } from "@/src/kanban-route"; import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; type WorkspaceWorkflow = { id: string; workspaceId: string }; +function ThreadsPageHeader({ + workspaceId, + query, + repositories, + threads, + activeMobileTaskId, +}: { + workspaceId: string | null | undefined; + query: ThreadViewQueryResult; + repositories: ComponentProps["repositories"]; + threads: readonly ActiveThread[]; + activeMobileTaskId: string | null; +}) { + return ( + + + thread.taskId === activeMobileTaskId) + 1} + total={threads.length} + /> + + } + /> + ); +} + /** * Keep the derived deck scoped to the workspace that the route currently * names. Snapshot cleanup runs in an effect, so render-time filtering must not @@ -137,30 +174,24 @@ export function ThreadsPageClient() { const focusedSessionId = focusedTaskId ? searchParams.get("sessionId") : null; return ( -
- + ( + - } + )} /> -
- -
); } diff --git a/apps/web/components/kanban/kanban-header-mobile.test.tsx b/apps/web/components/kanban/kanban-header-mobile.test.tsx index aa7aeba7dd..a010551cbf 100644 --- a/apps/web/components/kanban/kanban-header-mobile.test.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.test.tsx @@ -1,191 +1,214 @@ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { useState, type ComponentProps, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { StateProvider } from "@/components/state-provider"; import { KanbanHeaderMobile } from "./kanban-header-mobile"; +import { pluginRegistry } from "@/lib/plugins/registry"; vi.mock("@/components/page-topbar", () => ({ PageTopbar: ({ title, + titleSlot, leading, - leftActions, actions, freeWidth, }: { - title?: string; + title: string; + titleSlot?: ReactNode; leading?: ReactNode; - leftActions?: ReactNode; actions?: ReactNode; freeWidth?: "lead" | "actions"; }) => (
{leading} - {title} -
{leftActions}
-
{actions}
+ {titleSlot ?? title} + {actions}
), })); vi.mock("./mobile-menu-sheet", () => ({ - MobileMenuSheet: () => null, + MobileMenuSheet: ({ open, pageActions }: { open: boolean; pageActions?: ReactNode }) => + open ?
{pageActions}
: null, })); -const quickChatMocks = vi.hoisted(() => ({ +const launchers = vi.hoisted(() => ({ openQuickChat: vi.fn(), openQuickTerminal: vi.fn(), })); -const statusDrawerState = vi.hoisted(() => ({ +const status = vi.hoisted(() => ({ issueSeverity: "none" as "none" | "unstable" | "lost", })); +const chat = vi.hoisted(() => ({ + activity: null as "running" | "finished" | null, + label: "Quick Chat", +})); vi.mock("@/hooks/use-quick-chat-launcher", () => ({ - useQuickChatLauncher: () => quickChatMocks.openQuickChat, + useQuickChatLauncher: () => launchers.openQuickChat, })); - vi.mock("@/hooks/use-quick-terminal-launcher", () => ({ - useQuickTerminalLauncher: () => quickChatMocks.openQuickTerminal, + useQuickTerminalLauncher: () => launchers.openQuickTerminal, })); - vi.mock("@/components/app-status-bar/app-status-surface-provider", () => ({ - useAppStatusDrawer: () => statusDrawerState, + useAppStatusDrawer: () => status, +})); +vi.mock("@/components/quick-chat/use-quick-chat-activity", () => ({ + useQuickChatActivity: () => chat, })); -const LEFT_ACTIONS_TEST_ID = "topbar-left-actions"; -const QUICK_CHAT_TEST_ID = "mobile-quick-chat-button"; -const QUICK_TERMINAL_TEST_ID = "mobile-quick-terminal-button"; -const ACTIVE_WORKSPACE_ID = "workspace-1"; +const MENU = "mobile-topbar-menu"; +const CHAT = "mobile-quick-chat-button"; +const TERMINAL = "mobile-quick-terminal-button"; +const CONTEXT = "mobile-topbar-page-context"; +const SEARCH = "mobile-search-toggle"; +const WORKSPACE = "workspace-1"; +beforeEach(() => { + vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame"] }); +}); afterEach(() => { cleanup(); - quickChatMocks.openQuickChat.mockClear(); - quickChatMocks.openQuickTerminal.mockClear(); - statusDrawerState.issueSeverity = "none"; + pluginRegistry.unregisterPlugin("stateful-topbar-test"); + vi.useRealTimers(); + vi.clearAllMocks(); + status.issueSeverity = "none"; + chat.activity = null; + chat.label = "Quick Chat"; }); -/** - * `currentPage` — not the title text — decides whether this is the Home header. - * The component used to derive that from `title === "Home"`, which was true only - * in English, so these tests pass the discriminant explicitly now. - */ -function renderHeader( - title: string, - workspaceId?: string, - onSearchChange?: () => void, - currentPage: "kanban" | "tasks" = "kanban", -) { +function renderHeader(props: Partial> = {}) { return render( , ); } -describe("KanbanHeaderMobile", () => { - it("links the Kandev brand home and names the page through the title crumb", () => { - renderHeader("Home", ACTIVE_WORKSPACE_ID); +function openMenu() { + fireEvent.click(screen.getByTestId(MENU)); +} +function frame() { + act(() => vi.advanceTimersToNextFrame()); +} - expect(screen.getByRole("link", { name: "Kandev home" }).getAttribute("href")).toBe( - `/?home=overview&workspaceId=${ACTIVE_WORKSPACE_ID}`, - ); - expect(screen.getByTestId("topbar-title").textContent).toBe("Home"); - // The old two-line title/workspace stack is gone. - expect(screen.getByTestId(LEFT_ACTIONS_TEST_ID).textContent).toBe(""); - }); - - it("renders the title crumb without repeating the workspace label", () => { - renderHeader("Tasks", undefined, undefined, "tasks"); - - expect(screen.getByTestId("topbar-title").textContent).toBe("Tasks"); - // The picker owns workspace identity; the bar never repeats it. - expect(screen.queryByText("/root/kandev")).toBeNull(); - // The scrolling strip carries actions only; page context is the crumb's job. - const actionStrip = screen.getByTestId("mobile-topbar-action-strip"); - expect(actionStrip.textContent).not.toContain("Tasks"); - }); - - it("opens quick chat from the header action when a workspace is active", () => { - renderHeader("Home", ACTIVE_WORKSPACE_ID); - - fireEvent.click(screen.getByTestId(QUICK_CHAT_TEST_ID)); - expect(quickChatMocks.openQuickChat).toHaveBeenCalledTimes(1); - }); - - it("opens quick terminal immediately before quick chat", () => { - renderHeader("Home", ACTIVE_WORKSPACE_ID); - - const terminalTarget = screen.getByTestId("mobile-quick-terminal-hit-target"); - const quickChatTarget = screen.getByTestId("mobile-quick-chat-hit-target"); - expect(terminalTarget.nextElementSibling).toBe(quickChatTarget); - - fireEvent.click(screen.getByTestId(QUICK_TERMINAL_TEST_ID)); - expect(quickChatMocks.openQuickTerminal).toHaveBeenCalledTimes(1); - }); - - it("hides the quick chat action without an active workspace", () => { - renderHeader("Home"); - - expect(screen.queryByTestId(QUICK_CHAT_TEST_ID)).toBeNull(); - expect(screen.queryByTestId(QUICK_TERMINAL_TEST_ID)).toBeNull(); - }); - - it("places quick chat immediately before search", () => { - renderHeader("Home", "workspace-1", vi.fn()); - - const quickChat = screen.getByTestId("mobile-quick-chat-hit-target"); - const search = screen.getByTestId("mobile-search-toggle"); - expect(quickChat.nextElementSibling).toBe(search); - }); - - it("hands the bar's leftover width to the scrolling action strip", () => { - renderHeader("Home", ACTIVE_WORKSPACE_ID, vi.fn()); - - // The strip is `flex-1`, so its base size is zero. If the lead zone keeps - // the slack the strip resolves to zero width, the actions become - // unreachable, and the bar renders brand, empty middle, menu. - const bar = screen.getByTestId("mobile-topbar-action-strip").closest("header"); - expect(bar?.getAttribute("data-free-width")).toBe("actions"); - }); - - it("keeps the brand and menu outside the middle action strip", () => { - renderHeader("Home", ACTIVE_WORKSPACE_ID, vi.fn()); - - const strip = screen.getByTestId("mobile-topbar-action-strip"); - const menu = screen.getByTestId("mobile-topbar-menu"); - expect(strip.parentElement).toBe(menu.parentElement); - expect(strip.previousElementSibling).not.toBe(menu); - expect(menu.previousElementSibling).toBe(strip); - }); - - it("uses the shared compact icon geometry for native mobile actions", () => { - renderHeader("Home", ACTIVE_WORKSPACE_ID, vi.fn()); - - for (const id of [ - QUICK_TERMINAL_TEST_ID, - QUICK_CHAT_TEST_ID, - "mobile-search-toggle", - "mobile-topbar-menu", - ]) { - expect(screen.getByTestId(id).className).not.toContain("!size-11"); +describe("stateful phone menu plugins", () => { + // Review-requested contract coverage: plugin slots own state, not just launch callbacks. + it("keeps a plugin's local disclosure and input mounted during interaction", () => { + function StatefulPlugin() { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(""); + return ( + <> + + {open && ( + setValue(event.target.value)} + /> + )} + + ); } - expect(screen.getByTestId("mobile-quick-terminal-hit-target").className).toContain("h-11"); - expect(screen.getByTestId("mobile-quick-chat-hit-target").className).toContain("h-11"); - }); - - it("describes a connectivity warning on the persistent Home menu trigger", () => { - statusDrawerState.issueSeverity = "lost"; - renderHeader("Home"); - - expect( - screen.getByRole("button", { - name: "Connection lost for at least 10 seconds. Live updates may be stale. Open menu", - }), - ).toBeTruthy(); + pluginRegistry + .forPlugin("stateful-topbar-test") + .registerComponent("main-top-bar", StatefulPlugin); + renderHeader(); + openMenu(); + fireEvent.click(screen.getByRole("button", { name: "Plugin details" })); + frame(); + const query = screen.getByRole("textbox", { name: "Plugin query" }); + fireEvent.click(query); + fireEvent.change(query, { target: { value: "checkout" } }); + frame(); + expect(screen.getByRole("dialog")).toBeTruthy(); + expect((screen.getByRole("textbox", { name: "Plugin query" }) as HTMLInputElement).value).toBe( + "checkout", + ); + }); +}); + +describe("shared phone listing header", () => { + // @covers AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.5 AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.7 + it.each([ + ["kanban", "Kanban"], + ["tasks", "List"], + ] as const)("uses route-derived context and opens the menu on %s", (currentPage, label) => { + renderHeader({ currentPage }); + const context = screen.getByTestId(CONTEXT); + expect(context.textContent).toContain("Harbor"); + expect(context.textContent).toContain(label); + expect(screen.queryByTestId("mobile-topbar-brand")).toBeNull(); + expect(screen.queryByTestId("mobile-topbar-action-strip")).toBeNull(); + expect(context.closest("header")?.getAttribute("data-free-width")).toBe("lead"); + fireEvent.click(context); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); + + it("keeps the Threads-supplied view control in the title slot", () => { + renderHeader({ currentPage: "threads", taskListingControls: }); + const view = screen.getByRole("button", { name: "Review view" }); + expect(view.closest("header")).not.toBeNull(); + expect(screen.queryByTestId(CONTEXT)).toBeNull(); + expect(screen.queryByTestId("mobile-topbar-action-strip")).toBeNull(); + }); + + // @covers AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.2 AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.3 + it.each([ + [CHAT, "openQuickChat"], + [TERMINAL, "openQuickTerminal"], + ] as const)("closes the menu before launching %s", (testId, launcher) => { + renderHeader(); + expect(screen.queryByTestId(testId)).toBeNull(); + openMenu(); + fireEvent.click(screen.getByTestId(testId)); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(launchers[launcher]).not.toHaveBeenCalled(); + frame(); + expect(launchers[launcher]).toHaveBeenCalledTimes(1); + }); + + it("omits workspace launchers without an active workspace", () => { + renderHeader({ workspaceId: undefined }); + openMenu(); + expect(screen.queryByTestId(CHAT)).toBeNull(); + expect(screen.queryByTestId(TERMINAL)).toBeNull(); + }); + + // @covers AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.10 + it("reveals search from the menu and clears its query on collapse", () => { + const onSearchChange = vi.fn(); + renderHeader({ currentPage: "tasks", searchQuery: "Alpha", onSearchChange }); + openMenu(); + 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(); + expect(screen.getByTestId(SEARCH).getAttribute("aria-pressed")).toBe("true"); + fireEvent.click(screen.getByTestId(SEARCH)); + frame(); + expect(onSearchChange).toHaveBeenCalledWith(""); + }); + + it("keeps connectivity and Quick Chat feedback on the persistent menu", () => { + status.issueSeverity = "lost"; + chat.activity = "running"; + chat.label = "Quick Chat running"; + renderHeader(); + const menu = screen.getByTestId(MENU); + expect(menu.getAttribute("aria-label")).toContain("Connection lost"); + expect(menu.getAttribute("aria-description")).toBe(chat.label); + expect(menu.getAttribute("data-connection-severity")).toBe("lost"); + expect( + within(menu).getByTestId("quick-chat-activity-indicator").getAttribute("data-state"), + ).toBe("running"); }); }); diff --git a/apps/web/components/kanban/kanban-header-mobile.tsx b/apps/web/components/kanban/kanban-header-mobile.tsx index e1a77433b3..a1926eb149 100644 --- a/apps/web/components/kanban/kanban-header-mobile.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.tsx @@ -1,25 +1,14 @@ "use client"; -import { Button } from "@kandev/ui/button"; -import { IconMenu2, IconMessageCircle, IconSearch, IconTerminal2 } from "@tabler/icons-react"; -import type { ReactNode } from "react"; +import { useRef, type MouseEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import Link from "@/components/routing/app-link"; import { PageTopbar } from "@/components/page-topbar"; -import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics"; -import { MainTopBarPluginActions } from "./main-top-bar-plugin-actions"; -import { MobileTopbarActionStrip } from "./mobile-topbar-action-strip"; import { MobileMenuSheet } from "./mobile-menu-sheet"; +import { MobileListingContext } from "./mobile-listing-context"; +import { MobileListingMenuButton } from "./mobile-listing-menu-button"; +import { MobileListingMenuActions } from "./mobile-listing-menu-actions"; import type { TasksListDisplayOptions } from "./mobile-menu-task-list-options"; import { useAppStore } from "@/components/state-provider"; -import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider"; -import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-status-item"; -import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; -import { useQuickTerminalLauncher } from "@/hooks/use-quick-terminal-launcher"; -import { workspaceHomeHref } from "@/lib/navigation/workspace-home"; -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"; import type { TaskListingPage } from "@/lib/task-listing/view-navigation"; type KanbanHeaderMobileProps = { @@ -34,179 +23,11 @@ type KanbanHeaderMobileProps = { taskListingControls?: ReactNode; }; -function MobileBrandLink({ workspaceId }: Pick) { - const { t } = useTranslation(); - return ( - - Kandev - - ); -} - -function MobileLauncherTarget({ - children, - onClick, - testId, -}: { - children: ReactNode; - onClick: () => void; - testId: string; -}) { - return ( - - {children} - - ); -} - -function MobileQuickChatButton({ - workspaceId, - onClick, -}: { - workspaceId: string; - onClick: () => void; -}) { - const { activity: quickChatActivity, label: quickChatLabel } = useQuickChatActivity(workspaceId); - return ( - - - - ); -} - -function MobileHeaderActionItems({ - workspaceId, - workspaceLabel, - currentPage, - onSearchChange, - isSearchOpen, - handleOpenQuickChat, - handleOpenQuickTerminal, - toggleSearch, - taskListingControls, -}: { - workspaceId?: string; - workspaceLabel: string; - currentPage: TaskListingPage; - onSearchChange?: (query: string) => void; - isSearchOpen: boolean; - handleOpenQuickChat: () => void; - handleOpenQuickTerminal: () => void; - toggleSearch: () => void; - taskListingControls?: ReactNode; -}) { - const { t } = useTranslation(); - - return ( - <> - {taskListingControls} - - - {workspaceId && ( - - - - )} - {workspaceId && ( - - )} - {onSearchChange && ( - - )} - - ); -} - -function MobileHeaderActions( - props: Parameters[0] & { - setMenuOpen: (open: boolean) => void; - }, -) { - const { setMenuOpen, ...actionProps } = props; - const { t } = useTranslation(); - const { issueSeverity } = useAppStatusDrawer(); - const issueDetails = useConnectionIssueCopy(issueSeverity); - - return ( - <> - - - - - - ); -} +const MODE_LABELS: Record = { + kanban: "kanban:kanban", + tasks: "kanban:list", + threads: "threads:title", +}; export function KanbanHeaderMobile({ workspaceId, @@ -219,61 +40,88 @@ export function KanbanHeaderMobile({ tasksListOptions, taskListingControls, }: KanbanHeaderMobileProps) { + const { t } = useTranslation(); const isMenuOpen = useAppStore((state) => state.mobileKanban.isMenuOpen); const setMenuOpen = useAppStore((state) => state.setMobileKanbanMenuOpen); const isSearchOpen = useAppStore((state) => state.mobileKanban.isSearchOpen); const setSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen); - const handleOpenQuickChat = useQuickChatLauncher(workspaceId); - const handleOpenQuickTerminal = useQuickTerminalLauncher(workspaceId); + const openerRef = useRef(null); + const restoreFocusRef = useRef(true); - const toggleSearch = () => { + function openMenu(event: MouseEvent) { + openerRef.current = event.currentTarget; + restoreFocusRef.current = true; + setMenuOpen(true); + } + + function closeMenuForAction(restoreFocus = false) { + restoreFocusRef.current = restoreFocus; + setMenuOpen(false); + } + + function restoreMenuFocus(event: Event) { + event.preventDefault(); + if (restoreFocusRef.current && openerRef.current?.isConnected) { + openerRef.current.focus({ preventScroll: true }); + } + } + + function toggleSearch() { const next = !isSearchOpen; setSearchOpen(next); - // Clear any active query when collapsing so results aren't filtered by a hidden search. + // A hidden search must not leave the listing filtered. if (!next) onSearchChange?.(""); - }; + } return ( <> } + testId={currentPage === "threads" ? "threads-mobile-topbar" : undefined} + titleSlot={ + currentPage === "threads" ? ( + taskListingControls + ) : ( + + ) + } + className="h-14 min-h-14" showStatusTrigger={false} - // The brand link is this bar's home affordance; a home crumb on top - // of it would say the same thing twice. homeAffordance="none" - // The strip is this bar's flexible zone, so the bar's leftover width has - // to reach it. Left with the lead zone it would sit in the empty middle - // and the `flex-1` strip, whose base size is zero, would render at zero - // width with the actions unreachable behind it. - freeWidth="actions" - // The action strip scrolls rather than pushing the title crumb off the - // bar, so this cluster has to be the flexible one in the right zone. - actionsClassName="min-w-0 flex-1 !shrink gap-2" + freeWidth="lead" actions={ - + } /> + } /> ); diff --git a/apps/web/components/kanban/main-top-bar-plugin-actions.tsx b/apps/web/components/kanban/main-top-bar-plugin-actions.tsx index 3ff4e1e8ba..5575578c8f 100644 --- a/apps/web/components/kanban/main-top-bar-plugin-actions.tsx +++ b/apps/web/components/kanban/main-top-bar-plugin-actions.tsx @@ -60,7 +60,7 @@ export function MainTopBarPluginActions(props: { return (
{content} diff --git a/apps/web/components/kanban/mobile-listing-context.tsx b/apps/web/components/kanban/mobile-listing-context.tsx new file mode 100644 index 0000000000..59bf943992 --- /dev/null +++ b/apps/web/components/kanban/mobile-listing-context.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { Button } from "@kandev/ui/button"; +import { IconChevronDown } from "@tabler/icons-react"; + +export function MobileListingContext({ + context, + label, + ...props +}: ComponentProps & { context: string; label: string }) { + return ( + + ); +} diff --git a/apps/web/components/kanban/mobile-listing-menu-actions.tsx b/apps/web/components/kanban/mobile-listing-menu-actions.tsx new file mode 100644 index 0000000000..e5653779a4 --- /dev/null +++ b/apps/web/components/kanban/mobile-listing-menu-actions.tsx @@ -0,0 +1,104 @@ +"use client"; + +import type { RefObject } from "react"; +import { Button } from "@kandev/ui/button"; +import { IconMessageCircle, IconSearch, IconTerminal2 } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { QuickChatActivityIndicator } from "@/components/quick-chat/quick-chat-activity-indicator"; +import { useQuickChatActivity } from "@/components/quick-chat/use-quick-chat-activity"; +import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; +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 type { TaskListingPage } from "@/lib/task-listing/view-navigation"; + +export function MobileListingMenuActions({ + workspaceId, + workspaceLabel, + currentPage, + open, + closeMenu, + onToggleSearch, + isSearchOpen, + returnFocusRef, +}: { + workspaceId?: string; + workspaceLabel: string; + currentPage: TaskListingPage; + open: boolean; + closeMenu: (restoreFocus?: boolean) => void; + onToggleSearch?: () => void; + isSearchOpen: boolean; + 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) { + closeMenu(restoreFocus); + requestAnimationFrame(action); + } + + return ( +
+ {onToggleSearch && ( + + )} + {workspaceId && ( + <> + + + + )} + + {!statusBarEnabled && ( + + )} +
+ ); +} diff --git a/apps/web/components/kanban/mobile-listing-menu-button.tsx b/apps/web/components/kanban/mobile-listing-menu-button.tsx new file mode 100644 index 0000000000..3f439d0ec9 --- /dev/null +++ b/apps/web/components/kanban/mobile-listing-menu-button.tsx @@ -0,0 +1,61 @@ +"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-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index ce071b12d8..f06f6a5abc 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -37,12 +37,14 @@ import type { TaskListingPage } from "@/lib/task-listing/view-navigation"; export type MobileMenuSheetProps = { open: boolean; onOpenChange: (open: boolean) => void; + onCloseAutoFocus?: (event: Event) => void; workspaceId?: string; currentPage?: TaskListingPage; searchQuery?: string; onSearchChange?: (query: string) => void; isSearchLoading?: boolean; tasksListOptions?: TasksListDisplayOptions; + pageActions?: ReactNode; }; export type MobileDisplayOptionsProps = { @@ -317,6 +319,7 @@ function ResponsiveMenuSurface({ onOpenChange, contentRef, onOpenAutoFocus, + onCloseAutoFocus, children, }: { isMobile: boolean; @@ -324,6 +327,7 @@ function ResponsiveMenuSurface({ onOpenChange: (open: boolean) => void; contentRef: RefObject; onOpenAutoFocus: (event: Event) => void; + onCloseAutoFocus?: (event: Event) => void; children: ReactNode; }) { const { t } = useTranslation(); @@ -334,6 +338,7 @@ function ResponsiveMenuSurface({ ref={contentRef} tabIndex={-1} onOpenAutoFocus={onOpenAutoFocus} + onCloseAutoFocus={onCloseAutoFocus} className="h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] !max-h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] outline-none" >
@@ -371,6 +377,7 @@ function ResponsiveMenuSurface({ } function MobileMenuContent({ + isMobile, workspaceId, searchQuery, onSearchChange, @@ -381,10 +388,17 @@ function MobileMenuContent({ showPipeline, displayOptions, navControls, + pageActions, }: Pick< MobileMenuSheetProps, - "workspaceId" | "searchQuery" | "onSearchChange" | "isSearchLoading" | "onOpenChange" + | "workspaceId" + | "searchQuery" + | "onSearchChange" + | "isSearchLoading" + | "onOpenChange" + | "pageActions" > & { + isMobile: boolean; viewValue: string; onViewChange: (value: string) => void; showPipeline: boolean; @@ -393,11 +407,13 @@ function MobileMenuContent({ }) { return (
- + {pageActions ?? ( + + )} - {/* Home and Tasks are omitted here on purpose: the mobile header's brand - link is this surface's home affordance and the View toggle above - switches between Kanban and List. */} + {/* Phone Home lives in this menu; the View toggle owns listing modes. */} onOpenChange(false)} - omitSections={["primary"]} + omitSections={isMobile || viewValue === "threads" ? [] : ["primary"]} + omitDestinations={["tasks", "threads"]} workspaceActions={} controls={navControls} /> @@ -421,12 +436,14 @@ function MobileMenuContent({ export function MobileMenuSheet({ open, onOpenChange, + onCloseAutoFocus, workspaceId, currentPage = "kanban", searchQuery = "", onSearchChange, isSearchLoading = false, tasksListOptions, + pageActions, }: MobileMenuSheetProps) { const navControls = useAppNavDialogs(() => onOpenChange(false)); const { contentRef, isMobile, viewValue, handleViewChange, displayOptions, focusMenu } = @@ -439,6 +456,7 @@ export function MobileMenuSheet({ onOpenChange={onOpenChange} contentRef={contentRef} onOpenAutoFocus={focusMenu} + onCloseAutoFocus={onCloseAutoFocus} workspaceId={workspaceId} searchQuery={searchQuery} onSearchChange={onSearchChange} @@ -447,6 +465,7 @@ export function MobileMenuSheet({ onViewChange={handleViewChange} displayOptions={displayOptions} navControls={navControls} + pageActions={pageActions} /> ); } @@ -454,7 +473,14 @@ export function MobileMenuSheet({ function MobileMenuRender( props: Pick< MobileMenuSheetProps, - "open" | "onOpenChange" | "workspaceId" | "searchQuery" | "onSearchChange" | "isSearchLoading" + | "open" + | "onOpenChange" + | "onCloseAutoFocus" + | "workspaceId" + | "searchQuery" + | "onSearchChange" + | "isSearchLoading" + | "pageActions" > & { isMobile: boolean; contentRef: RefObject; diff --git a/apps/web/components/quick-chat/quick-chat-focus.test.ts b/apps/web/components/quick-chat/quick-chat-focus.test.ts index 1b857b81b8..a6a70f3b21 100644 --- a/apps/web/components/quick-chat/quick-chat-focus.test.ts +++ b/apps/web/components/quick-chat/quick-chat-focus.test.ts @@ -80,4 +80,22 @@ describe("quick chat launcher focus", () => { expect(document.activeElement).not.toBe(launcher); expect(launcher.getAttribute(SILENT_FOCUS_ATTRIBUTE)).toBeNull(); }); + + it("returns to the persistent opener when a menu launcher unmounts", () => { + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + const menuButton = document.createElement("button"); + const menuAction = document.createElement("button"); + document.body.append(menuButton, menuAction); + menuAction.focus(); + + captureQuickChatLauncherFocus({ returnFocusRef: { current: menuButton } }); + menuAction.remove(); + restoreQuickChatLauncherFocus(); + + expect(document.activeElement).toBe(menuButton); + expect(menuButton.getAttribute(SILENT_FOCUS_ATTRIBUTE)).toBe("true"); + }); }); diff --git a/apps/web/components/quick-chat/quick-chat-focus.ts b/apps/web/components/quick-chat/quick-chat-focus.ts index 2f4d1cc5e1..7260f023f2 100644 --- a/apps/web/components/quick-chat/quick-chat-focus.ts +++ b/apps/web/components/quick-chat/quick-chat-focus.ts @@ -1,3 +1,10 @@ +import type { RefObject } from "react"; + +export type QuickChatLauncherFocusOptions = { + silent?: boolean; + returnFocusRef?: RefObject; +}; + let launcherFocus: HTMLElement | null = null; let launcherFocusShouldBeSilent = false; let quickChatCloseHandler: (() => void) | null = null; @@ -11,10 +18,12 @@ function markFocusAsSilent(element: HTMLElement): void { } /** Records the control that opened the shared Quick Chat surface. */ -export function captureQuickChatLauncherFocus(options: { silent?: boolean } = {}): void { +export function captureQuickChatLauncherFocus(options: QuickChatLauncherFocusOptions = {}): void { launcherFocusShouldBeSilent = false; if (typeof document === "undefined") return; - launcherFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + launcherFocus = + options.returnFocusRef?.current ?? + (document.activeElement instanceof HTMLElement ? document.activeElement : null); launcherFocusShouldBeSilent = options.silent ?? true; } diff --git a/apps/web/components/task/mobile/mobile-picker-sheet.tsx b/apps/web/components/task/mobile/mobile-picker-sheet.tsx index a4a00ccffc..dbae835c02 100644 --- a/apps/web/components/task/mobile/mobile-picker-sheet.tsx +++ b/apps/web/components/task/mobile/mobile-picker-sheet.tsx @@ -18,6 +18,7 @@ type MobilePickerSheetProps = { contentTestId?: string; /** Optional trailing element rendered to the right of the title (e.g. a "+" CTA). */ headerAction?: ReactNode; + onCloseAutoFocus?: (event: Event) => void; /** Fixed content above the single scrolling picker region. */ fixedContent?: ReactNode; children: ReactNode; @@ -35,12 +36,13 @@ export function MobilePickerSheet({ description, contentTestId, headerAction, + onCloseAutoFocus, fixedContent, children, }: MobilePickerSheetProps) { return ( - +
{title} diff --git a/apps/web/components/threads/AGENTS.md b/apps/web/components/threads/AGENTS.md index bcb905e7b2..f2ef971ce6 100644 --- a/apps/web/components/threads/AGENTS.md +++ b/apps/web/components/threads/AGENTS.md @@ -40,6 +40,20 @@ taking a fixed slice, so two threads fill the deck and a busy deck scrolls horizontally instead of shrinking into slivers. The phone keeps one full-width snapping column, which is a deliberately different layout. +The phone title button opens one board-owned `MobileThreadPicker` using +admitted task summaries. Selection scrolls an existing shell; viewport +activation still owns transcript mounting. The picker restores focus without +scrolling back to the previous column. Phone headers prioritize task title, +status, and agent selection; workflow and step context remain in the picker. +Titles wrap to two lines. Inline topbar pagination shows position/count for +multiple threads and decorative dots only for decks of up to seven threads. +`ThreadsBoard.renderHeader` receives `mobileTaskId`, derived from board scroll +geometry by `useMobileThreadPosition`; the page derives its ordinal from stable +order without copying selection state. Do not derive pagination from loaded +chat or visibility-ID membership alone: both adjacent columns can stay +intersecting across a swipe midpoint. Position changes also refresh the nearest +visible detail calculation, retaining the one-phone-transcript limit. + The page reads `?workspace=` into the route it hands `useKanbanRouteBootstrap`. Without it a cross-workspace link loads whichever workspace the cookie last named. Scope changes from the shared header go through `listingHistoryHref`, diff --git a/apps/web/components/threads/mobile-thread-column-header.tsx b/apps/web/components/threads/mobile-thread-column-header.tsx new file mode 100644 index 0000000000..21bb1496c5 --- /dev/null +++ b/apps/web/components/threads/mobile-thread-column-header.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { IconArrowsMaximize, IconChevronDown } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { useTranslation } from "react-i18next"; +import type { ActiveThread } from "@/lib/threads/active-threads"; +import type { ThreadStatus } from "@/lib/threads/thread-session-status"; +import type { TaskSession } from "@/lib/types/http"; +import { ThreadSessionStatusIcon, ThreadSessionSwitcher } from "./thread-session-switcher"; + +export type MobileThreadNavigation = { + onChoose: () => void; +}; + +export function MobileThreadColumnHeader({ + thread, + status, + sessions, + selectedSessionId, + onSelectSession, + onOpenTask, + navigation, +}: { + thread: ActiveThread; + status: ThreadStatus; + sessions: readonly TaskSession[]; + selectedSessionId: string | null; + onSelectSession: (sessionId: string) => void; + onOpenTask: (taskId: string) => void; + navigation: MobileThreadNavigation; +}) { + const { t } = useTranslation(); + return ( +
+
+ + +
+
+ + + {t(status.labelKey)} + + +
+
+ ); +} diff --git a/apps/web/components/threads/mobile-thread-pagination.tsx b/apps/web/components/threads/mobile-thread-pagination.tsx new file mode 100644 index 0000000000..43c5b3f4d6 --- /dev/null +++ b/apps/web/components/threads/mobile-thread-pagination.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { cn } from "@kandev/ui/lib/utils"; +import { useTranslation } from "react-i18next"; + +export function MobileThreadPagination({ position, total }: { position: number; total: number }) { + const { t } = useTranslation(); + if (total < 2 || position < 1 || position > total) return null; + return ( + + {total <= 7 && ( + + ); +} diff --git a/apps/web/components/threads/mobile-thread-picker.tsx b/apps/web/components/threads/mobile-thread-picker.tsx new file mode 100644 index 0000000000..e60a15b9d8 --- /dev/null +++ b/apps/web/components/threads/mobile-thread-picker.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { IconCheck } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet"; +import type { ActiveThread } from "@/lib/threads/active-threads"; +import { resolveThreadColumnStatus } from "@/lib/threads/thread-session-status"; +import { ThreadSessionStatusIcon } from "./thread-session-switcher"; + +export function MobileThreadPicker({ + threads, + selectedTaskId, + open, + onOpenChange, + onSelect, + onCloseAutoFocus, +}: { + threads: readonly ActiveThread[]; + selectedTaskId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (taskId: string) => void; + onCloseAutoFocus: (event: Event) => void; +}) { + const { t } = useTranslation(); + return ( + +
+ {threads.map((thread) => { + const selected = selectedTaskId === thread.taskId; + const status = resolveThreadColumnStatus({ + taskState: thread.taskState, + reviewStatus: thread.reviewStatus, + taskPendingAction: thread.taskPendingAction, + session: { state: thread.sessionState, pending_action: thread.pendingAction }, + }); + return ( + + ); + })} +
+
+ ); +} diff --git a/apps/web/components/threads/thread-column-activation.test.tsx b/apps/web/components/threads/thread-column-activation.test.tsx index 486eeb01a4..9083dc08f7 100644 --- a/apps/web/components/threads/thread-column-activation.test.tsx +++ b/apps/web/components/threads/thread-column-activation.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useCallback } from "react"; import type { ResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; @@ -23,6 +23,7 @@ const TASK_C = "c"; const TASK_D = "d"; const DETAIL_IDS = "detail-ids"; const PRELOAD_IDS = "preload-ids"; +const MOBILE_TASK_ID = "mobile-task"; class MockIntersectionObserver implements IntersectionObserver { readonly root: Element | Document | null = null; @@ -91,6 +92,7 @@ function ActivationFixture({ ids, focusedTaskId }: { ids: string[]; focusedTaskI
{[...activation.preloadTaskIds].join(",")} {[...activation.detailTaskIds].join(",")} + {activation.mobileTaskId} {ids.map((id) => ( ))} @@ -105,10 +107,119 @@ function ids(testId: string): string[] { afterEach(() => { cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); observers.length = 0; }); +function mobileGeometry() { + const board = screen.getByTestId("activation-board"); + const a = screen.getByTestId(`column-${TASK_A}`); + const b = screen.getByTestId(`column-${TASK_B}`); + vi.spyOn(board, "getBoundingClientRect").mockReturnValue({ left: 0, right: 300 } as DOMRect); + const rectA = vi.spyOn(a, "getBoundingClientRect"); + const rectB = vi.spyOn(b, "getBoundingClientRect"); + function move(offset: number) { + rectA.mockReturnValue({ left: -offset, right: 300 - offset } as DOMRect); + rectB.mockReturnValue({ left: 300 - offset, right: 600 - offset } as DOMRect); + fireEvent.scroll(board); + } + return { board, a, b, move }; +} + +describe("phone position feedback", () => { + let resize: ResizeObserverCallback; + const disconnectResize = vi.fn(); + + beforeEach(() => { + responsiveMocks.useResponsiveBreakpoint.mockReturnValue(mobileBreakpoint()); + vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame"] }); + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: ResizeObserverCallback) { + resize = callback; + } + observe() {} + disconnect = disconnectResize; + }, + ); + disconnectResize.mockClear(); + }); + + function frame() { + act(() => vi.advanceTimersToNextFrame()); + } + + // @covers AC-UI-THREADS-DECK-003.13 + it("updates mobile position across the midpoint without changing intersecting membership", () => { + render(); + const { a, b, move } = mobileGeometry(); + move(30); + observers[0].instance.emit( + { target: a, isIntersecting: true, intersectionRatio: 0.9 }, + { target: b, isIntersecting: true, intersectionRatio: 0.1 }, + ); + frame(); + expect(ids(DETAIL_IDS)).toEqual([TASK_A]); + + move(180); + observers[0].instance.emit( + { target: a, isIntersecting: true, intersectionRatio: 0.4 }, + { target: b, isIntersecting: true, intersectionRatio: 0.6 }, + ); + frame(); + expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(TASK_B); + expect(ids(DETAIL_IDS)).toEqual([TASK_B]); + + move(90); + frame(); + expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(TASK_A); + }); + + it("reconciles resize, removal, and an empty deck without waiting for visibility", () => { + const view = render(); + const { board, move } = mobileGeometry(); + move(180); + frame(); + expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(TASK_B); + + vi.mocked(board.getBoundingClientRect).mockReturnValue({ left: 0, right: 50 } as DOMRect); + act(() => resize([], {} as ResizeObserver)); + frame(); + expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(TASK_A); + + view.rerender(); + expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(TASK_B); + view.rerender(); + expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(""); + }); + + it("uses deep-link fallback before geometry is available and clears on desktop", () => { + const view = render(); + expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(TASK_B); + responsiveMocks.useResponsiveBreakpoint.mockReturnValue(desktopBreakpoint()); + view.rerender(); + expect(screen.getByTestId(MOBILE_TASK_ID).textContent).toBe(""); + expect(disconnectResize).toHaveBeenCalled(); + }); + + it("coalesces scroll work and cancels pending frames on unmount", () => { + const view = render(); + const { board, move } = mobileGeometry(); + move(180); + move(190); + expect(vi.getTimerCount()).toBe(1); + view.unmount(); + expect(vi.getTimerCount()).toBe(0); + expect(disconnectResize).toHaveBeenCalledTimes(1); + fireEvent.scroll(board); + expect(vi.getTimerCount()).toBe(0); + }); +}); + describe("useThreadColumnActivation", () => { beforeEach(() => { responsiveMocks.useResponsiveBreakpoint.mockReturnValue(desktopBreakpoint()); diff --git a/apps/web/components/threads/thread-column.tsx b/apps/web/components/threads/thread-column.tsx index c6d72064d8..d241e935c3 100644 --- a/apps/web/components/threads/thread-column.tsx +++ b/apps/web/components/threads/thread-column.tsx @@ -12,6 +12,10 @@ import { selectThreadSessionId } from "@/lib/threads/thread-session-selection"; import { resolveThreadColumnStatus, type ThreadStatus } from "@/lib/threads/thread-session-status"; import { ThreadConversation } from "./thread-conversation"; import { ThreadSessionStatusIcon, ThreadSessionSwitcher } from "./thread-session-switcher"; +import { + MobileThreadColumnHeader, + type MobileThreadNavigation, +} from "./mobile-thread-column-header"; export function resolveThreadStatus(thread: ActiveThread): ThreadStatus { return resolveThreadColumnStatus({ @@ -200,6 +204,7 @@ function ThreadSessionMembership({ type ThreadColumnProps = { thread: ActiveThread; + mobileNavigation?: MobileThreadNavigation; isFocused?: boolean; isPreloaded?: boolean; isDetailActive?: boolean; @@ -216,6 +221,7 @@ function ThreadColumnHeader({ selectedSessionId, onSelectSession, onOpenTask, + mobileNavigation, }: { thread: ActiveThread; status: ThreadStatus; @@ -223,8 +229,22 @@ function ThreadColumnHeader({ selectedSessionId: string | null; onSelectSession: (sessionId: string) => void; onOpenTask: (taskId: string) => void; + mobileNavigation?: MobileThreadNavigation; }) { const { t } = useTranslation(); + if (mobileNavigation) { + return ( + + ); + } return (
@@ -283,7 +303,7 @@ function ThreadColumnBody({ onInvalidRequestedSession?: (taskId: string, sessionId: string) => void; }) { return ( -
+
{isPreloaded && ( { setSelectedSessionId(sessionId); }, []); - const handleSessions = useCallback((nextSessions: TaskSession[]) => { + const handleSessionListState = useCallback((nextSessions: TaskSession[], isLoaded: boolean) => { setSessions(nextSessions); + setSessionListReady(isLoaded); }, []); - const handleSessionListState = useCallback( - (nextSessions: TaskSession[], isLoaded: boolean) => { - handleSessions(nextSessions); - setSessionListReady(isLoaded); - }, - [handleSessions], - ); const handleRequestedSessionResolved = useCallback(() => { setRequestedSessionResolved(true); }, []); @@ -396,10 +411,11 @@ export function ThreadColumn({ // state, not focus, so in a deck of composers nothing else // says where typing would land. // outline — the column a deep link asked for. - className="flex h-full min-h-0 w-[85vw] shrink-0 snap-start flex-col overflow-hidden rounded-lg border bg-card focus-within:ring-2 focus-within:ring-ring data-[focused=true]:outline data-[focused=true]:outline-2 data-[focused=true]:outline-offset-2 data-[focused=true]:outline-primary md:w-auto md:min-w-[360px] md:flex-1 md:shrink" + className="flex h-full min-h-0 min-w-0 w-full shrink-0 snap-start flex-col overflow-hidden bg-card focus-within:ring-2 focus-within:ring-ring data-[focused=true]:outline data-[focused=true]:outline-2 data-[focused=true]:outline-offset-[-2px] data-[focused=true]:outline-primary md:w-auto md:min-w-[360px] md:flex-1 md:shrink md:rounded-lg md:border md:data-[focused=true]:outline-offset-2" > setOpen(true)} data-testid="thread-session-picker-trigger" @@ -305,7 +306,10 @@ export function ThreadSessionSwitcher({ if (views.length <= 1) return null; return isMobile ? ( -
+
, +): string | null { + if (candidateIds.length === 0) return null; + if (!board) return candidateIds[0] ?? null; + const boardRect = board.getBoundingClientRect(); + const boardCenter = (boardRect.left + boardRect.right) / 2; + let nearest: string | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const id of candidateIds) { + const element = elements.get(id); + if (!element) continue; + const rect = element.getBoundingClientRect(); + const distance = Math.abs((rect.left + rect.right) / 2 - boardCenter); + if (distance < nearestDistance) { + nearest = id; + nearestDistance = distance; + } + } + return nearest ?? candidateIds[0] ?? null; +} diff --git a/apps/web/components/threads/threads-board.tsx b/apps/web/components/threads/threads-board.tsx index beb1ed423d..53fab96578 100644 --- a/apps/web/components/threads/threads-board.tsx +++ b/apps/web/components/threads/threads-board.tsx @@ -1,11 +1,13 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState, type ReactNode } from "react"; import { IconColumns } from "@tabler/icons-react"; import type { ActiveThread } from "@/lib/threads/active-threads"; import { useTranslation } from "react-i18next"; import { ThreadColumn } from "./thread-column"; import { useThreadColumnActivation } from "./use-thread-column-activation"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { MobileThreadPicker } from "./mobile-thread-picker"; type ThreadsBoardProps = { threads: ActiveThread[]; @@ -17,6 +19,8 @@ type ThreadsBoardProps = { /** Removes a target session query after the target column proves it invalid. */ onInvalidRequestedSession?: (taskId: string, sessionId: string) => void; onOpenTask: (taskId: string) => void; + /** Composes the page header with the viewport's active phone thread. */ + renderHeader?: (activeMobileTaskId: string | null) => ReactNode; }; function ThreadsPlaceholder({ testId, children }: { testId: string; children: React.ReactNode }) { @@ -86,41 +90,94 @@ export function ThreadsBoard({ focusedSessionId = null, onInvalidRequestedSession, onOpenTask, + renderHeader, }: ThreadsBoardProps) { const { markedTaskId, retire } = useRetiringFocusMark(focusedTaskId); + const { isMobile } = useResponsiveBreakpoint(); + const [pickerOpen, setPickerOpen] = useState(false); + if (!isMobile && pickerOpen) setPickerOpen(false); + const returnFocusTaskId = useRef(null); const orderedIds = useMemo(() => threads.map((thread) => thread.taskId), [threads]); - const { boardRef, registerColumn, preloadTaskIds, detailTaskIds } = useThreadColumnActivation( - orderedIds, - focusedTaskId, - ); + const { boardRef, registerColumn, preloadTaskIds, detailTaskIds, mobileTaskId } = + useThreadColumnActivation(orderedIds, focusedTaskId); - if (threads.length === 0) { - return isLoading ? : ; + function taskColumn(taskId: string | null) { + return Array.from(boardRef.current?.children ?? []).find( + (element) => element.getAttribute("data-thread-column-id") === taskId, + ); + } + + function selectThread(taskId: string) { + returnFocusTaskId.current = taskId; + taskColumn(taskId)?.scrollIntoView({ inline: "start", block: "nearest", behavior: "instant" }); + setPickerOpen(false); + } + + function restorePickerFocus(event: Event) { + const column = + taskColumn(returnFocusTaskId.current) ?? + taskColumn(mobileTaskId) ?? + taskColumn(orderedIds[0] ?? null); + const trigger = column?.querySelector( + '[data-testid="thread-picker-trigger"]', + ); + if (!trigger) return; + event.preventDefault(); + trigger.focus({ preventScroll: true }); } return ( -
- {threads.map((thread) => ( - + {renderHeader?.(mobileTaskId)} + {threads.length === 0 ? ( +
+ {isLoading ? : } +
+ ) : ( +
+ {threads.map((thread) => ( + { + returnFocusTaskId.current = thread.taskId; + setPickerOpen(true); + }, + } + : undefined + } + isFocused={thread.taskId === markedTaskId} + requestedSessionId={thread.taskId === focusedTaskId ? focusedSessionId : null} + isPreloaded={preloadTaskIds.has(thread.taskId)} + isDetailActive={detailTaskIds.has(thread.taskId)} + onInvalidRequestedSession={onInvalidRequestedSession} + onColumnRef={registerColumn} + onOpenTask={onOpenTask} + /> + ))} +
+ )} + {isMobile && threads.length > 0 && ( + - ))} + )}
); } diff --git a/apps/web/components/threads/threads-view-controls.tsx b/apps/web/components/threads/threads-view-controls.tsx index 05846ac051..0fa4b4a329 100644 --- a/apps/web/components/threads/threads-view-controls.tsx +++ b/apps/web/components/threads/threads-view-controls.tsx @@ -3,6 +3,7 @@ import { useMemo, useRef, useState } from "react"; import { IconAdjustments, + IconAlertTriangle, IconArrowLeft, IconCheck, IconChevronDown, @@ -20,6 +21,7 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/dra import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; +import { MobileListingContext } from "@/components/kanban/mobile-listing-context"; import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { @@ -299,6 +301,7 @@ function MobileThreadsViewControls({ onDismissSyncError, }: MobileThreadsViewControlsProps) { const { t } = useTranslation(); + const { isMobile } = useResponsiveBreakpoint(); const activeViewName = threadViewName(activeView, t); const [open, setOpen] = useState(false); const [page, setPage] = useState<"views" | "editor">("views"); @@ -326,7 +329,7 @@ function MobileThreadsViewControls({ return ( <> - {syncError && ( + {syncError && !isMobile && ( )} - + {isMobile ? ( + + ) : ( + + )} + {syncError && isMobile && ( + + + )} { @@ -391,6 +417,14 @@ function MobileThreadsViewControls({ className="min-h-0 flex-1 overflow-y-auto overscroll-contain" data-testid="threads-mobile-view-drawer-scroll-region" > + {syncError && isMobile && ( + + )} {page === "views" ? ( { + const boardRef = { current: document.createElement("div") as HTMLDivElement | null }; + const elementsRef = { current: new Map() }; + const committed: Array = []; + const { result, rerender } = renderHook( + ({ enabled, fallbackTaskId }) => { + const taskId = useMobileThreadPosition({ + enabled, + fallbackTaskId, + boardRef, + elementsRef, + orderedIds: ["a", "b"], + }); + useLayoutEffect(() => { + committed.push(taskId); + }); + return taskId; + }, + { initialProps: { enabled: true, fallbackTaskId: "a" } }, + ); + expect(result.current).toBe("a"); + rerender({ enabled: false, fallbackTaskId: "b" }); + expect(result.current).toBeNull(); + boardRef.current = null; + committed.length = 0; + rerender({ enabled: true, fallbackTaskId: "b" }); + expect(committed[0]).toBe("b"); + expect(result.current).toBe("b"); +}); diff --git a/apps/web/components/threads/use-mobile-thread-position.ts b/apps/web/components/threads/use-mobile-thread-position.ts new file mode 100644 index 0000000000..b4988f565b --- /dev/null +++ b/apps/web/components/threads/use-mobile-thread-position.ts @@ -0,0 +1,53 @@ +import { useEffect, useState, type RefObject } from "react"; +import { nearestTaskId } from "./thread-viewport-geometry"; + +/** Phone orientation follows scroll geometry, independently of detail loading. */ +export function useMobileThreadPosition({ + enabled, + boardRef, + elementsRef, + orderedIds, + fallbackTaskId, +}: { + enabled: boolean; + boardRef: RefObject; + elementsRef: RefObject>; + orderedIds: readonly string[]; + fallbackTaskId: string | null; +}): string | null { + const [taskId, setTaskId] = useState(null); + const idsKey = orderedIds.join("\u0000"); + if (!enabled && taskId !== null) setTaskId(null); + + useEffect(() => { + const board = boardRef.current; + if (!enabled || !board) return; + const ids = idsKey ? idsKey.split("\u0000") : []; + let frame: number | null = null; + let disposed = false; + const measure = () => { + frame = null; + if (disposed) return; + const rect = board.getBoundingClientRect(); + const next = + rect.right > rect.left ? nearestTaskId(ids, board, elementsRef.current) : fallbackTaskId; + setTaskId((previous) => (previous === next ? previous : next)); + }; + const schedule = () => { + if (!disposed && frame === null) frame = requestAnimationFrame(measure); + }; + board.addEventListener("scroll", schedule, { passive: true }); + const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule); + observer?.observe(board); + measure(); + return () => { + disposed = true; + board.removeEventListener("scroll", schedule); + observer?.disconnect(); + if (frame !== null) cancelAnimationFrame(frame); + }; + }, [enabled, boardRef, elementsRef, idsKey, fallbackTaskId]); + + if (!enabled) return null; + return taskId && orderedIds.includes(taskId) ? taskId : fallbackTaskId; +} diff --git a/apps/web/components/threads/use-thread-column-activation.ts b/apps/web/components/threads/use-thread-column-activation.ts index c01e09d0de..535ebf81e7 100644 --- a/apps/web/components/threads/use-thread-column-activation.ts +++ b/apps/web/components/threads/use-thread-column-activation.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { nearestTaskId } from "./thread-viewport-geometry"; +import { useMobileThreadPosition } from "./use-mobile-thread-position"; type VisibilityEntry = { element: Element; @@ -11,6 +13,7 @@ export type ThreadColumnActivation = { registerColumn: (taskId: string, element: HTMLElement | null) => void; preloadTaskIds: ReadonlySet; detailTaskIds: ReadonlySet; + mobileTaskId: string | null; }; function sameIds(a: ReadonlySet, b: ReadonlySet): boolean { @@ -28,30 +31,6 @@ function addAdjacent(ids: Set, orderedIds: readonly string[], id: string if (index < orderedIds.length - 1) ids.add(orderedIds[index + 1]); } -function nearestTaskId( - candidateIds: readonly string[], - board: HTMLElement | null, - elements: ReadonlyMap, -): string | null { - if (candidateIds.length === 0) return null; - if (!board) return candidateIds[0] ?? null; - const boardRect = board.getBoundingClientRect(); - const boardCenter = (boardRect.left + boardRect.right) / 2; - let nearest: string | null = null; - let nearestDistance = Number.POSITIVE_INFINITY; - for (const id of candidateIds) { - const element = elements.get(id); - if (!element) continue; - const rect = element.getBoundingClientRect(); - const distance = Math.abs((rect.left + rect.right) / 2 - boardCenter); - if (distance < nearestDistance) { - nearest = id; - nearestDistance = distance; - } - } - return nearest ?? candidateIds[0] ?? null; -} - function resolveVisibleIds( orderedIds: readonly string[], visibleIds: ReadonlySet, @@ -188,19 +167,27 @@ export function useThreadColumnActivation( return orderedIds[0] ?? null; }, [focusedTaskId, idsKey]); + const mobileTaskId = useMobileThreadPosition({ + enabled: isMobile, + boardRef, + elementsRef, + orderedIds, + fallbackTaskId, + }); + const sets = useMemo( () => buildActivationSets({ orderedIds, visibleIds, observerReady, - fallbackTaskId, + fallbackTaskId: isMobile ? mobileTaskId : fallbackTaskId, isMobile, board: boardRef.current, elements: elementsRef.current, }), - [fallbackTaskId, idsKey, isMobile, observerReady, visibleIds], + [fallbackTaskId, idsKey, isMobile, mobileTaskId, observerReady, visibleIds], ); - return { boardRef, registerColumn, ...sets }; + return { boardRef, registerColumn, mobileTaskId, ...sets }; } diff --git a/apps/web/e2e/helpers/mobile-threads-swipe-helpers.test.ts b/apps/web/e2e/helpers/mobile-threads-swipe-helpers.test.ts new file mode 100644 index 0000000000..77c227cdfc --- /dev/null +++ b/apps/web/e2e/helpers/mobile-threads-swipe-helpers.test.ts @@ -0,0 +1,26 @@ +import type { Page } from "@playwright/test"; +import { describe, expect, it } from "vitest"; +import { swipeDeckLeft } from "../tests/task/mobile-threads-swipe-helpers"; + +describe("swipeDeckLeft cleanup", () => { + it.each(["touchStart", "touchEnd"])("detaches CDP when %s fails", async (failure) => { + const events: string[] = []; + const page = { + getByTestId: () => ({ boundingBox: async () => ({ x: 0, y: 56, width: 360, height: 700 }) }), + context: () => ({ + newCDPSession: async () => ({ + send: async (_method: string, { type }: { type: string }) => { + events.push(type); + if (type === failure) throw new Error(failure); + }, + detach: async () => { + events.push("detach"); + }, + }), + }), + } as unknown as Page; + + await expect(swipeDeckLeft(page)).rejects.toThrow(failure); + expect(events.slice(-2)).toEqual(["touchEnd", "detach"]); + }); +}); diff --git a/apps/web/e2e/pages/mobile-kanban-page.ts b/apps/web/e2e/pages/mobile-kanban-page.ts index 9525d0b339..89ae10efe1 100644 --- a/apps/web/e2e/pages/mobile-kanban-page.ts +++ b/apps/web/e2e/pages/mobile-kanban-page.ts @@ -55,6 +55,7 @@ export class MobileKanbanPage { } async openSearch() { + await this.mobileMenuButton.click(); await this.mobileSearchToggle.click(); await this.mobileSearchBar.waitFor({ state: "visible" }); } 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 7873d9f462..8620822d05 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 @@ -3,7 +3,7 @@ * * Desktop opens Quick Chat via keyboard shortcut, command palette, or the app * sidebar — none of which exist on a touch viewport. These tests cover the - * touch affordances: the kanban header button on Home, the task-switcher sheet + * touch affordances: the listing menu on Home, the task-switcher sheet * button on a session page, and the explicit close control (touch devices have * no Escape key and the full-screen dialog leaves no overlay to tap). * @@ -15,8 +15,8 @@ import { SessionPage } from "../../pages/session-page"; import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; import { readQuickChatViewportLayout, - sendQuickChatMessage, startQuickChatFromSetup, + waitForQuickChatComposerReady, } from "./quick-chat-helpers"; const TASK_LISTING_VIEW_STORAGE_KEY = "kandev.taskListing.view.v1"; @@ -25,6 +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("mobile-quick-chat-button").tap(); const dialog = testPage.getByRole("dialog", { name: "Quick Chat" }); @@ -38,7 +39,12 @@ test.describe("Quick Chat entry points on mobile", () => { expect(layout.dialogBottom - layout.contentBottom).toBeLessThanOrEqual(2); expect(layout.dialogScrollHeight).toBeLessThanOrEqual(layout.dialogClientHeight + 1); - await sendQuickChatMessage(dialog, testPage, "/e2e:bulk:20"); + // Submit once: replaying a successful send while awaiting editor clearing + // creates duplicate bulk turns and changes the layout under measurement. + const editor = await waitForQuickChatComposerReady(dialog); + await editor.fill("/e2e:bulk:20"); + await dialog.getByTestId("submit-message-button").tap(); + await expect(editor).toHaveText(""); await expect(dialog.getByText(/Done\. Emitted 20 messages/)).toBeVisible({ timeout: 30_000 }); const longChatLayout = await readQuickChatViewportLayout(dialog); @@ -53,11 +59,12 @@ test.describe("Quick Chat entry points on mobile", () => { }); // @covers AC-UI-QUICK-CHAT-ELEVATION-001.4 AC-UI-QUICK-CHAT-ELEVATION-001.7 - test("opens from the home header and closes with the touch control", async ({ testPage }) => { + test("opens from the home menu and closes with the touch control", async ({ testPage }) => { await testPage.goto("/"); await testPage.waitForLoadState("networkidle"); await assertNoDocumentHorizontalOverflow(testPage); + await testPage.getByTestId("mobile-topbar-menu").tap(); await testPage.getByTestId("mobile-quick-chat-button").tap(); const dialog = testPage.getByRole("dialog", { name: "Quick Chat" }); @@ -77,10 +84,13 @@ 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(); }); test("chooses configuration mode from the setup panel", async ({ testPage }) => { await testPage.goto("/"); + const context = testPage.getByTestId("mobile-topbar-page-context"); + await context.tap(); await testPage.getByTestId("mobile-quick-chat-button").tap(); const dialog = testPage.getByRole("dialog", { name: "Quick Chat" }); @@ -90,6 +100,9 @@ test.describe("Quick Chat entry points on mobile", () => { await expect(dialog.getByTestId("config-chat-setup")).toBeVisible(); await assertNoDocumentHorizontalOverflow(testPage); + await dialog.getByTestId("quick-chat-close").tap(); + await expect(dialog).toBeHidden(); + await expect(context).toBeFocused(); }); test("opens from the task switcher sheet on a session page", async ({ @@ -129,7 +142,8 @@ test.describe("Quick Chat entry points on mobile", () => { await testPage.goto(`/tasks?workspace=${seedData.workspaceId}`); await testPage.waitForLoadState("networkidle"); - const homeLink = testPage.getByRole("link", { name: "Kandev home" }); + await testPage.getByTestId("mobile-topbar-menu").tap(); + const homeLink = testPage.getByRole("link", { name: "Home", exact: true }); await expect(homeLink).toHaveAttribute( "href", `/?home=overview&workspaceId=${seedData.workspaceId}`, 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 fc01d58c65..8666f6dec6 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,6 +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("mobile-quick-chat-button").tap(); const dialog = testPage.getByRole("dialog", { name: "Quick Chat" }); const created = testPage.waitForResponse( @@ -31,7 +32,7 @@ test.describe("quick chat activity indicators", () => { task_id: string; }; const tab = dialog.getByTestId("quick-chat-tab"); - const button = testPage.getByTestId("mobile-quick-chat-button"); + const button = testPage.getByTestId("mobile-topbar-menu"); const indicator = button.getByTestId("quick-chat-activity-indicator"); await expect(tab).toHaveCount(1); @@ -55,6 +56,7 @@ test.describe("quick chat activity indicators", () => { await expect(indicator).toHaveAttribute("data-state", "finished"); await button.tap(); + await testPage.getByTestId("mobile-quick-chat-button").tap(); await expect(indicator).toHaveCount(0); await expect(dialog).toBeVisible(); }); 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 90d844cd20..0b31b6f3c0 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,6 +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("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/quick-chat-saved-prompt-delivery-helpers.ts b/apps/web/e2e/tests/chat/quick-chat-saved-prompt-delivery-helpers.ts index c991bf13c7..1d07164412 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,6 +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("mobile-quick-chat-button").tap(); const dialog = page.getByRole("dialog", { name: "Quick Chat" }); 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 c04052a5f3..707c458b24 100644 --- a/apps/web/e2e/tests/kanban/mobile-kanban-topbar.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-kanban-topbar.spec.ts @@ -1,103 +1,253 @@ import { expect, test } from "../../fixtures/test-base"; import { assertNoDocumentHorizontalOverflow, requireBox } from "../../helpers/layout-assertions"; -import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; - -/** - * The phone board and task-list headers render through `PageTopbar` rather - * than a bespoke mobile bar, so the chrome contract's phone behaviour needs - * covering at phone width: the title crumb names the page, the brand link is - * the only home affordance, and the action strip absorbs pressure instead of - * pushing fixed chrome off-screen. - */ -test.describe("Mobile kanban topbar", () => { - test("names the page through the title crumb on the board and the task list", async ({ - testPage, - }) => { - const mobile = new MobileKanbanPage(testPage); - await mobile.goto(); - - const header = testPage.locator("header").first(); - const titleCrumb = header.locator('[data-slot="breadcrumb-page"]'); - await expect(titleCrumb).toHaveText("Home"); +import { waitForHttp } from "../../helpers/causal-waits"; +import { createStandardProfile, openTaskSession } from "../../helpers/git-helper"; +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"); - await expect(titleCrumb).toHaveText("Tasks"); - // The two-line title/workspace stack the bar used to render is gone: the - // workspace picker owns workspace identity, the crumb owns the page name. - await expect(header.getByTestId("mobile-topbar-page-context")).toHaveCount(0); + const opener = testPage.getByTestId("mobile-topbar-menu"); + const menu = testPage.getByRole("dialog", { name: "Menu", exact: true }); + const search = testPage.getByPlaceholder("Search tasks..."); + await opener.tap(); + await menu.getByTestId("mobile-search-toggle").tap(); + await expect(menu).toBeHidden(); + await expect(search).toBeFocused(); + await search.fill("checkout"); + await opener.tap(); + await menu.getByTestId("mobile-search-toggle").tap(); + await expect(menu).toBeHidden(); + await expect(search).toBeHidden(); + await expect(opener).toBeFocused(); + await opener.tap(); + await menu.getByTestId("mobile-search-toggle").tap(); + await expect(search).toHaveValue(""); + await expect(search).toBeFocused(); }); - test("uses the brand link as the only home affordance", async ({ testPage }) => { - const mobile = new MobileKanbanPage(testPage); - await mobile.goto(); + test("keeps Home but does not duplicate Threads in the tablet menu", async ({ testPage }) => { + await testPage.setViewportSize({ width: 820, height: 1180 }); + await testPage.goto("/threads"); + await testPage + .locator("header") + .first() + .getByRole("button", { name: "Open menu", exact: true }) + .tap(); + const menu = testPage.getByRole("dialog", { name: "Menu", exact: true }); + await expect(menu.getByRole("link", { name: "Home", exact: true })).toBeVisible(); + await expect(menu.getByRole("radio", { name: "Threads", exact: true })).toHaveAttribute( + "data-state", + "on", + ); + await expect(menu.getByRole("link", { name: "Threads", exact: true })).toHaveCount(0); + }); - const header = testPage.locator("header").first(); - const brand = header.getByTestId("mobile-topbar-brand"); - await expect(brand).toBeVisible(); - // `homeAffordance="none"`: a home crumb sitting next to a brand link that - // already goes home would say the same thing twice. - await expect(header.getByTestId("topbar-phone-home")).toHaveCount(0); + test("keeps saved-view sync recovery inside the drawer without crowding the topbar", async ({ + testPage, + apiClient, + seedData, + }) => { + const profile = await createStandardProfile(apiClient, "mobile-topbar-recovery"); + for (const title of ["First recovery thread", "Second recovery thread"]) { + const task = await apiClient.createTaskWithAgent(seedData.workspaceId, title, profile.id, { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + await openTaskSession(testPage, title); + await waitForLatestSessionDone(apiClient, task.id, 1, `agent turn for ${title}`); + } + await testPage.setViewportSize({ width: 360, height: 851 }); + await testPage.goto("/threads"); + let rejectWrites = true; + await testPage.route("**/api/v1/user/settings", async (route) => { + const request = route.request(); + if (rejectWrites && request.method() === "PATCH" && request.postDataJSON()?.thread_views) { + await route.fulfill({ status: 500, json: { error: "Saved views unavailable" } }); + } else { + await route.continue(); + } + }); - // From the board the brand stays a same-page home link; from the task list - // it must restore the preferred listing view instead of forcing the board - // — visiting /tasks stores "list", and Home honors that stored preference - // with the workspace preserved (the same contract - // chat/mobile-quick-chat-entry.spec.ts pins for the URL). The brand is - // still the bar's only home affordance either way. + const header = testPage.getByTestId("threads-mobile-topbar"); + const trigger = header.getByTestId("threads-mobile-view-trigger"); + const drawer = testPage.getByTestId("threads-mobile-view-drawer"); + await trigger.tap(); + const failedWrite = waitForHttp(testPage, "PATCH", /\/api\/v1\/user\/settings$/, { + predicate: (response) => response.status() === 500, + }); + await drawer.getByTestId("threads-mobile-new-view").tap(); + await failedWrite; + const recovery = drawer.getByTestId("threads-view-sync-error"); + await expect(recovery).toBeVisible(); + await expect(header.getByTestId("threads-view-sync-error")).toHaveCount(0); + await testPage.keyboard.press("Escape"); + await expect(drawer).toBeHidden(); + await expect(header.getByTestId("threads-mobile-view-sync-status")).toBeVisible(); + 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")]) { + expect( + await target.evaluate((element) => { + const box = element.getBoundingClientRect(); + return element.contains( + document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2), + ); + }), + ).toBe(true); + } + const cueBox = await requireBox(cue, "pagination during sync failure"); + const menuBox = await requireBox(header.getByTestId("mobile-topbar-menu"), "menu"); + expect(cueBox.x + cueBox.width).toBeLessThanOrEqual(menuBox.x); + await assertNoDocumentHorizontalOverflow(testPage, "saved-view sync failure"); + + await trigger.tap(); + for (const id of ["threads-view-sync-retry", "threads-view-sync-dismiss"]) { + expect( + (await requireBox(recovery.getByTestId(id), "recovery action")).height, + ).toBeGreaterThanOrEqual(44); + } + await recovery.getByTestId("threads-view-sync-dismiss").tap(); + await expect(recovery).toHaveCount(0); + await expect(header.getByTestId("threads-mobile-view-sync-status")).toHaveCount(0); + const failedAgain = waitForHttp(testPage, "PATCH", /\/api\/v1\/user\/settings$/, { + predicate: (response) => response.status() === 500, + }); + await drawer.getByTestId("threads-mobile-new-view").tap(); + await failedAgain; + await expect(recovery).toBeVisible(); + rejectWrites = false; + const retried = waitForHttp(testPage, "PATCH", /\/api\/v1\/user\/settings$/, { + predicate: (response) => response.ok(), + }); + await recovery.getByTestId("threads-view-sync-retry").tap(); + await retried; + await expect(recovery).toHaveCount(0); + await testPage.reload(); + await expect(trigger).toContainText("New view"); + await expect(header.getByTestId("threads-mobile-view-sync-status")).toHaveCount(0); + }); + + test("keeps all three modes compact with native context and menu targets", async ({ + testPage, + }) => { + test.setTimeout(120_000); + for (const width of [360, 393]) { + await testPage.setViewportSize({ width, height: 851 }); + for (const [route, label] of [ + ["/?home=overview", "Kanban"], + ["/tasks", "List"], + ["/threads", "All threads"], + ]) { + if (label === "Kanban") { + await testPage.goto("/tasks"); + await testPage.getByTestId("mobile-topbar-menu").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"); + await expect(context).toContainText(label); + await expect(header.getByTestId("mobile-topbar-brand")).toHaveCount(0); + await expect(header.getByTestId("mobile-topbar-action-strip")).toHaveCount(0); + const headerBox = await requireBox(header, "shared phone header"); + expect(headerBox.height).toBeCloseTo(56, 0); + for (const target of [context, menu]) { + const box = await requireBox(target, "phone header control"); + expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.width).toBeGreaterThanOrEqual(44); + expect(box.x + box.width).toBeLessThanOrEqual(width); + } + await assertNoDocumentHorizontalOverflow(testPage, `${label} at ${width}px`); + await context.tap(); + await expect(testPage.getByRole("dialog")).toBeVisible(); + if (label === "All threads") { + await expect(testPage.getByTestId("threads-mobile-view-drawer")).toBeVisible(); + } else { + await expect(testPage.getByRole("radio", { name: label, exact: true })).toHaveAttribute( + "data-state", + "on", + ); + } + await testPage.keyboard.press("Escape"); + await expect(testPage.getByRole("dialog")).toHaveCount(0); + await expect(context).toBeFocused(); + await menu.tap(); + await expect(testPage.getByRole("dialog", { name: "Menu", exact: true })).toBeVisible(); + await testPage.keyboard.press("Escape"); + await expect(testPage.getByRole("dialog")).toHaveCount(0); + await expect(menu).toBeFocused(); + } + } + }); + + test("keeps workspace-aware Home in the menu and restores the remembered list", async ({ + testPage, + }) => { await testPage.goto("/tasks"); - await expect(header.getByTestId("topbar-phone-home")).toHaveCount(0); - - await brand.tap(); - // The restore lands back on /tasks with the workspace pinned into the URL - // — the param only appears through the round trip, so it is the proof the - // tap navigated rather than sat inert. + await testPage.getByTestId("mobile-topbar-menu").tap(); + const dialog = testPage.getByRole("dialog", { name: "Menu", exact: true }); + await dialog.getByRole("link", { name: "Home", exact: true }).tap(); await expect(testPage).toHaveURL( (url) => url.pathname === "/tasks" && url.searchParams.has("workspace"), ); - await expect(header.locator('[data-slot="breadcrumb-page"]')).toHaveText("Tasks"); + await expect(testPage.getByTestId("mobile-topbar-page-context")).toContainText("List"); + await expect(dialog).toHaveCount(0); }); - test("keeps the brand and menu fixed while the action strip absorbs pressure", async ({ + test("leaves the coarse-pointer tablet header composition intact", async ({ testPage }) => { + await testPage.setViewportSize({ width: 820, height: 1180 }); + 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).toBeVisible(); + await assertNoDocumentHorizontalOverflow(testPage, "tablet listing header"); + const menu = header.getByRole("button", { name: "Open menu", exact: true }); + await expect(menu).toBeVisible(); + // Tablet retains its existing composition and direct 44px tool launchers. + for (const id of ["tablet-quick-chat-button", "tablet-quick-terminal-button"]) { + const box = await requireBox(header.getByTestId(id), "tablet launcher"); + expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.width).toBeGreaterThanOrEqual(44); + } + await menu.tap(); + await expect(testPage.getByRole("dialog", { name: "Menu", exact: true })).toBeVisible(); + await expect(testPage.getByRole("radio", { name: "Pipeline", exact: true })).toBeVisible(); + }); + + test("contains a long workspace name without squeezing the phone menu", async ({ testPage, apiClient, }) => { - // Metrics in the topbar are the cheapest way to make the action row wider - // than a phone, which is the state the strip exists to survive. - const response = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { - app_status_bar_enabled: false, - system_metrics_display: { show_in_topbar: true, simplified: false }, - }); - expect(response.ok).toBe(true); - - const mobile = new MobileKanbanPage(testPage); - await mobile.goto(); - await testPage.reload(); - - const header = testPage.locator("header").first(); - const brand = header.getByTestId("mobile-topbar-brand"); - const menu = header.getByTestId("mobile-topbar-menu"); - const strip = header.getByTestId("mobile-topbar-action-strip"); - await expect(brand).toBeVisible(); - await expect(menu).toBeVisible(); - await expect(strip).toBeVisible(); - - await assertNoDocumentHorizontalOverflow(testPage, "mobile kanban topbar"); - - const headerBox = await requireBox(header, "topbar"); - const brandBox = await requireBox(brand, "brand link"); - const menuBox = await requireBox(menu, "menu button"); - // Both ends of the bar stay inside it: the strip scrolls, the chrome does - // not slide out from under the viewport. - expect(brandBox.x).toBeGreaterThanOrEqual(headerBox.x - 1); - expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(headerBox.x + headerBox.width + 1); - // The title crumb keeps its floor rather than being crushed to nothing. - const titleBox = await requireBox(header.locator('[data-slot="breadcrumb-page"]'), "title"); - expect(titleBox.width).toBeGreaterThan(0); - }); - - test.afterEach(async ({ apiClient }) => { - await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { - system_metrics_display: { show_in_topbar: false }, - }); + const name = "Harbor checkout accessibility and international payment reconciliation"; + const workspace = await apiClient.createWorkspace(name); + try { + await testPage.setViewportSize({ width: 360, height: 851 }); + await testPage.goto(`/tasks?workspace=${workspace.id}`); + const context = testPage.getByTestId("mobile-topbar-page-context"); + await expect(context).toContainText(name); + const label = context.locator("span.truncate").first(); + expect(await label.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe( + true, + ); + const menu = testPage.getByTestId("mobile-topbar-menu"); + 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(); + } 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 0fe4ab7458..da72e68f50 100644 --- a/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts @@ -47,8 +47,9 @@ test.describe("Mobile kanban view", () => { await expect(mobile.mobileKanbanLayout()).toBeVisible(); // FAB should be visible for creating tasks await expect(mobile.mobileFab).toBeVisible(); - // Search is collapsed behind a topbar icon by default - await expect(mobile.mobileSearchToggle).toBeVisible(); + // Search lives in the listing menu and starts collapsed. + await expect(mobile.mobileMenuButton).toBeVisible(); + await expect(mobile.mobileSearchToggle).toHaveCount(0); await expect(mobile.mobileSearchBar).not.toBeVisible(); await expect(mobile.boardNavigator).toBeVisible(); await expect(mobile.boardNavigator).toContainText("E2E Workflow"); @@ -438,14 +439,15 @@ test.describe("Mobile kanban view", () => { const mobile = new MobileKanbanPage(testPage); await mobile.goto(); - // Hidden by default, revealed when the topbar search icon is tapped + // Hidden by default, revealed from the listing menu. await expect(mobile.mobileSearchBar).not.toBeVisible(); await mobile.openSearch(); await expect(mobile.mobileSearchBar).toBeVisible(); // Input is focused on reveal so the keyboard opens immediately await expect(mobile.searchInput()).toBeFocused(); - // Tapping the icon again collapses the search bar + // Toggling search in the menu again collapses the search bar. + await mobile.mobileMenuButton.click(); await mobile.mobileSearchToggle.click(); await expect(mobile.mobileSearchBar).not.toBeVisible(); }); @@ -468,6 +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.mobileSearchToggle.click(); await expect(mobile.mobileSearchBar).not.toBeVisible(); await expect(mobile.taskCardByTitle("Clearable Alpha")).toBeVisible({ timeout: 5000 }); @@ -528,8 +531,10 @@ test.describe("Mobile kanban view", () => { const dialog = testPage.getByRole("dialog", { name: "Menu" }); const searchInput = dialog.getByPlaceholder("Search tasks..."); - await expect(searchInput).toBeVisible(); - await expect(searchInput).not.toBeFocused(); + await expect(searchInput).toHaveCount(0); + await expect(mobile.mobileSearchToggle).toBeVisible(); + await expect(mobile.mobileSearchToggle).not.toBeFocused(); + await expect(mobile.mobileSearchBar).not.toBeVisible(); }); test("opens missing git health issue from mobile menu", async ({ testPage, backend }) => { 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 e38e05c6e6..7dffc6de8f 100644 --- a/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts +++ b/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import type { Locator, Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; import { expect, test } from "../../fixtures/test-base"; import { captureAppStatusBarSettings, @@ -28,7 +28,7 @@ async function installFixture(page: Page) { await expect(page.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible({ timeout: 30_000 }); } -test.describe("Mobile topbar action strip", () => { +test.describe("Mobile listing menu actions", () => { let metricsBaseline: SystemMetricsDisplayBaseline; let statusBarBaseline: AppStatusBarSettingsBaseline; @@ -55,118 +55,51 @@ test.describe("Mobile topbar action strip", () => { await restoreAppStatusBarSettings(apiClient, statusBarBaseline); }); - test("scrolls plugin, metric, and native actions without moving fixed chrome", async ({ + test("keeps plugins, opted-in metrics, and native tools accessible in every mode", async ({ testPage, }) => { test.setTimeout(120_000); - await installFixture(testPage); - await testPage.goto("/"); - await testPage.reload(); - - const strip = testPage.getByTestId("mobile-topbar-action-strip"); - const viewport = testPage.getByTestId("mobile-topbar-action-strip-viewport"); - const leftFade = testPage.getByTestId("mobile-topbar-action-strip-left-fade"); - const rightFade = testPage.getByTestId("mobile-topbar-action-strip-right-fade"); - const brand = testPage.getByTestId("mobile-topbar-brand"); - const menu = testPage.getByTestId("mobile-topbar-menu"); - const pluginButton = testPage.locator("#hello-main-top-bar"); - const metrics = testPage.getByTestId("topbar-metrics"); - const cpuMetric = metrics.getByLabel(/^CPU /); - const terminal = testPage.getByTestId("mobile-quick-terminal-button"); - const quickChat = testPage.getByTestId("mobile-quick-chat-button"); - const terminalHitTarget = testPage.getByTestId("mobile-quick-terminal-hit-target"); - const quickChatHitTarget = testPage.getByTestId("mobile-quick-chat-hit-target"); - const search = testPage.getByTestId("mobile-search-toggle"); - - await expect(strip).toBeVisible(); - await expect(pluginButton).toBeVisible(); - await expect(metrics).toBeVisible(); - await expect(cpuMetric).toBeVisible(); - await expect(terminal).toBeVisible(); - await expect(quickChat).toBeVisible(); - await expect(search).toBeVisible(); - await expect(menu).toBeVisible(); - - const nativeButtons = [terminal, quickChat, search, menu]; - const nativeBoxes = await Promise.all( - nativeButtons.map((button, index) => requireBox(button, `native button ${index}`)), - ); - for (const box of nativeBoxes) { - expect(box.width).toBeCloseTo(32, 0); - expect(box.height).toBeCloseTo(32, 0); + for (const [route, pluginPage] of [ + ["/?home=overview", "kanban"], + ["/tasks", "tasks"], + ["/threads", "kanban"], + ]) { + 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(); + 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(); + for (const target of [ + plugin, + menu.getByTestId("mobile-quick-chat-button"), + menu.getByTestId("mobile-quick-terminal-button"), + ]) { + const box = await requireBox(target, "menu action"); + expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.width).toBeGreaterThanOrEqual(44); + expect(box.x + box.width).toBeLessThanOrEqual(testPage.viewportSize()!.width); + } + const icon = await requireBox(plugin.locator("svg").first(), "plugin icon"); + expect(icon.width).toBeCloseTo(16, 0); + expect(icon.height).toBeCloseTo(16, 0); + await assertNoDocumentHorizontalOverflow(testPage, `menu tools on ${route}`); + if (route !== "/threads") { + await expect(menu.getByRole("textbox")).toHaveCount(0); + await menu.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); } - - const pluginBox = await requireBox(pluginButton, "plugin action"); - expect(pluginBox.width).toBeCloseTo(32, 0); - expect(pluginBox.height).toBeCloseTo(32, 0); - - const metricBox = await requireBox(metrics, "topbar metrics"); - expect(metricBox.height).toBeCloseTo(32, 0); - const metricIconBox = await requireBox(cpuMetric.locator("svg").first(), "CPU metric icon"); - expect(metricIconBox.width).toBeCloseTo(16, 0); - expect(metricIconBox.height).toBeCloseTo(16, 0); - - const pluginIconBox = await requireBox(pluginButton.locator("svg").first(), "plugin icon"); - expect(pluginIconBox.width).toBeCloseTo(16, 0); - expect(pluginIconBox.height).toBeCloseTo(16, 0); - - const expectTouchTarget = async (target: Locator, label: string) => { - const targetBox = await requireBox(target, `${label} hit target`); - expect(targetBox.height).toBeCloseTo(44, 0); - expect(targetBox.width).toBeCloseTo(32, 0); - const hitTarget = await target.evaluate((element) => { - const rect = element.getBoundingClientRect(); - const x = rect.left + rect.width / 2; - const points = [rect.top + 1, rect.bottom - 1]; - return points.every((y) => document.elementFromPoint(x, y) === element); - }); - expect(hitTarget, `${label} keeps a 44px hit area`).toBe(true); - }; - - const brandBefore = await requireBox(brand, "brand before scroll"); - const menuBefore = await requireBox(menu, "menu before scroll"); - await expect(strip).toHaveAttribute("data-can-scroll-left", "false"); - await expect(strip).toHaveAttribute("data-can-scroll-right", "true"); - await expect(leftFade).toHaveCount(0); - await expect(rightFade).toBeVisible(); - await assertNoDocumentHorizontalOverflow(testPage, "mobile topbar initial layout"); - - await viewport.evaluate((element) => { - const node = element as HTMLElement; - node.scrollLeft = Math.floor((node.scrollWidth - node.clientWidth) / 2); - node.dispatchEvent(new Event("scroll")); - }); - await expect(strip).toHaveAttribute("data-can-scroll-left", "true"); - await expect(strip).toHaveAttribute("data-can-scroll-right", "true"); - await expect(leftFade).toBeVisible(); - await expect(rightFade).toBeVisible(); - - await viewport.evaluate((element) => { - const node = element as HTMLElement; - node.scrollLeft = node.scrollWidth; - node.dispatchEvent(new Event("scroll")); - }); - await expect(strip).toHaveAttribute("data-can-scroll-left", "true"); - await expect(strip).toHaveAttribute("data-can-scroll-right", "false"); - await expect(leftFade).toBeVisible(); - await expect(rightFade).toHaveCount(0); - await expect(search).toBeInViewport(); - // Touch targets are probed at the end position: with the title crumb on - // the bar the native actions start beyond the strip's initial viewport, - // and `document.elementFromPoint` can only hit what is scrolled into view. - await expectTouchTarget(terminalHitTarget, "Quick Terminal"); - await expectTouchTarget(quickChatHitTarget, "Quick Chat"); - await assertNoDocumentHorizontalOverflow(testPage, "mobile topbar scrolled layout"); - - const brandAfter = await requireBox(brand, "brand after scroll"); - const menuAfter = await requireBox(menu, "menu after scroll"); - expect(Math.abs(brandAfter.x - brandBefore.x)).toBeLessThanOrEqual(1); - expect(Math.abs(menuAfter.x - menuBefore.x)).toBeLessThanOrEqual(1); - - await search.tap(); - await expect(testPage.getByTestId("mobile-search-bar")).toBeVisible(); - await expect(testPage.getByTestId("mobile-search-bar").getByRole("textbox")).toBeVisible(); - await assertNoDocumentHorizontalOverflow(testPage, "mobile search layout"); }); }); 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 7b078f7780..9228c97aa1 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 @@ -1,7 +1,7 @@ import { test, expect } from "../../fixtures/test-base"; test.describe("Mobile task list search", () => { - test("topbar search icon reveals, filters, and clears on collapse", async ({ + test("menu search action reveals, filters, and clears on collapse", async ({ testPage, apiClient, seedData, @@ -26,6 +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 searchToggle.click(); await expect(searchBar).toBeVisible(); await expect(searchBar.getByPlaceholder("Search tasks...")).toBeFocused(); @@ -34,6 +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 searchToggle.click(); await expect(searchBar).not.toBeVisible(); await expect(taskList.getByText("List Alpha Task")).toBeVisible({ timeout: 5000 }); @@ -61,6 +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-search-toggle").click(); await testPage .getByTestId("mobile-search-bar") diff --git a/apps/web/e2e/tests/task/mobile-threads-swipe-helpers.ts b/apps/web/e2e/tests/task/mobile-threads-swipe-helpers.ts new file mode 100644 index 0000000000..ebc5c5988e --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-threads-swipe-helpers.ts @@ -0,0 +1,28 @@ +import type { Page } from "@playwright/test"; + +export async function swipeDeckLeft(page: Page, whileHeld?: () => Promise) { + const box = await page.getByTestId("threads-board").boundingBox(); + if (!box) throw new Error("Threads deck has no bounding box"); + const client = await page.context().newCDPSession(page); + const y = box.y + 24; + const startX = box.x + box.width * 0.85; + try { + await client.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: startX, y }], + }); + for (let step = 1; step <= 12; step++) { + await client.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: startX - (box.width * 0.7 * step) / 12, y }], + }); + } + await whileHeld?.(); + } finally { + try { + await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + } finally { + await client.detach(); + } + } +} diff --git a/apps/web/e2e/tests/task/mobile-threads-swipe.spec.ts b/apps/web/e2e/tests/task/mobile-threads-swipe.spec.ts new file mode 100644 index 0000000000..c157ec23c5 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-threads-swipe.spec.ts @@ -0,0 +1,136 @@ +import { expect, test } from "../../fixtures/test-base"; +import { createStandardProfile, openTaskSession } from "../../helpers/git-helper"; +import { waitForLatestSessionDone } from "../../helpers/session"; +import { swipeDeckLeft } from "./mobile-threads-swipe-helpers"; + +test("keeps the thread picker closed after leaving and returning to phone layout", async ({ + testPage, + apiClient, + seedData, +}) => { + const profile = await createStandardProfile(apiClient, "mobile-picker-resize"); + const title = "Picker resize conversation"; + const task = await apiClient.createTaskWithAgent(seedData.workspaceId, title, profile.id, { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + await openTaskSession(testPage, title); + await waitForLatestSessionDone(apiClient, task.id, 1, "picker resize agent turn"); + await testPage.goto("/threads"); + const trigger = testPage.getByTestId("thread-picker-trigger"); + const picker = testPage.getByTestId("thread-picker-sheet"); + for (const width of [820, 1280]) { + await trigger.tap(); + await expect(picker).toBeVisible(); + await testPage.setViewportSize({ width, height: 900 }); + await expect(trigger).toHaveCount(0); + await expect(picker).toHaveCount(0); + await testPage.setViewportSize({ width: 393, height: 851 }); + await expect(trigger).toBeVisible(); + await expect(picker).toBeHidden(); + } + await trigger.tap(); + await expect(picker).toBeVisible(); +}); + +test("restores picker focus to a surviving thread when its opener is archived", async ({ + testPage, + apiClient, + seedData, +}) => { + const profile = await createStandardProfile(apiClient, "mobile-picker-removal"); + for (const title of ["Archived picker opener", "Surviving picker thread"]) { + const task = await apiClient.createTaskWithAgent(seedData.workspaceId, title, profile.id, { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + await openTaskSession(testPage, title); + await waitForLatestSessionDone(apiClient, task.id, 1, `agent turn for ${title}`); + } + await testPage.goto("/threads"); + const board = testPage.getByTestId("threads-board"); + const columns = board.locator("[data-thread-column-id]"); + await expect(columns).toHaveCount(2); + const openerId = await columns.first().getAttribute("data-thread-column-id"); + if (!openerId) throw new Error("Opening thread has no task ID"); + await columns.first().getByTestId("thread-picker-trigger").tap(); + const picker = testPage.getByTestId("thread-picker-sheet"); + await expect(picker).toBeVisible(); + await apiClient.archiveTask(openerId); + await expect(columns).toHaveCount(1); + await expect(picker.getByTestId(/^thread-picker-row-/)).toHaveCount(1); + await testPage.keyboard.press("Escape"); + await expect(picker).toBeHidden(); + const remainingTrigger = columns.getByTestId("thread-picker-trigger"); + await expect(remainingTrigger).toBeFocused(); + const boardBox = await board.boundingBox(); + expect((await remainingTrigger.boundingBox())!.x).toBeGreaterThanOrEqual(boardBox!.x); + await remainingTrigger.tap(); + await expect(picker).toBeVisible(); +}); + +// @covers AC-UI-THREADS-DECK-003.13 +test("updates inline pagination during a held swipe before destination detail loads", async ({ + testPage, + apiClient, + seedData, +}) => { + test.setTimeout(180_000); + const profile = await createStandardProfile(apiClient, "mobile-swipe-position"); + for (const title of ["First swipe conversation", "Second swipe conversation"]) { + const task = await apiClient.createTaskWithAgent(seedData.workspaceId, title, profile.id, { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + await openTaskSession(testPage, title); + await waitForLatestSessionDone(apiClient, task.id, 1, `agent turn for ${title}`); + } + + let release!: () => void; + const membershipGate = new Promise((resolve) => { + release = resolve; + }); + let heldRequests = 0; + await testPage.route("**/api/v1/tasks/*/sessions", async (route) => { + heldRequests++; + await membershipGate; + await route.continue(); + }); + try { + await testPage.goto(`/threads?workspace=${seedData.workspaceId}`); + const board = testPage.getByTestId("threads-board"); + const cue = testPage.getByTestId("thread-swipe-cue"); + await expect(board.locator("[data-thread-column-id]")).toHaveCount(2); + await expect.poll(() => heldRequests).toBeGreaterThan(0); + await expect(cue).toHaveText("1/2"); + await expect(board.getByTestId("session-chat")).toHaveCount(0); + + await swipeDeckLeft(testPage, async () => { + const progress = await board.evaluate((element) => element.scrollLeft / element.clientWidth); + expect(progress).toBeGreaterThan(0.5); + expect(progress).toBeLessThan(0.95); + await expect(cue).toHaveText("2/2"); + await expect(cue.getByTestId("thread-page-dot").nth(1)).toHaveAttribute( + "data-active", + "true", + ); + await expect(board.getByTestId("session-chat")).toHaveCount(0); + }); + + release(); + await expect(board.getByTestId("session-chat")).toHaveCount(1); + await expect(cue).toHaveText("2/2"); + await expect + .poll(() => board.evaluate((element) => element.scrollLeft / element.clientWidth)) + .toBeCloseTo(1, 2); + } finally { + release(); + await testPage.unrouteAll({ behavior: "wait" }); + } +}); 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 4b3cb83efb..21659a20d8 100644 --- a/apps/web/e2e/tests/task/mobile-threads-view.spec.ts +++ b/apps/web/e2e/tests/task/mobile-threads-view.spec.ts @@ -1,10 +1,14 @@ +import type { Locator, Page } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; +import { waitForFiniteAnimations } from "../../helpers/animations"; import { seedSecondaryClarificationTask } from "../../helpers/clarification"; import { createStandardProfile, openTaskSession } from "../../helpers/git-helper"; import { assertNoHorizontalOverflow } from "../../helpers/session-stream-overload"; import { waitForLatestSessionDone } from "../../helpers/session"; import { attachGatewayTrafficCapture, type GatewayTrafficFrame } from "../../helpers/ws-traffic"; +import { closeQuickTerminalTab } from "../terminal/terminal-test-helpers"; +import { swipeDeckLeft } from "./mobile-threads-swipe-helpers"; const AGENT_TITLE = "Mobile threads live work"; @@ -14,6 +18,33 @@ function sentSessionIds(frames: readonly GatewayTrafficFrame[], action: string): .map((frame) => frame.sessionId as string); } +async function expectSwipeCue(page: Page, column: Locator, total: number) { + const topbar = page.getByTestId("threads-mobile-topbar"); + const cue = topbar.getByTestId("thread-swipe-cue"); + const position = await column.evaluate( + (element) => Array.from(element.parentElement!.children).indexOf(element) + 1, + ); + await expect(cue).toHaveText(`${position}/${total}`); + await expect(page.getByTestId("thread-swipe-cue")).toHaveCount(1); + await expect(column.getByTestId("thread-swipe-cue")).toHaveCount(0); + await expect(cue.getByTestId("thread-page-dot")).toHaveCount(total); + await expect(cue.locator('[data-active="true"]')).toHaveCount(1); + await expect(cue.getByTestId("thread-page-dot").nth(position - 1)).toHaveAttribute( + "data-active", + "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 cueBox = await cue.boundingBox(); + expect(topbarBox!.height).toBe(56); + expect(cueBox!.x).toBeGreaterThanOrEqual(viewBox!.x + viewBox!.width); + expect(cueBox!.x + cueBox!.width).toBeLessThanOrEqual(menuBox!.x); + expect(cueBox!.y).toBeGreaterThanOrEqual(topbarBox!.y); + expect(cueBox!.y + cueBox!.height).toBeLessThanOrEqual(topbarBox!.y + topbarBox!.height); + expect(cueBox!.y + cueBox!.height / 2).toBeCloseTo(viewBox!.y + viewBox!.height / 2, 1); +} + test.describe("Mobile Threads view", () => { test("reaches the deck from the drawer and pages one full-width column", async ({ testPage, @@ -46,14 +77,205 @@ test.describe("Mobile Threads view", () => { const column = testPage.getByTestId(`thread-column-${task.id}`); await expect(column).toBeVisible(); await expect(column).toContainText(AGENT_TITLE); + expect( + await column + .locator("header") + .getByTestId(/^thread-status-/) + .ariaSnapshot(), + ).toBe(""); // The phone layout pages the deck: one column fills the viewport rather // than shrinking several into an unreadable row. const viewportWidth = testPage.viewportSize()?.width ?? 0; const columnWidth = (await column.boundingBox())?.width ?? 0; expect(viewportWidth).toBeGreaterThan(0); - expect(columnWidth).toBeGreaterThan(viewportWidth * 0.7); + // @covers AC-UI-THREADS-DECK-003.8 + expect(columnWidth).toBeGreaterThanOrEqual(viewportWidth - 2); expect(columnWidth).toBeLessThanOrEqual(viewportWidth); + await expect(testPage.getByTestId("thread-swipe-cue")).toHaveCount(0); + + const viewControl = testPage.getByTestId("threads-mobile-view-trigger"); + await testPage.setViewportSize({ width: 820, height: 1180 }); + await expect(viewControl).toBeVisible(); + await expect(viewControl.getByText("Threads", { exact: true })).toHaveCount(0); + await expect(column.getByTestId("thread-picker-trigger")).toHaveCount(0); + expect((await viewControl.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(); + }); + + // @covers AC-UI-THREADS-DECK-003.9, AC-UI-THREADS-DECK-003.10, AC-UI-THREADS-DECK-003.11, AC-UI-THREADS-DECK-003.12 + test("chooses a thread from its title and keeps narrow chat controls contained", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(180_000); + const profile = await createStandardProfile(apiClient, "mobile-threads-picker"); + const titles = [ + "Review checkout accessibility across keyboard and touch", + "Investigate a very long payment reconciliation identifier", + "Prepare the release notes", + ]; + const tasks = []; + for (const title of titles) { + const task = await apiClient.createTaskWithAgent(seedData.workspaceId, title, profile.id, { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + await openTaskSession(testPage, title); + await waitForLatestSessionDone(apiClient, task.id, 1, `agent turn for ${title}`); + const { sessions } = await apiClient.listTaskSessions(task.id); + const session = sessions.find((item) => item.is_primary); + if (!session) throw new Error(`No primary session for ${title}`); + await apiClient.seedSessionMessage(session.id, { + type: "message", + content: `Findings for ${title}.\n\n\`\`\`text\n${"reconciliation_".repeat(30)}\n\`\`\``, + }); + tasks.push(task); + } + + await testPage.goto(`/threads?taskId=${tasks[0].id}`); + await testPage.setViewportSize({ width: 360, height: 740 }); + const board = testPage.getByTestId("threads-board"); + const firstColumn = testPage.getByTestId(`thread-column-${tasks[0].id}`); + const firstPicker = firstColumn.getByTestId("thread-picker-trigger"); + await expect(firstPicker).toBeVisible(); + await expectSwipeCue(testPage, firstColumn, 3); + expect((await firstColumn.locator("header").first().boundingBox())!.height).toBeLessThanOrEqual( + 112, + ); + const titleLines = await firstColumn.getByTestId("thread-mobile-title").evaluate((element) => { + return ( + element.getBoundingClientRect().height / parseFloat(getComputedStyle(element).lineHeight) + ); + }); + expect(titleLines).toBeCloseTo(2, 1); + const viewControl = testPage.getByTestId("threads-mobile-view-trigger"); + 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); + await firstPicker.tap(); + const picker = testPage.getByRole("dialog", { name: "Choose thread", exact: true }); + await expect(picker).toBeVisible(); + await expect(picker.getByTestId(/^thread-picker-row-/)).toHaveCount(3); + await picker.getByTestId(`thread-picker-row-${tasks[2].id}`).tap(); + await expect(picker).toBeHidden(); + + const selected = testPage.getByTestId(`thread-column-${tasks[2].id}`); + const selectedPicker = selected.getByTestId("thread-picker-trigger"); + await expect(selectedPicker).toBeFocused(); + await expectSwipeCue(testPage, selected, 3); + await expect(selected.getByTestId("session-chat")).toBeVisible(); + await expect(board.getByTestId("session-chat")).toHaveCount(1); + await expect + .poll(async () => { + const deckBox = await board.boundingBox(); + const columnBox = await selected.boundingBox(); + return Math.abs((columnBox?.x ?? -100) - (deckBox?.x ?? 0)); + }) + .toBeLessThanOrEqual(1); + + await selectedPicker.tap(); + await expect(picker.getByTestId(`thread-picker-row-${tasks[2].id}`)).toHaveAttribute( + "aria-current", + "true", + ); + await testPage.keyboard.press("Escape"); + await expect(picker).toBeHidden(); + 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"); + for (const control of [viewPicker, menu, selected.getByTestId("thread-picker-trigger")]) { + const box = await control.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(44); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(360); + expect( + await control.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return element.contains( + document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2), + ); + }), + ).toBe(true); + } + await expect(testPage.getByTestId("mobile-topbar-action-strip")).toHaveCount(0); + const chat = selected.getByTestId("session-chat"); + expect(await chat.evaluate((element) => element.scrollWidth <= element.clientWidth + 1)).toBe( + true, + ); + await assertNoHorizontalOverflow(testPage, "full-width phone Threads"); + + await menu.tap(); + await expect(testPage.getByRole("dialog", { name: "Menu", exact: true })).toBeVisible(); + await expect(testPage.getByRole("link", { name: "Stats", exact: true })).toBeVisible(); + await testPage.keyboard.press("Escape"); + await expect(testPage.getByRole("dialog", { name: "Menu", exact: true })).toBeHidden(); + + await swipeDeckLeft(testPage); + await expect(selected.getByTestId("session-chat")).toHaveCount(0); + await expect(board.getByTestId("session-chat")).toHaveCount(1); + await expect + .poll(() => + board.evaluate((element) => { + const page = element.scrollLeft / element.clientWidth; + return Math.abs(page - Math.round(page)); + }), + ) + .toBeLessThanOrEqual(0.01); + const activeColumn = board.getByTestId(/^thread-column-/).filter({ + has: testPage.getByTestId("session-chat"), + }); + await expectSwipeCue(testPage, activeColumn, 3); + await testPage.setViewportSize({ width: 820, height: 1180 }); + await expect(testPage.getByTestId("thread-swipe-cue")).toHaveCount(0); + await testPage.setViewportSize({ width: 360, height: 740 }); + await expect(activeColumn).toHaveCount(1); + await expectSwipeCue(testPage, activeColumn, 3); + }); + + test("keeps home, status, Quick Chat, and terminal reachable from the menu", async ({ + testPage, + apiClient, + }) => { + 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 menu = testPage.getByRole("dialog", { name: "Menu", exact: true }); + await trigger.tap(); + await expect(menu.getByRole("link", { name: "Home", exact: true })).toBeVisible(); + await expect(menu.getByTestId("mobile-home-status-button")).toBeVisible(); + const quickChat = menu.getByTestId("mobile-quick-chat-button"); + const terminal = menu.getByTestId("mobile-quick-terminal-button"); + await waitForFiniteAnimations(menu); + for (const action of [quickChat, terminal]) { + await expect(action).toBeVisible(); + expect((await action.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44); + } + await quickChat.tap(); + await expect(menu).toBeHidden(); + const dialog = testPage.getByRole("dialog", { name: "Quick Chat", exact: true }); + await expect(dialog.getByTestId("quick-chat-setup")).toBeVisible(); + await dialog.getByTestId("quick-chat-close").tap(); + await expect(dialog).toBeHidden(); + await trigger.tap(); + await terminal.tap(); + await expect(menu).toBeHidden(); + try { + await expect(dialog.getByTestId("quick-terminal-tab-panel")).toBeVisible(); + } finally { + await closeQuickTerminalTab(testPage, dialog.getByTestId("quick-terminal-tab")); + } + await expect(dialog.getByTestId("quick-terminal-tab")).toHaveCount(0); + await expect(dialog).toBeHidden(); }); test("uses a bounded native picker for the selected session on phone", async ({ 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 742648bac2..fda26dac9d 100644 --- a/apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts +++ b/apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts @@ -41,8 +41,13 @@ async function sendCommand(page: Page, command: string, marker: string) { 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-terminal-button"); - if (await launcher.isVisible().catch(() => false)) await launcher.tap(); + const launcher = page.getByTestId("mobile-quick-chat-button"); + const menu = page.getByTestId("mobile-topbar-menu"); + if (!(await menu.isVisible())) return; + if ((await menu.getAttribute("aria-expanded")) !== "true") await menu.tap(); + await expect(menu).toHaveAttribute("aria-expanded", "true"); + await page.getByRole("dialog", { name: "Menu", exact: true }).waitFor(); + await launcher.tap({ timeout: 10_000 }); } if (!(await dialog.isVisible().catch(() => false))) return; @@ -65,18 +70,20 @@ test.describe("mobile quick terminal tabs", () => { const terminalButton = testPage.getByTestId("mobile-quick-terminal-button"); const quickChatButton = testPage.getByTestId("mobile-quick-chat-button"); const menuButton = testPage.getByTestId("mobile-topbar-menu"); - await expect(terminalButton).toBeVisible(); - await expect(quickChatButton).toBeVisible(); await expect(menuButton).toBeVisible(); const headerMenuBox = await menuButton.boundingBox(); expect(headerMenuBox).not.toBeNull(); if (!headerMenuBox) throw new Error("mobile menu geometry unavailable"); + await menuButton.tap(); + await expect(terminalButton).toBeVisible(); + await expect(quickChatButton).toBeVisible(); for (const button of [terminalButton, quickChatButton]) { const buttonBox = await button.boundingBox(); expect(buttonBox).not.toBeNull(); if (!buttonBox) throw new Error("mobile launcher geometry unavailable"); - expect(buttonBox.width).toBeCloseTo(headerMenuBox.width, 1); - expect(buttonBox.height).toBeCloseTo(headerMenuBox.height, 1); + expect(buttonBox.width).toBeGreaterThanOrEqual(44); + expect(buttonBox.height).toBeGreaterThanOrEqual(44); + expect(buttonBox.x + buttonBox.width).toBeLessThanOrEqual(testPage.viewportSize()!.width); } await terminalButton.tap(); @@ -112,6 +119,7 @@ test.describe("mobile quick terminal tabs", () => { // A mobile reload restores the durable descriptor and reattaches the // detached PTY before the user creates any additional terminal. await testPage.reload(); + await menuButton.tap(); await expect(terminalButton).toBeVisible(); await terminalButton.tap(); await expect(dialog).toBeVisible(); @@ -194,9 +202,10 @@ test.describe("mobile quick terminal tabs", () => { await dialog.getByTestId("quick-chat-close").tap(); await expect(dialog).toBeHidden(); - await expect(terminalButton).toBeFocused(); + await expect(menuButton).toBeFocused(); // Reopening through the mobile launcher selects the same terminal tab. + await menuButton.tap(); await terminalButton.tap(); await expect(dialog).toBeVisible(); await expect(terminalTabs).toHaveCount(1); @@ -205,6 +214,7 @@ test.describe("mobile quick terminal tabs", () => { .toContain(normalizeTerminalText("MOBILE_TERMINAL_ONE")); await dialog.getByTestId("quick-chat-close").tap(); await expect(dialog).toBeHidden(); + await expect(menuButton).toBeFocused(); } finally { await closeSurvivingQuickTerminals(testPage); } diff --git a/apps/web/hooks/use-quick-chat-launcher.ts b/apps/web/hooks/use-quick-chat-launcher.ts index 19a8ec86a5..6672bfed3e 100644 --- a/apps/web/hooks/use-quick-chat-launcher.ts +++ b/apps/web/hooks/use-quick-chat-launcher.ts @@ -3,10 +3,11 @@ import { useAppStore } from "@/components/state-provider"; import { captureQuickChatLauncherFocus, requestQuickChatClose, + type QuickChatLauncherFocusOptions, } from "@/components/quick-chat/quick-chat-focus"; import type { QuickChatSessionKind } from "@/lib/state/slices/ui/types"; -type QuickChatLauncherOptions = { +type QuickChatLauncherOptions = Pick & { silentFocusReturn?: boolean; toggleWhenOpen?: boolean; }; @@ -22,6 +23,7 @@ export function useQuickChatLauncher( ) { const silentFocusReturn = options.silentFocusReturn ?? true; const toggleWhenOpen = options.toggleWhenOpen ?? false; + const returnFocusRef = options.returnFocusRef; const openQuickChat = useAppStore((state) => state.openQuickChat); const closeQuickChat = useAppStore((state) => state.closeQuickChat); const isQuickChatOpen = useAppStore((state) => state.quickChat.isOpen); @@ -34,7 +36,7 @@ export function useQuickChatLauncher( if (!requestQuickChatClose()) closeQuickChat(); return; } - captureQuickChatLauncherFocus({ silent: silentFocusReturn }); + captureQuickChatLauncherFocus({ silent: silentFocusReturn, returnFocusRef }); // If there's an existing session, open it. Otherwise just open the modal with agent picker const matchingSessions = quickChatSessions.filter( @@ -60,6 +62,7 @@ export function useQuickChatLauncher( isQuickChatOpen, closeQuickChat, silentFocusReturn, + returnFocusRef, quickChatSessions, kind, activeSessionId, diff --git a/apps/web/hooks/use-quick-terminal-launcher.ts b/apps/web/hooks/use-quick-terminal-launcher.ts index af80b22245..c179e9d41d 100644 --- a/apps/web/hooks/use-quick-terminal-launcher.ts +++ b/apps/web/hooks/use-quick-terminal-launcher.ts @@ -2,18 +2,25 @@ import { useCallback } from "react"; import { useAppStore } from "@/components/state-provider"; -import { captureQuickChatLauncherFocus } from "@/components/quick-chat/quick-chat-focus"; +import { + captureQuickChatLauncherFocus, + type QuickChatLauncherFocusOptions, +} from "@/components/quick-chat/quick-chat-focus"; import { listQuickTerminalTabs, toQuickTerminalTab } from "@/lib/api/domains/quick-terminal-api"; /** Opens or re-selects a workspace's terminal in the shared Quick Chat surface. */ -export function useQuickTerminalLauncher(workspaceId?: string | null) { +export function useQuickTerminalLauncher( + workspaceId?: string | null, + options: Pick = {}, +) { + const returnFocusRef = options.returnFocusRef; const reuseOrCreateQuickTerminal = useAppStore((state) => state.reuseOrCreateQuickTerminal); const hydrate = useAppStore((state) => state.hydrate); const terminalTabs = useAppStore((state) => state.quickChat.terminalTabs); return useCallback(async () => { if (!workspaceId) return; - captureQuickChatLauncherFocus(); + captureQuickChatLauncherFocus({ returnFocusRef }); if (!terminalTabs.some((tab) => tab.workspaceId === workspaceId)) { try { @@ -30,5 +37,5 @@ export function useQuickTerminalLauncher(workspaceId?: string | null) { } reuseOrCreateQuickTerminal(workspaceId); - }, [hydrate, reuseOrCreateQuickTerminal, terminalTabs, workspaceId]); + }, [hydrate, reuseOrCreateQuickTerminal, returnFocusRef, terminalTabs, workspaceId]); } diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts index 9569eb68bc..9d331cbfc4 100644 --- a/apps/web/lib/plugins/types.ts +++ b/apps/web/lib/plugins/types.ts @@ -124,10 +124,9 @@ export interface IntegrationSettingsRegistration { * view/display controls — the app-wide, task-agnostic counterpart to * "chat-top-bar"; receives `{ workspaceId, workspaceLabel, currentPage, * presentation }`). On phones, `presentation` is "mobile": contributions - * join the horizontally scrollable middle action strip between the fixed - * Kandev link and menu button. Use the host `ui.Button` icon-button contract - * there: a 32px box with a 16px SVG icon. Desktop contributions retain their - * existing sizing. + * live in the listing topbar menu with 44px touch targets and 16px SVG icons. + * Slots retain ownership of their controls and disclosure state; arbitrary + * interactions do not dismiss the host menu. Desktop sizing stays unchanged. * "app-status-bar-left" / "app-status-bar-right" (receives * `AppStatusBarSlotProps` as `slotProps`), and * "plugin-settings" (inline UI on a plugin's own settings diff --git a/apps/web/src/locales/en/threads.json b/apps/web/src/locales/en/threads.json index f4f022c351..a8f2f279b7 100644 --- a/apps/web/src/locales/en/threads.json +++ b/apps/web/src/locales/en/threads.json @@ -1,5 +1,9 @@ { "columnLabel": "Agent thread for {{title}}", + "chooseThread": "Choose thread", + "chooseThreadLabel": "Choose thread. Current: {{title}}", + "chooseThreadDescription": "Jump to a conversation in this view.", + "threadPosition": "{{position}}/{{total}}", "emptyBody": "Threads puts every running agent side by side. Start an agent on a task and its conversation shows up here as a column.", "emptyTitle": "No agent is working right now", "loading": "Loading threads...", diff --git a/apps/web/src/locales/pseudo/threads.json b/apps/web/src/locales/pseudo/threads.json index b5a084d029..97e4a21277 100644 --- a/apps/web/src/locales/pseudo/threads.json +++ b/apps/web/src/locales/pseudo/threads.json @@ -1,5 +1,9 @@ { "columnLabel": "Àĝēńţ ţĥŕēàď ƒōŕ {{title}}", + "chooseThread": "Ćĥōōśē ţĥŕēàď", + "chooseThreadLabel": "Ćĥōōśē ţĥŕēàď. Ćũŕŕēńţ: {{title}}", + "chooseThreadDescription": "Ĵũḿƥ ţō à ćōńvēŕśàţĩōń ĩń ţĥĩś vĩēŵ.", + "threadPosition": "{{position}}/{{total}}", "emptyBody": "Ţĥŕēàďś ƥũţś ēvēŕŷ ŕũńńĩńĝ àĝēńţ śĩďē ƀŷ śĩďē. Śţàŕţ àń àĝēńţ ōń à ţàśķ àńď ĩţś ćōńvēŕśàţĩōń śĥōŵś ũƥ ĥēŕē àś à ćōĺũḿń.", "emptyTitle": "Ńō àĝēńţ ĩś ŵōŕķĩńĝ ŕĩĝĥţ ńōŵ", "loading": "Ĺōàďĩńĝ ţĥŕēàďś...", diff --git a/apps/web/src/locales/pt-pt/threads.json b/apps/web/src/locales/pt-pt/threads.json index 45c2782972..3f4bfabc4c 100644 --- a/apps/web/src/locales/pt-pt/threads.json +++ b/apps/web/src/locales/pt-pt/threads.json @@ -1,5 +1,9 @@ { "columnLabel": "Conversa do agente para {{title}}", + "chooseThread": "Escolher conversa", + "chooseThreadLabel": "Escolher conversa. Atual: {{title}}", + "chooseThreadDescription": "Ir para uma conversa nesta vista.", + "threadPosition": "{{position}}/{{total}}", "emptyBody": "Os Tópicos colocam lado a lado todos os agentes em execução. Inicie um agente numa tarefa e a conversa aparece aqui como uma coluna.", "emptyTitle": "Nenhum agente está a trabalhar neste momento", "loading": "A carregar as conversas...", diff --git a/apps/web/src/locales/zh-cn/threads.json b/apps/web/src/locales/zh-cn/threads.json index efec91f25d..317ebca124 100644 --- a/apps/web/src/locales/zh-cn/threads.json +++ b/apps/web/src/locales/zh-cn/threads.json @@ -1,5 +1,9 @@ { "columnLabel": "{{title}} 的智能体对话", + "chooseThread": "选择对话", + "chooseThreadLabel": "选择对话。当前:{{title}}", + "chooseThreadDescription": "跳转到此视图中的对话。", + "threadPosition": "{{position}}/{{total}}", "emptyBody": "对话流把所有正在运行的智能体并排展示。在某个任务上启动智能体,它的对话就会作为一列出现在这里。", "emptyTitle": "当前没有智能体在工作", "loading": "正在加载对话...", diff --git a/apps/web/src/locales/zh-hk/threads.json b/apps/web/src/locales/zh-hk/threads.json index 9dc6e07eeb..cc9fbf50f0 100644 --- a/apps/web/src/locales/zh-hk/threads.json +++ b/apps/web/src/locales/zh-hk/threads.json @@ -1,5 +1,9 @@ { "columnLabel": "{{title}} 的代理程式對話", + "chooseThread": "選擇對話", + "chooseThreadLabel": "選擇對話。目前:{{title}}", + "chooseThreadDescription": "跳轉到此視圖中的對話。", + "threadPosition": "{{position}}/{{total}}", "emptyBody": "對話流把所有正在執行的代理程式並排展示。在某個任務上啓動代理程式,它的對話就會作為一列出現在這裏。", "emptyTitle": "目前沒有代理程式在工作", "loading": "正在載入對話...", diff --git a/apps/web/src/locales/zh-tw/threads.json b/apps/web/src/locales/zh-tw/threads.json index 64307c156e..a80c0d3d90 100644 --- a/apps/web/src/locales/zh-tw/threads.json +++ b/apps/web/src/locales/zh-tw/threads.json @@ -1,5 +1,9 @@ { "columnLabel": "{{title}} 的代理程式對話", + "chooseThread": "選擇對話", + "chooseThreadLabel": "選擇對話。目前:{{title}}", + "chooseThreadDescription": "跳轉到此檢視中的對話。", + "threadPosition": "{{position}}/{{total}}", "emptyBody": "對話流把所有正在執行的代理程式並排展示。在某個任務上啟動代理程式,它的對話就會作為一列出現在這裡。", "emptyTitle": "目前沒有代理程式在工作", "loading": "正在載入對話...", diff --git a/docs/plans/mobile-threads/plan.md b/docs/plans/mobile-threads/plan.md new file mode 100644 index 0000000000..41b084e35b --- /dev/null +++ b/docs/plans/mobile-threads/plan.md @@ -0,0 +1,210 @@ +--- +created: 2026-09-09 +status: done +requirements: + - REQ-UI-THREADS-DECK-003 + - REQ-UI-MOBILE-QUICK-CHAT-TOPBAR-001 +system_design: + - ../../specs/ui/system-design/threads-conversation-deck.md + - ../../specs/ui/system-design/mobile-quick-chat-topbar.md +legacy_specs: [] +--- + +# Implementation Plan: Mobile Threads + +## Overview + +Implement the accepted full-width pager and task picker, then leave an isolated +instance running for evaluation. The user explicitly requested implementation +after the brainstorming turn. UI owns the existing responsive Threads contract; +the session delivery and selection boundaries remain unchanged. + +The next evaluation follow-up corrects swipe-position feedback, then normalizes +Kanban/List phone headers to the compact Threads composition. Tasks 01-03 are +complete. The user subsequently authorized implementation of Tasks 04-05 and +an updated evaluation instance. Tasks 04 and 05 are complete. + +## Scope + +- Full-width phone conversations, compact page and task headers, and a thread + picker using existing task summaries and mobile primitives. +- Localized labels, narrow viewport coverage, and a disposable fictional demo. +- Swipe-synchronous phone pagination and shared Kanban/List/Threads phone chrome. +- Preserve menu access to search, tools, status, metrics, and plugin actions. +- No backend contract, persistence, tablet/desktop layout, or session lifecycle + change. Explicit default-Home selection is a separate subtask. + +## Technical approach + +Keep `ThreadsBoard` as the snap/viewport owner. Add one `MobileThreadPicker`; +each phone column's title opens it. Preserve stable task shells and the existing +session picker. Tasks 01-03 specialized the phone topbar for Threads. Task 04 +exposes lightweight scroll-derived phone position from the existing viewport +owner. Task 05 replaces the other listing modes' phone-only header branches +with that shared composition and generalizes the menu action component. + +### Follow-up diagnosis + +`useThreadColumnActivation.updateVisibleIds` retains the previous Set when +intersecting IDs do not change. Its memoized nearest-column calculation then +does not rerun as the same two columns cross the midpoint. `ThreadsBoard` +currently passes `detailTaskIds` to its header, so pagination stays on the old +thread until that column exits the viewport. Transcript hydration is not the +direct timing gate. + +A temporary Vitest fixture reproduced this with a 300-pixel board: A occupies +90% and B 10%, then A 40% and B 60%. Both stay intersecting. Expected B, +received A. The control assertion passes once A leaves and B fills the board. +The throwaway test was removed after diagnosis; Task 04 owns permanent RED +coverage of the new position signal. + +UI owns both affected presentation contracts. The existing deck requirement +covers placement; new AC-UI-THREADS-DECK-003.13 specifies the missing during-drag +timing. REQ-UI-MOBILE-QUICK-CHAT-TOPBAR-001 is amended in place to supersede the +former fixed-brand/scrolling-strip layout. No new ADR is needed: session-stream +and navigation ownership remain unchanged. + +## Tests + +Run existing Threads selection, board, and activation tests for unchanged state +contracts. Use rendered geometry and interaction tests for the new presentation. +Task 04 adds midpoint/reversal, resize, membership removal, and cleanup cases +to `thread-column-activation.test.tsx`, plus header/picker projection coverage. +Task 05 updates `kanban-header-mobile.test.tsx` and adds focused generalized +menu-action tests. Work orders name exact regression cases and commands. + +## E2E tests + +`apps/web/e2e/tests/task/mobile-threads-view.spec.ts` covers +AC-UI-THREADS-DECK-003.8 through .12: full-width columns, title picker, local +content overflow, topbar reachability, swipe discovery, and one active +conversation after paging. It also checks phone/tablet transitions. +`threads-view.spec.ts` covers the preserved desktop deck. +Task 04 adds a mid-gesture assertion before touch release and destination chat +hydration (AC-UI-THREADS-DECK-003.13). Task 05 updates the shared phone-header and +plugin specs plus every affected search/chat/terminal entry-point test. It +covers REQ-UI-MOBILE-QUICK-CHAT-TOPBAR-001 across all three listing modes and +preserves existing activity/status tests. No gesture test may wait for a new +chat mount before checking pagination. + +## Work orders + +- [x] [Task 01: Improve the phone conversation deck](task-01-phone-deck.md) +- [x] [Task 02: Calm the header and reveal swiping](task-02-header-and-swipe-cue.md) +- [x] [Task 03: Move pagination into the topbar](task-03-inline-pagination.md) +- [x] [Task 04: Synchronize swipe-position feedback](task-04-swipe-position-feedback.md) +- [x] [Task 05: Normalize phone listing headers](task-05-shared-phone-topbar.md) + +Execution order is 04 then 05, sequentially in the primary session. No native +implementation agents were authorized. + +## Separate requested subtask + +Kandev task `09730b6e-5e4c-4d81-ada6-fc0a5102e08a`, **Choose Threads as default +home view**, is a child of this task with `workspace_mode: new_workspace`, +base branch `main`, and inherited repository/profiles. It was created queued +with `start_agent: false`; no agent or extra session was launched. That task +owns explicit default-Home behavior and must distinguish it from remembered +last-listing state. Its workspace will not share this dirty worktree. + +## Verification results + +Tasks 04-05 are complete: + +- 319 distinct unit tests passed across the combined Threads/listing suite and + focused launcher/focus tests. +- 46 browser regressions passed: 33 mobile and 13 desktop, with one worker and + retries disabled. These cover all three headers, held-touch pagination while + loading, both opener focus paths, search reveal/clear, Home, plugins/metrics, + Quick Chat activity/delivery, terminal reload/reuse/cleanup, status, and + unchanged desktop Threads behavior. +- Permanent midpoint, held-swipe, shared-header, and detached-launcher + regressions failed before their fixes and passed afterward. The phone + viewport test now submits its bulk message once, avoiding replay of an + accepted send. Terminal cleanup waits for the settled menu state. +- Typecheck, scoped zero-warning lint, formatting, i18n checks and ratchet, + web build, 30 spec-linter tests, specification lint, and public-docs + validation passed. +- The existing seeded demo was updated in place. All three headers measured + 56px high at both 360px and 393px, with 44px menu buttons and no document + overflow. The final held swipe showed 2/4 at 65.8% scroll before release, + then one active conversation after snap. Dark captures are + `/tmp/kandev-mobile-threads-PuEDK5/mobile-shared-*.png`. +- HTTPS health and TLS-verified WebSocket upgrade passed during evaluation. + After the user reviewed the demo, its backend and mock-agent descendants + were stopped and only its private Tailscale Serve route was removed. Other + instances and routes were preserved. The database and captures remain in + the temporary demo directory. +- The user approved evaluation and requested a ready-for-review PR. Explicit + default-Home selection and task-level context-menu access remain separate + queued subtasks, outside this implementation. + +### PR review follow-up + +Resolved narrow-layout recovery and focus edge cases: saved-view errors stay +inside the phone drawer with a compact topbar warning; hiding search returns +focus; an archived picker opener falls back to a remaining thread. Leaving +phone layout clears measured position and closes the task picker. Tablet +navigation no longer duplicates Threads, the phone status icon is decorative, +and the swipe test helper always detaches CDP after touch failures. + +The review also clarified existing plugin ownership: arbitrary slot clicks +cannot close and unmount stateful controls. A real-registry regression protects +local plugin disclosure/input state. Public docs distinguish view selection, +the separate tool menu, and Quick Chat-only activity. Supported raw Playwright +project flags remain unchanged. + +Local follow-up verification passed 63 unit tests and 29 browser regressions +(17 mobile, 12 desktop), with one browser worker and retries disabled. New +failure-path, accessibility, focus, and responsive-transition regressions proved +RED before their fixes. Web build, typecheck, scoped zero-warning lint, +formatting, i18n checks/ratchet, specification lint, public-docs validation, and +diff checks passed. The evaluated demo remains stopped. Remote CI/review state +is tracked on the PR rather than recorded as a durable current-head claim. + +The first fixup CI run exposed the CDP unit test inside Playwright's discovery +root. Moving it to `e2e/helpers/` preserves the two cleanup regressions while +keeping Vitest outside browser discovery. The CI shard-planning command failed +locally before the move, then generated all 14 normal and six container +manifests successfully. The 13 helper/planner unit tests, typecheck, lint, and +formatting passed. This follow-up changes no application behavior. + +### Historical results (Tasks 01-03) + +- Implemented full-width phone pages, a title picker with focus return, a + grouped page/view control, balanced two-line task titles, and inline topbar + pagination with position and bounded page dots. Menu launchers and plugin + actions remain reachable. +- 187 focused unit tests passed: 157 Threads tests and 30 shared-header tests. +- 22 browser tests passed with one worker and retries disabled: six mobile + Threads, 12 desktop Threads, and four shared mobile-header/plugin tests. +- Web build, typecheck, scoped zero-warning lint, translation checks, public + docs validation, and specification lint passed. +- The isolated branch instance was evaluated privately at + `https://koi.taile29c7d.ts.net:48490/threads` through Tailscale Serve. + It is now stopped. Its retained fictional workspace has four tasks, five + sessions, and two demo profiles. + Its runtime records and captures were retained locally as historical + evaluation artifacts, not as repository prerequisites. +- Manual phone-emulation checks at 360 pixels verified equal viewport/chat + widths, successful mock reply delivery, and secondary-agent selection. + Physical-device keyboard and Safari behavior were not exercised. +- The header revision passed the same 187 unit and 22 browser tests. Its + active-dot assertion was also rerun after being strengthened. Dark 360-pixel + screenshots confirm balanced titles and contained multi-agent controls; + the existing HTTPS route and TLS WebSocket upgrade remain healthy. +- The inline-pagination follow-up passed 162 focused Threads/page unit tests + and all 22 browser regressions. Topbar height remains 56 pixels, while the + two-line single-agent task header decreased from 139 to 111 pixels. Position + follows the board's existing activation directly; no extra selection state, + instruction label, or pagination row remains in the column header. + +## Risks + +- Long translated labels and nested code scrolling can pressure narrow layouts. +- Picker navigation must not change stable order, drafts, or selected sessions. +- Evaluation uses mocked agents in a fresh database, not production work. +- Moving menu launchers can break existing search focus, dialog handoff, Home + routing, or background activity visibility. Task 05 preserves and tests each. +- Position sampling must stay independent of expensive chat activation and + cancel pending frames/listeners on viewport or membership changes. diff --git a/docs/plans/mobile-threads/task-01-phone-deck.md b/docs/plans/mobile-threads/task-01-phone-deck.md new file mode 100644 index 0000000000..f0781d1b5d --- /dev/null +++ b/docs/plans/mobile-threads/task-01-phone-deck.md @@ -0,0 +1,101 @@ +--- +id: "01-phone-deck" +title: "Improve the phone conversation deck" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-THREADS-DECK-003 +acceptance_criteria: + - AC-UI-THREADS-DECK-003.8 + - AC-UI-THREADS-DECK-003.9 + - AC-UI-THREADS-DECK-003.10 + - AC-UI-THREADS-DECK-003.11 +system_design: + - ../../specs/ui/system-design/threads-conversation-deck.md +--- + +# Task 01: Improve the phone conversation deck + +## Summary + +Give phone conversations their available width and a discoverable thread picker. +Reduce header competition while preserving session and utility actions. + +## In scope + +- Phone Threads topbar, column composition, and shared menu action slot. +- Translations and focused mobile interaction/geometry regressions. +- A fresh mock-data instance for user evaluation after verification. + +## Out of scope + +- Desktop redesign, new APIs, session lifecycle, publication, and real agents. + +## Acceptance + +- Each phone chat fills the deck; prose and composer fit at narrow widths. +- Title/count picker changes the visible chat; swipe and session selection work. +- Header controls remain reachable and a seeded branch instance is provided. + +## Verification + +```bash +make build-web +make -C apps/backend build e2e-plugin-package +cd apps/web +pnpm test components/threads/ lib/threads/ +pnpm test components/kanban/kanban-header-mobile.test.tsx components/kanban/mobile-menu-sheet.test.tsx components/kanban/mobile-menu-utility-actions.test.tsx components/kanban/main-top-bar-plugin-actions.test.tsx components/kanban/mobile-topbar-action-strip.test.tsx +pnpm run typecheck +pnpm run i18n:check +pnpm e2e:raw tests/task/mobile-threads-view.spec.ts --project=mobile-chrome --retries=0 +pnpm e2e:raw tests/task/threads-view.spec.ts --project=chromium --retries=0 +pnpm e2e:raw tests/kanban/mobile-kanban-topbar.spec.ts tests/plugins/mobile-plugin-topbar.spec.ts --project=mobile-chrome --retries=0 +``` + +## Files likely touched + +- `apps/web/components/threads/` +- `apps/web/components/kanban/kanban-header-mobile.tsx` +- `apps/web/components/kanban/mobile-menu-sheet.tsx` +- `apps/web/components/kanban/mobile-listing-menu-actions.tsx` +- `apps/web/app/threads/threads-page-client.tsx` +- `apps/web/src/locales/*/threads.json` +- `apps/web/e2e/tests/task/mobile-threads-view.spec.ts` +- `apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts` + +## Dependencies + +None. Install workspace dependencies in this fresh worktree before checking. + +## Risks + +Keep picker navigation separate from session selection and read-cursor state. + +## Parallelism + +`sequential` + +## Inputs + +Threads requirements/design, existing mobile column navigator and picker sheet. + +## Results + +- RED: the baseline phone column measured about 334 pixels in a 393-pixel + viewport and failed the full-width assertion. A separate menu regression + exposed missing Home/tool access after compacting the topbar. +- GREEN: 187 unit tests and 22 browser tests passed. Browser tests cover picker + selection and dismissal focus, real touch paging, one active chat, 360-pixel + containment, saved views, session switching, Quick Chat/terminal launchers, + plugin reachability, and unchanged desktop behavior. +- Web build, typecheck, scoped lint, i18n checks, specification checks, and + public documentation validation passed. +- A disposable mock instance was evaluated on loopback port 48490, with + four fictional tasks and five sessions, then stopped at the user's request. + The manual send check received a normal mock reply. The demo backend and + private route are stopped; its database and captures were retained locally. +- No production credentials, personal instance, real provider calls, commit, + push, or publication were used. Real-device keyboard/Safari checks remain + outside this emulated-browser verification. diff --git a/docs/plans/mobile-threads/task-02-header-and-swipe-cue.md b/docs/plans/mobile-threads/task-02-header-and-swipe-cue.md new file mode 100644 index 0000000000..1560063d57 --- /dev/null +++ b/docs/plans/mobile-threads/task-02-header-and-swipe-cue.md @@ -0,0 +1,74 @@ +--- +id: "02-header-and-swipe-cue" +title: "Calm the header and reveal swiping" +status: done +wave: 2 +depends_on: + - 01-phone-deck +plan: "plan.md" +requirements: + - REQ-UI-THREADS-DECK-003 +acceptance_criteria: + - AC-UI-THREADS-DECK-003.9 + - AC-UI-THREADS-DECK-003.10 + - AC-UI-THREADS-DECK-003.11 + - AC-UI-THREADS-DECK-003.12 +system_design: + - ../../specs/ui/system-design/threads-conversation-deck.md +--- + +# Task 02: Calm the header and reveal swiping + +## Summary + +Apply the user's evaluation feedback to the implemented mobile deck. Group the +page and view labels, give task titles space, and explain horizontal paging. + +## Scope and boundaries + +- Phone-only topbar composition and task header presentation. +- A persistent localized swipe hint, numeric position, and at most seven + decorative page dots; omit the cue for a single conversation. +- Preserve tablet/desktop controls, full-width chats, and existing pickers. +- No new session state, persisted preference, gesture handler, or backend API. +- Keep the same isolated demo and private Tailscale route; preserve its data. + +## Implementation + +Reuse the `PageTopbar` interactive title slot and the native `MobileColumnTabs` +orientation pattern. The board remains the only horizontal scroll owner; +transcripts and picker sheets retain their own vertical scroll regions. Keep +all actions at least 44 pixels and numeric indicators noninteractive. + +## Validation + +Add mobile E2E assertions before changing production markup: a two-line long +title at 360 pixels, stacked page/view labels, a separate swipe cue reflecting +picker and touch navigation, and no cue for one thread. Run the task-01 focused +unit, mobile/desktop E2E, build, typecheck, lint, and translation commands. +Visually inspect dark phone layouts and a multi-agent thread in the demo. + +## Parallelism + +`sequential` + +## Results + +- RED: the new mobile regression failed because the original header had no + `thread-swipe-cue` element. +- GREEN: all 187 focused unit tests and 22 browser tests passed with one E2E + worker and no retries. Mobile assertions cover stacked page/view labels, + two-line long titles, no cue for a single thread, separate count/dots, and + the correct active dot after both picker selection and a real touch swipe. + Phone/tablet transitions, utility actions, plugins, agent selection, saved + views, and all 12 desktop Threads regressions passed. +- Web build, backend fixture build, typecheck, scoped zero-warning lint, + formatting, translation checks, specification lint, and public docs + validation passed. The 138 existing locale orphan warnings remain unchanged. +- Dark phone emulation measured a 360-pixel chat and document in a 360-pixel + viewport. Balanced titles and multi-agent controls were visually inspected. + Screenshots are in `/tmp/kandev-mobile-threads-PuEDK5/`. +- The original evaluation database and private Tailscale route remain intact. + HTTPS health and the TLS-verified WebSocket upgrade (101) passed after the + UI refresh. No personal instance, credentials, commit, or publication used. +- Physical-device keyboard and Safari behavior remain unverified. diff --git a/docs/plans/mobile-threads/task-03-inline-pagination.md b/docs/plans/mobile-threads/task-03-inline-pagination.md new file mode 100644 index 0000000000..37768f531e --- /dev/null +++ b/docs/plans/mobile-threads/task-03-inline-pagination.md @@ -0,0 +1,87 @@ +--- +id: "03-inline-pagination" +title: "Move pagination into the topbar" +status: done +wave: 3 +depends_on: + - 02-header-and-swipe-cue +plan: "plan.md" +requirements: + - REQ-UI-THREADS-DECK-003 +acceptance_criteria: + - AC-UI-THREADS-DECK-003.9 + - AC-UI-THREADS-DECK-003.10 + - AC-UI-THREADS-DECK-003.12 +system_design: + - ../../specs/ui/system-design/threads-conversation-deck.md +--- + +# Task 03: Move pagination into the topbar + +## Scope and acceptance + +Apply the user's next evaluation feedback: keep dots and position, remove the +instruction text, and move the indicator inline beside the view control without +increasing the topbar height. Remove the extra task-header row. Preserve native +title/session pickers, full-width chat, tablet/desktop behavior, and demo data. + +## Design and ownership + +Reuse `PageTopbar` and the `MobileColumnTabs` current-context pattern. The board +keeps horizontal scrolling and viewport activation; its header render slot +exposes the active mobile task ID. The page derives position from stable order. +No extra observer, copied selection state, persisted preference, or new gesture +handler. Numeric dots are noninteractive; existing actions retain 44px targets. +The inline surface keeps orientation visible while restoring vertical chat space. + +Owned files: Threads page, board, mobile column header, mobile pagination, +Threads-only topbar test ID, localized label removal, and mobile E2E coverage. + +## Validation + +First fail the mobile regression on the missing topbar indicator. Then verify +same-row geometry, unchanged 56px topbar, reclaimed task-header space, active +dots after swipes/picker navigation, and no indicator for empty/single-thread or +tablet states. Run: + +```bash +make build-web +cd apps/web +pnpm test components/threads/ lib/threads/ app/threads/threads-page-client.test.tsx +pnpm run typecheck +pnpm run i18n:check +pnpm e2e:raw tests/task/mobile-threads-view.spec.ts tests/kanban/mobile-kanban-topbar.spec.ts tests/plugins/mobile-plugin-topbar.spec.ts --project=mobile-chrome --retries=0 +pnpm e2e:raw tests/task/threads-view.spec.ts --project=chromium --retries=0 +``` + +Run scoped ESLint, formatting, spec lint, public docs validation, and a dark +360px demo browser check. Refresh the existing private Tailscale demo; do not +reseed it. Risks: stale position, title pressure, or hidden header in empty state. + +## Parallelism + +`sequential` + +## Results + +- RED: the existing build failed the new regression because the page topbar + had no pagination indicator. +- GREEN: 162 focused unit tests and all 22 browser regressions passed with + one worker and no retries. Six mobile Threads tests prove live position after + picker selection and real swipes, inline control alignment, reclaimed header + height, and phone/tablet transitions. Four shared mobile/plugin tests and all + 12 desktop Threads tests passed. +- An initial geometry assertion included the topbar's bottom border, placing + its center half a pixel below every control. The final assertion compares + actual control centers and separately checks containment and topbar height. +- Build, typecheck, scoped zero-warning lint, formatting, translations, spec + lint, and public documentation checks passed. No backend rebuild or schema + change was needed. +- Dark 360px demo inspection measured the unchanged 56px topbar, a task-header + reduction from 139 to 111px, and a 360px chat/document width. Multi-agent + controls remain contained. Screenshots: `mobile-inline-pagination-dark.png` + and `mobile-inline-pagination-agents.png` under the demo runtime directory. +- The existing demo database and private Tailscale route remain intact. HTTPS + health and a TLS-verified WebSocket upgrade (101) passed. The temporary + inspection browser was closed; the demo remains running. No commit or + publication was made. Physical-device keyboard and Safari remain untested. diff --git a/docs/plans/mobile-threads/task-04-swipe-position-feedback.md b/docs/plans/mobile-threads/task-04-swipe-position-feedback.md new file mode 100644 index 0000000000..4c96d34c9c --- /dev/null +++ b/docs/plans/mobile-threads/task-04-swipe-position-feedback.md @@ -0,0 +1,145 @@ +--- +id: "04-swipe-position-feedback" +title: "Synchronize swipe-position feedback" +status: done +wave: 4 +depends_on: + - 03-inline-pagination +plan: "plan.md" +requirements: + - REQ-UI-THREADS-DECK-003 +acceptance_criteria: + - AC-UI-THREADS-DECK-003.4 + - AC-UI-THREADS-DECK-003.9 + - AC-UI-THREADS-DECK-003.12 + - AC-UI-THREADS-DECK-003.13 +system_design: + - ../../specs/ui/system-design/threads-conversation-deck.md +--- + +# Task 04: Synchronize swipe-position feedback + +## Summary + +Make the inline pagination and task picker's current row follow phone scroll +position during a swipe, independently of detail hydration. Keep existing +session activation limits, stable order, and desktop behavior intact. + +## In scope + +- Expose `mobileTaskId` from the board's existing viewport owner using passive + scroll, frame-coalesced shell geometry, and resize/order reconciliation. +- Consume that ID directly in the board's header slot and picker instead of + deriving their current context from `detailTaskIds`. +- Add permanent targeted unit and mobile touch regressions after proving RED. +- Keep Threads guidance and public pagination description accurate after the + behavior changes. Refresh the existing demo without reseeding. + +## Out of scope + +- Transcript prefetch policy, expanded detail budgets, session selection, + global state, saved preferences, custom touch handling, continuous fractional + dot animation, and other listing headers. + +## Acceptance + +1. The indicator changes as the nearest shell changes while two shells remain + intersecting, including reversal, with no dependency on loading completion. +2. Deep links, picker selection, removal, resize, empty/single-thread states, + and phone/tablet transitions leave a valid position and clean up scheduled + work. Only one phone transcript is detail-active. +3. Existing 56-pixel topbar, bounded dots, no instruction text, full-width + columns, local content overflow, and focus-return behavior remain intact. + +## Verification + +First add a regression named `updates mobile position across the midpoint +without changing intersecting membership` in +`thread-column-activation.test.tsx`. The temporary diagnostic found A still +selected with B covering 60% of a 300-pixel board. Assert the new presentation +identity rather than requiring a change to detail hydration policy. Add +reversal, pending-frame cleanup, resize, and admitted-order fallback cases. + +Add `updates inline pagination during a held swipe before destination detail +loads` to `mobile-threads-swipe.spec.ts`. Share the existing CDP touch pattern, +assert actual intermediate geometry before `touchEnd`, and delay destination +detail delivery through existing test fixtures. Do not weaken this to a +settled-scroll or chat-mounted assertion. Then release and assert snap and +the one-conversation budget. Avoid arbitrary sleeps. + +From the repository root (dependencies are already installed in this worktree): + +```bash +cd apps/web +pnpm test components/threads/ lib/threads/ app/threads/threads-page-client.test.tsx +pnpm run typecheck +pnpm exec eslint components/threads/ app/threads/threads-page-client.tsx --max-warnings=0 +pnpm run i18n:check +cd ../.. +make build-web +cd apps/web +pnpm e2e:raw tests/task/mobile-threads-view.spec.ts tests/task/mobile-threads-swipe.spec.ts --project=mobile-chrome --retries=0 +pnpm e2e:raw tests/task/threads-view.spec.ts --project=chromium --retries=0 +``` + +Run scoped formatting and spec checks after edits. Inspect the refreshed +360-pixel demo during a real swipe, not just after it. Preserve the database +and private Tailscale Serve route documented in the parent plan. + +## Files likely touched + +- `apps/web/components/threads/use-thread-column-activation.ts` +- `apps/web/components/threads/use-mobile-thread-position.ts` +- `apps/web/components/threads/thread-viewport-geometry.ts` +- `apps/web/components/threads/thread-column-activation.test.tsx` +- `apps/web/components/threads/threads-board.tsx` and its tests +- `apps/web/app/threads/threads-page-client.test.tsx` +- `apps/web/e2e/tests/task/mobile-threads-view.spec.ts` +- `apps/web/e2e/tests/task/mobile-threads-swipe.spec.ts` and its shared touch helper +- `apps/web/components/threads/AGENTS.md` +- `docs/public/sessions-and-review.md` + +## Dependencies + +Task 03 is complete. Read its current uncommitted implementation, not just HEAD. + +## Risks + +React renders on every scroll pixel, stale geometry after order/width changes, +late callbacks after unmount, or accidentally promoting additional transcripts. +Update only on identity changes and retain the existing network owners. + +## Parallelism + +`sequential` + +## Inputs + +- `REQ-UI-THREADS-DECK-003`, especially .9, .12, and .13. +- Threads design: Phone position feedback and Responsive behavior. +- Existing activation hook tests and real-touch mobile Threads E2E helper. + +## Results + +Implemented after the user's explicit implementation request. Permanent unit +and real held-touch browser regressions failed on stale position before the +fix. The position signal now samples shared shell geometry once per frame and +updates header/picker without waiting for detail delivery. It also invalidates +the nearest-visible detail calculation: browser coverage exposed zero-area +edge intersections retaining the prior conversation after snap. The one-chat +budget and existing preload policy remain intact. + +Passed 166 focused unit tests, seven mobile Threads tests, and 12 desktop +Threads tests, all browser tests with one worker and retries disabled. +Typecheck, scoped zero-warning lint, translation checks, and web build passed. +The shared-header work order owns the final combined demo/visual verification. + +PR review follow-up adds a first-commit regression for returning from desktop +with a changed fallback task. The stale measured phone identity now clears +when disabled. It also tests picker closure across phone/tablet/desktop +transitions and CDP cleanup when touch start or end fails. Each regression +failed before its fix. Combined follow-up results are recorded in the plan. + +CI follow-up keeps the CDP unit regression under `e2e/helpers/`, outside +Playwright's `e2e/tests/` discovery root. CI's full shard-planning command +reproduced the accidental Vitest import before this test-only relocation. diff --git a/docs/plans/mobile-threads/task-05-shared-phone-topbar.md b/docs/plans/mobile-threads/task-05-shared-phone-topbar.md new file mode 100644 index 0000000000..a1efc98ae2 --- /dev/null +++ b/docs/plans/mobile-threads/task-05-shared-phone-topbar.md @@ -0,0 +1,196 @@ +--- +id: "05-shared-phone-topbar" +title: "Normalize phone listing headers" +status: done +wave: 5 +depends_on: + - 04-swipe-position-feedback +plan: "plan.md" +requirements: + - REQ-UI-MOBILE-QUICK-CHAT-TOPBAR-001 + - REQ-UI-THREADS-DECK-003 + - REQ-UI-QUICK-CHAT-IDLE-DOT-001 +acceptance_criteria: + - AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.1 + - AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.2 + - AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.3 + - AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.4 + - AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.5 + - AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.6 + - AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.7 + - AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.8 + - AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.9 + - AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.10 + - AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.11 + - AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.12 + - AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.13 + - AC-UI-THREADS-DECK-003.10 + - AC-UI-THREADS-DECK-003.12 + - AC-UI-QUICK-CHAT-IDLE-DOT-001.5 +system_design: + - ../../specs/ui/system-design/mobile-quick-chat-topbar.md + - ../../specs/ui/system-design/threads-conversation-deck.md +--- + +# Task 05: Normalize phone listing headers + +## Summary + +Apply one compact phone header to Kanban, List, and Threads. Generalize the +existing Threads menu actions so moving controls does not remove functionality +or change workspace, listing, or plugin semantics. + +## In scope + +- Shared 56-pixel phone topbar, stacked context control, fixed ghost menu, and + unchanged inline Threads pagination; remove the phone brand/action strip. +- Kanban/List workspace-and-mode context buttons opening the existing menu; + keep the Threads saved-view control and drawer. +- Generalize menu launchers, preserve search reveal/clear, workspace-aware + Home, list/board display controls, plugin props, metrics, connection status, + and Quick Chat activity feedback. Preserve focus from both menu openers. +- Migrate all affected mobile entry-point tests to visible menu controls. +- Update affected public how-to sections in `developer-tools.md`, + `tasks-and-workflows.md`, and `sessions-and-review.md` where needed. No + speculative default-Home instructions. Reuse existing localization keys + where possible; new copy requires every supported catalog. +- Refresh and inspect the existing private evaluation instance without + reseeding it or altering unrelated Tailscale services. + +## Out of scope + +Tablet/desktop headers, task-session navigation, new saved views for Kanban or +List, backend/settings changes, explicit Home defaults, plugin API changes, +and agent/session lifecycle changes. + +## Acceptance + +1. All three phone modes share header geometry and visible context/menu + hierarchy at 360 and 393 pixels, without horizontal overflow or a permanent + extra row. Their primary context actions and Threads indicator work. +2. Every relocated action remains touch-reachable and workspace-correct; + search focuses/clears correctly, Home preserves routing, activity/status + remain visible, menu/dialog handoff and focus return work, and enabled + metrics/plugins fit inside the scrolling drawer. +3. Existing listing preferences, tablet/desktop composition, phone content + scrolling, drafts, and session selection remain unchanged. + +## Verification + +RED: parameterize `kanban-header-mobile.test.tsx` over Kanban, List, and +Threads, asserting the shared title slot and absence of the strip. Replace the +old fixed-brand browser expectations with a three-mode geometry/navigation +regression before changing production. Add focused menu action tests for +page-specific plugin props, both openers' focus return, close-before-launch, +search clearing, missing workspace, and simultaneous activity/connection cues. + +Update the existing mobile search/chat/terminal tests instead of deleting +their outcome assertions. Search for every old launcher selector and migrate +shared helpers as well as direct test calls. Keep the tests' shared-setting +capture/restore isolation. + +From the repository root (install dependencies first only in a fresh worktree): + +```bash +cd apps/web +pnpm test components/kanban/ lib/task-listing/ components/threads/ app/threads/threads-page-client.test.tsx +pnpm test components/quick-chat/quick-chat-focus.test.ts components/quick-chat/quick-chat-provider-focus.test.tsx hooks/use-quick-chat-launcher.test.ts hooks/use-quick-terminal-launcher.test.ts +pnpm run typecheck +pnpm exec eslint components/kanban/ --max-warnings=0 +pnpm run i18n:check +cd ../.. +make build-web +cd apps/web +pnpm e2e:raw tests/kanban/mobile-kanban-topbar.spec.ts tests/plugins/mobile-plugin-topbar.spec.ts tests/task/mobile-threads-view.spec.ts tests/task/mobile-threads-swipe.spec.ts tests/task/mobile-task-list-search.spec.ts tests/task/mobile-task-listing-display.spec.ts --project=mobile-chrome --retries=0 +pnpm e2e:raw tests/chat/mobile-quick-chat-entry.spec.ts tests/chat/mobile-quick-chat-tabs.spec.ts tests/chat/mobile-quick-chat-idle-dot.spec.ts tests/chat/mobile-quick-chat-saved-prompt-delivery.spec.ts tests/terminal/mobile-quick-terminal.spec.ts tests/layout/mobile-app-status-bar.spec.ts --project=mobile-chrome --retries=0 +pnpm e2e:raw tests/task/threads-view.spec.ts tests/chat/quick-chat-saved-prompt-delivery.spec.ts --project=chromium --retries=0 +pnpm e2e:raw tests/kanban/mobile-kanban.spec.ts tests/mobile-zoom.spec.ts --project=mobile-chrome --retries=0 --grep 'renders focused mobile layout|search toggle reveals|collapsing search clears|opening mobile menu does not focus|mobile search bar filters|form fields render' +cd ../.. +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +node scripts/validate-public-docs.mjs +git diff --check +``` + +Run guarded E2E commands sequentially, never pass a literal `--` before their +arguments, and rebuild before browser runs. Include a focused 820-pixel +coarse-pointer regression in the shared header tests. Run scoped formatting +and lint on any additional changed files. Check dark 360/393-pixel screenshots +of all three modes and opened menus, then verify the existing demo HTTPS and +WebSocket route. Do not call the old 22-test result proof of this follow-up. + +## Files likely touched + +- `apps/web/components/kanban/kanban-header-mobile.tsx` and its tests +- `apps/web/components/kanban/kanban-header.tsx` if a phone-only input is needed +- `apps/web/components/kanban/mobile-threads-menu-actions.tsx` (generalize name) +- `apps/web/components/kanban/mobile-menu-sheet.tsx` +- `apps/web/components/kanban/mobile-search-bar.tsx` only for focus/close wiring +- `apps/web/components/quick-chat/quick-chat-focus.ts` and its regression tests +- `apps/web/hooks/use-quick-chat-launcher.ts` and `use-quick-terminal-launcher.ts` + for explicit persistent-opener focus through the existing focus owner +- `apps/web/components/threads/threads-view-controls.tsx` for shared appearance +- Existing mobile topbar, plugin, search, chat, terminal, and status E2Es above +- `apps/web/e2e/tests/chat/quick-chat-saved-prompt-delivery-helpers.ts` +- Applicable locale catalogs, scoped guidance, and public how-to docs + +## Dependencies + +Task 04; preserve its direct, scroll-derived pagination signal while composing +the shared header. No dependency on the queued default-Home subtask. + +## Risks + +Missing Home after wordmark removal, hidden metrics with status disabled, +incorrect plugin `currentPage`, focus restored into an unmounted menu, duplicated +search inputs, hidden Quick Chat activity, and accidentally changing tablet +menus through shared components. Tests must retain those capabilities. + +## Parallelism + +`sequential` + +## Inputs + +- Mobile Workspace Topbar requirement and paired system design. +- Threads responsive design and Task 04's completed implementation. +- Quick Chat activity requirements, existing launcher hooks, PageTopbar, and + MobileMenuSheet. Use the navigation manifest, not a second destination list. + +## Results + +Implemented the shared context control and quiet 56px phone header, generalized +menu actions, search reveal/clear, plugin identity, opted-in metrics, Home, and +visible connection/activity cues. Permanent unit and browser checks reproduced +the old composition before implementation. Tablet/desktop composition remains +unchanged. + +The terminal close regression found a detached menu-row focus target. Added +an optional persistent-opener ref through the existing Quick Chat focus owner; +both unit and browser assertions failed on the missing focus return before +the fix. Targeted focus, launcher, plugin, and header tests now pass. Combined +Tasks 04-05 verification passed 319 distinct unit and 46 browser tests (33 +mobile, 13 desktop), with one browser worker and retries disabled. The final +chat/terminal run also passed with tracing enabled. The layout test submits +once instead of replaying an accepted send; terminal cleanup waits for the +settled menu state. + +Typecheck, zero-warning scoped lint, formatting, i18n checks/ratchet, web build, +30 spec-linter tests, specification lint, and public-docs validation passed. + +The existing demo was refreshed without reseeding. At 360px and 393px, all +three headers measured 56px high with 44px menu buttons and no document +overflow. The final held touch swipe reported 2/4 at 65.8% scroll before release, with +one active chat after snap. Dark captures are in the runtime directory. +HTTPS health and TLS WebSocket upgrade passed. After the user reviewed the +demo, its backend and private route were stopped; the database and captures +remain available for an explicitly requested restart. + +PR review follow-up moves saved-view recovery into the phone drawer with a +compact warning beside the view name, removes duplicate tablet Threads +navigation, restores focus when hiding search or archiving a picker opener, +and hides the redundant phone status icon from assistive technology. New +browser regressions failed before these fixes. A real plugin-registry test +preserves stateful plugin controls: closing the menu on arbitrary plugin clicks +would unmount their local state. Plugin and public UI guidance now documents +the current composition. Combined follow-up results are recorded in the plan. diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md index af91d990a9..0d773ad0d9 100644 --- a/docs/plans/plugins/PLUGIN-API.md +++ b/docs/plans/plugins/PLUGIN-API.md @@ -821,10 +821,10 @@ interface PluginRegistry { // Home / Kanban / Tasks views (beside the CPU/DB metrics and the view/display // controls) and forwards `{ workspaceId, workspaceLabel, currentPage, // presentation }`, where presentation is "desktop" or "mobile". On a phone, - // contributions join the horizontally scrollable middle action strip between - // the fixed Kandev link and menu button. Documented host ui.Button icon - // contributions are normalized to a 32px box with a 16px SVG icon on phones; - // desktop contribution sizing is unchanged. It is the app-wide, + // listing contributions live inside the topbar menu with 44px touch targets + // and 16px SVG icons. Slots own their controls and disclosure state; the host + // does not dismiss the menu on arbitrary plugin interactions. Desktop + // contribution sizing is unchanged. It is the app-wide, // task-agnostic counterpart to "chat-top-bar", so it carries no task/session // ids. // "sidebar-workspace-actions" renders icon buttons after the built-in Quick diff --git a/docs/public/developer-tools.md b/docs/public/developer-tools.md index 73305b2e73..3eaa26e117 100644 --- a/docs/public/developer-tools.md +++ b/docs/public/developer-tools.md @@ -44,6 +44,12 @@ 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 +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. + ### Start a chat 1. Turn on **Configuration chat** when the conversation should inspect or change Kandev configuration. This option is hidden when the workspace already has a configuration conversation. diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md index d8ca4c0ca1..1e0078498f 100644 --- a/docs/public/sessions-and-review.md +++ b/docs/public/sessions-and-review.md @@ -85,6 +85,17 @@ Threads shows one column for each task with an active primary agent session. The On desktop, use the session tabs in a column to switch between any existing session for that task. On a phone, tap the session control and choose a session from the bottom sheet. The selected conversation keeps its normal reply controls, so you can answer the agent without leaving Threads. +On a phone, each conversation fills the screen width. The topbar shows your +position beside the view name; small decks also show page dots. Position follows +your swipe, even while the next conversation is loading. Swipe sideways, +or tap the task title to choose a thread +from a bottom sheet. The picker also shows task status, workflow, and step. +Tap the view name below **Threads** at the top to change views. Use the separate +topbar menu button for Quick Chat, Quick Terminal, and system status. + +If a warning appears beside the view name on a phone, open the view picker to +retry the failed saved-view update or dismiss the warning. + Select **Open task** in a column when you need the complete task workbench. To link directly to a task and session, use a Threads URL with `taskId` and `sessionId` query parameters.
diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 2e8f59037f..a7f8ff726a 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -393,7 +393,19 @@ deletion is not success. ## Find and organize tasks -On desktop and tablet, the header switches between **Kanban**, **Pipeline**, and **List**. Kanban and Pipeline show the same workflow steps in different layouts. Kandev remembers the last selected view in that browser on the current device. Phones offer **Kanban** and **List** only; a saved desktop Pipeline preference is kept but shown as Kanban on the phone. +On desktop and tablet, the header switches between **Kanban**, **Pipeline**, +**Threads**, and **List**. Kanban and Pipeline show the same workflow steps in +different layouts. Kandev remembers the last selected view in that browser on +the current device. Phones offer **Kanban**, **Threads**, and **List** in the +topbar menu; 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. Under **Settings → General → Appearance → Startup Page**, choose **Task overview** (the default) or **Last visited task**. The latter resumes the most recently opened task in the current workspace on that device when Kandev starts or you open bare Home. It does not change an explicit task or workflow link. Home navigation and a task's Back action always return to the task overview; when there is no matching local recent task, Kandev opens the overview instead. diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md index f46455fb81..37c85187c3 100644 --- a/docs/specs/ui/README.md +++ b/docs/specs/ui/README.md @@ -181,6 +181,7 @@ UI owns responsive behavior; other systems own behavior/state. - [Message Queue Automation Controls (current)](system-design/message-queue-automation-controls.md) - [Native HTML File Preview](system-design/native-html-preview.md) - [Mobile Task Chrome](system-design/mobile-task-chrome.md) +- [Mobile workspace topbar](system-design/mobile-quick-chat-topbar.md) - [Mobile Menu Backdrops System Design](system-design/mobile-menu-backdrops.md) - [Persistent status motion](system-design/persistent-status-motion.md) - [Repository Groups](system-design/sidebar-repository-grouping.md) diff --git a/docs/specs/ui/requirements/mobile-quick-chat-topbar.md b/docs/specs/ui/requirements/mobile-quick-chat-topbar.md index ab41f35057..c0abb17860 100644 --- a/docs/specs/ui/requirements/mobile-quick-chat-topbar.md +++ b/docs/specs/ui/requirements/mobile-quick-chat-topbar.md @@ -2,98 +2,91 @@ status: active system: ui created: 2026-07-17 -updated: 2026-08-18 +updated: 2026-09-09 owners: - kandev --- -# Mobile Workspace Topbar Actions Requirements + +# Mobile Workspace Topbar Requirements ## Overview -Mobile users need direct access to workspace actions from the Home and Tasks headers. The header must remain usable when resource metrics and plugin actions add more controls than a phone can fit. +Phone Kanban, List, and Threads share one compact listing header. Current +context and navigation remain visible while secondary workspace actions stay +available in a touch-friendly menu. UI owns this reusable presentation +contract; each listing retains its own filtering and navigation state. ## Requirements -### REQ-UI-MOBILE-QUICK-CHAT-TOPBAR-001: Mobile Workspace Topbar Actions +### REQ-UI-MOBILE-QUICK-CHAT-TOPBAR-001: Mobile workspace topbar -**Intent:** Mobile users need direct access to workspace actions from the Home and Tasks headers. The header must remain usable when resource metrics and plugin actions add more controls than a phone can fit. +**Intent:** Make listing modes feel like one application without crowding the +conversation, board, or task list. #### Acceptance criteria -- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.1:** On mobile Home and Tasks headers with an active workspace, `Quick Terminal` appears before `Quick Chat`. `Quick Chat` appears immediately before the task-search button. -- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.2:** Activating `Quick Chat` opens Quick Chat for the active workspace. Existing workspace chats are available in the dialog, and its existing new-chat action remains available. -- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.3:** Activating `Quick Terminal` opens the active workspace's terminal in the shared Quick Chat surface. -- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.4:** The mobile Kandev wordmark is a link to the active workspace's Home board. -- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.5:** The Home header does not render a separate `Home` label. Non-Home headers retain their existing page-title visibility while keeping the Kandev home link available. -- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.6:** The Kandev link is the only fixed content at the left edge. The menu button is the only fixed content at the right edge. -- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.7:** A non-Home title, its workspace label, plugin actions, resource metrics, `Quick Terminal`, `Quick Chat`, and search use one horizontal strip between the fixed controls. -- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.8:** When the strip content fits, its actions are aligned against the fixed menu on the right. The middle strip scrolls horizontally only when its content does not fit. It does not add horizontal scrolling to the page. +- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.1:** On phone Kanban, List, and Threads + with an active workspace, the header menu shall expose Quick Chat and Quick + Terminal as labeled touch actions. +- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.2:** Activating Quick Chat shall open it + for the active workspace with existing chats and its new-chat action + available. The listing menu shall close before the chat surface opens. +- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.3:** Activating Quick Terminal shall open + the active workspace's terminal in the shared Quick Chat surface. The listing + menu shall close before that surface opens. +- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.4:** Each phone listing menu shall expose + Home through the existing workspace-aware home navigation. Changing header + presentation shall not alter the selected listing preference or Home routing. +- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.5:** Each phone listing header shall show + one unboxed, two-line current-context control. Kanban and List shall identify + their mode and workspace; Threads shall identify its mode and active saved + view. Long names shall truncate within the control. +- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.6:** The context control shall remain at + the left and the navigation menu shall remain at the right, using the same + header height, spacing, typography, and touch-target geometry across all + three modes. Neither shall require horizontal scrolling to reach. +- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.7:** The idle phone listing header shall + not contain a separate wordmark, redundant page breadcrumb, utility action + strip, or extra navigation row. Threads pagination follows the + [Threads deck contract](threads-conversation-deck.md). +- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.8:** The header and its menu shall not + cause document-level horizontal overflow, including with long translated + labels, enabled metrics, or multiple plugin actions. A long menu shall scroll + vertically inside the viewport and clear safe-area insets. +- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.9:** Activating the Kanban or List + context control shall open the existing listing menu. Activating the Threads + context control shall retain its saved-view picker. Mode switching, + workspace selection, applicable filters, and display options shall remain + available without introducing new saved-view semantics in Kanban or List. +- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.10:** When task search is supported, the + menu shall expose its existing search action. Opening search shall dismiss + the menu and focus a visible search input. Closing search shall clear its + query and restore unfiltered results. No permanent search row shall appear + while search is closed. +- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.11:** Page-specific plugin actions, + workspace actions, enabled metrics, and system status shall remain reachable + from the phone listing menu. Their existing availability settings, workspace + scope, and action behavior shall be preserved. +- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.12:** The persistent menu control shall + preserve connection warnings and Quick Chat activity feedback, including + accessible descriptions. Quick Chat activity follows the existing + [activity indicator contract](quick-chat-idle-dot.md). +- **AC-UI-MOBILE-QUICK-CHAT-TOPBAR-001.13:** Context controls, standalone menu + buttons, and utility rows shall provide touch targets of at least 44 CSS + pixels in the active dimension. Dismissing an overlay shall restore focus to + a mounted trigger. Without an active workspace, unusable workspace launchers + shall not appear. -## Migrated source detail +## Compatibility and exclusions -## Why +The compact phone composition supersedes the former fixed-wordmark and +scrolling-action-strip presentation. Workspace launchers move into the menu; +the underlying capabilities and navigation preferences do not change. -Mobile users need direct access to workspace actions from the Home and Tasks headers. The header -must remain usable when resource metrics and plugin actions add more controls than a phone can fit. +Tablet and desktop headers, task-session chrome, mobile bottom navigation, +floating task creation, session lifecycle, plugin APIs, and persistence are +outside this change. Selecting an explicit default Home view is separate work. -## What +## System design -- On mobile Home and Tasks headers with an active workspace, `Quick Terminal` appears before - `Quick Chat`. `Quick Chat` appears immediately before the task-search button. -- Activating `Quick Chat` opens Quick Chat for the active workspace. Existing workspace chats - are available in the dialog, and its existing new-chat action remains available. -- Activating `Quick Terminal` opens the active workspace's terminal in the shared Quick Chat - surface. -- The mobile Kandev wordmark is a link to the active workspace's Home board. -- The Home header does not render a separate `Home` label. Non-Home headers retain their - existing page-title visibility while keeping the Kandev home link available. -- The Kandev link is the only fixed content at the left edge. The menu button is the only fixed - content at the right edge. -- A non-Home title, its workspace label, plugin actions, resource metrics, `Quick Terminal`, - `Quick Chat`, and search use one horizontal strip between the fixed controls. -- When the strip content fits, its actions are aligned against the fixed menu on the right. The - middle strip scrolls horizontally only when its content does not fit. It does not add horizontal - scrolling to the page. -- A fade appears at each strip edge only when more actions exist beyond that edge. The fade - disappears when the strip reaches that edge. -- Native icon buttons in this header use the same 32 by 32 CSS-pixel visible box and 16 CSS-pixel - icon. Resource metric and host-rendered plugin icons use the same 16 CSS-pixel icon size. -- The resource metrics region uses the same 32 CSS-pixel height as the icon buttons. -- Quick Terminal and Quick Chat retain a 44 CSS-pixel coarse-pointer hit area around their 32 - CSS-pixel visible boxes without widening the action-strip items. -- The tablet header and mobile task switcher retain their existing Quick Chat entry points. -- Desktop headers do not change. - -## Scenarios - -- **GIVEN** a mobile Home header with an active workspace, **WHEN** the header renders, **THEN** - the terminal, chat, search, and menu controls use the same visible size and no separate `Home` - label is shown. -- **GIVEN** a mobile Home header, **WHEN** the user activates `Quick Chat`, **THEN** the active - workspace's Quick Chat dialog opens and the user can access existing chats or start a new one. -- **GIVEN** a mobile Home header, **WHEN** the user activates `Quick Terminal`, **THEN** the active - workspace's terminal opens in the shared Quick Chat surface. -- **GIVEN** resource metrics and plugin actions that exceed the middle strip width, **WHEN** the - mobile header renders, **THEN** the Kandev link and menu remain fixed while the strip scrolls. -- **GIVEN** a scrollable middle strip, **WHEN** hidden actions exist to the left or right, **THEN** - only the corresponding directional fade is visible. -- **GIVEN** a middle strip whose actions fit, **WHEN** the mobile header renders, **THEN** its - actions align against the menu, no overflow fade is visible, and the document has no horizontal - overflow. -- **GIVEN** a mobile non-Home workbench page, **WHEN** the user activates the Kandev wordmark, - **THEN** the app navigates to that workspace's Home board. -- **GIVEN** no active workspace, **WHEN** the mobile header renders, **THEN** it does not show an - unusable Quick Chat or Quick Terminal button. - -## Out of scope - -- Changes to Quick Chat creation, persistence, session selection, or modal layout. -- Changes to Quick Terminal lifecycle or terminal layout. -- Changes to desktop topbars. -- Changes to tablet header sizing. -- Changes to task-session topbars, mobile bottom navigation, or floating task creation. - -## Implementation plan - -- [Original Quick Chat topbar implementation](../../../plans/mobile-quick-chat-topbar/plan.md) -- [Scrollable mobile topbar repair](../../../plans/mobile-topbar-action-strip/plan.md) +- [Mobile Workspace Topbar](../system-design/mobile-quick-chat-topbar.md) diff --git a/docs/specs/ui/requirements/threads-conversation-deck.md b/docs/specs/ui/requirements/threads-conversation-deck.md index 9eb0353e6e..86253ba4ab 100644 --- a/docs/specs/ui/requirements/threads-conversation-deck.md +++ b/docs/specs/ui/requirements/threads-conversation-deck.md @@ -129,6 +129,33 @@ desktop and mobile navigation. - **AC-UI-THREADS-DECK-003.7:** Loading, empty-session, and recoverable session-list failure states shall remain inside their task column and shall not block horizontal navigation to other columns. +- **AC-UI-THREADS-DECK-003.8:** On a phone, each snapped conversation shall fill + the available content width. The composer and prose shall remain contained; + wide code and tables may scroll within their own content region. +- **AC-UI-THREADS-DECK-003.9:** On a phone, the task title shall open a bottom + sheet listing admitted threads in stable order with their attention state and + workflow context. Selecting a row shall reveal its conversation and close the + sheet. When several threads are available, the page topbar shall show the + visible task's position and thread count. +- **AC-UI-THREADS-DECK-003.10:** The phone page header shall keep the selected + view and navigation menu directly reachable without horizontal scrolling. + Secondary tools and status shall remain available from that menu. +- **AC-UI-THREADS-DECK-003.11:** Phone task headers shall prioritize the title, + attention state, session picker, and Open task action. Workflow and step + metadata shall remain available in the thread picker. New standalone controls + shall have a minimum 44-pixel touch target. +- **AC-UI-THREADS-DECK-003.12:** The phone page header shall group page identity + and the selected view in one control. Task titles shall have up to two lines. + With multiple threads, position and bounded, noninteractive page indicators + shall sit inline in the existing page topbar, without instruction text or an + additional row. Indicators shall follow the visible thread after swiping or + picker selection. Small decks shall include page dots. Empty decks and a + single thread shall omit the indicator. +- **AC-UI-THREADS-DECK-003.13:** While a phone swipe makes a different thread + nearest the viewport center, the page indicator shall update during the + gesture, without waiting for release, snap completion, session membership, + or transcript loading. Reversing the swipe shall restore the indicator for + the nearest thread. Loading completion alone shall not change its position. ## Out of scope diff --git a/docs/specs/ui/system-design/mobile-quick-chat-topbar.md b/docs/specs/ui/system-design/mobile-quick-chat-topbar.md new file mode 100644 index 0000000000..eb02b447ba --- /dev/null +++ b/docs/specs/ui/system-design/mobile-quick-chat-topbar.md @@ -0,0 +1,158 @@ +--- +status: draft +system: ui +requirements: + - REQ-UI-MOBILE-QUICK-CHAT-TOPBAR-001 +--- + +# Mobile Workspace Topbar System Design + +## Purpose and boundaries + +This UI-owned reusable composition covers phone Kanban, List, and Threads. +It replaces the Kanban/List scrolling action strip with the quiet Threads +header while keeping listing state and workspace tools in their current owners. +`useResponsiveBreakpoint().isMobile` selects this composition; tablet, +desktop, task-session headers, and plugin-owned routes retain their layouts. + +The closest shipped exemplars are `MobileColumnTabs` for visible current +context and `MobileMenuSheet` for inset, safe-area-aware navigation. The +current Threads page contributes the 56-pixel topbar, unboxed stacked title, +and 44-pixel ghost menu button. The always-visible header carries orientation; +the drawer carries temporary navigation and less frequent workspace tools. + +## Requirement mapping + +| Requirement | Design sections | +| --- | --- | +| `REQ-UI-MOBILE-QUICK-CHAT-TOPBAR-001` | [Shared header](#shared-header), [Menu capabilities](#menu-capabilities), [Responsive geometry and focus](#responsive-geometry-and-focus) | + +## Shared header + +`KanbanHeader` continues to own responsive dispatch and passes existing page, +workspace, search, and listing-control inputs to `KanbanHeaderMobile`. +`KanbanHeaderMobile` renders one `PageTopbar` composition for all phone listing +pages: height/min-height 56 pixels, `freeWidth="lead"`, no separate brand or +home breadcrumb, a shrinking title slot, and a fixed 44-pixel ghost menu button. +No conditional phone branch retains the old action strip. + +The title slot uses the same two-line typography, spacing, truncation, and +chevron treatment, with these mode-specific contents: + +| Mode | Context line | Primary line | Tap destination | +| --- | --- | --- | --- | +| Kanban | Existing workspace label | Localized Kanban label | Existing listing menu | +| List | Existing workspace label | Localized List label | Existing listing menu | +| Threads | Localized Threads label | Existing active saved-view name | Existing saved-view drawer | + +Kanban/List reuse the already-resolved workspace label, including its loading +or no-workspace fallback. Labels are rendered with existing i18n keys and are +not used as routing discriminants. The current route determines the visible +mode; the phone's effective Pipeline fallback never overwrites desktop state. + +Threads supplies `ThreadsViewControls` and `MobileThreadPagination` through +the existing `taskListingControls` slot. Its +[deck design](threads-conversation-deck.md#phone-position-feedback) owns swipe +position. The shared header knows neither transcript state nor thread order. +Kanban/List get a small presentation-only context button, not another saved +view model. Shared appearance is factored only where needed to prevent the +three phone controls drifting; no new global configuration is introduced. + +## Menu capabilities + +Generalize `MobileThreadsMenuActions` into `MobileListingMenuActions` and pass +the actual `currentPage` instead of a Threads constant. `MobileMenuSheet` +retains its inset drawer and existing display/state hook. Its phone content +owns these capabilities: + +- `AppNavSections` exposes Home through the existing manifest. Do not omit the + whole primary section after removing the brand link. Omit redundant Tasks + and Threads destinations already covered by the mode selector, without + changing manifest routing or tablet navigation policy. +- Workspace picker, repository/workflow filters, board column visibility, + List sort/group/archive/detail controls, and mode switching keep their + existing handlers in `useMobileMenuSheetState` and listing owners. +- Quick Chat and Quick Terminal reuse their launcher hooks and workspace IDs. + Rows close the drawer before opening the destination on the next frame, + following the existing `useAppNavDialogs` pattern. Their dialog ownership + remains outside the unmounted menu content. +- The existing phone search toggle moves into the menu. On Kanban/List it + closes the menu before revealing/focusing `MobileSearchBar`, which retains + the existing `mobileKanban.isSearchOpen` state and query callbacks. Closing + search clears the query and returns focus to the persistent menu opener. + Do not leave an independently editable duplicate phone search input in the + drawer; the tablet menu search remains unchanged. +- `MainTopBarPluginActions` retains the `main-top-bar` slot, workspace label, + `presentation="mobile"`, and actual page identity. Contributions remain + mounted only in their intended presentation, not hidden in a second header. + `MobileWorkspaceActionsSection` retains its separate slot and canvas links. +- Status continues through the existing `AppNavSections` integration. When + the ordinary status surface is off but metrics are opted in, the open menu + uses `StatusSurfaceMetrics` with `presentation="mobile-drawer"`, compact + density, and the actual drawer-open value. Do not force-enable status, + hide opted-in metrics, or keep a closed-phone-menu subscription alive. + +The persistent menu button retains the current connection warning. It also +mirrors existing `useQuickChatActivity` feedback so moving the launcher into a +drawer does not hide background work/completion while the drawer is closed. +Reuse `QuickChatActivityIndicator` on the menu button and its Quick Chat row; +keep connection and activity cues distinguishable and do not cover either +with the other. Accessible menu copy includes applicable warning/activity +descriptions. No notification ledger or subscription is added. + +## Responsive geometry and focus + +The header remains one fixed-height in-flow row. The page's existing content +retains vertical scrolling; the Threads board retains its own horizontal snap +scrolling. The header never owns a horizontal scroller. Search adds space only +while explicitly opened, not to the normal idle topbar. + +`MobileMenuSheet` remains the single vertical scroll owner while open, with +dynamic viewport height, safe-area clearance, and a fixed drawer header. +Long labels shrink or wrap inside their rows. Context and menu controls have +44-pixel active targets; menu utility rows use that same minimum. Tablet and +fine-pointer controls retain their current density. + +Both the context button and menu button can open the same drawer on Kanban or +List. Remember the actual opener locally so dismiss returns focus correctly; +do not always focus the right-hand button. Launching a dialog or search must +not return focus into a closing drawer. Pass that persistent opener's ref to +the existing Quick Chat/terminal focus capture so closing the launched surface +returns there, not to a detached menu row. No separate focus owner is added. +Threads keeps its saved-view and task pickers' existing focus-return behavior. + +## Failure, persistence, and security + +No backend, API, schema, saved preference, plugin contract, or permission +changes occur. Missing workspaces omit unusable launchers. Existing menu +fallback labels, connection severity, metrics availability, plugin errors, +and disabled actions retain their existing owners. The explicit default-Home +preference requested by the user belongs to a separate Kandev subtask. + +## Verification design + +`kanban-header-mobile.test.tsx` becomes a three-mode contract test, including +route-derived labels, menu capability delegation, no-workspace behavior, +activity/connection feedback, and direct slot composition. Focused menu tests +cover page-specific plugin props, search clearing, close-before-launch, and +focus return from each opener. Existing `mobile-menu-sheet.test.tsx`, +`mobile-menu-utility-actions.test.tsx`, and listing navigation tests protect +unchanged business logic. + +`mobile-kanban-topbar.spec.ts` checks the shared 56-pixel geometry on Kanban, +List, and Threads, mode/context actions, Home routing, long labels, and zero +document overflow. `mobile-plugin-topbar.spec.ts` checks menu contribution +reachability, touch targets, enabled-metrics fallbacks, and absence of a +horizontal header strip. Existing mobile search, Quick Chat, terminal, +activity, saved-prompt delivery, and status tests switch to menu entry points +without dropping their original outcome assertions. + +Phone screenshot checks at 360 and 393 pixels compare all three modes; +820-pixel coarse-pointer and desktop regressions protect the unchanged +responsive branches. The existing isolated Tailscale demo is rebuilt and +refreshed after implementation without reseeding or restarting its backend. + +## Related decisions + +- [Navigation manifest boundaries](../../../decisions/2026-08-04-navigation-manifest-boundaries.md) +- [Viewport activation owns thread streams](../../../decisions/2026-08-28-viewport-activation-owns-thread-streams.md) diff --git a/docs/specs/ui/system-design/threads-conversation-deck.md b/docs/specs/ui/system-design/threads-conversation-deck.md index 502c943a9f..321eb23d3f 100644 --- a/docs/specs/ui/system-design/threads-conversation-deck.md +++ b/docs/specs/ui/system-design/threads-conversation-deck.md @@ -48,7 +48,9 @@ and deep-link behavior. - A new `useThreadColumnActivation` hook produces two sets. The preload set contains visible columns plus at most one adjacent column on each side. The detail set contains only intersecting desktop columns. On phone it contains - only the nearest snapped column. + only the nearest snapped column. The same viewport owner exposes a separate + lightweight `mobileTaskId` for pagination and picker context; that identity + does not depend on membership hydration or detail mounting. - `ThreadColumn` always renders the task shell. It mounts the session-list adapter only while preloaded and mounts `ThreadConversation` only while detail-active. @@ -159,6 +161,40 @@ If `IntersectionObserver` is unavailable, the board activates the focused URL column or first column. Pointer, focus, and scroll interactions can promote a new column. The fallback must not activate every conversation. +### Phone position feedback + +`useThreadColumnActivation` also derives the phone's presentation position from +the existing board and registered shell geometry. A passive board scroll +listener schedules at most one `requestAnimationFrame` measurement. It chooses +the shell nearest the board center and updates `mobileTaskId` only when that +identity changes. It measures registered shells in stable order, not the +IntersectionObserver's asynchronously delivered membership set. + +Both adjacent shells can remain intersecting as a swipe crosses their +midpoint. Equal intersecting-ID sets therefore cannot suppress phone position +updates. Position must not be read from `detailTaskIds`, a loaded transcript, +the focused URL marker, or a completion callback. + +Initial measurement, board resize, and admitted-order changes reconcile the +same position. Before usable geometry exists, the focused admitted task or +first admitted task is the fallback; empty decks yield null. Native swipe, +picker scrolling, and deep-link scrolling all use this one path. There is no +touch interception, scroll-end debounce, second shell registry, stored +preference, or network request. Leaving phone mode or unmounting removes +listeners/resize observation and cancels any pending frame. Leaving phone mode +also clears its measured identity, so returning uses the current fallback +until the new phone geometry is available. + +The indicator and thread picker's selected row consume `mobileTaskId` directly +through the board. Their update never enlarges the detail/preload window or +mounts a second phone transcript. Existing viewport-bounded loading and +column-local session selections keep their separate responsibilities. +Position changes also invalidate the nearest-visible detail calculation and +provide its conservative phone fallback. An edge-adjacent column can remain +intersecting at zero area after snapping; unchanged visibility membership must +not leave the previous column detail-active. The detail set remains bounded to +one visible phone column and is never gated by transcript loading completion. + ## Deep links `linkToThreads` accepts an optional `sessionId` and produces a URL with @@ -184,9 +220,41 @@ horizontal gesture region. The metadata row shows a compact the label, selected state, and the same agent identity or grid spinner used by desktop tabs. The sheet owns vertical scrolling and safe-area padding. -Long metadata truncates in the left region. Desktop tabs scroll inside the -right region. The task column, board, and document keep their existing -horizontal overflow ownership. +Phone columns use the board's full content width, without an inset outer card +or a neighboring-card peek. The board retains horizontal snap ownership; each +transcript owns vertical scrolling. Flex ancestors allow width shrinking, and +the composer stays within the dynamic-height app shell and its safe-area rules. +Code and tables retain their local horizontal scrolling. + +The phone column header uses a two-line title button, an Open task action, and +a second row for attention and the existing session picker. Position/count +appears inline beside the view control in the page topbar, with no instruction +text or extra task-header row. Decks of up to seven threads also show decorative +page dots; larger decks retain the numeric position without an overflowing dot +row. Empty and single-thread decks omit the indicator. The board's header +render slot receives `mobileTaskId` from phone position feedback, so the page +derives the ordinal from stable order during the swipe. The page does not copy +that identity into state or synchronize it through a loading callback. +The title opens one board-owned `MobileThreadPicker`, using the inset +`MobilePickerSheet` pattern. It lists the admitted task summaries in stable +order, including workflow, step, and attention. Selecting a task scrolls its +existing shell into view; viewport activation still owns transcript mounting. +This reuses the focused-column navigation pattern from `MobileColumnTabs` and +does not duplicate session selection or store state. + +`KanbanHeaderMobile` uses the shared +[Mobile Workspace Topbar](mobile-quick-chat-topbar.md) composition. Threads +supplies its page identity and selected saved view in the unboxed stacked title +control, with pagination alongside it. The saved-view editor continues to use +its existing drawer; listing navigation and secondary workspace actions use +the shared menu. Saved-view semantics are not introduced into Kanban or List. + +Desktop metadata and tabs keep their existing regions. Phone pickers own their +internal vertical scrolling, clear safe areas, and return focus on dismissal. +If the opening task disappears while its picker is open, focus returns to the +current mounted phone thread or the first remaining column, without scrolling. +Leaving phone layout closes the thread picker; returning does not reopen it. +Long labels truncate inside controls without document-level horizontal overflow. ## Failure and recovery @@ -214,6 +282,14 @@ horizontal scroll activation. Phone Playwright covers one active snapped conversation, the picker sheet, 44-pixel rows, safe-area containment, and zero document overflow. +Phone position tests cross the midpoint while the same two shells remain +intersecting, reverse that geometry, resize, remove the current task, and +unmount with a scheduled frame. Browser coverage holds a real touch drag past +the midpoint before touch release and verifies the topbar's new position while +the destination's detail loading is delayed. It must not wait for the new chat +to mount before asserting position. Settled swipes and picker navigation still +prove the one-phone-transcript budget. + ## Related decisions - [Viewport Activation Owns Threads Session Streams](../../../decisions/2026-08-28-viewport-activation-owns-thread-streams.md) diff --git a/docs/specs/ui/system-design/threads-saved-views.md b/docs/specs/ui/system-design/threads-saved-views.md index de6b2770d4..984b12a2d4 100644 --- a/docs/specs/ui/system-design/threads-saved-views.md +++ b/docs/specs/ui/system-design/threads-saved-views.md @@ -264,8 +264,8 @@ session membership. ## Top-bar controls `KanbanHeader` gains optional task-listing control slots. Desktop and tablet -place the slot before `ViewToggleGroup`. Phone places its slot first in the -scrollable action strip. +place the slot before `ViewToggleGroup`. Phone groups the page label and active +view in the title control, with inline pagination and no scrolling action strip. Threads supplies two compact desktop/tablet controls: @@ -302,8 +302,9 @@ The picker has title search, Select all, Clear all, and checkbox rows. Tablet uses the same top-bar position with 44-pixel triggers. Its trigger opens the mobile drawer because a touch pointer does not use the desktop popover. -Phone shows a compact active-view button at the start of the top-bar action -strip. The button opens one inset bottom drawer. The drawer has three internal +Phone groups the Threads page label and active view in one unboxed, stacked +button in the top bar, outside a scrolling action strip. The button opens one +inset bottom drawer. The drawer has three internal pages: saved-view selection, view editor, and task picker. Back navigation changes the drawer page instead of opening another overlay. @@ -322,6 +323,9 @@ detail-active. placeholder and does not write it back automatically. - A save error rolls back views, active ID, and draft to the last backend snapshot. The existing toast bridge shows a recoverable error. +- On phones, a compact status icon beside the view control announces sync + failure. Retry and Dismiss live inside the saved-view drawer's scrolling body, + not the fixed-height page title row. Desktop and tablet keep inline recovery. - An unknown filter dimension, operator, sort key, or task ID is ignored during frontend normalization. Known valid fields remain available for editing. - If the active ID is invalid, normalization selects the canonical view. It