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

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 + gatingenabled on ThreadRecord, setEnabled/setOverridePrompt, reactor gates both scheduling and firing
2. HTTP routeauthenticated GET/POST /api/t3x/auto-resume
3. Web overlayapps/web/src/t3x/AutoResumeOverlay.tsx + 1 mount line
4. DocsSEAMS 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 sameAutoResumeStoreLive; 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

radroidand 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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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:

CaseExpected
Rejected credential401/403, not a 500
Session without operate scope401/403
GET on a never-seen threadenabled: true default
GET without threadId400
POST patching one fieldmust not clobber the other
POST malformed body400, 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 neitherServerAuthCredentialError 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

radroidand 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>
Sign up for freeto 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

@radroid
, '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

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

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 + gatingenabled on ThreadRecord, setEnabled/setOverridePrompt, reactor gates both scheduling and firing
2. HTTP routeauthenticated GET/POST /api/t3x/auto-resume
3. Web overlayapps/web/src/t3x/AutoResumeOverlay.tsx + 1 mount line
4. DocsSEAMS 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 sameAutoResumeStoreLive; 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

radroidand 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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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:

CaseExpected
Rejected credential401/403, not a 500
Session without operate scope401/403
GET on a never-seen threadenabled: true default
GET without threadId400
POST patching one fieldmust not clobber the other
POST malformed body400, 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 neitherServerAuthCredentialError 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

radroidand 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>
Sign up for freeto 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

@radroid
, '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

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

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 + gatingenabled on ThreadRecord, setEnabled/setOverridePrompt, reactor gates both scheduling and firing
2. HTTP routeauthenticated GET/POST /api/t3x/auto-resume
3. Web overlayapps/web/src/t3x/AutoResumeOverlay.tsx + 1 mount line
4. DocsSEAMS 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 sameAutoResumeStoreLive; 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

radroidand 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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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:

CaseExpected
Rejected credential401/403, not a 500
Session without operate scope401/403
GET on a never-seen threadenabled: true default
GET without threadId400
POST patching one fieldmust not clobber the other
POST malformed body400, 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 neitherServerAuthCredentialError 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

radroidand 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>
Sign up for freeto 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

@radroid
, '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

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

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 + gatingenabled on ThreadRecord, setEnabled/setOverridePrompt, reactor gates both scheduling and firing
2. HTTP routeauthenticated GET/POST /api/t3x/auto-resume
3. Web overlayapps/web/src/t3x/AutoResumeOverlay.tsx + 1 mount line
4. DocsSEAMS 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 sameAutoResumeStoreLive; 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

radroidand 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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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:

CaseExpected
Rejected credential401/403, not a 500
Session without operate scope401/403
GET on a never-seen threadenabled: true default
GET without threadId400
POST patching one fieldmust not clobber the other
POST malformed body400, 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 neitherServerAuthCredentialError 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

radroidand 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>
Sign up for freeto 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

@radroid
, '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

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

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 + gatingenabled on ThreadRecord, setEnabled/setOverridePrompt, reactor gates both scheduling and firing
2. HTTP routeauthenticated GET/POST /api/t3x/auto-resume
3. Web overlayapps/web/src/t3x/AutoResumeOverlay.tsx + 1 mount line
4. DocsSEAMS 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 sameAutoResumeStoreLive; 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

radroidand 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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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:

CaseExpected
Rejected credential401/403, not a 500
Session without operate scope401/403
GET on a never-seen threadenabled: true default
GET without threadId400
POST patching one fieldmust not clobber the other
POST malformed body400, 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 neitherServerAuthCredentialError 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

radroidand 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>
Sign up for freeto 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

@radroid
, '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

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

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 + gatingenabled on ThreadRecord, setEnabled/setOverridePrompt, reactor gates both scheduling and firing
2. HTTP routeauthenticated GET/POST /api/t3x/auto-resume
3. Web overlayapps/web/src/t3x/AutoResumeOverlay.tsx + 1 mount line
4. DocsSEAMS 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 sameAutoResumeStoreLive; 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

radroidand 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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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:

CaseExpected
Rejected credential401/403, not a 500
Session without operate scope401/403
GET on a never-seen threadenabled: true default
GET without threadId400
POST patching one fieldmust not clobber the other
POST malformed body400, 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 neitherServerAuthCredentialError 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

radroidand 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>
Sign up for freeto 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

@radroid
, '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

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

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 + gatingenabled on ThreadRecord, setEnabled/setOverridePrompt, reactor gates both scheduling and firing
2. HTTP routeauthenticated GET/POST /api/t3x/auto-resume
3. Web overlayapps/web/src/t3x/AutoResumeOverlay.tsx + 1 mount line
4. DocsSEAMS 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 sameAutoResumeStoreLive; 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

radroidand 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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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:

CaseExpected
Rejected credential401/403, not a 500
Session without operate scope401/403
GET on a never-seen threadenabled: true default
GET without threadId400
POST patching one fieldmust not clobber the other
POST malformed body400, 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 neitherServerAuthCredentialError 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

radroidand 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>
Sign up for freeto 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

@radroid
, '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

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

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 + gatingenabled on ThreadRecord, setEnabled/setOverridePrompt, reactor gates both scheduling and firing
2. HTTP routeauthenticated GET/POST /api/t3x/auto-resume
3. Web overlayapps/web/src/t3x/AutoResumeOverlay.tsx + 1 mount line
4. DocsSEAMS 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 sameAutoResumeStoreLive; 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

radroidand 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

coderabbitaiBot 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

Copy link
Copy Markdown
OwnerAuthor

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:

CaseExpected
Rejected credential401/403, not a 500
Session without operate scope401/403
GET on a never-seen threadenabled: true default
GET without threadId400
POST patching one fieldmust not clobber the other
POST malformed body400, 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 neitherServerAuthCredentialError 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

radroidand 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>
Sign up for freeto 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

@radroid