refactor(native-chat): move the pending structured-launch registry out of the launch entrypoint

The launch module owned both the pending-launch registry (map, listeners, status
subscription, identity key) and the launch orchestration, leaving it at the 300-line
cap with no room. The registry is a whole responsibility with its own callers, so it
moves to its own module; the entrypoint re-exports the status API it published.
This commit is contained in:
Merge Sim
2026-09-11 01:59:35 -07:00
parent 545b0fb081
commit 29561631fb
3 changed files with 144 additions and 91 deletions
+19
View File
@@ -6,6 +6,9 @@ importers:
.:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 12.0.0
version: 12.0.0
pnpm:
specifier: 12.0.0
version: 12.0.0
@@ -56,6 +59,11 @@ packages:
cpu: [x64]
os: [win32]
'@pnpm/exe@12.0.0':
resolution: {integrity: sha512-405vw2qYPPghNxoPRt2cvkGtGNU5gnInDcIe5TztQxe73yUZLqFJN/jCJFGs1xbVaIWDXEVuQ71P8npFZeatZQ==}
engines: {node: '>=18.*'}
hasBin: true
pnpm@12.0.0:
resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
engines: {node: '>=18.*'}
@@ -87,6 +95,17 @@ snapshots:
'@pnpm/exe.win32-x64@12.0.0':
optional: true
'@pnpm/exe@12.0.0':
optionalDependencies:
'@pnpm/exe.darwin-arm64': 12.0.0
'@pnpm/exe.darwin-x64': 12.0.0
'@pnpm/exe.linux-arm64': 12.0.0
'@pnpm/exe.linux-arm64-musl': 12.0.0
'@pnpm/exe.linux-x64': 12.0.0
'@pnpm/exe.linux-x64-musl': 12.0.0
'@pnpm/exe.win32-arm64': 12.0.0
'@pnpm/exe.win32-x64': 12.0.0
pnpm@12.0.0:
optionalDependencies:
'@pnpm/exe.darwin-arm64': 12.0.0
@@ -0,0 +1,104 @@
import { useSyncExternalStore } from 'react'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create'
import type { StructuredLaunchRecoveryState } from '@/lib/structured-agent-session-launch-recovery'
import type {
StructuredAgentLaunchOptions,
StructuredLaunchCallerGroup
} from '@/lib/structured-agent-session-launch-callers'
export type StructuredLaunchState = StructuredLaunchRecoveryState & {
identity: string
/** Fixed by the caller that opened this launch; a joiner delivers its text the same way. Without
* that, two entrypoints racing one identity seed the composer AND submit. */
promptDelivery: StructuredAgentLaunchOptions['promptDelivery']
callers: StructuredLaunchCallerGroup
}
export type StructuredAgentLaunchStatus = 'idle' | 'pending' | 'unknown'
const pendingStructuredLaunchesByIdentity = new Map<string, StructuredLaunchState>()
const structuredLaunchListeners = new Set<() => void>()
export function notifyStructuredLaunchListeners(): void {
for (const listener of structuredLaunchListeners) {
listener()
}
}
export function subscribeStructuredAgentLaunchStatus(listener: () => void): () => void {
structuredLaunchListeners.add(listener)
return () => structuredLaunchListeners.delete(listener)
}
// Why keyed by agent too: one worktree can hold a Claude and a Codex launch at once, and a shared
// key would hand the second caller the first agent's intent.
//
// Why keyed by the adopted conversation as well: a joining caller is handed the EXISTING intent and
// contributes only its prompt, so without this a resume that arrives while a blank launch is pending
// would be silently dropped — the user would get a blank chat, or another row's conversation, with
// no error. A launch that adopts a conversation is a different launch.
export function launchIdentity(
worktreeId: string,
agent: AgentSessionHandleProvider,
resumeFrom?: StructuredAgentSessionResumeSource
): string {
return resumeFrom
? `${agent}:${worktreeId}:resume:${resumeFrom.providerSessionId}`
: `${agent}:${worktreeId}`
}
export function getStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider
): StructuredAgentLaunchStatus {
// Any launch for this pair, not just the blank one: adopting launches carry the conversation in
// their identity, and a caller asking "is a chat starting here" means all of them.
const states = [
pendingStructuredLaunchesByIdentity.get(launchIdentity(worktreeId, agent)),
...[...pendingStructuredLaunchesByIdentity.entries()]
.filter(([identity]) => identity.startsWith(`${agent}:${worktreeId}:resume:`))
.map(([, state]) => state)
].filter((state): state is StructuredLaunchState => Boolean(state))
if (states.length === 0) {
return 'idle'
}
return states.some((state) => state.visibilityUnknown) ? 'unknown' : 'pending'
}
export function useStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider
): StructuredAgentLaunchStatus {
return useSyncExternalStore(
subscribeStructuredAgentLaunchStatus,
() => getStructuredAgentLaunchStatus(worktreeId, agent),
() => 'idle'
)
}
export function getPendingStructuredLaunch(identity: string): StructuredLaunchState | undefined {
return pendingStructuredLaunchesByIdentity.get(identity)
}
export function findPendingStructuredLaunchBySession(
worktreeId: string,
sessionId: string
): StructuredLaunchState | undefined {
return [...pendingStructuredLaunchesByIdentity.values()].find(
(candidate) =>
candidate.intent.worktreeId === worktreeId && candidate.intent.sessionId === sessionId
)
}
export function trackPendingStructuredLaunch(state: StructuredLaunchState): void {
pendingStructuredLaunchesByIdentity.set(state.identity, state)
notifyStructuredLaunchListeners()
}
export function forgetPendingStructuredLaunch(state: StructuredLaunchState): void {
if (pendingStructuredLaunchesByIdentity.get(state.identity) === state) {
pendingStructuredLaunchesByIdentity.delete(state.identity)
notifyStructuredLaunchListeners()
}
}
@@ -1,4 +1,3 @@
import { useSyncExternalStore } from 'react'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label'
import {
@@ -13,8 +12,7 @@ import {
import {
launchAndReconcile,
reconcileUnknownLaunch,
type StructuredAgentLaunchReceipt,
type StructuredLaunchRecoveryState
type StructuredAgentLaunchReceipt
} from '@/lib/structured-agent-session-launch-recovery'
import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt'
import {
@@ -27,22 +25,27 @@ import {
structuredLaunchCallersHavePendingWork,
type StructuredAgentLaunchOptions,
type StructuredLaunchCaller,
type StructuredLaunchCallerGroup,
type StructuredRefusalFallback
} from '@/lib/structured-agent-session-launch-callers'
import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create'
import {
findPendingStructuredLaunchBySession,
forgetPendingStructuredLaunch,
getPendingStructuredLaunch,
launchIdentity,
notifyStructuredLaunchListeners,
trackPendingStructuredLaunch,
type StructuredLaunchState
} from '@/lib/structured-agent-session-launch-registry'
import * as launchDraft from './structured-agent-session-launch-draft'
import { trackStructuredLaunchFailureToast } from './structured-agent-session-launch-failure-toast'
export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt }
type StructuredLaunchState = StructuredLaunchRecoveryState & {
identity: string
/** Fixed by the caller that opened this launch; a joiner delivers its text the same way. Without
* that, two entrypoints racing one identity seed the composer AND submit. */
promptDelivery: StructuredAgentLaunchOptions['promptDelivery']
callers: StructuredLaunchCallerGroup
}
export {
getStructuredAgentLaunchStatus,
subscribeStructuredAgentLaunchStatus,
useStructuredAgentLaunchStatus,
type StructuredAgentLaunchStatus
} from '@/lib/structured-agent-session-launch-registry'
type StructuredLaunchStateResult = {
state: StructuredLaunchState
@@ -58,68 +61,6 @@ export type StructuredAgentLaunchResult = {
claimDefinitiveRefusalFallback: (fallback: StructuredRefusalFallback) => Promise<boolean>
}
export type StructuredAgentLaunchStatus = 'idle' | 'pending' | 'unknown'
const pendingStructuredLaunchesByIdentity = new Map<string, StructuredLaunchState>()
const structuredLaunchListeners = new Set<() => void>()
function notifyStructuredLaunchListeners(): void {
for (const listener of structuredLaunchListeners) {
listener()
}
}
export function subscribeStructuredAgentLaunchStatus(listener: () => void): () => void {
structuredLaunchListeners.add(listener)
return () => structuredLaunchListeners.delete(listener)
}
export function getStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider
): StructuredAgentLaunchStatus {
// Any launch for this pair, not just the blank one: adopting launches carry the conversation in
// their identity, and a caller asking "is a chat starting here" means all of them.
const states = [
pendingStructuredLaunchesByIdentity.get(launchIdentity(worktreeId, agent)),
...[...pendingStructuredLaunchesByIdentity.entries()]
.filter(([identity]) => identity.startsWith(`${agent}:${worktreeId}:resume:`))
.map(([, state]) => state)
].filter((state): state is StructuredLaunchState => Boolean(state))
if (states.length === 0) {
return 'idle'
}
return states.some((state) => state.visibilityUnknown) ? 'unknown' : 'pending'
}
export function useStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider
): StructuredAgentLaunchStatus {
return useSyncExternalStore(
subscribeStructuredAgentLaunchStatus,
() => getStructuredAgentLaunchStatus(worktreeId, agent),
() => 'idle'
)
}
// Why keyed by agent too: one worktree can hold a Claude and a Codex launch at once, and a shared
// key would hand the second caller the first agent's intent.
//
// Why keyed by the adopted conversation as well: a joining caller is handed the EXISTING intent and
// contributes only its prompt, so without this a resume that arrives while a blank launch is pending
// would be silently dropped — the user would get a blank chat, or another row's conversation, with
// no error. A launch that adopts a conversation is a different launch.
function launchIdentity(
worktreeId: string,
agent: AgentSessionHandleProvider,
resumeFrom?: StructuredAgentSessionResumeSource
): string {
return resumeFrom
? `${agent}:${worktreeId}:resume:${resumeFrom.providerSessionId}`
: `${agent}:${worktreeId}`
}
/** What the outbox must carry: a draft goes to the composer seed instead. */
function outboxPromptText(options: StructuredAgentLaunchOptions): string {
return options.promptDelivery === 'draft' ? '' : (options.prompt?.trim() ?? '')
@@ -136,18 +77,11 @@ function joinLaunchDelivery(
return mode ? { ...rest, promptDelivery: mode } : rest
}
function cleanupLaunchState(state: StructuredLaunchState): void {
if (pendingStructuredLaunchesByIdentity.get(state.identity) === state) {
pendingStructuredLaunchesByIdentity.delete(state.identity)
notifyStructuredLaunchListeners()
}
}
function maybeCleanupLaunchState(state: StructuredLaunchState): void {
if (structuredLaunchCallersHavePendingWork(state.callers)) {
return
}
cleanupLaunchState(state)
forgetPendingStructuredLaunch(state)
}
function settleDefinitiveRefusalFallback(state: StructuredLaunchState): void {
@@ -197,7 +131,7 @@ function structuredAgentLaunchState(
options: StructuredAgentLaunchOptions
): StructuredLaunchStateResult {
const identity = launchIdentity(worktreeId, agent, options.resumeFrom)
const existing = pendingStructuredLaunchesByIdentity.get(identity)
const existing = getPendingStructuredLaunch(identity)
if (existing) {
if (existing.visibilityUnknown) {
existing.callers.outcome = 'pending'
@@ -270,8 +204,7 @@ function structuredAgentLaunchState(
options,
stagedEntry: stagedPrompt
})
pendingStructuredLaunchesByIdentity.set(identity, state)
notifyStructuredLaunchListeners()
trackPendingStructuredLaunch(state)
trackLaunchSettlement(state, state.promise)
trackStructuredLaunchFailureToast(
state.intent.agent,
@@ -285,16 +218,13 @@ function structuredAgentLaunchState(
}
export function cancelStructuredAgentLaunch(worktreeId: string, sessionId: string): boolean {
const state = [...pendingStructuredLaunchesByIdentity.values()].find(
(candidate) =>
candidate.intent.worktreeId === worktreeId && candidate.intent.sessionId === sessionId
)
const state = findPendingStructuredLaunchBySession(worktreeId, sessionId)
if (!state) {
return false
}
state.cancelled = true
settleStructuredLaunchCallersWithoutFallback(state.callers, 'cancelled')
cleanupLaunchState(state)
forgetPendingStructuredLaunch(state)
discardStructuredAgentSessionLaunchOutbox(state.intent.sessionId)
launchDraft.clearStructuredAgentLaunchDraft(state.intent.sessionId)
abandonStructuredAgentSessionLaunchIntent(state.intent)