diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7add63b397b..baf05a74084 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts new file mode 100644 index 00000000000..2d490974a81 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -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() +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() + } +} diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 3747358736f..23c24b6cec4 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -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 } -export type StructuredAgentLaunchStatus = 'idle' | 'pending' | 'unknown' - -const pendingStructuredLaunchesByIdentity = new Map() -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)