From 946735d03a55c1fb4cc5434d6bf1d87742daa9df Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:32:40 -0600 Subject: [PATCH] Persist task sorting in URLs and show newest routine tasks first Save task sort choices in URLs across task views. Open routine tasks by creation time, newest first, with stable sorting before pagination. Co-Authored-By: Paperclip --- doc/PRODUCT.md | 10 ++- doc/SPEC-implementation.md | 11 ++- maps/agent-access-map/tasks.md | 7 +- maps/feature-map/routines.md | 2 +- maps/feature-map/tasks.md | 4 +- .../issues-list-query-parsing.test.ts | 26 +++++++ server/src/routes/issues.ts | 6 +- server/src/services/issues.ts | 10 ++- ui/src/api/issues.ts | 2 +- ui/src/components/IssuesList.test.tsx | 55 +++++++++++++++ ui/src/components/IssuesList.tsx | 70 ++++++++++--------- ui/src/components/LegacyIssuesList.tsx | 68 +++++++++--------- ui/src/components/TaskList.test.tsx | 29 ++++++++ ui/src/components/TaskList.tsx | 36 +++++----- ui/src/hooks/useTaskListCollection.ts | 9 ++- ui/src/lib/issue-sort.ts | 56 +++++++++++++++ ui/src/lib/task-list.ts | 34 ++++++++- ui/src/pages/CoordinatorTasks.test.tsx | 45 +++++++++++- ui/src/pages/CoordinatorTasks.tsx | 32 +++++++-- ui/src/pages/Issues.routine-filter.test.tsx | 33 ++++++++- ui/src/pages/Issues.tsx | 27 ++++++- ui/src/pages/Routines.test.tsx | 2 +- ui/src/pages/Routines.tsx | 2 +- 23 files changed, 459 insertions(+), 117 deletions(-) create mode 100644 ui/src/lib/issue-sort.ts diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 230d7e38cb3..62390a783de 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -146,9 +146,15 @@ choice stays selected while task data refreshes. 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** -link opens `/issues?view=all&originKind=routine_execution` with that checkbox +link opens `/issues?view=all&originKind=routine_execution&sortField=created&sortDir=desc` with that checkbox selected. The URL retains the selection across reloads and task navigation. -Search and pagination apply the routine origin filter on the server. +Sort controls save `sortField` and `sortDir` in the URL. URL sorting overrides +saved collection sorting, and the routines link opens newest-created tasks first. +Shared sort links work across Coordinator, Streamlined UI, and Classic UI, including +sort fields without a visible column in the selected interface. Search relevance +and board-column membership keep their existing server order. +Search and pagination apply the routine origin filter on the server. Creation-time +sorting also runs before pagination, with task ID as a stable tie-breaker. Tasks and project lists hide agent-created and system-created tasks by default. The Filters menu's **Show agent-created tasks** checkbox includes routine runs, diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 135b70dcf47..810faf75d9d 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -2713,9 +2713,16 @@ its upstream authors; operators can pause updates to review changes manually. 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** -link opens `/issues?view=all&originKind=routine_execution` with that checkbox +link opens `/issues?view=all&originKind=routine_execution&sortField=created&sortDir=desc` with that checkbox selected. The URL retains the selection across reloads and task navigation. -Search and pagination apply the routine origin filter on the server. +Sort controls save `sortField` and `sortDir` in the URL. URL sorting overrides +saved collection sorting, and the routines link opens newest-created tasks first. +Shared sort links retain their order across task interfaces. Workflow sorting +requests the existing authorized dependency-edge projection. Search and board +queries keep their existing server order. Blocked-attention lists reject +`sortField=created` with `422` because they use their own activity order. +Search and pagination apply the routine origin filter on the server. Creation-time +sorting also runs before pagination, with task ID as a stable tie-breaker. Tasks and project collections default to hiding agent/system-created tasks and provide a saved **Show agent-created tasks** filter. Classify creation using diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md index 6ba136f9091..331a2694955 100644 --- a/maps/agent-access-map/tasks.md +++ b/maps/agent-access-map/tasks.md @@ -74,7 +74,7 @@ API: `present` — GET /api/companies/{companyId}/issues; optional view=compact. Agent access: `partial` — REST/call_api full query; CLI issue list exposes status/agent/project and local --match on one fetched page; native search_tasks query up to 500 characters, statuses, limit 1..200 (default 50), and a nextCursor in ascending task-ID order. Responses expose effective limit, returnedCount and hasMore. Exhaust nextCursor with the same filters before treating an inventory as complete. Each page checks current access; concurrent task changes require a fresh scan. Project/label/workspace knobs remain REST-only. -Policy: Company/read privacy filtering and inaccessible edge redaction; task-bridge company list denied. Default 500, maximum 1000, offset>=0, sortField updated|id, sortDir asc|desc, afterId. Supports status/assignee/participant/project/workspace/parent/descendant/label/origin/text/date and routine/plugin/blocker query groups. includeAgentCreatedTasks=false filters independent roots before search/pagination; omission retains agent/API behavior. includeParticipatedTasks and user me filters require board identity. Compact ETag is actor-private; 429 is not empty results. +Policy: Company/read privacy filtering and inaccessible edge redaction; task-bridge company list denied. Default 500, maximum 1000, offset>=0, sortField updated|created|id, sortDir asc|desc, afterId. Blocked attention lists reject created and id ordering with 422. Supports status/assignee/participant/project/workspace/parent/descendant/label/origin/text/date and routine/plugin/blocker query groups. includeAgentCreatedTasks=false filters independent roots before search/pagination; omission retains agent/API behavior. includeParticipatedTasks and user me filters require board identity. Compact ETag is actor-private; 429 is not empty results. Evidence: `source-only` — reviewed at `3bbc2104b65caf9f7763938a01260a5673e12f7a`; [issues.ts](../../server/src/routes/issues.ts), [issues.ts](../../server/src/services/issues.ts), [paperclip-runner-tool-authority.ts](../../server/src/services/native-runtime/paperclip-runner-tool-authority.ts), [issue.ts](../../cli/src/commands/client/issue.ts), [issues-list-query-parsing.test.ts](../../server/src/__tests__/issues-list-query-parsing.test.ts), [issues-creation-visibility.test.ts](../../server/src/__tests__/issues-creation-visibility.test.ts), [issue-list-assignee-filter-routes.test.ts](../../server/src/__tests__/issue-list-assignee-filter-routes.test.ts), [private-json-etag.test.ts](../../server/src/__tests__/private-json-etag.test.ts). No live execution claimed. @@ -120,7 +120,10 @@ Agent access: `not-applicable` — no agent layout API; permitted underlying tas Policy: IssuesList loads/saves browser preferences by company/collection, with separate agent-created visibility. The Tasks page also owns a routine-only checkbox and `originKind=routine_execution` URL state; it uses the existing -company-scoped task-list origin filter before pagination. Contextual ancestors/descendants do not become unrelated independent results. No shared server view preference inferred. +company-scoped task-list origin filter before pagination. Sort controls write +`sortField` and `sortDir` to the URL; explicit URL sorting overrides browser +preferences. `created` sorting runs on the server before pagination, with ID as +a stable tie-breaker. The routines task link requests `created` descending. Contextual ancestors/descendants do not become unrelated independent results. No shared server view preference inferred. Evidence: `source-only` — reviewed at `3bbc2104b65caf9f7763938a01260a5673e12f7a`; [IssuesList.tsx](../../ui/src/components/IssuesList.tsx), [Issues.tsx](../../ui/src/pages/Issues.tsx), [Issues.test.tsx](../../ui/src/pages/Issues.test.tsx). No live execution claimed. diff --git a/maps/feature-map/routines.md b/maps/feature-map/routines.md index 75f8e21a926..bb830f726a8 100644 --- a/maps/feature-map/routines.md +++ b/maps/feature-map/routines.md @@ -30,7 +30,7 @@ Open `/routines`, create one, and inspect `/routines/:routineId` and its edit se Use the routine's trigger controls and Runs/Recent Runs/Audit links; lists also offer Run now. **View all tasks** opens the Tasks list with **Routine tasks** -selected, using `/issues?view=all&originKind=routine_execution`. Uncheck the +selected, using `/issues?view=all&originKind=routine_execution&sortField=created&sortDir=desc`. Uncheck the filter to return to other tasks. **History** records configuration versions, not executions. ## Driving it with Playwright and the Paperclip CLI diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md index 2f335db00d6..934b1d50ce9 100644 --- a/maps/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -64,7 +64,9 @@ terminal states. Inspect due/overdue copy separately; no runner is implied. Open `/issues` or a project’s Issues view; legacy `/tasks` links redirect. Filters → **Routine tasks** selects tasks created by routine runs. The -`/issues?view=all&originKind=routine_execution` link opens that selection. +`/issues?view=all&originKind=routine_execution&sortField=created&sortDir=desc` link opens that selection with newest-created tasks first. Sort controls update +`sortField` and `sortDir` in the URL. Reloads and task return links keep that order. +Shared sort links also retain their order when opened in another task interface. ### `task-detail` diff --git a/server/src/__tests__/issues-list-query-parsing.test.ts b/server/src/__tests__/issues-list-query-parsing.test.ts index 91024b93d05..9a369ce0afe 100644 --- a/server/src/__tests__/issues-list-query-parsing.test.ts +++ b/server/src/__tests__/issues-list-query-parsing.test.ts @@ -56,4 +56,30 @@ describeEmbeddedPostgres("issue list status query parsing", () => { it("returns every status when ?status is absent", async () => { expect(await listStatuses("")).toEqual(["done", "in_progress", "todo"]); }); + it.each(["", "&view=compact", "&view=compact&projection=index"])("rejects unsupported creation sorting for blocked attention (%s)", async projection => { + const { companyId, actor } = await seedCompanyWithBoardAccess(ctx.db, "Blocked sorting"); + await request(routeApp(ctx.db, actor, issueRoutes)) + .get(`/api/companies/${companyId}/issues?attention=blocked&sortField=created&sortDir=asc${projection}`) + .expect(422); + }); + + it.each(["", "&view=compact", "&view=compact&projection=index"])("sorts routine tasks by creation time before pagination (%s)", async (projection) => { + const { companyId, actor } = await seedCompanyWithBoardAccess(ctx.db, "Creation sorting"); + const ids = Array.from({ length: 4 }, (_, index) => `00000000-0000-4000-8000-00000000000${index}`); + await ctx.db.insert(issues).values([ + ...ids.map((id, index) => ({ id, companyId, title: "Routine sorting", originKind: "routine_execution", status: "todo", + createdAt: new Date(index < 2 ? "2026-01-01" : "2026-02-01"), + updatedAt: new Date(index < 2 ? "2026-03-01" : "2026-02-01"), priority: index < 2 ? "critical" : "low" })), + { companyId, title: "Routine sorting manual", originKind: "manual", createdAt: new Date("2026-04-01") }, + ]); + const app = routeApp(ctx.db, actor, issueRoutes); + const path = `/api/companies/${companyId}/issues?originKind=routine_execution&includeRoutineExecutions=true&sortField=created&limit=2${projection}`; + for (const sortDir of ["asc", "desc"]) { + const first = await request(app).get(`${path}&sortDir=${sortDir}`).expect(200); + const second = await request(app).get(`${path}&sortDir=${sortDir}&offset=2`).expect(200); + expect([...first.body, ...second.body].map((issue: { id: string }) => issue.id)).toEqual(sortDir === "asc" ? ids : [...ids].reverse()); + } + await request(app).get(`${path}&sortDir=invalid`).expect(400); + }); + }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index b63d1135afa..3fba75d406f 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -8549,10 +8549,10 @@ export function issueRoutes( res.status(400).json({ error: "offset must be a non-negative integer" }); return; } - if (sortField !== undefined && sortField !== "updated" && sortField !== "id") { + if (sortField !== undefined && sortField !== "updated" && sortField !== "created" && sortField !== "id") { res .status(400) - .json({ error: "sortField must be 'updated' or 'id' when provided" }); + .json({ error: "sortField must be 'updated', 'created', or 'id' when provided" }); return; } if (sortDir !== undefined && sortDir !== "asc" && sortDir !== "desc") { @@ -8659,7 +8659,7 @@ export function issueRoutes( q: req.query.q as string | undefined, limit, offset, - sortField: sortField === "updated" || sortField === "id" ? sortField : undefined, + sortField: sortField === "updated" || sortField === "created" || sortField === "id" ? sortField : undefined, afterId: req.query.afterId as string | undefined, sortDir: sortDir === "asc" || sortDir === "desc" ? sortDir : undefined, updatedSince: rawUpdatedSince, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index d390be0475d..9eb64654b46 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1947,7 +1947,7 @@ export interface IssueFilters { q?: string; limit?: number; offset?: number; - sortField?: "updated" | "id"; + sortField?: "updated" | "created" | "id"; afterId?: string; sortDir?: "asc" | "desc"; /** ISO 8601 timestamp — only return issues with updatedAt strictly after this value. */ @@ -3221,6 +3221,11 @@ function issueListOrderBy( }, ) { if (sortField === "id") return [sortDir === "desc" ? desc(issues.id) : asc(issues.id)]; + if (sortField === "created") { + return sortDir === "asc" + ? [asc(issues.createdAt), asc(issues.id)] + : [desc(issues.createdAt), desc(issues.id)]; + } const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId); if (sortField === "updated") { const activityOrder = @@ -8206,6 +8211,9 @@ export function issueService(db: Db) { if (filters?.sortField === "id" && filters.attention) { throw unprocessable("ID ordering is not supported for blocked attention lists"); } + if (filters?.sortField === "created" && filters.attention) { + throw unprocessable("Creation-time ordering is not supported for blocked attention lists"); + } if (filters?.afterId !== undefined && ( !isUuidLike(filters.afterId) || filters.sortField !== "id" || filters.sortDir !== "asc" || (filters.offset ?? 0) !== 0 diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index afe23d44d09..739487a577c 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -221,7 +221,7 @@ export type IssueListFilters = { q?: string; limit?: number; offset?: number; - sortField?: "updated" | "id"; + sortField?: "updated" | "created" | "id"; afterId?: string; sortDir?: "asc" | "desc"; }; diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 87d8033e904..db19fc0fdd8 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -15,6 +15,7 @@ import { import { TooltipProvider } from "@/components/ui/tooltip"; import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; import { taskCollectionPreferencesStorageKey } from "../lib/task-collection-preferences"; +import { readIssueSortParams } from "../lib/issue-sort"; const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1", @@ -406,6 +407,60 @@ describe("IssuesList", () => { queryClient.clear(); }); + it.each([true, false])("URL sorting overrides saved sorting and handles direction clicks (streamlined=%s)", async (enableStreamlinedUi) => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi, enableExternalObjects: false }); + localStorage.setItem("paperclip:test-issues:company-1", JSON.stringify({ sortField: "title", sortDir: "asc" })); + const issues = [ + createIssue({ id: "older", title: "Alpha older task", createdAt: new Date("2026-01-01") }), + createIssue({ id: "newer", title: "Zulu newer task", createdAt: new Date("2026-02-01") }), + ]; + const onSortChange = vi.fn(); + const { root, queryClient } = renderWithQueryClient( undefined} />, container); + const titles = () => [...container.querySelectorAll('[data-testid="issue-row"]')].map(row => row.textContent); + await waitForAssertion(() => expect(titles()[0]).toContain("Zulu newer task")); + await act(() => container.querySelector('button[title="Sort"]')!.click()); + await waitForAssertion(() => expect([...document.querySelectorAll("button")].some(button => button.textContent?.startsWith("Created"))).toBe(true)); + await act(() => [...document.querySelectorAll("button")].find(button => button.textContent?.startsWith("Created"))!.click()); + expect(onSortChange).toHaveBeenLastCalledWith({ sortField: "created", sortDir: "asc" }); + await act(() => root.render( + undefined} /> + )); + await waitForAssertion(() => expect(titles()[0]).toContain("Alpha older task")); + await act(() => root.unmount()); queryClient.clear(); + }); + + it.each([true, false])("honors a Coordinator task-ID link in the other task interfaces (streamlined=%s)", async (enableStreamlinedUi) => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi }); + const issues = [ + createIssue({ id: "ten", identifier: "PAP-10", title: "Alpha", updatedAt: new Date("2026-03-01") }), + createIssue({ id: "two", identifier: "PAP-2", title: "Zulu", updatedAt: new Date("2026-01-01") }), + ]; + const { root, queryClient } = renderWithQueryClient( undefined} />, container); + await waitForAssertion(() => expect([...container.querySelectorAll('[data-testid="issue-row"]')].map(row => row.textContent)) + .toEqual([expect.stringContaining("Zulu"), expect.stringContaining("Alpha")])); + await act(() => root.unmount()); queryClient.clear(); + }); + + it.each([true, false])("sorts shared status links by Monitoring display status (streamlined=%s)", async (enableStreamlinedUi) => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableStreamlinedUi }); + const issues = [ + createIssue({ id: "waiting", title: "Waiting for CI", status: "in_progress", displayStatus: "monitoring" }), + createIssue({ id: "working", title: "Active work", status: "in_progress" }), + createIssue({ id: "todo", title: "Next task", status: "todo" }), + ]; + const { root, queryClient } = renderWithQueryClient( undefined} />, container); + await waitForAssertion(() => expect([...container.querySelectorAll('[data-testid="issue-row"]')].map(row => row.textContent)) + .toEqual([expect.stringContaining("Active work"), expect.stringContaining("Waiting for CI"), expect.stringContaining("Next task")])); + await act(() => root.unmount()); queryClient.clear(); + }); + it("uses the master list and legacy persistence when Streamlined UI is off", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false, diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 1a8816d0b15..4e11dba0189 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,3 +1,4 @@ +import { ISSUE_SORT_FIELDS, sortIssues, type IssueSort, type IssueSortField } from "../lib/issue-sort"; import { useIssueRowDetails } from "../hooks/useIssueRowDetails"; import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode"; import { IssueRowDetailsNotice } from "./IssueRowDetailsNotice"; @@ -94,7 +95,6 @@ import { getInboxKeyboardSelectionIndex } from "../lib/inbox"; import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts"; import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults"; import { statusBadge } from "../lib/status-colors"; -import { workflowSort } from "../lib/workflow-sort"; import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff"; import { loadTaskCollectionPreferences, @@ -162,7 +162,7 @@ const progressSegmentClasses: Record = { /* ── View state ── */ -export type IssueSortField = "status" | "priority" | "title" | "created" | "updated" | "workflow"; +export type { IssueSortField } from "../lib/issue-sort"; export type BoardCardDensity = "auto" | "compact" | "comfortable"; export type BoardColdLaneMode = "auto" | "collapsed" | "expanded"; export type BoardColumnPageSize = KanbanColumnPageSize; @@ -219,7 +219,7 @@ function normalizeIssueViewState(value: unknown): IssueViewState { // A saved workspace selection has no filter control. Drop it on load. // `initialWorkspaces` from the page address is applied after this. workspaces: [], - sortField: ["status", "priority", "title", "created", "updated", "workflow"].includes(parsed.sortField ?? "") + sortField: ISSUE_SORT_FIELDS.includes(parsed.sortField as IssueSortField) ? parsed.sortField as IssueSortField : defaultViewState.sortField, sortDir: parsed.sortDir === "asc" ? "asc" : "desc", @@ -290,31 +290,6 @@ function loadIssueCollectionPreferences( }); } -function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { - if (state.sortField === "workflow") { - const ordered = workflowSort(issues); - return state.sortDir === "desc" ? [...ordered].reverse() : ordered; - } - const sorted = [...issues]; - const dir = state.sortDir === "asc" ? 1 : -1; - sorted.sort((a, b) => { - switch (state.sortField) { - case "status": - return dir * (issueStatusOrder.indexOf(a.displayStatus ?? a.status) - issueStatusOrder.indexOf(b.displayStatus ?? b.status)); - case "priority": - return dir * (issuePriorityOrder.indexOf(a.priority) - issuePriorityOrder.indexOf(b.priority)); - case "title": - return dir * a.title.localeCompare(b.title); - case "created": - return dir * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); - case "updated": - return dir * (new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()); - default: - return 0; - } - }); - return sorted; -} // Only recency sorts (newest first) get date separators — for any other // sort/direction the boundaries would be meaningless. @@ -493,8 +468,11 @@ interface IssuesListProps { initialStatuses?: string[]; routineTasksOnly?: boolean; onRoutineTasksOnlyChange?: (checked: boolean) => void; + /** URL sorting overrides the saved collection preference when supplied. */ + sort?: IssueSort; + onSortChange?: (sort: IssueSort) => void; initialSearch?: string; - searchFilters?: Omit; + searchFilters?: Omit; searchWithinLoadedIssues?: boolean; baseCreateIssueDefaults?: Record; createIssueLabel?: string; @@ -740,6 +718,8 @@ function StreamlinedIssuesList({ initialStatuses, routineTasksOnly, onRoutineTasksOnlyChange, + sort, + onSortChange, initialSearch, searchFilters, searchWithinLoadedIssues = false, @@ -836,7 +816,11 @@ function StreamlinedIssuesList({ ), ); // The Tasks URL owns this filter; other collections keep their saved state. - const viewState = useMemo(() => routineTasksOnly === undefined ? savedViewState : { ...savedViewState, routineTasksOnly }, [savedViewState, routineTasksOnly]); + const viewState = useMemo(() => ({ + ...savedViewState, + ...sort, + ...(routineTasksOnly === undefined ? {} : { routineTasksOnly }), + }), [savedViewState, sort, routineTasksOnly]); const showAgentCreatedTasks = useShowAgentCreatedTasks(selectedCompanyId, viewStateKey); useEffect(() => { if (!enableAgentCreatedVisibilityFilter) return; @@ -893,8 +877,14 @@ function StreamlinedIssuesList({ const updateView = useCallback((patch: Partial) => { if (patch.routineTasksOnly !== undefined) onRoutineTasksOnlyChange?.(patch.routineTasksOnly); + if (patch.sortField !== undefined || patch.sortDir !== undefined) { + onSortChange?.({ + sortField: patch.sortField ?? viewState.sortField, + sortDir: patch.sortDir ?? viewState.sortDir, + }); + } setViewState((prev) => { - const next = { ...prev, ...patch }; + const next = { ...prev, ...sort, ...patch }; saveTaskCollectionPreferences(preferenceLocation, { viewState: next, columns: visibleIssueColumns, @@ -908,6 +898,10 @@ function StreamlinedIssuesList({ preferenceLocation.legacyViewStorageKey, visibleIssueColumns, onRoutineTasksOnlyChange, + onSortChange, + sort, + viewState.sortField, + viewState.sortDir, ]); useEffect(() => { @@ -1237,7 +1231,16 @@ function StreamlinedIssuesList({ liveIssueIds, issueFilterContext, ); - return sortIssues(filteredByControls, viewState); + const byId = new Map(searchScopedIssues.map(issue => [issue.id, issue])); + return sortIssues(filteredByControls, viewState, issue => { + const parent = issue.parentId ? byId.get(issue.parentId) : null; + return { + createdBy: issue.createdByAgentId ? agentName(issue.createdByAgentId) ?? "Unknown agent" : companyUserLabelMap.get(issue.createdByUserId ?? "") ?? "Board", + assignee: issue.assigneeAgentId ? agentName(issue.assigneeAgentId) ?? "Unknown agent" : companyUserLabelMap.get(issue.assigneeUserId ?? "") ?? null, + project: projectById.get(issue.projectId ?? "")?.name, + parentTask: parent ? `${parent.identifier} ${parent.title}` : null, + }; + }); }, [ searchScopedIssues, viewState, @@ -1245,6 +1248,9 @@ function StreamlinedIssuesList({ enableAgentCreatedVisibilityFilter, liveIssueIds, issueFilterContext, + agentName, + companyUserLabelMap, + projectById, ]); const progressSummary = useMemo( diff --git a/ui/src/components/LegacyIssuesList.tsx b/ui/src/components/LegacyIssuesList.tsx index e908370342f..63408cdbfb1 100644 --- a/ui/src/components/LegacyIssuesList.tsx +++ b/ui/src/components/LegacyIssuesList.tsx @@ -1,3 +1,4 @@ +import { ISSUE_SORT_FIELDS, sortIssues, type IssueSort, type IssueSortField } from "../lib/issue-sort"; import { saveTaskCollectionPreferences } from "../lib/task-collection-preferences"; import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode"; import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; @@ -89,7 +90,6 @@ import { getInboxKeyboardSelectionIndex } from "../lib/inbox"; import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget } from "../lib/keyboardShortcuts"; import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults"; import { statusBadge } from "../lib/status-colors"; -import { workflowSort } from "../lib/workflow-sort"; import { isSuccessfulRunHandoffRequired } from "../lib/successful-run-handoff"; import { deriveOriginatingActor, ISSUE_STATUSES, ISSUE_DISPLAY_STATUSES, type Issue, type IssueDisplayStatus, type IssueStatus, type Project } from "@paperclipai/shared"; import { Badge } from "@/components/ui/badge"; @@ -151,7 +151,7 @@ const progressSegmentClasses: Record = { /* ── View state ── */ -export type IssueSortField = "status" | "priority" | "title" | "created" | "updated" | "workflow"; +export type { IssueSortField } from "../lib/issue-sort"; export type BoardCardDensity = "auto" | "compact" | "comfortable"; export type BoardColdLaneMode = "auto" | "collapsed" | "expanded"; export type BoardColumnPageSize = KanbanColumnPageSize; @@ -207,6 +207,8 @@ function getViewState(key: string): IssueViewState { ...parsed, ...normalizeIssueFilterState(parsed), workspaces: [], + sortField: ISSUE_SORT_FIELDS.includes(parsed.sortField) ? parsed.sortField : defaultViewState.sortField, + sortDir: parsed.sortDir === "asc" ? "asc" : "desc", boardCardDensity: normalizeBoardCardDensity(parsed.boardCardDensity), boardColdLaneMode: normalizeBoardColdLaneMode(parsed.boardColdLaneMode), boardColumnPageSize: normalizeBoardColumnPageSize(parsed.boardColumnPageSize), @@ -273,31 +275,6 @@ function loadIssueColumns(key: string): InboxIssueColumn[] { } } -function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { - if (state.sortField === "workflow") { - const ordered = workflowSort(issues); - return state.sortDir === "desc" ? [...ordered].reverse() : ordered; - } - const sorted = [...issues]; - const dir = state.sortDir === "asc" ? 1 : -1; - sorted.sort((a, b) => { - switch (state.sortField) { - case "status": - return dir * (issueStatusOrder.indexOf(a.displayStatus ?? a.status) - issueStatusOrder.indexOf(b.displayStatus ?? b.status)); - case "priority": - return dir * (issuePriorityOrder.indexOf(a.priority) - issuePriorityOrder.indexOf(b.priority)); - case "title": - return dir * a.title.localeCompare(b.title); - case "created": - return dir * (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); - case "updated": - return dir * (new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()); - default: - return 0; - } - }); - return sorted; -} const AGE_BUCKET_DAY_MS = 24 * 60 * 60 * 1000; const AGE_BUCKET_WEEK_MS = 7 * AGE_BUCKET_DAY_MS; @@ -471,8 +448,11 @@ interface IssuesListProps { initialWorkspaces?: string[]; routineTasksOnly?: boolean; onRoutineTasksOnlyChange?: (checked: boolean) => void; + /** URL sorting overrides the saved collection preference when supplied. */ + sort?: IssueSort; + onSortChange?: (sort: IssueSort) => void; initialSearch?: string; - searchFilters?: Omit; + searchFilters?: Omit; searchWithinLoadedIssues?: boolean; baseCreateIssueDefaults?: Record; createIssueLabel?: string; @@ -689,6 +669,8 @@ export function IssuesList({ initialWorkspaces, routineTasksOnly, onRoutineTasksOnlyChange, + sort, + onSortChange, initialSearch, searchFilters, searchWithinLoadedIssues = false, @@ -764,7 +746,11 @@ export function IssuesList({ getInitialWorkspaceViewState(scopedKey, initialAssignees, initialWorkspaces, defaultSortField), ); // The Tasks URL owns this filter; other collections keep their saved state. - const viewState = useMemo(() => routineTasksOnly === undefined ? savedViewState : { ...savedViewState, routineTasksOnly }, [savedViewState, routineTasksOnly]); + const viewState = useMemo(() => ({ + ...savedViewState, + ...sort, + ...(routineTasksOnly === undefined ? {} : { routineTasksOnly }), + }), [savedViewState, sort, routineTasksOnly]); const showAgentCreatedTasks = useShowAgentCreatedTasks(selectedCompanyId, viewStateKey); useEffect(() => { if (!enableAgentCreatedVisibilityFilter) return; @@ -806,15 +792,21 @@ export function IssuesList({ const updateView = useCallback((patch: Partial) => { if (patch.routineTasksOnly !== undefined) onRoutineTasksOnlyChange?.(patch.routineTasksOnly); + if (patch.sortField !== undefined || patch.sortDir !== undefined) { + onSortChange?.({ + sortField: patch.sortField ?? viewState.sortField, + sortDir: patch.sortDir ?? viewState.sortDir, + }); + } setViewState((prev) => { - const next = { ...prev, ...patch }; + const next = { ...prev, ...sort, ...patch }; saveTaskCollectionPreferences({ companyId: selectedCompanyId ?? "__unscoped__", collectionKey: viewStateKey, legacyViewStorageKey: scopedKey, legacyColumnsStorageKey: getIssueColumnsStorageKey(scopedKey), }, { viewState: next, columns: visibleIssueColumns }); return next; }); - }, [scopedKey, selectedCompanyId, viewStateKey, visibleIssueColumns, onRoutineTasksOnlyChange]); + }, [scopedKey, selectedCompanyId, viewStateKey, visibleIssueColumns, onRoutineTasksOnlyChange, onSortChange, sort, viewState.sortField, viewState.sortDir]); useEffect(() => { if (!experimentalSettingsLoaded || externalObjectsEnabled || viewState.externalObjectStatuses.length === 0) return; @@ -1135,7 +1127,16 @@ export function IssuesList({ liveIssueIds, issueFilterContext, ); - return sortIssues(filteredByControls, viewState); + const byId = new Map(searchScopedIssues.map(issue => [issue.id, issue])); + return sortIssues(filteredByControls, viewState, issue => { + const parent = issue.parentId ? byId.get(issue.parentId) : null; + return { + createdBy: issue.createdByAgentId ? agentName(issue.createdByAgentId) ?? "Unknown agent" : companyUserLabelMap.get(issue.createdByUserId ?? "") ?? "Board", + assignee: issue.assigneeAgentId ? agentName(issue.assigneeAgentId) ?? "Unknown agent" : companyUserLabelMap.get(issue.assigneeUserId ?? "") ?? null, + project: projectById.get(issue.projectId ?? "")?.name, + parentTask: parent ? `${parent.identifier} ${parent.title}` : null, + }; + }); }, [ searchScopedIssues, viewState, @@ -1143,6 +1144,9 @@ export function IssuesList({ enableAgentCreatedVisibilityFilter, liveIssueIds, issueFilterContext, + agentName, + companyUserLabelMap, + projectById, ]); const progressSummary = useMemo( diff --git a/ui/src/components/TaskList.test.tsx b/ui/src/components/TaskList.test.tsx index 460b93bedd7..af4a4849675 100644 --- a/ui/src/components/TaskList.test.tsx +++ b/ui/src/components/TaskList.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createIssue } from "../../storybook/fixtures/paperclipData"; import { TaskList, type TaskListItem } from "./TaskList"; import { TooltipProvider } from "./ui/tooltip"; +import { readTaskListSortParams } from "../lib/task-list"; vi.mock("./IssueRow", () => ({ IssueRow: ({ issue, leadingControl }: { issue: { id: string; title: string }; leadingControl?: ReactNode }) =>
{leadingControl}
})); @@ -24,6 +25,34 @@ describe("TaskList progressive rendering", () => { async function more() { await act(async () => Array.from(host.querySelectorAll("button")).find(b => b.textContent === "Show more tasks")!.click()); } + it("honors a creation-time URL sort and reports column sorting", async () => { + const items = [ + { issue: createIssue({ id: "older", title: "Older", identifier: "PAP-1", createdAt: new Date(0) }) }, + { issue: createIssue({ id: "newer", title: "Newer", identifier: "PAP-2", createdAt: new Date(1000) }) }, + ]; + const onSortChange = vi.fn(); + await act(async () => root.render()); + expect([...rows()].map(row => row.getAttribute("data-issue-row-details"))).toEqual(["newer", "older"]); + await act(async () => host.querySelector('button[aria-label="Task ID: sort ascending"]')!.click()); + expect(onSortChange).toHaveBeenLastCalledWith({ column: "taskId", direction: "ascending" }); + await act(async () => root.render()); + expect([...rows()].map(row => row.getAttribute("data-issue-row-details"))).toEqual(["older", "newer"]); + await act(async () => host.querySelector('button[aria-label="Task ID: sort descending"]')!.click()); + expect(onSortChange).toHaveBeenLastCalledWith({ column: "taskId", direction: "descending" }); + }); + + it.each(["status", "workflow"])("honors a Classic %s sort link", async field => { + const items = [ + { issue: createIssue({ id: "later", identifier: "PAP-1", status: "done", createdAt: new Date("2026-02-01") }) }, + { issue: createIssue({ id: "earlier", identifier: "PAP-2", status: "in_progress", createdAt: new Date("2026-01-01") }) }, + ]; + await act(async () => root.render()); + expect([...rows()].map(row => row.getAttribute("data-issue-row-details"))).toEqual(["earlier", "later"]); + }); + it("bounds the first render, reveals batches, and retains mounted rows through enrichment and append", async () => { const items = tasks(400); await render(items); diff --git a/ui/src/components/TaskList.tsx b/ui/src/components/TaskList.tsx index 88336804bc9..3ff785fb695 100644 --- a/ui/src/components/TaskList.tsx +++ b/ui/src/components/TaskList.tsx @@ -3,7 +3,8 @@ import type { Issue } from "@paperclipai/shared"; import { ChevronDown, ChevronRight, GitBranch } from "lucide-react"; import { buildIssueTree } from "../lib/issue-tree"; import { issueFilterLabel } from "../lib/issue-filters"; -import { allTaskListColumns, defaultTaskListColumns, defaultTaskListSort, sortTaskListItems, type TaskListColumnId, type TaskListSort } from "../lib/task-list"; +import { allTaskListColumns, defaultTaskListColumns, defaultTaskListSort, isTaskListColumnSort, taskListOrderToIssueSort, type TaskListColumnId, type TaskListSort, type TaskListOrder } from "../lib/task-list"; +import { sortIssues } from "../lib/issue-sort"; import { timeAgo } from "../lib/timeAgo"; import { IssueRow, type IssueRowDensity } from "./IssueRow"; import { IssueGroupHeader } from "./IssueGroupHeader"; @@ -37,7 +38,7 @@ export function includeTaskListAncestors(items: readonly TaskListItem[], matches } /** Opt-in collection built from the reviewed row, hierarchy and column primitives. */ -export function TaskList({ items, allItems = items, initialColumns = defaultTaskListColumns, density = "comfortable", collapsedIds, onCollapsedIdsChange, groupBy = "none", emptyState, issueLinkState, collectionKey }: { +export function TaskList({ items, allItems = items, initialColumns = defaultTaskListColumns, density = "comfortable", collapsedIds, onCollapsedIdsChange, groupBy = "none", emptyState, issueLinkState, collectionKey, sort: controlledSort, onSortChange }: { items: readonly TaskListItem[]; allItems?: readonly TaskListItem[]; initialColumns?: readonly TaskListColumnId[]; @@ -49,32 +50,29 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask issueLinkState?: unknown; /** Company/person/view/filter identity; membership changes do not reset it. */ collectionKey?: string; + sort?: TaskListOrder; + onSortChange?: (sort: TaskListSort | undefined) => void; }) { const localKey = useId(); const [columns, setColumns] = useState(initialColumns); const [columnOrder, setColumnOrder] = useState(allTaskListColumns); - const [sort, setSort] = useState(); + const [localSort, setLocalSort] = useState(); + const sort = controlledSort ?? (onSortChange ? undefined : localSort); + const setSort = (next: TaskListSort | undefined) => { + setLocalSort(next); + onSortChange?.(next); + }; const [localCollapsed, setLocalCollapsed] = useState>(new Set()); const collapsed = collapsedIds ?? localCollapsed; const setCollapsed = onCollapsedIdsChange ?? setLocalCollapsed; const byId = new Map(allItems.map(item => [item.issue.id, item])); const visibleById = new Map(items.map(item => [item.issue.id, item])); - const sortValue = (issue: Issue, column: TaskListColumnId) => { + const ordered = sortIssues(items.map(item => item.issue), taskListOrderToIssueSort(sort ?? defaultTaskListSort), issue => { const item = byId.get(issue.id)!; const parent = issue.parentId ? byId.get(issue.parentId)?.issue : null; - switch (column) { - case "task": return issue.title; - case "taskId": return issue.identifier; - case "createdBy": return item.createdBy; - case "assignee": return item.assignee; - case "project": return issue.project?.name; - case "parentTask": return parent ? `${parent.identifier} ${parent.title}` : null; - case "tags": return issue.labels?.map(label => label.name).sort().join(", "); - case "worktree": return issue.currentExecutionWorkspace?.branchName; - case "lastUpdated": return new Date(issue.updatedAt).getTime(); - } - }; - const { roots, childMap } = buildIssueTree(sortTaskListItems(items.map(item => item.issue), sort ?? defaultTaskListSort, sortValue)); + return { createdBy: item.createdBy, assignee: item.assignee, parentTask: parent ? `${parent.identifier} ${parent.title}` : null }; + }); + const { roots, childMap } = buildIssueTree(ordered); const rowIds: string[] = []; const collectRows = (issue: Issue) => { rowIds.push(issue.id); @@ -85,7 +83,7 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask const renderedIds = new Set(rowIds.slice(0, limit)); const updateColumns = (next: readonly TaskListColumnId[]) => { setColumns(next); - if (sort && !next.includes(sort.column)) setSort(undefined); + if (sort && isTaskListColumnSort(sort) && !next.includes(sort.column)) setSort(undefined); }; const renderBranch = (issue: Issue, depth: number): ReactNode => { if (!renderedIds.has(issue.id)) return null; @@ -128,7 +126,7 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask } return ( setSort(undefined)} />}> - + {items.length === 0 ? emptyState : (
{[...groups].map(([label, groupRoots]) => groupRoots.some(issue => renderedIds.has(issue.id)) ? ( diff --git a/ui/src/hooks/useTaskListCollection.ts b/ui/src/hooks/useTaskListCollection.ts index 5ecdc104e17..5ae922ea0ce 100644 --- a/ui/src/hooks/useTaskListCollection.ts +++ b/ui/src/hooks/useTaskListCollection.ts @@ -8,7 +8,7 @@ const expanded: ReadonlySet = new Set(); /** Shared collection behavior for the proposed task UI. Supply accessible data; * this hook scopes presentation, and does not replace server authorization. */ -export function useTaskListCollection({ items, companyId, userId, viewStorageKey, initialQuery = "", query: controlledQuery, onQueryChange, view: controlledView, initialFilters, searchMode = "local", liveIssueIds }: { +export function useTaskListCollection({ items, companyId, userId, viewStorageKey, initialQuery = "", query: controlledQuery, onQueryChange, view: controlledView, layout: controlledLayout, onLayoutChange, initialFilters, searchMode = "local", liveIssueIds }: { items: readonly TaskListItem[]; companyId: string | null; userId: string | null; @@ -17,6 +17,8 @@ export function useTaskListCollection({ items, companyId, userId, viewStorageKey query?: string; onQueryChange?: (query: string) => void; view?: TaskListView; + layout?: "list" | "board"; + onLayoutChange?: (layout: "list" | "board") => void; initialFilters?: Partial; /** Server results may match comments, documents, or normalized identifiers. */ searchMode?: "local" | "server"; @@ -26,7 +28,8 @@ export function useTaskListCollection({ items, companyId, userId, viewStorageKey const query = controlledQuery ?? queryState; const [preference, setPreference] = useState(() => ({ key: viewStorageKey, view: loadTaskListView(viewStorageKey) })); const view = controlledView ?? (preference.key === viewStorageKey ? preference.view : loadTaskListView(viewStorageKey)); - const [layout, setLayout] = useState<"list" | "board">("list"); + const [layoutState, setLayoutState] = useState<"list" | "board">("list"); + const layout = controlledLayout ?? layoutState; const [filters, setFilters] = useState(() => ({ ...defaultIssueFilterState, ...initialFilters })); const [collapse, setCollapse] = useState({ companyId, ids: expanded }); const collapsedIds = collapse.companyId === companyId ? collapse.ids : expanded; @@ -67,7 +70,7 @@ export function useTaskListCollection({ items, companyId, userId, viewStorageKey const clear = () => { setQuery(""); setFilters(defaultIssueFilterState); setView("all"); }; return { - query, setQuery, view, setView, layout, setLayout, filters, updateFilters, clear, + query, setQuery, view, setView, layout, setLayout: onLayoutChange ?? setLayoutState, filters, updateFilters, clear, companyItems, matchingItems, visibleItems, creators: [...creatorsById.values()], collapsedIds, setCollapsedIds, allExpanded, toggleExpanded: () => setCollapsedIds(allExpanded ? parentIds : expanded), diff --git a/ui/src/lib/issue-sort.ts b/ui/src/lib/issue-sort.ts new file mode 100644 index 00000000000..63e6c7a1377 --- /dev/null +++ b/ui/src/lib/issue-sort.ts @@ -0,0 +1,56 @@ +import type { Issue } from "@paperclipai/shared"; +import { issuePriorityOrder, issueStatusOrder } from "./issue-filters"; +import { workflowSort } from "./workflow-sort"; + +/** URL sort fields are shared by all task interfaces, including hidden columns. */ +export const ISSUE_SORT_FIELDS = ["status", "priority", "title", "created", "updated", "workflow", "taskId", "createdBy", "assignee", "project", "parentTask", "tags", "worktree"] as const; + +export type IssueSortField = typeof ISSUE_SORT_FIELDS[number]; +export type IssueSort = { sortField: IssueSortField; sortDir: "asc" | "desc" }; + +export function readIssueSortParams(params: URLSearchParams): IssueSort | undefined { + const field = params.get("sortField"); + if (!ISSUE_SORT_FIELDS.includes(field as IssueSortField)) return undefined; + return { sortField: field as IssueSortField, sortDir: params.get("sortDir") === "asc" ? "asc" : "desc" }; +} + +export type IssueSortContext = { + createdBy?: string | null; + assignee?: string | null; + project?: string | null; + parentTask?: string | null; +}; + +const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +/** Compare the same raw values in every interface. Missing values stay last. */ +export function sortIssues(issues: readonly Issue[], sort: IssueSort, context: (issue: Issue) => IssueSortContext = () => ({})): Issue[] { + if (sort.sortField === "workflow") { + const ordered = workflowSort([...issues]); + return sort.sortDir === "desc" ? ordered.reverse() : ordered; + } + const value = (issue: Issue): string | number | null | undefined => { + switch (sort.sortField) { + case "status": return issueStatusOrder.indexOf(issue.displayStatus ?? issue.status); + case "priority": return issuePriorityOrder.indexOf(issue.priority); + case "title": return issue.title; + case "created": return new Date(issue.createdAt).getTime(); + case "updated": return new Date(issue.updatedAt).getTime(); + case "taskId": return issue.identifier; + case "createdBy": return context(issue).createdBy; + case "assignee": return context(issue).assignee; + case "project": return context(issue).project ?? issue.project?.name; + case "parentTask": return context(issue).parentTask; + case "tags": return issue.labels?.map(label => label.name).sort().join(", "); + case "worktree": return issue.currentExecutionWorkspace?.branchName; + } + }; + return [...issues].sort((a, b) => { + const left = value(a), right = value(b); + const missing = (value: string | number | null | undefined) => value == null || value === "" || (typeof value === "number" && !Number.isFinite(value)); + const leftMissing = missing(left), rightMissing = missing(right); + if (leftMissing || rightMissing) return Number(leftMissing) - Number(rightMissing); + const result = typeof left === "number" && typeof right === "number" ? left - right : collator.compare(String(left), String(right)); + return sort.sortDir === "asc" ? result : -result; + }); +} diff --git a/ui/src/lib/task-list.ts b/ui/src/lib/task-list.ts index 542378c2d2c..0d567853635 100644 --- a/ui/src/lib/task-list.ts +++ b/ui/src/lib/task-list.ts @@ -1,3 +1,5 @@ +import { readIssueSortParams, type IssueSort, type IssueSortField } from "./issue-sort"; + /** Ordered columns and token references shared by the proposed header and rows. */ export const taskListColumns = [ { id: "task", label: "Task", width: "var(--task-list-column-task)" }, @@ -12,7 +14,8 @@ export const taskListColumns = [ ] as const; export type TaskListColumnId = (typeof taskListColumns)[number]["id"]; -export type TaskListSort = { column: TaskListColumnId; direction: "ascending" | "descending" }; +export type TaskListSort = { column: Column; direction: "ascending" | "descending" }; +export type TaskListOrder = TaskListSort; export type TaskListSortValue = string | number | null | undefined; export const allTaskListColumns: readonly TaskListColumnId[] = taskListColumns.map(column => column.id); export const defaultTaskListColumns: readonly TaskListColumnId[] = ["task", "taskId", "lastUpdated"]; @@ -40,7 +43,7 @@ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "bas * Callers supply timestamps, task titles, names and sorted tag names, * never formatted relative dates or rendered React nodes. */ -export function sortTaskListItems(items: readonly T[], sort: TaskListSort, value: (item: T, column: TaskListColumnId) => TaskListSortValue): T[] { +export function sortTaskListItems(items: readonly T[], sort: TaskListSort, value: (item: T, column: Column) => TaskListSortValue): T[] { return [...items].sort((a, b) => { const left = value(a, sort.column); const right = value(b, sort.column); @@ -53,3 +56,30 @@ export function sortTaskListItems(items: readonly T[], sort: TaskListSort, va return sort.direction === "ascending" ? result : -result; }); } + +const issueSortFieldByColumn = { + task: "title", lastUpdated: "updated", taskId: "taskId", createdBy: "createdBy", + assignee: "assignee", project: "project", parentTask: "parentTask", tags: "tags", + worktree: "worktree", created: "created", status: "status", priority: "priority", workflow: "workflow", +} as const satisfies Record; + +export function isTaskListColumnSort(sort: TaskListOrder): sort is TaskListSort { + return allTaskListColumns.some(column => column === sort.column); +} + +export function taskListOrderToIssueSort(sort: TaskListOrder): IssueSort { + return { sortField: issueSortFieldByColumn[sort.column], sortDir: sort.direction === "ascending" ? "asc" : "desc" }; +} + +/** Read shared links even when the selected field has no visible table column. */ +export function readTaskListSortParams(params: URLSearchParams): TaskListOrder | undefined { + const sort = readIssueSortParams(params); + if (!sort) return undefined; + const column = (Object.keys(issueSortFieldByColumn) as TaskListOrder["column"][]) + .find(column => issueSortFieldByColumn[column] === sort.sortField); + if (!column) return undefined; + return { + column, + direction: sort.sortDir === "asc" ? "ascending" : "descending", + }; +} diff --git a/ui/src/pages/CoordinatorTasks.test.tsx b/ui/src/pages/CoordinatorTasks.test.tsx index 32ceb4a7f30..94a280d1efb 100644 --- a/ui/src/pages/CoordinatorTasks.test.tsx +++ b/ui/src/pages/CoordinatorTasks.test.tsx @@ -9,6 +9,7 @@ import { MemoryRouter } from "@/lib/router"; import { useLocation, useNavigate } from "react-router-dom"; import { CoordinatorTasks } from "./CoordinatorTasks"; import { queryKeys } from "@/lib/queryKeys"; +import type { TaskListSort } from "@/lib/task-list"; const mocks = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), attention: vi.fn(), breadcrumbs: vi.fn(), liveRuns: vi.fn() })); vi.mock("@/api/heartbeats", () => ({ heartbeatsApi: { liveRunsForCompany: mocks.liveRuns } })); @@ -21,7 +22,7 @@ vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: " vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company" }) })); vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: vi.fn() }) })); vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mocks.breadcrumbs }) })); -vi.mock("@/components/TaskListToolbar", () => ({ TaskListToolbar: ({ filters, onViewChange, onLayoutChange }: { filters: { onChange: (value: { liveOnly?: boolean; priorities?: string[]; assignees?: string[]; statuses?: string[]; routineTasksOnly?: boolean }) => void }; onViewChange: (view: "blocked") => void; onLayoutChange: (layout: "board") => void }) => <> +vi.mock("@/components/TaskListToolbar", () => ({ TaskListToolbar: ({ filters, onViewChange, onLayoutChange }: { filters: { onChange: (value: { liveOnly?: boolean; priorities?: string[]; assignees?: string[]; statuses?: string[]; routineTasksOnly?: boolean }) => void }; onViewChange: (view: "blocked") => void; onLayoutChange: (layout: "list" | "board") => void }) => <> @@ -30,11 +31,12 @@ vi.mock("@/components/TaskListToolbar", () => ({ TaskListToolbar: ({ filters, on + })); vi.mock("@/components/TaskList", async importOriginal => ({ ...await importOriginal(), - TaskList: ({ items, emptyState }: { items: { issue: { id: string; title: string } }[]; emptyState: React.ReactNode }) => -
{items.length ? items.map(item =>

{item.issue.title}

) : emptyState}
, + TaskList: ({ items, emptyState, onSortChange }: { onSortChange?: (sort: TaskListSort) => void; items: { issue: { id: string; title: string } }[]; emptyState: React.ReactNode }) => +
{useLocation().search}{items.length ? items.map(item =>

{item.issue.title}

) : emptyState}
, })); describe("Coordinator tasks using the proposed table", () => { @@ -74,6 +76,43 @@ describe("Coordinator tasks using the proposed table", () => { await vi.waitFor(() => expect(host.querySelector('[data-task-id="manual-task"]')).toBeNull()); }); + it("reads newest-first sorting and puts column sort clicks in the URL", async () => { + mocks.list.mockResolvedValue([createIssue({ companyId: "company", parentId: null, originKind: "routine_execution" })]); + await mount("/issues?view=all&originKind=routine_execution&sortField=created&sortDir=desc"); + expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ sortField: "created", sortDir: "desc", originKind: "routine_execution", offset: 0 }), expect.anything()); + flushSync(() => [...host.querySelectorAll("button")].find(button => button.textContent === "Sort by task ID")!.click()); + await vi.waitFor(() => expect(host.querySelector("output")?.textContent).toContain("sortField=taskId")); + expect(Object.fromEntries(new URLSearchParams(host.querySelector("output")!.textContent!))).toMatchObject({ + view: "all", originKind: "routine_execution", sortField: "taskId", sortDir: "desc", + }); + }); + + it("keeps search relevance when a shared link selects creation order", async () => { + await mount("/issues?sortField=created&sortDir=asc&q=report"); + expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ q: "report", sortField: "updated", sortDir: "desc" }), expect.anything()); + }); + + it.each(["updated&sortDir=asc", "created&sortDir=asc", "created&sortDir=desc"])("keeps recent board tasks when the list selects %s", async order => { + const older = createIssue({ id: "older-task", companyId: "company", parentId: null, originKind: "routine_execution", title: "Older board task" }); + const recent = createIssue({ id: "recent-task", companyId: "company", parentId: null, originKind: "routine_execution", title: "Recently active board task" }); + mocks.list.mockImplementation(async (_company, filters) => filters.ids + ? [older, recent].filter(issue => filters.ids.includes(issue.id)) + : filters.sortField === "updated" && filters.sortDir === "desc" ? [recent] : [older]); + await mount(`/issues?sortField=${order}&originKind=routine_execution`); + expect(host.textContent).toContain(older.title); + flushSync(() => [...host.querySelectorAll("button")].find(button => button.textContent === "Board view")!.click()); + await vi.waitFor(() => expect(host.textContent).toContain(recent.title)); + expect(mocks.list.mock.calls.filter(call => call[1].projection === "index").at(-1)?.[1]).toMatchObject({ sortField: "updated", sortDir: "desc", originKind: "routine_execution", offset: 0 }); + expect(host.querySelector("output")?.textContent).toContain(`sortField=${order}`); + flushSync(() => [...host.querySelectorAll("button")].find(button => button.textContent === "List view")!.click()); + await vi.waitFor(() => expect(host.textContent).toContain(older.title)); + }); + + it("requests dependency edges for shared workflow links", async () => { + await mount("/issues?sortField=workflow&sortDir=asc"); + expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ includeBlockedBy: true }), expect.anything()); + }); + it("filters pending-decision batches by routine origin without treating excluded IDs as missing", async () => { const routine = createIssue({ id: "routine-decision", companyId: "company", parentId: null, originKind: "routine_execution", title: "Routine decision" }); mocks.attention.mockResolvedValue({ taskIds: [pending.id, routine.id] }); diff --git a/ui/src/pages/CoordinatorTasks.tsx b/ui/src/pages/CoordinatorTasks.tsx index 6a9e4208944..01ecb37827f 100644 --- a/ui/src/pages/CoordinatorTasks.tsx +++ b/ui/src/pages/CoordinatorTasks.tsx @@ -1,4 +1,5 @@ -import { useDeferredValue, useEffect, useRef } from "react"; +import { readTaskListSortParams, taskListOrderToIssueSort, type TaskListSort } from "@/lib/task-list"; +import { useDeferredValue, useEffect, useRef, useState } from "react"; import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode"; import { useInfiniteQuery, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { ListChecks, SearchX } from "lucide-react"; @@ -47,9 +48,24 @@ export function CoordinatorTasks() { const listRef = useRef(null); const location = useLocation(); const [params, setParams] = useSearchParams(); + const [layout, setLayout] = useState<"list" | "board">("list"); const urlAssignee = JSON.stringify(params.getAll("assignee")); const urlStatuses = JSON.stringify(params.getAll("status")); const urlView = params.get("view"); + const sort = readTaskListSortParams(params); + const includeBlockedBy = sort?.column === "workflow"; + const setSort = (nextSort: TaskListSort | undefined) => setParams(current => { + const next = new URLSearchParams(current); + if (nextSort) { + const sharedSort = taskListOrderToIssueSort(nextSort); + next.set("sortField", sharedSort.sortField); + next.set("sortDir", sharedSort.sortDir); + } else { + next.delete("sortField"); + next.delete("sortDir"); + } + return next; + }, { replace: true }); const routineTasksOnly = params.get("originKind") === "routine_execution"; const originKind = routineTasksOnly ? "routine_execution" : undefined; const normalizedView = normalizeTaskViewKey(urlView); @@ -59,6 +75,9 @@ export function CoordinatorTasks() { const needsAttention = params.get("view") === "needs_attention"; const query = params.get("q") ?? ""; const deferredQuery = useDeferredValue(query.trim()); + const useListSort = layout === "list" && !deferredQuery; + const serverSortField = useListSort && sort?.column === "created" ? "created" : "updated"; + const serverSortDir = useListSort && (sort?.column === "created" || sort?.column === "lastUpdated") && sort.direction === "ascending" ? "asc" : "desc"; const participantAgentId = params.get("participantAgentId") ?? undefined; const workspaces = params.getAll("workspace").filter(Boolean); const workspaceIds = [...new Set(workspaces.length ? workspaces : params.getAll("workspaceId").filter(Boolean))]; @@ -75,11 +94,12 @@ export function CoordinatorTasks() { useEffect(() => { setBreadcrumbs([{ label: "Tasks" }]); }, [setBreadcrumbs]); const tasks = useInfiniteQuery({ - queryKey: [...queryKeys.issues.list(companyId!), "coordinator-table", "index", userId, deferredQuery, participantAgentId, workspaceIds, originKind], + queryKey: [...queryKeys.issues.list(companyId!), "coordinator-table", "index", userId, deferredQuery, participantAgentId, workspaceIds, originKind, serverSortField, serverSortDir, includeBlockedBy], queryFn: async ({ pageParam, signal }) => { const pages = await Promise.all((workspaceIds.length ? workspaceIds : [undefined]).map(workspaceId => issuesApi.listCompact(companyId!, { projection: "index", limit: pageSize, offset: pageParam, includeAgentCreatedTasks: true, includeRoutineExecutions: true, - sortField: "updated", sortDir: "desc", q: deferredQuery || undefined, participantAgentId, workspaceId, originKind, + sortField: serverSortField, sortDir: serverSortDir, + includeBlockedBy: includeBlockedBy || undefined, q: deferredQuery || undefined, participantAgentId, workspaceId, originKind, }, { signal }))); return { items: pages.flat(), hasNextPage: pages.some(page => page.length === pageSize) }; }, @@ -94,10 +114,10 @@ export function CoordinatorTasks() { const attentionIds = needsAttention ? attention.data?.taskIds ?? [] : []; const attentionBatches = Array.from({ length: Math.ceil(attentionIds.length / 100) }, (_, index) => attentionIds.slice(index * 100, (index + 1) * 100)); const attentionTasks = useQueries({ queries: attentionBatches.map(ids => ({ - queryKey: [...queryKeys.issues.list(companyId!), "coordinator-attention-index", userId, participantAgentId, deferredQuery, originKind, ids], + queryKey: [...queryKeys.issues.list(companyId!), "coordinator-attention-index", userId, participantAgentId, deferredQuery, originKind, includeBlockedBy, ids], queryFn: async ({ signal }: { signal: AbortSignal }) => { const rows = await issuesApi.listCompact(companyId!, { - ids, projection: "index", limit: 100, q: deferredQuery || undefined, participantAgentId, originKind, + ids, projection: "index", limit: 100, q: deferredQuery || undefined, participantAgentId, originKind, includeBlockedBy: includeBlockedBy || undefined, includeAgentCreatedTasks: true, includeRoutineExecutions: true, }, { signal }); if (!deferredQuery && !participantAgentId && !originKind && ids.some(id => !rows.some(row => row.id === id))) { @@ -126,6 +146,7 @@ export function CoordinatorTasks() { assignee: issue.assigneeAgentId ? agentNames.get(issue.assigneeAgentId) ?? "Unknown agent" : users.get(issue.assigneeUserId ?? "") ?? null, })); const collection = useTaskListCollection({ items, companyId, userId, query, onQueryChange: setQuery, view: "all", + layout, onLayoutChange: setLayout, searchMode: "server", liveIssueIds: collectLiveIssueIds(liveRuns.data, records), viewStorageKey: taskListViewStorageKey("coordinator", userId ?? "local-board"), @@ -215,6 +236,7 @@ export function CoordinatorTasks() { {collection.layout === "board" ? collection.matchingItems.length ? enrichIssueRow(item.issue))} agents={agents.data} onUpdateIssue={(id, data) => update.mutate({ id, data })} /> : empty : null} diff --git a/ui/src/pages/Issues.routine-filter.test.tsx b/ui/src/pages/Issues.routine-filter.test.tsx index 3f5c27ff718..3308287da8d 100644 --- a/ui/src/pages/Issues.routine-filter.test.tsx +++ b/ui/src/pages/Issues.routine-filter.test.tsx @@ -7,6 +7,7 @@ import type { Issue } from "@paperclipai/shared"; import { MemoryRouter } from "@/lib/router"; import { useLocation } from "react-router-dom"; import { Issues } from "./Issues"; +import type { IssueSort } from "../lib/issue-sort"; const mocks = vi.hoisted(() => ({ list: vi.fn() })); vi.mock("../context/DesignSandboxContext", () => ({ useDesignSandbox: () => false })); @@ -20,12 +21,14 @@ vi.mock("../api/agents", () => ({ agentsApi: { list: async () => [] } })); vi.mock("../api/projects", () => ({ projectsApi: { list: async () => [] } })); vi.mock("../api/heartbeats", () => ({ heartbeatsApi: { liveRunsForCompany: async () => [] } })); vi.mock("../hooks/useUpdateIssueMutation", () => ({ useUpdateIssueMutation: () => ({ mutate: vi.fn() }) })); -vi.mock("../components/IssuesList", () => ({ IssuesList: ({ issues, routineTasksOnly, onRoutineTasksOnlyChange, onLoadMoreIssues }: { - issues: Issue[]; routineTasksOnly: boolean; onRoutineTasksOnlyChange: (checked: boolean) => void; onLoadMoreIssues: () => void; +vi.mock("../components/IssuesList", () => ({ IssuesList: ({ issues, routineTasksOnly, onRoutineTasksOnlyChange, onLoadMoreIssues, sort, onSortChange, searchFilters }: { + searchFilters?: Record; + sort?: IssueSort; onSortChange: (sort: IssueSort) => void; issues: Issue[]; routineTasksOnly: boolean; onRoutineTasksOnlyChange: (checked: boolean) => void; onLoadMoreIssues: () => void; }) => <> - {issues.map(issue => issue.id).join(",")} + + {issues.map(issue => issue.id).join(",")}

{useLocation().search}

})); @@ -57,3 +60,27 @@ it("fetches routine tasks before pagination and removes the URL filter when unch flushSync(() => root.unmount()); client.clear(); host.remove(); } }); + +it("restores URL sorting, resets paging on a sort click, and preserves routine filters", async () => { + mocks.list.mockImplementation(async (_company, filters) => Array.from({ length: filters.offset ? 1 : 100 }, (_, index) => ({ id: `${filters.sortDir}-${filters.offset + index}` }))); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + try { + flushSync(() => root.render()); + await vi.waitFor(() => expect(host.querySelector("output")?.textContent).toContain("desc-99")); + expect(mocks.list.mock.lastCall?.[1]).toMatchObject({ sortField: "created", sortDir: "desc", originKind: "routine_execution", offset: 0 }); + expect(JSON.parse(host.querySelector("output")!.getAttribute("data-search-filters")!)).toEqual({ + originKind: "routine_execution", participantAgentId: "agent", + }); + flushSync(() => host.querySelector("button")!.click()); + await vi.waitFor(() => expect(host.querySelector("output")?.textContent).toContain("desc-100")); + flushSync(() => host.querySelector("[data-sort]")!.click()); + await vi.waitFor(() => expect(host.querySelector("output")?.textContent).toContain("asc-99")); + expect(mocks.list.mock.lastCall?.[1]).toMatchObject({ sortField: "created", sortDir: "asc", originKind: "routine_execution", participantAgentId: "agent", offset: 0 }); + const params = new URLSearchParams(host.querySelector("p")!.textContent!); + expect(Object.fromEntries(params)).toMatchObject({ view: "all", originKind: "routine_execution", sortField: "created", sortDir: "asc", participantAgentId: "agent" }); + } finally { + flushSync(() => root.unmount()); client.clear(); host.remove(); + } +}); diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 61d9da5f0e1..7d8107ec189 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -19,6 +19,7 @@ import { queryKeys } from "../lib/queryKeys"; import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { EmptyState } from "../components/EmptyState"; import { IssuesList } from "../components/IssuesList"; +import { readIssueSortParams, type IssueSort } from "../lib/issue-sort"; import { TaskViewsMenu } from "../components/TaskViewsMenu"; import { Button } from "@/components/ui/button"; import { CircleDot, Plus } from "lucide-react"; @@ -198,6 +199,20 @@ function OrganizationIssues({ return urlSearch; }, [searchOverride, urlSearch, location.search]); const showAgentCreatedTasks = useShowAgentCreatedTasks(selectedCompanyId, "paperclip:issues-view"); + const sort = useMemo(() => readIssueSortParams(searchParams), [searchParams]); + const includeBlockedBy = sort?.sortField === "workflow"; + const serverSortField = sort?.sortField === "created" ? "created" : "updated"; + const serverSortDir = sort?.sortField === "created" || sort?.sortField === "updated" ? sort.sortDir : "desc"; + const handleSortChange = useCallback((nextSort: IssueSort) => { + setSearchParams(current => { + const next = new URLSearchParams(current); + if (syncedSearch) next.set("q", syncedSearch); + else next.delete("q"); + next.set("sortField", nextSort.sortField); + next.set("sortDir", nextSort.sortDir); + return next; + }, { replace: true }); + }, [setSearchParams, syncedSearch]); const routineTasksOnly = searchParams.get("originKind") === "routine_execution"; const includeAgentCreatedTasks = showAgentCreatedTasks || routineTasksOnly; const originKind = routineTasksOnly ? "routine_execution" : undefined; @@ -290,6 +305,9 @@ function OrganizationIssues({ originKind, "infinite", issuePageSize, + serverSortField, + serverSortDir, + includeBlockedBy, ], queryFn: ({ pageParam, signal }) => issuesApi.listCompact(selectedCompanyId!, { projection: streamlinedUiEnabled ? "index" : undefined, @@ -300,8 +318,9 @@ function OrganizationIssues({ originKind, limit: issuePageSize, offset: pageParam, - sortField: "updated", - sortDir: "desc", + sortField: serverSortField, + sortDir: serverSortDir, + includeBlockedBy: includeBlockedBy || undefined, }, { signal }), initialPageParam: 0, getNextPageParam: (lastPage, _allPages, lastPageParam) => @@ -358,10 +377,12 @@ function OrganizationIssues({ enableAgentCreatedVisibilityFilter routineTasksOnly={routineTasksOnly} onRoutineTasksOnlyChange={handleRoutineTasksOnlyChange} + sort={sort} + onSortChange={handleSortChange} hasMoreIssues={hasMoreServerIssues} onLoadMoreIssues={loadMoreServerIssues} onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })} - searchFilters={participantAgentId || workspaceIdFilter || originKind ? { participantAgentId, workspaceId: workspaceIdFilter, originKind } : undefined} + searchFilters={participantAgentId || workspaceIdFilter || originKind || includeBlockedBy ? { participantAgentId, workspaceId: workspaceIdFilter, originKind, ...(includeBlockedBy ? { includeBlockedBy: true } : {}) } : undefined} /> ); } diff --git a/ui/src/pages/Routines.test.tsx b/ui/src/pages/Routines.test.tsx index 53c75af751f..fc2282b726f 100644 --- a/ui/src/pages/Routines.test.tsx +++ b/ui/src/pages/Routines.test.tsx @@ -1093,7 +1093,7 @@ describe("Routines page", () => { await flush(); }); const link = Array.from(container.querySelectorAll("a")).find(anchor => anchor.textContent === "View all tasks"); - expect(link?.getAttribute("href")).toBe("/issues?view=all&originKind=routine_execution"); + expect(link?.getAttribute("href")).toBe("/issues?view=all&originKind=routine_execution&sortField=created&sortDir=desc"); expect(container.textContent).not.toContain("View all runs"); await act(() => root.unmount()); queryClient.clear(); diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index 35c2b73d088..9fdaeaad9d6 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -852,7 +852,7 @@ export function Routines() { onGroupChange={groupBy => updateRoutineView({ groupBy, collapsedGroups: [] })} onNewFolder={routineViewState.groupBy === "folder" && !hasRoutineFolders ? () => openCreateFolder() : undefined} onCreateRoutine={openCreateRoutine} - tasksHref="/issues?view=all&originKind=routine_execution" + tasksHref="/issues?view=all&originKind=routine_execution&sortField=created&sortDir=desc" selectMode={selectMode} onToggleSelect={showFolderRail ? () => setSelectMode(current => !current) : undefined} />