mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
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:
11 files changed
+74
-28
No files matched your search
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user