mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 00:02:41 +00:00
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:
Generated
+19
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user