From 8ac8828319ddaa757168b5ff08a96f697444ad30 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 20:04:52 +0000 Subject: [PATCH 1/3] Show tags on the board and filter by them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tags were write-only. The create form accepted them, the API stored them, the server could already filter on them (`?tag=a&tag=b`, ANDed) — and no surface in the web app ever showed one again. The one thing a tag is for, finding the rest of its group, could not be done from the UI at all. Now: - A task's tags render as chips on its row and on its phone card, coloured by the same hashed palette as the chat label chips so a tag reads the same everywhere. Each chip is a control: clicking one adds that tag to the board's filter, clicking a selected one removes it. - The filter bar gains a **Tags** group — a select that ADDS a tag (it never holds a value, because the board is filtered by every chip beside it, not by the last one chosen) plus a removable chip per selected tag. - Options come from `GET /tasks/tags`, the existing catalogue endpoint, fetched once per activation rather than on the 30s refresh: it is a GROUP BY over every task's tag array and it changes only when someone retags something. The offered set is that catalogue unioned with the tags on the listed tasks, so a tag created after the catalogue loaded is still selectable as soon as a task carrying it appears. - Tags count as an active filter, so Clear filters appears and clears them. Two things had to change underneath. `passThroughQuery` read only the first value of each parameter, which is right for every single-valued filter and wrong for `tag`: dropping the second of `?tag=a&tag=b` widens the result instead of narrowing it, the one direction a filter must never fail in. It now forwards every value, unchanged for single-valued parameters. And `/api/orchestrator/tasks/tags` did not exist; the static segment wins over the sibling `[taskId]` route, so it does not shadow GET /tasks/{id}. The Operations Center guide said to treat tags as a label for your own grouping rather than a control on this screen. That was true and is not any more. Verified: `make ci-web` (153 files / 1608 tests, audit clean, build clean), `tsc --noEmit`, and the mocked Playwright suite (98 passed). The three tests that guard real defects — every tag reaching the query, a chip not opening the log viewer, tags counting as an active filter — were each confirmed to fail against the bug they describe. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018qQJHUvRV2FM7Rj2GfUNqg --- .../fleet-guide/operations-center.md | 13 +- .../app/api/orchestrator/_lib/proxy.test.ts | 60 +++++++ web/src/app/api/orchestrator/_lib/proxy.ts | 10 +- web/src/app/api/orchestrator/tasks/route.ts | 2 + .../app/api/orchestrator/tasks/tags/route.ts | 15 ++ web/src/app/globals.css | 64 ++++++++ web/src/app/help/guides/operations-center.md | 13 +- web/src/app/orchestrator/TasksTable.test.tsx | 116 +++++++++++++ web/src/app/orchestrator/TasksTable.tsx | 155 +++++++++++++++++- .../app/orchestrator/orchestrator-client.tsx | 1 + .../app/shared/hooks/useDashboardData.test.ts | 77 +++++++++ web/src/app/shared/hooks/useDashboardData.ts | 46 +++++- web/src/app/shared/lib/orchestratorApi.ts | 12 ++ 13 files changed, 578 insertions(+), 6 deletions(-) create mode 100644 web/src/app/api/orchestrator/_lib/proxy.test.ts create mode 100644 web/src/app/api/orchestrator/tasks/tags/route.ts diff --git a/internal/clientconfig/builtin_skills/fleet-guide/operations-center.md b/internal/clientconfig/builtin_skills/fleet-guide/operations-center.md index 27f7a369d..2e965a278 100644 --- a/internal/clientconfig/builtin_skills/fleet-guide/operations-center.md +++ b/internal/clientconfig/builtin_skills/fleet-guide/operations-center.md @@ -93,6 +93,17 @@ the wake, not a second copy started alongside it. Stop is offered on both. The board filters by **Status** and **Created by**, narrows to **Scheduled only**, and searches across title, prompt, and ID. +It also filters by **tag**. A task's tags appear as small coloured chips on its +row; clicking one narrows the board to that tag, and the **Tags** dropdown in +the filter bar picks from every tag in use across the deployment — including +ones no task on the current page carries. Tags stack: each one you add narrows +the board further, to tasks carrying *all* of them. Remove a tag by clicking +its chip in the filter bar, or drop everything at once with **Clear filters**. + +Like the counters above, the dropdown lists tags from the whole deployment +while the board shows only your own tasks, so a tag a colleague uses can filter +down to nothing. + ### Whose tasks you see The board shows the tasks **you created**. Workspace admins see everyone's, which @@ -120,7 +131,7 @@ with it. | **Schedule** | Three modes: **Run now**, **Run once** at a date and time, or **Repeat**. Repeat offers a plain-language builder for daily, weekday, and weekly patterns, and an advanced field for anything else. A repeat can also end on its own, under **End repeat**: never, on a date, or after a set number of runs. The form always previews the computed next run; read it before launching. | | **Recipients** | The email addresses that receive each run's result. You set them here rather than in the library prompt, so the same prompt can serve different audiences, and the form keeps them across an edit — including when you re-insert a different prompt from the library. (They are delivered as an instruction the form writes into the task's prompt for you. That only matters if you drive the API directly: a client that replaces a task's prompt wholesale replaces that instruction too.) | | **Tools & files** | What the task may reach: mailboxes, connectors, files. Some connections are always on for every run; the rest are selected per task, so new tasks start with your deployment's recommended set and an existing task never silently gains new connections. Files can also be attached directly to the task, for work that runs against a fixed reference like a template or a lookup table. | -| **Context** | Notes that travel with the task for the people who operate it: why it exists, who owns it, what to do if it fails. These are shown to operators and never enter the assistant's instructions. Alongside them sit **tags** and the task's **persona**, which is left blank for the workspace default unless the task genuinely needs a different one. Tags are stored with the task and can be filtered on through the API; the board itself filters by status, creator and text, so treat tags as a label for your own grouping rather than a control on this screen. | +| **Context** | Notes that travel with the task for the people who operate it: why it exists, who owns it, what to do if it fails. These are shown to operators and never enter the assistant's instructions. Alongside them sit **tags** and the task's **persona**, which is left blank for the workspace default unless the task genuinely needs a different one. Tags are how you group related tasks: they show as chips on the board and it filters by them (see [Finding things](#finding-things)), so a tag you give a task here is a way back to the whole group later. | | **Advanced** | Further settings, including the model the task runs on and an option for a recurring task to carry a short summary of its previous run into the next one. The model in particular is worth choosing deliberately: match it to the demands of the job rather than leaving it to chance. | **Estimate Cost**, beneath the form, produces a **cost forecast** on demand: the diff --git a/web/src/app/api/orchestrator/_lib/proxy.test.ts b/web/src/app/api/orchestrator/_lib/proxy.test.ts new file mode 100644 index 000000000..1931d87b7 --- /dev/null +++ b/web/src/app/api/orchestrator/_lib/proxy.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { NextRequest } from "next/server"; + +import { passThroughQuery } from "./proxy"; + +const ORIGIN = "https://chat.example.com"; + +function request(query: string): NextRequest { + return new NextRequest(`${ORIGIN}/api/orchestrator/tasks?${query}`); +} + +// passThroughQuery is the allow-list between the browser and the orchestrator. +// It read only the FIRST value of each param, which is correct for every +// single-valued filter and wrong for `tag`: ?tag=a&tag=b means "carrying BOTH" +// (the server ANDs them), so dropping b WIDENED the result instead of +// narrowing it — the one direction a filter must never fail in. +describe("passThroughQuery", () => { + it("forwards every value of a repeated param", () => { + const qs = passThroughQuery(request("tag=ops&tag=urgent"), ["tag"]); + expect(new URLSearchParams(qs.slice(1)).getAll("tag")).toEqual(["ops", "urgent"]); + }); + + it("passes a single-valued param through unchanged", () => { + expect(passThroughQuery(request("status=running"), ["status"])).toBe("?status=running"); + }); + + it("drops params outside the allow-list", () => { + expect(passThroughQuery(request("status=running&secret=x"), ["status"])).toBe( + "?status=running", + ); + expect(passThroughQuery(request("tag=ops"), ["status"])).toBe(""); + }); + + it("drops empty values rather than forwarding a blank filter", () => { + expect(passThroughQuery(request("status=&q=hello"), ["status", "q"])).toBe("?q=hello"); + // An empty value among repeated ones drops only itself. + const qs = passThroughQuery(request("tag=ops&tag=&tag=urgent"), ["tag"]); + expect(new URLSearchParams(qs.slice(1)).getAll("tag")).toEqual(["ops", "urgent"]); + }); + + it("returns an empty string, not a bare '?', when nothing passes", () => { + expect(passThroughQuery(request("nope=1"), ["status"])).toBe(""); + }); + + it("emits params in allow-list order, not the caller's", () => { + expect(passThroughQuery(request("q=hi&status=running"), ["status", "q"])).toBe( + "?status=running&q=hi", + ); + }); + + it("keeps a comma-separated value intact for completed_status", () => { + // The Failed Today card sends two statuses in one value; splitting or + // truncating it here would silently halve the filter. + const qs = passThroughQuery( + request("completed_status=error%2Cdead_lettered"), + ["completed_status"], + ); + expect(new URLSearchParams(qs.slice(1)).get("completed_status")).toBe("error,dead_lettered"); + }); +}); diff --git a/web/src/app/api/orchestrator/_lib/proxy.ts b/web/src/app/api/orchestrator/_lib/proxy.ts index e820818c8..0c9de8670 100644 --- a/web/src/app/api/orchestrator/_lib/proxy.ts +++ b/web/src/app/api/orchestrator/_lib/proxy.ts @@ -84,12 +84,18 @@ export async function proxyToOrchestrator( /** * Builds the upstream query string from the incoming request's search params, * passing through only the named allow-list of params. + * + * Every value of a repeated param is forwarded, not just the first: the task + * list's `tag` filter is repeatable (`?tag=a&tag=b` means "carrying BOTH"), and + * keeping only the first silently widened that to "carrying a". Single-valued + * params are unaffected — one value in, one value out, in allow-list order. */ export function passThroughQuery(request: NextRequest, allowed: string[]): string { const out = new URLSearchParams(); for (const key of allowed) { - const v = request.nextUrl.searchParams.get(key); - if (v !== null && v !== "") out.set(key, v); + for (const v of request.nextUrl.searchParams.getAll(key)) { + if (v !== "") out.append(key, v); + } } const qs = out.toString(); return qs ? `?${qs}` : ""; diff --git a/web/src/app/api/orchestrator/tasks/route.ts b/web/src/app/api/orchestrator/tasks/route.ts index 43108ea5d..aa314a515 100644 --- a/web/src/app/api/orchestrator/tasks/route.ts +++ b/web/src/app/api/orchestrator/tasks/route.ts @@ -14,6 +14,8 @@ export async function GET(request: NextRequest) { "completed_today", "completed_status", "created_by", + // Repeatable: ?tag=a&tag=b narrows to tasks carrying BOTH (#212). + "tag", ]); return proxyToOrchestrator(request, `/tasks${qs}`); } diff --git a/web/src/app/api/orchestrator/tasks/tags/route.ts b/web/src/app/api/orchestrator/tasks/tags/route.ts new file mode 100644 index 000000000..bf3e4ab17 --- /dev/null +++ b/web/src/app/api/orchestrator/tasks/tags/route.ts @@ -0,0 +1,15 @@ +import { NextRequest } from "next/server"; +import { proxyToOrchestrator } from "../../_lib/proxy"; + +export const runtime = "nodejs"; + +// GET /api/orchestrator/tasks/tags → orchestrator GET /tasks/tags (#212): the +// distinct tags in use, busiest first. Feeds the board's tag filter, which +// needs the tags that exist rather than only those on the page in front of you. +// +// The static `tags` segment wins over the sibling `[taskId]` route, so this +// does not shadow GET /tasks/{id} — the same ordering the Go router spells out +// explicitly in cmd/fleet/main.go. +export async function GET(request: NextRequest) { + return proxyToOrchestrator(request, "/tasks/tags"); +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 6bc2b4839..4fb3ebee3 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -3057,6 +3057,70 @@ tr.sla-row-fail { flex: 1 1 100%; } } +/* ── Task tags (#212) ───────────────────────────────────────────────────── + Chips share the conversation-label recipe and its hashed --chip colour, so + the same tag reads the same everywhere. Every chip is a button: on a row it + filters the board to its tag, in the filter bar it removes that tag. The + active state is the filled one — a tag currently narrowing the board should + not look identical to one merely present on a task. */ +.task-tag-row { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.3rem; +} +.task-tag-chip { + display: inline-flex; + align-items: center; + gap: 0.2rem; + max-width: 12rem; + overflow: hidden; + font-size: 0.65rem; + font-weight: 500; + line-height: 1; + white-space: nowrap; + text-overflow: ellipsis; + padding: 0.18rem 0.45rem; + border: 1px solid color-mix(in srgb, var(--chip) 38%, transparent); + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--chip) 12%, transparent); + color: color-mix(in srgb, var(--chip) 72%, white); + cursor: pointer; +} +.task-tag-chip:hover { + background: color-mix(in srgb, var(--chip) 24%, transparent); +} +.task-tag-chip-active { + background: color-mix(in srgb, var(--chip) 30%, transparent); + border-color: color-mix(in srgb, var(--chip) 62%, transparent); +} +.task-tag-chip:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} +:root[data-theme="light"] .task-tag-chip { + border-color: color-mix(in srgb, var(--chip) 48%, white); + background: color-mix(in srgb, var(--chip) 14%, white); + color: color-mix(in srgb, var(--chip) 42%, #15110e); +} +:root[data-theme="light"] .task-tag-chip-active { + background: color-mix(in srgb, var(--chip) 30%, white); +} +/* The select and the chips it fills sit on one line until the chips need + more room, at which point they wrap under it rather than squeezing the + select below its min-width. */ +.tag-filter-control { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; +} +.tag-filter-chips { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + max-width: 18rem; +} .tasks-pagination { display: flex; align-items: center; diff --git a/web/src/app/help/guides/operations-center.md b/web/src/app/help/guides/operations-center.md index 27f7a369d..2e965a278 100644 --- a/web/src/app/help/guides/operations-center.md +++ b/web/src/app/help/guides/operations-center.md @@ -93,6 +93,17 @@ the wake, not a second copy started alongside it. Stop is offered on both. The board filters by **Status** and **Created by**, narrows to **Scheduled only**, and searches across title, prompt, and ID. +It also filters by **tag**. A task's tags appear as small coloured chips on its +row; clicking one narrows the board to that tag, and the **Tags** dropdown in +the filter bar picks from every tag in use across the deployment — including +ones no task on the current page carries. Tags stack: each one you add narrows +the board further, to tasks carrying *all* of them. Remove a tag by clicking +its chip in the filter bar, or drop everything at once with **Clear filters**. + +Like the counters above, the dropdown lists tags from the whole deployment +while the board shows only your own tasks, so a tag a colleague uses can filter +down to nothing. + ### Whose tasks you see The board shows the tasks **you created**. Workspace admins see everyone's, which @@ -120,7 +131,7 @@ with it. | **Schedule** | Three modes: **Run now**, **Run once** at a date and time, or **Repeat**. Repeat offers a plain-language builder for daily, weekday, and weekly patterns, and an advanced field for anything else. A repeat can also end on its own, under **End repeat**: never, on a date, or after a set number of runs. The form always previews the computed next run; read it before launching. | | **Recipients** | The email addresses that receive each run's result. You set them here rather than in the library prompt, so the same prompt can serve different audiences, and the form keeps them across an edit — including when you re-insert a different prompt from the library. (They are delivered as an instruction the form writes into the task's prompt for you. That only matters if you drive the API directly: a client that replaces a task's prompt wholesale replaces that instruction too.) | | **Tools & files** | What the task may reach: mailboxes, connectors, files. Some connections are always on for every run; the rest are selected per task, so new tasks start with your deployment's recommended set and an existing task never silently gains new connections. Files can also be attached directly to the task, for work that runs against a fixed reference like a template or a lookup table. | -| **Context** | Notes that travel with the task for the people who operate it: why it exists, who owns it, what to do if it fails. These are shown to operators and never enter the assistant's instructions. Alongside them sit **tags** and the task's **persona**, which is left blank for the workspace default unless the task genuinely needs a different one. Tags are stored with the task and can be filtered on through the API; the board itself filters by status, creator and text, so treat tags as a label for your own grouping rather than a control on this screen. | +| **Context** | Notes that travel with the task for the people who operate it: why it exists, who owns it, what to do if it fails. These are shown to operators and never enter the assistant's instructions. Alongside them sit **tags** and the task's **persona**, which is left blank for the workspace default unless the task genuinely needs a different one. Tags are how you group related tasks: they show as chips on the board and it filters by them (see [Finding things](#finding-things)), so a tag you give a task here is a way back to the whole group later. | | **Advanced** | Further settings, including the model the task runs on and an option for a recurring task to carry a short summary of its previous run into the next one. The model in particular is worth choosing deliberately: match it to the demands of the job rather than leaving it to chance. | **Estimate Cost**, beneath the form, produces a **cost forecast** on demand: the diff --git a/web/src/app/orchestrator/TasksTable.test.tsx b/web/src/app/orchestrator/TasksTable.test.tsx index 2dd1d5609..32ca823e0 100644 --- a/web/src/app/orchestrator/TasksTable.test.tsx +++ b/web/src/app/orchestrator/TasksTable.test.tsx @@ -14,6 +14,7 @@ const FILTERS: TaskFilters = { completedToday: false, completedStatus: "", createdBy: "", + tags: [], }; function renderTable(onFilters: TasksTableProps["onFilters"]) { @@ -489,3 +490,118 @@ describe("TasksTable Clear filters", () => { expect(screen.queryByTestId("tasks-clear-filters")).toBeNull(); }); }); + +// ── Tag filter and tag chips (#212) ────────────────────────────────────── +// Tags were write-only: the create form accepted them, the API stored them, +// and no surface on the board ever showed one again — so the thing a tag is +// for, finding the rest of its group, could not be done from the UI at all. +describe("TasksTable tags", () => { + const tagged: Task = { + id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + prompt: "nightly reconciliation", + status: "success", + tags: ["ops", "billing"], + }; + + function renderTags( + overrides: Partial & { filters?: TaskFilters } = {}, + ) { + const onFilters = vi.fn(); + render( + {}} + onPageSize={() => {}} + onOpenLogs={() => {}} + {...overrides} + />, + ); + return onFilters; + } + + it("shows a task's tags on the board", () => { + renderTags(); + // Table row and phone card both render them (CSS picks one). + expect(screen.getAllByLabelText("Filter by tag ops").length).toBe(2); + expect(screen.getAllByLabelText("Filter by tag billing").length).toBe(2); + }); + + it("filters by a tag when its chip is clicked", () => { + const onFilters = renderTags(); + fireEvent.click(screen.getAllByLabelText("Filter by tag ops")[0]); + expect(onFilters).toHaveBeenCalledWith({ tags: ["ops"] }); + }); + + it("does not open the log viewer when a chip is clicked", () => { + // The row is itself a button; without stopPropagation, filtering by a tag + // would also throw the log modal open over the board you just narrowed. + const onOpenLogs = vi.fn(); + renderTags({ onOpenLogs }); + fireEvent.click(screen.getAllByLabelText("Filter by tag ops")[0]); + expect(onOpenLogs).not.toHaveBeenCalled(); + }); + + it("adds to the selection rather than replacing it — tags AND together", () => { + const onFilters = renderTags({ filters: { ...FILTERS, tags: ["billing"] } }); + fireEvent.click(screen.getAllByLabelText("Filter by tag ops")[0]); + expect(onFilters).toHaveBeenCalledWith({ tags: ["billing", "ops"] }); + }); + + it("removes a tag when its selected chip is clicked again", () => { + const onFilters = renderTags({ filters: { ...FILTERS, tags: ["ops", "billing"] } }); + fireEvent.click(screen.getAllByLabelText("Stop filtering by tag ops")[0]); + expect(onFilters).toHaveBeenCalledWith({ tags: ["billing"] }); + }); + + it("adds a tag chosen from the select", () => { + const onFilters = renderTags(); + fireEvent.change(screen.getByLabelText("Filter by tag"), { target: { value: "urgent" } }); + expect(onFilters).toHaveBeenCalledWith({ tags: ["urgent"] }); + }); + + it("keeps the select on its placeholder — it adds, it does not hold a value", () => { + // Holding the last-added tag would claim the board is filtered by that one + // tag when it is filtered by every chip beside it. + renderTags({ filters: { ...FILTERS, tags: ["ops"] } }); + const select = screen.getByLabelText("Filter by tag") as HTMLSelectElement; + expect(select.value).toBe(""); + // An already-selected tag is not offered twice. + const offered = [...select.options].map((o) => o.value); + expect(offered).not.toContain("ops"); + expect(offered).toContain("urgent"); + }); + + it("hides the control entirely when nothing is tagged", () => { + renderTags({ tasks: [], tagOptions: [] }); + expect(screen.queryByLabelText("Filter by tag")).toBeNull(); + }); + + it("still shows the control when a tag is selected but the catalogue is empty", () => { + // A failed catalogue fetch must not strand an applied filter with no way + // to remove it. + renderTags({ tasks: [], tagOptions: [], filters: { ...FILTERS, tags: ["ops"] } }); + expect(screen.getByLabelText("Filter by tag")).toBeTruthy(); + expect(screen.getByLabelText("Stop filtering by tag ops")).toBeTruthy(); + }); + + it("counts a tag selection as an active filter, so Clear filters appears", () => { + // Without this, the only way back to the full board was a page reload. + renderTags({ + tasks: [], + filters: { ...FILTERS, tags: ["ops"] }, + onClearFilters: () => {}, + }); + expect(screen.getByTestId("tasks-clear-filters")).toBeTruthy(); + }); + + it("renders no chip row for an untagged task", () => { + renderTags({ tasks: [{ ...tagged, tags: undefined }] }); + expect(screen.queryByLabelText(/^Filter by tag /)).toBeNull(); + }); +}); diff --git a/web/src/app/orchestrator/TasksTable.tsx b/web/src/app/orchestrator/TasksTable.tsx index 8e0a0137f..408172f59 100644 --- a/web/src/app/orchestrator/TasksTable.tsx +++ b/web/src/app/orchestrator/TasksTable.tsx @@ -5,6 +5,7 @@ import type { Task } from "@/app/shared/lib/orchestratorApi"; import type { TaskFilters } from "@/app/shared/hooks/useDashboardData"; import { formatTimeFirst, truncate } from "@/app/shared/lib/format"; import { Icon } from "@/app/shared/ui/Icon"; +import { labelChipStyle } from "@/app/shared/lib/labelColors"; import { createdByLabel, scheduleLabel, slaBadge, TaskSlaBadge } from "./taskDisplay"; // Statuses whose tasks can be edited: pending/scheduled edit in place; @@ -59,6 +60,10 @@ export type TasksTableProps = { page: number; pageSize: number; filters: TaskFilters; + // Tags the filter offers (useDashboardData.tagOptions). Empty is a valid + // state — a deployment where nobody tags anything — and hides the control + // rather than showing an empty dropdown. + tagOptions?: string[]; onFilters: (next: Partial) => void; // Reset every filter at once (useDashboardData.clearFilters). The button // only renders while some filter is set, and only when the parent wires it. @@ -104,6 +109,7 @@ export function TasksTable({ page, pageSize, filters, + tagOptions = [], onFilters, onClearFilters, onPage, @@ -150,7 +156,20 @@ export function TasksTable({ filters.query !== "" || filters.scheduledOnly || filters.completedToday || - filters.createdBy !== ""; + filters.createdBy !== "" || + filters.tags.length > 0; + + // Tags AND together server-side, so adding one always narrows and removing + // one always widens. Toggling is the whole interaction: the select adds, + // the chips (in the bar and on the rows) remove or add. + const toggleTag = (tag: string) => { + onFilters({ + tags: filters.tags.includes(tag) + ? filters.tags.filter((t) => t !== tag) + : [...filters.tags, tag], + }); + }; + const unselectedTags = tagOptions.filter((t) => !filters.tags.includes(t)); const totalPages = Math.max(1, Math.ceil(total / pageSize)); const start = total > 0 ? Math.min((page - 1) * pageSize + 1, total) : 0; @@ -200,6 +219,54 @@ export function TasksTable({ + {tagOptions.length > 0 || filters.tags.length > 0 ? ( +
+ +
+
+ +
+ {filters.tags.length > 0 ? ( + + {filters.tags.map((tag) => ( + + ))} + + ) : null} +
+
+ ) : null}