Repository navigation
feat(conditions): provider-agnostic condition-module registry + generic outlet (PR-1 framework) - #325
Merged
Juliusolsson05 merged 2 commits intoJun 21, 2026
Conversation
…ic outlet (PR-1 framework) Introduce a provider-agnostic condition-module registry and route the EXISTING modals through it as drop-in view modules, replacing the two per-provider hand-switch outlets (ClaudeConditionOutlet/CodexConditionOutlet). Behavior is byte-identical: same modals, same keystrokes, same triggers. WHY a registry instead of a switch ---------------------------------- The old outlets enumerated each condition kind by hand — read state by string key, map to modal props inline, mount the modal — coupling "which conditions exist" to the outlet's source. The new core knows nothing about specific kinds: it iterates the live snapshot, looks each kind up in a provider view registry, and renders that view. Adding a condition becomes "register one view", not "edit the switch + selectors + keybinds". What lands here (app-repo only; NO submodule changes) ----------------------------------------------------- - src/shared/conditions-core/: contract.ts (wire action primitives now have their canonical home here; providerConditions.ts re-exports them so every existing import keeps working byte-for-byte) + the forward-looking headless ConditionModule contract (detect is a FREE-FORM function — the detection toolkit is an optional helper, never a straitjacket); view.ts (ConditionView/ViewProps/AttentionLevel); ConditionOutlet.tsx (the ONE generic outlet — routes by kind, preserves snapshot order, skips unknown kinds for old-app/new-headless forward compat); dispatch.ts (pty arm wired to the exact existing send path; custom arm intentionally DORMANT — throws, because no live condition emits a custom action and a resolver with zero callers would only rot). - Provider view modules wrapping the existing modals as thin adapters (Codex approval/trust live-proven; Claude trust/permission/resume/compaction registered but DORMANT until the Claude headless emitter lands in a later PR). Each adapter preserves the modal's exact prop shape + state->props mapping (incl. Codex approval's commandParts ?? command.split fallback and compaction's visible && phase guard). Deferred ON PURPOSE (blast radius): applyConditionSnapshot + runtime.pending* denormalization, selectors.ts, composer keybind gating, dispatch badges, agent-status — ALL untouched. The `attention` field on views is defined for a later PR and not consumed yet. Design doc: docs/design/conditions-system.md (3-layer architecture, module contract, wire format, data-flow seam, registration model, vendor-sync plan for the headless core, PR roadmap). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ctx + tsconfig/doc — review fixes (PR-1)
Four type/build/doc-hardening fixes to the conditions-core framework. Runtime
behavior is byte-identical: the provider registries are the same object literals
with the same kind→view entries; eraseRegistry is an identity pass-through.
FIX 1 — sound registry typing (kill the `as unknown as ConditionView` cast).
The provider registries were built via `Object.fromEntries(LIST.map(v => [v.kind,
v as unknown as ConditionView]))`. That erasure happened BEFORE the kind→view
pairing existed as a checkable literal, so a kind could point at the wrong
component and still compile. Now each provider declares a `StateByKind` map (the
source of truth binding each kind to its concrete state type), and the registry
is an EXPLICIT object literal passed through `eraseRegistry<M>(reg:
Partial<ViewRegistry<M>>)`. The helper's parameter type checks every key↔view
binding against `StateByKind` BEFORE erasing — a wrong mapping is now a compile
error at the call site (proven: swapping codex.approval/codex.trust-dialog views
fails tsc with TS2322). `ViewRegistry<M> = { [K in keyof M & string]:
ConditionView<K, M[K]> }` and `eraseRegistry` live in view.ts.
Why a helper and not a plain `satisfies` + `Record` annotation: the outlet feeds
each Component `state: unknown`, and `state` sits in the Component's contravariant
parameter position, so a precise `ConditionView<K, S>` is genuinely NOT a subtype
of the erased `ConditionView<string, unknown>`. Assigning the precise literal to a
`Record<string, ConditionView>` annotation does not type-check — which is exactly
why the old code needed the unchecked cast. eraseRegistry localizes that one
unavoidable erasure to a SINGLE documented cast whose INPUT is fully checked,
replacing N unchecked per-view casts. No `any`. Thick WHY in view.ts.
FIX 2 — tsconfig.web include. `tsc -b tsconfig.web.json` (the real build, vs a
cache-masking `--noEmit`) emitted TS6307: ConditionOutlet.tsx was reached only
TRANSITIVELY and was not in any project's explicit file list. Added
`src/shared/conditions-core/**/*` to tsconfig.web.json include so the web project
authoritatively type-checks the renderer-only .tsx regardless of who imports it.
The node project keeps excluding ConditionOutlet.tsx (stays JSX-free). This
clears the pre-existing TS6307.
FIX 3 — typed resolve ctx generic. `ConditionModule` gains a 4th generic
`Ctx = unknown`, and `resolve` is narrowed to `(action: ConditionCustomAction,
ctx: Ctx)`. The future multi-step answering driver (sendThenReparse, roadmap
steps 4-5) needs to hand resolve a live terminal handle; keeping it a generic
(defaulting to unknown) means that handle can be specified per-module later
without a contract change or a re-sync of the vendored headless copy. pty actions
never reach resolve, so the action arg is narrowed to the custom arm. Type-only;
nothing constructs a module yet.
FIX 4 — doc render-order claim. Softened the design doc: the old per-provider
outlets used a fixed JSX order; the new outlet renders in
`Object.values(snapshot.conditions)` insertion order. These are NOT guaranteed
identical, but are behaviorally equivalent under the current "at most one live
overlay at a time" invariant. Noted that if multiple simultaneous overlays ever
ship, the outlet should sort by an explicit per-view `priority` (forward
reference). Also synced the Layer-2 code example and the type-safety guardrail
paragraph to the eraseRegistry form so the doc doesn't drift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Juliusolsson05
changed the base branch from
main
to
integration/conditions-framework
June 21, 2026 21:10
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR-1 — Conditions framework (renderer)
Introduces a provider-agnostic condition-module registry and routes the existing modals through it as drop-in view modules, replacing the two per-provider hand-switch outlets. Behavior is byte-identical — same modals, same keystrokes, same triggers.
This is the foundation. Later PRs migrate the headless emitters and the denormalization seam.
The registry framework (
src/shared/conditions-core/)contract.ts— the app-side source of truth for the generic wire action primitives (ConditionAction/ConditionPtyAction/ConditionCustomAction, now canonically here;providerConditions.tsre-exports them so every existing import keeps working byte-for-byte). Also the forward-looking headlessConditionModulecontract —detectis a free-form function (the detection toolkit is an optional helper, never a straitjacket). Not consumed at runtime in this PR.view.ts—ConditionView/ConditionViewProps/AttentionLevel. Theattentionfield is defined for a later PR, not consumed yet.ConditionOutlet.tsx— the one generic outlet: iterates the snapshot, routes each record bykindthrough a registry, preserves snapshot key order (modal stacking unchanged), and skips unknown kinds (graceful old-app / new-headless forward compat).dispatch.ts—makeDispatch/makeDispatchFromOnSend. Theptyarm calls the exact existing send path (onSend(data)→sendInput). Thecustomarm is intentionally dormant (throws): no live condition emits a custom action, and a resolver with zero callers would only rot. The first PR with a real custom condition adds the IPC + resolver and replaces the throw.Routing existing modals byte-identically
Thin view adapters wrap the existing modal components — no modal was rewritten. Each adapter preserves the modal's exact prop shape and state→props mapping (incl. Codex approval's
commandParts ?? command.split(/\s+/)fallback and compaction'svisible && phaseguard), and translatesonSend(data)intodispatch({ kind:'pty', … }).approvalView,codexTrustView) — live-proven through the new path (Codex already emits a conditions snapshot).claudeTrustView,permissionView,resumeView,compactionView) — registered and ready, but dormant: Claude's headless emitter doesn't emit a snapshot yet, so these light up in a later PR. Wiring them now proves the registry is provider-symmetric and makes that PR "turn on the wire", not "also build the renderer".ProviderConditionOutletis rewired to pick the provider registry and feedConditionOutlet;ClaudeConditionOutlet/CodexConditionOutletare deleted (their logic now lives in the view modules).Deferred on purpose (blast radius)
applyConditionSnapshot+runtime.pending*denormalization,selectors.ts(attention/unread labels), composer keybind gating, dispatch badges, agent-status — all untouched. No headless/submodule changes, no new IPC, no legacy-channel deletions.Design doc
docs/design/conditions-system.md— 3-layer architecture, module + view contracts, wire format, the emit→sessionManager→forwarder→session:conditions→applyConditionSnapshotdata-flow seam, registration model, the vendor-sync plan for the headless core (packages are submodules), and the full PR roadmap.Verification
tsc -p tsconfig.web.jsonandtsconfig.node.json: zero errors in the changed files (web project 0 total; node project dropped from baseline 45 → 43 after a targeted JSX exclude for the one renderer-only.tsx).codex.switch-model-prompt, which the old outlet also never rendered); snapshot key order preserved.state=null(→ renders nothing) and the new outlet skips the kind (→ renders nothing) — identical output; when live, both pass the same mapped props.🤖 Generated with Claude Code