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} />