[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 deleted183
not archived173
explicitly settled (settled_override = 'settled')155
carrying no settle override at all28
snoozed5
in the active block (settled_at IS NULL, not archived, not snoozed)13
pinned0
projects holding live threads11

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 onlyprimary/. 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.

Filechurnest. fork Δest. riskWhy
apps/web/src/components/Sidebar.tsx54~15-25810-1350One 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.ts5~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

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
       blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      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 deleted183
      not archived173
      explicitly settled (settled_override = 'settled')155
      carrying no settle override at all28
      snoozed5
      in the active block (settled_at IS NULL, not archived, not snoozed)13
      pinned0
      projects holding live threads11

      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 onlyprimary/. 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.

      Filechurnest. fork Δest. riskWhy
      apps/web/src/components/Sidebar.tsx54~15-25810-1350One 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.ts5~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

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          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 deleted183
          not archived173
          explicitly settled (settled_override = 'settled')155
          carrying no settle override at all28
          snoozed5
          in the active block (settled_at IS NULL, not archived, not snoozed)13
          pinned0
          projects holding live threads11

          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 onlyprimary/. 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.

          Filechurnest. fork Δest. riskWhy
          apps/web/src/components/Sidebar.tsx54~15-25810-1350One 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.ts5~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

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              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 deleted183
              not archived173
              explicitly settled (settled_override = 'settled')155
              carrying no settle override at all28
              snoozed5
              in the active block (settled_at IS NULL, not archived, not snoozed)13
              pinned0
              projects holding live threads11

              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 onlyprimary/. 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.

              Filechurnest. fork Δest. riskWhy
              apps/web/src/components/Sidebar.tsx54~15-25810-1350One 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.ts5~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

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  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 deleted183
                  not archived173
                  explicitly settled (settled_override = 'settled')155
                  carrying no settle override at all28
                  snoozed5
                  in the active block (settled_at IS NULL, not archived, not snoozed)13
                  pinned0
                  projects holding live threads11

                  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 onlyprimary/. 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.

                  Filechurnest. fork Δest. riskWhy
                  apps/web/src/components/Sidebar.tsx54~15-25810-1350One 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.ts5~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

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      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 deleted183
                      not archived173
                      explicitly settled (settled_override = 'settled')155
                      carrying no settle override at all28
                      snoozed5
                      in the active block (settled_at IS NULL, not archived, not snoozed)13
                      pinned0
                      projects holding live threads11

                      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 onlyprimary/. 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.

                      Filechurnest. fork Δest. riskWhy
                      apps/web/src/components/Sidebar.tsx54~15-25810-1350One 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.ts5~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

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          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 deleted183
                          not archived173
                          explicitly settled (settled_override = 'settled')155
                          carrying no settle override at all28
                          snoozed5
                          in the active block (settled_at IS NULL, not archived, not snoozed)13
                          pinned0
                          projects holding live threads11

                          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 onlyprimary/. 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.

                          Filechurnest. fork Δest. riskWhy
                          apps/web/src/components/Sidebar.tsx54~15-25810-1350One 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.ts5~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

                              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              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 deleted183
                              not archived173
                              explicitly settled (settled_override = 'settled')155
                              carrying no settle override at all28
                              snoozed5
                              in the active block (settled_at IS NULL, not archived, not snoozed)13
                              pinned0
                              projects holding live threads11

                              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 onlyprimary/. 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.

                              Filechurnest. fork Δest. riskWhy
                              apps/web/src/components/Sidebar.tsx54~15-25810-1350One 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.ts5~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