You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I searched existing issues and did not find a duplicate.
I am describing a concrete problem or use case, not just a vague idea.
Area
apps/web
Problem or use case
I want to open the sidebar and see, without thinking, which threads I have decided matter — and be able to hide everything else while I work on them. Today there is no way to record why a thread matters or how much, so that decision lives in my head and gets re-made every time I open the app.
Measured against my own install (~/.t3/userdata/state.sqlite, opened read-only, 2026-08-24):
threads, not deleted
183
not archived
173
explicitly settled (settled_override = 'settled')
155
carrying no settle override at all
28
snoozed
5
in the active block (settled_at IS NULL, not archived, not snoozed)
13
pinned
0
projects holding live threads
11
Read that table the right way round, because it argues against the obvious version of this request: I am not drowning. I curate hard — 155 explicit settles — and the active block is about 13 rows spanning 11 projects. The problem is not volume. The problem is that those 13 rows are indistinguishable from one another. Two or three of them are the thing I actually care about this week. The sidebar has no way to know that, so it treats all 13 the same, and so does every other surface.
Four primitives exist today. Each moves a thread along one axis — later, done, gone — and none of them lets me attach a meaning to it.
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.
Snooze — defers to a wake time (snoozedUntil, orchestration.ts:400-401). Answers "not now". Never answers "this one, first".
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.
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).
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-45 — buildEnvironmentAuthHeaders + withEnvironmentCredentials + executeEnvironmentHttpRequest against a PreparedConnection, which handles session-cookie, bearer, and relay DPoP alike. One caveat for the implementer: that file reaches its route through the typed API-client builder (makeEnvironmentHttpApiUrlBuilder(...).pullRequests.diff()), and a fork raw route is not in that builder — so the URL is hand-built while the auth and execution helpers are reused as-is.
Palette across environments: each environment stores its own palette; the client merges by case-folded name and takes the minimum rank. Two servers with a now tag mean one Now. This mirrors the reasoning already written into pinOrderKey — servers never need each other's threads to agree on the merged list — and single-environment users (me, today) never hit the merge at all.
Web surfaces, and the anchor points that make them cheap
Assign / remove — apps/web/src/components/threadActionMenu.logic.ts (churn 5). This file is already the right shape. ThreadActionMenuId (:9) is a closed union with one data-driven template member, `snooze:${string}` (:16), and the file describes itself (:46) as "Single source for the per-thread action menu: the sidebar row's right-click menu and the chat header menu both render exactly this list, so labels, ordering, and capability gating cannot drift between the two surfaces." Adding `tag:${string}` is the idiom this file already sanctions — and because it is the single source, one edit lands tags in both the sidebar row menu and the chat header menu, which is the "Entry points" rule in AGENTS.md satisfied by construction rather than by discipline.
Filter — apps/web/src/components/Sidebar.tsx:3368, the flex items-center gap-1 header row that already holds the search input and the "Filter threads by project" menu (:3461-3465). A tag filter is a third sibling in a container built for exactly this.
Chips on the row, and the filter applied to the visible set — Sidebar.tsx:2006, the threads.filter(...) that computes the visible partition.
Cost, measured
Two new ledger rows. Both get their fork logic hoisted into apps/web/src/coil/threadTags/* so the displaced upstream lines stay minimal — the pattern the ledger rewards.
File
churn
est. fork Δ
est. risk
Why
apps/web/src/components/Sidebar.tsx
54
~15-25
810-1350
One hook call, one wrap of the visible array at :2006, one filter control at :3368, chips in the row, fork items spread into the menu array at :3053
apps/web/src/components/threadActionMenu.logic.ts
5
~20
~100
`tag:${string}` union member plus the menu section; buys both entry points at once
Sidebar.tsx at churn 54 is the honest cost of this feature and it should be argued, not waved through — it would land as one of the ledger's highest-risk rows. The alternative that costs zero rows is a fork-owned overlay (the AutoResumeOverlay precedent, and the question #112 is already asking about panels): a tag-grouped thread list in its own surface. I do not recommend it for this feature — a filter that is not in the sidebar is not the feature, because the sidebar is the thing I am looking at when I decide what to work on. But if the maintainer would rather not take a 54-churn row, the overlay is the fallback, and it degrades gracefully rather than fails.
Why this matters
Every other prioritisation tool the fork has is about deferring work — snooze it, settle it, archive it. There is nothing for electing work. That asymmetry is why the decision about what matters lives in my head: the app can record everything I want to stop looking at, and nothing about what I want to look at next.
The concrete outcome: I tag two or three threads Now, click the filter, and the sidebar shows me those and nothing else. When I come back tomorrow, or on a different machine, or on the phone, the decision is still there. That is the difference between a tool that holds my intent and one I have to re-derive every session.
It also unblocks work already on this tracker: the maintainer agent (#44) and the self-paced loops (#42, #38) both need to answer "which thread should I pick up?" and today have nothing to read. A server-stored rank is the cheapest possible answer, and it is deliberately readable by anything that can make one HTTP request.
Smallest useful scope
A first pass that is genuinely useful stops well short of the above:
The state file and the GET/POST route.
A fixed default palette — Now, 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.
Assign / remove through the existing thread action menu, so both the sidebar row and the chat header get it.
Chips on the sidebar row.
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.ts → mobilePreferencesAtom), 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
Upstream pinning, the feature this is measured against: #5312 (da6e1a967), #5581 (5661c6116).
The fork pattern this copies wholesale: apps/server/src/coil/autoResume/{state,http}.ts and apps/server/src/coil/index.ts.
The per-environment HTTP pattern this must use instead of the primary-only one: packages/client-runtime/src/state/pullRequestDiffHttp.ts.
The ordering contract this must not break: apps/web/src/components/Sidebar.logic.ts:535-537.
The extension point that makes the menu cheap: apps/web/src/components/threadActionMenu.logic.ts:9-16, :46.
Seam ledger and its tripwire: docs/coil/SEAMS.md (53 files, merge-base a4cc1367b).
Churn figures above were regenerated against a4cc1367b using the @<epoch> form from the ledger's "Regenerating" section; the ChatView.tsx sanity check returns 83, not 0.
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.
Before submitting
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):settled_override = 'settled')settled_at IS NULL, not archived, not snoozed)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.
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) andfeat(web): drag pinned threads into your own order (#5581)(5661c6116). It is a complete feature —pinnedAt+pinOrderKeyon the thread (packages/contracts/src/orchestration.ts:403-411), thethread.pin/thread.unpin/thread.pin.reordercommands (: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.
Snooze — defers to a wake time (
snoozedUntil,orchestration.ts:400-401). Answers "not now". Never answers "this one, first".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.
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 byregenerateTitle(ThreadMetaUpdateCommandinorchestration.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:
A thread may carry several tags. Its rank is the best rank among them.
Now + Blockedis 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
priorityenum next to a separate freeformtagsbag. 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 ap1thread taggedSomedaymeans.What it must not do: re-sort the whole list
apps/web/src/components/Sidebar.logic.ts:535-537states the sidebar's ordering contract outright: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:
pinnedThreads/snoozedThreads/settledThreads(Sidebar.tsx:2065,:2074,:2082) keep upstream's ordering untouched.Nownarrows the list toNow. 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.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 ofapps/server/src/coil/autoResume/state.ts: an EffectSchema.Struct, mutations serialised through aSynchronizedRef, persisted atomically inside the critical section viawriteFileStringAtomically(autoResume/state.ts:23,:140).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 onautoResume/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 writeReuse the
authenticateWithOperateScopemirror (autoResume/http.ts:45), already a documented logic mirror indocs/coil/SEAMS.md. Register throughCoilRoutesLiveinapps/server/src/coil/index.ts— which exists precisely so this costs zero new upstream rows (server.tsalready 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-basea4cc1367b. 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.ProjectionThreads.apps/server/src/persistence/Migrations/, whose registry is statically imported and numbered. The last entry is040_ProjectionProjectFaviconPath.ts; pinning itself took036_ProjectionThreadsPinned.tsand038_ProjectionThreadsPinOrderKey.ts. A fork-authored041_…collides with upstream's next041_…permanently — the add/add conflict that cannot be resolved by taking either side.autoResume/state.ts:10already 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 layerThe sidebar is a merged list across environments — every row carries
thread.environmentIdand 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:91runs overprimaryEnvironmentHttpLayer, andapps/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-45—buildEnvironmentAuthHeaders+withEnvironmentCredentials+executeEnvironmentHttpRequestagainst aPreparedConnection, 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
nowtag mean oneNow. This mirrors the reasoning already written intopinOrderKey— 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
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 inAGENTS.mdsatisfied by construction rather than by discipline.apps/web/src/components/Sidebar.tsx:3368, theflex items-center gap-1header 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.Sidebar.tsx:2006, thethreads.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.apps/web/src/components/Sidebar.tsx:2006, one filter control at:3368, chips in the row, fork items spread into the menu array at:3053apps/web/src/components/threadActionMenu.logic.ts`tag:${string}`union member plus the menu section; buys both entry points at onceSidebar.tsxat 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 (theAutoResumeOverlayprecedent, 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:
GET/POSTroute.Now,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.Explicitly deferred, with reasons rather than hand-waving:
apps/mobile/src/features/threads/use-thread-list-v2-enabled.ts→mobilePreferencesAtom), 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 inlocalStorage.Reverse states, per the
AGENTS.md"Reverse states" rule — a one-way door is a bug: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
PreparedConnectionauth; on any failure the UI degrades to "no tags" exactly asautoResumeClientdegrades tonullrather than damaging the sidebar.Alternatives considered
priorityenum 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.p1,p2) that nothing can reason about — the title-prefix workaround with extra steps.Sidebar.tsxis at churn 54, so this area is under active upstream development.orchestration.ts:403-405) is upstream's to change.localStorage. Cheapest of all, zero server work. Rejected outright:AGENTS.mdmakes 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 wherelocalStorageruns out (docs/coil/SEAMS.md: the web queue drops image attachments, which localStorage cannot hold).[p1] …). Works today at zero cost, which is why it is worth naming. Does not sort, does not filter, invisible to other surfaces, andregenerateTitleoverwrites it.Risks or tradeoffs
Sidebar.tsxat churn 54. The single biggest cost. Mitigated by hoisting logic intoapps/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.Nowtags resolve to the minimum rank: defensible, but not obvious.Sidebar.logic.ts:535-537.t3x-auto-resume.jsonandt3x-web-push-subscriptions.json(apps/server/src/coil/index.ts) — pre-rename names that cannot change without a migration.coil-thread-tags.jsonis the right name for a new file and deliberately breaks with its neighbours; flagging it so it is a decision rather than an inconsistency.Examples or references
#5312(da6e1a967),#5581(5661c6116).apps/server/src/coil/autoResume/{state,http}.tsandapps/server/src/coil/index.ts.packages/client-runtime/src/state/pullRequestDiffHttp.ts.apps/web/src/components/Sidebar.logic.ts:535-537.apps/web/src/components/threadActionMenu.logic.ts:9-16,:46.docs/coil/SEAMS.md(53 files, merge-basea4cc1367b).a4cc1367busing the@<epoch>form from the ledger's "Regenerating" section; theChatView.tsxsanity check returns 83, not 0.wake_meinstead #42 / [Feature]: supervise long-running threads — turns complete while background subagents are still working, and nothing restarts the run #38 (all three need a "what next?" signal), What does registering a right-panel tab actually cost? #112 (the cost of a panel row, if the overlay fallback is taken — measured here in passing:RightPanelTabs.tsxchurn is 14).Contribution
Not labelled
ready-for-agenton purpose. Two decisions in here are the maintainer's to make before an agent should touch it: taking a new ledger row onSidebar.tsx(churn 54) versus falling back to a fork-owned overlay, and the ordered-palette model versus a plainpriorityenum. Everything downstream of those two answers is specified.