Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ above fails otherwise.
- [`SUBAGENTS.md`](SUBAGENTS.md) — Sub-agents: default-on, parent decides, typed children (#1043)
- [`TASK-SCHEDULE-UX.md`](TASK-SCHEDULE-UX.md) — Create Task schedule controls
- [`TASK-SERIALIZATION.md`](TASK-SERIALIZATION.md) — Task serialization — opaque `serialization_key` mutual exclusion (#709)
- [`TASK-TAGS.md`](TASK-TAGS.md) — Task tags on the board — chips, the tag filter, and the catalogue TTL
- [`TASK-TITLES.md`](TASK-TITLES.md) — Task titles
- [`TEAM-SHARING.md`](TEAM-SHARING.md) — Sharing work inside a project — team-shared chats and team learnings
- [`TESTING.md`](TESTING.md) — Testing fleet
Expand Down
89 changes: 89 additions & 0 deletions docs/TASK-TAGS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Task tags on the board

What shipped when tags stopped being write-only, what deviated, and what was
deliberately left out. The user-facing description lives in the Operations
Center guide ("Finding things"); this note is for whoever changes the code.

## The gap

Tags (#212) were storable and queryable but invisible. The create form accepted
them, `models.Task` carried them, `TaskFilter.Tags` filtered on them,
`GET /tasks?tag=a&tag=b` narrowed to tasks carrying **both**, and
`GET /tasks/tags` returned the whole catalogue with per-tag counts — and no
surface in the web app ever rendered a tag again. So the one thing a tag is
for, finding the rest of its group, could not be done from the UI at all.

The gap was found while writing the user guide, which is worth recording: the
guide had to describe tags as "stored metadata rather than a control on that
screen", and a sentence that awkward is usually a defect wearing prose.

## What shipped

- **Chips.** A task's tags render on its table row and its phone card, coloured
from the same hashed palette (`shared/lib/labelColors`) as the chat
conversation labels, so one tag reads the same everywhere it appears.
- **Every chip is a control.** Clicking one adds that tag to the board's
filter; clicking a selected one removes it. Tags AND server-side, so each
addition narrows and each removal widens.
- **A Tags group in the filter bar** — a select that *adds* a tag, plus a
removable chip per selected tag. The select never holds a value: the board is
filtered by every chip beside it, not by the last one chosen, and a select
reading `ops` while `ops + urgent` were applied would misstate the board.
- **Tags count as an active filter**, so **Clear filters** appears and clears
them. Without that the only way back to the full board was a page reload.
- **`/api/orchestrator/tasks/tags`**, a thin proxy to the existing catalogue
endpoint. The static `tags` segment wins over the sibling `[taskId]` route,
so it does not shadow `GET /tasks/{id}` — the same ordering `cmd/fleet/main.go`
spells out explicitly for the Go router.

Two things underneath had to change:

- **`passThroughQuery` forwards every value of a repeated parameter.** It read
only the first, 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. Single-valued
parameters behave exactly as before.
- **The phone card's box moved from its `<button>` to the enclosing `<li>`.**
The chips cannot live inside the card button (see below), so they render as
its sibling; moving the border, radius and background one level out is what
keeps them inside the visible card.

## Two decisions worth keeping

**A chip is a `<button>`, and on the phone card it is NOT inside the card
button.** The card is itself a `<button>`, and a control nested inside a button
has invalid accessibility semantics however it is marked up — assistive
technology can expose only the outer "View task" control, or make the tag
action ambiguous. The first version dressed the chip as a `<span
role="button">`, which dodges the HTML parsing rule and keeps the actual
problem. Siblings, not children. `TasksTable.test.tsx` pins this.

(The table row is also `role="button"` and already nests real buttons — run
now, delete. That predates this change and was left alone rather than widening
the PR; it is a reasonable thing to revisit.)

**The catalogue has a TTL, not a fetch-once and not a fetch-every-reload.**
`GET /tasks/tags` is a `GROUP BY` over every task's tag array. Fetching it with
the dashboard's 30s refresh would pay for that constantly to catch a list that
changes only when somebody retags something; fetching it once per activation
left a tag created later, on a task not on the current page, unreachable until
a full reload, because `active` stays true for the whole signed-in session.
`TAG_CATALOGUE_TTL_MS` (5 minutes) bounds both. The gap it leaves is closed
from the other side: `tagOptions` is the catalogue **unioned with the tags on
the listed tasks**, so a brand-new tag is selectable the moment a task carrying
it appears, without waiting for the refresh.

## Honest scope

- **The catalogue is deployment-wide; the board is not.** A non-admin sees only
their own tasks, so a tag a colleague uses can appear in the dropdown and
filter down to nothing. This is the same pre-existing property as the
dashboard counters, it is not introduced here, and the guide states it rather
than hiding it.
- **No tag counts in the UI.** The catalogue returns them, but "ops (12)" above
a board showing two of them is a number that is wrong for most readers, for
the reason above. Names only.
- **Not shipped:** tag management from the board (renaming or deleting a tag
across tasks — retagging is per-task, through the form or
`POST /tasks/{id}/tags`), and no tag filter on the Upcoming or Sleeping
panels.
9 changes: 6 additions & 3 deletions docs/USER-GUIDES.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,12 @@ had drifted. Corrected while porting:
by default, but where an operator sets it `ExpirePausedTasks` fails an
unanswered run and clears its question. The draft promised "no rush"
unconditionally.
- **Tags do not filter the board.** The API takes `?tag=`, but `TaskFilters`
offers only status, creator, scheduled-only and text, so the guide describes
tags as stored metadata rather than a control on that screen.
- **Tags did not filter the board.** The API took `?tag=`, but `TaskFilters`
offered only status, creator, scheduled-only and text, so the guide described
tags as stored metadata rather than a control on that screen. *Since closed:*
writing that sentence is what surfaced the gap, and the board now shows tags
as chips and filters by them — see [`docs/TASK-TAGS.md`](TASK-TAGS.md). The
guide describes the control.
- **`ERROR` is not where a failure waits for you — `DEAD_LETTERED` is.**
`handleRunFailure` re-queues a retryable failure (back to `PENDING`, with
backoff), quarantines a deterministic one on its *first* attempt, and reaches
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
bradflaugher marked this conversation as resolved.
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
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions web/src/app/api/orchestrator/_lib/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
10 changes: 8 additions & 2 deletions web/src/app/api/orchestrator/_lib/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` : "";
Expand Down
2 changes: 2 additions & 0 deletions web/src/app/api/orchestrator/tasks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down
15 changes: 15 additions & 0 deletions web/src/app/api/orchestrator/tasks/tags/route.ts
Original file line number Diff line number Diff line change
@@ -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");
}
85 changes: 82 additions & 3 deletions web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -3057,6 +3057,76 @@ 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;
}
/* The chips are a sibling of the card button, so they carry the padding the
button would have given them — aligned to its text, not to the card edge. */
.task-cards .task-tag-row {
padding: 0 0.8rem 0.7rem;
margin-top: 0;
}
.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;
Expand All @@ -3083,15 +3153,24 @@ tr.sla-row-fail {
margin: 0;
padding: 0;
}
/* The box lives on the <li>, not on the button inside it. A task's tag chips
are controls and cannot be nested inside the card's own <button>, so they
render as its sibling — and this is what keeps them within the same visible
card rather than stranded beneath it. */
.task-cards > li {
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 0.55rem);
background: var(--color-surface-2, rgba(255, 255, 255, 0.03));
overflow: hidden;
}
.task-card {
display: grid;
gap: 0.45rem;
width: 100%;
text-align: left;
padding: 0.7rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 0.55rem);
background: var(--color-surface-2, rgba(255, 255, 255, 0.03));
border: 0;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
}
Expand Down
Loading