From b3da1d886aa96a891c8be563662b635152c83da1 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:47:57 -0600 Subject: [PATCH] fix(ui): retain Coordinator label filters across reloads (#19233) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators use task filters to keep a useful view of their work. > - Coordinator saves status and assignee filters in the task-list URL. > - A selected label stayed only in React state, so reload removed it and showed unrelated rows. > - This change saves and restores selected labels through the same page-owned URL path. > - Operators can reload and share that filtered collection without selecting its labels again. ## Linked Issues or Issue Description **What happened?** In Coordinator All tasks, select a status, assignee, and label. Sort by Task ID and reload. Status, assignee, and sort remain selected, but the label is cleared and rows without that label appear. **Expected behavior** Reload and history navigation retain the selected labels with the other URL filters. Clear filters removes the selected labels. **Steps to reproduce** 1. Open Coordinator All tasks with labeled and unlabeled tasks for the same assignee and status. 2. Select that status, assignee, and a label. 3. Change Task ID sorting and reload the page. 4. Check both the selected label and the displayed tasks. **Paperclip version or commit** Source regression reproduced at master 47027e7ba52155c9ff78d746847bf6e55892241c. **Deployment mode** Local rendered component test with the production filter panel, page, and collection hook. API data is mocked. Live staging acceptance of this repair remains pending. Related prior work: #18831 preserves Coordinator views and filtered links. Bounded searches found no matching label reload repair. ## What Changed - Save selected labels as repeated `label` URL parameters in CoordinatorTasks. - Restore labels on mount and synchronize them on history navigation. - Remove label parameters when clearing filters. Keep unrelated URL values. - Open label-only links in All tasks when no view is specified. Canonicalize legacy Inbox-view links with labels to All tasks before mounting the collection, so removing the last label retains local filters and layout. - Preserve the URL hash when filters change or clear. - Add rendered checkbox, remount, repeated-label, history, unknown-label, and legacy-view routing regressions through the real page and collection. - Document this URL behavior in PRODUCT.md. ## Verification - First observed red: a production label checkbox restricted rows before remount; after remount an unlabeled row appeared. The failure was captured before production edits. - Focused component suites: 90 tests pass across CoordinatorTasks, Issues coordinator routing, useTaskListCollection, and issue-filters. - UI typecheck and production build pass after generating the existing plugin-sdk declarations locally. - Token gates, map checks, and `git diff --check` pass. A review correction restored the map to its unchanged base version. A native review then found that the parent route omitted label scope. Four settled routing cases failed before the one-entry route fix and pass after it; the original label regressions still pass. A follow-up review found a last-label redirect from the legacy URL; real parent-plus-collection tests reproduced it. The route now canonicalizes that entry before mount, and the tests preserve local priority and board layout through label changes and history. - The first treatment attempt exposed asynchronous test router timing. The test now waits for a route update before the next interaction. Initial failures remain in the private evidence record. - Local checks above cover the UI and the affected component suites only. `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build` were not run across the full workspace on this candidate. No whole-repository local pass is claimed. - Fresh hosted checks completed on exact head `11586cc4e1bc4951b1fdde403ff229f760db721b`: 54 success, 3 skipped, 1 neutral; no failures or pending checks. Snyk status passed. Greptile and native review both gave 5/5. Both actionable review threads are resolved. Native review noted one optional redundant scope entry; no blocking finding remains. - Fixed-revision live browser acceptance remains pending. No deployed repair or recovery is claimed. ## Risks Low scope. The change affects Coordinator task-list presentation only. Unknown label IDs remain restrictive until cleared; missing label metadata does not silently widen results. Multiple labels keep the existing OR filtering behavior. Other task interfaces keep their existing persistence path. URL label values use company-local IDs and do not grant access. Label filtering remains over loaded records; this change does not add server pagination filtering. ## Model Used OpenAI `gpt-6.1-sol` through Codex, with reasoning, source inspection, code execution, and local tests. The runtime did not expose context window size. The parent agent owns delivery coordination. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/PRODUCT.md | 1 + ui/src/pages/CoordinatorTasks.test.tsx | 143 +++++++++++++++++- ui/src/pages/CoordinatorTasks.tsx | 67 ++++---- .../pages/Issues.coordinator-routing.test.tsx | 10 ++ ui/src/pages/Issues.tsx | 7 +- 5 files changed, 190 insertions(+), 38 deletions(-) diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 6ddeae94ac0..5cf4795e89c 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -148,6 +148,7 @@ The task Filters menu also offers **Routine tasks**. It selects tasks with when other automatic tasks are hidden. The routines list's **View all tasks** 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. +Coordinator task labels use repeated `label` URL parameters so selected labels survive reloads and shared task-list links. 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 diff --git a/ui/src/pages/CoordinatorTasks.test.tsx b/ui/src/pages/CoordinatorTasks.test.tsx index 97a6e2de3dd..3b038bc6692 100644 --- a/ui/src/pages/CoordinatorTasks.test.tsx +++ b/ui/src/pages/CoordinatorTasks.test.tsx @@ -6,25 +6,31 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createIssue } from "../../storybook/fixtures/paperclipData"; import { coordinatorAttentionTasksQueryKey } from "@/lib/coordinator-attention"; import { MemoryRouter } from "@/lib/router"; -import { useLocation, useNavigate } from "react-router-dom"; +import { Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { CoordinatorTasks } from "./CoordinatorTasks"; +import { Issues } from "./Issues"; import { queryKeys } from "@/lib/queryKeys"; +import { IssueFilterPanel } from "@/components/IssueFilterPanel"; +import type { ComponentProps } from "react"; import type { TaskListSort } from "@/lib/task-list"; import { taskListViews, taskListViewStorageKey, type TaskListView } from "@/lib/task-list-views"; -const mocks = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), attention: vi.fn(), breadcrumbs: vi.fn(), liveRuns: vi.fn(), localTrusted: false, identity: { userId: "person" as string | null, settled: true } })); +const mocks = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), attention: vi.fn(), breadcrumbs: vi.fn(), liveRuns: vi.fn(), localTrusted: false, realFilters: false, labels: [] as { id: string; name: string; color: string }[], identity: { userId: "person" as string | null, settled: true } })); vi.mock("@/api/heartbeats", () => ({ heartbeatsApi: { liveRunsForCompany: mocks.liveRuns } })); -vi.mock("@/api/issues", () => ({ issuesApi: { list: mocks.list, listCompact: mocks.list, get: mocks.get, listLabels: async () => [], update: vi.fn() } })); +vi.mock("@/api/issues", () => ({ issuesApi: { list: mocks.list, listCompact: mocks.list, get: mocks.get, listLabels: async () => mocks.labels, update: vi.fn() } })); vi.mock("@/api/attention", () => ({ attentionApi: { taskIndex: mocks.attention } })); vi.mock("@/api/agents", () => ({ agentsApi: { list: async () => [] } })); vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => [] } })); vi.mock("@/api/access", () => ({ accessApi: { listUserDirectory: async () => ({ users: [] }) } })); vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => mocks.identity })); -vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ userId: mocks.identity.userId ?? (mocks.localTrusted ? "local-board" : null), loaded: mocks.identity.settled }) })); +vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ enabled: true, userId: mocks.identity.userId ?? (mocks.localTrusted ? "local-board" : null), loaded: mocks.identity.settled }) })); +vi.mock("@/hooks/useStreamlinedUiEnabled", () => ({ useStreamlinedUiEnabled: () => ({ enabled: true }) })); +vi.mock("@/hooks/useCombinedInboxTasksEnabled", () => ({ useCombinedInboxTasksEnabled: () => ({ enabled: false }) })); 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, view, onViewChange, onLayoutChange }: { filters: { onChange: (value: { liveOnly?: boolean; priorities?: string[]; assignees?: string[]; statuses?: string[]; routineTasksOnly?: boolean }) => void }; view: TaskListView; onViewChange: (view: TaskListView) => void; onLayoutChange: (layout: "list" | "board") => void }) => <> +vi.mock("@/components/TaskListToolbar", () => ({ TaskListToolbar: ({ filters, view, onViewChange, onLayoutChange }: { filters: Omit, "onClear">; view: TaskListView; onViewChange: (view: TaskListView) => void; onLayoutChange: (layout: "list" | "board") => void }) => <> + {mocks.realFilters ? filters.onChange({ labels: [] })} /> : null} {view} {taskListViews.map(option => )} @@ -48,6 +54,7 @@ describe("Coordinator tasks using the proposed table", () => { const pending = createIssue({ id: "pending", companyId: "company", status: "todo", parentId: null, title: "Needs a channel answer" }); beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + mocks.realFilters = false; mocks.labels = []; mocks.identity = { userId: "person", settled: true }; mocks.localTrusted = false; mocks.liveRuns.mockResolvedValue([]); @@ -82,6 +89,7 @@ describe("Coordinator tasks using the proposed table", () => { expect(host.querySelector("[data-selected-view]")?.textContent).toBe(value); }); it("uses the resolved local-board principal when no account session exists", async () => { + mocks.realFilters = false; mocks.labels = []; mocks.identity = { userId: null, settled: true }; mocks.localTrusted = true; mocks.list.mockResolvedValue([createIssue({ id: "local", companyId: "company", parentId: null, createdByUserId: "local-board" })]); await mount("/issues"); @@ -124,9 +132,11 @@ describe("Coordinator tasks using the proposed table", () => { it("waits for the viewer identity before resolving their saved view", async () => { const key = taskListViewStorageKey("coordinator", "person"); localStorage.setItem(key, "all"); + mocks.realFilters = false; mocks.labels = []; mocks.identity = { userId: null, settled: false }; await mount("/issues"); expect(localStorage.getItem(key)).toBe("all"); + mocks.realFilters = false; mocks.labels = []; mocks.identity = { userId: "person", settled: true }; await mount("/issues"); expect(host.querySelector("[data-selected-view]")?.textContent).toBe("all"); @@ -282,6 +292,129 @@ describe("Coordinator tasks using the proposed table", () => { expect(mocks.list).toHaveBeenCalledWith("company", expect.objectContaining({ q: query }), expect.anything()); expect(host.querySelector('[data-task-id="server-match"]')).not.toBeNull(); }); + it("keeps a selected label and other URL filters after sorting and reloading", async () => { + mocks.realFilters = true; + mocks.labels = [{ id: "qa", name: "qa-task", color: "var(--accent)" }]; + mocks.list.mockResolvedValue([ + createIssue({ id: "qa-done", companyId: "company", parentId: null, assigneeAgentId: "chosen", status: "done", labelIds: ["qa"] }), + createIssue({ id: "other-done", companyId: "company", parentId: null, assigneeAgentId: "chosen", status: "done", labelIds: [] }), + createIssue({ id: "qa-todo", companyId: "company", parentId: null, assigneeAgentId: "chosen", status: "todo", labelIds: ["qa"] }), + ]); + await mount("/issues?view=all&status=done&assignee=chosen&workspace=team&q=release&keep=value"); + const taskIds = () => [...host.querySelectorAll("[data-task-id]")].map(node => node.getAttribute("data-task-id")); + const labelCheckbox = () => host.querySelector('[role="checkbox"][aria-label="qa-task"]')!; + flushSync(() => labelCheckbox().click()); + await vi.waitFor(() => expect(taskIds()).toEqual(["qa-done"])); + expect(labelCheckbox().getAttribute("aria-checked")).toBe("true"); + await vi.waitFor(() => expect(host.querySelector("output")!.textContent).toContain("label=qa")); + flushSync(() => [...host.querySelectorAll("button")].find(button => button.textContent === "Sort by task ID")!.click()); + await vi.waitFor(() => expect(host.querySelector("output")!.textContent).toContain("sortField=taskId")); + const search = host.querySelector("output")!.textContent!; + flushSync(() => root.unmount()); + root = createRoot(host); + client.clear(); + await mount(`/issues${search}`); + await vi.waitFor(() => expect(taskIds()).toEqual(["qa-done"])); + expect(labelCheckbox().getAttribute("aria-checked")).toBe("true"); + const params = new URLSearchParams(host.querySelector("output")!.textContent!); + expect(params.getAll("label")).toEqual(["qa"]); + expect(Object.fromEntries(params)).toMatchObject({ view: "all", status: "done", assignee: "chosen", workspace: "team", q: "release", keep: "value", sortField: "taskId", sortDir: "desc" }); + }); + it("restores repeated labels from label-only links and follows history without clearing other filters", async () => { + mocks.realFilters = true; + mocks.labels = [{ id: "qa", name: "qa-task", color: "var(--accent)" }, { id: "review", name: "review-task", color: "var(--accent)" }]; + mocks.list.mockResolvedValue([ + createIssue({ id: "qa-high", companyId: "company", parentId: null, labelIds: ["qa"], priority: "high" }), + createIssue({ id: "review-high", companyId: "company", parentId: null, labelIds: ["review"], priority: "high" }), + createIssue({ id: "review-low", companyId: "company", parentId: null, labelIds: ["review"], priority: "low" }), + createIssue({ id: "unlabeled", companyId: "company", parentId: null, labelIds: [], priority: "high" }), + ]); + function Navigation() { + const navigate = useNavigate(); + return <> + ; + } + flushSync(() => root.render()); + const taskIds = () => [...host.querySelectorAll("[data-task-id]")].map(node => node.getAttribute("data-task-id")); + const click = (label: string) => flushSync(() => [...host.querySelectorAll("button")].find(button => button.textContent === label)!.click()); + await vi.waitFor(() => expect(taskIds()).toEqual(["qa-high", "review-high", "review-low"])); + expect(host.querySelector("[data-selected-view]")?.textContent).toBe("all"); + expect(host.querySelector('[role="checkbox"][aria-label="qa-task"]')?.getAttribute("aria-checked")).toBe("true"); + expect(host.querySelector('[role="checkbox"][aria-label="review-task"]')?.getAttribute("aria-checked")).toBe("true"); + click("High priority"); click("Review label"); + await vi.waitFor(() => expect(taskIds()).toEqual(["review-high"])); + click("Back"); + await vi.waitFor(() => expect(taskIds()).toEqual(["qa-high", "review-high"])); + click("Forward"); + await vi.waitFor(() => expect(taskIds()).toEqual(["review-high"])); + flushSync(() => host.querySelector('[role="checkbox"][aria-label="qa-task"]')!.click()); + await vi.waitFor(() => expect(new URLSearchParams(host.querySelector("output")!.textContent!).getAll("label")).toEqual(["review", "qa"])); + expect(taskIds()).toEqual(["qa-high", "review-high"]); + flushSync(() => host.querySelector('[role="checkbox"][aria-label="qa-task"]')!.click()); + await vi.waitFor(() => expect(new URLSearchParams(host.querySelector("output")!.textContent!).getAll("label")).toEqual(["review"])); + flushSync(() => host.querySelector('[role="checkbox"][aria-label="review-task"]')!.click()); + await vi.waitFor(() => expect(taskIds()).toEqual(["qa-high", "review-high", "unlabeled"])); + await vi.waitFor(() => expect(new URLSearchParams(host.querySelector("output")!.textContent!).getAll("label")).toEqual([])); + const params = new URLSearchParams(host.querySelector("output")!.textContent!); + expect(params.getAll("label")).toEqual([]); + expect(params.get("keep")).toBe("value"); + }); + it("keeps unknown labels restrictive and removes them when clearing an empty collection", async () => { + mocks.list.mockResolvedValue([createIssue({ id: "unlabeled", companyId: "company", parentId: null, labelIds: [] })]); + await mount("/issues?view=all&label=missing&label=other-missing&keep=value&sortField=taskId&sortDir=asc"); + expect(host.querySelectorAll("[data-task-id]")).toHaveLength(0); + flushSync(() => [...host.querySelectorAll("button")].find(button => button.textContent === "Clear filters")!.click()); + await vi.waitFor(() => expect(host.querySelector('[data-task-id="unlabeled"]')).not.toBeNull()); + await vi.waitFor(() => expect(new URLSearchParams(host.querySelector("output")!.textContent!).getAll("label")).toEqual([])); + const params = new URLSearchParams(host.querySelector("output")!.textContent!); + expect(params.getAll("label")).toEqual([]); + expect(Object.fromEntries(params)).toMatchObject({ view: "all", keep: "value", sortField: "taskId", sortDir: "asc" }); + }); + it.each(["mine", "blocked", "recent", "everything"])("keeps Coordinator and local filters/layout when the last label is removed from a legacy %s link", async view => { + mocks.realFilters = true; + mocks.labels = [{ id: "qa", name: "qa-task", color: "var(--accent)" }]; + mocks.list.mockResolvedValue([ + createIssue({ id: "qa-high", companyId: "company", parentId: null, labelIds: ["qa"], priority: "high" }), + createIssue({ id: "qa-low", companyId: "company", parentId: null, labelIds: ["qa"], priority: "low" }), + createIssue({ id: "unlabeled-high", companyId: "company", parentId: null, labelIds: [], priority: "high" }), + ]); + function CurrentRoute() { + const location = useLocation(); const navigate = useNavigate(); + return <>{location.pathname}{location.search}{location.hash} + + ; + } + flushSync(() => root.render( + } />Inbox owner

} /> +
)); + const taskIds = () => [...host.querySelectorAll("[data-task-id]")].map(node => node.getAttribute("data-task-id")); + await vi.waitFor(() => expect(taskIds()).toEqual(["qa-high", "qa-low"])); + flushSync(() => [...host.querySelectorAll("button")].find(button => button.textContent === "High priority")!.click()); + await vi.waitFor(() => expect(taskIds()).toEqual(["qa-high"])); + flushSync(() => [...host.querySelectorAll("button")].find(button => button.textContent === "Board view")!.click()); + expect(host.querySelector(".design-task-list-panel")?.hidden).toBe(true); + flushSync(() => host.querySelector('[role="checkbox"][aria-label="qa-task"]')!.click()); + await vi.waitFor(() => expect(host.querySelector("[data-route]")?.textContent).toBe("/issues?view=all&keep=value#saved")); + expect(host.querySelector(".design-task-list-panel")?.hidden).toBe(true); + await vi.waitFor(() => expect(taskIds()).toEqual(["qa-high", "unlabeled-high"])); + expect(host.textContent).not.toContain("Inbox owner"); + await vi.waitFor(() => expect(new URLSearchParams(host.querySelector(".design-task-list-panel output")!.textContent!).getAll("label")).toEqual([])); + expect(Object.fromEntries(new URLSearchParams(host.querySelector(".design-task-list-panel output")!.textContent!))).toMatchObject({ view: "all", keep: "value" }); + flushSync(() => host.querySelector('[role="checkbox"][aria-label="qa-task"]')!.click()); + await vi.waitFor(() => expect(host.querySelector("[data-route]")?.textContent).toBe("/issues?view=all&keep=value&label=qa#saved")); + expect(taskIds()).toEqual(["qa-high"]); + flushSync(() => host.querySelector('[role="checkbox"][aria-label="qa-task"]')!.click()); + await vi.waitFor(() => expect(host.querySelector("[data-route]")?.textContent).toBe("/issues?view=all&keep=value#saved")); + const click = (label: string) => flushSync(() => [...host.querySelectorAll("button")].find(button => button.textContent === label)!.click()); + click("Restore label link"); + await vi.waitFor(() => expect(taskIds()).toEqual(["qa-high"])); + click("Back"); + await vi.waitFor(() => expect(taskIds()).toEqual(["qa-high", "unlabeled-high"])); + click("Forward"); + await vi.waitFor(() => expect(taskIds()).toEqual(["qa-high"])); + expect(host.querySelector(".design-task-list-panel")?.hidden).toBe(true); + expect(host.querySelector("[data-route]")?.textContent).toBe("/issues?view=all&label=qa&keep=value#saved"); + }); it("still applies assignee filters to server search matches", async () => { mocks.list.mockResolvedValue([ createIssue({ id: "chosen", companyId: "company", parentId: null, assigneeAgentId: "chosen-agent" }), diff --git a/ui/src/pages/CoordinatorTasks.tsx b/ui/src/pages/CoordinatorTasks.tsx index 697efd5e9ab..98c401bea66 100644 --- a/ui/src/pages/CoordinatorTasks.tsx +++ b/ui/src/pages/CoordinatorTasks.tsx @@ -29,7 +29,7 @@ import { coordinatorAttentionTasksQueryKey } from "@/lib/coordinator-attention"; import { createIssueDetailLocationState } from "@/lib/issueDetailBreadcrumb"; import { defaultIssueFilterState, type IssueFilterState } from "@/lib/issue-filters"; import { queryKeys } from "@/lib/queryKeys"; -import { useLocation, useSearchParams } from "@/lib/router"; +import { useLocation, useNavigate, useSearchParams } from "@/lib/router"; import { loadTaskListView, saveTaskListView, taskListViews, taskListViewStorageKey, type TaskListView } from "@/lib/task-list-views"; import { normalizeTaskViewKey, ORGANIZATION_SCOPED_PARAMS, taskView } from "@/lib/task-views"; import type { Issue } from "@paperclipai/shared"; @@ -46,16 +46,18 @@ export function CoordinatorTasks() { const client = useQueryClient(); const listRef = useRef(null); const location = useLocation(); + const navigate = useNavigate(); 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 urlLabels = JSON.stringify(params.getAll("label").filter(Boolean)); const urlView = params.get("view"); const viewStorageKey = taskListViewStorageKey("coordinator", userId ?? "local-board"); const [preference, setPreference] = useState(() => ({ key: viewStorageKey, view: loadTaskListView(viewStorageKey, undefined, "inbox") })); const savedView = preference.key === viewStorageKey ? preference.view : loadTaskListView(viewStorageKey, undefined, "inbox"); const explicitView = urlView === "needs_attention" ? "blocked" : taskListViews.find(option => option.value === urlView)?.value; - const organizationScoped = [...ORGANIZATION_SCOPED_PARAMS, "workspaceId", "status", "sortField", "sortDir"].some(param => params.getAll(param).some(Boolean)); + const organizationScoped = [...ORGANIZATION_SCOPED_PARAMS, "workspaceId", "status", "label", "sortField", "sortDir"].some(param => params.getAll(param).some(Boolean)); const view: TaskListView = explicitView ?? (organizationScoped || normalizeTaskViewKey(urlView) ? "all" : savedView); useEffect(() => { if (!identitySettled) return; @@ -89,7 +91,7 @@ export function CoordinatorTasks() { const normalizedView = normalizeTaskViewKey(urlView); const presetStatuses = normalizedView && taskView(normalizedView).surface === "issues" ? taskView(normalizedView).statuses ?? [] : []; const initialStatuses: string[] = params.has("status") ? params.getAll("status") : presetStatuses; - const previousUrlFilters = useRef({ assignee: urlAssignee, statuses: urlStatuses, view: urlView, routineTasksOnly }); + const previousUrlFilters = useRef({ assignee: urlAssignee, statuses: urlStatuses, labels: urlLabels, view: urlView, routineTasksOnly }); const needsAttention = view === "blocked"; const query = params.get("q") ?? ""; const deferredQuery = useDeferredValue(query.trim()); @@ -196,48 +198,49 @@ export function CoordinatorTasks() { searchMode: "server", liveIssueIds: collectLiveIssueIds(liveRuns.data, records), viewStorageKey, - initialFilters: { assignees: params.getAll("assignee"), statuses: initialStatuses, routineTasksOnly }, + initialFilters: { labels: params.getAll("label").filter(Boolean), assignees: params.getAll("assignee"), statuses: initialStatuses, routineTasksOnly }, }); useEffect(() => { const previous = previousUrlFilters.current; - if (previous.assignee === urlAssignee && previous.view === urlView && previous.statuses === urlStatuses && previous.routineTasksOnly === routineTasksOnly) return; - previousUrlFilters.current = { assignee: urlAssignee, statuses: urlStatuses, view: urlView, routineTasksOnly }; + if (previous.assignee === urlAssignee && previous.view === urlView && previous.statuses === urlStatuses && previous.labels === urlLabels && previous.routineTasksOnly === routineTasksOnly) return; + previousUrlFilters.current = { assignee: urlAssignee, statuses: urlStatuses, labels: urlLabels, view: urlView, routineTasksOnly }; collection.updateFilters({ + ...(previous.labels !== urlLabels ? { labels: params.getAll("label").filter(Boolean) } : {}), ...(previous.routineTasksOnly !== routineTasksOnly ? { routineTasksOnly } : {}), ...(previous.assignee !== urlAssignee ? { assignees: params.getAll("assignee") } : {}), ...(previous.view !== urlView || previous.statuses !== urlStatuses ? { statuses: initialStatuses } : {}), }); - }, [urlAssignee, urlView, urlStatuses, initialStatuses, routineTasksOnly, collection.updateFilters]); + }, [urlAssignee, urlView, urlStatuses, urlLabels, initialStatuses, routineTasksOnly, collection.updateFilters]); const updateFilters = (patch: Partial) => { collection.updateFilters(patch); - if (patch.assignees === undefined && patch.statuses === undefined && patch.routineTasksOnly === undefined) return; - setParams(current => { - const next = new URLSearchParams(current); - if (patch.routineTasksOnly !== undefined) { - if (patch.routineTasksOnly) next.set("originKind", "routine_execution"); - else next.delete("originKind"); - } - if (patch.assignees !== undefined) { - next.delete("assignee"); - for (const id of patch.assignees) next.append("assignee", id); - } - if (patch.statuses !== undefined) { - next.delete("status"); - for (const status of patch.statuses) next.append("status", status); - const preset = normalizeTaskViewKey(next.get("view")); - if (preset && taskView(preset).surface === "issues") next.set("view", "all"); - } - return next; - }, { replace: true }); + if (patch.assignees === undefined && patch.statuses === undefined && patch.labels === undefined && patch.routineTasksOnly === undefined) return; + const next = new URLSearchParams(params); + if (patch.routineTasksOnly !== undefined) { + if (patch.routineTasksOnly) next.set("originKind", "routine_execution"); + else next.delete("originKind"); + } + if (patch.labels !== undefined) { + next.delete("label"); + for (const id of patch.labels) next.append("label", id); + } + if (patch.assignees !== undefined) { + next.delete("assignee"); + for (const id of patch.assignees) next.append("assignee", id); + } + if (patch.statuses !== undefined) { + next.delete("status"); + for (const status of patch.statuses) next.append("status", status); + const preset = normalizeTaskViewKey(next.get("view")); + if (preset && taskView(preset).surface === "issues") next.set("view", "all"); + } + navigate({ search: next.toString(), hash: location.hash }, { replace: true }); }; const clearFilters = () => { collection.updateFilters(defaultIssueFilterState); - setParams(current => { - const next = new URLSearchParams(current); - for (const key of ["q", "assignee", "status", "originKind"]) next.delete(key); - if (next.get("view") !== "needs_attention") next.set("view", "all"); - return next; - }, { replace: true }); + const next = new URLSearchParams(params); + for (const key of ["q", "assignee", "status", "label", "originKind"]) next.delete(key); + if (next.get("view") !== "needs_attention") next.set("view", "all"); + navigate({ search: next.toString(), hash: location.hash }, { replace: true }); }; const update = useMutation({ mutationFn: ({ id, data }: { id: string; data: Record }) => issuesApi.update(id, data), onSuccess: () => { void client.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) }); void client.invalidateQueries({ queryKey: queryKeys.attention(companyId!) }); }, diff --git a/ui/src/pages/Issues.coordinator-routing.test.tsx b/ui/src/pages/Issues.coordinator-routing.test.tsx index a8e50b54e4b..db3c661c140 100644 --- a/ui/src/pages/Issues.coordinator-routing.test.tsx +++ b/ui/src/pages/Issues.coordinator-routing.test.tsx @@ -40,3 +40,13 @@ it.each(["workspace=workspace", "originKind=routine_execution"])("keeps organiza try { expect(host.textContent).toContain("Coordinator tasks"); expect(host.textContent).not.toContain("Inbox owner"); } finally { cleanup(); } }); + +it.each(["mine", "blocked", "recent", "everything"])("keeps shared %s label links on the Coordinator collection", async view => { + const url = `/ORG/issues?view=${view}&label=qa&label=review&sortField=taskId&sortDir=asc&keep=value#saved`; + const { host, cleanup } = render(url); + try { + await vi.waitFor(() => expect(host.textContent).toContain("Coordinator tasks")); + expect(host.textContent).not.toContain("Inbox owner"); + expect(host.querySelector("output")?.textContent).toBe(url.replace(`view=${view}`, "view=all")); + } finally { cleanup(); } +}); diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 4109a1cfb84..f99a97d1c46 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -105,7 +105,12 @@ export function Issues() { const [params] = useSearchParams(); const location = useLocation(); const requestedView = normalizeTaskViewKey(params.get(TASK_VIEW_PARAM)); - const organizationScoped = [...ORGANIZATION_SCOPED_PARAMS, "workspaceId"].some(param => params.getAll(param).some(Boolean)); + const organizationScoped = [...ORGANIZATION_SCOPED_PARAMS, "workspaceId", "label"].some(param => params.getAll(param).some(Boolean)); + if (!designSandbox && coordinatorEnabled && params.getAll("label").some(Boolean) && requestedView && requestedView !== "unread" && taskView(requestedView).surface === "inbox") { + const next = new URLSearchParams(params); + next.set(TASK_VIEW_PARAM, "all"); + return ; + } if (!designSandbox && coordinatorEnabled && !organizationScoped && requestedView && requestedView !== "unread" && taskView(requestedView).surface === "inbox") { const next = new URLSearchParams(params); next.delete(TASK_VIEW_PARAM); const search = next.toString();