Skip to content

t3x: per-thread auto-resume UI (status, on/off, resume message) - #2

Merged
radroid merged 7 commits into
mainfrom
t3x/autoresume-ui
Jul 24, 2026
Merged

t3x: per-thread auto-resume UI (status, on/off, resume message)#2
radroid merged 7 commits into
mainfrom
t3x/autoresume-ui

Conversation

@radroid

@radroid radroid commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Implements docs/superpowers/specs/2026-07-24-t3x-autoresume-ui-design.md. Gives each thread a visible control for the existing headless auto-resume feature.

A collapsed pill in the thread view — Auto-resume: on · next attempt ~3:47 PM — expands to an on/off switch and a resume-message box.

Phases

1. Server state + gating enabled on ThreadRecord, setEnabled/setOverridePrompt, reactor gates both scheduling and firing
2. HTTP route authenticated GET/POST /api/t3x/auto-resume
3. Web overlay apps/web/src/t3x/AutoResumeOverlay.tsx + 1 mount line
4. Docs SEAMS ledger + supersede note on the parent spec

Upstream footprint

  • server.ts: +1 symbol on the existing t3x import, +1 entry in the route list (3 lines total, still fanned in via t3x/index.ts)
  • the thread route: +1 import, +1 JSX element
  • zero contracts changes, zero settings-schema changes, no edits to ChatView.tsx / ChatComposer / ws.ts

Three things worth reviewing closely

1. Backward compatibility is a data-loss risk, not a nicety. The store's boot path turns a decode failure into EMPTY_STATE. So adding enabled as a required key would make every pre-existing state file fail to decode and silently wipe pending resumes + fired history. It's declared with withDecodingDefaultKey(Effect.succeed(true)), and a test pins the exact legacy on-disk format.

The design doc specified Schema.optionalWith — that does not exist in this repo's Effect (4.0.0-beta.78). withDecodingDefaultKey is the equivalent.

2. The route's layer shape prevents a seam leak. Handlers close over a store resolved by Layer.unwrap at layer-construction time. My first attempt had them yield* it from context — that propagated an AutoResumeStore requirement out through HttpRouter into the type of upstream's makeRoutesLayer and broke 222 checks in server.test.ts + 34 in bin.test.ts. A fork change must never widen an upstream signature. Now 0 errors.

3. One store, or the feature silently does nothing. The reactor and the route each Layer.provide the same AutoResumeStoreLive; Effect's per-build memoisation makes that one instance. If it ever regressed, the route would mutate its own copy while the reactor read a stale one — the toggle would look like it worked and the thread would resume anyway. sharing.test.ts pins it with both reference equality and a cross-visibility write.

Why the overlay isn't a plain fetch

The same web bundle runs in the browser and in the Electron renderer (t3code://app) — the macOS app has no separate UI. They authenticate differently: session cookie + credentials: "include" for a same-origin browser, bearer token + credentials: "omit" otherwise. The deciding helper isSameOriginBrowserPrimary() is module-private, so a hand-rolled fetch would have to duplicate it — and getting it wrong would 401 in the macOS app only, where the overlay's graceful degradation would make it silently never appear. Going through primaryEnvironmentHttpLayer gets both branches.

Verification

  • Server typecheck 0 errors; web typecheck 0 errors
  • t3x suite 53 passing (was 50)
  • The two reactor gating tests were confirmed to fail when the gates are neutered — they're real guards, not vacuous

Caveats

  • Not exercised end-to-end against a running server. Typecheck + unit tests only; the route has no HTTP-level integration test yet, and the overlay hasn't been clicked in a live app.
  • I saw 2 intermittent failures in Reactor.test.ts early on and could not reproduce them across 14+ later runs (including under induced CPU load). The harness is TestClock/fiber-timing sensitive. Unresolved — flagging rather than hiding it.
  • Conflicts with t3x: auto-build & install the desktop app on change #1 in docs/t3x/SEAMS.md (both add sections). Trivial to resolve; merge t3x: auto-build & install the desktop app on change #1 first.
  • ManagedRuntime is created at module level, unlike clientTracing.ts which builds one inside a function. Deliberate (lazy make, one shared client), but a reasonable thing to push back on.

🤖 Generated with Claude Code

radroid and others added 4 commits July 24, 2026 10:16
Phase 1 of docs/superpowers/specs/2026-07-24-t3x-autoresume-ui-design.md.

state.ts
- ThreadRecord gains `enabled`, plus `setEnabled` / `setOverridePrompt` mutations
  (overridePrompt was already stored but had no setter).
- `enabled` is declared with `Schema.withDecodingDefaultKey(Effect.succeed(true))`,
  NOT as a required key. The boot path turns a decode failure into EMPTY_STATE, so a
  required key missing from an older state file would silently destroy every pending
  resume and the fired history. A regression test pins the legacy on-disk format.

  Note: the design doc specified `Schema.optionalWith`, which does not exist in this
  repo's Effect (4.0.0-beta.78). `withDecodingDefaultKey` is the equivalent there.

Reactor.ts
- Detection: a disabled thread never schedules, and posts no timeline note — the user
  turned it off deliberately, so an activity row would be noise.
- fireOne: re-reads the record instead of trusting the value seen at scheduling time,
  so a thread switched off *mid-wait* cancels rather than fires, and does not burn one
  of its 24h attempts.

Tests 50 -> 52 green; server typecheck clean. Both new Reactor tests were verified to
FAIL when the gates are neutered, so they are real guards rather than vacuous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 of docs/superpowers/specs/2026-07-24-t3x-autoresume-ui-design.md.

  GET  /api/t3x/auto-resume?threadId=… -> { enabled, overridePrompt, pending }
  POST /api/t3x/auto-resume            -> same shape, after applying the write

A raw route, not a WS-RPC method: an RPC would mean editing @t3tools/contracts,
ws.ts and its scope map — several hot upstream files. This costs one additive
line in server.ts's route list, and it is imported from t3x/index.ts alongside
the existing T3xLayerLive, so server.ts gains no new import line.

Auth mirrors the module-private `authenticateRawRouteWithScope` used by the OTLP
proxy route (operate scope, since these endpoints change scheduling behaviour).
Importing it would require exporting it from an upstream file, so the fork
replicates it — recorded as a logic mirror in SEAMS.md.

The layer shape here is load-bearing. Handlers close over the store resolved by
`Layer.unwrap` at layer-construction time rather than `yield*`-ing it from
context. Doing the latter propagated an `AutoResumeStore` requirement out through
HttpRouter into the type of upstream's `makeRoutesLayer`, breaking 222 checks in
server.test.ts and 34 in bin.test.ts. A fork change must never widen an upstream
signature. Server typecheck is back to 0 errors.

Both T3xLayerLive and T3xRoutesLive `Layer.provide` the same AutoResumeStoreLive
value, so Effect's per-build memoisation gives the reactor and the route ONE
store. sharing.test.ts pins that: if it regressed, the route would mutate its own
copy while the reactor read a stale one — the UI toggle would look like it worked
and the thread would resume anyway, silently.

Tests 52 -> 53 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3 of docs/superpowers/specs/2026-07-24-t3x-autoresume-ui-design.md.

A collapsed status pill in the thread view ("Auto-resume: on · next attempt
~3:47 PM") that expands to a switch and a resume-message textbox, backed by
GET/POST /api/t3x/auto-resume.

Web seam is exactly +1 import and +1 JSX element in the server-thread route,
rendered as a sibling of <ChatView> inside <SidebarInset>. Everything else is
new code under apps/web/src/t3x/.

Requests go through `primaryEnvironmentHttpLayer` rather than a hand-rolled
fetch. This is not incidental: the same web bundle runs both in the browser and
inside the Electron renderer (t3code://app), and those authenticate differently
— session cookies with credentials:"include" for a same-origin browser, a
desktop bearer token with credentials:"omit" otherwise. The deciding helper
(`isSameOriginBrowserPrimary`) is module-private, so a hand-rolled fetch would
have to duplicate it; getting it wrong would 401 in the macOS app only, and
because the overlay degrades silently the control would simply never appear
there.

Any failure (401, route absent, offline) resolves to null and renders nothing,
so a broken auto-resume backend can never degrade the chat view.

Positioned top-right: the composer is itself a full-width absolutely-positioned
overlay, so a bottom-right card would sit on top of it. A poll is skipped while
a write is unacked so it cannot stomp an optimistic value, and a thread-id ref
stops a late response from a previous thread being applied to the current one.

Note: ManagedRuntime is created at module level (clientTracing.ts builds its own
inside a function). Deliberate — `make` is lazy, so this shares one HTTP client
across thread views instead of rebuilding per mount.

Web typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4 of docs/superpowers/specs/2026-07-24-t3x-autoresume-ui-design.md.

SEAMS.md
- server.ts seam grows from 2 lines to 3 (the route-list entry). Both the layer
  and the route still fan in through t3x/index.ts and share one import
  statement, so it stays 3 lines however many features are added.
- New logic mirror: autoResume/http.ts replicates the module-private
  `authenticateRawRouteWithScope`. If upstream changes how raw routes
  authenticate, this route could end up weaker than the ones beside it.
- Web is no longer "none": one mount line in the thread route. Notes that the
  overlay reaches the desktop app for free (Electron loads the same web bundle)
  and that mobile, being a separate codebase, does not get it.

Contracts and the settings schema remain untouched.

Also marks B7 ("zero UI patch") in the parent spec as superseded for apps/web.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b76faaa9-b5a7-422a-b5ec-d1966b701eb3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3x/autoresume-ui

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Closes the gap the PR flagged: the route had typecheck and unit coverage but was
never exercised over HTTP.

Serves ONLY the fork's route layer (not upstream's whole makeRoutesLayer) on an
ephemeral port with EnvironmentAuth mocked, and covers:

- a rejected credential  -> 401/403, not a 500
- a session without the operate scope -> 401/403
- GET on a never-seen thread -> enabled defaults to true
- GET without threadId -> 400
- POST patches one field without clobbering the other (a toggle must not wipe a
  resume message the user is part-way through typing, and vice versa)
- POST with a malformed body -> 400, not a 500

Scope limit, stated plainly: auth is mocked, so these do NOT prove the route's
`authenticateWithOperateScope` faithfully mirrors upstream's private
`authenticateRawRouteWithScope`. That stays a logic mirror tracked in SEAMS.md.
They prove the failure paths render as proper status codes rather than escaping
as a 500 or an unhandled defect.

Writing these caught a mistake in the test itself worth recording: the first
version failed a credential with ServerAuthSessionCredentialValidationError,
which is in NEITHER `ServerAuthCredentialError` nor `ServerAuthInternalError`,
so it matched neither catchIf branch and produced a 500. That looked like a
product bug but was a bad mock — upstream's OTLP route uses the identical two
branches and behaves the same way. Fixed to use ServerAuthInvalidCredentialError.

Tests 53 -> 59 green; server typecheck 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@radroid

radroid commented Jul 24, 2026

Copy link
Copy Markdown
Owner Author

Update: the "no end-to-end verification" caveat is now closed

The description listed this gap:

Not exercised end-to-end against a running server. Typecheck + unit tests only; the route has no HTTP-level integration test yet.

f9e8c8c40 adds http.test.ts, which serves only the fork's route layer (not upstream's whole makeRoutesLayer) on an ephemeral port with EnvironmentAuth mocked, and covers:

Case Expected
Rejected credential 401/403, not a 500
Session without operate scope 401/403
GET on a never-seen thread enabled: true default
GET without threadId 400
POST patching one field must not clobber the other
POST malformed body 400, not a 500

Tests 53 → 59, typecheck still 0 errors.

Scope limit, stated plainly

Auth is mocked, so these do not prove that authenticateWithOperateScope faithfully mirrors upstream's private authenticateRawRouteWithScope. That stays a logic mirror tracked in SEAMS.md. What they prove is that the failure paths render as proper status codes rather than escaping as a 500 or an unhandled defect.

A mistake worth recording

My first version of the test failed the credential with ServerAuthSessionCredentialValidationError and got a 500. That looked like a real auth bug. It wasn't — that error is in neither ServerAuthCredentialError nor ServerAuthInternalError, so it matched neither catchIf branch. Upstream's OTLP route uses the identical two branches and behaves the same way, so it's a shared property, not a fork divergence. I switched the mock to ServerAuthInvalidCredentialError.

Flagging it because it's exactly the kind of thing that becomes a false bug report if you don't chase it down.

Still open

radroid and others added 2 commits July 24, 2026 11:03
Adversarial review finding. The severe one is a bug this branch introduced.

state.ts — `recordFor` did `state.threads[threadId] ?? EMPTY_RECORD`. `threads` is a
plain object, so ids like `constructor`, `toString`, `valueOf`, `hasOwnProperty` and
`__proto__` resolve on Object.prototype and are truthy: `??` never falls through and the
caller gets a prototype method typed as a ThreadRecord.

Before this branch threadId only ever arrived from provider events. The new HTTP route is
what makes it caller-controlled, so this branch is what made it reachable:

  * reads dereference `record.pending` (undefined, not null) -> TypeError -> the route's
    catchTags only cover the three auth tags, so it escapes as a 500;
  * writes spread a prototype object, which has no own enumerable properties, and persist
    `{"enabled":false}` with none of the required keys. The next boot fails the WHOLE-file
    decode, and that path collapses to EMPTY_STATE — silently destroying every pending
    resume and the fired history behind the 24h cap.

Now uses `Object.hasOwn`. Regression tests cover all five hostile ids at the store level
plus, at the HTTP boundary, that GET/POST return 200 with defaults instead of 500. All
six were confirmed to FAIL against the old lookup, including "the real thread's pending
resume must survive".

Also from the review:

- AutoResumeOverlay: a debounced resume-message edit was dropped when switching threads
  mid-debounce (the next keystroke in the new thread cleared the old timer), silently
  losing what the user typed. Pending edits now flush, carrying their originating
  threadId. Writes also release the in-flight counter via a guaranteed path — that
  counter gates polling, and URL construction can throw synchronously before the promise
  exists, which would have pinned it above zero and frozen refreshes for the component's
  lifetime.

- Corrected two overstated claims rather than leaving them: t3x/index.ts cited an
  `index.test.ts` that does not exist, and sharing.test.ts is now explicit that it guards
  the memoisation *assumption* for this composition shape using its own probes — it does
  not import T3xLayerLive/T3xRoutesLive and would stay green if they stopped sharing a
  store. (The real wiring was independently verified correct from Effect's source: layer
  memoisation is keyed on layer identity and provideWith/mergeAllEffect/HttpRouter.serve
  all thread one MemoMap.)

Tests 59 -> 66; server and web typecheck both clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@radroid
radroid merged commit b8323ef into main Jul 24, 2026
1 check passed
@radroid
radroid deleted the t3x/autoresume-ui branch July 24, 2026 18:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant