diff --git a/plugins/tasks/shell/app-shell.tsx b/plugins/tasks/shell/app-shell.tsx index 365038ea08..61ae5e838a 100644 --- a/plugins/tasks/shell/app-shell.tsx +++ b/plugins/tasks/shell/app-shell.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { PluginNavPanelProps } from "@get-bb/plugin-sdk/app"; import { useActiveTasks, @@ -10,8 +10,11 @@ import { import { parseTasksRoute, useTasksNavigation, + type ResolvedTasksRoute, + type TasksNavigation, type TasksRoute, } from "./routes.js"; +import { loadViewMode, storeViewMode } from "./view-preference.js"; import { TasksSidebar } from "./sidebar.js"; import { loadSidebarCollapsed, @@ -154,7 +157,7 @@ function RouteOutlet({ route, boardUsable, }: { - route: TasksRoute; + route: ResolvedTasksRoute; /** False in phone-width containers: board routes fall back to the list (deep links/rotation would otherwise strand a crushed board with the toggle hidden). The URL keeps the board view for when width returns. */ @@ -178,9 +181,31 @@ function RouteOutlet({ } } +/** + * A project URL without a `?view=` marker (sidebar click, breadcrumb, deep + * link) restores the view this client last used for that project. + */ +function resolveRoute(route: TasksRoute): ResolvedTasksRoute { + if (route.kind !== "project") return route; + return { ...route, view: route.view ?? loadViewMode(route.projectId) }; +} + function TasksAppShellContent({ subPath }: PluginNavPanelProps) { - const route = parseTasksRoute(subPath); - const navigation = useTasksNavigation(); + const route = resolveRoute(parseTasksRoute(subPath)); + const tasksNavigation = useTasksNavigation(); + // Every explicit project view in a navigation is a user choice worth + // remembering — the topbar's List/Board toggle is the only source of one. + const navigation = useMemo( + () => ({ + go: (target, options) => { + if (target.kind === "project" && target.view !== null) { + storeViewMode(target.projectId, target.view); + } + tasksNavigation.go(target, options); + }, + }), + [tasksNavigation], + ); const [sidebarCollapsed, setSidebarCollapsed] = useState(loadSidebarCollapsed); const [newTaskOpen, setNewTaskOpen] = useState(false); diff --git a/plugins/tasks/shell/routes.ts b/plugins/tasks/shell/routes.ts index 46cf4b7143..74b7e7992c 100644 --- a/plugins/tasks/shell/routes.ts +++ b/plugins/tasks/shell/routes.ts @@ -6,13 +6,23 @@ export const PANEL_PATH = "tasks"; export type TaskViewMode = "list" | "board"; +/** + * A project route's `view` is `null` when the URL names no view — the shell + * then resolves the user's stored preference for that project (see + * view-preference.ts). Navigating with an explicit view pins it in the URL. + */ export type TasksRoute = | { kind: "all" } | { kind: "active" } | { kind: "manage" } - | { kind: "project"; projectId: string; view: TaskViewMode } + | { kind: "project"; projectId: string; view: TaskViewMode | null } | { kind: "task"; taskKey: string }; +/** A route whose project view has been resolved; what the shell renders. */ +export type ResolvedTasksRoute = + | Exclude + | { kind: "project"; projectId: string; view: TaskViewMode }; + /** * subPath grammar (the trailing route below /plugins/tasks/tasks): * "" → all tasks (default) @@ -20,7 +30,8 @@ export type TasksRoute = * "active" → tasks with agents working * "manage" → manage panel (labels, presets, folders) * "task/" → task detail (e.g. task/TSK-4) - * "" → project list view + * "" → project, view from the stored preference + * "?view=list" → project list view * "?view=board" → project board view */ function decodeSegment(segment: string): string { @@ -52,7 +63,9 @@ export function parseTasksRoute(rawSubPath: string): TasksRoute { return { kind: "project", projectId: head, - view: view === "board" ? "board" : "list", + // Anything other than the two known views (including no marker at all) + // leaves the choice to the caller's stored preference. + view: view === "board" || view === "list" ? view : null, }; } @@ -67,9 +80,9 @@ export function tasksRouteToSubPath(route: TasksRoute): string { case "task": return `task/${route.taskKey}`; case "project": - return route.view === "board" - ? `${route.projectId}?view=board` - : route.projectId; + return route.view === null + ? route.projectId + : `${route.projectId}?view=${route.view}`; } } diff --git a/plugins/tasks/shell/shell.test.tsx b/plugins/tasks/shell/shell.test.tsx index 4c33cf3de3..6a30318a76 100644 --- a/plugins/tasks/shell/shell.test.tsx +++ b/plugins/tasks/shell/shell.test.tsx @@ -24,6 +24,7 @@ const { parseTasksRoute, tasksRouteToSubPath } = await import("./routes.js"); const { pagerPosition } = await import("./topbar.js"); const { SIDEBAR_COLLAPSED_STORAGE_KEY } = await import("./sidebar-preference.js"); +const { loadViewMode } = await import("./view-preference.js"); beforeEach(() => window.localStorage.clear()); afterEach(() => { @@ -32,6 +33,7 @@ afterEach(() => { }); const PROJECT_ID = "01HZZZZZZZZZZZZZZZZZZZZZP1"; +const OTHER_PROJECT_ID = "01HZZZZZZZZZZZZZZZZZZZZZP2"; const FOLDER_ID = "01HZZZZZZZZZZZZZZZZZZZZZF1"; const project = { @@ -81,6 +83,8 @@ describe("tasks route grammar", () => { { kind: "task", taskKey: "TSK-4" }, { kind: "project", projectId: PROJECT_ID, view: "list" }, { kind: "project", projectId: PROJECT_ID, view: "board" }, + // No view marker: the shell fills it from the stored preference. + { kind: "project", projectId: PROJECT_ID, view: null }, ] as const; for (const route of routes) { expect(parseTasksRoute(tasksRouteToSubPath(route))).toEqual(route); @@ -92,6 +96,82 @@ describe("tasks route grammar", () => { view: "board", }); expect(parseTasksRoute("")).toEqual({ kind: "all" }); + // An unknown marker is as good as none — never a silent "list". + expect(parseTasksRoute(`${PROJECT_ID}?view=kanban`)).toEqual({ + kind: "project", + projectId: PROJECT_ID, + view: null, + }); + }); +}); + +describe("project view preference", () => { + const openProject = (subPath: string) => + renderSlot( + app.navPanels[0]!, + { subPath }, + { rpc: seededRpc({ listLabels: () => ({ labels: [] }) }) }, + ); + + it("restores the remembered view when the URL names none", async () => { + const listed = openProject(`${PROJECT_ID}?view=list`); + // The toggle is the only way a user picks a view; it must persist. + fireEvent.click(await listed.findByRole("button", { name: "Board" })); + expect(listed.navigateCalls).toContainEqual({ + method: "toPluginPanel", + path: "tasks", + options: { subPath: `${PROJECT_ID}?view=board` }, + }); + listed.lifecycle.unmount(); + + // Reopening the project without a marker (sidebar click, deep link). + const reopened = openProject(PROJECT_ID); + const boardSegment = await reopened.findByRole("button", { name: "Board" }); + expect(boardSegment.getAttribute("aria-pressed")).toBe("true"); + await reopened.findByText("In Review"); + }); + + it("keeps per-project choices apart and defaults unseen projects to the last one used", async () => { + const slot = openProject(`${PROJECT_ID}?view=list`); + fireEvent.click(await slot.findByRole("button", { name: "Board" })); + slot.lifecycle.unmount(); + + expect(loadViewMode(PROJECT_ID)).toBe("board"); + // A project opened for the first time follows the most recent choice + // rather than snapping back to the list. + expect(loadViewMode(OTHER_PROJECT_ID)).toBe("board"); + + const other = renderSlot( + app.navPanels[0]!, + { subPath: `${OTHER_PROJECT_ID}?view=list` }, + { rpc: seededRpc() }, + ); + fireEvent.click(await other.findByRole("button", { name: "List" })); + expect(loadViewMode(OTHER_PROJECT_ID)).toBe("list"); + expect(loadViewMode(PROJECT_ID)).toBe("board"); + }); + + it("navigates from the sidebar without pinning a view", async () => { + const slot = openProject("all"); + fireEvent.click(await slot.findByText("Tasks Plugin")); + expect(slot.navigateCalls).toContainEqual({ + method: "toPluginPanel", + path: "tasks", + options: { subPath: PROJECT_ID }, + }); + }); + + it("still toggles when client storage rejects writes", async () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Storage is disabled", "SecurityError"); + }); + const slot = openProject(`${PROJECT_ID}?view=list`); + fireEvent.click(await slot.findByRole("button", { name: "Board" })); + expect(slot.navigateCalls).toContainEqual({ + method: "toPluginPanel", + path: "tasks", + options: { subPath: `${PROJECT_ID}?view=board` }, + }); }); }); diff --git a/plugins/tasks/shell/sidebar.tsx b/plugins/tasks/shell/sidebar.tsx index ce77593d72..01e38f9c36 100644 --- a/plugins/tasks/shell/sidebar.tsx +++ b/plugins/tasks/shell/sidebar.tsx @@ -262,12 +262,9 @@ export function TasksSidebar({ [summaries], ); const activeProjectId = route.kind === "project" ? route.projectId : null; + // No explicit view: the shell restores the view last used for that project. const openProject = (projectId: string) => - onNavigate({ - kind: "project", - projectId, - view: route.kind === "project" ? route.view : "list", - }); + onNavigate({ kind: "project", projectId, view: null }); const toggleFolder = (folderId: string) => setCollapsedFolders((current) => { const next = new Set(current); diff --git a/plugins/tasks/shell/topbar.tsx b/plugins/tasks/shell/topbar.tsx index e5a9b81c07..a7b053ab96 100644 --- a/plugins/tasks/shell/topbar.tsx +++ b/plugins/tasks/shell/topbar.tsx @@ -2,7 +2,11 @@ import { useCallback, useMemo } from "react"; import type { Project, Task } from "../shared/contract.js"; import { groupTasksByStatus } from "../views/list/lib.js"; import { listAllTasks, useTasksQuery } from "./data.js"; -import type { TaskViewMode, TasksRoute } from "./routes.js"; +import type { + ResolvedTasksRoute, + TaskViewMode, + TasksRoute, +} from "./routes.js"; import { Button } from "@bb/shared-ui/button"; import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; @@ -182,7 +186,7 @@ function RefreshTasksButton() { } export interface TasksTopbarProps { - route: TasksRoute; + route: ResolvedTasksRoute; projects: Project[] | undefined; sidebarCollapsed: boolean; /** @@ -277,10 +281,12 @@ export function TasksTopbar({ type="button" className="hidden min-w-0 items-center gap-2 text-muted-foreground hover:text-foreground @md:flex" onClick={() => + // No explicit view: the shell restores the project's + // remembered List/Board choice. onNavigate({ kind: "project", projectId: project.id, - view: "list", + view: null, }) } > diff --git a/plugins/tasks/shell/view-preference.test.ts b/plugins/tasks/shell/view-preference.test.ts new file mode 100644 index 0000000000..58ab6a3b8a --- /dev/null +++ b/plugins/tasks/shell/view-preference.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from "vitest"; +import { + VIEW_PREFERENCE_STORAGE_KEY, + VIEW_PREFERENCE_VERSION, + loadViewMode, + storeViewMode, +} from "./view-preference.js"; + +const PROJECT_A = "01HZZZZZZZZZZZZZZZZZZZZZP1"; +const PROJECT_B = "01HZZZZZZZZZZZZZZZZZZZZZP2"; + +beforeEach(() => window.localStorage.clear()); + +describe("view preference storage", () => { + it("falls back to the list before anything is stored", () => { + expect(loadViewMode(PROJECT_A)).toBe("list"); + }); + + it("keeps other projects' choices when one project changes", () => { + storeViewMode(PROJECT_A, "board"); + storeViewMode(PROJECT_B, "list"); + expect(loadViewMode(PROJECT_A)).toBe("board"); + expect(loadViewMode(PROJECT_B)).toBe("list"); + }); + + it("treats corrupt or partial documents as unset rather than throwing", () => { + window.localStorage.setItem(VIEW_PREFERENCE_STORAGE_KEY, "{not json"); + expect(loadViewMode(PROJECT_A)).toBe("list"); + + window.localStorage.setItem( + VIEW_PREFERENCE_STORAGE_KEY, + JSON.stringify({ + version: VIEW_PREFERENCE_VERSION, + lastUsed: "kanban", + projects: { [PROJECT_A]: "gallery" }, + }), + ); + expect(loadViewMode(PROJECT_A)).toBe("list"); + }); + + it("leaves a document written by a newer client untouched", () => { + const future = JSON.stringify({ + version: VIEW_PREFERENCE_VERSION + 1, + lastUsed: "board", + projects: { [PROJECT_A]: "board" }, + timeline: "view a future build added", + }); + window.localStorage.setItem(VIEW_PREFERENCE_STORAGE_KEY, future); + + // Known fields still read; the write is refused so the future build's own + // fields survive this session. + expect(loadViewMode(PROJECT_A)).toBe("board"); + storeViewMode(PROJECT_A, "list"); + expect(window.localStorage.getItem(VIEW_PREFERENCE_STORAGE_KEY)).toBe( + future, + ); + }); +}); diff --git a/plugins/tasks/shell/view-preference.ts b/plugins/tasks/shell/view-preference.ts new file mode 100644 index 0000000000..a85a80b540 --- /dev/null +++ b/plugins/tasks/shell/view-preference.ts @@ -0,0 +1,108 @@ +import type { TaskViewMode } from "./routes.js"; + +/** + * Client-local List/Board choice per project. Stored in the browser profile so + * one client does not rewrite another client connected to the same bb server — + * the same boundary as the sidebar and list preferences. + * + * A project route without an explicit `?view=` resolves through here, so + * reopening a project restores the view the user last picked for it. Projects + * never opened before fall back to the last view chosen anywhere, then to the + * list. + */ +export const VIEW_PREFERENCE_STORAGE_KEY = "bb-tasks:view-preferences"; +export const VIEW_PREFERENCE_VERSION = 1 as const; + +export const DEFAULT_VIEW_MODE: TaskViewMode = "list"; + +interface StoredDocumentV1 { + version: typeof VIEW_PREFERENCE_VERSION; + /** View chosen most recently on any project; default for unseen projects. */ + lastUsed: TaskViewMode; + projects: Record; +} + +function asViewMode(value: unknown): TaskViewMode | null { + return value === "list" || value === "board" ? value : null; +} + +interface ParsedStorage { + lastUsed: TaskViewMode | null; + projects: Record; + /** True when the document was written by a newer client. */ + isFutureVersion: boolean; +} + +function readStorage(): ParsedStorage | null { + try { + const raw = window.localStorage.getItem(VIEW_PREFERENCE_STORAGE_KEY); + if (raw === null) return null; + const parsed: unknown = JSON.parse(raw); + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { + return null; + } + const record = parsed as Record; + const version = + typeof record.version === "number" && Number.isFinite(record.version) + ? record.version + : null; + // No older versions shipped; refuse rather than invent fields. + if (version !== null && version < VIEW_PREFERENCE_VERSION) return null; + const projects = + record.projects !== null && + typeof record.projects === "object" && + !Array.isArray(record.projects) + ? (record.projects as Record) + : {}; + return { + lastUsed: asViewMode(record.lastUsed), + projects, + isFutureVersion: version !== null && version > VIEW_PREFERENCE_VERSION, + }; + } catch { + return null; + } +} + +export function loadViewMode(projectId: string): TaskViewMode { + const document = readStorage(); + if (document === null) return DEFAULT_VIEW_MODE; + return ( + asViewMode(document.projects[projectId]) ?? + document.lastUsed ?? + DEFAULT_VIEW_MODE + ); +} + +/** + * Persist the view for one project and make it the fallback for projects the + * user has not opened yet. Refuses to overwrite storage written by a newer + * client so older builds cannot down-convert a future document. + */ +export function storeViewMode(projectId: string, view: TaskViewMode): void { + try { + const existing = readStorage(); + if (existing?.isFutureVersion) return; + const projects: Record = {}; + for (const [id, value] of Object.entries(existing?.projects ?? {})) { + const mode = asViewMode(value); + if (mode !== null) projects[id] = mode; + } + projects[projectId] = view; + const document: StoredDocumentV1 = { + version: VIEW_PREFERENCE_VERSION, + lastUsed: view, + projects, + }; + window.localStorage.setItem( + VIEW_PREFERENCE_STORAGE_KEY, + JSON.stringify(document), + ); + } catch { + // Persistence is best-effort (private mode / storage disabled). + } +} diff --git a/plugins/tasks/views/manage/new-project-dialog.tsx b/plugins/tasks/views/manage/new-project-dialog.tsx index 6d014f5108..f94c3cba35 100644 --- a/plugins/tasks/views/manage/new-project-dialog.tsx +++ b/plugins/tasks/views/manage/new-project-dialog.tsx @@ -150,7 +150,8 @@ export function NewProjectDialog({ open, onOpenChange }: NewProjectDialogProps) linkedBbProjectId: linkedTrimmed === "" ? null : linkedTrimmed, }); onOpenChange(false); - navigation.go({ kind: "project", projectId: project.id, view: "list" }); + // No explicit view: the shell opens the view this client last used. + navigation.go({ kind: "project", projectId: project.id, view: null }); } catch (submitError) { setError(describeCreateProjectError(submitError)); } finally {