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
47027e7ba5.

**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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-05 07:47:57 -06:00
1 parent e8163fc860
commit b3da1d886a
5 files changed
+190 -38

No files matched your search

+1
View File
@@ -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
+138 -5
View File
@@ -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<ComponentProps<typeof IssueFilterPanel>, "onClear">; view: TaskListView; onViewChange: (view: TaskListView) => void; onLayoutChange: (layout: "list" | "board") => void }) => <>
{mocks.realFilters ? <IssueFilterPanel {...filters} onClear={() => filters.onChange({ labels: [] })} /> : null}
<span data-selected-view>{view}</span>
{taskListViews.map(option => <button key={option.value} onClick={() => onViewChange(option.value)}>{option.label}</button>)}
<button onClick={() => filters.onChange({ routineTasksOnly: true })}>Routine tasks</button>
@@ -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<HTMLButtonElement>('[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 <><button onClick={() => navigate("/issues?view=all&label=review&keep=value")}>Review label</button>
<button onClick={() => navigate(-1)}>Back</button><button onClick={() => navigate(1)}>Forward</button><CoordinatorTasks /></>;
}
flushSync(() => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={["/issues?label=qa&label=review&keep=value"]}><Navigation /></MemoryRouter></QueryClientProvider>));
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<HTMLButtonElement>('[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<HTMLButtonElement>('[role="checkbox"][aria-label="qa-task"]')!.click());
await vi.waitFor(() => expect(new URLSearchParams(host.querySelector("output")!.textContent!).getAll("label")).toEqual(["review"]));
flushSync(() => host.querySelector<HTMLButtonElement>('[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 <><output data-route>{location.pathname}{location.search}{location.hash}</output>
<button onClick={() => navigate("/issues?view=all&label=qa&keep=value#saved")}>Restore label link</button>
<button onClick={() => navigate(-1)}>Back</button><button onClick={() => navigate(1)}>Forward</button></>;
}
flushSync(() => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/issues?view=${view}&label=qa&keep=value#saved`]}><CurrentRoute /><Routes>
<Route path="/issues" element={<Issues />} /><Route path="/inbox/:tab" element={<p>Inbox owner</p>} />
</Routes></MemoryRouter></QueryClientProvider>));
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<HTMLDivElement>(".design-task-list-panel")?.hidden).toBe(true);
flushSync(() => host.querySelector<HTMLButtonElement>('[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<HTMLDivElement>(".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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLDivElement>(".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" }),
+35 -32
View File
@@ -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<HTMLElement>(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<IssueFilterState>) => {
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<string, unknown> }) => issuesApi.update(id, data),
onSuccess: () => { void client.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) }); void client.invalidateQueries({ queryKey: queryKeys.attention(companyId!) }); },
@@ -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(); }
});
+6 -1
View File
@@ -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 <Navigate to={`/issues?${next.toString()}${location.hash}`} replace />;
}
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();