diff --git a/apps/web/app/github/github-page-client.tsx b/apps/web/app/github/github-page-client.tsx index fdc4d5b078..59832d5683 100644 --- a/apps/web/app/github/github-page-client.tsx +++ b/apps/web/app/github/github-page-client.tsx @@ -2,10 +2,8 @@ import Link from "@/components/routing/app-link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { IconBrandGithub, IconMenu2 } from "@tabler/icons-react"; +import { IconBrandGithub } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; -import { Button } from "@kandev/ui/button"; -import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; import { PageShell } from "@/components/page-shell"; import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; import { usePRKeyToTasks } from "@/hooks/domains/github/use-pr-key-to-tasks"; @@ -14,10 +12,10 @@ import type { Repository, Workflow, WorkflowStep } from "@/lib/types/http"; import type { GitHubIssue, GitHubPR, TaskIssueLink, TaskPR } from "@/lib/types/github"; import { PRList } from "@/components/github/my-github/pr-list"; import { IssueList } from "@/components/github/my-github/issue-list"; -import { - PresetsSidebar, - type SidebarSelection, -} from "@/components/github/my-github/presets-sidebar"; +import type { SidebarSelection } from "@/components/github/my-github/presets-sidebar"; +import { MobileViewsPicker } from "@/components/github/my-github/mobile-views-picker"; +import { SavedQueryLoadStatus } from "@/components/github/my-github/saved-query-load-status"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { PresetsScopeBar } from "@/components/github/my-github/presets-scope-bar"; import { PR_PRESETS, @@ -61,23 +59,6 @@ type GitHubPageClientProps = { repositories: Repository[]; }; -function MobileFiltersButton({ show, onClick }: { show: boolean; onClick: () => void }) { - const { t } = useTranslation(); - if (!show) return null; - return ( - - - - ); -} - function NotAuthenticatedNotice({ workspaceId, personalRequired, @@ -253,6 +234,18 @@ function useSearchInteractionControls( }; } +function useSaveQueryDialog(canSaveCurrent: boolean) { + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const saveDialogFocusRef = useRef(null); + const onOpenSaveDialog = () => { + if (!canSaveCurrent) return; + saveDialogFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + setSaveDialogOpen(true); + }; + return { saveDialogOpen, setSaveDialogOpen, saveDialogFocusRef, onOpenSaveDialog }; +} + function useGitHubPageState(workspaceId: string | null, enabled: boolean) { const { t } = useTranslation(); const { pr: resolvedPrPresets, issue: resolvedIssuePresets } = @@ -265,7 +258,6 @@ function useGitHubPageState(workspaceId: string | null, enabled: boolean) { commit: commitCustomQuery, } = useCommittedQuery(resolvedPrPresets[0]?.filter ?? ""); const [repoFilter, setRepoFilterRaw] = useState(""); - const [saveDialogOpen, setSaveDialogOpen] = useState(false); const savedPresetStore = useSavedPresets(workspaceId); const { autoResetSearch, markSearchInteracted, setCustomQuery, setRepoFilter } = useSearchInteractionControls(setCustomQueryRaw, setRepoFilterRaw); @@ -305,10 +297,7 @@ function useGitHubPageState(workspaceId: string | null, enabled: boolean) { workspaceId, }); const repos = useRepoOptions(workspaceId, selection, committedQuery, search.items, repoFilter); - const title = useMemo( - () => resolveTitle(selection, savedPresets, resolvedPrPresets, resolvedIssuePresets, t), - [selection, savedPresets, resolvedPrPresets, resolvedIssuePresets, t], - ); + const title = resolveTitle(selection, savedPresets, resolvedPrPresets, resolvedIssuePresets, t); const onSelect = useSidebarSelectionHandler({ currentKind: selection.kind, @@ -321,10 +310,13 @@ function useGitHubPageState(workspaceId: string | null, enabled: boolean) { markSearchInteracted, }); - const canSaveCurrent = customQuery.trim().length > 0 || repoFilter.length > 0; + const canSaveCurrent = + !savedPresetStore.loading && + !savedPresetStore.error && + (customQuery.trim().length > 0 || repoFilter.length > 0); // i18n-exempt: persisted as the saved query's name, so it must not depend on the creating locale. const suggestedLabel = customQuery.trim() || (repoFilter ? `In ${repoFilter}` : "Saved query"); - const onOpenSaveDialog = () => canSaveCurrent && setSaveDialogOpen(true); + const saveDialog = useSaveQueryDialog(canSaveCurrent); return { selection, @@ -335,15 +327,18 @@ function useGitHubPageState(workspaceId: string | null, enabled: boolean) { repoFilter, setRepoFilter, savedPresets, + savedQueryLoad: { + loading: savedPresetStore.loading, + error: savedPresetStore.error, + retry: savedPresetStore.retry, + }, search, repoOptions: repos, title, onSelect, canSaveCurrent, suggestedLabel, - saveDialogOpen, - setSaveDialogOpen, - onOpenSaveDialog, + ...saveDialog, onConfirmSave, onDeleteSaved, onToggleSavedDefault, @@ -367,6 +362,7 @@ function AuthenticatedLayout({ onStartTask: (payload: LaunchPayload) => void; }) { const { selection, search, repoOptions, title } = state; + const { isMobile } = useResponsiveBreakpoint(); const prKeyToTasks = usePRKeyToTasks(workspaceId ?? null); const issueKeyToTasks = useIssueKeyToTasks(workspaceId ?? null); useAllWorkflowSnapshots(workspaceId ?? null); @@ -378,6 +374,11 @@ function AuthenticatedLayout({ selected={selection} onSelect={state.onSelect} savedPresets={state.savedPresets} + savedStatus={ + state.savedQueryLoad.loading || state.savedQueryLoad.error ? ( + + ) : undefined + } onDeleteSaved={state.onDeleteSaved} onToggleSavedDefault={state.onToggleSavedDefault} defaultMutationPendingId={state.defaultMutationPendingId} @@ -388,6 +389,25 @@ function AuthenticatedLayout({ /> + ) : undefined + } count={search.total} loading={search.loading} lastFetchedAt={search.lastFetchedAt} @@ -401,7 +421,7 @@ function AuthenticatedLayout({ onRefresh={search.refresh} /> {!workspaceId && } - + void; - state: GitHubPageState; - onSelect: (s: Parameters[0]) => void; - onSaveCurrent: () => void; -}) { - const { t } = useTranslation(); - return ( - - - - {t("github:filters")} - - - - - ); -} - export function GitHubPageClient({ workspaceId, workflows, @@ -476,7 +455,6 @@ export function GitHubPageClient({ const { status, loaded } = useGitHubStatus(workspaceId ?? null); const authed = hasGitHubUserIdentity(status); const [launchPayload, setLaunchPayload] = useState(null); - const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const state = useGitHubPageState(workspaceId ?? null, authed); const { presets: storedPresets } = useGitHubActionPresets(workspaceId ?? null); const prPresets = useMemo(() => resolvePRPresets(storedPresets), [storedPresets]); @@ -488,19 +466,6 @@ export function GitHubPageClient({ const onStartTask = useCallback((payload: LaunchPayload) => setLaunchPayload(payload), []); const onCloseLaunch = useCallback(() => setLaunchPayload(null), []); - // Close the mobile sheet after any sidebar selection. KindToggle clicks also - // route through onSelect — closing on every selection is acceptable UX since - // the user always wants to see the list after picking a kind or preset. - // No useCallback: `state` is a fresh object every render, so memoizing - // these handlers would be deceptive — they'd still be new refs each pass. - const onMobileSidebarSelect = (s: Parameters[0]) => { - state.onSelect(s); - setMobileSidebarOpen(false); - }; - const onMobileSaveCurrent = () => { - setMobileSidebarOpen(false); - state.onOpenSaveDialog(); - }; return ( } scroll="none" - actions={ - setMobileSidebarOpen(true)} /> - } > {!loaded && ( @@ -536,13 +498,6 @@ export function GitHubPageClient({ /> )} - ; }; -function StartTaskMenu({ - issue, - presets, - onStartTask, -}: { - issue: GitHubIssue; - presets: TaskPreset[]; - onStartTask: IssueListProps["onStartTask"]; -}) { - const { t } = useTranslation(); - const launch = (preset: TaskPreset) => onStartTask({ kind: "issue", issue, preset }); - return ( - - - - - {t("github:task")} - - - - - {presets.map((p) => { - const ItemIcon = p.icon; - return ( - launch(p)} - > - - - {p.label} - {p.hint} - - - ); - })} - - - ); -} - function IssueLabels({ labels }: { labels: string[] }) { if (!labels?.length) return null; return ( <> - {labels.slice(0, 4).map((l) => ( - - {l} + {labels.slice(0, 4).map((label) => ( + + {label} ))} {labels.length > 4 && ( @@ -103,78 +57,65 @@ function IssueRow({ ? "text-emerald-600 dark:text-emerald-400" : "text-purple-600 dark:text-purple-400"; return ( - - - - - {issue.title} - - - + } + title={issue.title} + href={issue.html_url} + metadata={ + <> + {issue.repo_owner}/{issue.repo_name}#{issue.number} · - + {t("github:byAuthorOpenedAgo", { author: issue.author_login, time: formatRelativeTime(issue.created_at), })} - ({ - id: task.task_id, - taskId: task.task_id, - fallbackTitle: task.task_title, - }))} - testIdPrefix="issue-row-task-indicator" - /> - - - - - - + > + } + taskIndicator={ + ({ + id: task.task_id, + taskId: task.task_id, + fallbackTitle: task.task_title, + }))} + testIdPrefix="issue-row-task-indicator" + /> + } + action={ + onStartTask({ kind: "issue", issue, preset })} + triggerTestId="issue-start-task-trigger" + itemTestId="issue-start-task-preset" + /> + } + testId="issue-row" + dataAttributes={{ "data-issue-number": issue.number }} + /> ); } -function IssueListBody({ +export function IssueList({ + items, loading, error, - items, presets, onStartTask, issueKeyToTasks, }: IssueListProps) { const { t } = useTranslation(); - if (loading) { - return ( - - - - ); - } - if (error) { - return {error}; - } - if (items.length === 0) { - return ( - - {t("github:noIssuesMatchThisFilter")} - - ); - } return ( - + {items.map((issue) => { const key = `${issue.repo_owner}/${issue.repo_name}#${issue.number}`; return ( @@ -187,14 +128,6 @@ function IssueListBody({ /> ); })} - - ); -} - -export function IssueList(props: IssueListProps) { - return ( - - - + ); } diff --git a/apps/web/components/github/my-github/list-toolbar.tsx b/apps/web/components/github/my-github/list-toolbar.tsx index 565d2ec848..467a0199ec 100644 --- a/apps/web/components/github/my-github/list-toolbar.tsx +++ b/apps/web/components/github/my-github/list-toolbar.tsx @@ -1,11 +1,13 @@ "use client"; import { useTranslation } from "react-i18next"; +import type { ReactNode } from "react"; import { IntegrationListToolbar } from "@/components/integrations/integration-list-toolbar"; import { RepoFilterCombobox } from "./repo-filter-combobox"; type ListToolbarProps = { title: string; + titleControl?: ReactNode; count: number; loading: boolean; lastFetchedAt: Date | null; @@ -21,6 +23,7 @@ type ListToolbarProps = { export function ListToolbar({ title, + titleControl, count, loading, lastFetchedAt, @@ -37,6 +40,7 @@ export function ListToolbar({ return ( void; +}; + +function PagePicker({ page, totalPages, onPageChange }: PagePickerProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const selectedPage = useRef(null); + const currentPageId = useId(); + return ( + + + + {t("github:pageOf", { page, total: totalPages })} + + + + { + if (!selectedPage.current) return; + event.preventDefault(); + selectedPage.current?.focus({ preventScroll: true }); + selectedPage.current?.scrollIntoView({ block: "center" }); + }} + > + + {t("github:choosePage")} + + {t("task:done")} + + + + {Array.from({ length: totalPages }, (_, index) => index + 1).map((value) => ( + { + if (value !== page) onPageChange(value); + setOpen(false); + }} + > + {t("github:pageOf", { page: value, total: totalPages })} + {value === page && } + + ))} + + + + ); +} + +export function MobileResultsPagination({ range, ...props }: PagePickerProps & { range: string }) { + const { t } = useTranslation(); + const { page, totalPages, onPageChange } = props; + return ( + + + {range} + + + onPageChange(page - 1)} + > + + + + = totalPages} + onClick={() => onPageChange(page + 1)} + > + + + + + ); +} diff --git a/apps/web/components/github/my-github/mobile-views-picker.tsx b/apps/web/components/github/my-github/mobile-views-picker.tsx new file mode 100644 index 0000000000..f5062614d8 --- /dev/null +++ b/apps/web/components/github/my-github/mobile-views-picker.tsx @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconChevronDown } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@kandev/ui/drawer"; +import { PresetsSidebar, type PresetsSidebarProps } from "./presets-sidebar"; + +export function MobileViewsPicker({ + title, + onSelect, + onSaveCurrent, + ...props +}: PresetsSidebarProps & { title: string }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const saveAfterClose = useRef(false); + const saveFrame = useRef(0); + useEffect( + () => () => { + saveAfterClose.current = false; + cancelAnimationFrame(saveFrame.current); + }, + [], + ); + return ( + + + + + {t("github:views")}:{" "} + + {title} + + + + + + { + if (!saveAfterClose.current) return; + saveAfterClose.current = false; + saveFrame.current = requestAnimationFrame(onSaveCurrent); + }} + > + + {t("github:views")} + + {t("task:done")} + + + { + onSelect(selection); + if (selection.source !== "kind-switch") setOpen(false); + }} + onSaveCurrent={() => { + saveAfterClose.current = true; + setOpen(false); + }} + /> + + + ); +} diff --git a/apps/web/components/github/my-github/pr-row-task-indicator.test.tsx b/apps/web/components/github/my-github/pr-row-task-indicator.test.tsx index 28e1969f95..339248b947 100644 --- a/apps/web/components/github/my-github/pr-row-task-indicator.test.tsx +++ b/apps/web/components/github/my-github/pr-row-task-indicator.test.tsx @@ -82,11 +82,12 @@ describe("PRRowTaskIndicator", () => { expect(container.textContent).toContain("2"); }); - it("keeps long task titles in the DOM while visually truncating them", () => { + it("keeps long task titles readable with desktop-only truncation", () => { const longTitle = "This is an extremely long pull request title that should be truncated"; renderWithStore(); const btn = screen.getByRole("button"); expect(btn.textContent).toContain(longTitle); - expect(screen.getByText(longTitle).classList.contains("truncate")).toBe(true); + expect(screen.getByText(longTitle).classList.contains("md:truncate")).toBe(true); + expect(screen.getByText(longTitle).classList.contains("wrap-anywhere")).toBe(true); }); }); diff --git a/apps/web/components/github/my-github/presets-scope-bar.tsx b/apps/web/components/github/my-github/presets-scope-bar.tsx index 7e8c48f5c8..0207ca5d54 100644 --- a/apps/web/components/github/my-github/presets-scope-bar.tsx +++ b/apps/web/components/github/my-github/presets-scope-bar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef } from "react"; +import { useCallback, useRef, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { IntegrationScopeBar } from "@/components/integrations/presets-scope-bar-base"; import { PR_PRESETS, ISSUE_PRESETS, type PresetOption } from "./search-bar"; @@ -12,6 +12,7 @@ type PresetsScopeBarProps = { selected: SidebarSelection; onSelect: (request: SidebarSelectionRequest) => void; savedPresets: SavedPreset[]; + savedStatus?: ReactNode; onDeleteSaved: (id: string) => void; canSaveCurrent: boolean; onSaveCurrent: () => void; @@ -30,7 +31,7 @@ const KINDS = [ /** * Horizontal scope bar for the /github dashboard (desktop). Thin wrapper over * the shared {@link IntegrationScopeBar}; mobile keeps the vertical - * PresetsSidebar in a sheet. + * PresetsSidebar in the Views drawer. */ export function PresetsScopeBar({ prPresets = PR_PRESETS, diff --git a/apps/web/components/github/my-github/presets-sidebar.tsx b/apps/web/components/github/my-github/presets-sidebar.tsx index 908da4d38d..9a0b5f140a 100644 --- a/apps/web/components/github/my-github/presets-sidebar.tsx +++ b/apps/web/components/github/my-github/presets-sidebar.tsx @@ -3,6 +3,8 @@ import { IconX, IconDeviceFloppy, IconBookmark } from "@tabler/icons-react"; import type { Icon } from "@tabler/icons-react"; import { useCallback } from "react"; +import { Button } from "@kandev/ui/button"; +import { SavedQueryLoadStatus, type SavedQueryLoadState } from "./saved-query-load-status"; import { cn } from "@/lib/utils"; import { PR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./search-bar"; import type { SavedPreset } from "./saved-preset-model"; @@ -24,7 +26,7 @@ export type SidebarSelectionRequest = | SidebarSelection | { kind: SidebarSelection["kind"]; source: "kind-switch" }; -type PresetsSidebarProps = { +export type PresetsSidebarProps = { selected: SidebarSelection; onSelect: (request: SidebarSelectionRequest) => void; savedPresets: SavedPreset[]; @@ -35,6 +37,7 @@ type PresetsSidebarProps = { defaultMutationPendingId: string | null; prPresets?: PresetOption[]; issuePresets?: PresetOption[]; + savedQueryLoad?: SavedQueryLoadState; }; function KindToggle({ @@ -46,11 +49,12 @@ function KindToggle({ }) { const { t } = useTranslation(); return ( - + {(["pr", "issue"] as const).map((value) => ( onChange(value)} className={cn( "min-h-11 px-2 py-1 rounded cursor-pointer transition-colors", @@ -101,7 +105,7 @@ function PresetItem({ className="flex min-h-11 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > - {label} + {label} {trailing} @@ -146,20 +150,18 @@ function SavedSection({ onSelect, onDelete, kind, - canSaveCurrent, - onSaveCurrent, onToggleSavedDefault, defaultMutationPendingId, + savedQueryLoad, }: { saved: SavedPreset[]; selected: SidebarSelection; onSelect: (s: SidebarSelection) => void; onDelete: (id: string) => void; kind: "pr" | "issue"; - canSaveCurrent: boolean; - onSaveCurrent: () => void; onToggleSavedDefault: (preset: SavedPreset) => void; defaultMutationPendingId: string | null; + savedQueryLoad?: SavedQueryLoadState; }) { const { t } = useTranslation(); const deletion = useSavedTaskViewDeleteConfirmation(saved); @@ -167,7 +169,8 @@ function SavedSection({ return ( <> - {saved.length === 0 && ( + {savedQueryLoad && } + {saved.length === 0 && !savedQueryLoad?.loading && !savedQueryLoad?.error && ( {t("github:noSavedQueriesYet")} @@ -185,21 +188,6 @@ function SavedSection({ onToggleDefault={() => void onToggleSavedDefault(preset)} /> ))} - - - {t("github:saveCurrentQuery")} - > ); } @@ -297,7 +285,9 @@ export function PresetsSidebar({ defaultMutationPendingId, prPresets = PR_PRESETS, issuePresets = ISSUE_PRESETS, + savedQueryLoad, }: PresetsSidebarProps) { + const { t } = useTranslation(); const presets = selected.kind === "pr" ? prPresets : issuePresets; const saved = savedPresets.filter((p) => p.kind === selected.kind); const onKindChange = useCallback( @@ -308,33 +298,48 @@ export function PresetsSidebar({ [onSelect, selected.kind], ); return ( - + - - - + + + + + + + + + {t("github:saveCurrentQuery")} + + ); } diff --git a/apps/web/components/github/my-github/results-pagination.test.tsx b/apps/web/components/github/my-github/results-pagination.test.tsx new file mode 100644 index 0000000000..de50830333 --- /dev/null +++ b/apps/web/components/github/my-github/results-pagination.test.tsx @@ -0,0 +1,77 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ResultsPagination } from "./results-pagination"; + +const CHOOSE_PAGE = "Choose results page"; +let mobile = true; +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isMobile: mobile }), +})); +beforeEach(() => { + mobile = true; +}); +afterEach(cleanup); + +// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.4 +describe("phone pagination", () => { + it("keeps focus in the page drawer when the current page disappears after a refresh", async () => { + const onPageChange = vi.fn(); + const { rerender } = render( + , + ); + rerender(); + fireEvent.click(screen.getByRole("button", { name: CHOOSE_PAGE })); + const dialog = screen.getByRole("dialog", { name: CHOOSE_PAGE }); + await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true)); + expect(onPageChange).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Page 2 of 2" })); + expect(onPageChange).toHaveBeenCalledExactlyOnceWith(2); + }); + + it("offers direct selection up to GitHub's 1000-result cap", () => { + const onPageChange = vi.fn(); + render(); + const selector = screen.getByLabelText(CHOOSE_PAGE, { selector: "button,select" }); + expect(selector.tagName).toBe("BUTTON"); + expect(screen.getByText("1–25 of 1000+")).not.toBeNull(); + expect(screen.getByRole("button", { name: "Previous page" }).hasAttribute("disabled")).toBe( + true, + ); + fireEvent.click(selector); + expect(screen.getByRole("dialog", { name: CHOOSE_PAGE })).toBeTruthy(); + expect(screen.getAllByRole("button", { name: /^Page \d+ of 40$/ })).toHaveLength(40); + fireEvent.click(screen.getByRole("button", { name: "Page 5 of 40" })); + expect(onPageChange).toHaveBeenCalledExactlyOnceWith(5); + expect(selector.getAttribute("aria-expanded")).toBe("false"); + }); + + it("bounds next and previous at the last available page", () => { + const onPageChange = vi.fn(); + render(); + expect(screen.getByLabelText(CHOOSE_PAGE, { selector: "button,select" }).textContent).toBe( + "Page 40 of 40", + ); + expect(screen.getByRole("button", { name: "Next page" }).hasAttribute("disabled")).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Previous page" })); + expect(onPageChange).toHaveBeenCalledExactlyOnceWith(39); + }); + + it("marks the current page and dismisses without fetching it again", () => { + const onPageChange = vi.fn(); + render(); + const selector = screen.getByLabelText(CHOOSE_PAGE, { selector: "button,select" }); + fireEvent.click(selector); + const current = screen.getByRole("button", { name: "Page 5 of 40" }); + expect(current.getAttribute("aria-current")).toBe("page"); + fireEvent.click(current); + expect(selector.getAttribute("aria-expanded")).toBe("false"); + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it("retains numbered pagination on desktop", () => { + mobile = false; + render(); + expect(screen.queryByRole("combobox")).toBeNull(); + expect(screen.getByRole("link", { name: "40" })).not.toBeNull(); + }); +}); diff --git a/apps/web/components/github/my-github/results-pagination.tsx b/apps/web/components/github/my-github/results-pagination.tsx index 187e29c128..3fcb233a13 100644 --- a/apps/web/components/github/my-github/results-pagination.tsx +++ b/apps/web/components/github/my-github/results-pagination.tsx @@ -1,6 +1,9 @@ "use client"; import { Fragment } from "react"; +import { useTranslation } from "react-i18next"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { MobileResultsPagination } from "./mobile-results-pagination"; import { Pagination, PaginationContent, @@ -29,6 +32,8 @@ function pageWindow(page: number, totalPages: number): number[] { } export function ResultsPagination({ page, pageSize, total, onPageChange }: ResultsPaginationProps) { + const { t } = useTranslation(); + const { isMobile } = useResponsiveBreakpoint(); const effectiveTotal = Math.min(total, GITHUB_MAX_RESULTS); const totalPages = Math.max(1, Math.ceil(effectiveTotal / pageSize)); if (totalPages <= 1) return null; @@ -36,12 +41,24 @@ export function ResultsPagination({ page, pageSize, total, onPageChange }: Resul const windowPages = pageWindow(page, totalPages); const start = (page - 1) * pageSize + 1; const end = Math.min(page * pageSize, effectiveTotal); + const range = t("github:resultRange", { + start, + end, + total: total > GITHUB_MAX_RESULTS ? `${GITHUB_MAX_RESULTS}+` : total, + }); + if (isMobile) + return ( + + ); return ( - - {start}–{end} of {total > GITHUB_MAX_RESULTS ? `${GITHUB_MAX_RESULTS}+` : total} - + {range} diff --git a/apps/web/components/github/my-github/save-preset-dialog.test.tsx b/apps/web/components/github/my-github/save-preset-dialog.test.tsx new file mode 100644 index 0000000000..cbebd35793 --- /dev/null +++ b/apps/web/components/github/my-github/save-preset-dialog.test.tsx @@ -0,0 +1,64 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SavePresetDialog } from "./save-preset-dialog"; +afterEach(cleanup); + +function renderDialog(onSave: (label: string, repo: string) => Promise) { + const onOpenChange = vi.fn(); + render( + , + ); + return onOpenChange; +} + +// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.1 +describe("SavePresetDialog persistence", () => { + it("keeps the entered name after failure and closes only after a successful retry", async () => { + const save = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const onOpenChange = renderDialog(save); + fireEvent.change(screen.getByLabelText("Name"), { target: { value: "New name" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(save).toHaveBeenCalledTimes(1)); + expect(onOpenChange).not.toHaveBeenCalled(); + expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("New name"); + await waitFor(() => + expect(screen.getByRole("button", { name: "Save" }).hasAttribute("disabled")).toBe(false), + ); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)); + expect(save).toHaveBeenLastCalledWith("New name", "org/repo"); + }); + + it("prevents duplicate submission and dismissal while persistence is pending", async () => { + let finish!: (success: boolean) => void; + const save = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const onOpenChange = renderDialog(save); + const submit = screen.getByRole("button", { name: "Save" }); + fireEvent.click(submit); + expect(screen.getByRole("button", { name: "Save" })).toBe(submit); + expect(submit.hasAttribute("disabled")).toBe(true); + expect(screen.getAllByRole("status")).toHaveLength(1); + expect(screen.getByRole("status").textContent).toBe("Saving..."); + fireEvent.click(submit); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(save).toHaveBeenCalledTimes(1); + expect(onOpenChange).not.toHaveBeenCalled(); + await act(async () => finish(true)); + expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false); + expect(screen.queryByRole("status")).toBeNull(); + }); +}); diff --git a/apps/web/components/github/my-github/save-preset-dialog.tsx b/apps/web/components/github/my-github/save-preset-dialog.tsx index fe58b605d9..70855edb31 100644 --- a/apps/web/components/github/my-github/save-preset-dialog.tsx +++ b/apps/web/components/github/my-github/save-preset-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState, type RefObject } from "react"; import { Dialog, DialogContent, @@ -14,6 +14,7 @@ import { Button } from "@kandev/ui/button"; import { Label } from "@kandev/ui/label"; import { RepoFilterCombobox } from "./repo-filter-combobox"; import { useTranslation } from "react-i18next"; +import { createFocusReturnHandler } from "@/lib/dialog-focus-return"; type SavePresetDialogProps = { open: boolean; @@ -23,7 +24,8 @@ type SavePresetDialogProps = { repoFilter: string; repoOptions: string[]; suggestedLabel: string; - onSave: (label: string, repoFilter: string) => void; + onSave: (label: string, repoFilter: string) => Promise; + focusReturnRef?: RefObject; }; function SavePresetForm({ @@ -34,6 +36,7 @@ function SavePresetForm({ suggestedLabel, onSave, onClose, + pending, }: { kind: "pr" | "issue"; customQuery: string; @@ -42,18 +45,18 @@ function SavePresetForm({ suggestedLabel: string; onSave: (label: string, repoFilter: string) => void; onClose: () => void; + pending: boolean; }) { const { t } = useTranslation(); const [value, setValue] = useState(suggestedLabel); const [defaultRepoFilter, setDefaultRepoFilter] = useState(repoFilter); const trimmed = value.trim(); - const canSubmit = trimmed.length > 0; + const canSubmit = trimmed.length > 0 && !pending; const handleSubmit = useCallback(() => { if (!canSubmit) return; onSave(trimmed, defaultRepoFilter); - onClose(); - }, [canSubmit, trimmed, defaultRepoFilter, onSave, onClose]); + }, [canSubmit, trimmed, defaultRepoFilter, onSave]); return ( <> @@ -65,7 +68,7 @@ function SavePresetForm({ })} - + {t("github:name")} @@ -104,9 +107,14 @@ function SavePresetForm({ {t("github:thisRepositoryOpensByDefaultYou")} - + + {pending && ( + + {t("github:saving")} + + )} - + {t("common:cancel")} @@ -126,11 +134,35 @@ export function SavePresetDialog({ repoOptions, suggestedLabel, onSave, + focusReturnRef, }: SavePresetDialogProps) { - const handleClose = useCallback(() => onOpenChange(false), [onOpenChange]); + const [pending, setPending] = useState(false); + const pendingRef = useRef(false); + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!pendingRef.current) onOpenChange(nextOpen); + }, + [onOpenChange], + ); + const handleClose = useCallback(() => handleOpenChange(false), [handleOpenChange]); + const handleSave = async (label: string, repo: string) => { + if (pendingRef.current) return; + pendingRef.current = true; + setPending(true); + try { + if (await onSave(label, repo)) onOpenChange(false); + } finally { + pendingRef.current = false; + setPending(false); + } + }; return ( - - + + {open && ( )} diff --git a/apps/web/components/github/my-github/saved-query-load-status.test.tsx b/apps/web/components/github/my-github/saved-query-load-status.test.tsx new file mode 100644 index 0000000000..08b9168b56 --- /dev/null +++ b/apps/web/components/github/my-github/saved-query-load-status.test.tsx @@ -0,0 +1,20 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu"; +import { SavedQueryLoadStatus } from "./saved-query-load-status"; + +afterEach(cleanup); + +it("exposes Retry as a keyboard-accessible menu item inside desktop saved queries", async () => { + const retry = vi.fn(); + render( + + Saved queries + + + + , + ); + fireEvent.click(await screen.findByRole("menuitem", { name: "Retry" })); + expect(retry).toHaveBeenCalledOnce(); +}); diff --git a/apps/web/components/github/my-github/saved-query-load-status.tsx b/apps/web/components/github/my-github/saved-query-load-status.tsx new file mode 100644 index 0000000000..6bd4473720 --- /dev/null +++ b/apps/web/components/github/my-github/saved-query-load-status.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import { DropdownMenuItem } from "@kandev/ui/dropdown-menu"; + +export type SavedQueryLoadState = { + loading: boolean; + error: boolean; + retry: () => void; +}; + +export function SavedQueryLoadStatus({ + loading, + error, + retry, + presentation = "inline", +}: SavedQueryLoadState & { presentation?: "inline" | "menu" }) { + const { t } = useTranslation(); + if (loading) { + return ( + + {t("github:loadingSavedQueries")} + + ); + } + if (!error) return null; + return ( + + + {t("github:savedQueriesLoadFailed")} + + {presentation === "menu" ? ( + { + event.preventDefault(); + retry(); + }} + > + {t("github:retry")} + + ) : ( + + {t("github:retry")} + + )} + + ); +} diff --git a/apps/web/components/github/my-github/use-saved-preset-actions.test.ts b/apps/web/components/github/my-github/use-saved-preset-actions.test.ts index b8a625375e..a88f354ec9 100644 --- a/apps/web/components/github/my-github/use-saved-preset-actions.test.ts +++ b/apps/web/components/github/my-github/use-saved-preset-actions.test.ts @@ -132,7 +132,7 @@ async function expectLatestPresetCatalogAfterDelete() { expect(setQueryImmediate).toHaveBeenCalledWith(refreshedIssuePreset.filter); } -async function expectStaleSaveOutcomeIgnored() { +async function expectStaleSaveOutcomeIgnored(returnToFirstWorkspace = false) { let finishSave!: () => void; const save = vi.fn( () => @@ -149,14 +149,21 @@ async function expectStaleSaveOutcomeIgnored() { setRepoFilter, } = renderActions({}, makeStore({ save })); - let mutation!: Promise; + let mutation!: Promise; act(() => { mutation = result.current.onConfirmSave(savedPreset.label, savedPreset.repoFilter); }); rerender({ workspaceId: SECOND_WORKSPACE_ID }); + if (returnToFirstWorkspace) { + rerender({ + workspaceId: FIRST_WORKSPACE_ID, + customQuery: "newer query", + selection: { kind: "pr", source: "preset", id: "mentions" }, + }); + } await act(async () => { finishSave(); - await mutation; + expect(await mutation).toBe(false); }); expect(markSearchInteracted).not.toHaveBeenCalled(); @@ -275,7 +282,10 @@ beforeEach(() => { }); describe("useSavedPresetActions save actions", () => { - it("ignores a completed save after changing workspace", expectStaleSaveOutcomeIgnored); + it("ignores a completed save after changing workspace", () => expectStaleSaveOutcomeIgnored()); + + it("ignores a completed save after navigating A to B to A", () => + expectStaleSaveOutcomeIgnored(true)); it("saves the current query, commits it, selects it, and applies its repository", async () => { const save = vi.fn(async () => savedPreset); @@ -321,7 +331,9 @@ describe("useSavedPresetActions save actions", () => { markSearchInteracted, } = renderActions({}, makeStore({ save })); - await act(async () => result.current.onConfirmSave("Unavailable", REPO)); + await act(async () => { + expect(await result.current.onConfirmSave("Unavailable", REPO)).toBe(false); + }); expect(markSearchInteracted).not.toHaveBeenCalled(); expect(save).toHaveBeenCalledWith({ @@ -344,7 +356,9 @@ describe("useSavedPresetActions save actions", () => { makeStore({ save }), ); - await act(async () => result.current.onConfirmSave("Unavailable", REPO)); + await act(async () => { + expect(await result.current.onConfirmSave("Unavailable", REPO)).toBe(false); + }); expect(mockToast).toHaveBeenCalledWith({ description: "Failed to save saved query", diff --git a/apps/web/components/github/my-github/use-saved-preset-actions.ts b/apps/web/components/github/my-github/use-saved-preset-actions.ts index 7aaf590e63..818d8d8e48 100644 --- a/apps/web/components/github/my-github/use-saved-preset-actions.ts +++ b/apps/web/components/github/my-github/use-saved-preset-actions.ts @@ -69,19 +69,24 @@ function useConfirmSave({ reportError: () => void; isCurrentWorkspace: IsCurrentWorkspace; }) { + const scope = useRef({ workspaceId }); + if (scope.current.workspaceId !== workspaceId) scope.current = { workspaceId }; return useCallback( async (label: string, defaultRepoFilter: string) => { + const startedIn = scope.current; try { const created = await save({ kind, label, customQuery, repoFilter: defaultRepoFilter }); // No persistence started when workspace presets are not available yet. - if (!created) return; - if (!isCurrentWorkspace(workspaceId)) return; + if (!created || scope.current !== startedIn || !isCurrentWorkspace(workspaceId)) + return false; markSearchInteracted(); setProgrammaticSelection({ kind, source: "saved", id: created.id }); setQueryImmediate(customQuery); setRepoFilter(defaultRepoFilter); + return true; } catch { - reportError(); + if (scope.current === startedIn && isCurrentWorkspace(workspaceId)) reportError(); + return false; } }, [ diff --git a/apps/web/components/github/my-github/use-saved-presets-recovery.test.ts b/apps/web/components/github/my-github/use-saved-presets-recovery.test.ts new file mode 100644 index 0000000000..fcd375c6b8 --- /dev/null +++ b/apps/web/components/github/my-github/use-saved-presets-recovery.test.ts @@ -0,0 +1,137 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useLayoutEffect } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchUserSettings } from "@/lib/api/domains/settings-api"; +import { + fetchGitHubWorkspaceSettings, + updateGitHubWorkspaceSettings, +} from "@/lib/api/domains/github-api"; +import { __resetSnapshotForTests, useSavedPresets } from "./use-saved-presets"; + +vi.mock("@/lib/api/domains/settings-api", () => ({ + fetchUserSettings: vi.fn(), + updateUserSettings: vi.fn(), +})); +vi.mock("@/lib/api/domains/github-api", () => ({ + fetchGitHubWorkspaceSettings: vi.fn(), + updateGitHubWorkspaceSettings: vi.fn(), +})); + +const saved = { + id: "saved-review", + kind: "pr", + label: "Reviews", + customQuery: "review-requested:@me", + repoFilter: "", + isDefault: false, + createdAt: "2026-09-11T00:00:00Z", +}; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + vi.resetAllMocks(); + __resetSnapshotForTests(); +}); + +// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.1 +describe.each([null, "workspace-1"])("saved-query recovery in %s", (workspaceId) => { + const response = (presets: unknown[]) => + workspaceId + ? { workspace_id: workspaceId, saved_presets: presets } + : { settings: { github_saved_presets: presets } }; + const fetchSettings = () => + vi.mocked(workspaceId ? fetchGitHubWorkspaceSettings : fetchUserSettings); + + it("distinguishes loading, failure and a successful retry", async () => { + const request = deferred(); + fetchSettings() + .mockReturnValueOnce(request.promise) + .mockResolvedValueOnce(response([saved]) as never); + const { result } = renderHook(() => useSavedPresets(workspaceId)); + expect(result.current.loading).toBe(true); + await act(async () => request.reject(new Error("unavailable"))); + expect(result.current.error).toBe(true); + expect(result.current.loading).toBe(false); + await act(async () => result.current.retry()); + await waitFor(() => expect(result.current.presets).toEqual([saved])); + expect(result.current.error).toBe(false); + expect(result.current.loading).toBe(false); + expect(fetchSettings()).toHaveBeenCalledTimes(2); + }); + + it("distinguishes a successful empty collection from unavailable settings", async () => { + fetchSettings().mockResolvedValue(response([]) as never); + const { result } = renderHook(() => useSavedPresets(workspaceId)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe(false); + expect(result.current.presets).toEqual([]); + }); +}); + +it("ignores a failed load from the previous workspace", async () => { + const first = deferred>>(); + vi.mocked(fetchGitHubWorkspaceSettings) + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce({ saved_presets: [saved] } as never); + const { result, rerender } = renderHook((id) => useSavedPresets(id), { initialProps: "first" }); + rerender("second"); + await waitFor(() => expect(result.current.presets).toEqual([saved])); + await act(async () => first.reject(new Error("old workspace failed"))); + expect(result.current.error).toBe(false); + expect(result.current.loading).toBe(false); + expect(result.current.presets).toEqual([saved]); +}); + +it("exposes no previous-workspace data or writable snapshot on the first new-scope render", async () => { + const second = deferred>>(); + vi.mocked(updateGitHubWorkspaceSettings).mockResolvedValue({} as never); + vi.mocked(fetchGitHubWorkspaceSettings) + .mockResolvedValueOnce({ saved_presets: [saved] } as never) + .mockReturnValueOnce(second.promise); + const renders: Array<{ id: string; labels: string[]; loading: boolean }> = []; + let attemptedSave: Promise | undefined; + const { result, rerender } = renderHook( + (id: string) => { + const store = useSavedPresets(id); + const { save } = store; + renders.push({ + id, + labels: store.presets.map((preset) => preset.label), + loading: store.loading, + }); + useLayoutEffect(() => { + if (id === "second") { + attemptedSave = save({ + kind: "pr", + label: "New view", + customQuery: "is:open", + repoFilter: "", + }); + } + }, [id, save]); + return store; + }, + { initialProps: "first" }, + ); + await waitFor(() => expect(result.current.presets).toEqual([saved])); + rerender("second"); + expect(renders.find((render) => render.id === "second")).toEqual({ + id: "second", + labels: [], + loading: true, + }); + await expect(attemptedSave).resolves.toBeNull(); + expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled(); + await act(async () => second.resolve({ saved_presets: [] } as never)); + expect(result.current.presets).toEqual([]); + expect(result.current.loading).toBe(false); +}); diff --git a/apps/web/components/github/my-github/use-saved-presets.ts b/apps/web/components/github/my-github/use-saved-presets.ts index dc628f6579..3a3184a855 100644 --- a/apps/web/components/github/my-github/use-saved-presets.ts +++ b/apps/web/components/github/my-github/use-saved-presets.ts @@ -98,52 +98,79 @@ export function __resetSnapshotForTests() { } function useUserSavedPresetsSync(enabled: boolean) { + const [loadState, setLoadState] = useState({ loading: true, error: false }); + const [attempt, setAttempt] = useState(0); + const retry = useCallback(() => setAttempt((value) => value + 1), []); useEffect(() => { if (!enabled) return; let cancelled = false; const initialVersion = snapshotVersion; + setLoadState({ loading: true, error: false }); fetchUserSettings({ cache: "no-store" }) .then((response) => { const serverPresets = readSavedPresets(response.settings.github_saved_presets); - if (cancelled || snapshotVersion !== initialVersion) return; - publish(serverPresets); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, [enabled]); -} - -function useWorkspaceSavedPresets(workspaceId: string | null) { - const [workspacePresets, setWorkspacePresets] = useState(undefined); - const writeSeq = useRef(0); - useEffect(() => { - if (!workspaceId) { - setWorkspacePresets(undefined); - return; - } - let cancelled = false; - const seq = writeSeq.current; - setWorkspacePresets(undefined); - fetchGitHubWorkspaceSettings(workspaceId) - .then((settings) => { - if (cancelled || seq !== writeSeq.current) return; - const serverPresets = readSavedPresets(settings.saved_presets); - setWorkspacePresets(serverPresets); + if (cancelled) return; + if (snapshotVersion === initialVersion) publish(serverPresets); + setLoadState({ loading: false, error: false }); }) .catch(() => { - if (!cancelled && seq === writeSeq.current) setWorkspacePresets(undefined); + if (!cancelled) setLoadState({ loading: false, error: snapshotVersion === initialVersion }); }); return () => { cancelled = true; }; - }, [workspaceId]); - const setWorkspacePresetsFromLocal = useCallback((next: SavedPreset[]) => { - writeSeq.current += 1; - setWorkspacePresets(next); - }, []); - return { workspacePresets, setWorkspacePresets: setWorkspacePresetsFromLocal }; + }, [enabled, attempt]); + return { ...loadState, retry }; +} + +function useWorkspaceSavedPresets(workspaceId: string | null) { + const [state, setState] = useState<{ + workspaceId: string | null; + presets?: SavedPreset[]; + loading: boolean; + error: boolean; + }>({ workspaceId, loading: true, error: false }); + const [attempt, setAttempt] = useState(0); + const retry = useCallback(() => setAttempt((value) => value + 1), []); + const writeSeq = useRef(0); + useEffect(() => { + if (!workspaceId) { + setState({ workspaceId, loading: true, error: false }); + return; + } + let cancelled = false; + const seq = writeSeq.current; + setState({ workspaceId, loading: true, error: false }); + fetchGitHubWorkspaceSettings(workspaceId) + .then((settings) => { + if (cancelled || seq !== writeSeq.current) return; + const serverPresets = readSavedPresets(settings.saved_presets); + setState({ workspaceId, presets: serverPresets, loading: false, error: false }); + }) + .catch(() => { + if (!cancelled && seq === writeSeq.current) { + setState({ workspaceId, loading: false, error: true }); + } + }); + return () => { + cancelled = true; + }; + }, [workspaceId, attempt]); + const setWorkspacePresetsFromLocal = useCallback( + (next: SavedPreset[]) => { + writeSeq.current += 1; + setState({ workspaceId, presets: next, loading: false, error: false }); + }, + [workspaceId], + ); + const current = state.workspaceId === workspaceId; + return { + workspacePresets: current ? state.presets : undefined, + setWorkspacePresets: setWorkspacePresetsFromLocal, + loading: current ? state.loading : true, + error: current ? state.error : false, + retry, + }; } function discardSavedPreset(presets: SavedPreset[], id: string): SavedPreset[] { @@ -226,18 +253,7 @@ function useSavedPresetMutationContext( return { mutationContextRef, applyLocal }; } -export function useSavedPresets(workspaceId: string | null = null) { - const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); - const { workspacePresets, setWorkspacePresets } = useWorkspaceSavedPresets(workspaceId); - const workspacePresetsRef = useRef(workspacePresets); - workspacePresetsRef.current = workspacePresets; - useUserSavedPresetsSync(!workspaceId); - const activePresets = workspaceId ? (workspacePresets ?? emptySnapshot) : presets; - const { mutationContextRef, applyLocal } = useSavedPresetMutationContext( - workspaceId, - activePresets, - setWorkspacePresets, - ); +function useSavedPresetMutationQueue(workspaceId: string | null) { const mutationQueueRef = useRef>(Promise.resolve()); const previousWorkspaceIdRef = useRef(workspaceId); if (previousWorkspaceIdRef.current !== workspaceId) { @@ -245,7 +261,7 @@ export function useSavedPresets(workspaceId: string | null = null) { mutationQueueRef.current = Promise.resolve(); } - const queueMutation = useCallback( + return useCallback( (mutation: () => Promise) => { if (workspaceId === null) return queuePortableMutation(mutation); const queued = mutationQueueRef.current.catch(() => undefined).then(mutation); @@ -254,6 +270,22 @@ export function useSavedPresets(workspaceId: string | null = null) { }, [workspaceId], ); +} + +export function useSavedPresets(workspaceId: string | null = null) { + const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + const { workspacePresets, setWorkspacePresets, ...workspaceLoad } = + useWorkspaceSavedPresets(workspaceId); + const workspacePresetsRef = useRef(workspacePresets); + workspacePresetsRef.current = workspacePresets; + const userLoad = useUserSavedPresetsSync(!workspaceId); + const activePresets = workspaceId ? (workspacePresets ?? emptySnapshot) : presets; + const { mutationContextRef, applyLocal } = useSavedPresetMutationContext( + workspaceId, + activePresets, + setWorkspacePresets, + ); + const queueMutation = useSavedPresetMutationQueue(workspaceId); /** * @returns The created preset, or null while workspace settings are unavailable. @@ -351,5 +383,11 @@ export function useSavedPresets(workspaceId: string | null = null) { [applyLocal, queueMutation, workspaceId], ); - return { presets: activePresets, save, remove, setDefault }; + return { + presets: activePresets, + save, + remove, + setDefault, + ...(workspaceId ? workspaceLoad : userLoad), + }; } diff --git a/apps/web/components/integrations/change-request-list.tsx b/apps/web/components/integrations/change-request-list.tsx index 624f8626ba..3c9514414f 100644 --- a/apps/web/components/integrations/change-request-list.tsx +++ b/apps/web/components/integrations/change-request-list.tsx @@ -69,12 +69,14 @@ export function ChangeRequestRow({ target="_blank" rel="noopener noreferrer" title={title} - className="flex min-w-0 cursor-pointer items-center gap-1.5 text-sm font-semibold hover:underline" + className="flex min-h-11 min-w-0 cursor-pointer items-center gap-1.5 text-sm font-semibold hover:underline md:min-h-0 [@media(pointer:coarse)]:min-h-11" > - {title} + + {title} + - + {metadata} {taskIndicator} diff --git a/apps/web/components/integrations/integration-list-toolbar.test.tsx b/apps/web/components/integrations/integration-list-toolbar.test.tsx index 9af59d48ae..06367c996d 100644 --- a/apps/web/components/integrations/integration-list-toolbar.test.tsx +++ b/apps/web/components/integrations/integration-list-toolbar.test.tsx @@ -1,17 +1,21 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { IntegrationListToolbar } from "./integration-list-toolbar"; +import i18n from "i18next"; -afterEach(cleanup); +afterEach(async () => { + cleanup(); + await i18n.changeLanguage("en"); +}); -function renderToolbar(customQuery = "open") { +function renderToolbar(customQuery = "open", count = 3, loading = false) { const onCommitCustomQuery = vi.fn(); const onRefresh = vi.fn(); render( { + it.each([ + ["en", 1, "Result 1"], + ["pt-pt", 1, "Resultado 1"], + ["pt-pt", 3, "Resultados 3"], + ["zh-cn", 3, "共 3 项结果"], + ])("localizes the whole result count in %s for %s", async (locale, count, expected) => { + await i18n.changeLanguage(locale); + renderToolbar("open", count); + const result = screen.getByTestId("integration-mobile-result-count"); + expect(result.textContent).toBe(expected); + expect(result.querySelector(".tabular-nums")?.textContent).toBe(String(count)); + }); + + it("uses separate localized loading copy without a stale count", () => { + renderToolbar("open", 3, true); + expect(screen.getByTestId("integration-mobile-result-count").textContent).toBe( + "Loading results…", + ); + }); + it("renders shared title, count, filter, query, and responsive refresh controls", () => { const { onRefresh } = renderToolbar(); expect(screen.getByTestId("change-toolbar-title").textContent).toBe("Pull requests"); - expect(screen.getByText("3")).toBeTruthy(); + expect(screen.getAllByText("3")).toHaveLength(2); + expect(screen.getByTestId("integration-mobile-result-count").textContent).toMatch( + /Results\s+3/, + ); expect(screen.getByRole("button", { name: "All repositories" })).toBeTruthy(); expect(screen.getByTestId("change-toolbar-query").getAttribute("placeholder")).toBe( "Search pull requests", diff --git a/apps/web/components/integrations/integration-list-toolbar.tsx b/apps/web/components/integrations/integration-list-toolbar.tsx index 96ed1c73d6..6e80c5fdc9 100644 --- a/apps/web/components/integrations/integration-list-toolbar.tsx +++ b/apps/web/components/integrations/integration-list-toolbar.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactNode } from "react"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { IconRefresh } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; @@ -10,6 +10,7 @@ import { controlSizingClassName } from "@kandev/ui/control-sizing"; export type IntegrationListToolbarProps = { title: string; + titleControl?: ReactNode; count: number; loading: boolean; lastFetchedAt: Date | null; @@ -41,7 +42,7 @@ function RefreshControls({ return ( <> {lastFetchedAt && !loading ? ( - + {showUpdatedPrefix ? t("github:updated") : ""} {formatRelativeTime(lastFetchedAt.toISOString())} @@ -61,8 +62,34 @@ function RefreshControls({ ); } +function MobileToolbarStatus({ count, ...props }: RefreshControlsProps & { count: number }) { + const { t } = useTranslation(); + return ( + + + {props.loading ? ( + t("integrations:loadingResults") + ) : ( + }} + /> + )} + + + + + + ); +} + export function IntegrationListToolbar({ title, + titleControl, count, loading, lastFetchedAt, @@ -83,22 +110,15 @@ export function IntegrationListToolbar({ - - {title} - - + {titleControl ?? ( + + {title} + + )} + {loading ? "…" : count} - - - {filter} @@ -124,6 +144,14 @@ export function IntegrationListToolbar({ ) : null} + ({ return ( onSelect(preset)} data-testid={itemTestId} data-preset-id={preset.id} diff --git a/apps/web/components/integrations/presets-scope-bar-base.test.tsx b/apps/web/components/integrations/presets-scope-bar-base.test.tsx index 1161f3ad6c..793ec334c3 100644 --- a/apps/web/components/integrations/presets-scope-bar-base.test.tsx +++ b/apps/web/components/integrations/presets-scope-bar-base.test.tsx @@ -8,13 +8,21 @@ import { } from "./presets-scope-bar-base"; let lastMenuItemSelectEvent: Event | null = null; +let closeMenuFocus: (() => void) | undefined; vi.mock("@kandev/ui/dropdown-menu", () => ({ DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}>, DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}>, - DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( - {children} - ), + DropdownMenuContent: ({ + children, + onCloseAutoFocus, + }: { + children: React.ReactNode; + onCloseAutoFocus?: () => void; + }) => { + closeMenuFocus = onCloseAutoFocus; + return {children}; + }, DropdownMenuItem: ({ children, disabled, @@ -75,6 +83,7 @@ vi.mock("@kandev/ui/dropdown-menu", () => ({ afterEach(() => { cleanup(); lastMenuItemSelectEvent = null; + closeMenuFocus = undefined; }); type Kind = "pr" | "issue"; @@ -210,6 +219,35 @@ describe("IntegrationScopeBar saved defaults", () => { }); describe("IntegrationScopeBar saved-query actions", () => { + it("opens Save only after the menu has restored focus", async () => { + const onSaveCurrent = vi.fn(); + renderBar({ canSaveCurrent: true, onSaveCurrent }); + fireEvent.click(screen.getByRole("menuitem", { name: "Save current query" })); + expect(onSaveCurrent).not.toHaveBeenCalled(); + closeMenuFocus?.(); + screen.getByTestId("saved-menu").focus(); + await waitFor(() => expect(onSaveCurrent).toHaveBeenCalledOnce()); + expect(document.activeElement).toBe(screen.getByTestId("saved-menu")); + }); + + it("cancels a deferred Save when the scope bar unmounts", async () => { + const onSaveCurrent = vi.fn(); + const { unmount } = renderBar({ canSaveCurrent: true, onSaveCurrent }); + fireEvent.click(screen.getByRole("menuitem", { name: "Save current query" })); + closeMenuFocus?.(); + unmount(); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + expect(onSaveCurrent).not.toHaveBeenCalled(); + }); + + it.each(["status", "alert"])("retains cached saved queries alongside a %s", (role) => { + const { onSelect } = renderBar({ savedStatus: Saved-query recovery }); + expect(screen.getByRole(role).textContent).toBe("Saved-query recovery"); + fireEvent.click(screen.getByRole("menuitem", { name: "Current default" })); + expect(onSelect).toHaveBeenCalledWith({ kind: "pr", source: "saved", id: "saved-a" }); + expect(screen.queryByText("No saved queries yet.")).toBeNull(); + }); + it("renders no default actions when an integration omits the optional contract", () => { renderBar({ onToggleSavedDefault: undefined }); diff --git a/apps/web/components/integrations/presets-scope-bar-base.tsx b/apps/web/components/integrations/presets-scope-bar-base.tsx index 366a71d57f..a3813c774e 100644 --- a/apps/web/components/integrations/presets-scope-bar-base.tsx +++ b/apps/web/components/integrations/presets-scope-bar-base.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef, useState, type RefObject } from "react"; +import { useCallback, useEffect, useRef, useState, type RefObject, type ReactNode } from "react"; import { IconBookmark, IconChevronDown, IconDeviceFloppy, IconX } from "@tabler/icons-react"; import type { Icon } from "@tabler/icons-react"; import { @@ -145,6 +145,28 @@ function SavedMenuTrigger({ ); } +function useSavedMenuSave(onSaveCurrent: () => void) { + const pending = useRef(false); + const frame = useRef(0); + useEffect( + () => () => { + pending.current = false; + cancelAnimationFrame(frame.current); + }, + [], + ); + return { + request: () => { + pending.current = true; + }, + onCloseAutoFocus: () => { + if (!pending.current) return; + pending.current = false; + frame.current = requestAnimationFrame(onSaveCurrent); + }, + }; +} + function SavedMenu({ testId, selected, @@ -155,6 +177,7 @@ function SavedMenu({ onSaveCurrent, onToggleSavedDefault, defaultMutationPendingId, + savedStatus, }: { testId: string; selected: ScopeSelection; @@ -165,10 +188,12 @@ function SavedMenu({ onSaveCurrent: () => void; onToggleSavedDefault?: (id: string) => void; defaultMutationPendingId: string | null; + savedStatus?: ReactNode; }) { const { t } = useTranslation(); const { isFinePointer } = useResponsiveBreakpoint(); const [menuOpen, setMenuOpen] = useState(false); + const save = useSavedMenuSave(onSaveCurrent); const deletion = useSavedTaskViewDeleteConfirmation(saved); const menuContentRef = useRef(null); const defaultMutationPending = defaultMutationPendingId !== null; @@ -187,6 +212,7 @@ function SavedMenu({ ref={menuContentRef} align="end" className="w-56" + onCloseAutoFocus={save.onCloseAutoFocus} onFocusOutside={(event) => { if (isActionConfirmationTarget(event.target)) event.preventDefault(); }} @@ -194,29 +220,30 @@ function SavedMenu({ if (isActionConfirmationTarget(event.target)) event.preventDefault(); }} > - {saved.length === 0 ? ( - {t("integrations:noSavedQueriesYet")} - ) : ( - saved.map((preset) => ( - onSelect({ kind: preset.kind, source: "saved", id: preset.id })} - onToggleDefault={ - onToggleSavedDefault ? () => onToggleSavedDefault(preset.id) : undefined - } - onDeleteSaved={onDeleteSaved} - /> - )) - )} + {savedStatus} + {saved.length === 0 + ? !savedStatus && ( + {t("integrations:noSavedQueriesYet")} + ) + : saved.map((preset) => ( + onSelect({ kind: preset.kind, source: "saved", id: preset.id })} + onToggleDefault={ + onToggleSavedDefault ? () => onToggleSavedDefault(preset.id) : undefined + } + onDeleteSaved={onDeleteSaved} + /> + ))} @@ -351,6 +378,7 @@ export type IntegrationScopeBarProps = { onKindChange?: (kind: K) => void; presetsByKind: (kind: K) => ScopePreset[]; savedPresets: ScopeSavedPreset[]; + savedStatus?: ReactNode; onDeleteSaved: (id: string) => void; canSaveCurrent: boolean; onSaveCurrent: () => void; @@ -366,6 +394,7 @@ export function IntegrationScopeBar({ onKindChange, presetsByKind, savedPresets, + savedStatus, onDeleteSaved, canSaveCurrent, onSaveCurrent, @@ -415,6 +444,7 @@ export function IntegrationScopeBar({ testId={savedMenuTestId} selected={selected} saved={saved} + savedStatus={savedStatus} onSelect={onSelect} onDeleteSaved={onDeleteSaved} canSaveCurrent={canSaveCurrent} diff --git a/apps/web/components/integrations/task-row-indicator.tsx b/apps/web/components/integrations/task-row-indicator.tsx index 0b0e515039..a3f3f866ce 100644 --- a/apps/web/components/integrations/task-row-indicator.tsx +++ b/apps/web/components/integrations/task-row-indicator.tsx @@ -58,6 +58,7 @@ function TaskTitle({ task }: { task: TaskRowLink }) { const buttonClass = cn( "inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs", "hover:bg-muted/70 transition-colors cursor-pointer w-fit max-w-full", + "min-h-11 min-w-11 text-left md:min-h-0 md:min-w-0 [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", ); const iconClass = "h-3 w-3 shrink-0 text-muted-foreground"; @@ -84,7 +85,19 @@ function SingleTaskButton({ className={buttonClass} > - {title} + + + {title} + + {stepTitle && ( + + {t("github:stepLabelled", { stepTitle })} + + )} + diff --git a/apps/web/components/kanban/mobile-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index f06f6a5abc..4c748a5c2e 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -456,7 +456,10 @@ export function MobileMenuSheet({ onOpenChange={onOpenChange} contentRef={contentRef} onOpenAutoFocus={focusMenu} - onCloseAutoFocus={onCloseAutoFocus} + onCloseAutoFocus={(event) => { + onCloseAutoFocus?.(event); + navControls.onMenuCloseAutoFocus?.(event); + }} workspaceId={workspaceId} searchQuery={searchQuery} onSearchChange={onSearchChange} diff --git a/apps/web/components/navigation/app-nav-sections.tsx b/apps/web/components/navigation/app-nav-sections.tsx index b350a02714..1fc5f7e44e 100644 --- a/apps/web/components/navigation/app-nav-sections.tsx +++ b/apps/web/components/navigation/app-nav-sections.tsx @@ -8,6 +8,7 @@ import { IconMoon, IconStethoscope, IconSun, + IconLayoutList, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider"; @@ -27,9 +28,10 @@ import { MOBILE_MENU_UTILITY_SECTIONS } from "@/lib/navigation/surface-policy"; import type { NavSection } from "@/lib/navigation/types"; import { useRouter } from "@/lib/routing/client-router"; import { cn } from "@/lib/utils"; +import { useTaskViewNavigation } from "./use-task-view-navigation"; /** - * Dialog-backed rows in the shared nav block (Improve Kandev, Health issues). + * Overlay-backed rows in the shared nav block (Task views, Improve Kandev, Health issues). * Hoisted out of `AppNavSections` because the sections render inside a * Sheet/Drawer that unmounts on close: a dialog owned there would vanish the * moment the menu closes. The host renders `dialogs` as a sibling of its menu @@ -39,10 +41,13 @@ export type AppNavDialogControls = { showHealthRow: boolean; openImproveKandev: () => void; openHealthDialog: () => void; + openTaskViews?: () => void; + onMenuCloseAutoFocus?: (event: Event) => void; dialogs: ReactNode; }; export function useAppNavDialogs(closeMenu: () => void): AppNavDialogControls { + const taskViews = useTaskViewNavigation(closeMenu); const router = useRouter(); const workspaceId = useAppStore((s) => s.workspaces.activeId); const health = useSystemHealthIndicator(); @@ -63,6 +68,7 @@ export function useAppNavDialogs(closeMenu: () => void): AppNavDialogControls { // mount, and this hook now sits behind every page's topbar. const dialogs = ( <> + {taskViews.dialog} {improveOpen && ( void): AppNavDialogControls { > ); - return { showHealthRow: health.hasIssues, openImproveKandev, openHealthDialog, dialogs }; + return { + showHealthRow: health.hasIssues, + openImproveKandev, + openHealthDialog, + dialogs, + openTaskViews: taskViews.openTaskViews, + onMenuCloseAutoFocus: taskViews.onMenuCloseAutoFocus, + }; } type AppNavSectionsProps = { @@ -108,8 +121,8 @@ type AppNavSectionsProps = { /** * The global navigation block every mobile menu shares: primary destinations, * plugin pages, integrations, and the utility tail (Status, Stats, Settings, - * Improve Kandev, Health issues). All rows come from the navigation manifest, - * so a new destination appears on every surface at once. + * Improve Kandev, Health issues). Route destinations come from the navigation + * manifest; Task views and other overlay actions share the hoisted controls. */ export function AppNavSections({ onNavigate, @@ -118,6 +131,7 @@ export function AppNavSections({ workspaceActions, controls, }: AppNavSectionsProps) { + const { t } = useTranslation(); const omit = new Set(omitSections); return ( <> @@ -125,6 +139,16 @@ export function AppNavSections({ {!omit.has("primary") && ( )} + {controls.openTaskViews && ( + + + {t("sidebar:taskViews")} + + )} {!omit.has("plugins") && } {!omit.has("integrations") && } diff --git a/apps/web/components/navigation/app-nav-sheet.test.tsx b/apps/web/components/navigation/app-nav-sheet.test.tsx index a8185811b7..ad7cf614f1 100644 --- a/apps/web/components/navigation/app-nav-sheet.test.tsx +++ b/apps/web/components/navigation/app-nav-sheet.test.tsx @@ -18,14 +18,22 @@ const ARIA_LABEL = "aria-label"; const state = { features: { canvases: false }, - workspaces: { activeId: "ws-1" as string | null }, + workspaces: { + activeId: "ws-1" as string | null, + items: [{ id: "ws-1", name: "Workspace", office_workflow_id: null as string | null }], + }, userSettings: { ...defaultState.userSettings }, }; beforeEach(() => { state.userSettings = { ...defaultState.userSettings }; + state.workspaces.items[0].office_workflow_id = null; }); +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isMobile: true, isFinePointer: false }), +})); + let healthHasIssues = false; let statusSeverity: "none" | "unstable" | "lost" = "none"; let workspaceActionsRegistrations: Array<{ @@ -134,6 +142,13 @@ function SectionsHost({ } describe("AppNavSheet", () => { + it("offers task views for Kanban, not Office workspaces", () => { + const host = render(); + expect(screen.getByRole("button", { name: "Task views" })).not.toBeNull(); + state.workspaces.items[0].office_workflow_id = "office-workflow"; + host.rerender(); + expect(screen.queryByRole("button", { name: "Task views" })).toBeNull(); + }); beforeEach(() => { healthHasIssues = false; resolvedTheme = "light"; diff --git a/apps/web/components/navigation/app-nav-sheet.tsx b/apps/web/components/navigation/app-nav-sheet.tsx index 14ba05d6b6..7b516e9576 100644 --- a/apps/web/components/navigation/app-nav-sheet.tsx +++ b/apps/web/components/navigation/app-nav-sheet.tsx @@ -44,6 +44,7 @@ export function AppNavSheet({ pageNav, omitDestinations }: AppNavSheetProps) { + import("@/components/task/mobile/session-task-switcher-sheet").then((module) => ({ + default: module.SessionTaskSwitcherSheet, + })), +); + +function TaskViewSurface({ + workspaceId, + open, + onOpenChange, + restoreFocus, +}: { + workspaceId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + restoreFocus: (event: Event) => void; +}) { + const workflowId = useAppStore((state) => state.workflows.activeId); + const router = useRouter(); + const navigate = useCallback((taskId: string) => router.push(linkToTask(taskId)), [router]); + return ( + + + + ); +} + +export function useTaskViewNavigation(closeMenu: () => void) { + const workspace = useAppStore(selectActiveWorkspace); + const { isMobile } = useResponsiveBreakpoint(); + const kanbanWorkspace = workspace && !isOfficeWorkspace(workspace); + const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); + const requested = useRef(false); + const frame = useRef(0); + const opener = useRef(null); + useEffect(() => () => cancelAnimationFrame(frame.current), []); + + const openTaskViews = + isMobile && kanbanWorkspace + ? () => { + requested.current = true; + closeMenu(); + } + : undefined; + const onMenuCloseAutoFocus = () => { + if (!requested.current) return; + requested.current = false; + // The menu restores its own trigger before the task drawer takes focus. + frame.current = requestAnimationFrame(() => { + opener.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + setMounted(true); + setOpen(true); + }); + }; + const restoreFocus = (event: Event) => { + event.preventDefault(); + if (opener.current?.isConnected) opener.current.focus(); + }; + return { + openTaskViews, + onMenuCloseAutoFocus, + // Child dialogs outlive the drawer, including phone-to-tablet layout changes. + dialog: + kanbanWorkspace && mounted ? ( + + ) : null, + }; +} diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts index 5afc2a2e1b..e2ad9fd9be 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts @@ -151,6 +151,7 @@ export function useSheetData(workspaceId: string | null) { } type SheetNavOptions = { + navigate: (taskId: string) => void; workspaceId: string | null; store: ReturnType; loadTaskSessionsForTask: ( @@ -205,7 +206,7 @@ async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) { } else { setActiveTask(mostRecentTask.id); } - replaceTaskUrl(mostRecentTask.id); + opts.navigate(mostRecentTask.id); } onOpenChange(false); } catch (error) { @@ -328,6 +329,7 @@ function buildKanbanTaskUpsert( function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { const { + navigate, workspaceId, store, loadTaskSessionsForTask, @@ -340,6 +342,7 @@ function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { async (newWorkspaceId: string) => { if (newWorkspaceId === workspaceId) return; await switchWorkspace(newWorkspaceId, { + navigate, workspaceId, store, loadTaskSessionsForTask, @@ -350,7 +353,15 @@ function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { }, // Spread the individual fields rather than the `opts` object so callers // re-passing a fresh literal each render don't defeat memoization. - [workspaceId, store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange], + [ + workspaceId, + store, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask, + onOpenChange, + navigate, + ], ); const handleTaskCreated = useCallback( @@ -379,10 +390,10 @@ function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { if (meta?.taskSessionId) { setActiveSession(task.id, meta.taskSessionId); } - replaceTaskUrl(task.id); + navigate(task.id); onOpenChange(false); }, - [store, setActiveTask, setActiveSession, onOpenChange], + [store, setActiveTask, setActiveSession, onOpenChange, navigate], ); return { handleWorkspaceChange, handleTaskCreated }; @@ -523,6 +534,7 @@ export function useSheetActions( workspaceId: string | null, onOpenChange: (open: boolean) => void, selection: TaskSheetSelectionController, + navigate: (taskId: string) => void = replaceTaskUrl, ) { const setActiveTask = useAppStore((state) => state.setActiveTask); const setActiveSession = useAppStore((state) => state.setActiveSession); @@ -556,14 +568,23 @@ export function useSheetActions( const selectedTask = findSheetTask(store.getState(), selectedTaskId); return selectedTask ? taskPendingSelectionSnapshot(selectedTask) : undefined; }, - navigate: replaceTaskUrl, + navigate, onOpenChange, }); }, - [loadTaskSessionsForTask, setActiveSession, setActiveTask, store, onOpenChange, selection], + [ + loadTaskSessionsForTask, + setActiveSession, + setActiveTask, + store, + onOpenChange, + selection, + navigate, + ], ); const { handleWorkspaceChange, handleTaskCreated } = useWorkspaceAndTaskCreatedActions({ + navigate, workspaceId, store, loadTaskSessionsForTask, diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index b22dc13435..c0aa795861 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -32,7 +32,7 @@ import { import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; import { useMobileTaskRename } from "./use-mobile-task-rename"; import { useSidebarTaskEdit } from "../task-session-sidebar-edit"; -import { usePortForwardingVisibility } from "../port-forwarding-visibility-provider"; +import { useOptionalPortForwardingVisibility } from "../port-forwarding-visibility-provider"; import { buildMobileTaskSwitcherProps } from "./session-task-switcher-sheet-props"; import { TaskSwitcherDialogs } from "./session-task-switcher-sheet-dialogs"; type SessionTaskSwitcherSheetProps = { @@ -41,6 +41,8 @@ type SessionTaskSwitcherSheetProps = { workspaceId: string | null; workflowId: string | null; presentation?: "sheet" | "drawer"; + navigate?: (taskId: string) => void; + onCloseAutoFocus?: (event: Event) => void; }; export function useTaskSheetSelectionController() { const [selectionController] = useState(createTaskSheetSelectionController); @@ -174,12 +176,7 @@ function TaskSwitcherSurfaceHeader({ )} {workspaceId && } - + {t("task:new")} @@ -397,7 +394,9 @@ function PortForwardingTaskAction({ onClose: () => void; }) { const { t } = useTranslation(); - const { enabled, canToggle, isUpdating, togglePortForwarding } = usePortForwardingVisibility(); + const visibility = useOptionalPortForwardingVisibility(); + if (!visibility) return null; + const { enabled, canToggle, isUpdating, togglePortForwarding } = visibility; return ( @@ -426,6 +425,8 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ workspaceId, workflowId, presentation = "sheet", + navigate, + onCloseAutoFocus, }: SessionTaskSwitcherSheetProps) { const [dialogOpen, setDialogOpen] = useState(false); const [subtaskTarget, setSubtaskTarget] = useState<{ id: string; title: string } | null>(null); @@ -435,7 +436,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ (nextOpen: boolean) => handleTaskSheetOpenChange(selectionController, nextOpen, onOpenChange), [onOpenChange, selectionController], ); - const actions = useSheetActions(workspaceId, handleOpenChange, selectionController); + const actions = useSheetActions(workspaceId, handleOpenChange, selectionController, navigate); const rename = useMobileTaskRename(); const edit = useSidebarTaskEdit(); const linking = useMobileTaskLinking(workspaceId); @@ -475,7 +476,10 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ const surface = presentation === "drawer" ? ( - + {surfaceContent} diff --git a/apps/web/e2e/pages/mobile-github-page.ts b/apps/web/e2e/pages/mobile-github-page.ts index 576364475f..df75ae14fe 100644 --- a/apps/web/e2e/pages/mobile-github-page.ts +++ b/apps/web/e2e/pages/mobile-github-page.ts @@ -14,7 +14,7 @@ export class MobileGitHubPage { this.mobileMenuButton = page.getByTestId("github-mobile-menu-button"); this.mobileSidebar = page.getByTestId("github-mobile-sidebar"); // Desktop scope bar (replaces the old inline presets rail). Hidden on - // mobile, where the presets live in the hamburger sheet instead. + // mobile, where the presets live in the Views drawer instead. this.inlineSidebar = page.getByTestId("github-presets-scope-bar"); this.toolbarTitle = page.getByTestId("github-list-toolbar-title"); this.repoFilterTrigger = page.getByTestId("github-repo-filter-trigger"); @@ -25,7 +25,7 @@ export class MobileGitHubPage { async goto() { await this.page.goto("/github"); - // The hamburger only mounts once auth has resolved on a mobile viewport — + // The Views picker only mounts once auth has resolved on a mobile viewport — // waiting on it is deterministic and avoids the ambiguity of matching // "GitHub" text (which appears in the breadcrumb AND breadcrumb link aria). await this.mobileMenuButton.waitFor({ state: "visible" }); diff --git a/apps/web/e2e/tests/github/github-scope-bar-focus.spec.ts b/apps/web/e2e/tests/github/github-scope-bar-focus.spec.ts new file mode 100644 index 0000000000..88e0ae33ec --- /dev/null +++ b/apps/web/e2e/tests/github/github-scope-bar-focus.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from "../../fixtures/test-base"; + +// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.1 +test("desktop keyboard saves and cancels return focus to the saved-query trigger", async ({ + testPage, + apiClient, + seedData, +}) => { + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + const response = await testPage.request.get("/api/v1/github/workspace-settings", { + params: { workspace_id: seedData.workspaceId }, + }); + expect(response.ok()).toBe(true); + const baseline = await response.json(); + try { + await testPage.goto("/github"); + const trigger = testPage.getByTestId("github-saved-queries-menu"); + const dialog = testPage.getByRole("dialog", { name: "Save query", exact: true }); + for (const action of ["Cancel", "Save"]) { + await trigger.focus(); + await trigger.press("Enter"); + const saveQuery = testPage.getByRole("menuitem", { name: "Save current query", exact: true }); + await expect(saveQuery).toBeEnabled(); + await testPage.keyboard.press("End"); + await expect(saveQuery).toBeFocused(); + await testPage.keyboard.press("Enter"); + await expect(dialog.getByLabel("Name")).toBeFocused(); + await dialog.getByLabel("Name").fill("Keyboard review queue"); + const button = dialog.getByRole("button", { name: action, exact: true }); + await button.focus(); + await button.press("Enter"); + await expect(dialog).toBeHidden(); + await expect(trigger).toBeFocused(); + } + await expect(trigger).toContainText("Keyboard review queue"); + } finally { + const restored = await testPage.request.put("/api/v1/github/workspace-settings", { + data: { workspace_id: seedData.workspaceId, saved_presets: baseline.saved_presets ?? [] }, + }); + expect(restored.ok()).toBe(true); + } +}); diff --git a/apps/web/e2e/tests/github/mobile-github-results.spec.ts b/apps/web/e2e/tests/github/mobile-github-results.spec.ts new file mode 100644 index 0000000000..e038b578a6 --- /dev/null +++ b/apps/web/e2e/tests/github/mobile-github-results.spec.ts @@ -0,0 +1,178 @@ +import { test, expect } from "../../fixtures/test-base"; +import { MobileGitHubPage } from "../../pages/mobile-github-page"; +import { waitForFiniteAnimations } from "../../helpers/animations"; + +// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.3 +// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.4 +// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.5 +test("phone results wrap long content, expose touch actions, and support every capped page", async ({ + testPage, + apiClient, +}) => { + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + await apiClient.mockGitHubAddPRs( + Array.from({ length: 1050 }, (_, index) => ({ + number: index + 1, + title: `Result ${index + 1}: A long pull request title that remains readable on a narrow phone screen`, + state: "open", + head_branch: `feature/${index}`, + base_branch: "main", + author_login: "test-user", + repo_owner: "a-long-organization-name", + repo_name: "a-very-long-repository-name-without-any-spaces", + })), + ); + // The mock provider returns every seeded PR; model GitHub's bounded page response. + let resultTotal = 1050; + await testPage.route("**/api/v1/github/user/prs?**", async (route) => { + const response = await route.fetch(); + const data = await response.json(); + const params = new URL(route.request().url()).searchParams; + const page = Number(params.get("page") ?? 1); + const pageSize = Number(params.get("per_page") ?? 25); + await route.fulfill({ + response, + json: { + ...data, + total_count: resultTotal, + prs: data.prs.slice(0, resultTotal).slice((page - 1) * pageSize, page * pageSize), + }, + }); + }); + await testPage.setViewportSize({ width: 320, height: 640 }); + const github = new MobileGitHubPage(testPage); + await github.goto(); + const query = testPage.getByPlaceholder(/Custom query/); + await query.fill("is:open"); + await query.press("Enter"); + await expect(testPage.getByTestId("pr-row")).toHaveCount(25); + const chooser = testPage.locator( + 'button[aria-label="Choose results page"],select[aria-label="Choose results page"]', + ); + await expect(chooser).toBeVisible(); + expect(await chooser.evaluate((element) => element.tagName)).toBe("BUTTON"); + expect((await chooser.boundingBox())!.height).toBeGreaterThanOrEqual(44); + await expect(testPage.getByRole("button", { name: "Previous page", exact: true })).toBeDisabled(); + const firstRow = testPage.getByTestId("pr-row").first(); + await expect(firstRow).toBeVisible(); + expect(await firstRow.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe( + true, + ); + const taskAction = firstRow.getByTestId("pr-start-task-trigger"); + expect((await taskAction.boundingBox())!.height).toBeGreaterThanOrEqual(44); + await taskAction.tap(); + await expect(testPage.getByTestId("pr-start-task-preset").first()).toBeVisible(); + expect( + (await testPage.getByTestId("pr-start-task-preset").first().boundingBox())!.height, + ).toBeGreaterThanOrEqual(44); + await testPage.keyboard.press("Escape"); + await chooser.tap(); + const pages = testPage.getByRole("dialog", { name: "Choose results page", exact: true }); + await expect(pages).toBeVisible(); + await waitForFiniteAnimations(pages); + await expect(pages.getByRole("button", { name: /^Page \d+ of 40$/ })).toHaveCount(40); + const pageFive = pages.getByRole("button", { name: "Page 5 of 40", exact: true }); + expect((await pageFive.boundingBox())!.height).toBeGreaterThanOrEqual(44); + await pageFive.tap(); + await expect(pages).toBeHidden(); + await expect(chooser).toBeFocused(); + await expect(testPage.getByText("101–125 of 1000+", { exact: true })).toBeVisible(); + await chooser.tap(); + const lastPage = pages.getByRole("button", { name: "Page 40 of 40", exact: true }); + await lastPage.scrollIntoViewIfNeeded(); + await lastPage.tap(); + await expect(testPage.getByText("976–1000 of 1000+", { exact: true })).toBeVisible(); + await chooser.tap(); + await expect(lastPage).toBeInViewport(); + await expect(lastPage).toHaveAttribute("aria-current", "page"); + await testPage.keyboard.press("Escape"); + await expect(pages).toBeHidden(); + await expect(chooser).toBeFocused(); + await expect(testPage.getByRole("button", { name: "Next page", exact: true })).toBeDisabled(); + resultTotal = 50; + await testPage.getByRole("button", { name: "Refresh", exact: true }).tap(); + await expect(chooser).toContainText("Page 40 of 2"); + await chooser.tap(); + await expect(pages.getByRole("button", { name: "Done", exact: true })).toBeFocused(); + await pages.getByRole("button", { name: "Page 2 of 2", exact: true }).tap(); + await expect(testPage.getByText("26–50 of 50", { exact: true })).toBeVisible(); + await expect(chooser).toBeFocused(); + expect( + await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + await testPage.setViewportSize({ width: 767, height: 640 }); + await expect(chooser).toBeVisible(); + await testPage.setViewportSize({ width: 768, height: 640 }); + await expect(chooser).toBeHidden(); + await expect(github.inlineSidebar).toBeVisible(); +}); + +// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.2 +// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.5 +test("long view names do not compress result counts and refresh metadata", async ({ + testPage, + apiClient, + seedData, +}) => { + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + await apiClient.mockGitHubAddPRs([ + { + number: 1, + title: "Harbor dashboard", + state: "open", + head_branch: "feature/mobile", + base_branch: "main", + author_login: "test-user", + repo_owner: "harbor", + repo_name: "web", + }, + ]); + const settings = await apiClient.rawRequest( + "GET", + `/api/v1/github/workspace-settings?workspace_id=${seedData.workspaceId}`, + ); + const baseline = await settings.json(); + const saved = await apiClient.rawRequest("PUT", "/api/v1/github/workspace-settings", { + workspace_id: seedData.workspaceId, + saved_presets: [ + { + id: "long-toolbar-view", + kind: "pr", + label: "Harbor navigation improvements ready for mobile review", + customQuery: "is:open", + repoFilter: "", + createdAt: "2026-09-11T00:00:00Z", + isDefault: true, + }, + ], + }); + expect(saved.ok).toBe(true); + try { + await testPage.setViewportSize({ width: 320, height: 640 }); + const github = new MobileGitHubPage(testPage); + await github.goto(); + await expect(testPage.getByTestId("pr-row")).toBeVisible(); + const refresh = testPage.getByRole("button", { name: "Refresh", exact: true }); + const viewBounds = (await github.mobileMenuButton.boundingBox())!; + const refreshBounds = (await refresh.boundingBox())!; + expect(refreshBounds.y).toBeGreaterThanOrEqual(viewBounds.y + viewBounds.height); + expect(viewBounds.height).toBeLessThanOrEqual(48); + await expect(testPage.getByTestId("integration-mobile-result-count")).toHaveText("Result 1"); + expect( + await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + await testPage.setViewportSize({ width: 767, height: 640 }); + await expect(testPage.getByTestId("integration-mobile-result-count")).toBeVisible(); + await testPage.setViewportSize({ width: 768, height: 640 }); + await expect(testPage.getByTestId("integration-mobile-result-count")).toBeHidden(); + await expect(github.inlineSidebar).toBeVisible(); + } finally { + const restored = await apiClient.rawRequest("PUT", "/api/v1/github/workspace-settings", { + workspace_id: seedData.workspaceId, + saved_presets: baseline.saved_presets ?? [], + }); + expect(restored.ok).toBe(true); + } +}); diff --git a/apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts b/apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts index 0eb499556c..78ae6b9ca2 100644 --- a/apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts +++ b/apps/web/e2e/tests/github/mobile-github-sidebar.spec.ts @@ -8,6 +8,97 @@ const SAVED_QUERY_FILTER = "assignee:@me is:open mobile-saved-default-repo-e2e"; const DEFAULTS_TIMESTAMP = "2026-08-07T00:00:00Z"; test.describe("Mobile /github sidebar", () => { + // @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.1 + test("opens the save form after the Views drawer releases focus", async ({ + testPage, + apiClient, + }) => { + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + const github = new MobileGitHubPage(testPage); + await github.goto(); + await github.mobileMenuButton.tap(); + await expect(github.mobileSidebar).toBeVisible(); + await testPage.addStyleTag({ + content: + '[data-testid="github-mobile-sidebar"][data-state="closed"] { animation-duration: 1s !important; transition-duration: 1s !important; }', + }); + await github.mobileSidebar.getByRole("button", { name: "Save current query" }).tap(); + const dialog = testPage.getByRole("dialog", { name: "Save query", exact: true }); + await expect(dialog).toBeVisible(); + expect(await github.mobileSidebar.isVisible()).toBe(false); + await dialog.getByLabel("Name", { exact: true }).fill("Keep this draft"); + await expect(dialog.getByLabel("Name", { exact: true })).toBeFocused(); + await dialog.getByRole("button", { name: "Cancel", exact: true }).tap(); + await expect(dialog).toBeHidden(); + await expect(github.mobileMenuButton).toBeFocused(); + }); + + // @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.2 + test("keeps the view picker open while changing result kind", async ({ testPage, apiClient }) => { + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + const github = new MobileGitHubPage(testPage); + await github.goto(); + await github.mobileMenuButton.tap(); + await github.mobileSidebar.getByRole("button", { name: "Issues", exact: true }).tap(); + await expect(github.mobileSidebar).toBeVisible(); + await github.presetByLabel("Mentions").tap(); + await expect(github.mobileSidebar).toBeHidden(); + await expect(github.toolbarTitle).toContainText("Mentions"); + await expect(github.mobileMenuButton).toBeFocused(); + }); + + // @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.1 + test("retains a failed save for retry", async ({ testPage, apiClient, seedData }) => { + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + const baseline = await testPage.request.get("/api/v1/github/workspace-settings", { + params: { workspace_id: seedData.workspaceId }, + }); + expect(baseline.ok()).toBe(true); + const settings = await baseline.json(); + const github = new MobileGitHubPage(testPage); + await github.goto(); + await github.mobileMenuButton.tap(); + await github.mobileSidebar.getByRole("button", { name: "Save current query" }).tap(); + const dialog = testPage.getByRole("dialog", { name: "Save query" }); + await dialog.getByLabel("Name", { exact: true }).fill("Retry my view"); + let failSave = true; + await testPage.route("**/api/v1/github/workspace-settings", async (route) => { + if (route.request().method() === "PUT" && failSave) { + failSave = false; + await route.fulfill({ status: 503, json: { error: "settings unavailable" } }); + } else await route.continue(); + }); + try { + const failed = testPage.waitForResponse( + (response) => + response.url().includes("/api/v1/github/workspace-settings") && response.status() === 503, + ); + await dialog.getByRole("button", { name: "Save", exact: true }).tap(); + await failed; + await expect(dialog).toBeVisible(); + await expect(dialog.getByLabel("Name", { exact: true })).toHaveValue("Retry my view"); + const saved = testPage.waitForResponse( + (response) => + response.url().includes("/api/v1/github/workspace-settings") && + response.request().method() === "PUT" && + response.ok(), + ); + await dialog.getByRole("button", { name: "Save", exact: true }).tap(); + await saved; + await expect(dialog).toBeHidden(); + await expect(github.toolbarTitle).toHaveText("Retry my view"); + } finally { + await testPage.unroute("**/api/v1/github/workspace-settings"); + const restored = await testPage.request.put("/api/v1/github/workspace-settings", { + data: { workspace_id: seedData.workspaceId, saved_presets: settings.saved_presets ?? [] }, + }); + expect(restored.ok()).toBe(true); + } + }); + test.describe("saved default views", () => { test.afterEach(async ({ testPage, seedData }) => { const cleanupResponse = await testPage.request.put("/api/v1/github/workspace-settings", { @@ -58,7 +149,6 @@ test.describe("Mobile /github sidebar", () => { await page.mobileMenuButton.tap(); await page.mobileSidebar.getByRole("button", { name: "Issues", exact: true }).tap(); await expect(page.toolbarTitle).toContainText("Assigned"); - await page.mobileMenuButton.tap(); const defaultAction = page.savedQueryDefaultAction(savedLabel); const actionBox = await defaultAction.boundingBox(); @@ -95,7 +185,7 @@ test.describe("Mobile /github sidebar", () => { }); }); - test("hamburger opens sheet and selecting a preset updates the toolbar", async ({ + test("Views opens the drawer and selecting a preset updates the toolbar", async ({ testPage, apiClient, }) => { @@ -118,7 +208,7 @@ test.describe("Mobile /github sidebar", () => { await page.goto(); // On a mobile viewport the inline desktop sidebar is hidden and the - // hamburger menu button is visible. + // named Views button is visible. await expect(page.mobileMenuButton).toBeVisible(); await expect(page.inlineSidebar).toBeHidden(); @@ -209,6 +299,7 @@ test.describe("Mobile /github sidebar", () => { await page.goto(); await page.mobileMenuButton.tap(); await page.mobileSidebar.getByRole("button", { name: "Issues", exact: true }).tap(); + await page.mobileSidebar.getByRole("button", { name: "Done", exact: true }).tap(); const queryInput = testPage.getByPlaceholder(/Custom query/); await queryInput.fill(SAVED_QUERY_FILTER); await queryInput.press("Enter"); @@ -255,7 +346,6 @@ test.describe("Mobile /github sidebar", () => { await page.mobileMenuButton.waitFor({ state: "visible" }); await page.mobileMenuButton.tap(); await page.mobileSidebar.getByRole("button", { name: "Issues", exact: true }).tap(); - await page.mobileMenuButton.tap(); await page.savedQueryByLabel(savedQuery).tap(); await expect(page.repoFilterTrigger).toContainText(DEFAULT_REPO); await expect(page.issueRowByTitle(DEFAULT_REPO_ISSUE)).toBeVisible(); diff --git a/apps/web/e2e/tests/github/mobile-github-view-recovery.spec.ts b/apps/web/e2e/tests/github/mobile-github-view-recovery.spec.ts new file mode 100644 index 0000000000..dfb8611b0a --- /dev/null +++ b/apps/web/e2e/tests/github/mobile-github-view-recovery.spec.ts @@ -0,0 +1,101 @@ +import { test, expect } from "../../fixtures/test-base"; +import { MobileGitHubPage } from "../../pages/mobile-github-page"; + +// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.1 +test("saved-query load failure is recoverable, not an empty collection", async ({ + testPage, + apiClient, +}) => { + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + let unavailable = true; + await testPage.route("**/api/v1/github/workspace-settings?**", async (route) => { + if (unavailable) await route.fulfill({ status: 503, json: { error: "unavailable" } }); + else await route.continue(); + }); + const github = new MobileGitHubPage(testPage); + await github.goto(); + await github.mobileMenuButton.tap(); + await expect(github.mobileSidebar.getByRole("alert")).toContainText( + "Saved queries could not be loaded", + ); + await expect(github.mobileSidebar.getByText("No saved queries yet.")).toBeHidden(); + await expect( + github.mobileSidebar.getByRole("button", { name: "Save current query" }), + ).toBeDisabled(); + unavailable = false; + await github.mobileSidebar.getByRole("button", { name: "Retry", exact: true }).tap(); + await expect(github.mobileSidebar.getByRole("alert")).toBeHidden(); + await expect( + github.mobileSidebar.getByRole("button", { name: "Save current query" }), + ).toBeEnabled(); + await testPage.unroute("**/api/v1/github/workspace-settings?**"); +}); + +// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.2 +// @covers AC-INTEGRATIONS-GITHUB-MOBILE-001.5 +test("long Views collections scroll inside the drawer while header and Save stay reachable", async ({ + testPage, + apiClient, + seedData, +}) => { + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + const baseline = await testPage.request.get("/api/v1/github/workspace-settings", { + params: { workspace_id: seedData.workspaceId }, + }); + expect(baseline.ok()).toBe(true); + const settings = await baseline.json(); + const presets = Array.from({ length: 20 }, (_, i) => ({ + id: `long-view-${i}`, + kind: "pr", + label: `Team review queue ${i} with a long descriptive saved name`, + customQuery: "is:open", + repoFilter: "", + createdAt: "2026-09-11T00:00:00Z", + })); + const seeded = await testPage.request.put("/api/v1/github/workspace-settings", { + data: { workspace_id: seedData.workspaceId, saved_presets: presets }, + }); + expect(seeded.ok()).toBe(true); + try { + await testPage.setViewportSize({ width: 320, height: 640 }); + const github = new MobileGitHubPage(testPage); + await github.goto(); + await expect(github.mobileMenuButton).toHaveAccessibleName(/Views: Review requested/); + expect((await github.mobileMenuButton.boundingBox())!.height).toBeGreaterThanOrEqual(44); + await github.mobileMenuButton.tap(); + await expect + .poll(async () => { + const bounds = await github.mobileSidebar.boundingBox(); + return Math.round((bounds?.y ?? 0) + (bounds?.height ?? 0)); + }) + .toBe(640); + const scroller = github.mobileSidebar.getByTestId("github-views-scroll"); + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight > element.clientHeight)) + .toBe(true); + const save = github.mobileSidebar.getByRole("button", { name: "Save current query" }); + const before = await save.boundingBox(); + await scroller.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await expect(github.savedQueryByLabel(presets[19].label)).toBeInViewport(); + expect((await save.boundingBox())!.y).toBeCloseTo(before!.y, 0); + await expect(save).toBeInViewport(); + await expect( + github.mobileSidebar.getByRole("button", { name: "Done", exact: true }), + ).toBeInViewport(); + expect( + await github.mobileSidebar.evaluate((element) => element.scrollWidth <= element.clientWidth), + ).toBe(true); + await testPage.keyboard.press("Escape"); + await expect(github.mobileSidebar).toBeHidden(); + await expect(github.mobileMenuButton).toBeFocused(); + } finally { + const restored = await testPage.request.put("/api/v1/github/workspace-settings", { + data: { workspace_id: seedData.workspaceId, saved_presets: settings.saved_presets ?? [] }, + }); + expect(restored.ok()).toBe(true); + } +}); diff --git a/apps/web/e2e/tests/github/mobile-issue-list-task-indicator.spec.ts b/apps/web/e2e/tests/github/mobile-issue-list-task-indicator.spec.ts index 66f186aaaa..36a3dc46e6 100644 --- a/apps/web/e2e/tests/github/mobile-issue-list-task-indicator.spec.ts +++ b/apps/web/e2e/tests/github/mobile-issue-list-task-indicator.spec.ts @@ -37,10 +37,16 @@ test.describe("Mobile GitHub issue task indicator", () => { await github.goto(); await github.mobileMenuButton.tap(); await github.mobileSidebar.getByRole("button", { name: "Issues", exact: true }).tap(); + await github.mobileSidebar.getByRole("button", { name: "Done", exact: true }).tap(); const row = testPage.locator(`[data-testid="issue-row"][data-issue-number="${number}"]`); const indicator = row.getByTestId("issue-row-task-indicator-single"); await expect(indicator).toContainText("Mobile linked task", { timeout: 15_000 }); + const actionBox = await row.getByRole("button", { name: "Task", exact: true }).boundingBox(); + expect(actionBox!.height).toBeGreaterThanOrEqual(44); + const indicatorBox = await indicator.boundingBox(); + expect(indicatorBox!.height).toBeGreaterThanOrEqual(44); + await expect(indicator.getByTestId("task-row-step-title")).toBeVisible(); expect( await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), ).toBe(true); diff --git a/apps/web/e2e/tests/github/mobile-task-view-access.spec.ts b/apps/web/e2e/tests/github/mobile-task-view-access.spec.ts new file mode 100644 index 0000000000..c4298b0b18 --- /dev/null +++ b/apps/web/e2e/tests/github/mobile-task-view-access.spec.ts @@ -0,0 +1,96 @@ +import { test, expect } from "../../fixtures/test-base"; +import { MobileGitHubPage } from "../../pages/mobile-github-page"; +import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; + +// @covers AC-UI-MOBILE-TASK-VIEWS-001.1 +// @covers AC-UI-MOBILE-TASK-VIEWS-001.2 +test("GitHub app navigation opens shared task views and preserves browser Back", async ({ + testPage, + apiClient, + seedData, +}) => { + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("test-user"); + const { settings } = await apiClient.getUserSettings(); + expect(typeof settings.sidebar_active_view_id).toBe("string"); + const view = { + id: "github-mobile-task-view", + name: "My mobile tasks", + filters: [], + sort: { key: "updatedAt", direction: "desc" }, + group: "none", + collapsedGroups: [], + }; + await apiClient.saveUserSettings({ sidebar_views: [view], sidebar_active_view_id: view.id }); + const task = await apiClient.seedTask(seedData.workspaceId, "Task reachable from GitHub", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + try { + const github = new MobileGitHubPage(testPage); + await github.goto(); + const opener = testPage.getByTestId("app-nav-trigger"); + await opener.tap(); + await testPage + .getByTestId("app-nav-sheet") + .getByRole("button", { name: "Task views", exact: true }) + .tap(); + const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true }); + await expect(drawer).toBeVisible(); + await expect(testPage.getByTestId("app-nav-sheet")).toBeHidden(); + await expect(testPage.locator('[role="dialog"]:visible')).toHaveCount(1); + await expect( + drawer.getByTestId("sidebar-view-chip").filter({ hasText: view.name }), + ).toHaveAttribute("data-active", "true"); + await expect(drawer.getByTestId("sidebar-new-view")).toBeVisible(); + await testPage.keyboard.press("Escape"); + await expect(drawer).toBeHidden(); + await expect(opener).toBeFocused(); + await opener.tap(); + await testPage + .getByTestId("app-nav-sheet") + .getByRole("button", { name: "Task views", exact: true }) + .tap(); + await drawer.locator(`[data-task-row-id="${task.task_id}"]`).tap(); + await expect(testPage).toHaveURL(new RegExp(`/t/${task.task_id}`)); + await testPage.goBack(); + await expect(testPage).toHaveURL(/\/github$/); + await expect(github.mobileMenuButton).toBeVisible(); + // The task remains selected in shared state, but GitHub has no task-only providers. + await opener.tap(); + await testPage + .getByTestId("app-nav-sheet") + .getByRole("button", { name: "Task views", exact: true }) + .tap(); + await expect(drawer.getByTestId("sidebar-filter-bar")).toBeVisible(); + await drawer.getByRole("button", { name: "New", exact: true }).tap(); + await expect(drawer).toBeHidden(); + await expect(testPage.getByRole("dialog")).toBeVisible(); + await expect(testPage.getByRole("dialog")).not.toHaveAccessibleName("Tasks"); + const titleInput = testPage.getByTestId("create-task-dialog").getByTestId("task-title-input"); + await titleInput.fill("Draft survives phone rotation"); + await testPage.setViewportSize({ width: 851, height: 393 }); + await expect(titleInput).toHaveValue("Draft survives phone rotation"); + } finally { + await apiClient.saveUserSettings({ + sidebar_views: (settings.sidebar_views ?? []) as unknown[], + sidebar_active_view_id: settings.sidebar_active_view_id as string, + }); + } +}); + +// @covers AC-UI-MOBILE-TASK-VIEWS-001.1 +// @covers AC-UI-MOBILE-TASK-VIEWS-001.2 +test("Kanban navigation can open empty task views and return focus", async ({ testPage }) => { + const kanban = new MobileKanbanPage(testPage); + await kanban.goto(); + await kanban.mobileMenuButton.tap(); + await kanban.menuCard.getByRole("button", { name: "Task views", exact: true }).tap(); + const drawer = testPage.getByRole("dialog", { name: "Tasks", exact: true }); + await expect(drawer.getByTestId("sidebar-filter-bar")).toBeVisible(); + await expect(drawer.locator('[data-slot="task-switcher-empty-state"]')).toBeVisible(); + await expect(kanban.menuCard).toBeHidden(); + await testPage.keyboard.press("Escape"); + await expect(drawer).toBeHidden(); + await expect(kanban.mobileMenuButton).toBeFocused(); +}); diff --git a/apps/web/e2e/tests/workflow/workflow-step-move-overrides-helpers.ts b/apps/web/e2e/tests/workflow/workflow-step-move-overrides-helpers.ts index 0ab35372ba..ba397d8bd6 100644 --- a/apps/web/e2e/tests/workflow/workflow-step-move-overrides-helpers.ts +++ b/apps/web/e2e/tests/workflow/workflow-step-move-overrides-helpers.ts @@ -3,6 +3,7 @@ import { expect } from "../../fixtures/test-base"; import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import { SessionPage } from "../../pages/session-page"; +import { waitForLatestSessionDone } from "../../helpers/session"; export const MOVE_INSTRUCTIONS = "Reproduce the checkout failure before editing."; @@ -53,6 +54,13 @@ export async function seedMoveOverrideFixture( repository_ids: [seedData.repositoryId], }, ); + await waitForLatestSessionDone( + apiClient, + task.id, + 1, + "Move fixture agent must finish before navigation", + 30_000, + ); await page.goto(`/t/${task.id}`); const session = new SessionPage(page); await session.waitForLoad(); diff --git a/apps/web/src/locales/en/github.json b/apps/web/src/locales/en/github.json index 457e720908..9297c68b0a 100644 --- a/apps/web/src/locales/en/github.json +++ b/apps/web/src/locales/en/github.json @@ -1,4 +1,13 @@ { + "views": "Views", + "viewPicker": "Views: {{view}}", + "loadingSavedQueries": "Loading saved queries...", + "savedQueriesLoadFailed": "Saved queries could not be loaded.", + "pageOf": "Page {{page}} of {{total}}", + "choosePage": "Choose results page", + "resultRange": "{{start}}–{{end}} of {{total}}", + "previousPage": "Previous page", + "nextPage": "Next page", "aRegistrationCanBeReusedAcross": "A registration can be reused across workspaces, or you can keep work and personal Apps separate. Each workspace selects and installs its own credential.", "accountsUnavailable": "Accounts unavailable", "actionPromptPlaceholderHelp": "Type {{token}} to see available placeholders. <4>{{url}}4> and <7>{{title}}7> are substituted when the action runs.", diff --git a/apps/web/src/locales/en/integrations.json b/apps/web/src/locales/en/integrations.json index ea44289034..77e5715651 100644 --- a/apps/web/src/locales/en/integrations.json +++ b/apps/web/src/locales/en/integrations.json @@ -193,5 +193,8 @@ "cursorPaginationSummary_one": "Page {{page}} · {{count}} result", "cursorPaginationSummary_other": "Page {{page}} · {{count}} results", "allRepositories": "All repositories", - "changeRequestCreationFailed": "Change-request creation failed" + "changeRequestCreationFailed": "Change-request creation failed", + "resultCount_one": "Result {{count}}", + "resultCount_other": "Results {{count}}", + "loadingResults": "Loading results…" } diff --git a/apps/web/src/locales/en/sidebar.json b/apps/web/src/locales/en/sidebar.json index 643a0c8626..2cf59296a6 100644 --- a/apps/web/src/locales/en/sidebar.json +++ b/apps/web/src/locales/en/sidebar.json @@ -1,4 +1,5 @@ { + "taskViews": "Task views", "archivedLoadFailed": "Archived tasks could not be loaded", "collapseAllBranches": "Collapse all", "orgChart": "Org Chart", diff --git a/apps/web/src/locales/pseudo/github.json b/apps/web/src/locales/pseudo/github.json index 634c75b906..f9f80795e7 100644 --- a/apps/web/src/locales/pseudo/github.json +++ b/apps/web/src/locales/pseudo/github.json @@ -1,4 +1,13 @@ { + "views": "Vĩēŵś", + "viewPicker": "Vĩēŵś: {{view}}", + "loadingSavedQueries": "Ĺōàďĩńĝ śàvēď qũēŕĩēś...", + "savedQueriesLoadFailed": "Śàvēď qũēŕĩēś ćōũĺď ńōţ ƀē ĺōàďēď.", + "pageOf": "Ƥàĝē {{page}} ōƒ {{total}}", + "choosePage": "Ćĥōōśē ŕēśũĺţś ƥàĝē", + "resultRange": "{{start}}–{{end}} ōƒ {{total}}", + "previousPage": "Ƥŕēvĩōũś ƥàĝē", + "nextPage": "Ńēxţ ƥàĝē", "aRegistrationCanBeReusedAcross": "À ŕēĝĩśţŕàţĩōń ćàń ƀē ŕēũśēď àćŕōśś ŵōŕķśƥàćēś, ōŕ ŷōũ ćàń ķēēƥ ŵōŕķ àńď ƥēŕśōńàĺ Àƥƥś śēƥàŕàţē. Ēàćĥ ŵōŕķśƥàćē śēĺēćţś àńď ĩńśţàĺĺś ĩţś ōŵń ćŕēďēńţĩàĺ.", "accountsUnavailable": "Àććōũńţś ũńàvàĩĺàƀĺē", "actionPromptPlaceholderHelp": "Ţŷƥē {{token}} ţō śēē àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś. <4>{{url}}4> àńď <7>{{title}}7> àŕē śũƀśţĩţũţēď ŵĥēń ţĥē àćţĩōń ŕũńś.", diff --git a/apps/web/src/locales/pseudo/integrations.json b/apps/web/src/locales/pseudo/integrations.json index 9a6e7d55bc..c87997ecdb 100644 --- a/apps/web/src/locales/pseudo/integrations.json +++ b/apps/web/src/locales/pseudo/integrations.json @@ -193,5 +193,8 @@ "cursorPaginationSummary_one": "Ƥàĝē {{page}} · {{count}} ŕēśũĺţ", "cursorPaginationSummary_other": "Ƥàĝē {{page}} · {{count}} ŕēśũĺţś", "allRepositories": "Àĺĺ ŕēƥōśĩţōŕĩēś", - "changeRequestCreationFailed": "Ćĥàńĝē-ŕēqũēśţ ćŕēàţĩōń ƒàĩĺēď" + "changeRequestCreationFailed": "Ćĥàńĝē-ŕēqũēśţ ćŕēàţĩōń ƒàĩĺēď", + "resultCount_one": "Ŕēśũĺţ {{count}}", + "resultCount_other": "Ŕēśũĺţś {{count}}", + "loadingResults": "Ĺōàďĩńĝ ŕēśũĺţś…" } diff --git a/apps/web/src/locales/pseudo/sidebar.json b/apps/web/src/locales/pseudo/sidebar.json index 8ceac651ac..4e41e474ac 100644 --- a/apps/web/src/locales/pseudo/sidebar.json +++ b/apps/web/src/locales/pseudo/sidebar.json @@ -1,4 +1,5 @@ { + "taskViews": "Ţàśķ vĩēŵś", "archivedLoadFailed": "Àŕćĥĩvēď ţàśķś ćōũĺď ńōţ ƀē ĺōàďēď", "collapseAllBranches": "Ćōĺĺàƥśē àĺĺ", "orgChart": "Ōŕĝ Ćĥàŕţ", diff --git a/apps/web/src/locales/pt-pt/github.json b/apps/web/src/locales/pt-pt/github.json index 0c3c02e3f7..fabea20be5 100644 --- a/apps/web/src/locales/pt-pt/github.json +++ b/apps/web/src/locales/pt-pt/github.json @@ -1,4 +1,13 @@ { + "views": "Vistas", + "viewPicker": "Vistas: {{view}}", + "loadingSavedQueries": "A carregar consultas guardadas...", + "savedQueriesLoadFailed": "Não foi possível carregar as consultas guardadas.", + "pageOf": "Página {{page}} de {{total}}", + "choosePage": "Escolher página de resultados", + "resultRange": "{{start}}–{{end}} de {{total}}", + "previousPage": "Página anterior", + "nextPage": "Página seguinte", "aRegistrationCanBeReusedAcross": "Um registo pode ser reutilizado em várias áreas de trabalho, ou pode manter separadas as Apps de trabalho e pessoais. Cada área de trabalho seleciona e instala a sua própria credencial.", "accountsUnavailable": "Contas indisponíveis", "actionPromptPlaceholderHelp": "Escreva {{token}} para ver os marcadores disponíveis. <4>{{url}}4> e <7>{{title}}7> são substituídos quando a ação é executada.", diff --git a/apps/web/src/locales/pt-pt/integrations.json b/apps/web/src/locales/pt-pt/integrations.json index 68f11759e3..fada2f837d 100644 --- a/apps/web/src/locales/pt-pt/integrations.json +++ b/apps/web/src/locales/pt-pt/integrations.json @@ -193,5 +193,8 @@ "untitledPullRequest": "Pull request sem título", "updatedAgo": "atualizado há {{elapsed}}", "updatedJustNow": "atualizado agora mesmo", - "viewPullRequestExternally": "Ver pull request #{{number}} externamente" + "viewPullRequestExternally": "Ver pull request #{{number}} externamente", + "resultCount_one": "Resultado {{count}}", + "resultCount_other": "Resultados {{count}}", + "loadingResults": "A carregar resultados…" } diff --git a/apps/web/src/locales/pt-pt/sidebar.json b/apps/web/src/locales/pt-pt/sidebar.json index 2fecacdfd5..abd47d550a 100644 --- a/apps/web/src/locales/pt-pt/sidebar.json +++ b/apps/web/src/locales/pt-pt/sidebar.json @@ -1,4 +1,5 @@ { + "taskViews": "Vistas de tarefas", "archivedLoadFailed": "Não foi possível carregar as tarefas arquivadas", "retry": "Tentar novamente", "activeWorkspaceBadge": "Ativa", diff --git a/apps/web/src/locales/zh-cn/github.json b/apps/web/src/locales/zh-cn/github.json index 07b330f74c..74bb7887b3 100644 --- a/apps/web/src/locales/zh-cn/github.json +++ b/apps/web/src/locales/zh-cn/github.json @@ -1,4 +1,13 @@ { + "views": "视图", + "viewPicker": "视图:{{view}}", + "loadingSavedQueries": "正在加载已保存的查询...", + "savedQueriesLoadFailed": "无法加载已保存的查询。", + "pageOf": "第 {{page}} 页,共 {{total}} 页", + "choosePage": "选择结果页", + "resultRange": "第 {{start}}–{{end}} 项,共 {{total}} 项", + "previousPage": "上一页", + "nextPage": "下一页", "aRegistrationCanBeReusedAcross": "一个注册可跨工作区复用,也可将工作和个人应用分开。每个工作区自行选择并安装凭据。", "accountsUnavailable": "账户不可用", "actionPromptPlaceholderHelp": "输入 {{token}} 可查看可用占位符。执行操作时会替换 <4>{{url}}4> 和 <7>{{title}}7>。", diff --git a/apps/web/src/locales/zh-cn/integrations.json b/apps/web/src/locales/zh-cn/integrations.json index b00c4f0ecb..8d72a34307 100644 --- a/apps/web/src/locales/zh-cn/integrations.json +++ b/apps/web/src/locales/zh-cn/integrations.json @@ -193,5 +193,8 @@ "untitledPullRequest": "未命名的 pull request", "updatedAgo": "{{elapsed}}前更新", "updatedJustNow": "刚刚更新", - "viewPullRequestExternally": "在外部查看 pull request #{{number}}" + "viewPullRequestExternally": "在外部查看 pull request #{{number}}", + "resultCount_one": "共 {{count}} 项结果", + "resultCount_other": "共 {{count}} 项结果", + "loadingResults": "正在加载结果…" } diff --git a/apps/web/src/locales/zh-cn/sidebar.json b/apps/web/src/locales/zh-cn/sidebar.json index b247b2d819..9bb6515623 100644 --- a/apps/web/src/locales/zh-cn/sidebar.json +++ b/apps/web/src/locales/zh-cn/sidebar.json @@ -1,4 +1,5 @@ { + "taskViews": "任务视图", "archivedLoadFailed": "无法加载已归档任务", "collapseAllBranches": "全部折叠", "orgChart": "组织架构", diff --git a/apps/web/src/locales/zh-hk/github.json b/apps/web/src/locales/zh-hk/github.json index e3b5218fe8..343c2edfd0 100644 --- a/apps/web/src/locales/zh-hk/github.json +++ b/apps/web/src/locales/zh-hk/github.json @@ -1,4 +1,13 @@ { + "views": "視圖", + "viewPicker": "視圖:{{view}}", + "loadingSavedQueries": "正在載入已儲存的查詢...", + "savedQueriesLoadFailed": "無法載入已儲存的查詢。", + "pageOf": "第 {{page}} 頁,共 {{total}} 頁", + "choosePage": "選擇結果頁", + "resultRange": "第 {{start}}–{{end}} 項,共 {{total}} 項", + "previousPage": "上一頁", + "nextPage": "下一頁", "aRegistrationCanBeReusedAcross": "一個註冊可跨工作區複用,也可將工作和個人應用分開。每個工作區自行選擇並安裝認證資料。", "accountsUnavailable": "帳戶不可用", "actionPromptPlaceholderHelp": "輸入 {{token}} 可查看可用佔位符。執行操作時會替換 <4>{{url}}4> 和 <7>{{title}}7>。", diff --git a/apps/web/src/locales/zh-hk/integrations.json b/apps/web/src/locales/zh-hk/integrations.json index 5e6aac5af8..c975568ea9 100644 --- a/apps/web/src/locales/zh-hk/integrations.json +++ b/apps/web/src/locales/zh-hk/integrations.json @@ -193,5 +193,8 @@ "untitledPullRequest": "未命名的 pull request", "updatedAgo": "{{elapsed}}前更新", "updatedJustNow": "剛剛更新", - "viewPullRequestExternally": "在外部查看 pull request #{{number}}" + "viewPullRequestExternally": "在外部查看 pull request #{{number}}", + "resultCount_one": "共 {{count}} 項結果", + "resultCount_other": "共 {{count}} 項結果", + "loadingResults": "正在載入結果…" } diff --git a/apps/web/src/locales/zh-hk/sidebar.json b/apps/web/src/locales/zh-hk/sidebar.json index 977ecb3136..36ea65857b 100644 --- a/apps/web/src/locales/zh-hk/sidebar.json +++ b/apps/web/src/locales/zh-hk/sidebar.json @@ -1,4 +1,5 @@ { + "taskViews": "任務視圖", "archivedLoadFailed": "無法載入已歸檔任務", "collapseAllBranches": "全部摺疊", "orgChart": "組織架構", diff --git a/apps/web/src/locales/zh-tw/github.json b/apps/web/src/locales/zh-tw/github.json index d884a68c68..667ef06351 100644 --- a/apps/web/src/locales/zh-tw/github.json +++ b/apps/web/src/locales/zh-tw/github.json @@ -1,4 +1,13 @@ { + "views": "檢視", + "viewPicker": "檢視:{{view}}", + "loadingSavedQueries": "正在載入已儲存的查詢...", + "savedQueriesLoadFailed": "無法載入已儲存的查詢。", + "pageOf": "第 {{page}} 頁,共 {{total}} 頁", + "choosePage": "選擇結果頁", + "resultRange": "第 {{start}}–{{end}} 項,共 {{total}} 項", + "previousPage": "上一頁", + "nextPage": "下一頁", "aRegistrationCanBeReusedAcross": "一個註冊可跨工作區複用,也可將工作和個人應用分開。每個工作區自行選擇並安裝認證資訊。", "accountsUnavailable": "帳戶不可用", "actionPromptPlaceholderHelp": "輸入 {{token}} 可檢視可用佔位符。執行操作時會替換 <4>{{url}}4> 和 <7>{{title}}7>。", diff --git a/apps/web/src/locales/zh-tw/integrations.json b/apps/web/src/locales/zh-tw/integrations.json index b9d87cd6b7..3ae13b87ce 100644 --- a/apps/web/src/locales/zh-tw/integrations.json +++ b/apps/web/src/locales/zh-tw/integrations.json @@ -193,5 +193,8 @@ "untitledPullRequest": "未命名的 pull request", "updatedAgo": "{{elapsed}}前更新", "updatedJustNow": "剛剛更新", - "viewPullRequestExternally": "在外部檢視 pull request #{{number}}" + "viewPullRequestExternally": "在外部檢視 pull request #{{number}}", + "resultCount_one": "共 {{count}} 項結果", + "resultCount_other": "共 {{count}} 項結果", + "loadingResults": "正在載入結果…" } diff --git a/apps/web/src/locales/zh-tw/sidebar.json b/apps/web/src/locales/zh-tw/sidebar.json index dd0e8f2800..67781ea7d7 100644 --- a/apps/web/src/locales/zh-tw/sidebar.json +++ b/apps/web/src/locales/zh-tw/sidebar.json @@ -1,4 +1,5 @@ { + "taskViews": "任務檢視", "archivedLoadFailed": "無法載入已歸檔任務", "collapseAllBranches": "全部摺疊", "orgChart": "組織架構", diff --git a/docs/plans/github-mobile-parity/integration-followups.md b/docs/plans/github-mobile-parity/integration-followups.md new file mode 100644 index 0000000000..c51d6919ec --- /dev/null +++ b/docs/plans/github-mobile-parity/integration-followups.md @@ -0,0 +1,49 @@ +# Integration mobile-usability follow-ups + +Assessed 2026-09-11 from the native integration UI, live [plugin registry](https://github.com/kdlbs/kandev/blob/main/plugin-registry/plugins.yaml), and each plugin's default-branch source. The user requested one persistent subtask per needed repository, each with its own workspace. + +## Created planning tasks + +All four were created under parent `a7d164bc-194f-4b79-941a-60a6f1280b47` with `workspace_mode: new_workspace`, `start_agent: false`, and inherited agent/executor profiles. Task state can subsequently change when the user starts planning. Their deliverable is requirements, system design, an implementation plan, and provider-specific work orders, followed by a review checkpoint. Creation does not authorize implementation, publishing, or additional agents. + +### Later implementation authorization + +On 2026-09-11, the user explicitly asked the parent to share the accepted GitHub changes and have these tasks begin implementation (parent conversation message `be8d0b0e-d707-447e-b727-3712fe5f7a22`). The native, Bitbucket and Slack tasks had completed design packages; their existing sessions received the implementation handoff with source paths, final toolbar/page-drawer behavior, test evidence, provider-semantic limits and the stopped-instance warning. This later instruction supersedes the original planning-only phase for those packages, not their technical dependency gates or repository ownership. + +Bitbucket started its independent saved-query recovery order, and Slack's existing session resumed. Native received the direct user-message identifier to verify implementation authority and was asked to begin independent Sentry/generated-settings work where its dependency gates allow. Shared work still requiring the unmerged parent patch must not be reported as unblocked. + +YouTrack had no session or plan despite the user's expectation that all four were started. Messaging returned `NOT_FOUND`; moving it to the configured In Progress start step still left zero sessions. Its implementation/context brief is prepared, but no additional session was silently created. Its missing planning package also remains a prerequisite rather than assumed reviewed work. + +| Repository and verified base | Existing surfaces in scope | Kandev task | +| --- | --- | --- | +| [kdlbs/kandev](https://github.com/kdlbs/kandev), `main` | GitLab, Jira, Linear, Azure DevOps, Sentry; genuinely needed host/SDK primitives | `b6f16caa-4ada-45ee-8c96-b4550f69ac8f` | +| [kdlbs/kandev-plugin-bitbucket](https://github.com/kdlbs/kandev-plugin-bitbucket), `main` | PR dashboard, saved queries, review/task actions and watches | `71b6794f-2399-4920-b49f-e6aae2223c13` | +| [ahmedbally/kandev-plugin-youtrack](https://github.com/ahmedbally/kandev-plugin-youtrack), `main` | Issues, saved views, filters, task actions and watcher dialog | `ea7a86cd-4102-4e8c-894e-d04576232aa8` | +| [kdlbs/kandev-plugin-slack](https://github.com/kdlbs/kandev-plugin-slack), `master` | Existing connection/settings/activity panel, not a new dashboard | `c2e6a1ec-86d5-450e-a3d7-0fbc56a209c4` | + +The native providers share one repository task but require separate work orders. Plugin code stays in its actual repository. YouTrack is community-owned; later delivery needs a verified contribution route and explicit authorization rather than assuming push access. + +## Native evidence and planning scope + +- **GitLab:** `apps/web/app/gitlab/gitlab-page-client.tsx` hides desktop preset controls below `md` and provides a separate hamburger/left Sheet. Assess named Views discovery, saved-query recovery, and compact truthful pagination. It consumes the shared toolbar updated in the parent task. +- **Jira:** `apps/web/components/jira/my-jira/list-toolbar.tsx` combines search, views, save, count, sorting, refresh, and JQL in a wrapping desktop toolbar. Its results footer also needs hierarchy and touch review. Jira's `/search/jql` pagination is token-based without a total; arbitrary page jumps and synthetic counts are not valid adaptations. +- **Linear:** `apps/web/app/linear/linear-page-client.tsx` uses fixed-width team/assignee controls and a desktop-shaped next/previous footer. Assess filters, long issue labels, task actions, and cursor/loaded-page feedback. Do not silently add saved views where none exist. +- **Azure DevOps:** `apps/web/app/azure-devops/azure-devops-page-client.tsx` uses a left filter Sheet while mode/scope controls remain inline. Assess work-item and PR mode controls, filters, status and linked tasks. Preserve its existing phone board's focused-column navigation. +- **Sentry:** There is no top-level `/sentry` dashboard, as documented in `docs/public/integrations.md`. Assess existing task issue-selection/current-task links, `components/sentry/sentry-issue-watch-table.tsx`, watch forms, and multi-instance settings. A new dashboard requires a separate approved proposal. + +## Registry evidence and planning scope + +**Bitbucket:** [ui/src/bitbucket-page.ts](https://github.com/kdlbs/kandev-plugin-bitbucket/blob/main/ui/src/bitbucket-page.ts) consumes host toolbar and cursor pagination; [ui/src/dashboard-scope.ts](https://github.com/kdlbs/kandev-plugin-bitbucket/blob/main/ui/src/dashboard-scope.ts) puts desktop scope controls inside a left Sheet. Plan an intentional mobile choice surface, discoverable existing saved-query actions, balanced metadata, matching touch geometry, and packaged mobile/desktop tests. Retain discovered-cursor navigation rather than copying GitHub's known-total page picker. + +**YouTrack:** [ui/bundle.js](https://github.com/ahmedbally/kandev-plugin-youtrack/blob/main/ui/bundle.js) contains narrow toolbar overrides, a fixed-width saved-view Popover, hover-only deletion, input clearing at save time, wide issue-list padding, and a custom watcher overlay. Plan touch-accessible view actions and retry-safe saves, filter/sort hierarchy, readable issue/task actions, and safe-area-aware dialogs. Retain Load more semantics. + +**Slack:** [ui/bundle.js](https://github.com/kdlbs/kandev-plugin-slack/blob/master/ui/bundle.js) registers a plugin-settings status card, not an issue dashboard. Its unwrapped connection/team/user header and activity timestamps/actions are analogous density problems. Limit planning to those existing surfaces and the credential-form layout; do not invent saved views or pagination. Tests must mock status/activity and never send real messages or scans. + +The live registry's other entries are tools or analytics: session-cost, provider-usage, Kandy, task-manager, GitHub Status, tags, notes, voice, and DeepSeek balance. They do not expose the provider work dashboards requested here, so no speculative tasks were created for them. + +## Shared constraints and dependency order + +1. Each task audits its current default branch and captures mobile evidence in a disposable mocked instance. No access to the developer's main :9998 data, provider credentials, or live-provider mutations is needed. +2. Plans preserve provider capabilities and pagination semantics while addressing 44px touch targets, desktop density, long/translated labels, empty/loading/error/retry states, focus/Back, internal scrolling, safe areas, and 320px/767px/768px geometry. +3. The parent's GitHub/shared-toolbar changes are not yet merged. Native host/SDK changes required by plugins belong to the native task, with explicit exports, compatibility tests and docs. Plugin work depends on those changes; no private host imports or duplicated host implementations. +4. Initial plans return for review before implementation. The later user instruction above permits the completed packages to proceed; missing packages and host dependencies remain explicit gates. Packaged plugin tests and release/minimum-host compatibility are not permission to publish or install anything in a personal instance. diff --git a/docs/plans/github-mobile-parity/plan.md b/docs/plans/github-mobile-parity/plan.md new file mode 100644 index 0000000000..61aac67fd4 --- /dev/null +++ b/docs/plans/github-mobile-parity/plan.md @@ -0,0 +1,207 @@ +--- +created: 2026-09-11 +status: complete +requirements: + - REQ-INTEGRATIONS-GITHUB-MOBILE-001 + - REQ-UI-MOBILE-TASK-VIEWS-001 +system_design: + - ../../specs/integrations/system-design/github-dashboard-mobile.md + - ../../specs/ui/system-design/mobile-task-view-access.md +legacy_specs: [] +--- +# GitHub mobile parity implementation plan + +## Overview + +Implements the assessment accepted by the user's explicit implementation request. The earlier Kandev task plan contains the source evidence; these work orders record execution. Integrations owns GitHub query/results behavior; UI owns the independently reusable task-view navigation. + +## Scope and technical approach + +Restore query load/save feedback, replace the GitHub hamburger with a Views drawer, expose existing task-sidebar views through shared navigation, improve touch rows, and replace phone pagination. Reuse existing state and primitives; no backend schema or provider API changes. Each work order owns its regression tests and locale changes. + +## ASCII UI preview + +Structural requirements are control order, separate collections, fixed drawer header/footer, internal scrolling, and conditional touch geometry. Labels/spacing are illustrative; product copy is localized. + +Desktop retains its scope bar: +```text +[PRs | Issues] [Preset pills] [Saved queries v] +Title/count [Repository] [Query] [Refresh] +Results +Count [Previous] [1 2 ...] [Next] +``` + +### UI-01: Saved-query recovery + +```text +Loading views... +Unable to load views [Retry] +Save query: [Name] [Repository] +[Cancel] [Save] (pending prevents duplicates) +``` + +### UI-02: Mobile Views picker + +```text +GitHub +[Views: Review requested v] +[Repository] +[Query] +Results 75 Updated just now [Refresh] +Drawer: Views [Done] +[Pull requests] [Issues] + Inbox / Created / Saved (scroll) +[Save current query] (fixed footer) +``` + +### UI-03: Shared task-view entry + +```text +App menu -> [Task views] +Task drawer: [Saved view v] [Filters] +Matching task -> /t/id +Browser Back -> /github +``` + +### UI-04: Touchable result actions + +```text +Wrapped PR/issue title [Task] +owner/repository#123 +[Linked task Workflow step] +``` + +### UI-05: Compact phone pagination + +```text +Results (scroll) +101-125 of 1000+ +[Previous] [Page 5 of 40 v] [Next] +Drawer: Choose results page [Done] + Page 4 of 40 + Page 5 of 40 (selected) + Page 6 of 40 +(one scrolling page list) +``` + +## Work orders + +- [x] [Saved-query recovery](task-01-query-recovery.md) (complete) +- [x] [Mobile Views picker](task-02-view-picker.md) (complete) +- [x] [Shared task-view entry](task-03-task-views.md) (complete) +- [x] [Touchable result actions](task-04-result-actions.md) (complete) +- [x] [Compact phone pagination](task-05-pagination.md) (complete) +- [x] [Screenshot feedback refinements](task-06-toolbar-page-picker.md) (complete) + +Sequential delivery: 01 -> 02 -> 03 -> 04 -> 05 -> 06. No delegated implementation. User-requested cross-repository planning tasks are tracked separately in the [integration follow-up inventory](integration-followups.md); they do not expand this implementation's scope. + +## Tests and E2E + +AC .1 uses saved-query hook/action regressions and mobile save/load recovery. +AC .2/.5 use mobile-github-sidebar.spec.ts and github-scope-bar.spec.ts. +The UI criteria use mobile-task-view-access.spec.ts and existing task-sheet navigation tests. +AC .3/.4 use mobile-github-results.spec.ts and mobile-issue-list-task-indicator.spec.ts. +Use configured Pixel 5 and desktop projects; assert actual hitboxes, no clipping/overflow, long names, and 767/768px transitions. + +## Verification + +Bootstrap completed: `(cd apps && pnpm install --frozen-lockfile)`. + +Run each work order's commands from apps/web. Final shared checks: + +```bash +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm e2e:run --project chromium tests/github/github-scope-bar.spec.ts tests/github/issue-list-task-indicator.spec.ts tests/github/pr-list-task-indicator.spec.ts tests/github/pr-action-create-task-dialog.spec.ts) +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check +``` + +Run focused eslint on changed source/tests. The managed E2E runner builds fresh assets and isolates mock data; desktop and phone runs stay sequential. + +## Risks and exclusions + +Slow settings responses must not override local mutation or a new workspace. Shared task navigation must preserve browser history. Shared rows must retain other providers' desktop behavior. No new saved-query rename/edit feature, Office redesign, commits, push, or PR is included. + +## Verification results + +Completed 2026-09-11. + +- 230 unit tests passed in 30 focused files covering the GitHub dashboard, shared integration rows/menus, navigation, and mobile task switching. The 22 navigation tests were rerun successfully after the orientation fix. +- 23 distinct Playwright scenarios passed: 12 GitHub/mobile scenarios, 7 desktop GitHub regressions, and 4 shared-navigation/task-view editing regressions. The final task-navigation run also verifies preserving a typed task draft when rotating from 393px to 851px. +- TypeScript typecheck, focused ESLint, translation completeness/pseudo synchronization and new-code ratchet, specification lint (including 36 validator tests), public-doc validators (46 pages), and whitespace checks passed. +- Production frontend assets built successfully with the existing Vite chunk warnings. No dependencies or backend contracts changed. +- Public how-to documentation updated: `docs/public/integrations.md`. + +### Test environment and observed failures + +Initial worktree dependencies were installed with the frozen lockfile. The backend build required `GOCACHE=/tmp/kandev-github-mobile-go-cache` because the default cache is read-only. The sandbox also blocks local listening sockets; isolated E2E runs used approved local-server access. Subsequent frontend-only runs rebuilt with `pnpm run build:e2e` and reused the unchanged backend through `pnpm e2e:run --host --no-build`. + +Red regressions reproduced premature kind-switch dismissal, failed-save form closure, duplicate submission, missing loading/error/retry state, missing Task views navigation, a 24px issue task action, absent phone page selection, and loss of a child task draft on rotation. All are covered by passing regressions. A long-list geometry assertion now waits for the drawer's final position before measuring it; an old unconditional-truncation assertion was updated for the intended desktop-only behavior. + +Implementation remains uncommitted. No push or PR was requested. + +### Screenshot feedback refinements + +Completed 2026-09-11 after the user's isolated-instance test. The long Views trigger now stays on one line, Results and updated/refresh metadata have their own row below the query, and the page chooser opens an in-app drawer instead of an operating-system select. The desktop pager remains unchanged. + +- Six focused unit tests and 15 browser regressions passed: ten mobile GitHub, four desktop GitHub, and one shared GitLab mobile browse/review flow. +- Final typecheck, zero-warning focused ESLint, i18n completeness and new-code ratchet, specification lint with 36 validator tests, public-doc checks, and whitespace checks passed. Existing unused-locale-key notices and Vite chunk warnings remain non-blocking. +- Browser verification through the actual Tailscale test URL confirmed the long trigger is 44px high at both 320px and 393px, Results 75 and Updated just now occupy the status row, all three page choices work, focus returns, and there is no horizontal overflow. Light-theme captures were inspected. +- Updated only frontend assets in `/tmp/kandev-mobile-parity-test-vUutse/web-dist`, preserving old hashed assets and backing up the prior index. Backend PID 3052545 stayed running; neither the database nor in-memory mock provider data was reset. The main runtime on :9998 was not modified. + +The disposable environment was stopped at the user's request on 2026-09-11. Its former URL was `http://100.105.155.17:48761/github`; direct Tailscale IP access had replaced the earlier Serve HTTPS route without restarting the backend. The owned direct-IP listener, backend, and agentctl were stopped through the guarded shutdown script. Ports 48761, 48762, and 48763 were verified closed. Data, logs, screenshots, and ownership metadata remain in `/tmp/kandev-mobile-parity-test-vUutse`; main :9998 and the integration follow-up tasks were untouched. Four user-requested repo-specific planning tasks are recorded in [integration follow-ups](integration-followups.md). + +### Open PR delivery validation + +Completed 2026-09-11 in the inherited workspace and branch. The user's explicit Open PR request supersedes the earlier phase's commit, push, and PR exclusions. Delivery retains all six accepted work orders, shared navigation/integration changes, specifications, and public documentation. Neither the stopped demo environment nor the personal instance was restarted. + +The delivery regression run exposed a Views-to-save focus race. A failing browser regression reproduced the overlap; the save form now opens after the drawer's close/focus callback and returns focus to its initiating control on cancellation. Pending opening work is cancelled on unmount. The page-navigation regression now waits for finite drawer animations before measuring hitboxes and uses a bounded test-provider response while retaining the 1,050-result total and 1,000-result navigation cap. + +Fresh task-scoped validation: + +- 325 unit tests passed in 51 focused files. After the focus remediation, 29 focused unit tests passed again in four files. +- All 22 distinct browser regressions passed against the final production code across the final runs: 14 GitHub phone scenarios, seven GitHub desktop scenarios, and one shared GitLab phone scenario. The two results scenarios were rerun successfully after the test-provider correction; this is not a claim that the earlier combined run had no failures. +- Typecheck, zero-warning ESLint on changed source/tests, translation completeness and staged new-code ratchet, specification validation, public-doc validation, and whitespace checks passed. Existing unused-key notices and Vite chunk warnings remain non-blocking. +- E2E assets were rebuilt after the last production edit. All browser runs used the managed host runner, isolated mocks, one worker, and sequential suites; backend binaries were unchanged. +- Five fresh synthetic-data screenshots cover the phone dashboard, Views drawer, page chooser, Task views drawer, and desktop dashboard. The temporary capture spec is excluded from the delivery diff; compressed image assets are published separately from the feature branch. + +Commands (frontend commands run from `apps/web`): + +```bash +pnpm exec vitest run components/github/my-github components/integrations components/navigation components/task/mobile/session-task-switcher-sheet +pnpm exec vitest run components/github/my-github/save-preset-dialog.test.tsx components/github/my-github/use-saved-preset-actions.test.ts components/github/my-github/use-saved-presets-recovery.test.ts components/github/my-github/results-pagination.test.tsx +pnpm run typecheck +pnpm run i18n:check +pnpm run i18n:ratchet +pnpm run build:e2e +pnpm e2e:run --host --no-build --shards 1 --project mobile-chrome tests/github/mobile-github-results.spec.ts tests/github/mobile-github-view-recovery.spec.ts tests/github/mobile-github-sidebar.spec.ts tests/github/mobile-task-view-access.spec.ts tests/github/mobile-issue-list-task-indicator.spec.ts -- --retries 0 +pnpm e2e:run --host --no-build --shards 1 --project mobile-chrome tests/github/mobile-github-results.spec.ts -- --retries 0 +pnpm e2e:run --host --no-build --shards 1 --project chromium tests/github/github-scope-bar.spec.ts tests/github/issue-list-task-indicator.spec.ts tests/github/pr-list-task-indicator.spec.ts tests/github/pr-action-create-task-dialog.spec.ts -- --retries 0 +pnpm e2e:run --host --no-build --shards 1 --project mobile-chrome tests/gitlab/mobile-gitlab-parity.spec.ts -- --grep 'browses, quick launches' --retries 0 +``` + +Screenshot runs additionally enabled `CAPTURE_PR_ASSETS=1` and included a disposable `mobile-pr-delivery-capture.spec.ts`; its capture scenario passed. Repository-root specification and public-doc commands are listed in Verification above. Publication, CI, and automated-review state are tracked by the delivery task and PR; an open PR does not satisfy sibling work's merged-host dependency gate. + +### PR fixup results (2026-09-12) + +The later explicit user authorization permits reconciling main into PR #3614, fixing CI/review findings, and pushing this branch. It does not authorize merging the PR, queueing it, or restarting the stopped demo/personal instance. + +Reconciled base `20efe4855a8d88c6f2a8a220be9abc03ca3a0d50` with PR head `6b22494e142754c8cccae2128450a2cc878f3177`. The task-switcher conflict retains main's extracted dialogs and move options together with optional-provider handling, focus return, and rotation-safe child drafts. The UI index retains both independent specification links. + +All five inline findings and two additional CodeRabbit aggregate findings have scoped fixes: cached saved-query visibility; page-drawer focus fallback; stable Save label/status; desktop menu-to-save handoff; first-render workspace snapshot isolation; stale A-to-B-to-A save rejection; localized result count/plural/order. New regressions failed before their fixes. The desktop keyboard scenario passed before remediation, but the callback-order regression proved Save opened before menu focus restoration; the explicit deferred handoff and unmount cancellation now pass. + +Fresh local validation: + +- 338 unit tests passed in 51 scoped files using the original delivery Vitest command above. +- 17 phone scenarios passed using the original mobile command plus `tests/workflow/mobile-workflow-step-move-overrides.spec.ts`, with retries disabled. This includes page-count shrink recovery and all three move-option scenarios. +- 13 desktop scenarios passed using the original desktop command plus `tests/github/github-scope-bar-focus.spec.ts` and `tests/workflow/workflow-step-move-overrides.spec.ts`. The shared GitLab mobile browse/review scenario passed separately. All 31 final browser scenarios passed without retries; suites used one worker and did not overlap. +- Typecheck, zero-warning changed-file ESLint, i18n completeness/new-code ratchet, specification lint, and public-doc validators passed. Public how-to instructions remain accurate; these corrections clarify existing recovery, accessibility, and localization behavior rather than add a new user workflow. +- Harness validation passed: 19 harness-validator tests, all 196 harness files, 36 specification-validator tests, and the harness pre-commit hook. No manual harness changes were introduced. +- An initial mobile direct-move test observed stale running UI after an early idle-placeholder check. Three isolated runs and two complete original-order runs passed. The move fixture now waits for the real mock session to finish before navigation, then checks UI readiness; the final phone run passes without retries. This is a fixture readiness correction, not a task lifecycle change. +- Locale generation touched backend catalog timestamps without changing their contents. The managed freshness guard rejected stale artifacts; rebuilding backend and frontend resolved that guard before the successful phone run. Existing Vite chunk and unused-key notices remain non-blocking. + +Fresh screenshot publication, normal-hook commit/push, current-head CI, review dispositions, and the minimum five-minute feedback hold remain in progress. Exact publication receipts and mutable PR state are tracked in the Kandev delivery task plan. diff --git a/docs/plans/github-mobile-parity/task-01-query-recovery.md b/docs/plans/github-mobile-parity/task-01-query-recovery.md new file mode 100644 index 0000000000..12f71dd3fd --- /dev/null +++ b/docs/plans/github-mobile-parity/task-01-query-recovery.md @@ -0,0 +1,78 @@ +--- +id: "01-query-recovery" +title: "Saved-query recovery" +status: complete +wave: 1 +depends_on: [] +plan: plan.md +requirements: + - REQ-INTEGRATIONS-GITHUB-MOBILE-001 +acceptance_criteria: + - AC-INTEGRATIONS-GITHUB-MOBILE-001.1 +system_design: + - ../../specs/integrations/system-design/github-dashboard-mobile.md +--- +# Saved-query recovery + +## Summary and scope + +Implement the query recovery section of the [design](../../specs/integrations/system-design/github-dashboard-mobile.md), including its focused regressions and translations. Own only the files listed below and required adjacent tests. + +## Out of scope + +Other work-order outcomes and changes to persistence or provider API contracts. + +## Acceptance + +- The referenced acceptance criteria pass through the real interaction/state boundary. +- Failure paths retain prior state and permit recovery; desktop behavior remains intact. +- Required targeted checks pass and results are recorded. + +## ASCII UI preview + +### UI-01: Saved-query recovery + +```text +Loading views... +Unable to load views [Retry] +Save query: [Name] [Repository] +[Cancel] [Save] (pending prevents duplicates) +``` + +See the [combined preview](plan.md#ascii-ui-preview). Geometry and hierarchy are structural; ASCII spacing is illustrative. + +## Verification + +From the repository root: + +```bash +(cd apps/web && pnpm exec vitest run components/github/my-github/use-saved-presets*.test.ts components/github/my-github/use-saved-preset-actions.test.ts) +``` + +Hook regressions use Red-Green-Refactor. Run new/changed tests after the final source edit. + +## Files likely touched + +Paths relative to apps/web: `components/github/my-github/use-saved-presets.ts`, `use-saved-preset-actions.ts`, `save-preset-dialog.tsx`, `saved-query-load-status.tsx`, and the shared saved-menu status slot. + +## Dependencies + +None. + +## Risks + +Load failure/retry and save completion must preserve workspace ownership and existing mutation rollback. + +## Parallelism + +sequential + +## Inputs + +[Requirements](../../specs/integrations/requirements/github-dashboard-mobile.md), paired design, existing source and tests named above. + +## Results + +Verified deferred load/save failures, successful empty collections, retry, duplicate-submit prevention, optimistic rollback, mutation ordering, and stale workspace responses. The focused hook/form tests were observed failing before implementation, then passing. Mobile persistence/recovery tests and desktop saved-query regressions pass. Desktop Retry uses the menu's keyboard navigation semantics. + +PR #3614 remediation (2026-09-12) adds first-render workspace isolation, A-to-B-to-A save-generation rejection, cached portable entries alongside desktop load status, a stable Save accessible name with one progress status, and deferred desktop menu-to-save focus handoff. Red regressions reproduced each boundary; 338 scoped unit tests pass across 51 files. `github-scope-bar-focus.spec.ts` covers keyboard save/cancel; existing mobile save/recovery coverage remains active. Final browser/CI and commit evidence is recorded in the plan's PR fixup results. diff --git a/docs/plans/github-mobile-parity/task-02-view-picker.md b/docs/plans/github-mobile-parity/task-02-view-picker.md new file mode 100644 index 0000000000..9388d3539f --- /dev/null +++ b/docs/plans/github-mobile-parity/task-02-view-picker.md @@ -0,0 +1,81 @@ +--- +id: "02-view-picker" +title: "Mobile Views picker" +status: complete +wave: 2 +depends_on: ["01-query-recovery"] +plan: plan.md +requirements: + - REQ-INTEGRATIONS-GITHUB-MOBILE-001 +acceptance_criteria: + - AC-INTEGRATIONS-GITHUB-MOBILE-001.2 + - AC-INTEGRATIONS-GITHUB-MOBILE-001.5 +system_design: + - ../../specs/integrations/system-design/github-dashboard-mobile.md +--- +# Mobile Views picker + +## Summary and scope + +Implement the picker composition section of the [design](../../specs/integrations/system-design/github-dashboard-mobile.md), including its focused regressions and translations. Own only the files listed below and required adjacent tests. + +## Out of scope + +Other work-order outcomes and changes to persistence or provider API contracts. + +## Acceptance + +- The referenced acceptance criteria pass through the real interaction/state boundary. +- Failure paths retain prior state and permit recovery; desktop behavior remains intact. +- Required targeted checks pass and results are recorded. + +## ASCII UI preview + +### UI-02: Mobile Views picker + +```text +GitHub +[Views: Review requested v] +Drawer: Views [Done] +[Pull requests] [Issues] + Inbox / Created / Saved (scroll) +[Save current query] (fixed footer) +``` + +See the [combined preview](plan.md#ascii-ui-preview). Geometry and hierarchy are structural; ASCII spacing is illustrative. + +## Verification + +From the repository root: + +```bash +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/github/mobile-github-sidebar.spec.ts) +``` + +Hook regressions use Red-Green-Refactor. Run new/changed tests after the final source edit. + +## Files likely touched + +Paths relative to apps/web: `app/github/github-page-client.tsx`, `components/github/my-github/mobile-views-picker.tsx`, `presets-sidebar.tsx`, the integration toolbar title slot, and `e2e/pages/mobile-github-page.ts`. + +## Dependencies + +Task 01-query-recovery. + +## Risks + +Keep kind switches open, retain explicit selection/default behavior, update every mobile test using the old closing behavior. + +## Parallelism + +sequential + +## Inputs + +[Requirements](../../specs/integrations/requirements/github-dashboard-mobile.md), paired design, existing source and tests named above. + +## Results + +Named Views control opens an inset bottom drawer. Kind switching stays open, final selection/Done closes, focus returns, and a 20-view collection scrolls beneath fixed header/footer controls at 320px. Eight GitHub query/recovery browser scenarios pass; desktop scope-bar scenarios also pass. + +Open PR revalidation on 2026-09-11 exposed a drawer/save focus race. A slowed-close regression first failed because the form opened before the drawer released focus. Opening from the drawer's close/focus callback fixes the overlap, and the existing focus-return helper restores the initiating control after Cancel. All seven scenarios in `mobile-github-sidebar.spec.ts` pass after this remediation, including saved-repository selection, failed-save retry, and the new focus regression. Final delivery commands are recorded in the plan. diff --git a/docs/plans/github-mobile-parity/task-03-task-views.md b/docs/plans/github-mobile-parity/task-03-task-views.md new file mode 100644 index 0000000000..b32b9f80ef --- /dev/null +++ b/docs/plans/github-mobile-parity/task-03-task-views.md @@ -0,0 +1,77 @@ +--- +id: "03-task-views" +title: "Shared task-view entry" +status: complete +wave: 3 +depends_on: ["02-view-picker"] +plan: plan.md +requirements: + - REQ-UI-MOBILE-TASK-VIEWS-001 +acceptance_criteria: + - AC-UI-MOBILE-TASK-VIEWS-001.1 + - AC-UI-MOBILE-TASK-VIEWS-001.2 +system_design: + - ../../specs/ui/system-design/mobile-task-view-access.md +--- +# Shared task-view entry + +## Summary and scope + +Implement the components and flow section of the [design](../../specs/ui/system-design/mobile-task-view-access.md), including its focused regressions and translations. Own only the files listed below and required adjacent tests. + +## Out of scope + +Other work-order outcomes and changes to persistence or provider API contracts. + +## Acceptance + +- The referenced acceptance criteria pass through the real interaction/state boundary. +- Failure paths retain prior state and permit recovery; desktop behavior remains intact. +- Required targeted checks pass and results are recorded. + +## ASCII UI preview + +### UI-03: Shared task-view entry + +```text +App menu -> [Task views] +Task drawer: [Saved view v] [Filters] +Matching task -> /t/id +Browser Back -> /github +``` + +See the [combined preview](plan.md#ascii-ui-preview). Geometry and hierarchy are structural; ASCII spacing is illustrative. + +## Verification + +From the repository root: + +```bash +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/github/mobile-task-view-access.spec.ts) +``` + +Hook regressions use Red-Green-Refactor. Run new/changed tests after the final source edit. + +## Files likely touched + +Paths relative to apps/web: `components/navigation/use-task-view-navigation.tsx`, `app-nav-sections.tsx`, `app-nav-sheet.tsx`, `components/kanban/mobile-menu-sheet.tsx`, `components/task/mobile/session-task-switcher-sheet.tsx`, and `session-task-switcher-sheet-hooks.ts`. + +## Dependencies + +Task 02-view-picker. + +## Risks + +Reuse the existing drawer; preserve task-workbench navigation and avoid mounting task context until requested. + +## Parallelism + +sequential + +## Inputs + +[Requirements](../../specs/ui/requirements/mobile-task-view-access.md), paired design, existing source and tests named above. + +## Results + +Shared navigation, browser Back, focus restoration, empty Kanban workspaces, and existing task-view creation/editing/selection pass in mobile browsers. The surface is lazy until requested, then retains its controller for child dialogs. The rotation regression first reproduced a lost draft, then passed after keeping the requested controller mounted across width changes. All 22 navigation unit regressions and both task-view entry browser tests pass after that fix. diff --git a/docs/plans/github-mobile-parity/task-04-result-actions.md b/docs/plans/github-mobile-parity/task-04-result-actions.md new file mode 100644 index 0000000000..141be9e4b2 --- /dev/null +++ b/docs/plans/github-mobile-parity/task-04-result-actions.md @@ -0,0 +1,75 @@ +--- +id: "04-result-actions" +title: "Touchable result actions" +status: complete +wave: 4 +depends_on: ["03-task-views"] +plan: plan.md +requirements: + - REQ-INTEGRATIONS-GITHUB-MOBILE-001 +acceptance_criteria: + - AC-INTEGRATIONS-GITHUB-MOBILE-001.3 +system_design: + - ../../specs/integrations/system-design/github-dashboard-mobile.md +--- +# Touchable result actions + +## Summary and scope + +Implement the result rows section of the [design](../../specs/integrations/system-design/github-dashboard-mobile.md), including its focused regressions and translations. Own only the files listed below and required adjacent tests. + +## Out of scope + +Other work-order outcomes and changes to persistence or provider API contracts. + +## Acceptance + +- The referenced acceptance criteria pass through the real interaction/state boundary. +- Failure paths retain prior state and permit recovery; desktop behavior remains intact. +- Required targeted checks pass and results are recorded. + +## ASCII UI preview + +### UI-04: Touchable result actions + +```text +Wrapped PR/issue title [Task] +owner/repository#123 +[Linked task Workflow step] +``` + +See the [combined preview](plan.md#ascii-ui-preview). Geometry and hierarchy are structural; ASCII spacing is illustrative. + +## Verification + +From the repository root: + +```bash +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/github/mobile-issue-list-task-indicator.spec.ts tests/github/mobile-github-results.spec.ts) +``` + +Hook regressions use Red-Green-Refactor. Run new/changed tests after the final source edit. + +## Files likely touched + +Paths relative to apps/web: `components/github/my-github/issue-list.tsx`, `components/integrations/change-request-list.tsx`, `task-row-indicator.tsx`, and `integration-start-task-menu.tsx`. + +## Dependencies + +Task 03-task-views. + +## Risks + +Shared integration primitives affect other providers; retain fine-pointer density and independent links/actions. + +## Parallelism + +sequential + +## Inputs + +[Requirements](../../specs/integrations/requirements/github-dashboard-mobile.md), paired design, existing source and tests named above. + +## Results + +The baseline issue action measured 24px in Playwright; it now measures at least 44px. PR/issue actions reuse the shared launcher, long titles/repositories wrap, and linked tasks show their step on touch devices. Mobile action/navigation and desktop PR/issue/launcher regressions pass. diff --git a/docs/plans/github-mobile-parity/task-05-pagination.md b/docs/plans/github-mobile-parity/task-05-pagination.md new file mode 100644 index 0000000000..59d796ee08 --- /dev/null +++ b/docs/plans/github-mobile-parity/task-05-pagination.md @@ -0,0 +1,76 @@ +--- +id: "05-pagination" +title: "Compact phone pagination" +status: complete +wave: 5 +depends_on: ["04-result-actions"] +plan: plan.md +requirements: + - REQ-INTEGRATIONS-GITHUB-MOBILE-001 +acceptance_criteria: + - AC-INTEGRATIONS-GITHUB-MOBILE-001.4 + - AC-INTEGRATIONS-GITHUB-MOBILE-001.5 +system_design: + - ../../specs/integrations/system-design/github-dashboard-mobile.md +--- +# Compact phone pagination + +## Summary and scope + +Implement the pagination section of the [design](../../specs/integrations/system-design/github-dashboard-mobile.md), including its focused regressions and translations. Own only the files listed below and required adjacent tests. + +## Out of scope + +Other work-order outcomes and changes to persistence or provider API contracts. + +## Acceptance + +- The referenced acceptance criteria pass through the real interaction/state boundary. +- Failure paths retain prior state and permit recovery; desktop behavior remains intact. +- Required targeted checks pass and results are recorded. + +## ASCII UI preview + +### UI-05: Compact phone pagination + +```text +Results (scroll) +101-125 of 1000+ +[Previous] [Page 5 of 40 v] [Next] +``` + +See the [combined preview](plan.md#ascii-ui-preview). Geometry and hierarchy are structural; ASCII spacing is illustrative. + +## Verification + +From the repository root: + +```bash +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/github/mobile-github-results.spec.ts) +``` + +Hook regressions use Red-Green-Refactor. Run new/changed tests after the final source edit. + +## Files likely touched + +Paths relative to apps/web: `components/github/my-github/results-pagination.tsx` and its unit/browser regressions. + +## Dependencies + +Task 04-result-actions. + +## Risks + +Keep direct page selection and the search cap; test page 5 of 40 and widths on both sides of 768px. + +## Parallelism + +sequential + +## Inputs + +[Requirements](../../specs/integrations/requirements/github-dashboard-mobile.md), paired design, existing source and tests named above. + +## Results + +Three pagination unit tests pass. A phone browser scenario seeds 1,050 PRs, navigates directly to pages 5 and 40, verifies disabled previous/next limits, checks no overflow at 320px, and confirms the 767/768px presentation boundary. The existing 1,000-result limit and search state remain unchanged. diff --git a/docs/plans/github-mobile-parity/task-06-toolbar-page-picker.md b/docs/plans/github-mobile-parity/task-06-toolbar-page-picker.md new file mode 100644 index 0000000000..23880c3d02 --- /dev/null +++ b/docs/plans/github-mobile-parity/task-06-toolbar-page-picker.md @@ -0,0 +1,84 @@ +--- +id: "06-toolbar-page-picker" +title: "Refine mobile toolbar and page picker" +status: complete +wave: 6 +depends_on: ["05-pagination"] +plan: plan.md +requirements: + - REQ-INTEGRATIONS-GITHUB-MOBILE-001 +acceptance_criteria: + - AC-INTEGRATIONS-GITHUB-MOBILE-001.2 + - AC-INTEGRATIONS-GITHUB-MOBILE-001.4 + - AC-INTEGRATIONS-GITHUB-MOBILE-001.5 +system_design: + - ../../specs/integrations/system-design/github-dashboard-mobile.md +--- +# Refine mobile toolbar and page picker + +## Summary and scope + +Continue the implemented package from user screenshot feedback: a long saved-view label squeezed the bare result count and updated time into the same row, and pagination opened an operating-system select menu. Separate the toolbar hierarchy and use the existing in-app bottom-drawer interaction for page choice. + +## Out of scope + +Provider API or persistence changes, new locale keys, desktop pagination redesign, and implementation in the other integration repositories. Those providers have separate [planning tasks](integration-followups.md). + +## Acceptance + +- A long Views label stays within a single 44px trigger at 320px; its full accessible name and drawer label remain available. Results, updated time, and Refresh occupy a separate row below the input. +- The page button opens a bounded bottom drawer, reveals the selected page, and offers 44px rows. Selecting another page calls the existing navigation once; selecting the current page only closes. +- Dismissal restores focus, page 40 stays reachable, and no horizontal overflow occurs at 320px or either side of the 767/768px boundary. Desktop pagination and shared GitLab toolbar behavior remain intact. + +## ASCII UI preview + +### UI-06: Toolbar hierarchy and in-app page choice + +```text +[Views: Long saved query name... v] +[Repository] +[Query] +Results 75 Updated just now [Refresh] + +[Previous] [Page 1 of 3 v] [Next] +Drawer: Choose results page [Done] + Page 1 of 3 (selected) + Page 2 of 3 + Page 3 of 3 +``` + +Geometry and hierarchy are structural; ASCII spacing is illustrative. Product copy uses existing translations. + +## Files and ownership + +Own `components/github/my-github/mobile-views-picker.tsx`, `mobile-results-pagination.tsx`, `results-pagination.tsx`, the shared `components/integrations/integration-list-toolbar.tsx`, adjacent unit tests, and `e2e/tests/github/mobile-github-results.spec.ts` under `apps/web`. Update the paired integration spec/design and existing public mobile instructions. Do not change other providers' domain logic. + +## Dependencies and risks + +Task 05 is complete. The shared toolbar affects GitLab and plugin host consumers, so retain its prop contract and desktop composition. Browser tests, not jsdom animation teardown, own actual dialog dismissal and focus-return assertions. Updating the existing disposable demo must preserve its database and in-memory provider fixtures; refresh static assets without restarting its backend. + +## Verification + +From `apps/web`, run the changed pagination/toolbar Vitest files, focused ESLint, typecheck, and i18n checks. Run managed Playwright with one worker and fresh assets: + +```bash +pnpm e2e:run --host --no-build --project mobile-chrome tests/github/mobile-github-results.spec.ts tests/github/mobile-github-view-recovery.spec.ts tests/github/mobile-github-sidebar.spec.ts +pnpm e2e:run --host --no-build --project chromium tests/github/github-scope-bar.spec.ts +pnpm e2e:run --host --no-build --project mobile-chrome tests/gitlab/mobile-gitlab-parity.spec.ts --grep 'browses, quick launches' +``` + +Run specification/public-doc validators and `git diff --check` from the repository root. Inspect the refreshed isolated demo at phone size; do not touch the main runtime on :9998. + +## Parallelism + +Sequential implementation in the primary session. No delegated code changes. + +## Results + +Red tests reproduced the toolbar's refresh button sharing the long Views row (y=62 while the Views trigger ended at y=114), and the old page control being SELECT rather than BUTTON. Updated behavior passes six focused unit tests, ten mobile GitHub browser scenarios, four desktop GitHub regressions, and the GitLab mobile browse/review regression. Final typecheck, focused zero-warning lint, translation checks, spec/doc validators, and whitespace checks passed. + +Refreshed the existing isolated instance's assets without restarting its backend or resetting data. Browser checks through its Tailscale HTTPS URL confirmed the 44px long-view trigger at 320px and 393px, separate Results 75 and Updated just now metadata, three-page drawer navigation, focus return, and no horizontal overflow. Inspected captures: `/tmp/kandev-mobile-parity-test-vUutse/browser-artifacts/refined-mobile-toolbar.png` and `refined-mobile-page-picker.png`. + +Open PR revalidation on 2026-09-11 now waits for finite drawer animations before measuring page targets. The capped-page fixture shapes the mock provider's unpaged response into GitHub's bounded pages while preserving its total count; the scenario asserts 25 rendered rows and reaches page 40 without rendering all 1,050 seeded rows on every navigation. Both scenarios in `mobile-github-results.spec.ts` pass. Fresh desktop and phone PR screenshots use separate managed fixtures; the stopped demo remains untouched. + +PR #3614 review remediation (2026-09-12) localizes the complete result count with singular/plural forms and a styled numeric span, plus separate loading copy. This scoped review correction supersedes the earlier no-new-locale-keys exclusion. When a refreshed total removes the selected page, the page drawer retains its default Done focus fallback. Unit regressions reproduce both issues; the updated 320px browser scenario refreshes page 40 down to two available pages, verifies fallback focus, and selects page 2 once. Both results scenarios pass in the final 17-scenario phone run. See the plan's PR fixup results for full validation. diff --git a/docs/public/integrations.md b/docs/public/integrations.md index c7fba26e8e..a888f4c2e6 100644 --- a/docs/public/integrations.md +++ b/docs/public/integrations.md @@ -321,6 +321,14 @@ Workspace GitHub settings control repository scope, default/saved searches, quic In the **Saved** list, use the star beside a query to set or clear it as the default view. Pull requests and issues keep separate saved defaults. Kandev applies the relevant saved default, including its repository filter, the next time you enter `/github` or switch to that result type; setting or clearing the star does not replace the view currently on screen. Without a saved default, Kandev uses the first configured default query for that result type. +On a phone, tap **Views** beside the current query name to open the query drawer. Switch between **Pull requests** and **Issues**, then select a built-in or saved query. The drawer stays open when you switch result types; select a query or tap **Done** to return to results. **Save current query** stays at the bottom while the query list scrolls. Saved-query loading failures offer **Retry**; a failed save keeps your name and repository selection in the save dialog. + +Long query names shorten to one line in the **Views** button; open the drawer to read the full name. The result count, last-updated time, and **Refresh** appear in a separate row below the search input. + +Result rows provide touch-sized task actions and show a linked task's workflow step without hovering. Tap the page chooser between the previous/next buttons to open a bottom drawer with the current page marked. Select a page to jump directly to it, up to GitHub's first 1,000 search results, or tap **Done** to keep the current page. + +To use saved **task** filters from this page, open the app navigation menu and choose **Task views**. This opens the same task-view picker and filter editor as the task sidebar, for the active Kanban workspace. These task views are separate from GitHub saved queries and Threads views. Open a task from this drawer and use browser Back to return to the GitHub dashboard. + A **Review Watch** polls a GitHub search and creates review work. It requires a workflow, starting step, prompt, and workspace. The default query is `type:pr state:open review-requested:@me -is:draft`; add repository filters or replace the query as needed. An optional agent or executor profile overrides the selected step's defaults. The poll interval defaults to 300 seconds and accepts 60–3,600 seconds. The prompt field accepts `@name` references to saved prompts, resolved the same way as in a workflow step; see [Saved prompt references in step prompts](workflow-tips.md#saved-prompt-references-in-step-prompts). When a review watch is created, Kandev saves its verified target GitHub login. App-backed polling replaces `review-requested:@me` with that explicit login because an installation is not a user. Creating a user-targeted review watch therefore requires a connected personal identity or human PAT/CLI automation identity. A migrated watch with no verified target is disabled until an identity is reconnected. diff --git a/docs/specs/integrations/README.md b/docs/specs/integrations/README.md index f2ed8b862e..40934c69ec 100644 --- a/docs/specs/integrations/README.md +++ b/docs/specs/integrations/README.md @@ -49,6 +49,7 @@ outcomes that expose those contracts. - [GitHub task pull request sync coordination](requirements/github-task-pr-sync-coordination.md) - [GitHub PR discovery health](requirements/github-pr-discovery-health.md) - [GitHub workflow attention](requirements/github-workflow-attention.md) +- [GitHub dashboard mobile](requirements/github-dashboard-mobile.md) - [GitLab Integration](requirements/gitlab-integration.md) - [GitLab MR Status Chip](requirements/gitlab-mr-status-chip.md) - [GitLab MR Badge on the Sidebar and Tasks-List Rows](requirements/gitlab-mr-task-list-badges.md) @@ -84,6 +85,7 @@ outcomes that expose those contracts. - [GitHub task pull request sync coordination](system-design/github-task-pr-sync-coordination.md) - [GitHub PR discovery health](system-design/github-pr-discovery-health.md) - [GitHub workflow attention](system-design/github-workflow-attention.md) +- [GitHub dashboard mobile](system-design/github-dashboard-mobile.md) - [Pull request link copy actions](system-design/pr-link-copy-actions.md) - [GitLab Integration System Design Part 1](system-design/gitlab-integration-01.md) - [GitLab Integration System Design Part 2](system-design/gitlab-integration-02.md) diff --git a/docs/specs/integrations/requirements/github-dashboard-mobile.md b/docs/specs/integrations/requirements/github-dashboard-mobile.md new file mode 100644 index 0000000000..6ef47f39e7 --- /dev/null +++ b/docs/specs/integrations/requirements/github-dashboard-mobile.md @@ -0,0 +1,38 @@ +--- +status: active +system: integrations +created: 2026-09-11 +owners: + - Kandev +--- +# GitHub dashboard mobile requirements + +## Overview + +Phone users need discoverable GitHub queries and usable result actions. Integrations owns provider queries, their scope, and the dashboard outcomes. Shared task-view navigation is independently owned by [UI](../../ui/requirements/mobile-task-view-access.md). + +## Requirements + +### REQ-INTEGRATIONS-GITHUB-MOBILE-001: GitHub dashboard mobile parity + +**Intent:** Preserve desktop dashboard capabilities through focused phone interactions. + +#### Acceptance criteria + +- **AC-INTEGRATIONS-GITHUB-MOBILE-001.1:** Saved queries shall distinguish loading, failure with retry, and an empty collection. A failed save shall retain the entered name and repository and permit retry; successful persistence shall close the form. Workspace changes shall not apply stale outcomes to the new workspace. +- **AC-INTEGRATIONS-GITHUB-MOBILE-001.2:** Below 768px, a labeled current-view control shall open an inset bottom drawer. The trigger shall stay on one line without compressing result count or refresh metadata; its full name shall remain accessible. A separate status row shall label the result count and group updated time with Refresh below the query input. Changing PR/issue kind shall keep the drawer open; selecting a query or Done shall close it. Save shall remain reachable with long collections. Existing per-kind defaults, delete confirmation, and default-action isolation shall continue to work. +- **AC-INTEGRATIONS-GITHUB-MOBILE-001.3:** Phone and coarse-pointer result actions shall have at least 44px hit targets. Long titles and repository names shall remain readable without horizontal overflow. Linked tasks shall expose their workflow step without requiring hover while retaining direct task navigation. +- **AC-INTEGRATIONS-GITHUB-MOBILE-001.4:** Phone pagination shall expose previous, next, current/total page, and direct page selection through an in-app bottom drawer, not an operating-system select menu. Opening the chooser shall reveal and mark the current page. Selecting another page shall navigate once and dismiss; selecting the current page shall dismiss without refetching. Existing search limits and filter-triggered page resets shall remain unchanged, with no horizontal overflow. +- **AC-INTEGRATIONS-GITHUB-MOBILE-001.5:** Drawer and page controls shall support dismissal, focus return, internal scrolling, safe-area clearance, and translated labels. Desktop query state and preferences shall remain unchanged by phone composition. + +## Compatibility + +The [saved-query defaults contract](../../ui/requirements/github-saved-query-defaults.md) remains authoritative for default selection and scope; the drawer replaces only that document's historical filter-sheet presentation. + +## Out of scope + +New query storage, renamed/edited saved queries, changed authentication, direct dashboard merge/review actions, and shared task-view data semantics. + +## Implementation plans + +- [GitHub mobile parity](../../../plans/github-mobile-parity/plan.md) diff --git a/docs/specs/integrations/system-design/github-dashboard-mobile.md b/docs/specs/integrations/system-design/github-dashboard-mobile.md new file mode 100644 index 0000000000..e0f18e879b --- /dev/null +++ b/docs/specs/integrations/system-design/github-dashboard-mobile.md @@ -0,0 +1,47 @@ +--- +status: current +system: integrations +requirements: + - REQ-INTEGRATIONS-GITHUB-MOBILE-001 +--- +# GitHub dashboard mobile design + +## Boundaries and mapping + +`REQ-INTEGRATIONS-GITHUB-MOBILE-001` maps to query recovery (AC .1), picker composition (AC .2/.5), result rows (AC .3), and pagination (AC .4). The existing GitHub workspace settings and portable user settings remain the persistence boundary. [Shared task-view access](../../ui/system-design/mobile-task-view-access.md) owns the separate app-navigation entry. + +## Query recovery + +`useSavedPresets` exposes load status and retry along with existing mutations. Preserve generation/cancellation guards and pending mutation queues. Unavailable workspace data must not be written as an empty collection. Portable snapshot hydration must not overwrite a newer local mutation. Retry the settings read explicitly; query-result Refresh remains a different action. + +Associate workspace data and load status with the workspace ID so the first render after navigation cannot expose the previous workspace's snapshot or enable writes against it. Cached portable queries remain available alongside loading/error feedback on both desktop and phone. + +`useSavedPresetActions.onConfirmSave` reports whether persistence succeeded in the originating scope. `SavePresetDialog` awaits the outcome, prevents duplicate submission, retains values on failure, and closes only on success. Scope changes discard the old form. Existing error toasts remain localized. + +A save completion must belong to the same navigation generation, including A-to-B-to-A navigation. Keep the Save action's accessible name stable while pending and announce progress in one localized status region. + +## Picker composition + +`GitHubPageClient` keeps one shared query state; `useResponsiveBreakpoint` selects phone presentation below 768px. Extract a domain-owned mobile Views drawer, keeping `PresetsScopeBar` for desktop. The page toolbar supplies the labeled current-view opener. Kind selection applies existing defaults while leaving the drawer open. Final query selection closes it. Default/delete remain separate row actions. + +Reuse the geometry of `components/kanban/mobile-menu-sheet.tsx`: inset bottom Drawer, fixed header/kind controls, one min-h-0 body scroller, dynamic maximum height, and a safe-area footer containing Save. Restore focus to the actual opener except when moving into Save; do not stack open overlays. + +Open the save form after the Views drawer or desktop saved-query menu completes its close/focus callback. Cancel pending opening work on unmount and retain the mounted initiating trigger for focus return when the form closes. + +The mobile Views trigger is a full-width, 44px-high, single-line button. Truncate its visible current-view label while retaining the full accessible name and the complete name inside the drawer. `IntegrationListToolbar` separates mobile secondary information into a row after the query input: Results plus a tabular count on the left, updated time and a touch-sized Refresh on the right. The timestamp can truncate within the remaining width. Desktop retains its inline title/count and refresh group. This shared toolbar composition also applies to GitLab and host-plugin consumers without changing their query or pagination contracts. + +The result-count translation owns pluralization and word order; its numeric span retains tabular styling. Loading uses separate localized copy, without a stale count. + +## Result rows + +Use `ChangeRequestRow` and `IntegrationStartTaskMenu` for both PRs and issues. Phone titles/repository metadata wrap; independent links and task controls retain their semantics. Shared linked-task indicators use conditional touch sizing and inline step context on touch devices; desktop retains compact tooltip presentation. + +## Pagination + +Keep the numbered desktop footer. Phone pagination uses Previous, a page selector labeled with current/total page, and Next. `MobileResultsPagination` owns a controlled domain-local `Drawer` using the shared UI primitives. Its header and Done action stay fixed above a single scroll body capped at 80dvh with bottom safe-area clearance. Opening focuses and scrolls to the selected page; `aria-current="page"` and a check mark identify it. Each 44px page button dismisses the drawer and only calls `onPageChange` for a different page. Escape, dismissal, and selection return focus to the trigger. Preserve the 1000-result ceiling and the existing `useGitHubSearch` page reset. The result list owns its separate scrolling and the footer clears bottom safe areas. + +If a refreshed total no longer includes the current page, opening the chooser uses the drawer's default focus fallback (Done) instead of suppressing focus without a target. Choosing an available page still navigates exactly once. + +## Verification + +Use hook tests for deferred load/save errors, retries, successful empty collections, and stale workspace responses. Mobile Playwright exercises drawer hierarchy, save failure recovery, long names, task actions, many-page navigation, focus, and geometry. Desktop tests retain saved-query/default and result-action coverage. diff --git a/docs/specs/ui/README.md b/docs/specs/ui/README.md index 14d1bb23ee..58778ff903 100644 --- a/docs/specs/ui/README.md +++ b/docs/specs/ui/README.md @@ -19,6 +19,8 @@ runtime, and workspace lifecycle/state ownership. ### Requirements +- [Mobile task-view access](requirements/mobile-task-view-access.md) + - [Control sizing](requirements/control-sizing.md) - [ACP model summary](requirements/acp-model-configuration-summary.md) - [ACP Shell Command Output](requirements/acp-shell-command-output.md) @@ -155,6 +157,7 @@ runtime, and workspace lifecycle/state ownership. ### Design +- [Mobile task-view access](system-design/mobile-task-view-access.md) - [Agent launch prompt composer](system-design/agent-launch-prompt-composer.md) - [Control sizing](system-design/control-sizing.md) - [Adaptive Kanban](system-design/adaptive-kanban.md) diff --git a/docs/specs/ui/requirements/mobile-task-view-access.md b/docs/specs/ui/requirements/mobile-task-view-access.md new file mode 100644 index 0000000000..94a7afd6e5 --- /dev/null +++ b/docs/specs/ui/requirements/mobile-task-view-access.md @@ -0,0 +1,31 @@ +--- +status: active +system: ui +created: 2026-09-11 +owners: + - Kandev +--- +# Mobile task-view access requirements + +## Overview + +The shared phone navigation should expose the task-sidebar views available from desktop page chrome. UI owns this reusable navigation contract; task filtering and persistence retain their existing owners. + +## Requirements + +### REQ-UI-MOBILE-TASK-VIEWS-001: Shared phone task-view access + +**Intent:** Reach existing task views without first opening a task. + +#### Acceptance criteria + +- **AC-UI-MOBILE-TASK-VIEWS-001.1:** Shared phone navigation in a Kanban workspace shall expose Task views. Selecting it shall close navigation and open the existing mobile task-view surface with its saved views and editor. Task dialogs opened from that surface shall retain their drafts across phone orientation changes. +- **AC-UI-MOBILE-TASK-VIEWS-001.2:** Choosing a task shall navigate to that task and preserve browser Back to the originating page. Dismissal shall return focus to a visible navigation opener. GitHub queries, Threads views, and sidebar task views shall remain separate collections. + +## Out of scope + +Office navigation changes, new task-view storage, and a global navigation redesign. + +## Implementation plans + +- [GitHub mobile parity](../../../plans/github-mobile-parity/plan.md) diff --git a/docs/specs/ui/system-design/mobile-task-view-access.md b/docs/specs/ui/system-design/mobile-task-view-access.md new file mode 100644 index 0000000000..b4727300f0 --- /dev/null +++ b/docs/specs/ui/system-design/mobile-task-view-access.md @@ -0,0 +1,23 @@ +--- +status: current +system: ui +requirements: + - REQ-UI-MOBILE-TASK-VIEWS-001 +--- +# Mobile task-view access design + +## Boundary and mapping + +`REQ-UI-MOBILE-TASK-VIEWS-001` maps to the shared menu entry and overlay handoff described below. Existing sidebar view state and `SessionTaskSwitcherSheet` own filtering and task actions. This design does not merge those views with GitHub or Threads saved queries. + +## Components and flow + +Extend `AppNavSections` and its hoisted `useAppNavDialogs` controls with a localized Task views action for phone Kanban workspaces. Close the navigation surface before opening the task drawer. Mount the existing `SessionTaskSwitcherSheet` lazily in the hoisted dialogs, with the current workspace and workflow context and presentation=drawer. Do not mount it before the first request. Once requested, retain its controller until navigation or workspace changes so closing the drawer can hand off to task creation, editing, and confirmation dialogs without losing their state. + +The existing drawer's `SidebarFilterBar` provides saved view selection/editing. Task navigation from a non-task route pushes the task route so Back returns to the origin; in-workbench task switching retains its established navigation policy. Dismissal restores a visible app-navigation trigger. Do not add Office task controls to this Kanban surface. + +Only the navigation entry is gated by phone width. A controller already requested by the user remains mounted across breakpoints, so turning the phone sideways cannot discard a draft in a task dialog. + +## Verification + +A phone E2E starts at /github, opens app navigation, selects Task views, chooses a saved sidebar view, opens a matching task, and returns with Back. Cover dismissal and a workspace without tasks. Existing task-workbench switching tests protect its navigation policy.
+ {range} +
+ {t("github:saving")} +
+ {t("github:loadingSavedQueries")} +
+ {t("github:savedQueriesLoadFailed")} +