From f8cb4957dcd9f46c67f12e0feac0ba7cd1528a42 Mon Sep 17 00:00:00 2001 From: scotttong Date: Thu, 5 Nov 2026 18:44:59 -0800 Subject: [PATCH] fix(ui): default Tasks to All tasks with a neutral sort header (#19393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Tasks page helps people inspect work across their company. > - A new viewer currently starts in Mine and can miss other company tasks. > - The task table also shows an active sort header before the viewer selects a sort. > - This pull request starts new viewers in All tasks and keeps the default sort header neutral. > - People see company work in newest-update order and keep their saved view choices. ## Linked Issues or Issue Description **What existing behavior does this improve?** The initial task view and the task table's default sort state. **Subsystem affected** ui/ — React board UI. **Current behavior** Tasks opens Mine when the viewer has no saved view. The table highlights Last updated even when the viewer has not selected a column sort. **Proposed behavior** Tasks opens All tasks when no saved view exists. The table keeps newest-updated tasks first without an active header or arrow. A column selection or an explicit sort URL activates the selected header. **Reason and benefit** New viewers can inspect company work immediately. The header distinguishes default ordering from an explicit sort choice. **Breaking changes** The first-visit view changes from Mine to All tasks. Saved views and explicit view URLs keep priority. Existing Inbox and Needs attention preferences retain their meaning. The change does not alter task access or database contracts. **Additional context** Refs #19363. This change builds on the grouped task menu from that merged pull request. It uses the existing menu and design tokens. ## What Changed - Default the Coordinator and combined task surfaces to All tasks. - Preserve saved personal views and migrate previous picker choices. - Keep implicit Last updated descending order separate from active header state. - Test default ordering, explicit sorting, reset behavior, view selection, and preference migration. - Update the product and implementation docs. ## Verification - Passed 118 focused tests across task routing, table sorting, views, and collection preferences. - Passed `pnpm check:token-gates`, `pnpm -r typecheck`, and `pnpm build`. - Full `pnpm test:run` is running. Its result will be updated here. - Greptile passed the current head with 5/5 and no findings or open review threads. - Browser check: All tasks uses the grouped My work and Organization menu. Last updated has no active highlight by default. Selecting the header adds the sort URL and active direction. Removing the explicit sort restores the neutral header. - Manual check: Open Tasks with no saved task view. Confirm All tasks opens. Select Mine, leave Tasks, and return. Confirm Mine remains selected. ## Risks - The new default shows more accessible company tasks on the first visit. Saved view choices remain intact. - Task tables share the sort-state change. Explicit sort links and column clicks retain active styling. Default row order stays newest-updated first. - No schema, API, permission, or execution changes. ## Model Used OpenAI Codex, GPT-6 family. This session does not expose the exact model ID or context-window size. Used reasoning, code editing, terminal commands, and browser verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Scott Tong Co-authored-by: Paperclip --- doc/PRODUCT.md | 10 ++++++-- doc/SPEC-implementation.md | 5 ++++ ui/src/components/TaskList.test.tsx | 24 ++++++++++++++++++- ui/src/components/TaskList.tsx | 2 +- ui/src/lib/task-list-views.test.ts | 2 +- ui/src/lib/task-views.test.ts | 2 +- ui/src/lib/task-views.ts | 2 +- ui/src/pages/CoordinatorTaskViews.tsx | 6 ++--- ui/src/pages/CoordinatorTasks.test.tsx | 22 ++++++++++------- ui/src/pages/CoordinatorTasks.tsx | 4 ++-- .../pages/Issues.coordinator-routing.test.tsx | 23 ++++++++++++------ 11 files changed, 74 insertions(+), 28 deletions(-) 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 {