diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index dc54b088d8a..70aa5810afc 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -148,6 +148,12 @@ submits. ### Task collection creation filters +Tasks opens All tasks for new users. A saved view or an explicit view in the URL +takes precedence over this default. +The task table orders rows by Last updated, newest first, without highlighting a +column header. Selecting a column sort activates its header and direction arrow; +explicit sort URLs retain that selection. + The task Filters menu also offers **Routine tasks**. It selects tasks with `originKind=routine_execution`, including manually triggered routine runs, even when other automatic tasks are hidden. The routines list's **View all tasks** @@ -429,7 +435,7 @@ Coordinator navigation uses **Chat**, **Tasks**, and **Inbox**. Inbox uses the inbox icon and keeps the existing decision feed, its count, and decision history at `/decisions`. The previous Inbox destination under More is removed. -Tasks defaults to **Mine**, with a saved view per person and company. Mine uses +Tasks defaults to **All tasks**, with a saved view per person and company. Mine uses the previous Inbox's personal participation rules, mixed attention items, read/unread title weights, keyboard shortcuts, archive and undo, mobile swipe-to-archive, grouping, and saved columns. Its existing preferences carry @@ -443,7 +449,7 @@ In the Classic workspace, an opt-in setting (`enableCombinedInboxTasks`, off by folds Inbox into Tasks. The Inbox nav row goes away and its unread badge moves to Tasks. A Views menu on the task list covers the inbox views (Mine, Unread, Blocked, Recent, Everything) and the task-status views (All, Active, Backlog, -Done). Bare `/issues` opens the last-used view, defaulting to Mine; links that +Done). Bare `/issues` opens the last-used view, defaulting to All tasks; links that carry a task filter open All. Old `/inbox` links redirect to the matching view. ### CSV file previews diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index a504d078707..44efe30d8e9 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -2846,6 +2846,11 @@ precedence and dependency failures use the original built-in detector. ### Personal workspace preference +Tasks defaults to All tasks when the viewer has no saved task view. Explicit +view URLs and existing saved task views retain precedence. +Task tables default to Last updated descending without an active sort header; +an explicit column sort selects the header and shows its direction. + Coordinator is the standard workspace for every person. It is not an experiment. Settings → Experimental offers **Classic workspace**, a personal opt-in that defaults off. Existing Coordinator experiment values, including historical off diff --git a/ui/src/components/TaskList.test.tsx b/ui/src/components/TaskList.test.tsx index f7388a29a43..25ddf723a19 100644 --- a/ui/src/components/TaskList.test.tsx +++ b/ui/src/components/TaskList.test.tsx @@ -34,7 +34,28 @@ describe("TaskList progressive rendering", () => { ]; await act(async () => root.render()); expect([...rows()].map(row => row.getAttribute("data-issue-row-details"))).toEqual(["newest-root", "parent", "child-newest", "child-older"]); - expect(host.querySelector('[role="columnheader"][aria-sort="descending"]')?.textContent).toContain("Last updated"); + expect(host.querySelector('[role="columnheader"][aria-sort="descending"]')).toBeNull(); + expect(host.querySelector('.task-list-sort-button[data-active]')).toBeNull(); + expect(host.querySelector('[data-slot="task-list-sort-direction"]')).toBeNull(); + }); + it("highlights Last updated only after selection and returns to the unhighlighted default when reset", async () => { + const items = tasks(2).reverse(); + await render(items); + const orderedIds = () => [...rows()].map(row => row.getAttribute("data-issue-row-details")); + expect(orderedIds()).toEqual(["task-0", "task-1"]); + await act(async () => host.querySelector('button[aria-label="Last updated: sort ascending"]')!.click()); + expect(orderedIds()).toEqual(["task-1", "task-0"]); + expect(host.querySelector('.task-list-sort-button[data-active]')?.textContent).toBe("Last updated"); + expect(host.querySelector('[data-column="lastUpdated"][aria-sort="ascending"]')).not.toBeNull(); + await act(async () => host.querySelector('button[aria-label="Last updated: sort descending"]')!.click()); + expect(orderedIds()).toEqual(["task-0", "task-1"]); + expect(host.querySelector('[data-column="lastUpdated"][aria-sort="descending"]')).not.toBeNull(); + await act(async () => host.querySelector('button[aria-label="Columns"]')!.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }))); + const reset = [...document.body.querySelectorAll('[role="menuitem"]')].find(item => item.textContent === "Reset defaults"); + expect(reset).toBeDefined(); + await act(async () => reset!.click()); + expect(orderedIds()).toEqual(["task-0", "task-1"]); + expect(host.querySelector('.task-list-sort-button[data-active]')).toBeNull(); }); it("honors a creation-time URL sort and reports column sorting", async () => { const items = [ @@ -50,6 +71,7 @@ describe("TaskList progressive rendering", () => { await act(async () => root.render()); expect([...rows()].map(row => row.getAttribute("data-issue-row-details"))).toEqual(["older", "newer"]); + expect(host.querySelector('[data-column="taskId"][aria-sort="ascending"]')).not.toBeNull(); await act(async () => host.querySelector('button[aria-label="Task ID: sort descending"]')!.click()); expect(onSortChange).toHaveBeenLastCalledWith({ column: "taskId", direction: "descending" }); }); diff --git a/ui/src/components/TaskList.tsx b/ui/src/components/TaskList.tsx index 73e4c168cde..b653de661b9 100644 --- a/ui/src/components/TaskList.tsx +++ b/ui/src/components/TaskList.tsx @@ -59,7 +59,7 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask const localKey = useId(); const { columns, order: columnOrder, setColumns, setOrder: setColumnOrder } = useTaskListColumnPreferences(preferenceLocation, initialColumns); const [localSort, setLocalSort] = useState(); - const sort = controlledSort ?? (onSortChange ? undefined : localSort) ?? defaultTaskListSort; + const sort = controlledSort ?? (onSortChange ? undefined : localSort); const setSort = (next: TaskListSort | undefined) => { setLocalSort(next); onSortChange?.(next); diff --git a/ui/src/lib/task-list-views.test.ts b/ui/src/lib/task-list-views.test.ts index e227b579f82..4eb6841d3ba 100644 --- a/ui/src/lib/task-list-views.test.ts +++ b/ui/src/lib/task-list-views.test.ts @@ -46,7 +46,7 @@ describe("task list views", () => { expect(loadTaskListView("key", denied)).toBe("all"); expect(() => saveTaskListView("key", "inbox", denied)).not.toThrow(); }); - it("uses the coordinator's Inbox default for missing, invalid or denied preferences, preserving saved choices", () => { + it("honors an explicit fallback for missing, invalid or denied preferences, preserving saved choices", () => { for (const value of [null, "invalid", "all", "unread", "blocked", "inbox"]) { const storage = { getItem: () => value, setItem: () => {} }; expect(loadTaskListView("key", storage, "inbox")).toBe(value === null || value === "invalid" ? "inbox" : value); diff --git a/ui/src/lib/task-views.test.ts b/ui/src/lib/task-views.test.ts index 1dd594b86b3..3c27c86d796 100644 --- a/ui/src/lib/task-views.test.ts +++ b/ui/src/lib/task-views.test.ts @@ -79,7 +79,7 @@ describe("resolveInitialTaskView", () => { it("falls back to the last-used view on a bare /issues", () => { expect(resolveInitialTaskView(null, false, "backlog")).toBe("backlog"); expect(resolveInitialTaskView("nonsense", false, "backlog")).toBe("backlog"); - expect(resolveInitialTaskView(null, false, DEFAULT_TASK_VIEW)).toBe("mine"); + expect(resolveInitialTaskView(null, false, DEFAULT_TASK_VIEW)).toBe("all"); }); it("opens All tasks when an explicit inbox view carries an organization filter", () => { diff --git a/ui/src/lib/task-views.ts b/ui/src/lib/task-views.ts index 8d5a995cace..c0396b2be5d 100644 --- a/ui/src/lib/task-views.ts +++ b/ui/src/lib/task-views.ts @@ -76,7 +76,7 @@ const VIEWS_BY_KEY = new Map( TASK_VIEW_GROUPS.flatMap((group) => group.views.map((view) => [view.key, view] as const)), ); -export const DEFAULT_TASK_VIEW: TaskViewKey = "mine"; +export const DEFAULT_TASK_VIEW: TaskViewKey = "all"; export const TASK_VIEW_PARAM = "view"; export const TASK_LAST_VIEW_KEY = "paperclip:tasks:last-view"; diff --git a/ui/src/pages/CoordinatorTaskViews.tsx b/ui/src/pages/CoordinatorTaskViews.tsx index 227fda7f520..ca1d9cce125 100644 --- a/ui/src/pages/CoordinatorTaskViews.tsx +++ b/ui/src/pages/CoordinatorTaskViews.tsx @@ -6,7 +6,7 @@ import { useCompany } from "@/context/CompanyContext"; import { useDialogActions } from "@/context/DialogContext"; import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled"; import { loadTaskListView, taskListViewStorageKey } from "@/lib/task-list-views"; -import { ORGANIZATION_SCOPED_PARAMS, normalizeTaskViewKey, resolveInitialTaskView, taskView, type TaskViewKey } from "@/lib/task-views"; +import { DEFAULT_TASK_VIEW, ORGANIZATION_SCOPED_PARAMS, normalizeTaskViewKey, resolveInitialTaskView, taskView, type TaskViewKey } from "@/lib/task-views"; import { Navigate, useLocation, useNavigate, useSearchParams } from "@/lib/router"; import { CoordinatorTasks } from "./CoordinatorTasks"; import { Inbox } from "./Inbox"; @@ -15,10 +15,10 @@ function loadView(key: string, userId: string | null): TaskViewKey { try { const saved = normalizeTaskViewKey(localStorage.getItem(key)); if (saved) return saved; - const previous = userId ? loadTaskListView(taskListViewStorageKey("coordinator", userId), undefined, "inbox") : "inbox"; + const previous = userId ? loadTaskListView(taskListViewStorageKey("coordinator", userId)) : DEFAULT_TASK_VIEW; return previous === "inbox" ? "mine" : previous === "blocked" ? "needs_attention" : previous; } catch { - return "mine"; + return DEFAULT_TASK_VIEW; } } diff --git a/ui/src/pages/CoordinatorTasks.test.tsx b/ui/src/pages/CoordinatorTasks.test.tsx index 3b038bc6692..1ad5885cde5 100644 --- a/ui/src/pages/CoordinatorTasks.test.tsx +++ b/ui/src/pages/CoordinatorTasks.test.tsx @@ -69,16 +69,20 @@ describe("Coordinator tasks using the proposed table", () => { flushSync(() => root.render()); await vi.waitFor(() => expect(host.textContent).not.toContain("Loading tasks")); } - it("starts new users in Inbox and keeps contextual parents of their created or assigned tasks", async () => { + it.each([ + { url: "/issues", view: "all", ids: ["parent", "mine", "assigned", "unrelated"] }, + { url: "/issues?view=inbox", view: "inbox", ids: ["parent", "mine", "assigned"] }, + ])("opens $url in $view, retaining parent context for Inbox", async ({ url, view, ids }) => { mocks.list.mockResolvedValue([ createIssue({ id: "parent", companyId: "company", parentId: null, createdByUserId: "other" }), createIssue({ id: "mine", companyId: "company", parentId: "parent", createdByUserId: "person" }), createIssue({ id: "assigned", companyId: "company", parentId: null, assigneeUserId: "person" }), createIssue({ id: "unrelated", companyId: "company", parentId: null, createdByUserId: "other" }), ]); - await mount("/issues"); - expect(host.querySelector("[data-selected-view]")?.textContent).toBe("inbox"); - expect([...host.querySelectorAll("[data-task-id]")].map(row => row.getAttribute("data-task-id"))).toEqual(["parent", "mine", "assigned"]); + await mount(url); + expect(host.querySelector("[data-selected-view]")?.textContent).toBe(view); + expect([...host.querySelectorAll("[data-task-id]")].map(row => row.getAttribute("data-task-id"))).toEqual(ids); + expect(host.querySelector("output")?.textContent).toBe(`?view=${view}`); }); it.each(taskListViews)("remembers $label when leaving Tasks and returning without a view URL", async ({ value, label }) => { await mount("/issues"); @@ -94,7 +98,7 @@ describe("Coordinator tasks using the proposed table", () => { mocks.list.mockResolvedValue([createIssue({ id: "local", companyId: "company", parentId: null, createdByUserId: "local-board" })]); await mount("/issues"); expect(host.querySelector('[data-task-id="local"]')).not.toBeNull(); - expect(localStorage.getItem(taskListViewStorageKey("coordinator", "local-board"))).toBe("inbox"); + expect(localStorage.getItem(taskListViewStorageKey("coordinator", "local-board"))).toBe("all"); }); it.each(["inbox", "unread"])("keeps %s selected when applying a status filter", async view => { const mine = createIssue({ id: "mine-running", companyId: "company", parentId: null, createdByUserId: "person", status: "in_progress" }); @@ -131,16 +135,16 @@ describe("Coordinator tasks using the proposed table", () => { }); it("waits for the viewer identity before resolving their saved view", async () => { const key = taskListViewStorageKey("coordinator", "person"); - localStorage.setItem(key, "all"); + localStorage.setItem(key, "inbox"); mocks.realFilters = false; mocks.labels = []; mocks.identity = { userId: null, settled: false }; await mount("/issues"); - expect(localStorage.getItem(key)).toBe("all"); + expect(localStorage.getItem(key)).toBe("inbox"); mocks.realFilters = false; mocks.labels = []; mocks.identity = { userId: "person", settled: true }; await mount("/issues"); - expect(host.querySelector("[data-selected-view]")?.textContent).toBe("all"); - expect(localStorage.getItem(key)).toBe("all"); + expect(host.querySelector("[data-selected-view]")?.textContent).toBe("inbox"); + expect(localStorage.getItem(key)).toBe("inbox"); }); it("loads the task index without descriptions or historical diagnostics", async () => { await mount("/issues?view=all"); diff --git a/ui/src/pages/CoordinatorTasks.tsx b/ui/src/pages/CoordinatorTasks.tsx index 86b0273fcef..2e38c33b197 100644 --- a/ui/src/pages/CoordinatorTasks.tsx +++ b/ui/src/pages/CoordinatorTasks.tsx @@ -54,8 +54,8 @@ export function CoordinatorTasks({ viewControl }: { viewControl?: ReactNode } = const urlLabels = JSON.stringify(params.getAll("label").filter(Boolean)); const urlView = params.get("view"); const viewStorageKey = taskListViewStorageKey("coordinator", userId ?? "local-board"); - const [preference, setPreference] = useState(() => ({ key: viewStorageKey, view: loadTaskListView(viewStorageKey, undefined, "inbox") })); - const savedView = preference.key === viewStorageKey ? preference.view : loadTaskListView(viewStorageKey, undefined, "inbox"); + const [preference, setPreference] = useState(() => ({ key: viewStorageKey, view: loadTaskListView(viewStorageKey) })); + const savedView = preference.key === viewStorageKey ? preference.view : loadTaskListView(viewStorageKey); const explicitView = urlView === "needs_attention" ? "blocked" : taskListViews.find(option => option.value === urlView)?.value; const organizationScoped = [...ORGANIZATION_SCOPED_PARAMS, "workspaceId", "status", "label", "sortField", "sortDir"].some(param => params.getAll(param).some(Boolean)); const view: TaskListView = explicitView ?? (organizationScoped || normalizeTaskViewKey(urlView) ? "all" : savedView); diff --git a/ui/src/pages/Issues.coordinator-routing.test.tsx b/ui/src/pages/Issues.coordinator-routing.test.tsx index 7d926b3d0f2..e2d2508b807 100644 --- a/ui/src/pages/Issues.coordinator-routing.test.tsx +++ b/ui/src/pages/Issues.coordinator-routing.test.tsx @@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { beforeEach, expect, it, vi } from "vitest"; import { Issues } from "./Issues"; import { Inbox } from "./Inbox"; +import { taskListViewStorageKey } from "../lib/task-list-views"; const mountedRoutes = vi.hoisted(() => ({ organization: [] as string[] })); @@ -69,23 +70,31 @@ it.each(["mine", "blocked", "recent", "everything"])("keeps shared %s label link } finally { cleanup(); } }); -it("defaults Tasks to Mine and remembers a deliberate organization view", async () => { +it("defaults Tasks to All tasks and remembers a deliberate personal view", async () => { const first = render("/ORG/issues"); try { - await vi.waitFor(() => expect(first.host.querySelector("output")?.textContent).toBe("/ORG/issues?view=mine")); - expect(first.host.textContent).toContain("Tasks Inbox owner:mine"); + await vi.waitFor(() => expect(first.host.querySelector("output")?.textContent).toBe("/ORG/issues?view=all")); + expect(first.host.textContent).toContain("Coordinator tasks"); } finally { first.cleanup(); } - const selected = render("/ORG/issues?view=done"); + const selected = render("/ORG/issues?view=mine"); try { - await vi.waitFor(() => expect(localStorage.getItem(JSON.stringify(["paperclip:coordinator-task-view:v2", "company", "viewer"]))).toBe("done")); + await vi.waitFor(() => expect(localStorage.getItem(JSON.stringify(["paperclip:coordinator-task-view:v2", "company", "viewer"]))).toBe("mine")); } finally { selected.cleanup(); } const returned = render("/ORG/issues"); try { - await vi.waitFor(() => expect(returned.host.querySelector("output")?.textContent).toBe("/ORG/issues?view=done")); - expect(returned.host.textContent).toContain("Coordinator tasks"); + await vi.waitFor(() => expect(returned.host.querySelector("output")?.textContent).toBe("/ORG/issues?view=mine")); + expect(returned.host.textContent).toContain("Tasks Inbox owner:mine"); } finally { returned.cleanup(); } }); +it.each([["inbox", "mine"], ["blocked", "needs_attention"], ["all", "all"]])("preserves a saved %s view from the previous task picker", async (previous, view) => { + localStorage.setItem(taskListViewStorageKey("coordinator", "viewer"), previous); + const { host, cleanup } = render("/ORG/issues"); + try { + await vi.waitFor(() => expect(host.querySelector("output")?.textContent).toBe(`/ORG/issues?view=${view}`)); + } finally { cleanup(); } +}); + it.each(["mine", "blocked", "recent", "everything"])("normalizes a filtered %s link before mounting its task collection", async view => { const { host, cleanup } = render(`/ORG/issues?view=${view}&label=qa&keep=value#saved`); try {