From 219dee1bd6323c0c069bc97150732bdf21ec5c49 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 6 Jul 2026 14:37:48 +0200 Subject: [PATCH] Providers phase 3a: open the condition snapshot union, policy-driven selectors, ungated resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conditions system was the last closed provider surface (#394 phase 3 / §5). Three changes, none touching the vendored conditions-core slice (no submodule churn): 1. ProviderConditionSnapshot opens from the closed ClaudeConditionSnapshot | CodexConditionSnapshot union to the generic { provider: AgentProviderKind; conditions: Partial>; ts } shape — mirroring conditions-core's ConditionSnapshot

, which was already generic. Condition kinds are globally namespaced (.), so kind-keyed access was always provider-safe; the union bought nothing except closure (a third provider couldn't TYPE a snapshot without editing the file). The per-provider specializations remain as the shapes the provider session event maps emit; the new conditionStateByKind() helper is the one sanctioned typed lookup. Wire format unchanged. 2. workspace/conditions/selectors.ts becomes generic loops over a new conditionPolicy on RendererProviderCapabilities (attention set, action set, priority-ordered label rules, composer picker kind) — the exact deferred seam docs/design/conditions-system.md called out ("the migration is a deletion, not a re-derivation"). Policies live in providers//renderer/conditions/policy.ts. WHY data, not derivation from views' attention() fns: the sets and views deliberately disagree (AUQ is attention-worthy with NO view; compaction has a view but is excluded from the set). applyConditionSnapshot's two-provider fork becomes kind-keyed derivation for the same reason; the pending* mirrors stay (still documented for deletion; pendingCompaction + picker remain the only live consumers). 3. sessionManager.resolveCondition drops its claude-only gate. A provider that implements AgentSession.resolveCondition previously dead-ended in 'no-session' at the manager; now any agent kind routes through, and one WITHOUT a resolver (Codex today — its approvals answer via raw keystrokes) truthfully returns 'no-resolver' from the existing capability check. Also: TileLeaf's AUQ extraction and the debug module's two copies use conditionStateByKind instead of snapshot-level provider narrowing; the outlet's Partial→dense-Record erasure is the one sanctioned cast (same role as eraseRegistry). NOT in this slice (3b): structured actions replacing raw keystrokes, Codex-side resolveConditionAction parity (a codex-headless submodule change), and pending*-mirror deletion. Verified: tsc clean on both projects, electron-vite build clean, vitest 170/170 — including selectors.test.ts against the policy-driven rewrite. Part of #394. Co-Authored-By: Claude Fable 5 --- src/main/sessionManager.ts | 27 ++-- .../claude/renderer/conditions/policy.ts | 59 +++++++++ .../codex/renderer/conditions/policy.ts | 15 +++ .../registry.renderer.capabilities.ts | 32 +++++ .../conditions/ProviderConditionOutlet.tsx | 11 +- .../debug/devModules/Conditions/module.tsx | 22 ++-- .../src/workspace/conditions/selectors.ts | 81 ++++++------ .../workspace/hook/ipc/useIpcSubscriptions.ts | 118 +++++++++++------- .../src/workspace/tile-tree/TileLeaf.tsx | 15 ++- src/shared/types/providerConditions.ts | 56 ++++++++- 10 files changed, 320 insertions(+), 116 deletions(-) create mode 100644 src/providers/claude/renderer/conditions/policy.ts create mode 100644 src/providers/codex/renderer/conditions/policy.ts diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index 8bf8ae9be..62a9383eb 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -937,22 +937,27 @@ export class SessionManager extends EventEmitter { action: ConditionCustomAction, ): Promise { const entry = this.sessions.get(sessionId) - if (!entry || entry.kind !== 'claude') { + // ANY agent kind may carry a resolver now (#394 phase 3) — the + // old `entry.kind !== 'claude'` gate meant a provider that + // implemented AgentSession.resolveCondition still got a dead-end + // 'no-session' from the manager (#394 §5). `=== 'terminal'` + // rather than !isAgentProviderKind because TypeScript only + // discriminates the RegistryEntry union on literal comparisons. + // A provider without a resolver (Codex today — its approvals + // answer via raw keystrokes) hits the capability check below and + // returns 'no-resolver', which is truthful. + if (!entry || entry.kind === 'terminal') { return { ok: false, reason: 'no-session' } } if (typeof entry.session.resolveCondition !== 'function') { return { ok: false, reason: 'no-resolver' } } - // The AgentSession contract types `reason` as a plain string (see - // @shared/types/session.ts: a third provider needs freedom to - // define its own failure vocabulary). ResolveConditionResult - // narrows to Claude's known reasons for consumers; the runtime - // value satisfies it, but the compiler can't prove it here without - // the widened return type from the contract. Cast is safe because - // the manager only routes claude-kind sessions to this method - // (the entry.kind !== 'claude' guard above), and Claude's - // resolveCondition still returns the narrow shape. Phase 3 - // (conditions v2) generalizes this to a per-provider reason set. + // The AgentSession contract types `reason` as a plain string (a + // provider defines its own failure vocabulary); the app-side + // ResolveConditionResult narrows to the known reason set for + // renderer/IPC consumers. The cast asserts providers keep their + // reasons within that set — Claude's DriveResult does; a new + // provider's resolver must too (documented on the contract). return entry.session.resolveCondition(action) as Promise } diff --git a/src/providers/claude/renderer/conditions/policy.ts b/src/providers/claude/renderer/conditions/policy.ts new file mode 100644 index 000000000..ece3d9304 --- /dev/null +++ b/src/providers/claude/renderer/conditions/policy.ts @@ -0,0 +1,59 @@ +// Claude condition policy (#394 phase 3) — the per-provider data the +// generic workspace condition selectors consume. Previously these +// lived as hardcoded kind-string lists + `provider === 'claude'` +// branches inside workspace/conditions/selectors.ts (the exact seam +// docs/design/conditions-system.md and conditions-core/view.ts:27-32 +// deferred: "the migration is a deletion, not a re-derivation"). +// +// WHY policy data instead of deriving from the registered views' +// `attention()` fns: the sets and the views deliberately DISAGREE — +// ask-user-question is attention-worthy but has NO outlet view (it +// renders as a feed row), while compaction HAS a view with an +// attention fn but is excluded from the attention set (progress, not +// actionable). Derivation from views would silently change both. + +import type { ProviderConditionPolicy } from '@providers/registry.renderer.capabilities' + +export const CLAUDE_CONDITION_POLICY: ProviderConditionPolicy = { + // Live, user-actionable prompts. EXCLUDES claude.compaction + // (progress, not actionable) and claude.slash-picker (a composer + // affordance, not an attention surface). + attentionKinds: new Set([ + 'claude.trust-dialog', + 'claude.resume-prompt', + 'claude.permission-prompt', + 'claude.ask-user-question', + ]), + // Blocking-input kinds for keystroke routing (presence-gated, not + // visible-gated — see hasActionCondition's docstring). Same + // membership as attentionKinds today; kept separate because the + // gates differ and a future kind may belong to one but not the + // other. + actionKinds: new Set([ + 'claude.trust-dialog', + 'claude.resume-prompt', + 'claude.permission-prompt', + 'claude.ask-user-question', + ]), + // Priority-ordered dispatch badge rules — first match wins. + // 'QUESTION' is deliberately outside conditions-core's + // AttentionLevel union; dispatch badges are free-form strings. + attentionLabels: [ + { kind: 'claude.permission-prompt', label: 'ACTION' }, + // A live AskUserQuestion picker means the agent is BLOCKED + // waiting for the user to choose — it needs attention just like + // a permission/trust prompt. + { kind: 'claude.ask-user-question', label: 'QUESTION' }, + { kind: 'claude.trust-dialog', label: 'TRUST' }, + { kind: 'claude.resume-prompt', label: 'RESUME' }, + { + kind: 'claude.compaction', + // Compaction is a badge only when it FAILED — running/done + // phases are progress, not attention. + label: state => + (state as { phase?: string } | null)?.phase === 'error' ? 'ERROR' : null, + }, + ], + // The composer's slash-command dropdown reads this kind's state. + composerPickerKind: 'claude.slash-picker', +} diff --git a/src/providers/codex/renderer/conditions/policy.ts b/src/providers/codex/renderer/conditions/policy.ts new file mode 100644 index 000000000..bf3ce4214 --- /dev/null +++ b/src/providers/codex/renderer/conditions/policy.ts @@ -0,0 +1,15 @@ +// Codex condition policy (#394 phase 3). See the Claude counterpart +// for why this is explicit data rather than derived from views. + +import type { ProviderConditionPolicy } from '@providers/registry.renderer.capabilities' + +export const CODEX_CONDITION_POLICY: ProviderConditionPolicy = { + attentionKinds: new Set(['codex.trust-dialog', 'codex.approval']), + actionKinds: new Set(['codex.trust-dialog', 'codex.approval']), + attentionLabels: [ + { kind: 'codex.approval', label: 'ACTION' }, + { kind: 'codex.trust-dialog', label: 'TRUST' }, + ], + // Codex has no slash-picker condition — the composer picker stays + // hidden for codex panes (composerPickerKind omitted). +} diff --git a/src/providers/registry.renderer.capabilities.ts b/src/providers/registry.renderer.capabilities.ts index 1c64e668f..fb3809646 100644 --- a/src/providers/registry.renderer.capabilities.ts +++ b/src/providers/registry.renderer.capabilities.ts @@ -15,6 +15,8 @@ import { import type { TranscriptEntryMapper } from '@shared/types/providerConfig' import { CLAUDE_IDENTITY } from '@providers/claude/renderer/identity' import { claudeComposerSubmit } from '@providers/claude/renderer/composerSubmit' +import { CLAUDE_CONDITION_POLICY } from '@providers/claude/renderer/conditions/policy' +import { CODEX_CONDITION_POLICY } from '@providers/codex/renderer/conditions/policy' import { codexComposerSubmit } from '@providers/codex/renderer/composerSubmit' import { CODEX_IDENTITY } from '@providers/codex/renderer/identity' import { @@ -86,6 +88,34 @@ export type RendererProviderCapabilities = { * workspace store. */ usesOptimisticUserEcho: boolean + /** + * Condition policy (#394 phase 3): the per-provider data the + * generic workspace condition selectors consume (attention set, + * blocking-input set, priority-ordered dispatch badge rules, + * composer picker kind). Replaced selectors.ts's hardcoded kind + * lists and provider branches. + */ + conditionPolicy: ProviderConditionPolicy +} + +/** See providers//renderer/conditions/policy.ts for the concrete + * policies and the WHY on each field's gating semantics. */ +export type ProviderConditionPolicy = { + /** Kinds that mark a backgrounded pane unread (visible-gated). */ + attentionKinds: ReadonlySet + /** Kinds that route keystrokes to the PTY instead of the composer + * (presence-gated). */ + actionKinds: ReadonlySet + /** Priority-ordered dispatch-badge rules; first match wins. Labels + * are free-form strings (deliberately wider than conditions-core's + * AttentionLevel — 'QUESTION' exists only here). */ + attentionLabels: ReadonlyArray<{ + kind: string + label: string | ((state: unknown) => string | null) + }> + /** Condition kind whose state feeds the composer's slash-command + * dropdown; omitted when the provider has no picker condition. */ + composerPickerKind?: string } /** IO bag for composerSubmit. Draft images are structural (not the @@ -113,6 +143,7 @@ const claudeCapabilities: RendererProviderCapabilities = { composerSubmit: claudeComposerSubmit, supportsImageAttachments: true, usesOptimisticUserEcho: false, + conditionPolicy: CLAUDE_CONDITION_POLICY, } const codexCapabilities: RendererProviderCapabilities = { @@ -128,6 +159,7 @@ const codexCapabilities: RendererProviderCapabilities = { composerSubmit: codexComposerSubmit, supportsImageAttachments: false, usesOptimisticUserEcho: true, + conditionPolicy: CODEX_CONDITION_POLICY, } const rendererProviderCapabilities: Record = { diff --git a/src/providers/shared/renderer/conditions/ProviderConditionOutlet.tsx b/src/providers/shared/renderer/conditions/ProviderConditionOutlet.tsx index 03c33e918..06ffab0a5 100644 --- a/src/providers/shared/renderer/conditions/ProviderConditionOutlet.tsx +++ b/src/providers/shared/renderer/conditions/ProviderConditionOutlet.tsx @@ -41,7 +41,16 @@ export function ProviderConditionOutlet({ conditions, onSend, onResolveCustom }: const { conditionViews: registry } = getRendererProviderCapabilities(conditions.provider) const dispatch = makeDispatchFromOnSend(onSend, onResolveCustom) + // The app-side open snapshot types its map Partial (an artifact of + // the per-provider mapped types it must absorb); conditions-core's + // ConditionSnapshot wants a dense Record. Values are never actually + // undefined at runtime — the evaluators emit dense maps and records + // are only ever inserted whole. This cast is the ONE sanctioned + // erasure at the outlet boundary, same role as `eraseRegistry` for + // views (#394 phase 3). + const snapshot = conditions as import('@shared/conditions-core/contract').ConditionSnapshot + return ( - + ) } diff --git a/src/renderer/src/features/debug/devModules/Conditions/module.tsx b/src/renderer/src/features/debug/devModules/Conditions/module.tsx index 0cbb2e9dd..fa6b15b69 100644 --- a/src/renderer/src/features/debug/devModules/Conditions/module.tsx +++ b/src/renderer/src/features/debug/devModules/Conditions/module.tsx @@ -28,7 +28,11 @@ import type { SemanticLiveBlock, SemanticLiveTurn, } from '@renderer/workspace/workspaceState' -import type { ProviderConditionSnapshot } from '@shared/types/providerConditions' +import { + conditionStateByKind, + type ClaudeAskUserQuestionState, + type ProviderConditionSnapshot, +} from '@shared/types/providerConditions' export const conditionsDebugModule: DevDebugModule = { id: 'conditions', @@ -136,10 +140,10 @@ function ConditionsDebug({ sessionId, runtime, kind }: DevDebugModuleProps) { const attention = dispatchAttentionLabelFromConditions(snapshot) const actionCondition = hasActionCondition(snapshot) const slashFromConditions = slashPickerFromConditions(snapshot) - const auqFromConditions = - snapshot?.provider === 'claude' - ? snapshot.conditions['claude.ask-user-question']?.state ?? null - : null + const auqFromConditions = conditionStateByKind( + snapshot ?? null, + 'claude.ask-user-question', + ) const auqBlocks = useMemo( () => collectAskUserQuestionBlocks(runtime.semantic.currentTurn, runtime.semantic.history), [runtime.semantic.currentTurn, runtime.semantic.history], @@ -296,10 +300,10 @@ function buildConditionsCopyText( const attention = dispatchAttentionLabelFromConditions(snapshot) const actionCondition = hasActionCondition(snapshot) const slashFromConditions = slashPickerFromConditions(snapshot) - const auqFromConditions = - snapshot?.provider === 'claude' - ? snapshot.conditions['claude.ask-user-question']?.state ?? null - : null + const auqFromConditions = conditionStateByKind( + snapshot ?? null, + 'claude.ask-user-question', + ) const auqBlocks = collectAskUserQuestionBlocks(runtime.semantic.currentTurn, runtime.semantic.history) const full = mode === 'full' const payload = { diff --git a/src/renderer/src/workspace/conditions/selectors.ts b/src/renderer/src/workspace/conditions/selectors.ts index cd30371c4..39c4563fa 100644 --- a/src/renderer/src/workspace/conditions/selectors.ts +++ b/src/renderer/src/workspace/conditions/selectors.ts @@ -1,4 +1,9 @@ -import type { ProviderConditionSnapshot } from '@shared/types/providerConditions' +import { + conditionStateByKind, + type ClaudeSlashPickerState, + type ProviderConditionSnapshot, +} from '@shared/types/providerConditions' +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import type { SessionRuntime } from '@renderer/workspace/workspaceState' // WHY this predicate exists (conditions audit Finding 2 + Additional A): @@ -71,17 +76,16 @@ export function clearConditionRuntimeState(): Pick< } } -// The condition kinds that represent a live, user-actionable prompt the user -// must attend to. EXCLUDES claude.compaction (progress, not actionable) and -// claude.slash-picker (a composer affordance, not an attention surface). -const ATTENTION_CONDITION_KINDS: ReadonlySet = new Set([ - 'claude.trust-dialog', - 'claude.resume-prompt', - 'claude.permission-prompt', - 'claude.ask-user-question', - 'codex.trust-dialog', - 'codex.approval', -]) +// WHY these selectors are now generic loops over the provider's +// conditionPolicy (#394 phase 3): the kind lists and `provider === +// 'claude'` branches that lived here were the deferred seam +// docs/design/conditions-system.md called out — hand-maintained +// per-provider knowledge in shell code. The policy data (attention +// set, action set, ordered label rules, picker kind) now lives with +// each provider (providers//renderer/conditions/policy.ts) and +// a registered third provider participates in unread badges, +// keystroke routing, dispatch labels, and the composer picker with +// zero edits here. // WHY this exists (conditions audit Finding 5 + Additional B): the unread/ // attention transition in useIpcSubscriptions used to read the legacy `pending*` @@ -96,59 +100,50 @@ export function conditionRequiresAttention( snapshot: ProviderConditionSnapshot | null, ): boolean { if (!snapshot) return false + const policy = getRendererProviderCapabilities(snapshot.provider).conditionPolicy for (const [kind, condition] of Object.entries(snapshot.conditions)) { - if (!condition || !ATTENTION_CONDITION_KINDS.has(kind)) continue + if (!condition || !policy.attentionKinds.has(kind)) continue const state = condition.state as { visible?: boolean } if (typeof state.visible === 'boolean' ? state.visible : true) return true } return false } +// Presence-gated (NOT visible-gated) on purpose — see the policy +// docstrings. Used by keystroke routing: while a blocking condition +// record exists, arrow keys go to the provider TUI, not the composer. export function hasActionCondition( conditions: ProviderConditionSnapshot | null, ): boolean { if (!conditions) return false - if (conditions.provider === 'claude') { - return Boolean( - conditions.conditions['claude.trust-dialog'] || - conditions.conditions['claude.resume-prompt'] || - conditions.conditions['claude.permission-prompt'] || - conditions.conditions['claude.ask-user-question'], - ) + const policy = getRendererProviderCapabilities(conditions.provider).conditionPolicy + for (const kind of policy.actionKinds) { + if (conditions.conditions[kind]) return true } - return Boolean( - conditions.conditions['codex.trust-dialog'] || - conditions.conditions['codex.approval'], - ) + return false } export function dispatchAttentionLabelFromConditions( conditions: ProviderConditionSnapshot | null, ): string | null { if (!conditions) return null - if (conditions.provider === 'claude') { - if (conditions.conditions['claude.permission-prompt']) return 'ACTION' - // A live AskUserQuestion picker means the agent is BLOCKED waiting for the - // user to choose — it needs attention just like a permission/trust prompt, - // so it surfaces a dispatch badge ('QUESTION') the same way. This is the - // The inline row still renders from transcript state (`!resultAt`), but this - // condition is now a real blocking input surface too: keybinding routing uses - // `hasActionCondition` so arrow keys keep reaching Claude's picker while the - // user decides from the terminal instead of the feed row. - if (conditions.conditions['claude.ask-user-question']) return 'QUESTION' - if (conditions.conditions['claude.trust-dialog']) return 'TRUST' - if (conditions.conditions['claude.resume-prompt']) return 'RESUME' - if (conditions.conditions['claude.compaction']?.state.phase === 'error') return 'ERROR' - return null + const policy = getRendererProviderCapabilities(conditions.provider).conditionPolicy + for (const rule of policy.attentionLabels) { + const record = conditions.conditions[rule.kind] + if (!record) continue + const label = + typeof rule.label === 'function' ? rule.label(record.state) : rule.label + if (label) return label } - if (conditions.conditions['codex.approval']) return 'ACTION' - if (conditions.conditions['codex.trust-dialog']) return 'TRUST' return null } export function slashPickerFromConditions( conditions: ProviderConditionSnapshot | null, -) { - if (conditions?.provider !== 'claude') return null - return conditions.conditions['claude.slash-picker']?.state ?? null +): ClaudeSlashPickerState | null { + if (!conditions) return null + const pickerKind = getRendererProviderCapabilities(conditions.provider) + .conditionPolicy.composerPickerKind + if (!pickerKind) return null + return conditionStateByKind(conditions, pickerKind) } diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts index 5baf937de..1760a3b5c 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts @@ -45,7 +45,17 @@ import { } from '@renderer/workspace/hook/actions/streaming' import { shouldClearIdleCodexQueuedMessages } from '@renderer/workspace/queueInvariants' import type { StreamPhase } from '@renderer/workspace/workspaceState' -import type { ProviderConditionSnapshot } from '@shared/types/providerConditions' +import { + conditionStateByKind, + type ClaudeCompactionState, + type ClaudePermissionPromptState, + type ClaudeResumePromptState, + type ClaudeSlashPickerState, + type ClaudeTrustDialogState, + type CodexApprovalState, + type CodexTrustDialogState, + type ProviderConditionSnapshot, +} from '@shared/types/providerConditions' import { clearConditionRuntimeState, conditionRequiresAttention, @@ -193,65 +203,81 @@ function applyConditionSnapshot( runtime: SessionRuntime, snapshot: ProviderConditionSnapshot, ): SessionRuntime { - if (snapshot.provider === 'claude') { - const trust = snapshot.conditions['claude.trust-dialog']?.state - const resume = snapshot.conditions['claude.resume-prompt']?.state - const permission = snapshot.conditions['claude.permission-prompt']?.state - const compaction = snapshot.conditions['claude.compaction']?.state - const slashPicker = snapshot.conditions['claude.slash-picker']?.state - - return { - ...runtime, - conditions: snapshot, - pendingTrustDialog: trust?.visible ? { workspace: trust.workspace } : null, - pendingResumePrompt: resume?.visible - ? { - sessionAgeText: resume.sessionAgeText, - tokenCountText: resume.tokenCountText, - selectedIndex: resume.selectedIndex, - } - : null, - pendingPermissionPrompt: permission?.visible - ? { - title: permission.title, - toolName: permission.toolName, - command: permission.command, - options: permission.options, - selectedIndex: permission.selectedIndex, - } - : null, - pendingCompaction: compaction?.visible && compaction.phase - ? { - phase: compaction.phase, - statusText: compaction.statusText, - errorText: compaction.errorText, - } - : null, - // Slash picker now lives on the conditions snapshot. The old fallback kept - // the previous picker when a new snapshot omitted it, which was useful for - // the legacy per-event path but is wrong for condition semantics: absence - // from the map means "not live" and must clear the composer picker. - picker: slashPicker ?? { visible: false, items: [] }, - } - } - - const trust = snapshot.conditions['codex.trust-dialog']?.state - const approval = snapshot.conditions['codex.approval']?.state + // Kind-keyed derivation (#394 phase 3) — no provider fork. Condition + // kinds are globally namespaced, so `claude.trust-dialog` can only + // exist on a claude snapshot; reading every kind unconditionally is + // provider-safe and lets a third provider's snapshot flow through + // without touching this function. The `pending*` fields remain the + // documented COMPATIBILITY/CACHE mirrors (workspaceState.ts) — + // pendingCompaction (agent-status) and picker (composer dropdown) + // are the only live product consumers; the rest are debug-only and + // slated for deletion. + const trust = + conditionStateByKind(snapshot, 'claude.trust-dialog') ?? + conditionStateByKind(snapshot, 'codex.trust-dialog') + const resume = conditionStateByKind( + snapshot, + 'claude.resume-prompt', + ) + const permission = conditionStateByKind( + snapshot, + 'claude.permission-prompt', + ) + const compaction = conditionStateByKind( + snapshot, + 'claude.compaction', + ) + const approval = conditionStateByKind(snapshot, 'codex.approval') + // The composer picker kind is provider policy (claude.slash-picker + // today; codex has none). Absence from the map means "not live" and + // must clear the composer picker — the legacy sticky fallback was + // deliberately removed. + const pickerKind = getRendererProviderCapabilities(snapshot.provider) + .conditionPolicy.composerPickerKind + const slashPicker = pickerKind + ? conditionStateByKind(snapshot, pickerKind) + : null return { ...runtime, conditions: snapshot, pendingTrustDialog: trust?.visible ? { workspace: trust.workspace } : null, + pendingResumePrompt: resume?.visible + ? { + sessionAgeText: resume.sessionAgeText, + tokenCountText: resume.tokenCountText, + selectedIndex: resume.selectedIndex, + } + : null, + pendingPermissionPrompt: permission?.visible + ? { + title: permission.title, + toolName: permission.toolName, + command: permission.command, + options: permission.options, + selectedIndex: permission.selectedIndex, + } + : null, + pendingCompaction: compaction?.visible && compaction.phase + ? { + phase: compaction.phase, + statusText: compaction.statusText, + errorText: compaction.errorText, + } + : null, pendingApproval: approval ? { callId: approval.callId ?? null, - command: approval.commandParts ?? (approval.command ? approval.command.split(/\s+/) : []), + command: + approval.commandParts ?? + (approval.command ? approval.command.split(/\s+/) : []), workdir: approval.workdir ?? null, reason: approval.reason, options: approval.options, selectedIndex: approval.selectedIndex, } : null, + picker: slashPicker ?? { visible: false, items: [] }, } } diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx index 82ed62699..d376ebdb5 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx @@ -1,3 +1,7 @@ +import { + conditionStateByKind, + type ClaudeAskUserQuestionState, +} from '@shared/types/providerConditions' import { DEFAULT_PROVIDER, isAgentProviderKind, type AgentProviderKind } from '@shared/types/providerKind' import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' @@ -534,10 +538,15 @@ export function TileLeaf({ toolIndexVersion={runtime.toolIndexVersion} subAgents={runtime.subAgents} askUserQuestionState={ + // Kind-keyed lookup (#394 phase 3): globally namespaced + // kinds make the provider narrow redundant. undefined = + // "no snapshot yet", null = "snapshot without AUQ" — Feed + // distinguishes the two. runtime.conditions - ? runtime.conditions.provider === 'claude' - ? runtime.conditions.conditions['claude.ask-user-question']?.state ?? null - : null + ? conditionStateByKind( + runtime.conditions, + 'claude.ask-user-question', + ) : undefined } // Keep render-decision logging tied to mounted feeds, not diff --git a/src/shared/types/providerConditions.ts b/src/shared/types/providerConditions.ts index 33b3e4819..68305c434 100644 --- a/src/shared/types/providerConditions.ts +++ b/src/shared/types/providerConditions.ts @@ -13,6 +13,7 @@ import type { ConditionCustomAction, ConditionAction, } from '@shared/conditions-core/contract' +import type { AgentProviderKind } from '@shared/types/providerKind' export type { ConditionPtyAction, ConditionCustomAction, ConditionAction } @@ -181,6 +182,55 @@ export type CodexConditionSnapshot = { ts: number } -export type ProviderConditionSnapshot = - | ClaudeConditionSnapshot - | CodexConditionSnapshot +/** + * The OPEN provider condition snapshot (#394 phase 3). + * + * WHY this is no longer the closed `ClaudeConditionSnapshot | + * CodexConditionSnapshot` union: the closed union meant a third + * provider could not even TYPE a snapshot without editing this file, + * and every consumer that narrowed on `snapshot.provider === 'claude'` + * was a hand-maintained provider fork (#394 §5). Condition kinds are + * globally namespaced (`.`), so kind-keyed access — + * `snapshot.conditions['claude.compaction']` — is already + * provider-safe without narrowing the whole snapshot; the union bought + * nothing except closure. + * + * The shape mirrors conditions-core's generic `ConditionSnapshot

` + * (contract.ts:87-92) with Partial-valued map access, which is what + * the per-provider condition maps produce. The per-provider + * specializations below remain as the types the provider SESSION event + * maps emit — both are assignable to this open shape by construction, + * and the wire format is unchanged (nothing inspects the map beyond + * kind-keyed access; see docs/design/conditions-system.md). + */ +export type ProviderConditionSnapshot = { + provider: AgentProviderKind + conditions: Partial> + ts: number +} + +/** Generic per-kind record — the erased form of the per-provider + * condition unions above. `state` is provider/kind-specific; use + * `conditionStateByKind` for typed access. */ +export type ProviderConditionRecord = { + kind: string + state: unknown + actions: ConditionAction[] +} + +/** + * Typed kind-keyed state lookup. The ONE sanctioned way to read a + * specific condition's state off the open snapshot — kinds are + * globally namespaced, so presence of `claude.ask-user-question` + * already implies the claude provider; no snapshot-level narrowing + * needed. The cast is the caller declaring which state shape that + * kind carries (the same trust the old closed-union indexing + * provided, made explicit). + */ +export function conditionStateByKind( + snapshot: ProviderConditionSnapshot | null | undefined, + kind: string, +): S | null { + const record = snapshot?.conditions?.[kind] + return record ? (record.state as S) : null +}