mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
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 <Paperclip-Paperclip@users.noreply.github.com>
This commit is contained in:
1 parent
1f8f2e195f
commit
946735d03a
23 files changed
+459
-117
No files matched your search
+8
-2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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(<IssuesList issues={issues} agents={[]} projects={[]}
|
||||
viewStateKey="paperclip:test-issues" sort={{ sortField: "created", sortDir: "desc" }}
|
||||
onSortChange={onSortChange} onUpdateIssue={() => 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<HTMLButtonElement>('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(<QueryClientProvider client={queryClient}><TooltipProvider>
|
||||
<IssuesList issues={issues} agents={[]} projects={[]} viewStateKey="paperclip:test-issues"
|
||||
sort={{ sortField: "created", sortDir: "asc" }} onSortChange={onSortChange} onUpdateIssue={() => undefined} />
|
||||
</TooltipProvider></QueryClientProvider>));
|
||||
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(<IssuesList issues={issues} agents={[]} projects={[]} viewStateKey="cross-interface-sort"
|
||||
sort={readIssueSortParams(new URLSearchParams("sortField=taskId&sortDir=asc"))}
|
||||
onUpdateIssue={() => 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(<IssuesList issues={issues} agents={[]} projects={[]} viewStateKey="monitoring-sort"
|
||||
sort={readIssueSortParams(new URLSearchParams("sortField=status&sortDir=asc"))}
|
||||
onUpdateIssue={() => 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,
|
||||
|
||||
@@ -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<IssueDisplayStatus, string> = {
|
||||
|
||||
/* ── 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<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
|
||||
searchFilters?: Omit<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions" | "sortField" | "sortDir">;
|
||||
searchWithinLoadedIssues?: boolean;
|
||||
baseCreateIssueDefaults?: Record<string, unknown>;
|
||||
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<IssueViewState>) => {
|
||||
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(
|
||||
|
||||
@@ -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<IssueDisplayStatus, string> = {
|
||||
|
||||
/* ── 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<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions">;
|
||||
searchFilters?: Omit<IssueListRequestFilters, "q" | "projectId" | "limit" | "includeRoutineExecutions" | "sortField" | "sortDir">;
|
||||
searchWithinLoadedIssues?: boolean;
|
||||
baseCreateIssueDefaults?: Record<string, unknown>;
|
||||
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<IssueViewState>) => {
|
||||
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(
|
||||
|
||||
@@ -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 }) =>
|
||||
<div data-issue-row-details={issue.id}>{leadingControl}<button>{issue.title}</button></div> }));
|
||||
@@ -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(<TooltipProvider><TaskList items={items}
|
||||
sort={{ column: "created", direction: "descending" }} onSortChange={onSortChange} /></TooltipProvider>));
|
||||
expect([...rows()].map(row => row.getAttribute("data-issue-row-details"))).toEqual(["newer", "older"]);
|
||||
await act(async () => host.querySelector<HTMLButtonElement>('button[aria-label="Task ID: sort ascending"]')!.click());
|
||||
expect(onSortChange).toHaveBeenLastCalledWith({ column: "taskId", direction: "ascending" });
|
||||
await act(async () => root.render(<TooltipProvider><TaskList items={items}
|
||||
sort={{ column: "taskId", direction: "ascending" }} onSortChange={onSortChange} /></TooltipProvider>));
|
||||
expect([...rows()].map(row => row.getAttribute("data-issue-row-details"))).toEqual(["older", "newer"]);
|
||||
await act(async () => host.querySelector<HTMLButtonElement>('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(<TooltipProvider><TaskList items={items}
|
||||
sort={readTaskListSortParams(new URLSearchParams(`sortField=${field}&sortDir=asc`))} /></TooltipProvider>));
|
||||
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);
|
||||
|
||||
@@ -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<TaskListSort>();
|
||||
const [localSort, setLocalSort] = useState<TaskListSort>();
|
||||
const sort = controlledSort ?? (onSortChange ? undefined : localSort);
|
||||
const setSort = (next: TaskListSort | undefined) => {
|
||||
setLocalSort(next);
|
||||
onSortChange?.(next);
|
||||
};
|
||||
const [localCollapsed, setLocalCollapsed] = useState<ReadonlySet<string>>(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 (
|
||||
<TaskListViewport visibleColumns={columns} columnOrder={columnOrder} controls={<TaskListColumnSettings visibleColumns={columns} onVisibleColumnsChange={updateColumns} onColumnOrderChange={setColumnOrder} onResetDefaults={() => setSort(undefined)} />}>
|
||||
<TaskListColumnHeader sort={sort} onSortChange={setSort} onColumnOrderChange={setColumnOrder} />
|
||||
<TaskListColumnHeader sort={sort && isTaskListColumnSort(sort) ? sort : undefined} onSortChange={setSort} onColumnOrderChange={setColumnOrder} />
|
||||
{items.length === 0 ? emptyState : (
|
||||
<div aria-label={`${density} task list`}>
|
||||
{[...groups].map(([label, groupRoots]) => groupRoots.some(issue => renderedIds.has(issue.id)) ? (
|
||||
|
||||
@@ -8,7 +8,7 @@ const expanded: ReadonlySet<string> = 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<IssueFilterState>;
|
||||
/** 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<IssueFilterState>(() => ({ ...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),
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
+32
-2
@@ -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 extends string = TaskListColumnId> = { column: Column; direction: "ascending" | "descending" };
|
||||
export type TaskListOrder = TaskListSort<TaskListColumnId | "created" | "status" | "priority" | "workflow">;
|
||||
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<T>(items: readonly T[], sort: TaskListSort, value: (item: T, column: TaskListColumnId) => TaskListSortValue): T[] {
|
||||
export function sortTaskListItems<T, Column extends TaskListOrder["column"]>(items: readonly T[], sort: TaskListSort<Column>, 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<T>(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<TaskListOrder["column"], IssueSortField>;
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -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 }) => <>
|
||||
<button onClick={() => filters.onChange({ routineTasksOnly: true })}>Routine tasks</button>
|
||||
<button onClick={() => filters.onChange({ routineTasksOnly: false })}>Show other tasks</button>
|
||||
<button onClick={() => filters.onChange({ liveOnly: true })}>Live runs only</button>
|
||||
@@ -30,11 +31,12 @@ vi.mock("@/components/TaskListToolbar", () => ({ TaskListToolbar: ({ filters, on
|
||||
<button onClick={() => filters.onChange({ assignees: ["first", "second"], statuses: ["todo"] })}>Change assignee and status</button>
|
||||
<button onClick={() => onViewChange("blocked")}>Needs attention</button>
|
||||
<button onClick={() => onLayoutChange("board")}>Board view</button>
|
||||
<button onClick={() => onLayoutChange("list")}>List view</button>
|
||||
</> }));
|
||||
vi.mock("@/components/TaskList", async importOriginal => ({
|
||||
...await importOriginal<typeof import("@/components/TaskList")>(),
|
||||
TaskList: ({ items, emptyState }: { items: { issue: { id: string; title: string } }[]; emptyState: React.ReactNode }) =>
|
||||
<div>{items.length ? items.map(item => <p key={item.issue.id} data-task-id={item.issue.id}>{item.issue.title}</p>) : emptyState}</div>,
|
||||
TaskList: ({ items, emptyState, onSortChange }: { onSortChange?: (sort: TaskListSort) => void; items: { issue: { id: string; title: string } }[]; emptyState: React.ReactNode }) =>
|
||||
<div><button onClick={() => onSortChange?.({ column: "taskId", direction: "descending" })}>Sort by task ID</button><output>{useLocation().search}</output>{items.length ? items.map(item => <p key={item.issue.id} data-task-id={item.issue.id}>{item.issue.title}</p>) : emptyState}</div>,
|
||||
}));
|
||||
|
||||
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] });
|
||||
|
||||
@@ -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<HTMLElement>(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() {
|
||||
<div hidden={collection.layout !== "list"} className="design-task-list-panel">{collection.matchingItems.length ? <TaskList key={companyId}
|
||||
items={(needsAttention ? collection.matchingItems : collection.visibleItems).map(enrichItem)} allItems={collection.companyItems.map(enrichItem)}
|
||||
collectionKey={JSON.stringify([companyId, userId, needsAttention, deferredQuery, participantAgentId, workspaceIds, collection.view, collection.filters])}
|
||||
sort={sort} onSortChange={setSort}
|
||||
issueLinkState={issueLinkState} collapsedIds={collection.collapsedIds} onCollapsedIdsChange={collection.setCollapsedIds} /> : empty}</div>
|
||||
{collection.layout === "board" ? collection.matchingItems.length
|
||||
? <KanbanBoard showOverflowFades issues={collection.matchingItems.map(item => enrichIssueRow(item.issue))} agents={agents.data} onUpdateIssue={(id, data) => update.mutate({ id, data })} /> : empty : null}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
sort?: IssueSort; onSortChange: (sort: IssueSort) => void; issues: Issue[]; routineTasksOnly: boolean; onRoutineTasksOnlyChange: (checked: boolean) => void; onLoadMoreIssues: () => void;
|
||||
}) => <>
|
||||
<label><input type="checkbox" checked={routineTasksOnly} onChange={event => onRoutineTasksOnlyChange(event.target.checked)} />Routine tasks</label>
|
||||
<button onClick={onLoadMoreIssues}>Load more</button>
|
||||
<output>{issues.map(issue => issue.id).join(",")}</output>
|
||||
<button data-sort onClick={() => onSortChange({ sortField: "created", sortDir: sort?.sortDir === "desc" ? "asc" : "desc" })}>Created</button>
|
||||
<output data-search-filters={JSON.stringify(searchFilters)}>{issues.map(issue => issue.id).join(",")}</output>
|
||||
<p>{useLocation().search}</p>
|
||||
</> }));
|
||||
|
||||
@@ -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(<QueryClientProvider client={client}><MemoryRouter initialEntries={["/issues?view=all&originKind=routine_execution&sortField=created&sortDir=desc&participantAgentId=agent"]}><Issues /></MemoryRouter></QueryClientProvider>));
|
||||
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<HTMLButtonElement>("[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();
|
||||
}
|
||||
});
|
||||
+24
-3
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
Reference in new issue
Block a user