Skip to content

[Feature]: Thread tags — an ordered palette that gives threads a real priority, and the sidebar a filter to work one tag at a time #124

Description

@eddy-curly

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I am describing a concrete problem or use case, not just a vague idea.

Area

apps/web

Problem or use case

I want to open the sidebar and see, without thinking, which threads I have decided matter — and be able to hide everything else while I work on them. Today there is no way to record why a thread matters or how much, so that decision lives in my head and gets re-made every time I open the app.

Measured against my own install (~/.t3/userdata/state.sqlite, opened read-only, 2026-08-24):

threads, not deleted 183
not archived 173
explicitly settled (settled_override = 'settled') 155
carrying no settle override at all 28
snoozed 5
in the active block (settled_at IS NULL, not archived, not snoozed) 13
pinned 0
projects holding live threads 11

Read that table the right way round, because it argues against the obvious version of this request: I am not drowning. I curate hard — 155 explicit settles — and the active block is about 13 rows spanning 11 projects. The problem is not volume. The problem is that those 13 rows are indistinguishable from one another. Two or three of them are the thing I actually care about this week. The sidebar has no way to know that, so it treats all 13 the same, and so does every other surface.

Four primitives exist today. Each moves a thread along one axis — later, done, gone — and none of them lets me attach a meaning to it.

  1. Pin — the closest thing that exists, and I have used it zero times. Pinning is upstream's, not the fork's: feat(sidebar-v2): thread pinning for sidebar v2 (#5312) (da6e1a967, 2026-08-04) and feat(web): drag pinned threads into your own order (#5581) (5661c6116). It is a complete feature — pinnedAt + pinOrderKey on the thread (packages/contracts/src/orchestration.ts:403-411), the thread.pin / thread.unpin / thread.pin.reorder commands (:739, :749, :755), a fractional-index sort shared by web and mobile so servers never need to agree on a merged order (packages/client-runtime/src/state/threadSort.ts:151, :164, :190), its own block at the top of the list (apps/web/src/components/Sidebar.tsx:2065), capability-gated per environment (packages/contracts/src/environment.ts:63, :66).

    The zero is the finding, not an oversight. A pin is binary and anonymous: it means "up top" and nothing more. It cannot distinguish "ship this today" from "don't lose track of this", so a pinned strip of five is as ambiguous as the list it was meant to rescue. And its order is hand-maintained by dragging — with ~13 candidates, arranging them by hand costs more attention than just remembering which two matter.

  2. Snooze — defers to a wake time (snoozedUntil, orchestration.ts:400-401). Answers "not now". Never answers "this one, first".

  3. Settle — "I am done with this for now". This is what keeps my list at 13, and it is a lifecycle exit, not a ranking.

  4. Archive — removes the row from the sidebar entirely.

The workaround I actually use today is typing priority into the title ([p1] …). It costs nothing, which is why it is worth naming honestly — but it does not sort, does not filter, is invisible to every other surface, and gets clobbered by regenerateTitle (ThreadMetaUpdateCommand in orchestration.ts).

Proposed solution

The primitive: a tag carries its own rank. There is no second "priority" field.

A tag is a user-owned label with a name, a colour, and a position in an ordered palette. The palette is the priority scale:

1. Now        (red)
2. Next       (amber)
3. Blocked    (violet)
4. Review     (blue)
5. Someday    (grey)

A thread may carry several tags. Its rank is the best rank among them. Now + Blocked is a legal and useful state: it says the top-priority thing is stuck, which is exactly the sentence pinning cannot express.

This is the central design call, and it is deliberate: do not ship a priority enum next to a separate freeform tags bag. One concept, ordered, delivers both halves of the request — the labelling and the ranking — and it keeps the ranking machine-readable instead of a naming convention the app has to guess at. Two concepts would mean two mutation paths, two filters, and an inevitable argument about what a p1 thread tagged Someday means.

What it must not do: re-sort the whole list

apps/web/src/components/Sidebar.logic.ts:535-537 states the sidebar's ordering contract outright:

reorders the list — a row holds its position from open until settled, so the screen only moves at lifecycle transitions. Status (including pending approval) is carried by each card's edge strip, not by position.

Ranking every row by tag would break that on purpose, and would make the list move under the cursor every time a tag changes. So:

  • Tag rank orders rows only inside the active partition, and only when the user opts in via the header control. pinnedThreads / snoozedThreads / settledThreads (Sidebar.tsx:2065, :2074, :2082) keep upstream's ordering untouched.
  • The primary affordance is the filter, not the sort. Selecting Now narrows the list to Now. That is what "keep all the focus on the highest-importance tasks" actually needs — everything else off screen, rather than everything on screen in a cleverer order.
  • Pin is left completely alone. It stays the "keep this visible regardless" tool. Tags answer a different question and the two compose.

Storage: fork-owned state file plus a raw route, not an event-sourced schema change

apps/server/src/coil/threadTags/state.ts<config.stateDir>/coil-thread-tags.json, a straight copy of apps/server/src/coil/autoResume/state.ts: an Effect Schema.Struct, mutations serialised through a SynchronizedRef, persisted atomically inside the critical section via writeFileStringAtomically (autoResume/state.ts:23, :140).

{
  version: 1,
  palette: Array<{ id: string; name: string; color: string; rank: number }>,
  assignments: Record<string /* threadId */, ReadonlyArray<string /* tagId */>>,
}

Every field must decode with Schema.withDecodingDefaultKey (autoResume/state.ts:53, and read the comment above it). A missing required key fails the whole-file decode, and the boot path turns a decode failure into empty state — which here means silently losing every tag the user ever set.

Route: apps/server/src/coil/threadTags/http.ts, modelled line-for-line on autoResume/http.ts:

  • GET /api/coil/thread-tags{ palette, assignments } (the whole document — it is small, and the sidebar needs all of it at once)
  • POST /api/coil/thread-tags{ setThreadTags?, upsertTag?, deleteTag?, reorderPalette? }, returning the same shape after the write

Reuse the authenticateWithOperateScope mirror (autoResume/http.ts:45), already a documented logic mirror in docs/coil/SEAMS.md. Register through CoilRoutesLive in apps/server/src/coil/index.ts — which exists precisely so this costs zero new upstream rows (server.ts already carries the one-line route seam).

Why not the upstream-native path (thread.tag.* commands → events → projector column → capability flag), which is architecturally the "right" answer and is what pinning did:

  • packages/contracts/src/orchestration.ts — churn 20 in the 60 days before merge-base a4cc1367b. The fork has never taken a row in this file; this would be the first, and it is a persisted wire contract.
  • packages/contracts/src/environment.ts — churn 12, for the capability flag.
  • The decider, the projector, and ProjectionThreads.
  • A migration in apps/server/src/persistence/Migrations/, whose registry is statically imported and numbered. The last entry is 040_ProjectionProjectFaviconPath.ts; pinning itself took 036_ProjectionThreadsPinned.ts and 038_ProjectionThreadsPinOrderKey.ts. A fork-authored 041_… collides with upstream's next 041_… permanently — the add/add conflict that cannot be resolved by taking either side. autoResume/state.ts:10 already made this exact call and wrote down why: "Deliberately NOT a DB migration: the migration registry is upstream-owned, and adding to it would buy permanent conflict surface for what a single JSON file does fine."

That is six to eight new rows on a ledger whose header says the surface is already 53 files, and whose tripwire says to re-isolate something before adding row 54. The state file is the call this fork has already committed to for exactly this shape of problem.

The cost of that choice, stated plainly rather than buried: tags are not in the orchestration read model, so no server-side query can filter on them, and there is no event stream, so a tag set on the laptop reaches the phone on the next fetch rather than instantly. Tags change at human speed and are set by one person; that is an acceptable trade. If upstream ever ships thread labels natively this becomes a migration, not a rewrite — the palette and assignments map cleanly onto commands.

Cross-environment: use PreparedConnection, not the primary-environment layer

The sidebar is a merged list across environments — every row carries thread.environmentId and capabilities resolve per environment (Sidebar.tsx:3040-3043). Each environment owns its own threads, so each owns its own tag document.

This is where the existing fork precedent is a trap: apps/web/src/coil/autoResumeClient.ts:91 runs over primaryEnvironmentHttpLayer, and apps/web/src/environments/ contains only primary/. That is why auto-resume is primary-environment-only. Copying it would make tags silently unavailable on every secondary environment.

Use the per-environment raw-HTTP pattern instead: packages/client-runtime/src/state/pullRequestDiffHttp.ts:20-45buildEnvironmentAuthHeaders + withEnvironmentCredentials + executeEnvironmentHttpRequest against a PreparedConnection, which handles session-cookie, bearer, and relay DPoP alike. One caveat for the implementer: that file reaches its route through the typed API-client builder (makeEnvironmentHttpApiUrlBuilder(...).pullRequests.diff()), and a fork raw route is not in that builder — so the URL is hand-built while the auth and execution helpers are reused as-is.

Palette across environments: each environment stores its own palette; the client merges by case-folded name and takes the minimum rank. Two servers with a now tag mean one Now. This mirrors the reasoning already written into pinOrderKey — servers never need each other's threads to agree on the merged list — and single-environment users (me, today) never hit the merge at all.

Web surfaces, and the anchor points that make them cheap

  • Assign / remove — apps/web/src/components/threadActionMenu.logic.ts (churn 5). This file is already the right shape. ThreadActionMenuId (:9) is a closed union with one data-driven template member, `snooze:${string}` (:16), and the file describes itself (:46) as "Single source for the per-thread action menu: the sidebar row's right-click menu and the chat header menu both render exactly this list, so labels, ordering, and capability gating cannot drift between the two surfaces." Adding `tag:${string}` is the idiom this file already sanctions — and because it is the single source, one edit lands tags in both the sidebar row menu and the chat header menu, which is the "Entry points" rule in AGENTS.md satisfied by construction rather than by discipline.
  • Filter — apps/web/src/components/Sidebar.tsx:3368, the flex items-center gap-1 header row that already holds the search input and the "Filter threads by project" menu (:3461-3465). A tag filter is a third sibling in a container built for exactly this.
  • Chips on the row, and the filter applied to the visible set — Sidebar.tsx:2006, the threads.filter(...) that computes the visible partition.

Cost, measured

Two new ledger rows. Both get their fork logic hoisted into apps/web/src/coil/threadTags/* so the displaced upstream lines stay minimal — the pattern the ledger rewards.

File churn est. fork Δ est. risk Why
apps/web/src/components/Sidebar.tsx 54 ~15-25 810-1350 One hook call, one wrap of the visible array at :2006, one filter control at :3368, chips in the row, fork items spread into the menu array at :3053
apps/web/src/components/threadActionMenu.logic.ts 5 ~20 ~100 `tag:${string}` union member plus the menu section; buys both entry points at once

Sidebar.tsx at churn 54 is the honest cost of this feature and it should be argued, not waved through — it would land as one of the ledger's highest-risk rows. The alternative that costs zero rows is a fork-owned overlay (the AutoResumeOverlay precedent, and the question #112 is already asking about panels): a tag-grouped thread list in its own surface. I do not recommend it for this feature — a filter that is not in the sidebar is not the feature, because the sidebar is the thing I am looking at when I decide what to work on. But if the maintainer would rather not take a 54-churn row, the overlay is the fallback, and it degrades gracefully rather than fails.

Why this matters

Every other prioritisation tool the fork has is about deferring work — snooze it, settle it, archive it. There is nothing for electing work. That asymmetry is why the decision about what matters lives in my head: the app can record everything I want to stop looking at, and nothing about what I want to look at next.

The concrete outcome: I tag two or three threads Now, click the filter, and the sidebar shows me those and nothing else. When I come back tomorrow, or on a different machine, or on the phone, the decision is still there. That is the difference between a tool that holds my intent and one I have to re-derive every session.

It also unblocks work already on this tracker: the maintainer agent (#44) and the self-paced loops (#42, #38) both need to answer "which thread should I pick up?" and today have nothing to read. A server-stored rank is the cheapest possible answer, and it is deliberately readable by anything that can make one HTTP request.

Smallest useful scope

A first pass that is genuinely useful stops well short of the above:

  1. The state file and the GET/POST route.
  2. A fixed default paletteNow, Next, Blocked, Review, Someday. No palette editor, no colour picker, no reordering UI. The palette is data in the state file from day one so it can be edited later, but v1 ships the defaults and nothing to manage them.
  3. Assign / remove through the existing thread action menu, so both the sidebar row and the chat header get it.
  4. Chips on the sidebar row.
  5. One filter control in the sidebar header. Filter only — no sort mode in v1. Filtering is the behaviour actually asked for; ranked sorting inside the active partition can follow once the filter has proved itself.

Explicitly deferred, with reasons rather than hand-waving:

  • Sort-by-rank inside the active partition. Filtering first; see the ordering contract above.
  • A palette editor. Five sensible defaults answer the request. An editor is a settings surface and a whole second design.
  • Mobile. Follows Map: the issue queue as a first-class surface in T3 Code #108's standing preference ("Mobile — doubles the surface for the piece least likely to be used on a phone"), and mobile reads device-local preferences rather than server client settings (apps/mobile/src/features/threads/use-thread-list-v2-enabled.tsmobilePreferencesAtom), so it is its own wiring job. Because the server side is shared and per-environment, mobile can adopt it later with no data migration — that is the point of putting this on the server rather than in localStorage.
  • Tags on projects, tag-based search syntax, and anything auto-tagging.

Reverse states, per the AGENTS.md "Reverse states" rule — a one-way door is a bug:

  • Untag from the same menu that tagged it.
  • Clear the filter, and make an active filter visibly obvious (a filtered sidebar that looks like an empty sidebar is a support ticket).
  • Deleting a tag from the palette drops its assignments; orphan tag ids are ignored on read rather than erroring, so a stale document can never break the sidebar.
  • Deleted threads leave orphan assignment entries — there is no event to hook. Prune lazily: drop assignment keys the read model no longer knows about, on write.

Non-goals for the surfaces matrix: providers are irrelevant (tags are thread metadata, provider-agnostic — no per-adapter decision needed). Connection modes all work, because the route goes through PreparedConnection auth; on any failure the UI degrades to "no tags" exactly as autoResumeClient degrades to null rather than damaging the sidebar.

Alternatives considered

  • A priority enum instead of tags. Smaller, and it sorts. Rejected because it cannot say why — "blocked on review" and "P1" are different facts about the same thread, and I want both. The ordered palette gets ranking as a property of the labels rather than as a second field.
  • Freeform, unordered tags with a filter and no rank. Smaller still. Rejected because it does not deliver the actual ask: priority would be a naming convention (p1, p2) that nothing can reason about — the title-prefix workaround with extra steps.
  • Upstream-native commands and events. The architecturally correct answer; costed above at six to eight ledger rows plus a numbered migration that collides permanently. Revisit if upstream ships labels — and worth a check before building, since pinning landed only three weeks ago and Sidebar.tsx is at churn 54, so this area is under active upstream development.
  • More pin slots / pin groups. Rejected: pin's semantics are "always visible", its order is hand-dragged, and its contract (a pin overrides the settled/snoozed lifecycle, orchestration.ts:403-405) is upstream's to change.
  • Client-side only, in localStorage. Cheapest of all, zero server work. Rejected outright: AGENTS.md makes remote-ready and multi-surface non-negotiable, and the whole value is that the decision survives moving between the desktop app and the phone. The web outbox already documents where localStorage runs out (docs/coil/SEAMS.md: the web queue drops image attachments, which localStorage cannot hold).
  • Title conventions ([p1] …). Works today at zero cost, which is why it is worth naming. Does not sort, does not filter, invisible to other surfaces, and regenerateTitle overwrites it.

Risks or tradeoffs

  • Sidebar.tsx at churn 54. The single biggest cost. Mitigated by hoisting logic into apps/web/src/coil/threadTags/* and keeping the in-file edit to hook-call / array-wrap / component-mount lines. The zero-row fallback is the overlay, above.
  • No live cross-client sync. A raw route has no push. Poll on sidebar mount plus refetch-after-own-write, with an optimistic local update so the interaction never feels laggy. Two devices editing tags simultaneously is last-write-wins — which is why the route takes the field-level patch shape above rather than accepting a whole document from the client.
  • The state file grows with dead threads until the lazy prune runs.
  • Palette merge-by-name across environments is a convention, not an invariant. Two environments with differently-ranked Now tags resolve to the minimum rank: defensible, but not obvious.
  • Deliberately not re-sorting the whole list will read as a missing feature to anyone who expected "priority" to mean "priority order". That is a documentation problem, and the reason is in Sidebar.logic.ts:535-537.
  • State file naming. The existing files are t3x-auto-resume.json and t3x-web-push-subscriptions.json (apps/server/src/coil/index.ts) — pre-rename names that cannot change without a migration. coil-thread-tags.json is the right name for a new file and deliberately breaks with its neighbours; flagging it so it is a decision rather than an inconsistency.
  • Upstream may ship this. Check before building.

Examples or references

Contribution

  • I would be open to helping implement this.

Not labelled ready-for-agent on purpose. Two decisions in here are the maintainer's to make before an agent should touch it: taking a new ledger row on Sidebar.tsx (churn 54) versus falling back to a fork-owned overlay, and the ordered-palette model versus a plain priority enum. Everything downstream of those two answers is specified.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions