fix(ui): default Tasks to All tasks with a neutral sort header (#19393)

## 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 <scott@scottsmbpm5max.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
authored and GitHub committed 2026-11-05 18:44:59 -08:00
1 parent 5ab7b78f2b
commit f8cb4957dc
11 files changed
+74 -28

No files matched your search

+8 -2
View File
@@ -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
+5
View File
@@ -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
+23 -1
View File
@@ -34,7 +34,28 @@ describe("TaskList progressive rendering", () => {
];
await act(async () => root.render(<TooltipProvider><TaskList items={items} onSortChange={controlled ? vi.fn() : undefined} /></TooltipProvider>));
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<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('button[aria-label="Columns"]')!.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })));
const reset = [...document.body.querySelectorAll<HTMLElement>('[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(<TooltipProvider><TaskList items={items}
sort={{ column: "taskId", direction: "ascending" }} onSortChange={onSortChange} /></TooltipProvider>));
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<HTMLButtonElement>('button[aria-label="Task ID: sort descending"]')!.click());
expect(onSortChange).toHaveBeenLastCalledWith({ column: "taskId", direction: "descending" });
});
+1 -1
View File
@@ -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<TaskListSort>();
const sort = controlledSort ?? (onSortChange ? undefined : localSort) ?? defaultTaskListSort;
const sort = controlledSort ?? (onSortChange ? undefined : localSort);
const setSort = (next: TaskListSort | undefined) => {
setLocalSort(next);
onSortChange?.(next);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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", () => {
+1 -1
View File
@@ -76,7 +76,7 @@ const VIEWS_BY_KEY = new Map<TaskViewKey, TaskView>(
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";
+3 -3
View File
@@ -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;
}
}
+13 -9
View File
@@ -69,16 +69,20 @@ describe("Coordinator tasks using the proposed table", () => {
flushSync(() => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[url]}><CoordinatorTasks /></MemoryRouter></QueryClientProvider>));
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");
+2 -2
View File
@@ -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);
@@ -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 {