From b0799b8256ef3aae2ea477caed9108f8f0eef801 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:40:07 -0700 Subject: [PATCH 01/27] WIP: establish local chat draft and first submission ownership --- .../ipc/agent-session-attachment-upload.ts | 5 +- .../agent-session-attachment-store.ts | 3 +- .../structured-agent-session-attachments.ts | 13 +- .../native-chat-attachment-upload.ts | 9 +- .../native-chat-composer-draft-indexeddb.ts | 4 +- .../native-chat-composer-draft-load.ts | 2 + .../native-chat-composer-draft-persistence.ts | 7 ++ .../native-chat-composer-draft-storage.ts | 54 ++++++-- .../native-chat-composer-draft-store.ts | 3 + .../native-chat/native-chat-new-draft.ts | 115 ++++++++++++++++++ ...structured-agent-session-message-sender.ts | 13 ++ .../use-native-chat-composer-reveal-focus.ts | 4 +- .../use-native-chat-provisional-launch.ts | 23 +++- ...tive-chat-structured-composer-transport.ts | 10 +- .../use-structured-agent-session-options.ts | 9 ++ .../lib/launch-structured-agent-session.ts | 19 ++- .../src/lib/native-chat-first-submission.ts | 73 +++++++++++ .../src/lib/native-chat-local-draft.ts | 38 ++++++ ...structured-agent-session-launch-callers.ts | 3 + ...red-agent-session-launch-create-message.ts | 5 +- ...d-agent-session-launch-outcome-tracking.ts | 17 ++- ...red-agent-session-launch-prompt-outcome.ts | 2 + .../structured-agent-session-launch-prompt.ts | 34 +++++- ...ctured-agent-session-launch-publication.ts | 5 +- .../structured-agent-session-launch-record.ts | 19 +++ ...tructured-agent-session-launch-recovery.ts | 32 +++-- ...tructured-agent-session-launch-registry.ts | 10 +- .../structured-agent-session-launch-reload.ts | 1 + .../lib/structured-agent-session-launch.ts | 16 ++- src/shared/agent-session-attachments.ts | 7 +- src/shared/protocol-version.ts | 2 + .../agent-session-attachment-params.ts | 6 + 32 files changed, 507 insertions(+), 56 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-new-draft.ts create mode 100644 src/renderer/src/lib/native-chat-first-submission.ts create mode 100644 src/renderer/src/lib/native-chat-local-draft.ts diff --git a/src/main/ipc/agent-session-attachment-upload.ts b/src/main/ipc/agent-session-attachment-upload.ts index 258c5d355d9a..a8aa89657ae8 100644 --- a/src/main/ipc/agent-session-attachment-upload.ts +++ b/src/main/ipc/agent-session-attachment-upload.ts @@ -80,7 +80,10 @@ async function uploadAttachment( const { uploadId } = await callAttachmentMethod( context, 'agentSessionAttachment.uploadStart', - { sessionId: context.sessionId, name: file.name, byteLength: file.byteLength } + { + sessionId: context.sessionId, name: file.name, byteLength: file.byteLength, + ...(context.reservedSession ? { reservedSession: context.reservedSession } : {}) + } ) try { await writeBody((contentBase64, offset) => diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts index bd6862e4c3cf..ed15e257ab8c 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts @@ -74,8 +74,9 @@ export class AgentSessionAttachmentStore { sessionId: string name: string byteLength: number + reservedSessionAdmitted?: true }): Promise<{ uploadId: string }> { - if (!this.options.hasSession(args.sessionId)) { + if (!args.reservedSessionAdmitted && !this.options.hasSession(args.sessionId)) { throw new Error('This chat is not on this host') } const name = sanitizeAgentSessionAttachmentName(args.name) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts index eb25df049c4e..8456aa08dfba 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts @@ -8,6 +8,7 @@ import { defineMethod, type RpcContext } from '../core' import { requireInstalledStructuredHost, + requireStructuredCreateSupportAdmission, structuredCallerFor } from './structured-agent-session-gate' import { @@ -19,6 +20,8 @@ import { import { remoteFileContentBudget } from './files-remote-content-budget' import type { AgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store' import { getAgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store-registry' +import { isStructuredAgentSessionIdFor } from '../../../../shared/structured-agent-session-create' +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' async function requireStore( ctx: RpcContext @@ -38,7 +41,15 @@ export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ params: AttachmentUploadStartParams, handler: async (params, ctx) => { const { store, callerKey } = await requireStore(ctx) - return store.startUpload({ callerKey, ...params }) + const reserved = params.reservedSession + if (reserved) { + requireStructuredCreateSupportAdmission(ctx, reserved.agent) + const support = await ctx.runtime.getStructuredAgentSessionCreateSupport(reserved.worktree, reserved.agent) + if (!support.supported || !isStructuredAgentSessionIdFor(reserved.agent, params.sessionId)) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) + } + } + return store.startUpload({ callerKey, ...params, ...(reserved ? { reservedSessionAdmitted: true as const } : {}) }) } }), defineMethod({ diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 552f6fed2f70..dcdfe4702095 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -26,6 +26,8 @@ import type { AgentSessionAttachmentPathUploadResult, AgentSessionAttachmentUploadTarget } from '../../../../shared/agent-session-attachments' +import { AGENT_SESSION_DRAFT_ATTACHMENTS_RUNTIME_CAPABILITY, type AgentSessionReservedAttachmentSession } from '../../../../shared/agent-session-attachments' +import { readNativeChatNewDraft } from './native-chat-new-draft' import type { AppState } from '@/store/types' import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop' import { findTerminalTabWorktreeId } from './native-chat-file-link' @@ -131,7 +133,8 @@ export type NativeChatStructuredAttachmentSession = { export function resolveNativeChatRuntimeSessionAttachmentOwner(session: { sessionId: string runtimeEnvironmentId: string -}): NativeChatAttachmentOwner { +}, stageDraft = true): NativeChatAttachmentOwner { + if (stageDraft && readNativeChatNewDraft(session.sessionId)) return { kind: 'local' } const pairingRevision = getRuntimeEnvironmentRevision(session.runtimeEnvironmentId) if (pairingRevision === undefined) { return { kind: 'not-ready' } @@ -197,7 +200,8 @@ export { nativeChatLocalAttachmentUnsupportedNotice } from './native-chat-compos * upload: a path from this machine would mean nothing to its agent. */ export async function prepareNativeChatSessionAttachmentUpload( - owner: NativeChatRuntimeSessionAttachmentOwner + owner: NativeChatRuntimeSessionAttachmentOwner, + reservedSession?: AgentSessionReservedAttachmentSession ): Promise< { ok: true; target: AgentSessionAttachmentUploadTarget } | { ok: false; notice: string } > { @@ -224,6 +228,7 @@ export async function prepareNativeChatSessionAttachmentUpload( sessionId: owner.sessionId, expectedEnvironmentPairingRevision: owner.pairingRevision, expectedEnvironmentRuntimeId: status.runtimeId + ,...(reservedSession && status.capabilities?.includes(AGENT_SESSION_DRAFT_ATTACHMENTS_RUNTIME_CAPABILITY) ? { reservedSession } : {}) } } } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts index 32864af94ce5..1e803332f25f 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts @@ -3,7 +3,7 @@ import type { NativeChatComposerDraftStorage, - StoredNativeChatComposerDraft + NativeChatStoredDraft } from './native-chat-composer-draft-storage' const DATABASE_NAME = 'orca-native-chat-composer-drafts' @@ -85,7 +85,7 @@ export function createIndexedDbNativeChatComposerDraftStorage( }, read: async (scopeKey) => settled((await database()).transaction(DRAFTS, 'readonly').objectStore(DRAFTS).get(scopeKey)), - write: (scopeKey, draft: StoredNativeChatComposerDraft) => + write: (scopeKey, draft: NativeChatStoredDraft) => change((drafts) => drafts.put(draft, scopeKey)), remove: (scopeKeys) => change((drafts) => { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts index 4912869e3332..dcd6c852fb48 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts @@ -25,6 +25,7 @@ import { removeLegacyLocalStorageNativeChatComposerDrafts, type StoredNativeChatComposerDraft } from './native-chat-composer-draft-storage' +import { loadNativeChatNewDraft } from './native-chat-new-draft' // Why bounded: a database that will not open must not be retried for the whole run; after the // last try the drafts stay in memory and a refused save shows as one. @@ -59,6 +60,7 @@ function applyLoaded( ): void { const drafts = new Map() for (const [scopeKey, value] of loaded) { + if (loadNativeChatNewDraft(scopeKey, value)) continue drafts.set(scopeKey, parseStoredNativeChatComposerDraft(value)) } for (const scopeKey of replayNativeChatComposerDraftJournal(drafts, journal)) { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts index dc9e0a3d139e..11cc4a04127a 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts @@ -2,6 +2,7 @@ // storage has not confirmed when the window goes away, and follows other windows' writes. import { basename } from '@/lib/path' +import { loadNativeChatNewDraft } from './native-chat-new-draft' import { isNativeChatKeptPastePath, isNativeChatPastedImagePath } from './native-chat-image-paste' import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' import { @@ -40,6 +41,11 @@ const inFlight = new Set>() const scopeWrites = new Map>>() let channel: BroadcastChannel | null = null +export function broadcastNativeChatComposerDraftChange(scopeKey: string): void { + installNativeChatComposerDraftBroadcast() + channel?.postMessage({ scopeKey }) +} + /** An image the draft names but can no longer send, so the user can attach it again. */ export function unavailableNativeChatComposerDraftImage( image: NativeChatComposerDraftImage @@ -263,6 +269,7 @@ function adoptForeignChange(message: unknown): void { void nativeChatComposerDraftStorage() .read(scopeKey) .then((value) => { + if (loadNativeChatNewDraft(scopeKey, value, true)) return if (hasLocalChange(scopeKey)) { return } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts index bc38203f2154..2485c271e22b 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts @@ -5,6 +5,17 @@ import type { JSONContent } from '@tiptap/react' import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { createIndexedDbNativeChatComposerDraftStorage } from './native-chat-composer-draft-indexeddb' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../../shared/tui-agent' + +export type NativeChatNewDraft = { + readonly owner: NativeChatComposerDraftOwner + readonly agent: TuiAgent + readonly groupId: string + readonly options: Readonly> +} + +export type NativeChatStoredDraft = StoredNativeChatComposerDraft | NativeChatNewDraft export type NativeChatComposerDraftImage = { id: string @@ -38,12 +49,12 @@ export type StoredNativeChatComposerDraft = NativeChatComposerDraft & { export type NativeChatComposerDraftStorage = { loadAll(): Promise> read(scopeKey: string): Promise - write(scopeKey: string, draft: StoredNativeChatComposerDraft): Promise + write(scopeKey: string, draft: NativeChatStoredDraft): Promise remove(scopeKeys: readonly string[]): Promise /** Reads the stored record and writes what `apply` makes of it, as one change. */ update( scopeKey: string, - apply: (stored: unknown) => StoredNativeChatComposerDraft | null + apply: (stored: unknown) => NativeChatStoredDraft | null ): Promise } @@ -54,36 +65,46 @@ export function createMemoryNativeChatComposerDraftStorage(): NativeChatComposer refuseWrites: boolean } { const drafts = new Map() + const metadata = new Map() + const put = (key: string, draft: NativeChatStoredDraft): void => { + if ('text' in draft) drafts.set(key, draft) + else metadata.set(key, draft) + } const storage = { drafts, refuseWrites: false, - loadAll: async () => new Map(drafts), - read: async (scopeKey: string) => drafts.get(scopeKey), - write: (scopeKey: string, draft: StoredNativeChatComposerDraft) => { + loadAll: async () => new Map([...drafts, ...metadata]), + read: async (scopeKey: string) => drafts.get(scopeKey) ?? metadata.get(scopeKey), + write: (scopeKey: string, draft: NativeChatStoredDraft) => { if (storage.refuseWrites) { return Promise.reject(new DOMException('refused', 'QuotaExceededError')) } - drafts.set(scopeKey, draft) + put(scopeKey, draft) return Promise.resolve() }, remove: (scopeKeys: readonly string[]) => { + if (storage.refuseWrites) { + return Promise.reject(new DOMException('refused', 'QuotaExceededError')) + } for (const scopeKey of scopeKeys) { drafts.delete(scopeKey) + metadata.delete(scopeKey) } return Promise.resolve() }, update: ( scopeKey: string, - apply: (stored: unknown) => StoredNativeChatComposerDraft | null + apply: (stored: unknown) => NativeChatStoredDraft | null ) => { if (storage.refuseWrites) { return Promise.reject(new DOMException('refused', 'QuotaExceededError')) } - const next = apply(drafts.get(scopeKey)) + const next = apply(drafts.get(scopeKey) ?? metadata.get(scopeKey)) if (next) { - drafts.set(scopeKey, next) + put(scopeKey, next) } else { drafts.delete(scopeKey) + metadata.delete(scopeKey) } return Promise.resolve() } @@ -144,6 +165,21 @@ function parseOwner(value: unknown): NativeChatComposerDraftOwner | undefined { : undefined } +export function parseNativeChatNewDraft(value: unknown): NativeChatNewDraft | null { + if (!isRecord(value) || !isTuiAgent(value.agent) || typeof value.groupId !== 'string') { + return null + } + const owner = parseOwner(value.owner) + if (!owner || !isRecord(value.options) || !Object.values(value.options).every((v) => typeof v === 'string')) { + return null + } + const options: Record = {} + for (const [key, pick] of Object.entries(value.options)) { + if (typeof pick === 'string') options[key] = pick + } + return { owner, agent: value.agent, groupId: value.groupId, options } +} + /** A stored value as a draft, or null when it isn't one (a newer or damaged record). */ export function parseStoredNativeChatComposerDraft( value: unknown diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts index f263a569cd25..81ffe9063bbf 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -4,6 +4,7 @@ // skipped write is repaired by the next one. import type { JSONContent } from '@tiptap/react' +import { deleteNativeChatNewDraftsOwnedBy, resetNativeChatNewDraftsForTests } from './native-chat-new-draft' import { sameNativeChatComposerDraftDocument, sameNativeChatComposerDraftImages @@ -299,6 +300,7 @@ export function deleteNativeChatComposerDraftsForTab(tabId: string): void { /** Drops every draft written in a workspace the user removed, open and closed chats alike. */ export function deleteNativeChatComposerDraftsOwnedBy(owner: NativeChatComposerDraftOwner): void { + deleteNativeChatNewDraftsOwnedBy(owner) deleteDraftsWhere( (_scopeKey, draft) => draft?.owner?.workspaceId === owner.workspaceId && @@ -307,6 +309,7 @@ export function deleteNativeChatComposerDraftsOwnedBy(owner: NativeChatComposerD } export function clearNativeChatComposerDraftsForTests(): void { + resetNativeChatNewDraftsForTests() clearDraftMemoryForTests() resetNativeChatComposerDraftPersistenceForTests() resetNativeChatComposerDraftLoadForTests() diff --git a/src/renderer/src/components/native-chat/native-chat-new-draft.ts b/src/renderer/src/components/native-chat/native-chat-new-draft.ts new file mode 100644 index 000000000000..1a592079fe90 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-new-draft.ts @@ -0,0 +1,115 @@ +import { useSyncExternalStore } from 'react' +import { + nativeChatComposerDraftStorage, + parseNativeChatNewDraft, + type NativeChatComposerDraftOwner, + type NativeChatNewDraft +} from './native-chat-composer-draft-storage' +import { notifyScope, refusedScopes, scopeListeners } from './native-chat-composer-draft-memory' +import { broadcastNativeChatComposerDraftChange } from './native-chat-composer-draft-persistence' + +const PREFIX = 'agent-session-new:' +const drafts = new Map() +const changed = new Set() +const pending = new Set() + +export function nativeChatNewDraftKey(sessionId: string): string { + return `${PREFIX}${sessionId}` +} + +export function readNativeChatNewDraft(sessionId: string): NativeChatNewDraft | undefined { + return drafts.get(nativeChatNewDraftKey(sessionId)) +} + +function notify(key: string): void { + notifyScope(key) + notifyScope(`agent-session:${key.slice(PREFIX.length)}`) +} + +function failed(key: string, error: unknown): false { + console.warn('[native-chat-drafts] a new chat could not be saved', error) + refusedScopes.add(`agent-session:${key.slice(PREFIX.length)}`) + notify(key) + return false +} + +export function loadNativeChatNewDraft(key: string, value: unknown, foreign = false): boolean { + if (!key.startsWith(PREFIX)) return false + if (pending.has(key) || (!foreign && changed.has(key))) return true + const draft = parseNativeChatNewDraft(value) + if (draft) drafts.set(key, draft) + else drafts.delete(key) + notify(key) + return true +} + +export function allocateNativeChatNewDraft(sessionId: string, draft: NativeChatNewDraft): void { + const key = nativeChatNewDraftKey(sessionId) + changed.add(key) + pending.add(key) + drafts.set(key, draft) + notify(key) + void nativeChatComposerDraftStorage().write(key, draft).then( + () => broadcastNativeChatComposerDraftChange(key), + (error: unknown) => failed(key, error) + ).finally(() => pending.delete(key)) +} + +export function updateNativeChatNewDraft( + sessionId: string, + change: Partial> +): void { + const key = nativeChatNewDraftKey(sessionId) + const current = drafts.get(key) + if (!current) return + const next = { ...current, ...change } + changed.add(key) + drafts.set(key, next) + notify(key) + // Only allocation inserts metadata, so a stale window cannot revive a retired draft. + void nativeChatComposerDraftStorage().update(key, (stored) => { + const saved = parseNativeChatNewDraft(stored) + return saved ? { ...saved, ...change } : null + }).then(() => broadcastNativeChatComposerDraftChange(key), (error: unknown) => failed(key, error)) +} + +export async function retireNativeChatNewDraft(sessionId: string): Promise { + const key = nativeChatNewDraftKey(sessionId) + changed.add(key) + try { + await nativeChatComposerDraftStorage().remove([key]) + drafts.delete(key) + notify(key) + broadcastNativeChatComposerDraftChange(key) + return true + } catch (error) { + return failed(key, error) + } +} + +export function deleteNativeChatNewDraftsOwnedBy(owner: NativeChatComposerDraftOwner): void { + for (const [key, draft] of drafts) { + if (draft.owner.workspaceId === owner.workspaceId && draft.owner.executionHostId === owner.executionHostId) { + void retireNativeChatNewDraft(key.slice(PREFIX.length)) + } + } +} + +export function useNativeChatNewDraft(sessionId: string): NativeChatNewDraft | undefined { + return useSyncExternalStore( + (listener) => { + const key = nativeChatNewDraftKey(sessionId) + const listeners = scopeListeners.get(key) ?? new Set() + scopeListeners.set(key, listeners) + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + () => readNativeChatNewDraft(sessionId) + ) +} + +export function resetNativeChatNewDraftsForTests(): void { + drafts.clear() + changed.clear() + pending.clear() +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts index d61a06959652..077a0b7baf68 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts @@ -112,6 +112,8 @@ export function sendStructuredAgentSessionMessage( } export type StructuredAgentSessionReservedSend = { + clientMessageId: string + setAttachments: (attachments: NonNullable) => void outcome: Promise /** Older creates wait for acquisition; their send budget still begins at send. */ legacy: () => void @@ -160,6 +162,17 @@ export function reserveStructuredAgentSessionSend( } }, STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS) return { + clientMessageId: entry.clientMessageId, + setAttachments: (attachments) => { + const current = held() + if (current) updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, { + body: { ...current.body, blocks: [ + ...current.body.blocks.filter((block) => block.type !== 'image-ref'), + ...attachments.map((image) => ({ type: 'image-ref' as const, path: image.path })) + ] }, + previewUris: attachments.map((image) => image.previewUri) + }) + }, outcome, legacy: () => clearTimeout(deadline), send: (target) => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts index 189214ee492c..06e3972a8e97 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts @@ -40,7 +40,7 @@ export function captureNativeChatComposerFocusIntent(): NativeChatComposerFocusI } } const pending: { tabId: string | null; cancel: () => void } = { tabId: null, cancel } - let timeout: ReturnType | undefined + const timeout = setTimeout(cancel, PENDING_FOCUS_MS) pendingFocusRequest = pending document.addEventListener('pointerdown', cancel, true) document.addEventListener('keydown', cancelForNavigation, true) @@ -50,8 +50,6 @@ export function captureNativeChatComposerFocusIntent(): NativeChatComposerFocusI return } pending.tabId = tabId - clearTimeout(timeout) - timeout = setTimeout(cancel, PENDING_FOCUS_MS) const reclaim = revealedComposerReclaims.get(tabId) if (reclaim) { cancel() diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts index 193037770d4d..6c947d6f8d34 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -9,6 +9,11 @@ import { } from '@/lib/structured-agent-session-launch' import { relaunchFailedStructuredAgentSessionWithMessage } from '@/lib/structured-agent-session-launch-message' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { useNativeChatNewDraft } from './native-chat-new-draft' +import { submitNativeChatLocalDraft } from '@/lib/native-chat-first-submission' +import type { StructuredAgentSessionAttachment } from '../../../../shared/structured-agent-session-send-mutation' +import { structuredLaunchSeedOptions } from '../../../../shared/native-chat-session-option-defaults' +import { useAppStore } from '@/store' /** A chat this view launched: a new conversation, or one resumed from history. */ export type StructuredAgentSessionLaunchView = { @@ -67,8 +72,15 @@ export function useNativeChatProvisionalLaunch( sessionId: string ) { const lifecycle = useStructuredAgentSessionLaunchLifecycle(worktreeId ?? '', sessionId) + const draft = useNativeChatNewDraft(sessionId) + const localDraft = draft !== undefined && lifecycle !== 'published' const failure = useStructuredAgentSessionLaunchFailure(worktreeId ?? '', sessionId) - const launch = useLatchedLaunchView(sessionId, worktreeId, lifecycle !== null) + const latched = useLatchedLaunchView(sessionId, worktreeId, lifecycle !== null || Boolean(draft)) + const launch: StructuredAgentSessionLaunchView | undefined = localDraft && draft ? { + kind: 'new', heldOptions: draft.options, + ...(draft.owner.executionHostId === 'local' ? { seedOptions: structuredLaunchSeedOptions(useAppStore.getState().settings, draft.agent) } : {}), + worktree: toRuntimeWorktreeSelector(draft.owner.workspaceId) + } : latched const retry = useCallback(() => { if (worktreeId) { retryStructuredAgentSessionLaunch(worktreeId, sessionId) @@ -77,10 +89,13 @@ export function useNativeChatProvisionalLaunch( // While the chat starts it takes no send, so Send stays off and the text stays in the box. const starting = lifecycle === 'pending' || lifecycle === 'visibility-unknown' const sendThroughLaunch = useCallback( - (text: string, withImages: boolean, send: () => boolean | 'queued'): boolean | 'queued' => { + (text: string, withImages: boolean, send: () => boolean | 'queued', submission?: { + attachments: readonly StructuredAgentSessionAttachment[]; options: Readonly> + }): boolean | 'queued' => { if (starting) { return false } + if (localDraft) return submitNativeChatLocalDraft(sessionId, text, submission?.attachments ?? [], submission?.options ?? {}) if (lifecycle !== 'failed' || !worktreeId) { return send() } @@ -91,7 +106,7 @@ export function useNativeChatProvisionalLaunch( relaunchFailedStructuredAgentSessionForMessage(worktreeId, sessionId) return false }, - [lifecycle, sessionId, starting, worktreeId] + [lifecycle, localDraft, sessionId, starting, worktreeId] ) return { lifecycle, @@ -100,6 +115,6 @@ export function useNativeChatProvisionalLaunch( retry, starting, sendThroughLaunch, - transportEnabled: lifecycle === null || lifecycle === 'published' + transportEnabled: !localDraft && (lifecycle === null || lifecycle === 'published') } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts index 3e4a90395a76..451f6d760afa 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts @@ -7,6 +7,9 @@ import type { } from './native-chat-composer-types' import type { NativeChatStructuredViewProps } from './native-chat-view-types' import type { useStructuredAgentSession } from './use-structured-agent-session' +import type { StructuredAgentSessionAttachment } from '../../../../shared/structured-agent-session-send-mutation' +import { structuredAgentSessionSendSelection } from '../../../../shared/structured-agent-session-option-view' +import { readNativeChatNewDraft } from './native-chat-new-draft' /** What the composer of a structured chat sends, picks and runs through, from the chat's controller. */ export function useNativeChatStructuredComposerTransport(args: { @@ -16,7 +19,8 @@ export function useNativeChatStructuredComposerTransport(args: { sendThroughLaunch: ( text: string, withImages: boolean, - send: () => boolean | 'queued' + send: () => boolean | 'queued', + submission?: { attachments: readonly StructuredAgentSessionAttachment[]; options: Readonly> } ) => boolean | 'queued' /** The chat is starting: it takes no send yet. */ starting: boolean @@ -48,7 +52,9 @@ export function useNativeChatStructuredComposerTransport(args: { previewUri: path, ...(connectionId ? { connectionId } : {}) })) - ) + ), + { attachments: attachments.map(({ path, connectionId }) => ({ path, previewUri: path, ...(connectionId ? { connectionId } : {}) })), + options: { ...readNativeChatNewDraft(props.sessionId)?.options, ...structuredAgentSessionSendSelection(props.agent, controller.optionSnapshot) } } ), dispatchCommand: (text: string) => dispatchStructuredAgentSessionComposerCommand(text, { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 11a1ab3b4a96..e7dd0829e7f1 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -1,4 +1,5 @@ import { toast } from 'sonner' +import { readNativeChatNewDraft, updateNativeChatNewDraft } from './native-chat-new-draft' import { useCallback, useMemo } from 'react' import type { AgentSessionOptionResult, @@ -245,6 +246,14 @@ export function useStructuredAgentSessionOptions(args: { return false } if (!transportEnabled || fence === null) { + const draft = readNativeChatNewDraft(sessionId) + if (draft) { + const options = id === 'model' + ? { ...(draft.options[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] ? { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: draft.options[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] } : {}), [id]: encoded } + : { ...draft.options, [id]: encoded } + updateNativeChatNewDraft(sessionId, { options }) + return true + } // No fence yet: the launch holds it and applies it before anything else is sent. const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded) void applied?.then(settleLaunchOptionPick) diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index f941f9e40d7a..d1eac126fdd9 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -1,4 +1,5 @@ import type { TuiAgent } from '../../../shared/tui-agent' +import type { StructuredAgentFirstSubmission } from './structured-agent-session-launch-record' import { createStructuredAgentSessionId, structuredAgentSessionCreateParams, @@ -44,6 +45,7 @@ export type StructuredAgentSessionLaunchIntent = { target: RuntimeClientTarget agent: TuiAgent params: StructuredAgentSessionCreateParams + firstSubmission?: StructuredAgentFirstSubmission createMessageSupport?: boolean createPrepared?: true prepareCreate?: () => void @@ -86,20 +88,22 @@ export function createStructuredAgentSessionLaunchIntent( agent: TuiAgent, executionHostId?: ExecutionHostId, resumeFrom?: StructuredAgentSessionResumeSource, - hostSeedOptions?: LaunchSeed + hostSeedOptions?: LaunchSeed, + reservedSessionId?: string ): StructuredAgentSessionLaunchIntent { const owner = structuredAgentSessionOwnerTarget( worktreeId, executionHostId ?? resolveStructuredAgentSessionOwner(useAppStore.getState(), worktreeId) ) - const sessionId = createStructuredAgentSessionId(agent, createBrowserUuid) + const sessionId = reservedSessionId ?? createStructuredAgentSessionId(agent, createBrowserUuid) return buildStructuredAgentSessionLaunchIntent( worktreeId, owner, agent, sessionId, resumeFrom, - hostSeedOptions + hostSeedOptions, + reservedSessionId === undefined ) } @@ -109,10 +113,11 @@ function buildStructuredAgentSessionLaunchIntent( agent: TuiAgent, sessionId: string, resumeFrom: StructuredAgentSessionResumeSource | undefined, - hostSeedOptions: LaunchSeed + hostSeedOptions: LaunchSeed, + requestFocus = true ): StructuredAgentSessionLaunchIntent { const state = useAppStore.getState() - recordWebSessionFocusIntent( + if (requestFocus) recordWebSessionFocusIntent( structuredAgentSessionFocusOwner(owner.target), worktreeId, `agent-session:${sessionId}`, @@ -165,10 +170,11 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { firstMessage?: StructuredAgentSessionFirstMessage options?: Readonly> createMessageSupport?: boolean + firstSubmission?: StructuredAgentFirstSubmission }): StructuredAgentSessionLaunchIntent { const state = useAppStore.getState() const { target } = structuredAgentSessionOwnerTarget(args.worktreeId, args.executionHostId) - recordWebSessionFocusIntent( + if (!args.firstSubmission) recordWebSessionFocusIntent( structuredAgentSessionFocusOwner(target), args.worktreeId, `agent-session:${args.sessionId}`, @@ -181,6 +187,7 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { executionHostId: args.executionHostId, target, agent: args.agent, + ...(args.firstSubmission ? { firstSubmission: args.firstSubmission } : {}), params: { envelope: { sessionId: args.sessionId, diff --git a/src/renderer/src/lib/native-chat-first-submission.ts b/src/renderer/src/lib/native-chat-first-submission.ts new file mode 100644 index 000000000000..c02dd283ef58 --- /dev/null +++ b/src/renderer/src/lib/native-chat-first-submission.ts @@ -0,0 +1,73 @@ +import { + readNativeChatNewDraft +} from '@/components/native-chat/native-chat-new-draft' +import { readNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import { + stageStructuredLaunchPrompt, + failStagedStructuredLaunchPrompt +} from './structured-agent-session-launch-prompt' +import { startStructuredAgentLaunch } from './structured-agent-session-launch' +import { newAgentLaunchRequestId } from './agent-launch-request-id' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { + prepareNativeChatSessionAttachmentUpload, + resolveNativeChatRuntimeSessionAttachmentOwner, + uploadNativeChatSessionAttachmentPaths +} from '@/components/native-chat/native-chat-attachment-upload' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import type { StructuredAgentSessionAttachment } from '../../../shared/structured-agent-session-send-mutation' +import { getStructuredLaunchStateBySessionId, getPersistedStructuredAgentLaunchRecord } from './structured-agent-session-launch-registry' +import { confirmStructuredLaunchFirstMessagePublication } from './structured-agent-session-launch-publication-confirmation' + +/** Uses the existing launch reservation for the complete immutable opening submission. */ +export function submitNativeChatLocalDraft( + sessionId: string, + text: string, + attachments: readonly StructuredAgentSessionAttachment[], + options: Readonly> +): boolean { + const draft = readNativeChatNewDraft(sessionId) + if (!draft) return false + const prior = getStructuredLaunchStateBySessionId(sessionId) + const issued = prior?.intent.firstSubmission ?? getPersistedStructuredAgentLaunchRecord(sessionId)?.firstSubmission + if (issued && prior?.callers.outcome !== 'failed') { + confirmStructuredLaunchFirstMessagePublication(draft.owner.workspaceId, sessionId) + return false + } + const sourceDraft = readNativeChatComposerDraft(`agent-session:${sessionId}`) + const stagedPrompt = stageStructuredLaunchPrompt(sessionId, text, { attachments, options, sourceDraft }) + if (!stagedPrompt.slot) return false + void (async () => { + const target = structuredAgentSessionTargetForHost(draft.owner.executionHostId) + if (!target) throw new Error('The chat host is unavailable.') + if (target.kind === 'environment' && attachments.length > 0) { + const owner = resolveNativeChatRuntimeSessionAttachmentOwner({ sessionId, runtimeEnvironmentId: target.environmentId }, false) + if (owner.kind !== 'runtime-session') throw new Error('The chat host is unavailable.') + const prepared = await prepareNativeChatSessionAttachmentUpload(owner, { + worktree: toRuntimeWorktreeSelector(draft.owner.workspaceId), agent: draft.agent + }) + if (!prepared.ok) throw new Error(prepared.notice) + const upload = async (): Promise => { + const result = await uploadNativeChatSessionAttachmentPaths(attachments.map((image) => image.path), prepared.target) + if (result.failed.length || result.skipped.length || result.uploaded.length !== attachments.length) { + throw new Error('The images could not be attached. Attach them again and send.') + } + stagedPrompt.slot?.setAttachments(attachments.map((image, index) => ({ ...image, path: result.uploaded[index].path }))) + } + if (prepared.target.reservedSession) await upload() + else stagedPrompt.beforeSend = upload + } + // Close before issue is local discard, even if an upload or capability read finished late. + if (!readNativeChatNewDraft(sessionId) || stagedPrompt.discarded) { + stagedPrompt.slot?.release() + return + } + const launch = startStructuredAgentLaunch(draft.owner.workspaceId, draft.agent, { + requestId: newAgentLaunchRequestId(), executionHostId: draft.owner.executionHostId, + reservedSessionId: sessionId, stagedPrompt, prompt: text, firstSubmissionOptions: options + }) + void launch.promptDeliveryResult?.catch(() => failStagedStructuredLaunchPrompt(stagedPrompt)) + void launch.launchResult.catch(() => {}) + })().catch(() => failStagedStructuredLaunchPrompt(stagedPrompt)) + return true +} diff --git a/src/renderer/src/lib/native-chat-local-draft.ts b/src/renderer/src/lib/native-chat-local-draft.ts new file mode 100644 index 000000000000..47ce4f027083 --- /dev/null +++ b/src/renderer/src/lib/native-chat-local-draft.ts @@ -0,0 +1,38 @@ +import type { TuiAgent } from '../../../shared/tui-agent' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { createStructuredAgentSessionId } from '../../../shared/structured-agent-session-create' +import { openStructuredAgentSessionProvisionalTab, structuredLaunchOwner } from './structured-agent-session-provisional-tab' +import { allocateNativeChatNewDraft, readNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' +import { updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import { requestNativeChatComposerFocus } from '@/components/native-chat/use-native-chat-composer-reveal-focus' +import type { Tab } from '../../../shared/tab-types' +import { flushSync } from 'react-dom' + +/** The action owns the editor; the host owns only what its first Send submits. */ +export function openNativeChatLocalDraft(args: { + requestId: string + worktreeId: string + executionHostId?: ExecutionHostId + agent: TuiAgent + groupId?: string + activate?: boolean + focus?: boolean + prompt?: string +}): Tab | null { + const owner = structuredLaunchOwner({ route: 'structured-native-chat', executionHostId: args.executionHostId }, args.worktreeId) + if (!owner) return null + const sessionId = createStructuredAgentSessionId(args.agent, () => args.requestId) + const tab = flushSync(() => { + const opened = openStructuredAgentSessionProvisionalTab({ ...args, ...owner, sessionId, targetGroupId: args.groupId }) + if (!readNativeChatNewDraft(sessionId)) { + allocateNativeChatNewDraft(sessionId, { + owner: { workspaceId: args.worktreeId, executionHostId: owner.executionHostId }, + agent: args.agent, groupId: opened.groupId, options: {} + }) + if (args.prompt) updateNativeChatComposerDraft(`agent-session:${sessionId}`, { text: args.prompt }, 'immediate') + } + return opened + }) + if (args.focus !== false && args.activate !== false) requestNativeChatComposerFocus(tab.id) + return tab +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index 0733941e683e..63b42d9f721b 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -10,6 +10,9 @@ import type { StructuredLaunchAttempt } from './structured-agent-session-launch- import type { AgentLaunchRequestId } from './agent-launch-request-id' export type StructuredAgentLaunchOptions = { + reservedSessionId?: string + stagedPrompt?: StagedStructuredLaunchPrompt + firstSubmissionOptions?: Readonly> /** The user action this start serves; only a re-delivery of it joins its chat. */ requestId: AgentLaunchRequestId prompt?: string diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts index 3535c9ceb0d0..a9c83a70e74e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -3,6 +3,7 @@ import type { StructuredLaunchState } from './structured-agent-session-launch-re import { notifyStructuredLaunchListeners } from './structured-agent-session-launch-registry' import { prepareStructuredLaunchPromptForCreate, + structuredLaunchPromptNeedsLegacySend, prepareLegacyStructuredLaunchPrompts } from './structured-agent-session-launch-prompt' import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' @@ -29,7 +30,9 @@ export function prepareStructuredLaunchCreateMessage(state: StructuredLaunchStat notifyStructuredLaunchListeners() return } - const firstMessage = prepareStructuredLaunchPromptForCreate(intent.sessionId, intent.target) + const needsLegacySend = structuredLaunchPromptNeedsLegacySend(intent.sessionId) + if (needsLegacySend) prepareLegacyStructuredLaunchPrompts(intent.sessionId) + const firstMessage = needsLegacySend ? undefined : prepareStructuredLaunchPromptForCreate(intent.sessionId, intent.target) const options = structuredLaunchCreateOptions(state) const params = { ...intent.params, diff --git a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts index 02fd06166181..836ff409fe33 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts @@ -1,4 +1,6 @@ import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' +import { retireNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' +import { clearNativeChatComposerDraftIfUnchanged } from '@/components/native-chat/native-chat-composer-draft-store' import { settleStructuredLaunchCallers, structuredLaunchCallersHavePendingWork @@ -20,8 +22,18 @@ function cleanupLaunchState(state: StructuredLaunchState): void { } } +export async function finalizeStructuredLaunchFirstSubmission(state: StructuredLaunchState): Promise { + const submission = state.intent.firstSubmission + if (!submission || !(await retireNativeChatNewDraft(state.intent.sessionId))) return + clearNativeChatComposerDraftIfUnchanged(`agent-session:${state.intent.sessionId}`, submission.draft) + delete state.intent.firstSubmission + maybeCleanupLaunchState(state) + notifyStructuredLaunchListeners() +} + export function maybeCleanupLaunchState(state: StructuredLaunchState): void { if ( + state.intent.firstSubmission || state.callers.outcome === 'failed' || structuredLaunchCallersHavePendingWork(state.callers) || Object.keys(state.selection.held).length > 0 @@ -48,11 +60,14 @@ export function trackLaunchSettlement( promise: Promise ): void { void promise.then( - () => { + (receipt) => { if (state.promise !== promise) { return } settleStructuredLaunchCallers(state.callers, 'published') + if (receipt.firstMessage?.clientMessageId === state.intent.firstSubmission?.clientMessageId) { + void finalizeStructuredLaunchFirstSubmission(state) + } notifyStructuredLaunchListeners() }, (error) => { diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts index aba3d5db48ac..35bce455b37f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts @@ -1,5 +1,6 @@ import type { StructuredLaunchCaller } from './structured-agent-session-launch-callers' import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' +import { finalizeStructuredLaunchFirstSubmission } from './structured-agent-session-launch-outcome-tracking' import { STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS } from '@/components/native-chat/structured-agent-session-message-sender' import { notifyStructuredLaunchListeners, @@ -13,6 +14,7 @@ export function trackStructuredLaunchPromptOutcome( ): void { const group = state.callers void caller.promptDeliveryResult?.then((outcome) => { + if (outcome.delivered && state.intent.firstSubmission) void finalizeStructuredLaunchFirstSubmission(state) if ( state.callers !== group || state.cancelled || diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index fb9352720406..eb9301f870b1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -15,6 +15,9 @@ import { noteStructuredAgentSessionFence } from '@/components/native-chat/struct import { publishStructuredAgentSessionSends } from '@/components/native-chat/structured-agent-session-pending-sends' import { agentSessionWriteNoticeText } from '@/components/native-chat/agent-session-write-notice-text' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { StructuredAgentSessionSendInput } from '@/components/native-chat/structured-agent-session-send-slot' +import type { NativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-storage' +import { appendToNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' export type StructuredPromptDeliveryResult = { delivered: boolean @@ -46,6 +49,8 @@ export type StagedStructuredLaunchPrompt = { sessionId: string text: string slot: StructuredAgentSessionReservedSend | null + sourceDraft?: NativeChatComposerDraft + beforeSend?: () => Promise /** Its sender keeps the text if it comes back (notes), instead of the chat's composer. */ callerKeepsText?: true delivery?: Promise @@ -66,7 +71,7 @@ const staged = new Map>() export function stageStructuredLaunchPrompt( sessionId: string, text: string, - options: { callerKeepsText?: true } = {} + options: { callerKeepsText?: true; sourceDraft?: NativeChatComposerDraft } & Pick = {} ): StagedStructuredLaunchPrompt { let discard = (): void => {} const whenDiscarded = new Promise((resolve) => { @@ -79,7 +84,8 @@ export function stageStructuredLaunchPrompt( const prompt: StagedStructuredLaunchPrompt = { sessionId, text, - slot: reserveStructuredAgentSessionSend({ sessionId, text, ...options }), + slot: reserveStructuredAgentSessionSend({ sessionId, text, ...options, + ...(options.sourceDraft ? { callerKeepsText: true as const } : {}) }), ...options, whenDiscarded, discard, @@ -90,6 +96,7 @@ export function stageStructuredLaunchPrompt( forSession.add(prompt) staged.set(sessionId, forSession) void prompt.slot?.outcome.then((outcome) => { + if (prompt.sourceDraft && (outcome === 'returned' || outcome === 'unconfirmed')) handBackStagedPrompt(prompt) const result: StagedDelivery = { delivered: outcome === 'recorded', inComposer: !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), @@ -193,6 +200,9 @@ function handBackStagedPrompt(prompt: StagedStructuredLaunchPrompt): boolean { return false } try { + if (prompt.sourceDraft) { + return appendToNativeChatComposerDraft(`agent-session:${prompt.sessionId}`, prompt.sourceDraft) + } handBackStructuredAgentSessionMessage( prompt.sessionId, `launch-${prompt.sessionId}`, @@ -218,6 +228,7 @@ function sendStagedPrompt( ): Promise { prompt.delivery ??= (async () => { noteStructuredAgentSessionFence(prompt.sessionId, receipt.fence) + await prompt.beforeSend?.() const sent = prompt.slot?.send(target) ?? sendStructuredAgentSessionMessage({ sessionId: prompt.sessionId, target, text: prompt.text }) @@ -241,6 +252,18 @@ function sendStagedPrompt( return prompt.delivery } +export function structuredLaunchPromptNeedsLegacySend(sessionId: string): boolean { + return [...(staged.get(sessionId) ?? [])].some((prompt) => prompt.beforeSend !== undefined) +} + +export function failStagedStructuredLaunchPrompt(prompt: StagedStructuredLaunchPrompt): void { + if (prompt.discarded) return + unstage(prompt) + handBackStagedPrompt(prompt) + prompt.settleCreate({ delivered: false, inComposer: true }) + publishStructuredAgentSessionSends(prompt.sessionId, { notice: agentSessionWriteNoticeText(['notDoneSend']) }) +} + export function settleStructuredAgentLaunchPrompt(args: { launchResult: Promise target: RuntimeClientTarget @@ -249,7 +272,7 @@ export function settleStructuredAgentLaunchPrompt(args: { }): Promise | undefined { // Why: a draft has no delivery event — the composer adopts it and the user sends it — so // `onPromptDelivered` never fires and no result is reported. - if (args.options.promptDelivery === 'draft' || !args.options.prompt?.trim()) { + if (args.options.promptDelivery === 'draft' || (!args.options.prompt?.trim() && !args.stagedPrompt)) { return undefined } const prompt = args.stagedPrompt @@ -269,7 +292,10 @@ export function settleStructuredAgentLaunchPrompt(args: { prompt, receipt, args.target - ) + ).catch(() => { + failStagedStructuredLaunchPrompt(prompt) + return { delivered: false, inComposer: true } + }) return { delivered, failureNotified: false, diff --git a/src/renderer/src/lib/structured-agent-session-launch-publication.ts b/src/renderer/src/lib/structured-agent-session-launch-publication.ts index 3a8161a2dfe3..474e1c9fa94c 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-publication.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-publication.ts @@ -34,7 +34,7 @@ export function markStructuredAgentSessionLaunchPublished( if (persisted?.executionHostId !== executionHostId) { return false } - if (persisted.firstMessage) { + if (persisted.firstMessage || persisted.firstSubmission) { confirmStructuredLaunchFirstMessagePublication(worktreeId, sessionId) return false } @@ -49,6 +49,7 @@ export function markStructuredAgentSessionLaunchPublished( ) { return false } + state.hostPublished = true if (state.callers.outcome === 'published') { return true } @@ -56,7 +57,7 @@ export function markStructuredAgentSessionLaunchPublished( if (state.callers.outcome === 'pending') { return true } - if (state.intent.params.firstMessage) { + if (state.intent.params.firstMessage || state.intent.firstSubmission) { confirmStructuredLaunchFirstMessagePublication(worktreeId, sessionId) return false } diff --git a/src/renderer/src/lib/structured-agent-session-launch-record.ts b/src/renderer/src/lib/structured-agent-session-launch-record.ts index 0f631ef58116..6a3209a75322 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-record.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-record.ts @@ -8,6 +8,13 @@ import { isAgentSessionOptions } from '../../../shared/agent-session-record' import { SendBodyStructure } from '../../../shared/rpc-contract/structured-agent-session-message-params' import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' +import { parseStoredNativeChatComposerDraft, type NativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-storage' + +export type StructuredAgentFirstSubmission = { + clientMessageId: string + draft: NativeChatComposerDraft + options: Readonly> +} export type StructuredAgentLaunchPersistedLifecycle = 'pending' | 'visibility-unknown' | 'failed' @@ -23,6 +30,7 @@ export type StructuredAgentLaunchPersistedRecord = { expectedRuntimeFence: number | null resumeFrom?: StructuredAgentSessionResumeSource firstMessage?: StructuredAgentSessionFirstMessage + firstSubmission?: StructuredAgentFirstSubmission options?: Readonly> createMessageSupport?: boolean /** A paired server's reported seed, which this machine cannot re-derive after a reload. */ @@ -49,6 +57,7 @@ export function structuredAgentLaunchRecordFor( expectedRuntimeFence: envelope.expectedRuntimeFence, ...(resumeFrom ? { resumeFrom } : {}), ...(firstMessage ? { firstMessage } : {}), + ...(intent.firstSubmission ? { firstSubmission: intent.firstSubmission } : {}), ...(options ? { options } : {}), ...(intent.createMessageSupport === undefined ? {} @@ -88,6 +97,7 @@ export function validRecord(value: unknown): value is Omit< const executionHostId = 'executionHostId' in value ? value.executionHostId : undefined const failedAt = 'failedAt' in value ? value.failedAt : undefined const firstMessage = 'firstMessage' in value ? value.firstMessage : undefined + const firstSubmission = 'firstSubmission' in value ? value.firstSubmission : undefined const options = 'options' in value ? value.options : undefined const createMessageSupport = 'createMessageSupport' in value ? value.createMessageSupport : undefined @@ -103,6 +113,7 @@ export function validRecord(value: unknown): value is Omit< (expectedRuntimeFence === null || typeof expectedRuntimeFence === 'number') && (failedAt === undefined || Number.isFinite(failedAt)) && (firstMessage === undefined || validFirstMessage(firstMessage)) && + (firstSubmission === undefined || validFirstSubmission(firstSubmission)) && (options === undefined || isAgentSessionOptions(options)) && (createMessageSupport === undefined || typeof createMessageSupport === 'boolean') && (resumeFrom === undefined || @@ -113,6 +124,14 @@ export function validRecord(value: unknown): value is Omit< ) } +function validFirstSubmission(value: unknown): value is StructuredAgentFirstSubmission { + return typeof value === 'object' && value !== null && + 'clientMessageId' in value && typeof value.clientMessageId === 'string' && + 'options' in value && isAgentSessionOptions(value.options) && + 'draft' in value && typeof value.draft === 'object' && value.draft !== null && + parseStoredNativeChatComposerDraft({ ...value.draft, savedAt: 0 }) !== null +} + function validFirstMessage(value: unknown): value is StructuredAgentSessionFirstMessage { return ( typeof value === 'object' && diff --git a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts index 8a376210bc0c..6d9858fff308 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts @@ -60,23 +60,31 @@ async function verifyPublishedSession(state: StructuredLaunchRecoveryState): Pro export async function recoverPublishedSessionReceipt( state: StructuredLaunchRecoveryState ): Promise { - await verifyPublishedSession(state) - const history = await callStructuredAgentSession( + if (!state.intent.firstSubmission) await verifyPublishedSession(state) + let history = await callStructuredAgentSession( state.intent.target, 'agentSession.history', - { sessionId: state.intent.sessionId, direction: 'tail', limit: 1 } + { sessionId: state.intent.sessionId, direction: 'tail', limit: state.intent.firstSubmission ? 200 : 1 } ) throwIfLaunchCancelled(state) const fence = history.page.fence ?? (!history.ok ? history.fence : undefined) if (typeof fence !== 'number') { throw new Error('structured session fence publication unavailable') } - const firstMessage = state.intent.params.firstMessage - ? history.page.submissions.find( - (submission) => - submission.clientMessageId === state.intent.params.firstMessage?.clientMessageId - ) - : undefined + const messageId = state.intent.firstSubmission?.clientMessageId ?? state.intent.params.firstMessage?.clientMessageId + let firstMessage = history.page.submissions.find((submission) => submission.clientMessageId === messageId) + for (let page = 1; state.intent.firstSubmission && !firstMessage && history.page.hasOlder && page < 8; page += 1) { + const cursor = history.page.window.oldest + if (!cursor) break + history = await callStructuredAgentSession(state.intent.target, 'agentSession.history', { + sessionId: state.intent.sessionId, direction: 'before', cursor, limit: 200 + }) + throwIfLaunchCancelled(state) + firstMessage = history.page.submissions.find((submission) => submission.clientMessageId === messageId) + } + if (state.intent.firstSubmission && !firstMessage) { + throw new Error('The opening message has not been confirmed.') + } if (state.intent.params.firstMessage && !firstMessage) { return launchStructuredAgentSession(state.intent, state.onHostSeed, true) } @@ -97,6 +105,11 @@ async function retrySameIntent( priorError: unknown ): Promise { throwIfLaunchCancelled(state) + if (state.intent.firstSubmission) { + state.visibilityUnknown = true + state.onVisibilityChanged?.() + throw priorError + } try { const receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed, true) throwIfLaunchCancelled(state) @@ -167,6 +180,7 @@ export async function reconcileUnknownLaunch( try { return await recoverPublishedSessionReceipt(state) } catch (error) { + if (state.intent.firstSubmission) throw error return retrySameIntent(state, error) } } diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index 076ecf184862..da9623be281b 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -1,4 +1,5 @@ import { useSyncExternalStore } from 'react' +import { readNativeChatNewDraft, retireNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' import type { TuiAgent } from '../../../shared/tui-agent' import type { StructuredLaunchFailure } from './structured-agent-session-launch-failure' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' @@ -25,6 +26,7 @@ import { } from './structured-agent-session-launch-cancellation' export type StructuredLaunchState = StructuredLaunchRecoveryState & { + hostPublished?: true identity: string /** Fixed by the caller that opened this launch so coalesced prompts use one delivery mode. */ promptDelivery: StructuredAgentLaunchOptions['promptDelivery'] @@ -93,6 +95,7 @@ export function deleteStructuredLaunchStateIfCurrent(state: StructuredLaunchStat if (structuredLaunchesBySessionId.get(state.intent.sessionId) !== state) { return false } + if (state.intent.firstSubmission && !state.cancelled) return false structuredLaunchesBySessionId.delete(state.intent.sessionId) deleteStructuredAgentLaunchRecord(state.intent.sessionId) return true @@ -100,12 +103,12 @@ export function deleteStructuredLaunchStateIfCurrent(state: StructuredLaunchStat function persistStructuredLaunchState(state: StructuredLaunchState): void { const lifecycle = launchStateLifecycle(state) - if (lifecycle === 'published' || lifecycle === 'cancelled') { + if ((lifecycle === 'published' && !state.intent.firstSubmission) || lifecycle === 'cancelled') { deleteStructuredAgentLaunchRecord(state.intent.sessionId) return } writeStructuredAgentLaunchRecord({ - ...structuredAgentLaunchRecordFor(state.intent, lifecycle), + ...structuredAgentLaunchRecordFor(state.intent, lifecycle === 'published' ? 'visibility-unknown' : lifecycle), ...(lifecycle === 'failed' ? { failedAt: state.callers.failedAt } : {}) }) } @@ -129,6 +132,7 @@ export function launchStateLifecycle( if (state.callers.outcome === 'published') { return 'published' } + if (state.hostPublished) return 'published' if (state.visibilityUnknown || state.callers.outcome === 'unknown') { return 'visibility-unknown' } @@ -214,7 +218,7 @@ export function shouldRetainStructuredAgentSessionLaunchTab( sessionId: string ): boolean { const lifecycle = getStructuredAgentSessionLaunchLifecycle(worktreeId, sessionId) - return lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed' + return Boolean(readNativeChatNewDraft(sessionId)) || lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed' } function markStructuredAgentSessionLaunchCancelledInternal( diff --git a/src/renderer/src/lib/structured-agent-session-launch-reload.ts b/src/renderer/src/lib/structured-agent-session-launch-reload.ts index 8a511ff876f0..7abe4a39c2d9 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-reload.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-reload.ts @@ -35,6 +35,7 @@ export function restorePersistedStructuredLaunchState( ...(record.resumeFrom ? { resumeFrom: record.resumeFrom } : {}), ...(record.seedOptions ? { seedOptions: record.seedOptions } : {}), ...(record.firstMessage ? { firstMessage: record.firstMessage } : {}), + ...(record.firstSubmission ? { firstSubmission: record.firstSubmission } : {}), ...(record.options ? { options: record.options } : {}), ...(record.createMessageSupport === undefined ? {} diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 11468d845d99..812d56a8efef 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -202,12 +202,20 @@ function structuredAgentLaunchState( agent, options.executionHostId, options.resumeFrom, - options.hostSeedOptions + options.hostSeedOptions, + options.reservedSessionId ) const text = launchPromptText(options) - const stagedPrompt = text + const stagedPrompt = options.stagedPrompt ?? (text ? stageStructuredLaunchPrompt(intent.sessionId, text, promptOwner(options)) - : null + : null) + if (stagedPrompt?.sourceDraft && stagedPrompt.slot) { + intent.firstSubmission = { + clientMessageId: stagedPrompt.slot.clientMessageId, + draft: stagedPrompt.sourceDraft, + options: options.firstSubmissionOptions ?? {} + } + } launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options) const callers = createStructuredLaunchCallerGroup({ kind: 'first', @@ -223,7 +231,7 @@ function structuredAgentLaunchState( cancelled: false, onVisibilityChanged: notifyStructuredLaunchListeners, callers, - selection: { seed: intent.seedOptions, held: {} } + selection: { seed: intent.seedOptions, held: options.firstSubmissionOptions ?? {} } } state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions) callers.onSettled = () => maybeCleanupLaunchState(state) diff --git a/src/shared/agent-session-attachments.ts b/src/shared/agent-session-attachments.ts index d2fabcabd0e0..055b5875df63 100644 --- a/src/shared/agent-session-attachments.ts +++ b/src/shared/agent-session-attachments.ts @@ -2,6 +2,10 @@ // bytes, the server keeps them in a store it owns, and the agent only ever sees the server path. import type { ImportSkipReason } from './filesystem-import-result-types' +import type { TuiAgent } from './tui-agent' + +export const AGENT_SESSION_DRAFT_ATTACHMENTS_RUNTIME_CAPABILITY = 'agent-session-draft-attachments-v1' as const +export type AgentSessionReservedAttachmentSession = { worktree: string; agent: TuiAgent } /** Directory name of the server's store, under its userData. Also how a client recognizes a * stored attachment's path without a round trip. */ @@ -92,6 +96,7 @@ export type AgentSessionAttachmentUploadTarget = { sessionId: string expectedEnvironmentPairingRevision?: number expectedEnvironmentRuntimeId: string + reservedSession?: AgentSessionReservedAttachmentSession } export type AgentSessionAttachmentPathUploadResult = { @@ -105,7 +110,7 @@ export type AgentSessionAttachmentPathUploadResult = { * by the save's `runtimeEnvironmentId`, instead of that server's temp directory. */ export type AgentSessionAttachmentClipboardTarget = Omit< AgentSessionAttachmentUploadTarget, - 'environmentId' + 'environmentId' | 'reservedSession' > /** `orca-paste-…png`, the name every pasted image gets, so it reads as "Pasted image". */ diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 18193c55454a..cc6dbcc70011 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -249,6 +249,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_SESSION_DRAFT_ATTACHMENTS_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, ...AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES, @@ -276,3 +277,4 @@ export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string // still read desktop/mobile names; remove once mobile reads runtime names. export const DESKTOP_PROTOCOL_VERSION = RUNTIME_PROTOCOL_VERSION export const MIN_COMPATIBLE_MOBILE_VERSION = MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION +import { AGENT_SESSION_DRAFT_ATTACHMENTS_RUNTIME_CAPABILITY } from './agent-session-attachments' diff --git a/src/shared/rpc-contract/agent-session-attachment-params.ts b/src/shared/rpc-contract/agent-session-attachment-params.ts index 0288c1e46323..70e2cdace55d 100644 --- a/src/shared/rpc-contract/agent-session-attachment-params.ts +++ b/src/shared/rpc-contract/agent-session-attachment-params.ts @@ -5,12 +5,18 @@ import { } from '../agent-session-attachments' import { isValidBase64 } from './clipboard-params' import { Identifier, SessionId } from './structured-agent-session-params' +import { isTuiAgent } from '../tui-agent-config' +import type { TuiAgent } from '../tui-agent' const UploadId = Identifier('Invalid upload id', 128) export const AttachmentUploadStartParams = z .object({ sessionId: SessionId, + reservedSession: z.object({ + worktree: z.string().min(1).max(4096), + agent: z.custom(isTuiAgent) + }).strict().optional(), name: z.string().min(1).max(1024), byteLength: z .number() From e57162b84fbf30e263b9008a5c06607b6682bb90 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:47:36 -0700 Subject: [PATCH 02/27] WIP: retain first submission through preparation and settlement --- .../native-chat-composer-draft-store.ts | 6 ++- .../native-chat/native-chat-new-draft.ts | 43 ++++++++++++++----- .../use-structured-agent-session-options.ts | 4 +- .../lib/launch-structured-agent-session.ts | 2 +- .../src/lib/native-chat-first-submission.ts | 15 +++---- ...ctured-agent-session-launch-create-call.ts | 11 ++++- ...red-agent-session-launch-create-message.ts | 5 ++- ...d-agent-session-launch-outcome-tracking.ts | 12 +++++- ...ctured-agent-session-launch-persistence.ts | 6 ++- .../structured-agent-session-launch-prompt.ts | 23 +++++++--- ...ctured-agent-session-launch-publication.ts | 9 +++- ...tructured-agent-session-launch-registry.ts | 1 + .../lib/structured-agent-session-launch.ts | 5 ++- .../store/slices/tabs/tabs-close-actions.ts | 10 ++++- 14 files changed, 114 insertions(+), 38 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts index 81ffe9063bbf..b64602616a4a 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -4,7 +4,7 @@ // skipped write is repaired by the next one. import type { JSONContent } from '@tiptap/react' -import { deleteNativeChatNewDraftsOwnedBy, resetNativeChatNewDraftsForTests } from './native-chat-new-draft' +import { deleteNativeChatNewDraftsOwnedBy, resetNativeChatNewDraftsForTests, nativeChatNewDraftKey } from './native-chat-new-draft' import { sameNativeChatComposerDraftDocument, sameNativeChatComposerDraftImages @@ -120,7 +120,9 @@ export function markNativeChatComposerDraftVerified(scopeKey: string): void { /** A draft storage refused to save: it is kept in memory only until a later write lands. */ export function isNativeChatComposerDraftUnsaved(scopeKey: string): boolean { - return refusedScopes.has(scopeKey) && records.has(scopeKey) + const sessionId = structuredAgentSessionIdOfDraftScope(scopeKey) + return (refusedScopes.has(scopeKey) && records.has(scopeKey)) || + (sessionId !== null && refusedScopes.has(nativeChatNewDraftKey(sessionId))) } function isEmptyDraft(draft: NativeChatComposerDraft): boolean { diff --git a/src/renderer/src/components/native-chat/native-chat-new-draft.ts b/src/renderer/src/components/native-chat/native-chat-new-draft.ts index 1a592079fe90..4ede0b123e0c 100644 --- a/src/renderer/src/components/native-chat/native-chat-new-draft.ts +++ b/src/renderer/src/components/native-chat/native-chat-new-draft.ts @@ -11,7 +11,8 @@ import { broadcastNativeChatComposerDraftChange } from './native-chat-composer-d const PREFIX = 'agent-session-new:' const drafts = new Map() const changed = new Set() -const pending = new Set() +const pending = new Map>>() +const removedOwners: NativeChatComposerDraftOwner[] = [] export function nativeChatNewDraftKey(sessionId: string): string { return `${PREFIX}${sessionId}` @@ -26,9 +27,27 @@ function notify(key: string): void { notifyScope(`agent-session:${key.slice(PREFIX.length)}`) } +export function reportNativeChatNewDraftSaveFailure(sessionId: string, error: unknown): false { + return failed(nativeChatNewDraftKey(sessionId), error) +} + +function track(key: string, write: Promise): void { + const writes = pending.get(key) ?? new Set() + pending.set(key, writes) + writes.add(write) + void write.then(() => { + refusedScopes.delete(key) + notify(key) + broadcastNativeChatComposerDraftChange(key) + }, (error: unknown) => failed(key, error)).finally(() => { + writes.delete(write) + if (!writes.size) pending.delete(key) + }) +} + function failed(key: string, error: unknown): false { console.warn('[native-chat-drafts] a new chat could not be saved', error) - refusedScopes.add(`agent-session:${key.slice(PREFIX.length)}`) + refusedScopes.add(key) notify(key) return false } @@ -37,6 +56,10 @@ export function loadNativeChatNewDraft(key: string, value: unknown, foreign = fa if (!key.startsWith(PREFIX)) return false if (pending.has(key) || (!foreign && changed.has(key))) return true const draft = parseNativeChatNewDraft(value) + if (draft && removedOwners.some((owner) => owner.workspaceId === draft.owner.workspaceId && owner.executionHostId === draft.owner.executionHostId)) { + void retireNativeChatNewDraft(key.slice(PREFIX.length)) + return true + } if (draft) drafts.set(key, draft) else drafts.delete(key) notify(key) @@ -46,13 +69,9 @@ export function loadNativeChatNewDraft(key: string, value: unknown, foreign = fa export function allocateNativeChatNewDraft(sessionId: string, draft: NativeChatNewDraft): void { const key = nativeChatNewDraftKey(sessionId) changed.add(key) - pending.add(key) drafts.set(key, draft) notify(key) - void nativeChatComposerDraftStorage().write(key, draft).then( - () => broadcastNativeChatComposerDraftChange(key), - (error: unknown) => failed(key, error) - ).finally(() => pending.delete(key)) + track(key, nativeChatComposerDraftStorage().write(key, draft)) } export function updateNativeChatNewDraft( @@ -67,17 +86,19 @@ export function updateNativeChatNewDraft( drafts.set(key, next) notify(key) // Only allocation inserts metadata, so a stale window cannot revive a retired draft. - void nativeChatComposerDraftStorage().update(key, (stored) => { + track(key, nativeChatComposerDraftStorage().update(key, (stored) => { const saved = parseNativeChatNewDraft(stored) return saved ? { ...saved, ...change } : null - }).then(() => broadcastNativeChatComposerDraftChange(key), (error: unknown) => failed(key, error)) + })) } export async function retireNativeChatNewDraft(sessionId: string): Promise { const key = nativeChatNewDraftKey(sessionId) changed.add(key) try { - await nativeChatComposerDraftStorage().remove([key]) + const removed = nativeChatComposerDraftStorage().remove([key]) + track(key, removed) + await removed drafts.delete(key) notify(key) broadcastNativeChatComposerDraftChange(key) @@ -88,6 +109,7 @@ export async function retireNativeChatNewDraft(sessionId: string): Promise { await setStructuredOption(id, value) // Read, not rendered: a pick the launch just took is not in this render's props yet. - const currentHeld = getStructuredAgentSessionLaunchSelection(sessionId)?.held + const currentHeld = readNativeChatNewDraft(sessionId)?.options ?? getStructuredAgentSessionLaunchSelection(sessionId)?.held return { snapshot: structuredAgentSessionOptionSnapshot( optionView(optionStateRef.current, launchSeedOptions, currentHeld ?? NO_HELD_OPTIONS) diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index d1eac126fdd9..ec28eaf8ad29 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -48,7 +48,7 @@ export type StructuredAgentSessionLaunchIntent = { firstSubmission?: StructuredAgentFirstSubmission createMessageSupport?: boolean createPrepared?: true - prepareCreate?: () => void + prepareCreate?: () => void | Promise /** The saved selection create seeds, read when the intent is built. */ seedOptions?: Readonly> } diff --git a/src/renderer/src/lib/native-chat-first-submission.ts b/src/renderer/src/lib/native-chat-first-submission.ts index c02dd283ef58..7e121ce6b1c6 100644 --- a/src/renderer/src/lib/native-chat-first-submission.ts +++ b/src/renderer/src/lib/native-chat-first-submission.ts @@ -3,8 +3,7 @@ import { } from '@/components/native-chat/native-chat-new-draft' import { readNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' import { - stageStructuredLaunchPrompt, - failStagedStructuredLaunchPrompt + stageStructuredLaunchPrompt } from './structured-agent-session-launch-prompt' import { startStructuredAgentLaunch } from './structured-agent-session-launch' import { newAgentLaunchRequestId } from './agent-launch-request-id' @@ -37,7 +36,7 @@ export function submitNativeChatLocalDraft( const sourceDraft = readNativeChatComposerDraft(`agent-session:${sessionId}`) const stagedPrompt = stageStructuredLaunchPrompt(sessionId, text, { attachments, options, sourceDraft }) if (!stagedPrompt.slot) return false - void (async () => { + stagedPrompt.beforeCreate = async () => { const target = structuredAgentSessionTargetForHost(draft.owner.executionHostId) if (!target) throw new Error('The chat host is unavailable.') if (target.kind === 'environment' && attachments.length > 0) { @@ -59,15 +58,13 @@ export function submitNativeChatLocalDraft( } // Close before issue is local discard, even if an upload or capability read finished late. if (!readNativeChatNewDraft(sessionId) || stagedPrompt.discarded) { - stagedPrompt.slot?.release() - return + throw new Error('The draft was closed before sending.') } - const launch = startStructuredAgentLaunch(draft.owner.workspaceId, draft.agent, { + } + const launch = startStructuredAgentLaunch(draft.owner.workspaceId, draft.agent, { requestId: newAgentLaunchRequestId(), executionHostId: draft.owner.executionHostId, reservedSessionId: sessionId, stagedPrompt, prompt: text, firstSubmissionOptions: options }) - void launch.promptDeliveryResult?.catch(() => failStagedStructuredLaunchPrompt(stagedPrompt)) - void launch.launchResult.catch(() => {}) - })().catch(() => failStagedStructuredLaunchPrompt(stagedPrompt)) + void launch.launchResult.catch(() => {}) return true } diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts index d8ea08c6e025..dbb837939353 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts @@ -94,7 +94,16 @@ export async function launchStructuredAgentSession( } if (!intent.createPrepared) { intent.createMessageSupport = supported - intent.prepareCreate?.() + try { + await intent.prepareCreate?.() + } catch (error) { + // Preparation failed before create was issued, so its sources remain safe to submit again. + if (intent.firstSubmission) throw new StructuredAgentSessionCreateRefusalError( + error instanceof Error ? error.message : 'The message could not be prepared.', + 'agent_session_attachment_unavailable' + ) + throw error + } intent.createPrepared = true } } diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts index a9c83a70e74e..e06c700873d2 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -3,6 +3,7 @@ import type { StructuredLaunchState } from './structured-agent-session-launch-re import { notifyStructuredLaunchListeners } from './structured-agent-session-launch-registry' import { prepareStructuredLaunchPromptForCreate, + prepareStructuredLaunchPromptAttachments, structuredLaunchPromptNeedsLegacySend, prepareLegacyStructuredLaunchPrompts } from './structured-agent-session-launch-prompt' @@ -19,11 +20,12 @@ import { } from './structured-agent-session-launch-options' /** Freeze what create carries once; an ambiguous reply always replays the same payload. */ -export function prepareStructuredLaunchCreateMessage(state: StructuredLaunchState): void { +export async function prepareStructuredLaunchCreateMessage(state: StructuredLaunchState): Promise { const { intent } = state if (intent.createPrepared) { return } + await prepareStructuredLaunchPromptAttachments(intent.sessionId) if (intent.createMessageSupport !== true) { prepareLegacyStructuredLaunchPrompts(intent.sessionId) intent.createPrepared = true @@ -59,6 +61,7 @@ function structuredLaunchCreateOptions( state: StructuredLaunchState ): Readonly> | undefined { const { intent, selection } = state + if (intent.firstSubmission) return intent.firstSubmission.options const firstFrame = firstFrameOptionState(intent.agent, intent.target, { newLaunch: !intent.params.resumeFrom, worktree: intent.params.worktree, diff --git a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts index 836ff409fe33..9b746f33b20f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts @@ -22,7 +22,17 @@ function cleanupLaunchState(state: StructuredLaunchState): void { } } -export async function finalizeStructuredLaunchFirstSubmission(state: StructuredLaunchState): Promise { +const finalizing = new WeakMap>() + +export function finalizeStructuredLaunchFirstSubmission(state: StructuredLaunchState): Promise { + const active = finalizing.get(state) + if (active) return active + const finalized = finalizeFirstSubmission(state).finally(() => finalizing.delete(state)) + finalizing.set(state, finalized) + return finalized +} + +async function finalizeFirstSubmission(state: StructuredLaunchState): Promise { const submission = state.intent.firstSubmission if (!submission || !(await retireNativeChatNewDraft(state.intent.sessionId))) return clearNativeChatComposerDraftIfUnchanged(`agent-session:${state.intent.sessionId}`, submission.draft) diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts index ccd9298f380c..f36efa2c11f9 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts @@ -1,3 +1,4 @@ +import { reportNativeChatNewDraftSaveFailure } from '@/components/native-chat/native-chat-new-draft' import { validRecord, type StructuredAgentLaunchPersistedRecord @@ -115,7 +116,10 @@ function writeRecords(): void { } else { localStorage.setItem(LAUNCH_STORAGE_KEY, JSON.stringify([...records.values()])) } - } catch { + } catch (error) { + for (const record of records.values()) { + if (record.firstSubmission) reportNativeChatNewDraftSaveFailure(record.sessionId, error) + } // Why: persistence is recovery bookkeeping and must never block a launch. console.warn('[structured-agent-launch] could not persist launch state') } diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index eb9301f870b1..67d19202087c 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -17,7 +17,7 @@ import { agentSessionWriteNoticeText } from '@/components/native-chat/agent-sess import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import type { StructuredAgentSessionSendInput } from '@/components/native-chat/structured-agent-session-send-slot' import type { NativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-storage' -import { appendToNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import { readNativeChatComposerDraft, updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' export type StructuredPromptDeliveryResult = { delivered: boolean @@ -50,6 +50,7 @@ export type StagedStructuredLaunchPrompt = { text: string slot: StructuredAgentSessionReservedSend | null sourceDraft?: NativeChatComposerDraft + beforeCreate?: () => Promise beforeSend?: () => Promise /** Its sender keeps the text if it comes back (notes), instead of the chat's composer. */ callerKeepsText?: true @@ -96,7 +97,7 @@ export function stageStructuredLaunchPrompt( forSession.add(prompt) staged.set(sessionId, forSession) void prompt.slot?.outcome.then((outcome) => { - if (prompt.sourceDraft && (outcome === 'returned' || outcome === 'unconfirmed')) handBackStagedPrompt(prompt) + if (!prompt.discarded && prompt.sourceDraft && (outcome === 'returned' || outcome === 'unconfirmed')) handBackStagedPrompt(prompt) const result: StagedDelivery = { delivered: outcome === 'recorded', inComposer: !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), @@ -144,8 +145,8 @@ export function takeBackLegacyStructuredLaunchPrompts(sessionId: string): void { continue } unstage(prompt) - prompt.discarded = true handBackStagedPrompt(prompt) + prompt.discarded = true prompt.discard() } } @@ -196,12 +197,16 @@ export function hasStagedStructuredLaunchPrompt(sessionId: string): boolean { /** Puts a launch's text in its chat's composer, unless its sender keeps it. True when it did; a * draft write that throws is reported in the chat instead, and never stops its caller. */ function handBackStagedPrompt(prompt: StagedStructuredLaunchPrompt): boolean { - if (prompt.callerKeepsText) { + if (prompt.callerKeepsText || prompt.discarded) { return false } try { if (prompt.sourceDraft) { - return appendToNativeChatComposerDraft(`agent-session:${prompt.sessionId}`, prompt.sourceDraft) + const scopeKey = `agent-session:${prompt.sessionId}` + const current = readNativeChatComposerDraft(scopeKey) + if (current.text || current.images.length) return false + updateNativeChatComposerDraft(scopeKey, prompt.sourceDraft, 'immediate') + return true } handBackStructuredAgentSessionMessage( prompt.sessionId, @@ -252,6 +257,12 @@ function sendStagedPrompt( return prompt.delivery } +export async function prepareStructuredLaunchPromptAttachments(sessionId: string): Promise { + for (const prompt of staged.get(sessionId) ?? []) { + await prompt.beforeCreate?.() + } +} + export function structuredLaunchPromptNeedsLegacySend(sessionId: string): boolean { return [...(staged.get(sessionId) ?? [])].some((prompt) => prompt.beforeSend !== undefined) } @@ -315,8 +326,8 @@ export function settleStructuredAgentLaunchPrompt(args: { // The chat never started: its text waits in the chat's composer for the person's own Send. if (prompt && !prompt.discarded && !prompt.delivery) { unstage(prompt) - prompt.discarded = true handBackStagedPrompt(prompt) + prompt.discarded = true } throw error } diff --git a/src/renderer/src/lib/structured-agent-session-launch-publication.ts b/src/renderer/src/lib/structured-agent-session-launch-publication.ts index 474e1c9fa94c..b2328725d550 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-publication.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-publication.ts @@ -1,3 +1,4 @@ +import { restorePersistedStructuredLaunchState } from './structured-agent-session-launch-reload' import type { ExecutionHostId } from '../../../shared/execution-host' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { deleteStructuredAgentLaunchRecord } from './structured-agent-session-launch-persistence' @@ -28,12 +29,17 @@ export function markStructuredAgentSessionLaunchPublished( sessionId: string, executionHostId: ExecutionHostId ): boolean { - const state = getStructuredLaunchStateBySessionId(sessionId) + let state = getStructuredLaunchStateBySessionId(sessionId) if (!state) { const persisted = getPersistedStructuredAgentLaunchRecord(sessionId) if (persisted?.executionHostId !== executionHostId) { return false } + if (persisted.firstSubmission) { + state = restorePersistedStructuredLaunchState(worktreeId, sessionId) + if (state) state.hostPublished = true + notifyStructuredLaunchListeners() + } if (persisted.firstMessage || persisted.firstSubmission) { confirmStructuredLaunchFirstMessagePublication(worktreeId, sessionId) return false @@ -50,6 +56,7 @@ export function markStructuredAgentSessionLaunchPublished( return false } state.hostPublished = true + notifyStructuredLaunchListeners() if (state.callers.outcome === 'published') { return true } diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index da9623be281b..7ef50c1eb0d4 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -227,6 +227,7 @@ function markStructuredAgentSessionLaunchCancelledInternal( executionHostId: ExecutionHostId, notify: boolean ): boolean { + void retireNativeChatNewDraft(sessionId) const alreadyCancelled = hasStructuredAgentLaunchCancellationTombstonePersisted(sessionId) const state = getStructuredLaunchStateBySessionId(sessionId) if (matchesLaunchWorktree(state, worktreeId) && state) { diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 812d56a8efef..3739822c6a96 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -128,7 +128,7 @@ function resetStructuredLaunchCallers(state: StructuredLaunchState): void { function restartStructuredLaunchState(state: StructuredLaunchState): void { const wasVisibilityUnknown = state.visibilityUnknown - if (!wasVisibilityUnknown) { + if (!wasVisibilityUnknown && !state.intent.firstSubmission) { state.intent = retryStructuredAgentSessionLaunchIntent(state.intent) } resetStructuredLaunchCallers(state) @@ -140,7 +140,7 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void { state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions) state.promise = publishWithHeldOptions( state, - wasVisibilityUnknown ? reconcileUnknownLaunch(state) : launchAndReconcile(state) + (wasVisibilityUnknown || state.intent.firstSubmission) ? reconcileUnknownLaunch(state) : launchAndReconcile(state) ) trackLaunchSettlement(state, state.promise) trackStructuredLaunchConfirmationDeadline(state, state.promise) @@ -235,6 +235,7 @@ function structuredAgentLaunchState( } state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions) callers.onSettled = () => maybeCleanupLaunchState(state) + setStructuredLaunchState(state) state.promise = publishWithHeldOptions(state, launchAndReconcile(state)) const caller = addStructuredLaunchCaller({ group: state.callers, diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index 7eb871fc868f..53dee6434940 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -1,3 +1,6 @@ +import { readNativeChatNewDraft, retireNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' +import { getPersistedStructuredAgentLaunchRecord, getStructuredLaunchStateBySessionId } from '@/lib/structured-agent-session-launch-registry' +import { deleteNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' import { collapseGroupLayout } from '../../../../../shared/workspace-layout/tab-group-layout-tree' import { @@ -58,7 +61,12 @@ export function createTabsCloseActions( shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId) || hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, tab.entityId) const target = structuredAgentSessionTargetForTab(state, tab) - if (target) { + const unsent = readNativeChatNewDraft(tab.entityId) && + !getStructuredLaunchStateBySessionId(tab.entityId) && !getPersistedStructuredAgentLaunchRecord(tab.entityId) + if (unsent) { + void retireNativeChatNewDraft(tab.entityId) + deleteNativeChatComposerDraft(`agent-session:${tab.entityId}`) + } else if (target) { if (provisional) { clearWebSessionFocusIntentIfMatches( structuredAgentSessionFocusOwner(target), From 2e59d8ee77f0f9dbc911b835a11a06c334662ec5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:17:00 -0700 Subject: [PATCH 03/27] Keep new chat drafts local through their first submission --- .../app-shell/native-chat-draft-startup.ts | 8 + .../app-shell/use-app-startup-hydration.ts | 3 +- ...native-chat-composer-reveal-focus.test.tsx | 1 + .../native-chat/native-chat-new-draft.ts | 4 + ...structured-agent-session-message-sender.ts | 20 ++- .../structured-agent-session-send-runtime.ts | 6 + .../use-native-chat-composer-reveal-focus.ts | 18 +- .../use-native-chat-provisional-launch.ts | 4 +- .../lib/launch-structured-agent-session.ts | 2 +- .../src/lib/native-chat-draft-owner.ts | 4 + .../src/lib/native-chat-draft-reload.ts | 65 ++++++++ .../src/lib/native-chat-first-submission.ts | 33 ++-- .../src/lib/native-chat-local-draft.test.ts | 80 +++++++++ ...ctured-agent-session-launch-create-call.ts | 42 ++++- ...gent-session-launch-create-message.test.ts | 156 +++++++++++++++++- ...red-agent-session-launch-create-message.ts | 59 +++---- .../structured-agent-session-launch-errors.ts | 3 + ...d-agent-session-launch-outcome-tracking.ts | 19 ++- ...ured-agent-session-launch-prompt-return.ts | 39 +++++ ...tured-agent-session-launch-prompt-types.ts | 49 ++++++ .../structured-agent-session-launch-prompt.ts | 137 +++++---------- ...tructured-agent-session-launch-recovery.ts | 11 +- .../store/slices/tabs/tabs-move-actions.ts | 5 + 23 files changed, 605 insertions(+), 163 deletions(-) create mode 100644 src/renderer/src/lib/native-chat-draft-reload.ts create mode 100644 src/renderer/src/lib/native-chat-local-draft.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-prompt-return.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-prompt-types.ts diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.ts b/src/renderer/src/app-shell/native-chat-draft-startup.ts index 4d64bdd708f2..c1aebac7f957 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.ts @@ -23,3 +23,11 @@ export function startNativeChatDraftLoad(): void { export function waitForNativeChatDraftsAtStartup(): Promise { return waitForNativeChatComposerDrafts(DRAFT_LOAD_WAIT_MS) } + +/** Reconciliation reads host facts; a restored marker never grants execution or focus. */ +export function reconcileNativeChatDraftsAtStartup(): void { + void hydrateNativeChatComposerDrafts().then(async () => { + const { reconcileRestoredNativeChatDrafts } = await import('../lib/native-chat-draft-reload') + await reconcileRestoredNativeChatDrafts() + }).catch((error: unknown) => console.warn('[native-chat-drafts] recovery could not finish', error)) +} diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 0c0ab0dd5436..11a5acdd2275 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -5,7 +5,7 @@ import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-p import { useAppStore } from '../store' import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models' import { useStartupActions } from './use-app-startup-actions' -import { waitForNativeChatDraftsAtStartup } from './native-chat-draft-startup' +import { reconcileNativeChatDraftsAtStartup, waitForNativeChatDraftsAtStartup } from './native-chat-draft-startup' import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees' import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep' import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-hydration' @@ -214,6 +214,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta useAppStore.getState().reconcileWorktreeTabModels ) }) + reconcileNativeChatDraftsAtStartup() await timeRendererStartupStep('prepare-terminal-startup-restoration', () => window.api.app.prepareTerminalStartupRestoration() ) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx index 94e7551b42ac..15462062f79a 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx @@ -116,6 +116,7 @@ describe('useNativeChatComposerRevealFocus', () => { tab.focus() act(() => requestNativeChatComposerFocus('chat-tab')) + expect(container.querySelector('textarea')).toBe(document.activeElement) drainFrames() expect(container.querySelector('textarea')).toBe(document.activeElement) diff --git a/src/renderer/src/components/native-chat/native-chat-new-draft.ts b/src/renderer/src/components/native-chat/native-chat-new-draft.ts index 4ede0b123e0c..96332c9ded4b 100644 --- a/src/renderer/src/components/native-chat/native-chat-new-draft.ts +++ b/src/renderer/src/components/native-chat/native-chat-new-draft.ts @@ -22,6 +22,10 @@ export function readNativeChatNewDraft(sessionId: string): NativeChatNewDraft | return drafts.get(nativeChatNewDraftKey(sessionId)) } +export function nativeChatNewDrafts(): [string, NativeChatNewDraft][] { + return [...drafts].map<[string, NativeChatNewDraft]>(([key, draft]) => [key.slice(PREFIX.length), draft]) +} + function notify(key: string): void { notifyScope(key) notifyScope(`agent-session:${key.slice(PREFIX.length)}`) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts index 077a0b7baf68..eebe72b9a911 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts @@ -114,13 +114,14 @@ export function sendStructuredAgentSessionMessage( export type StructuredAgentSessionReservedSend = { clientMessageId: string setAttachments: (attachments: NonNullable) => void + prepare: () => boolean outcome: Promise /** Older creates wait for acquisition; their send budget still begins at send. */ legacy: () => void /** Sends it once the chat exists; null when the reservation was released or dropped meanwhile. */ send: (target: RuntimeClientTarget) => StructuredAgentSessionSent | null /** The same message, owned by create instead of a later send request. */ - create: (target: RuntimeClientTarget) => StructuredAgentSessionCreateSend | null + create: (target: RuntimeClientTarget, prepared?: true) => StructuredAgentSessionCreateSend | null /** Gives the slot back unsent. */ release: () => void } @@ -154,6 +155,7 @@ export function reserveStructuredAgentSessionSend( }) const held = (): StructuredAgentSessionPendingSend | undefined => sent ? undefined : findStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId) + let preparing = false const deadline = setTimeout(() => { const current = held() if (current) { @@ -163,6 +165,18 @@ export function reserveStructuredAgentSessionSend( }, STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS) return { clientMessageId: entry.clientMessageId, + prepare: () => { + const current = held() + if (!current) return false + if (!preparing && Date.now() - current.queuedAt >= STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS) { + handBack(current, ['unreachable', ...agentSessionWriteNotDoneParts('composer-send')]) + settleReservation('returned') + return false + } + preparing = true + clearTimeout(deadline) + return true + }, setAttachments: (attachments) => { const current = held() if (current) updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, { @@ -185,14 +199,14 @@ export function reserveStructuredAgentSessionSend( } return sending }, - create: (target) => { + create: (target, prepared) => { const current = held() if (!current) { return null } sent = true clearTimeout(deadline) - const remaining = STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS - (Date.now() - current.queuedAt) + const remaining = prepared ? STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS : STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS - (Date.now() - current.queuedAt) if (remaining <= 0) { handBack(current, ['unreachable', ...agentSessionWriteNotDoneParts('composer-send')]) settleReservation('returned') diff --git a/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts b/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts index e15628df6c0b..b66d627f14a2 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts @@ -6,6 +6,7 @@ import { agentSessionWriteNoticeParts, agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' +import { readNativeChatNewDraft } from './native-chat-new-draft' import type { AgentSessionWriteNoticePart } from '../../../../shared/agent-session-write-notice-copy' import type { AgentSessionWriteFailure } from '../../../../shared/agent-session-write-failure' import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' @@ -47,6 +48,11 @@ export function finish( runtime.resolve(outcome) } updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, keep ?? null) + if (outcome === 'recorded' && readNativeChatNewDraft(entry.sessionId)) { + void import('@/lib/structured-agent-session-launch-outcome-tracking').then( + ({ finalizeStructuredLaunchConfirmedSubmission }) => finalizeStructuredLaunchConfirmedSubmission(entry.sessionId) + ) + } } /** Puts the text back in the chat's composer; false, reported, when the draft write threw. */ diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts index 06e3972a8e97..29f1b88dff38 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { RefObject } from 'react' import { shouldPreserveEditableFocus } from '@/components/terminal-pane/pane-helpers' import { scheduleNextFrame } from '@/components/terminal-pane/terminal-ime-input-context-refresh' @@ -99,34 +99,40 @@ export function useNativeChatComposerRevealFocus({ const explicitRef = useRef(false) const [reclaimCount, setReclaimCount] = useState(0) - useEffect(() => { + useLayoutEffect(() => { mountedComposers.add(composerRef) return () => { mountedComposers.delete(composerRef) } }, [composerRef]) - useEffect(() => { + useLayoutEffect(() => { if (!revealed || !composerReady) { return } const reclaim = (): void => { claimedRef.current = false explicitRef.current = true + const active = rootRef.current?.ownerDocument.activeElement ?? null + const siblingComposer = [...mountedComposers].some( + (ownerRef) => ownerRef !== composerRef && ownerRef.current?.contains(active) + ) + if ((!shouldPreserveEditableFocus(active) || siblingComposer) && composerRef.current?.focus()) { + explicitRef.current = false + } setReclaimCount((count) => count + 1) } revealedComposerReclaims.set(tabId, reclaim) if (pendingFocusRequest?.tabId === tabId) { pendingFocusRequest.cancel() - claimedRef.current = false - explicitRef.current = true + reclaim() } return () => { if (revealedComposerReclaims.get(tabId) === reclaim) { revealedComposerReclaims.delete(tabId) } } - }, [composerReady, revealed, tabId]) + }, [composerReady, composerRef, revealed, rootRef, tabId]) useEffect(() => { if (!revealed || !composerReady) { diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts index 6c947d6f8d34..4cf00ccfc6bd 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -95,7 +95,7 @@ export function useNativeChatProvisionalLaunch( if (starting) { return false } - if (localDraft) return submitNativeChatLocalDraft(sessionId, text, submission?.attachments ?? [], submission?.options ?? {}) + if (draft) return submitNativeChatLocalDraft(sessionId, text, submission?.attachments ?? [], submission?.options ?? {}, lifecycle === 'published') if (lifecycle !== 'failed' || !worktreeId) { return send() } @@ -106,7 +106,7 @@ export function useNativeChatProvisionalLaunch( relaunchFailedStructuredAgentSessionForMessage(worktreeId, sessionId) return false }, - [lifecycle, localDraft, sessionId, starting, worktreeId] + [draft, lifecycle, sessionId, starting, worktreeId] ) return { lifecycle, diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index ec28eaf8ad29..6dcdffde46e0 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -48,7 +48,7 @@ export type StructuredAgentSessionLaunchIntent = { firstSubmission?: StructuredAgentFirstSubmission createMessageSupport?: boolean createPrepared?: true - prepareCreate?: () => void | Promise + prepareCreate?: () => void | (() => void) | Promise void)> /** The saved selection create seeds, read when the intent is built. */ seedOptions?: Readonly> } diff --git a/src/renderer/src/lib/native-chat-draft-owner.ts b/src/renderer/src/lib/native-chat-draft-owner.ts index 36923536669a..e47d763497da 100644 --- a/src/renderer/src/lib/native-chat-draft-owner.ts +++ b/src/renderer/src/lib/native-chat-draft-owner.ts @@ -1,3 +1,4 @@ +import { readNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' import type { AppState } from '../store/types' import type { NativeChatComposerDraftOwner } from '@/components/native-chat/native-chat-composer-draft-storage' import { @@ -34,6 +35,9 @@ export function resolveNativeChatDraftOwner( state: AppState, scopeKey: string ): NativeChatComposerDraftOwner | undefined { + const sessionId = structuredAgentSessionIdOfDraftScope(scopeKey) + const local = sessionId ? readNativeChatNewDraft(sessionId) : undefined + if (local) return local.owner const workspaceId = workspaceOfDraftScope(state, scopeKey) const executionHostId = workspaceId ? resolveWorktreeOperationRoute(state, workspaceId)?.executionHostId diff --git a/src/renderer/src/lib/native-chat-draft-reload.ts b/src/renderer/src/lib/native-chat-draft-reload.ts new file mode 100644 index 000000000000..9ba1b4b01983 --- /dev/null +++ b/src/renderer/src/lib/native-chat-draft-reload.ts @@ -0,0 +1,65 @@ +import { useAppStore } from '@/store' +import { nativeChatNewDrafts, retireNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' +import { deleteNativeChatComposerDraft, readNativeChatComposerDraft, updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import type { NativeChatNewDraft } from '@/components/native-chat/native-chat-composer-draft-storage' +import { findKnownWorktreeById } from '@/store/slices/worktrees/listing/detected-worktree-meta' +import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { parseExecutionHostId } from '../../../shared/execution-host' +import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import { fetchFolderWorkspaceCatalogForTarget, getFolderWorkspaceHostId } from '@/store/folder-workspaces/folder-workspace-catalog' +import { catalogOwnsHost } from '@/store/slices/project-group-owner-routing' +import { openStructuredAgentSessionProvisionalTab } from './structured-agent-session-provisional-tab' +import { getPersistedStructuredAgentLaunchRecord, getStructuredLaunchStateBySessionId } from './structured-agent-session-launch-registry' +import { deleteStructuredAgentLaunchRecord } from './structured-agent-session-launch-persistence' +import { confirmStructuredLaunchFirstMessagePublication } from './structured-agent-session-launch-publication-confirmation' + +async function ownerConfirmedGone(draft: NativeChatNewDraft): Promise { + const { workspaceId, executionHostId } = draft.owner + const state = useAppStore.getState() + const host = parseExecutionHostId(executionHostId) + if (host?.kind === 'runtime' && state.runtimeEnvironmentCatalogSettled && + !state.runtimeEnvironments.some((environment) => environment.id === host.environmentId)) return true + if (findKnownWorktreeById(state, workspaceId, executionHostId)) return false + const scope = parseWorkspaceKey(workspaceId) + if (scope?.type === 'folder') { + const target = structuredAgentSessionTargetForHost(executionHostId) + if (!target) return false + const catalog = await fetchFolderWorkspaceCatalogForTarget(target, state.projectGroups) + return catalogOwnsHost(catalog.hostId, executionHostId) && !catalog.folderWorkspaces.some((row) => + row.id === scope.folderWorkspaceId && getFolderWorkspaceHostId(row, state.projectGroups) === executionHostId) + } + const authoritative = await state.fetchWorktrees(getRepoIdFromWorktreeId(workspaceId), { + executionHostId, requireAuthoritative: true + }) + return authoritative && !findKnownWorktreeById(useAppStore.getState(), workspaceId, executionHostId) +} + +export async function reconcileRestoredNativeChatDrafts(): Promise { + for (const [sessionId, draft] of nativeChatNewDrafts()) { + try { + if (getStructuredLaunchStateBySessionId(sessionId)) continue + if (await ownerConfirmedGone(draft)) { + if (await retireNativeChatNewDraft(sessionId)) { + deleteNativeChatComposerDraft(`agent-session:${sessionId}`) + deleteStructuredAgentLaunchRecord(sessionId) + } + continue + } + const issued = getPersistedStructuredAgentLaunchRecord(sessionId)?.firstSubmission + if (issued) { + const key = `agent-session:${sessionId}` + const current = readNativeChatComposerDraft(key) + if (!current.text && !current.images.length) updateNativeChatComposerDraft(key, issued.draft, 'immediate') + confirmStructuredLaunchFirstMessagePublication(draft.owner.workspaceId, sessionId) + } else if ((useAppStore.getState().groupsByWorktree[draft.owner.workspaceId] ?? []).some((group) => group.id === draft.groupId)) { + openStructuredAgentSessionProvisionalTab({ + worktreeId: draft.owner.workspaceId, executionHostId: draft.owner.executionHostId, + sessionId, agent: draft.agent, targetGroupId: draft.groupId, activate: false + }) + } + } catch (error) { + console.warn('[native-chat-drafts] the saved chat owner could not be verified', error) + } + } +} diff --git a/src/renderer/src/lib/native-chat-first-submission.ts b/src/renderer/src/lib/native-chat-first-submission.ts index 7e121ce6b1c6..e639ab13c3d3 100644 --- a/src/renderer/src/lib/native-chat-first-submission.ts +++ b/src/renderer/src/lib/native-chat-first-submission.ts @@ -3,7 +3,8 @@ import { } from '@/components/native-chat/native-chat-new-draft' import { readNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' import { - stageStructuredLaunchPrompt + stageStructuredLaunchPrompt, + deliverStructuredLaunchPromptToExistingSession } from './structured-agent-session-launch-prompt' import { startStructuredAgentLaunch } from './structured-agent-session-launch' import { newAgentLaunchRequestId } from './agent-launch-request-id' @@ -23,26 +24,27 @@ export function submitNativeChatLocalDraft( sessionId: string, text: string, attachments: readonly StructuredAgentSessionAttachment[], - options: Readonly> + options: Readonly>, + hosted = false ): boolean { const draft = readNativeChatNewDraft(sessionId) if (!draft) return false const prior = getStructuredLaunchStateBySessionId(sessionId) const issued = prior?.intent.firstSubmission ?? getPersistedStructuredAgentLaunchRecord(sessionId)?.firstSubmission - if (issued && prior?.callers.outcome !== 'failed') { + if (!hosted && issued && prior?.callers.outcome !== 'failed') { confirmStructuredLaunchFirstMessagePublication(draft.owner.workspaceId, sessionId) return false } const sourceDraft = readNativeChatComposerDraft(`agent-session:${sessionId}`) const stagedPrompt = stageStructuredLaunchPrompt(sessionId, text, { attachments, options, sourceDraft }) if (!stagedPrompt.slot) return false - stagedPrompt.beforeCreate = async () => { + const prepare = async (): Promise => { const target = structuredAgentSessionTargetForHost(draft.owner.executionHostId) if (!target) throw new Error('The chat host is unavailable.') if (target.kind === 'environment' && attachments.length > 0) { const owner = resolveNativeChatRuntimeSessionAttachmentOwner({ sessionId, runtimeEnvironmentId: target.environmentId }, false) if (owner.kind !== 'runtime-session') throw new Error('The chat host is unavailable.') - const prepared = await prepareNativeChatSessionAttachmentUpload(owner, { + const prepared = await prepareNativeChatSessionAttachmentUpload(owner, hosted ? undefined : { worktree: toRuntimeWorktreeSelector(draft.owner.workspaceId), agent: draft.agent }) if (!prepared.ok) throw new Error(prepared.notice) @@ -51,16 +53,25 @@ export function submitNativeChatLocalDraft( if (result.failed.length || result.skipped.length || result.uploaded.length !== attachments.length) { throw new Error('The images could not be attached. Attach them again and send.') } - stagedPrompt.slot?.setAttachments(attachments.map((image, index) => ({ ...image, path: result.uploaded[index].path }))) + const stored = new Map(result.uploaded.map((image) => [image.sourcePath, image.path])) + stagedPrompt.slot?.setAttachments(attachments.map((image) => { + const path = stored.get(image.path) + if (!path) throw new Error('The images could not be attached. Attach them again and send.') + return { ...image, path } + })) } - if (prepared.target.reservedSession) await upload() + if (hosted || prepared.target.reservedSession) await upload() else stagedPrompt.beforeSend = upload } - // Close before issue is local discard, even if an upload or capability read finished late. - if (!readNativeChatNewDraft(sessionId) || stagedPrompt.discarded) { - throw new Error('The draft was closed before sending.') - } } + if (hosted) { + const target = structuredAgentSessionTargetForHost(draft.owner.executionHostId) + if (!target) { stagedPrompt.slot.release(); return false } + stagedPrompt.beforeSend = prepare + void deliverStructuredLaunchPromptToExistingSession(stagedPrompt, target) + return true + } + stagedPrompt.beforeCreate = prepare const launch = startStructuredAgentLaunch(draft.owner.workspaceId, draft.agent, { requestId: newAgentLaunchRequestId(), executionHostId: draft.owner.executionHostId, reservedSessionId: sessionId, stagedPrompt, prompt: text, firstSubmissionOptions: options diff --git a/src/renderer/src/lib/native-chat-local-draft.test.ts b/src/renderer/src/lib/native-chat-local-draft.test.ts new file mode 100644 index 000000000000..23253b2c62f3 --- /dev/null +++ b/src/renderer/src/lib/native-chat-local-draft.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { Tab } from '../../../shared/tab-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { openNativeChatLocalDraft } from './native-chat-local-draft' +import { newAgentLaunchRequestId } from './agent-launch-request-id' +import { allocateNativeChatNewDraft, readNativeChatNewDraft, retireNativeChatNewDraft, updateNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' +import { clearNativeChatComposerDraftsForTests, hydrateNativeChatComposerDrafts, readNativeChatComposerDraft, updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import { createMemoryNativeChatComposerDraftStorage, setNativeChatComposerDraftStorageForTests } from '@/components/native-chat/native-chat-composer-draft-storage' + +const mocks = vi.hoisted(() => { const tabs: Tab[] = []; return { tabs, focus: vi.fn(), call: vi.fn() } }) +vi.mock('@/components/native-chat/use-native-chat-composer-reveal-focus', () => ({ requestNativeChatComposerFocus: mocks.focus })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call })) +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ + unifiedTabsByWorktree: { 'folder:workspace': mocks.tabs }, + focusGroup: vi.fn(), activateTab: vi.fn(), setActiveTabType: vi.fn(), + createUnifiedTab: (worktreeId: string, contentType: Tab['contentType'], args: Partial & { targetGroupId?: string }) => { + const tab: Tab = { id: '', entityId: '', worktreeId, groupId: args.targetGroupId ?? 'group-1', contentType, + label: '', customLabel: null, color: null, sortOrder: 0, createdAt: 1, ...args } + mocks.tabs.push(tab) + return tab + } +}) } })) +let storage: ReturnType +beforeEach(async () => { + vi.clearAllMocks() + mocks.tabs.length = 0 + clearNativeChatComposerDraftsForTests() + storage = createMemoryNativeChatComposerDraftStorage() + setNativeChatComposerDraftStorageForTests(storage) + await hydrateNativeChatComposerDrafts() +}) +afterEach(() => clearNativeChatComposerDraftsForTests()) + +it.each(['claude', 'codex', 'grok', 'opencode', 'pi'] as const)( + 'opens a quiet folder draft or explicit split destination without asking the %s host', (agent) => { + const requestId = newAgentLaunchRequestId() + const args = { requestId, worktreeId: 'folder:workspace', executionHostId: 'local' as const, agent, groupId: 'split-right', prompt: 'unique marker' } + const tab = openNativeChatLocalDraft(args) + expect(tab).toMatchObject({ groupId: 'split-right', executionHostId: 'local', agentSessionAgent: agent }) + expect(readNativeChatComposerDraft(`agent-session:${tab?.entityId}`).text).toBe('unique marker') + expect(readNativeChatNewDraft(tab?.entityId ?? '')).toMatchObject({ owner: { workspaceId: 'folder:workspace', executionHostId: 'local' }, groupId: 'split-right', agent }) + expect(mocks.focus).toHaveBeenCalledExactlyOnceWith(tab?.id) + expect(openNativeChatLocalDraft(args)?.id).toBe(tab?.id) + expect(mocks.tabs).toHaveLength(1) + expect(mocks.call).not.toHaveBeenCalled() + mocks.focus.mockClear() + openNativeChatLocalDraft({ ...args, requestId: newAgentLaunchRequestId(), activate: false }) + expect(mocks.focus).not.toHaveBeenCalled() + expect(mocks.call).not.toHaveBeenCalled() + } +) + +it('keeps metadata independent of stale content and refuses to resurrect it after committed retirement', async () => { + const id = 'codex_reload' + const owner: { workspaceId: string; executionHostId: ExecutionHostId } = { workspaceId: 'folder:workspace', executionHostId: 'local' } + const metadata = { owner, agent: 'codex' as const, groupId: 'split-down', options: { model: 'picked' } } + allocateNativeChatNewDraft(id, metadata) + updateNativeChatComposerDraft(`agent-session:${id}`, { text: 'saved', document: { type: 'doc' }, images: [{ id: 'image', path: '/source/image.png' }] }, 'immediate') + await Promise.resolve() + clearNativeChatComposerDraftsForTests() + setNativeChatComposerDraftStorageForTests(storage) + await hydrateNativeChatComposerDrafts() + expect(readNativeChatNewDraft(id)).toEqual(metadata) + expect(readNativeChatComposerDraft(`agent-session:${id}`)).toMatchObject({ text: 'saved', images: [{ id: 'image', path: '/source/image.png' }] }) + storage.refuseWrites = true + expect(await retireNativeChatNewDraft(id)).toBe(false) + expect(readNativeChatNewDraft(id)).toEqual(metadata) + storage.refuseWrites = false + expect(await retireNativeChatNewDraft(id)).toBe(true) + await storage.update(`agent-session-new:${id}`, (stored) => stored ? metadata : null) + await storage.write(`agent-session:${id}`, { text: 'stale window', images: [], savedAt: 1 }) + updateNativeChatNewDraft(id, { options: { model: 'stale pick' } }) + clearNativeChatComposerDraftsForTests() + setNativeChatComposerDraftStorageForTests(storage) + await hydrateNativeChatComposerDrafts() + expect(readNativeChatNewDraft(id)).toBeUndefined() + expect(readNativeChatComposerDraft(`agent-session:${id}`).text).toBe('stale window') + expect(mocks.call).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts index dbb837939353..4183025f050f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts @@ -1,3 +1,4 @@ +import { ensureRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client' import type { AgentSessionAttachResult, AgentSessionMutationResult @@ -19,6 +20,7 @@ import { import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' import { StructuredAgentSessionCreateError, + StructuredAgentSessionSubmissionEndedError, StructuredAgentSessionCreateRefusalError, StructuredAgentSessionCreateUnknownOutcomeError } from './structured-agent-session-launch-errors' @@ -81,10 +83,20 @@ export async function launchStructuredAgentSession( onHostSeed?: StructuredLaunchHostSeedListener, priorCreateOutcomeUnknown = false ): Promise { - const hostSeed = await requireHostCreateSupport(intent) + let hostSeed: LaunchSeed + try { + hostSeed = await requireHostCreateSupport(intent) + } catch (error) { + if (!intent.firstSubmission || error instanceof StructuredAgentSessionCreateRefusalError) throw error + throw new StructuredAgentSessionCreateRefusalError( + error instanceof Error ? error.message : 'The chat host is unavailable.', + 'structured_agent_session_unsupported' + ) + } if (intent.target.kind !== 'local') { onHostSeed?.(hostSeed) } + let issue: void | (() => void) = undefined if (!intent.createPrepared) { let supported = false try { @@ -95,25 +107,39 @@ export async function launchStructuredAgentSession( if (!intent.createPrepared) { intent.createMessageSupport = supported try { - await intent.prepareCreate?.() + issue = await intent.prepareCreate?.() } catch (error) { // Preparation failed before create was issued, so its sources remain safe to submit again. + if (error instanceof StructuredAgentSessionSubmissionEndedError) throw error if (intent.firstSubmission) throw new StructuredAgentSessionCreateRefusalError( error instanceof Error ? error.message : 'The message could not be prepared.', 'agent_session_attachment_unavailable' ) throw error } - intent.createPrepared = true + } + } + if (intent.firstSubmission && intent.target.kind === 'environment') { + try { + await ensureRuntimeEnvironmentCompatible(intent.target.environmentId) + } catch (error) { + throw new StructuredAgentSessionCreateRefusalError( + error instanceof Error ? error.message : 'The chat host is unavailable.', + 'structured_agent_session_unsupported' + ) } } let result: AgentSessionMutationResult try { - result = await callStructuredAgentSession>( - intent.target, - 'agentSession.create', - intent.params - ) + issue?.() + intent.createPrepared = true + result = intent.firstSubmission && intent.target.kind === 'environment' + ? await callStructuredAgentSession>( + intent.target, 'agentSession.create', intent.params, { skipCompatibilityCheck: true } + ) + : await callStructuredAgentSession>( + intent.target, 'agentSession.create', intent.params + ) } catch (error) { const code = definitiveStructuredAgentSessionCreateErrorCode(error) if (code) { diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index 74d4a7b663fd..c51d2d3b21db 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -56,15 +56,19 @@ import { resetStructuredAgentLaunchPersistenceForTests, readStructuredAgentLaunchRecord } from './structured-agent-session-launch-persistence' +import { confirmStructuredLaunchFirstMessagePublication } from './structured-agent-session-launch-publication-confirmation' +import { createMemoryNativeChatComposerDraftStorage, setNativeChatComposerDraftStorageForTests } from '@/components/native-chat/native-chat-composer-draft-storage' +import { submitNativeChatLocalDraft } from './native-chat-first-submission' +import { allocateNativeChatNewDraft, readNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' import { newAgentLaunchRequestId } from './agent-launch-request-id' import { markStructuredAgentSessionLaunchPublished } from './structured-agent-session-launch-publication' -import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' +import { StructuredAgentSessionSubmissionEndedError, StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' import { getStructuredAgentSessionReadOwner, findStructuredAgentSessionReadOwner, resetStructuredAgentSessionReadOwnersForTests } from '@/components/native-chat/structured-agent-session-read-owner' -import { resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender' +import { reserveStructuredAgentSessionSend, resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender' import { getStructuredAgentSessionPendingSends } from '@/components/native-chat/structured-agent-session-pending-sends' import { structuredAgentSessionStopControl } from '@/components/native-chat/structured-agent-session-stop-control' import { @@ -72,7 +76,8 @@ import { readNativeChatDraftCache, writeNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache' -import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store' +import { structuredAgentSessionDraftScopeKey, updateNativeChatComposerDraft, readNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import { stageStructuredLaunchPrompt, discardStructuredLaunchPrompts } from './structured-agent-session-launch-prompt' function deferred() { let resolve = (_value: T): void => {} @@ -155,6 +160,151 @@ describe('the first message owned by create', () => { vi.useRealTimers() }) + it.each(['held', 'closed', 'releasedAfterUpload', 'expiredDiscovery', 'legacyClosed', 'legacySuperseded'] as const)( + 'keeps the original first submission authoritative through preparation: %s', async (scenario) => { + const source = { text: 'opening with images', document: { type: 'doc' }, images: [ + { id: 'one', path: '/source/one.png' }, { id: 'two', path: '/source/two.png' } + ] } + const sessionId = 'codex_reserved_first_send' + const scope = structuredAgentSessionDraftScopeKey(sessionId) + const upload = deferred() + const discovery = deferred() + const create = deferred() + const legacy = scenario.startsWith('legacy') + mocks.supports.mockReturnValue(scenario === 'expiredDiscovery' ? discovery.promise : Promise.resolve(!legacy)) + mocks.call.mockReturnValue(create.promise) + allocateNativeChatNewDraft(sessionId, { owner: { workspaceId: 'workspace-1', executionHostId: 'local' }, agent: 'codex', groupId: 'group-1', options: {} }) + const prompt = stageStructuredLaunchPrompt(sessionId, source.text, { + sourceDraft: source, options: legacy ? {} : { model: 'picked' }, + attachments: source.images.map((image) => ({ path: image.path, previewUri: image.path })) + }) + const prepareUpload = vi.fn(async () => { + await upload.promise + prompt.slot?.setAttachments(source.images.map((image) => ({ + path: image.path.replace('/source/', '/stored/'), previewUri: image.path + }))) + }) + if (legacy) prompt.beforeSend = prepareUpload + else prompt.beforeCreate = prepareUpload + const launch = startStructuredAgentLaunch('workspace-1', 'codex', { + requestId: newAgentLaunchRequestId(), executionHostId: 'local', reservedSessionId: sessionId, + stagedPrompt: prompt, prompt: source.text, firstSubmissionOptions: legacy ? {} : { model: 'picked' } + }) + const result = launch.launchResult.catch((error: unknown) => error) + expect(readStructuredAgentLaunchRecord(sessionId)?.firstSubmission?.draft).toEqual(source) + expect(reserveStructuredAgentSessionSend({ sessionId, text: 'duplicate' })).toBeNull() + if (scenario === 'expiredDiscovery') { + await vi.advanceTimersByTimeAsync(30_000) + expect(readNativeChatComposerDraft(scope)).toEqual(source) + discovery.resolve(true) + } else { + if (legacy) { + mocks.inventory.mockResolvedValue([{ worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId }] }]) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + create.resolve(created()) + } + await vi.waitFor(() => expect(prepareUpload).toHaveBeenCalledOnce()) + if (!legacy) expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', sessionId)).toBe('pending') + if (scenario === 'held') { + await vi.advanceTimersByTimeAsync(45_000) + expect(mocks.call).not.toHaveBeenCalled() + expect(readNativeChatComposerDraft(scope).text).toBe('') + } else if (scenario === 'releasedAfterUpload') { + upload.resolve() + queueMicrotask(() => discardStructuredLaunchPrompts(sessionId)) + } else if (scenario === 'legacySuperseded') { + upload.reject(new Error('upload failed')) + } else { + discardStructuredLaunchPrompts(sessionId) + } + } + upload.resolve() + if (scenario === 'held') { + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const params = createParams() + expect(params.firstMessage).toEqual({ clientMessageId: prompt.slot?.clientMessageId, body: { kind: 'message', role: 'user', blocks: [ + { type: 'text', text: source.text }, + { type: 'image-ref', path: '/stored/one.png' }, { type: 'image-ref', path: '/stored/two.png' } + ] } }) + expect(params.options).toEqual({ model: 'picked' }) + updateNativeChatComposerDraft(scope, { text: 'newer edit' }, 'immediate') + await vi.advanceTimersByTimeAsync(29_999) + expect(getStructuredAgentSessionPendingSends(sessionId)[0]?.phase).toBe('sending') + create.resolve(created()) + await result + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) + expect(readNativeChatComposerDraft(scope).text).toBe('newer edit') + } else if (!legacy) { + expect(await result).toBeInstanceOf(StructuredAgentSessionSubmissionEndedError) + expect(mocks.call).not.toHaveBeenCalled() + expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', sessionId)).toBeNull() + } else { + await result + await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: false }) + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) + if (scenario === 'legacySuperseded') { + expect(readNativeChatComposerDraft(scope)).toEqual(source) + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + updateNativeChatComposerDraft(scope, { text: 'newer edit' }, 'immediate') + mocks.call.mockImplementation(async (_target, method, params) => { + expect(method).toBe('agentSession.send') + const sent = SendParams.parse(params) + return { ok: true, value: { clientMessageId: sent.envelope.clientOperationId, + submission: submission(sent.envelope.clientOperationId) } } + }) + expect(submitNativeChatLocalDraft(sessionId, 'later explicit send', [], {}, true)).toBe(true) + await vi.waitFor(() => expect(readNativeChatNewDraft(sessionId)).toBeUndefined()) + expect(readStructuredAgentLaunchRecord(sessionId)).toBeUndefined() + expect(readNativeChatComposerDraft(scope).text).toBe('newer edit') + expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create', 'agentSession.send']) + } + } + } + ) + + it('confirms a closed-tab opening through paged history and retains evidence until metadata removal commits', async () => { + const storage = createMemoryNativeChatComposerDraftStorage() + setNativeChatComposerDraftStorageForTests(storage) + const sessionId = 'codex_paged_reload' + const source = { text: 'original', images: [] } + allocateNativeChatNewDraft(sessionId, { owner: { workspaceId: 'workspace-1', executionHostId: 'local' }, agent: 'codex', groupId: 'group-1', options: {} }) + const prompt = stageStructuredLaunchPrompt(sessionId, source.text, { sourceDraft: source }) + mocks.call.mockReturnValue(new Promise(() => {})) + const launch = startStructuredAgentLaunch('workspace-1', 'codex', { + requestId: newAgentLaunchRequestId(), executionHostId: 'local', reservedSessionId: sessionId, + stagedPrompt: prompt, prompt: source.text + }) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const response = created() + const recorded = response.value.page.submissions + await vi.advanceTimersByTimeAsync(30_000) + await launch.promptDeliveryResult + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + storage.refuseWrites = true + let page = 0 + mocks.call.mockImplementation(async (_target, method) => { + expect(method).toBe('agentSession.history') + page += 1 + return { ok: true, page: { ...response.value.page, submissions: page % 2 ? [] : recorded, + hasOlder: page % 2 === 1, window: { ...response.value.page.window, oldest: { epoch: 'epoch-1', sequence: 1 } } } } + }) + confirmStructuredLaunchFirstMessagePublication('workspace-1', sessionId) + await vi.waitFor(() => expect(page).toBe(2)) + await vi.advanceTimersByTimeAsync(0) + expect(readNativeChatNewDraft(sessionId)).toBeDefined() + expect(readStructuredAgentLaunchRecord(sessionId)?.firstSubmission).toBeDefined() + storage.refuseWrites = false + updateNativeChatComposerDraft(structuredAgentSessionDraftScopeKey(sessionId), { text: 'newer typing' }, 'immediate') + confirmStructuredLaunchFirstMessagePublication('workspace-1', sessionId) + await vi.waitFor(() => expect(readNativeChatNewDraft(sessionId)).toBeUndefined()) + expect(readStructuredAgentLaunchRecord(sessionId)).toBeUndefined() + expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey(sessionId)).text).toBe('newer typing') + expect(mocks.inventory).not.toHaveBeenCalled() + expect(mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.create')).toHaveLength(1) + }) + it('freezes the message and held options in create and publishes without another read or send', async () => { const create = deferred() mocks.call.mockReturnValue(create.promise) diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts index e06c700873d2..b80f1f8813bd 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -3,8 +3,7 @@ import type { StructuredLaunchState } from './structured-agent-session-launch-re import { notifyStructuredLaunchListeners } from './structured-agent-session-launch-registry' import { prepareStructuredLaunchPromptForCreate, - prepareStructuredLaunchPromptAttachments, - structuredLaunchPromptNeedsLegacySend, + prepareStructuredLaunchFirstSubmission, prepareLegacyStructuredLaunchPrompts } from './structured-agent-session-launch-prompt' import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' @@ -20,39 +19,43 @@ import { } from './structured-agent-session-launch-options' /** Freeze what create carries once; an ambiguous reply always replays the same payload. */ -export async function prepareStructuredLaunchCreateMessage(state: StructuredLaunchState): Promise { +export async function prepareStructuredLaunchCreateMessage(state: StructuredLaunchState): Promise void)> { const { intent } = state if (intent.createPrepared) { return } - await prepareStructuredLaunchPromptAttachments(intent.sessionId) - if (intent.createMessageSupport !== true) { - prepareLegacyStructuredLaunchPrompts(intent.sessionId) + const opening = intent.firstSubmission + const takeMessage = opening ? await prepareStructuredLaunchFirstSubmission( + intent.sessionId, opening.clientMessageId, intent.target, intent.createMessageSupport === true + ) : null + return () => { + const reservedMessage = takeMessage?.() + if (intent.createMessageSupport !== true) { + prepareLegacyStructuredLaunchPrompts(intent.sessionId) + intent.createPrepared = true + notifyStructuredLaunchListeners() + return + } + const firstMessage = takeMessage ? reservedMessage : prepareStructuredLaunchPromptForCreate(intent.sessionId, intent.target) + const options = structuredLaunchCreateOptions(state) + const params = { + ...intent.params, + ...(firstMessage ? { firstMessage } : {}), + ...(options ? { options } : {}) + } + intent.params = { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: structuredAgentSessionCreateFingerprint({ + sessionId: intent.sessionId, + ...params + }) + } + } intent.createPrepared = true notifyStructuredLaunchListeners() - return - } - const needsLegacySend = structuredLaunchPromptNeedsLegacySend(intent.sessionId) - if (needsLegacySend) prepareLegacyStructuredLaunchPrompts(intent.sessionId) - const firstMessage = needsLegacySend ? undefined : prepareStructuredLaunchPromptForCreate(intent.sessionId, intent.target) - const options = structuredLaunchCreateOptions(state) - const params = { - ...intent.params, - ...(firstMessage ? { firstMessage } : {}), - ...(options ? { options } : {}) - } - intent.params = { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: structuredAgentSessionCreateFingerprint({ - sessionId: intent.sessionId, - ...params - }) - } } - intent.createPrepared = true - notifyStructuredLaunchListeners() } /** Picks held so far, and the model and effort the chat shows: a first message starts its turn diff --git a/src/renderer/src/lib/structured-agent-session-launch-errors.ts b/src/renderer/src/lib/structured-agent-session-launch-errors.ts index bf03c9105708..13e8aaa6e43e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-errors.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-errors.ts @@ -43,3 +43,6 @@ export class StructuredAgentSessionOwnerUnresolvedError extends Error { this.name = 'StructuredAgentSessionOwnerUnresolvedError' } } + +/** A local submission ended before any create was issued. */ +export class StructuredAgentSessionSubmissionEndedError extends Error {} diff --git a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts index 9b746f33b20f..53a7826af05d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts @@ -1,5 +1,6 @@ +import { StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' -import { retireNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' +import { readNativeChatNewDraft, retireNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' import { clearNativeChatComposerDraftIfUnchanged } from '@/components/native-chat/native-chat-composer-draft-store' import { settleStructuredLaunchCallers, @@ -8,12 +9,15 @@ import { import type { StructuredAgentLaunchReceipt } from '@/lib/structured-agent-session-launch-recovery' import { structuredLaunchFailure } from './structured-agent-session-launch-failure' import { + getStructuredLaunchStateBySessionId, deleteStructuredLaunchStateIfCurrent, notifyStructuredLaunchListeners, retireStructuredAgentSessionLaunchCancellationTombstone, type StructuredLaunchState } from './structured-agent-session-launch-registry' +import { restorePersistedStructuredLaunchState } from './structured-agent-session-launch-reload' + // How a launch's create outcome settles its callers: published, refused, failed or unknown. function cleanupLaunchState(state: StructuredLaunchState): void { @@ -24,6 +28,13 @@ function cleanupLaunchState(state: StructuredLaunchState): void { const finalizing = new WeakMap>() +export async function finalizeStructuredLaunchConfirmedSubmission(sessionId: string): Promise { + const draft = readNativeChatNewDraft(sessionId) + const state = getStructuredLaunchStateBySessionId(sessionId) ?? + (draft ? restorePersistedStructuredLaunchState(draft.owner.workspaceId, sessionId) : undefined) + if (state?.intent.firstSubmission && !state.cancelled) await finalizeStructuredLaunchFirstSubmission(state) +} + export function finalizeStructuredLaunchFirstSubmission(state: StructuredLaunchState): Promise { const active = finalizing.get(state) if (active) return active @@ -37,6 +48,7 @@ async function finalizeFirstSubmission(state: StructuredLaunchState): Promise & { + inComposer: boolean +} + +export type StructuredLaunchPromptOptions = { + prompt?: string + promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' + onPromptDelivered?: () => void +} + +/** A launch's text, held in memory from the click until its chat exists, then sent once: every + * caller waiting on it shares the one send. From the click it holds the chat's one send slot, drawn + * as sending, so nothing typed meanwhile goes out ahead of it. */ +export type StagedStructuredLaunchPrompt = { + sessionId: string + text: string + slot: StructuredAgentSessionReservedSend | null + sourceDraft?: NativeChatComposerDraft + beforeCreate?: () => Promise + beforeSend?: () => Promise + /** Its sender keeps the text if it comes back (notes), instead of the chat's composer. */ + callerKeepsText?: true + delivery?: Promise + /** Create owns this prompt; it must never become a later send. */ + createOwned?: true + createSend?: StructuredAgentSessionCreateSend + whenCreateSettled: Promise + settleCreate: (outcome: StagedDelivery) => void + /** Its launch was cancelled: never sent, never given back. */ + discarded?: true + /** Settles once it is discarded, so its callers need not wait on a create that may never end. */ + whenDiscarded: Promise + discard: () => void +} + diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 67d19202087c..6399974a96a1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -1,15 +1,12 @@ -import { structuredAgentSessionSendBody } from '../../../shared/structured-agent-session-send-mutation' +import { handBackStagedPrompt } from './structured-agent-session-launch-prompt-return' import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create' -import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' +import { StructuredAgentSessionCreateRefusalError, StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' import { agentSessionThrownFailure } from '../../../shared/agent-session-write-failure' -import { handBackStructuredAgentSessionMessage } from '@/components/native-chat/structured-agent-session-message-hand-back' import { dropStructuredAgentSessionSends, reserveStructuredAgentSessionSend, - sendStructuredAgentSessionMessage, - type StructuredAgentSessionCreateSend, - type StructuredAgentSessionReservedSend + sendStructuredAgentSessionMessage } from '@/components/native-chat/structured-agent-session-message-sender' import { noteStructuredAgentSessionFence } from '@/components/native-chat/structured-agent-session-send-attempt' import { publishStructuredAgentSessionSends } from '@/components/native-chat/structured-agent-session-pending-sends' @@ -17,56 +14,11 @@ import { agentSessionWriteNoticeText } from '@/components/native-chat/agent-sess import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import type { StructuredAgentSessionSendInput } from '@/components/native-chat/structured-agent-session-send-slot' import type { NativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-storage' -import { readNativeChatComposerDraft, updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' - -export type StructuredPromptDeliveryResult = { - delivered: boolean - failureNotified: boolean - /** Not sent, and waiting in the new chat's composer, which now owns the text. */ - inComposer?: true - /** Nobody could confirm it went: the host may hold it. */ - unconfirmed?: true - /** Another send of the chat held its one slot, so this one never went. */ - busy?: true -} - -type StagedDelivery = Omit & { - inComposer: boolean -} - -export type StructuredLaunchPromptOptions = { - prompt?: string - promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' - onPromptDelivered?: () => void -} +import type { StructuredPromptDeliveryResult, StructuredLaunchPromptOptions, StagedDelivery, StagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt-types' +export type { StructuredPromptDeliveryResult, StructuredLaunchPromptOptions, StagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt-types' type LaunchReceipt = StructuredAgentLaunchReceipt -/** A launch's text, held in memory from the click until its chat exists, then sent once: every - * caller waiting on it shares the one send. From the click it holds the chat's one send slot, drawn - * as sending, so nothing typed meanwhile goes out ahead of it. */ -export type StagedStructuredLaunchPrompt = { - sessionId: string - text: string - slot: StructuredAgentSessionReservedSend | null - sourceDraft?: NativeChatComposerDraft - beforeCreate?: () => Promise - beforeSend?: () => Promise - /** Its sender keeps the text if it comes back (notes), instead of the chat's composer. */ - callerKeepsText?: true - delivery?: Promise - /** Create owns this prompt; it must never become a later send. */ - createOwned?: true - createSend?: StructuredAgentSessionCreateSend - whenCreateSettled: Promise - settleCreate: (outcome: StagedDelivery) => void - /** Its launch was cancelled: never sent, never given back. */ - discarded?: true - /** Settles once it is discarded, so its callers need not wait on a create that may never end. */ - whenDiscarded: Promise - discard: () => void -} - const staged = new Map>() export function stageStructuredLaunchPrompt( @@ -160,16 +112,17 @@ export function prepareLegacyStructuredLaunchPrompts(sessionId: string): void { /** Transfers the first staged prompt to create, retaining the sender's deadline and bubble. */ export function prepareStructuredLaunchPromptForCreate( sessionId: string, - target: RuntimeClientTarget + target: RuntimeClientTarget, + ownedPrompt?: StagedStructuredLaunchPrompt ): StructuredAgentSessionFirstMessage | undefined { - const prompt = [...(staged.get(sessionId) ?? [])].find( + const prompt = ownedPrompt ?? [...(staged.get(sessionId) ?? [])].find( (candidate) => !candidate.discarded && !candidate.delivery ) if (!prompt) { return undefined } prompt.createOwned = true - const sent = prompt.slot?.create(target) + const sent = prompt.slot?.create(target, ownedPrompt ? true : undefined) prompt.createSend = sent ?? undefined prompt.delivery = sent ? sent.outcome.then((outcome) => { @@ -194,48 +147,18 @@ export function hasStagedStructuredLaunchPrompt(sessionId: string): boolean { return (staged.get(sessionId)?.size ?? 0) > 0 } -/** Puts a launch's text in its chat's composer, unless its sender keeps it. True when it did; a - * draft write that throws is reported in the chat instead, and never stops its caller. */ -function handBackStagedPrompt(prompt: StagedStructuredLaunchPrompt): boolean { - if (prompt.callerKeepsText || prompt.discarded) { - return false - } - try { - if (prompt.sourceDraft) { - const scopeKey = `agent-session:${prompt.sessionId}` - const current = readNativeChatComposerDraft(scopeKey) - if (current.text || current.images.length) return false - updateNativeChatComposerDraft(scopeKey, prompt.sourceDraft, 'immediate') - return true - } - handBackStructuredAgentSessionMessage( - prompt.sessionId, - `launch-${prompt.sessionId}`, - structuredAgentSessionSendBody(prompt.text, []) - ) - return true - } catch (error) { - console.error( - '[native-chat-send] a launch message could not be put back in the composer', - error - ) - publishStructuredAgentSessionSends(prompt.sessionId, { - notice: agentSessionWriteNoticeText(['messageNotSaved']) - }) - return false - } -} function sendStagedPrompt( prompt: StagedStructuredLaunchPrompt, - receipt: LaunchReceipt, + receipt: LaunchReceipt | undefined, target: RuntimeClientTarget ): Promise { prompt.delivery ??= (async () => { - noteStructuredAgentSessionFence(prompt.sessionId, receipt.fence) + if (receipt) noteStructuredAgentSessionFence(prompt.sessionId, receipt.fence) + if (prompt.sourceDraft && !prompt.slot?.prepare()) throw new StructuredAgentSessionSubmissionEndedError() await prompt.beforeSend?.() const sent = - prompt.slot?.send(target) ?? + prompt.slot ? prompt.slot.send(target) : sendStructuredAgentSessionMessage({ sessionId: prompt.sessionId, target, text: prompt.text }) if (!sent) { // Another send of the chat holds its slot: the launch text waits in the composer instead. @@ -257,14 +180,36 @@ function sendStagedPrompt( return prompt.delivery } -export async function prepareStructuredLaunchPromptAttachments(sessionId: string): Promise { - for (const prompt of staged.get(sessionId) ?? []) { - await prompt.beforeCreate?.() - } +/** A later explicit Send continues the hosted chat through the same reserved sender. */ +export function deliverStructuredLaunchPromptToExistingSession( + prompt: StagedStructuredLaunchPrompt, + target: RuntimeClientTarget +): Promise { + return sendStagedPrompt(prompt, undefined, target).catch(() => { + failStagedStructuredLaunchPrompt(prompt) + return { delivered: false, inComposer: true } + }) } -export function structuredLaunchPromptNeedsLegacySend(sessionId: string): boolean { - return [...(staged.get(sessionId) ?? [])].some((prompt) => prompt.beforeSend !== undefined) +/** Captures the original reservation; late preparation never adopts a newer send. */ +export async function prepareStructuredLaunchFirstSubmission( + sessionId: string, + clientMessageId: string, + target: RuntimeClientTarget, + atomic: boolean +): Promise<() => StructuredAgentSessionFirstMessage | undefined> { + const prompt = [...(staged.get(sessionId) ?? [])].find( + (candidate) => candidate.slot?.clientMessageId === clientMessageId + ) + if (!prompt?.slot?.prepare()) throw new StructuredAgentSessionSubmissionEndedError() + await prompt.beforeCreate?.() + return () => { + if (!prompt.slot?.prepare()) throw new StructuredAgentSessionSubmissionEndedError() + if (!atomic || prompt.beforeSend) return undefined + const message = prepareStructuredLaunchPromptForCreate(sessionId, target, prompt) + if (!message) throw new StructuredAgentSessionSubmissionEndedError() + return message + } } export function failStagedStructuredLaunchPrompt(prompt: StagedStructuredLaunchPrompt): void { diff --git a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts index 6d9858fff308..5dd8534d8e73 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts @@ -1,3 +1,4 @@ +import { StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' import type { AgentSessionHistoryResult } from '../../../shared/agent-session-wire' import type { AgentJournalCursor, @@ -121,7 +122,7 @@ async function retrySameIntent( if (state.cancelled) { throw new StructuredAgentSessionLaunchCancelledError() } - if (error instanceof StructuredAgentSessionCreateRefusalError) { + if (error instanceof StructuredAgentSessionCreateRefusalError || error instanceof StructuredAgentSessionSubmissionEndedError) { throw error } try { @@ -148,7 +149,7 @@ export async function launchAndReconcile( if (state.cancelled) { throw new StructuredAgentSessionLaunchCancelledError() } - if (error instanceof StructuredAgentSessionCreateRefusalError) { + if (error instanceof StructuredAgentSessionCreateRefusalError || error instanceof StructuredAgentSessionSubmissionEndedError) { throw error } try { @@ -180,7 +181,11 @@ export async function reconcileUnknownLaunch( try { return await recoverPublishedSessionReceipt(state) } catch (error) { - if (state.intent.firstSubmission) throw error + if (state.intent.firstSubmission) { + state.visibilityUnknown = true + state.onVisibilityChanged?.() + throw error + } return retrySameIntent(state, error) } } diff --git a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts index fa28767fac45..4abf9a2d57cf 100644 --- a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts @@ -1,3 +1,4 @@ +import { updateNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' import { collapseGroupLayout } from '../../../../../shared/workspace-layout/tab-group-layout-tree' import { buildActiveSurfacePatch } from './tabs-surface' @@ -123,6 +124,10 @@ export function createTabsMoveActions( : {}) } }) + if (moved) { + const movedTab = findTabAndWorktree(get().unifiedTabsByWorktree, tabId)?.tab + if (movedTab?.contentType === 'agent-session') updateNativeChatNewDraft(movedTab.entityId, { groupId: targetGroupId }) + } if (moved && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('tab-splits') } From a9c331e604f32582a95144be2c36c1d661e6d872 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:24:03 -0700 Subject: [PATCH 04/27] fix(native-chat): release staged drafts and preserve explicit choices --- .../use-structured-agent-session-options.ts | 4 ++-- .../src/lib/native-chat-draft-reload.ts | 2 +- .../src/lib/native-chat-first-submission.ts | 22 ++++++++++++++----- .../structured-agent-session-launch-prompt.ts | 18 +++++++-------- src/shared/protocol-version.ts | 2 +- 5 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 98c99e626de3..efb3a76dac94 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -247,12 +247,12 @@ export function useStructuredAgentSessionOptions(args: { } if (!transportEnabled || fence === null) { const draft = readNativeChatNewDraft(sessionId) - if (draft && !getStructuredAgentSessionLaunchSelection(sessionId)) { + if (draft) { const options = id === 'model' ? { ...(draft.options[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] ? { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: draft.options[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] } : {}), [id]: encoded } : { ...draft.options, [id]: encoded } updateNativeChatNewDraft(sessionId, { options }) - return true + if (!getStructuredAgentSessionLaunchSelection(sessionId)) return true } // No fence yet: the launch holds it and applies it before anything else is sent. const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded) diff --git a/src/renderer/src/lib/native-chat-draft-reload.ts b/src/renderer/src/lib/native-chat-draft-reload.ts index 9ba1b4b01983..f4ad4b38a6bd 100644 --- a/src/renderer/src/lib/native-chat-draft-reload.ts +++ b/src/renderer/src/lib/native-chat-draft-reload.ts @@ -18,7 +18,7 @@ async function ownerConfirmedGone(draft: NativeChatNewDraft): Promise { const { workspaceId, executionHostId } = draft.owner const state = useAppStore.getState() const host = parseExecutionHostId(executionHostId) - if (host?.kind === 'runtime' && state.runtimeEnvironmentCatalogSettled && + if (host?.kind === 'runtime' && state.runtimeEnvironmentCatalogHydrated && !state.runtimeEnvironments.some((environment) => environment.id === host.environmentId)) return true if (findKnownWorktreeById(state, workspaceId, executionHostId)) return false const scope = parseWorkspaceKey(workspaceId) diff --git a/src/renderer/src/lib/native-chat-first-submission.ts b/src/renderer/src/lib/native-chat-first-submission.ts index e639ab13c3d3..db92b83a39fa 100644 --- a/src/renderer/src/lib/native-chat-first-submission.ts +++ b/src/renderer/src/lib/native-chat-first-submission.ts @@ -4,6 +4,7 @@ import { import { readNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' import { stageStructuredLaunchPrompt, + releaseStagedStructuredLaunchPrompt, deliverStructuredLaunchPromptToExistingSession } from './structured-agent-session-launch-prompt' import { startStructuredAgentLaunch } from './structured-agent-session-launch' @@ -37,7 +38,10 @@ export function submitNativeChatLocalDraft( } const sourceDraft = readNativeChatComposerDraft(`agent-session:${sessionId}`) const stagedPrompt = stageStructuredLaunchPrompt(sessionId, text, { attachments, options, sourceDraft }) - if (!stagedPrompt.slot) return false + if (!stagedPrompt.slot) { + releaseStagedStructuredLaunchPrompt(stagedPrompt) + return false + } const prepare = async (): Promise => { const target = structuredAgentSessionTargetForHost(draft.owner.executionHostId) if (!target) throw new Error('The chat host is unavailable.') @@ -66,16 +70,24 @@ export function submitNativeChatLocalDraft( } if (hosted) { const target = structuredAgentSessionTargetForHost(draft.owner.executionHostId) - if (!target) { stagedPrompt.slot.release(); return false } + if (!target) { + releaseStagedStructuredLaunchPrompt(stagedPrompt) + return false + } stagedPrompt.beforeSend = prepare void deliverStructuredLaunchPromptToExistingSession(stagedPrompt, target) return true } stagedPrompt.beforeCreate = prepare - const launch = startStructuredAgentLaunch(draft.owner.workspaceId, draft.agent, { + try { + const launch = startStructuredAgentLaunch(draft.owner.workspaceId, draft.agent, { requestId: newAgentLaunchRequestId(), executionHostId: draft.owner.executionHostId, reservedSessionId: sessionId, stagedPrompt, prompt: text, firstSubmissionOptions: options }) - void launch.launchResult.catch(() => {}) - return true + void launch.launchResult.catch(() => {}) + return true + } catch (error) { + releaseStagedStructuredLaunchPrompt(stagedPrompt) + throw error + } } diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 6399974a96a1..5d53177f5c5d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -57,14 +57,14 @@ export function stageStructuredLaunchPrompt( } if (!prompt.delivery) { prompt.delivery = Promise.resolve(result) - unstage(prompt) + releaseStagedStructuredLaunchPrompt(prompt) } prompt.settleCreate(result) }) return prompt } -function unstage(prompt: StagedStructuredLaunchPrompt): void { +export function releaseStagedStructuredLaunchPrompt(prompt: StagedStructuredLaunchPrompt): void { prompt.slot?.release() const forSession = staged.get(prompt.sessionId) forSession?.delete(prompt) @@ -96,7 +96,7 @@ export function takeBackLegacyStructuredLaunchPrompts(sessionId: string): void { if (prompt.delivery || prompt.createOwned) { continue } - unstage(prompt) + releaseStagedStructuredLaunchPrompt(prompt) handBackStagedPrompt(prompt) prompt.discarded = true prompt.discard() @@ -126,7 +126,7 @@ export function prepareStructuredLaunchPromptForCreate( prompt.createSend = sent ?? undefined prompt.delivery = sent ? sent.outcome.then((outcome) => { - unstage(prompt) + releaseStagedStructuredLaunchPrompt(prompt) return { delivered: outcome === 'recorded', inComposer: @@ -135,7 +135,7 @@ export function prepareStructuredLaunchPromptForCreate( } }) : Promise.resolve({ delivered: false, inComposer: !prompt.callerKeepsText }).then((outcome) => { - unstage(prompt) + releaseStagedStructuredLaunchPrompt(prompt) return outcome }) void prompt.delivery.then(prompt.settleCreate) @@ -162,7 +162,7 @@ function sendStagedPrompt( sendStructuredAgentSessionMessage({ sessionId: prompt.sessionId, target, text: prompt.text }) if (!sent) { // Another send of the chat holds its slot: the launch text waits in the composer instead. - unstage(prompt) + releaseStagedStructuredLaunchPrompt(prompt) return { delivered: false, inComposer: handBackStagedPrompt(prompt), busy: true } } try { @@ -174,7 +174,7 @@ function sendStagedPrompt( ...(outcome === 'unconfirmed' ? { unconfirmed: true as const } : {}) } } finally { - unstage(prompt) + releaseStagedStructuredLaunchPrompt(prompt) } })() return prompt.delivery @@ -214,7 +214,7 @@ export async function prepareStructuredLaunchFirstSubmission( export function failStagedStructuredLaunchPrompt(prompt: StagedStructuredLaunchPrompt): void { if (prompt.discarded) return - unstage(prompt) + releaseStagedStructuredLaunchPrompt(prompt) handBackStagedPrompt(prompt) prompt.settleCreate({ delivered: false, inComposer: true }) publishStructuredAgentSessionSends(prompt.sessionId, { notice: agentSessionWriteNoticeText(['notDoneSend']) }) @@ -270,7 +270,7 @@ export function settleStructuredAgentLaunchPrompt(args: { } // The chat never started: its text waits in the chat's composer for the person's own Send. if (prompt && !prompt.discarded && !prompt.delivery) { - unstage(prompt) + releaseStagedStructuredLaunchPrompt(prompt) handBackStagedPrompt(prompt) prompt.discarded = true } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index cc6dbcc70011..0432097459bf 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,3 +1,4 @@ +import { AGENT_SESSION_DRAFT_ATTACHMENTS_RUNTIME_CAPABILITY } from './agent-session-attachments' import { AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY } from './ai-vault-resume-preparation' import { JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY } from './jira-agent-capability' import { JIRA_STATUS_FILTER_RUNTIME_CAPABILITY } from './jira-quick-filters' @@ -277,4 +278,3 @@ export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string // still read desktop/mobile names; remove once mobile reads runtime names. export const DESKTOP_PROTOCOL_VERSION = RUNTIME_PROTOCOL_VERSION export const MIN_COMPATIBLE_MOBILE_VERSION = MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION -import { AGENT_SESSION_DRAFT_ATTACHMENTS_RUNTIME_CAPABILITY } from './agent-session-attachments' From a9fa876c500bb1ad460d517b496a80779dced2e9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:25:29 -0700 Subject: [PATCH 05/27] test(native-chat): keep distinct draft actions independent --- src/renderer/src/lib/native-chat-local-draft.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/lib/native-chat-local-draft.test.ts b/src/renderer/src/lib/native-chat-local-draft.test.ts index 23253b2c62f3..9764a652fe38 100644 --- a/src/renderer/src/lib/native-chat-local-draft.test.ts +++ b/src/renderer/src/lib/native-chat-local-draft.test.ts @@ -45,7 +45,10 @@ it.each(['claude', 'codex', 'grok', 'opencode', 'pi'] as const)( expect(mocks.tabs).toHaveLength(1) expect(mocks.call).not.toHaveBeenCalled() mocks.focus.mockClear() - openNativeChatLocalDraft({ ...args, requestId: newAgentLaunchRequestId(), activate: false }) + const quiet = openNativeChatLocalDraft({ ...args, requestId: newAgentLaunchRequestId(), activate: false }) + expect(quiet?.entityId).not.toBe(tab?.entityId) + expect(quiet?.groupId).toBe('split-right') + expect(readNativeChatComposerDraft(`agent-session:${quiet?.entityId}`).text).toBe('unique marker') expect(mocks.focus).not.toHaveBeenCalled() expect(mocks.call).not.toHaveBeenCalled() } From 3d6c983ed7f20058c1a65ae52a262fc8a2f74f4e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:31:06 -0700 Subject: [PATCH 06/27] fix(native-chat): defer cross-window draft announcements --- .../src/components/native-chat/native-chat-new-draft.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/native-chat-new-draft.ts b/src/renderer/src/components/native-chat/native-chat-new-draft.ts index 96332c9ded4b..af837df8102a 100644 --- a/src/renderer/src/components/native-chat/native-chat-new-draft.ts +++ b/src/renderer/src/components/native-chat/native-chat-new-draft.ts @@ -6,7 +6,6 @@ import { type NativeChatNewDraft } from './native-chat-composer-draft-storage' import { notifyScope, refusedScopes, scopeListeners } from './native-chat-composer-draft-memory' -import { broadcastNativeChatComposerDraftChange } from './native-chat-composer-draft-persistence' const PREFIX = 'agent-session-new:' const drafts = new Map() @@ -14,6 +13,13 @@ const changed = new Set() const pending = new Map>>() const removedOwners: NativeChatComposerDraftOwner[] = [] +function broadcastNativeChatComposerDraftChange(key: string): void { + // Persistence reads draft metadata; only a committed write needs its broadcast channel. + void import('./native-chat-composer-draft-persistence').then((persistence) => { + persistence.broadcastNativeChatComposerDraftChange(key) + }).catch((error: unknown) => console.warn('[native-chat-drafts] the saved draft could not be announced', error)) +} + export function nativeChatNewDraftKey(sessionId: string): string { return `${PREFIX}${sessionId}` } From 2dee84ad5bdfe3eeefaed5c18128d9d7c48b9879 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:19:39 -0700 Subject: [PATCH 07/27] Fix local chat draft settlement and recovery ownership --- .../refresh-startup-remote-catalog.ts | 41 ++++++ .../app-shell/use-app-startup-hydration.ts | 38 +---- .../native-chat-attachment-upload.ts | 4 +- .../native-chat-composer-draft-indexeddb.ts | 4 +- .../native-chat-composer-draft-load.ts | 4 +- .../native-chat-composer-draft-persistence.ts | 5 +- .../native-chat-composer-draft-storage.ts | 21 ++- .../native-chat-composer-draft-store.test.ts | 4 +- .../native-chat/native-chat-new-draft.ts | 134 +++++++++++++----- ...structured-agent-session-message-sender.ts | 24 +++- .../structured-agent-session-pending-sends.ts | 2 + .../structured-agent-session-send-runtime.ts | 9 ++ .../structured-agent-session-send-slot.ts | 3 + .../use-native-chat-provisional-launch.ts | 4 +- ...e-structured-agent-session-option-write.ts | 111 +++++++++++++++ .../use-structured-agent-session-options.ts | 111 +++------------ .../lib/launch-structured-agent-session.ts | 8 +- .../src/lib/native-chat-draft-owner.ts | 4 +- .../src/lib/native-chat-draft-reload.ts | 20 ++- .../src/lib/native-chat-first-submission.ts | 32 +++-- .../src/lib/native-chat-local-draft.test.ts | 47 +++++- .../src/lib/native-chat-local-draft.ts | 12 +- ...ctured-agent-session-launch-create-call.ts | 12 +- ...gent-session-launch-create-message.test.ts | 63 ++++++-- ...red-agent-session-launch-create-message.ts | 4 +- ...d-agent-session-launch-outcome-tracking.ts | 19 ++- ...ctured-agent-session-launch-persistence.ts | 10 +- ...red-agent-session-launch-prompt-outcome.ts | 25 +++- ...ured-agent-session-launch-prompt-return.ts | 8 -- .../structured-agent-session-launch-prompt.ts | 55 ++++--- ...session-launch-publication-confirmation.ts | 55 ++++--- ...ctured-agent-session-launch-publication.ts | 4 +- .../structured-agent-session-launch-record.ts | 8 +- ...tructured-agent-session-launch-recovery.ts | 8 +- ...tructured-agent-session-launch-registry.ts | 8 +- .../lib/structured-agent-session-launch.ts | 14 +- .../store/slices/tabs/tabs-move-actions.ts | 4 +- 37 files changed, 648 insertions(+), 291 deletions(-) create mode 100644 src/renderer/src/app-shell/refresh-startup-remote-catalog.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-option-write.ts diff --git a/src/renderer/src/app-shell/refresh-startup-remote-catalog.ts b/src/renderer/src/app-shell/refresh-startup-remote-catalog.ts new file mode 100644 index 000000000000..314c1800fefc --- /dev/null +++ b/src/renderer/src/app-shell/refresh-startup-remote-catalog.ts @@ -0,0 +1,41 @@ +import { useAppStore } from '../store' +import { timeRendererStartupStep } from '../startup/startup-diagnostics' +import type { useStartupActions } from './use-app-startup-actions' + +/** Network refresh stays outside the hydration gate; only authoritative results prune workspaces. */ +export async function refreshStartupRemoteCatalog(actions: ReturnType, isCancelled: () => boolean): Promise { + try { + try { + // Why: remote rows must not render under a fallback visibility while their owner default is still loading. + await timeRendererStartupStep('owner-visibility-defaults', () => + actions.awaitOwnerWorktreeVisibilityDefaultsHydration() + ) + await timeRendererStartupStep('remote-catalog-refresh', async () => { + await actions.fetchReposForAllHosts() + await actions.fetchProjectGroupsForAllHosts() + await actions.fetchFolderWorkspacesForAllHosts() + }) + } catch (err) { + console.warn('Remote startup catalog refresh failed:', err) + } + if (!isCancelled()) { + try { + await timeRendererStartupStep('remote-worktree-refresh', async () => { + // Why: the full scan is not required for session recovery, so keep it off the startup-critical path. + await actions.fetchAllWorktrees() + // Why: the startup prune only saw session-referenced repos; use the deferred scan's + // authoritative results to drop deleted-worktree visit timestamps that would + // otherwise accumulate unbounded (disconnected SSH stays non-authoritative and is kept). + actions.pruneLastVisitedTimestamps() + await actions.fetchWorktreeLineage() + }) + } catch (err) { + console.warn('Deferred startup worktree refresh failed:', err) + } + } + } finally { + if (!isCancelled()) { + useAppStore.setState({ startupWorktreeRefreshCompleted: true }) + } + } +} diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 11a5acdd2275..cdbdad52fa84 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -4,6 +4,7 @@ import { syncZoomCSSVar } from '@/lib/ui-zoom' import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler' import { useAppStore } from '../store' import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models' +import { refreshStartupRemoteCatalog } from './refresh-startup-remote-catalog' import { useStartupActions } from './use-app-startup-actions' import { reconcileNativeChatDraftsAtStartup, waitForNativeChatDraftsAtStartup } from './native-chat-draft-startup' import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees' @@ -310,42 +311,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta logRendererStartupDiagnostic('startup-hydration-done', { durationMs: Math.round(performance.now() - startupStartedAt) }) - void (async () => { - try { - try { - // Why: remote rows must not render under a fallback visibility while their owner default is still loading. - await timeRendererStartupStep('owner-visibility-defaults', () => - actions.awaitOwnerWorktreeVisibilityDefaultsHydration() - ) - await timeRendererStartupStep('remote-catalog-refresh', async () => { - await actions.fetchReposForAllHosts() - await actions.fetchProjectGroupsForAllHosts() - await actions.fetchFolderWorkspacesForAllHosts() - }) - } catch (err) { - console.warn('Remote startup catalog refresh failed:', err) - } - if (!cancelled) { - try { - await timeRendererStartupStep('remote-worktree-refresh', async () => { - // Why: the full scan is not required for session recovery, so keep it off the startup-critical path. - await actions.fetchAllWorktrees() - // Why: the startup prune only saw session-referenced repos; use the deferred scan's - // authoritative results to drop deleted-worktree visit timestamps that would - // otherwise accumulate unbounded (disconnected SSH stays non-authoritative and is kept). - actions.pruneLastVisitedTimestamps() - await actions.fetchWorktreeLineage() - }) - } catch (err) { - console.warn('Deferred startup worktree refresh failed:', err) - } - } - } finally { - if (!cancelled) { - useAppStore.setState({ startupWorktreeRefreshCompleted: true }) - } - } - })() + void refreshStartupRemoteCatalog(actions, () => cancelled) } } catch (error) { await recoverFromDegradedStartup({ diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index dcdfe4702095..d84a02ea3c9a 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -134,7 +134,9 @@ export function resolveNativeChatRuntimeSessionAttachmentOwner(session: { sessionId: string runtimeEnvironmentId: string }, stageDraft = true): NativeChatAttachmentOwner { - if (stageDraft && readNativeChatNewDraft(session.sessionId)) return { kind: 'local' } + if (stageDraft && readNativeChatNewDraft(session.sessionId)) { + return { kind: 'local' } + } const pairingRevision = getRuntimeEnvironmentRevision(session.runtimeEnvironmentId) if (pairingRevision === undefined) { return { kind: 'not-ready' } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts index 1e803332f25f..9c376b70f4c2 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts @@ -97,10 +97,11 @@ export function createIndexedDbNativeChatComposerDraftStorage( const transaction = (await database()).transaction(DRAFTS, 'readwrite') const done = committed(transaction) const drafts = transaction.objectStore(DRAFTS) + let next: NativeChatStoredDraft | null = null const read = drafts.get(scopeKey) // Why inside the read's callback: the write must land in the same transaction as the read. read.onsuccess = () => { - const next = apply(read.result) + next = apply(read.result) if (next) { drafts.put(next, scopeKey) } else { @@ -109,6 +110,7 @@ export function createIndexedDbNativeChatComposerDraftStorage( transaction.commit?.() } await done + return next } } } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts index dcd6c852fb48..45d4a2996128 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts @@ -60,7 +60,9 @@ function applyLoaded( ): void { const drafts = new Map() for (const [scopeKey, value] of loaded) { - if (loadNativeChatNewDraft(scopeKey, value)) continue + if (loadNativeChatNewDraft(scopeKey, value)) { + continue + } drafts.set(scopeKey, parseStoredNativeChatComposerDraft(value)) } for (const scopeKey of replayNativeChatComposerDraftJournal(drafts, journal)) { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts index 11cc4a04127a..2827c7bb6dcf 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts @@ -2,7 +2,7 @@ // storage has not confirmed when the window goes away, and follows other windows' writes. import { basename } from '@/lib/path' -import { loadNativeChatNewDraft } from './native-chat-new-draft' +import { invalidateNativeChatNewDraft } from './native-chat-new-draft' import { isNativeChatKeptPastePath, isNativeChatPastedImagePath } from './native-chat-image-paste' import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' import { @@ -263,13 +263,12 @@ function adoptForeignChange(message: unknown): void { typeof message === 'object' && message !== null && 'scopeKey' in message ? message.scopeKey : null - if (typeof scopeKey !== 'string' || hasLocalChange(scopeKey)) { + if (typeof scopeKey !== 'string' || invalidateNativeChatNewDraft(scopeKey) || hasLocalChange(scopeKey)) { return } void nativeChatComposerDraftStorage() .read(scopeKey) .then((value) => { - if (loadNativeChatNewDraft(scopeKey, value, true)) return if (hasLocalChange(scopeKey)) { return } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts index 2485c271e22b..0904cd7379f5 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts @@ -55,7 +55,7 @@ export type NativeChatComposerDraftStorage = { update( scopeKey: string, apply: (stored: unknown) => NativeChatStoredDraft | null - ): Promise + ): Promise } /** Drafts kept for this run only: the storage of an environment without IndexedDB (unit tests), @@ -67,8 +67,11 @@ export function createMemoryNativeChatComposerDraftStorage(): NativeChatComposer const drafts = new Map() const metadata = new Map() const put = (key: string, draft: NativeChatStoredDraft): void => { - if ('text' in draft) drafts.set(key, draft) - else metadata.set(key, draft) + if ('text' in draft) { + drafts.set(key, draft) + } else { + metadata.set(key, draft) + } } const storage = { drafts, @@ -106,7 +109,7 @@ export function createMemoryNativeChatComposerDraftStorage(): NativeChatComposer drafts.delete(scopeKey) metadata.delete(scopeKey) } - return Promise.resolve() + return Promise.resolve(next) } } return storage @@ -175,7 +178,9 @@ export function parseNativeChatNewDraft(value: unknown): NativeChatNewDraft | nu } const options: Record = {} for (const [key, pick] of Object.entries(value.options)) { - if (typeof pick === 'string') options[key] = pick + if (typeof pick === 'string') { + options[key] = pick + } } return { owner, agent: value.agent, groupId: value.groupId, options } } @@ -201,6 +206,12 @@ export function parseStoredNativeChatComposerDraft( } } +/** Select recovery content without retaining mutable draft-store bookkeeping. */ +export function projectNativeChatComposerDraft(draft: NativeChatComposerDraft): NativeChatComposerDraft { + const { text, document, images } = parseStoredNativeChatComposerDraft({ ...draft, savedAt: 0 }) ?? { text: '', images: [] } + return { text, ...(document ? { document } : {}), images } +} + const LEGACY_LOCAL_STORAGE_PREFIX = 'orca:nativeChatComposerDraft:v1:' /** Drafts this change kept in localStorage before it moved them; never released, so they go. */ diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index c25c67cd0343..9fee406bb2ae 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -162,7 +162,7 @@ describe('native-chat composer draft store', () => { ...storage, loadAll: () => new Promise>(() => {}), update: (scopeKey: string, apply: Parameters[1]) => - new Promise((resolve) => { + new Promise>>((resolve) => { complete = () => { void storage.update(scopeKey, apply).then(resolve) } @@ -302,7 +302,7 @@ describe('native-chat composer draft store', () => { land = () => resolve(snapshot) }) }, - update: () => new Promise(() => {}) + update: () => new Promise>>(() => {}) } const crashed = await reload({ using: crashing, hydrate: false }) await crashed.store.waitForNativeChatComposerDrafts(1) diff --git a/src/renderer/src/components/native-chat/native-chat-new-draft.ts b/src/renderer/src/components/native-chat/native-chat-new-draft.ts index af837df8102a..91d3e951115d 100644 --- a/src/renderer/src/components/native-chat/native-chat-new-draft.ts +++ b/src/renderer/src/components/native-chat/native-chat-new-draft.ts @@ -1,4 +1,5 @@ import { useSyncExternalStore } from 'react' +import { getAuthoritativelyRemovedWorktreeIds } from '@/store/slices/worktrees/listing/authoritative-worktree-removal-memory' import { nativeChatComposerDraftStorage, parseNativeChatNewDraft, @@ -10,8 +11,11 @@ import { notifyScope, refusedScopes, scopeListeners } from './native-chat-compos const PREFIX = 'agent-session-new:' const drafts = new Map() const changed = new Set() -const pending = new Map>>() -const removedOwners: NativeChatComposerDraftOwner[] = [] +const pending = new Map>() +const invalidated = new Set() +const revisions = new Map() +const reading = new Set() +const unsavedAllocations = new Set() function broadcastNativeChatComposerDraftChange(key: string): void { // Persistence reads draft metadata; only a committed write needs its broadcast channel. @@ -41,17 +45,70 @@ export function reportNativeChatNewDraftSaveFailure(sessionId: string, error: un return failed(nativeChatNewDraftKey(sessionId), error) } -function track(key: string, write: Promise): void { - const writes = pending.get(key) ?? new Set() - pending.set(key, writes) - writes.add(write) - void write.then(() => { +function adopt(key: string, draft: NativeChatNewDraft | null): void { + if (draft && getAuthoritativelyRemovedWorktreeIds(draft.owner.executionHostId)?.has(draft.owner.workspaceId)) { + void retireNativeChatNewDraft(key.slice(PREFIX.length)) + return + } + if (draft) { + drafts.set(key, draft) + } else if (!unsavedAllocations.has(key)) { + drafts.delete(key) + } + notify(key) +} + +function reconcile(key: string): void { + if (pending.has(key) || reading.has(key) || !invalidated.has(key)) { + return + } + const revision = revisions.get(key) + invalidated.delete(key) + reading.add(key) + void nativeChatComposerDraftStorage().read(key).then((value) => { + reading.delete(key) + if (revision !== revisions.get(key) || pending.has(key)) { + invalidated.add(key) + } else { + adopt(key, parseNativeChatNewDraft(value)) + } + reconcile(key) + }, (error: unknown) => { + reading.delete(key) + invalidated.add(key) + failed(key, error) + }) +} + +/** Foreign messages invalidate; the owner reads after local transactions have drained. */ +export function invalidateNativeChatNewDraft(key: string): boolean { + if (!key.startsWith(PREFIX)) { + return false + } + revisions.set(key, Symbol()) + invalidated.add(key) + reconcile(key) + return true +} + +function track(key: string, apply: () => Promise): Promise { + changed.add(key) + revisions.set(key, Symbol()) + const previous = pending.get(key) + const write = (previous ? previous.catch(() => null) : Promise.resolve()).then(apply) + pending.set(key, write) + return write.then((draft) => { refusedScopes.delete(key) - notify(key) broadcastNativeChatComposerDraftChange(key) + if (pending.get(key) === write) { + adopt(key, draft) + } + return true }, (error: unknown) => failed(key, error)).finally(() => { - writes.delete(write) - if (!writes.size) pending.delete(key) + if (pending.get(key) === write) { + pending.delete(key) + reconcile(key) + } }) } @@ -63,25 +120,28 @@ function failed(key: string, error: unknown): false { } export function loadNativeChatNewDraft(key: string, value: unknown, foreign = false): boolean { - if (!key.startsWith(PREFIX)) return false - if (pending.has(key) || (!foreign && changed.has(key))) return true - const draft = parseNativeChatNewDraft(value) - if (draft && removedOwners.some((owner) => owner.workspaceId === draft.owner.workspaceId && owner.executionHostId === draft.owner.executionHostId)) { - void retireNativeChatNewDraft(key.slice(PREFIX.length)) - return true + if (foreign) { + return invalidateNativeChatNewDraft(key) + } + if (!key.startsWith(PREFIX)) { + return false + } + if (!changed.has(key)) { + adopt(key, parseNativeChatNewDraft(value)) } - if (draft) drafts.set(key, draft) - else drafts.delete(key) - notify(key) return true } export function allocateNativeChatNewDraft(sessionId: string, draft: NativeChatNewDraft): void { const key = nativeChatNewDraftKey(sessionId) - changed.add(key) + unsavedAllocations.add(key) drafts.set(key, draft) notify(key) - track(key, nativeChatComposerDraftStorage().write(key, draft)) + void track(key, async () => { + await nativeChatComposerDraftStorage().write(key, draft) + unsavedAllocations.delete(key) + return draft + }) } export function updateNativeChatNewDraft( @@ -90,36 +150,29 @@ export function updateNativeChatNewDraft( ): void { const key = nativeChatNewDraftKey(sessionId) const current = drafts.get(key) - if (!current) return + if (!current) { + return + } const next = { ...current, ...change } - changed.add(key) drafts.set(key, next) notify(key) // Only allocation inserts metadata, so a stale window cannot revive a retired draft. - track(key, nativeChatComposerDraftStorage().update(key, (stored) => { + void track(key, async () => parseNativeChatNewDraft(await nativeChatComposerDraftStorage().update(key, (stored) => { const saved = parseNativeChatNewDraft(stored) return saved ? { ...saved, ...change } : null - })) + }))) } export async function retireNativeChatNewDraft(sessionId: string): Promise { const key = nativeChatNewDraftKey(sessionId) - changed.add(key) - try { - const removed = nativeChatComposerDraftStorage().remove([key]) - track(key, removed) - await removed - drafts.delete(key) - notify(key) - broadcastNativeChatComposerDraftChange(key) - return true - } catch (error) { - return failed(key, error) - } + return track(key, async () => { + await nativeChatComposerDraftStorage().remove([key]) + unsavedAllocations.delete(key) + return null + }) } export function deleteNativeChatNewDraftsOwnedBy(owner: NativeChatComposerDraftOwner): void { - removedOwners.push(owner) for (const [key, draft] of drafts) { if (draft.owner.workspaceId === owner.workspaceId && draft.owner.executionHostId === owner.executionHostId) { void retireNativeChatNewDraft(key.slice(PREFIX.length)) @@ -144,5 +197,8 @@ export function resetNativeChatNewDraftsForTests(): void { drafts.clear() changed.clear() pending.clear() - removedOwners.length = 0 + invalidated.clear() + revisions.clear() + reading.clear() + unsavedAllocations.clear() } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts index eebe72b9a911..2698b5e8ee55 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts @@ -17,6 +17,7 @@ import { agentSessionUnconfirmedSendParts, agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' +import type { AgentSessionWriteNoticePart } from '../../../../shared/agent-session-write-notice-copy' import type { AgentSessionWriteFailure } from '../../../../shared/agent-session-write-failure' import { structuredAgentSessionReadCoversSend, @@ -123,7 +124,7 @@ export type StructuredAgentSessionReservedSend = { /** The same message, owned by create instead of a later send request. */ create: (target: RuntimeClientTarget, prepared?: true) => StructuredAgentSessionCreateSend | null /** Gives the slot back unsent. */ - release: () => void + release: (notice?: readonly AgentSessionWriteNoticePart[] | null) => void } export type StructuredAgentSessionCreateSend = StructuredAgentSessionSent & { @@ -167,7 +168,9 @@ export function reserveStructuredAgentSessionSend( clientMessageId: entry.clientMessageId, prepare: () => { const current = held() - if (!current) return false + if (!current) { + return false + } if (!preparing && Date.now() - current.queuedAt >= STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS) { handBack(current, ['unreachable', ...agentSessionWriteNotDoneParts('composer-send')]) settleReservation('returned') @@ -179,7 +182,9 @@ export function reserveStructuredAgentSessionSend( }, setAttachments: (attachments) => { const current = held() - if (current) updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, { + if (current) { + updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, { + } body: { ...current.body, blocks: [ ...current.body.blocks.filter((block) => block.type !== 'image-ref'), ...attachments.map((image) => ({ type: 'image-ref' as const, path: image.path })) @@ -243,11 +248,16 @@ export function reserveStructuredAgentSessionSend( } } }, - release: () => { + release: (notice) => { clearTimeout(deadline) - if (held()) { - updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, null) - settleReservation('dropped') + const current = held() + if (current) { + if (notice === undefined) { + updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, null) + } else { + handBack(current, notice) + } + settleReservation(notice === undefined ? 'dropped' : 'returned') } } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts b/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts index a96c533f8b30..a3828ee00b16 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts @@ -2,6 +2,7 @@ // chat's view reads. import type { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-send-mutation' +import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' import type { AgentJournalCursor } from '../../../../shared/agent-session-journal-types' export type StructuredAgentSessionPendingSend = { @@ -23,6 +24,7 @@ export type StructuredAgentSessionPendingSend = { issued: boolean /** A sender outside the chat keeps its own copy, so nothing goes to the chat's composer. */ callerKeepsText?: true + sourceDraft?: NativeChatComposerDraft /** Made while the chat read Stopping: drawn after that turn until the host records it. */ sentWhileStopping?: true /** Each image's SSH connection, in body order, so a returned image still opens remotely. */ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts b/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts index b66d627f14a2..ce0f15765f01 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts @@ -6,6 +6,7 @@ import { agentSessionWriteNoticeParts, agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' +import { readNativeChatComposerDraft, updateNativeChatComposerDraft } from './native-chat-composer-draft-store' import { readNativeChatNewDraft } from './native-chat-new-draft' import type { AgentSessionWriteNoticePart } from '../../../../shared/agent-session-write-notice-copy' import type { AgentSessionWriteFailure } from '../../../../shared/agent-session-write-failure' @@ -58,6 +59,14 @@ export function finish( /** Puts the text back in the chat's composer; false, reported, when the draft write threw. */ function returnToComposer(entry: StructuredAgentSessionPendingSend): boolean { try { + if (entry.sourceDraft) { + const scopeKey = `agent-session:${entry.sessionId}` + const current = readNativeChatComposerDraft(scopeKey) + if (!current.text && !current.images.length) { + updateNativeChatComposerDraft(scopeKey, entry.sourceDraft, 'immediate') + } + return true + } handBackStructuredAgentSessionMessage( entry.sessionId, entry.clientMessageId, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts b/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts index aad2f2edbc5e..15587f249503 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts @@ -6,6 +6,7 @@ import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment } from '../../../../shared/structured-agent-session-send-mutation' +import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' import { createBrowserUuid } from '@/lib/browser-uuid' import { getStructuredAgentSessionPendingSends, @@ -24,6 +25,7 @@ export type StructuredAgentSessionSendInput = { options?: Readonly> /** The caller keeps the text if it comes back, instead of the chat's composer. */ callerKeepsText?: true + sourceDraft?: NativeChatComposerDraft /** Made while the chat read Stopping: drawn after that turn until the host records it. */ sentWhileStopping?: true now?: number @@ -43,6 +45,7 @@ export function takeStructuredAgentSessionSendSlot( body: structuredAgentSessionSendBody(input.text, attachments), previewUris: attachments.map((attachment) => attachment.previewUri), queuedAt: input.now ?? Date.now(), + ...(input.sourceDraft ? { sourceDraft: input.sourceDraft } : {}), ...(input.delivery ? { delivery: input.delivery } : {}), ...(input.options ? { options: input.options } : {}), ...(input.callerKeepsText ? { callerKeepsText: true as const } : {}), diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts index 4cf00ccfc6bd..66237af5171a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -95,7 +95,9 @@ export function useNativeChatProvisionalLaunch( if (starting) { return false } - if (draft) return submitNativeChatLocalDraft(sessionId, text, submission?.attachments ?? [], submission?.options ?? {}, lifecycle === 'published') + if (draft) { + return submitNativeChatLocalDraft(sessionId, text, submission?.attachments ?? [], submission?.options ?? {}, lifecycle === 'published') + } if (lifecycle !== 'failed' || !worktreeId) { return send() } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-write.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-write.ts new file mode 100644 index 000000000000..34374b084ef7 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-write.ts @@ -0,0 +1,111 @@ +import { useCallback } from 'react' +import type { AgentSessionOptionResult, AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' +import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' +import { applyStructuredAgentSessionOptions, commitStructuredAgentSessionOptionValues, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import type { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state' +import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' + +const NO_HELD_OPTIONS: Readonly> = {} + +type OptionState = ReturnType +type OptionWrite = Pick & { + launchSeedOptions: Readonly> | undefined + mutate: StructuredAgentSessionMutate + optionCatalog: AgentSessionOptionCatalog | null + rememberOptionPicks: (view: StructuredAgentSessionOptionState, committed: Readonly>) => void + sessionId: string + target: RuntimeClientTarget +} + +/** Applies a hosted pick and reconciles it only with the same session's live option record. */ +export function useStructuredAgentSessionOptionWrite(args: OptionWrite) { + const { activeOptionRecordRef, launchSeedOptions, mutate, optionCatalog, optionMutationGeneration, optionStateRef, optionView, pendingOptionRef, rememberOptionPicks, sessionId, target, updateOptionState } = args + return useCallback( + async (id: string, encoded: string): Promise => { + const currentState = optionStateRef.current + const targetRecord = currentState.record + const mutationGeneration = ++optionMutationGeneration.current + const isCurrent = (): boolean => + activeOptionRecordRef.current === targetRecord && + optionMutationGeneration.current === mutationGeneration + // Record-only: shown at once, so the picker stays usable while the write waits behind a start. + const shownAtOnce = id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + if (shownAtOnce) { + updateOptionState((current) => + current.record === targetRecord + ? commitStructuredAgentSessionOptionValues(current, { [id]: encoded }) + : current + ) + } else { + pendingOptionRef.current = { id, value: encoded } + updateOptionState((current) => ({ ...current, pendingId: id })) + } + let result: AgentSessionOptionResult | null = null + try { + result = await mutate( + 'agentSession.setOption', + 'agentSession.setOption', + { key: id, value: encoded } + ) + if (result && isCurrent()) { + const committed = result.options ?? { [id]: encoded } + updateOptionState((current) => + current.record === targetRecord + ? commitStructuredAgentSessionOptionValues(current, committed) + : current + ) + // The launch seed names the model an effort-only pick was made under. + rememberOptionPicks( + optionView(currentState, launchSeedOptions, NO_HELD_OPTIONS), + committed + ) + } + return Boolean(result) + } finally { + // A settled write, or a shown one that failed, reads back what the host now holds. + if (isCurrent() && (result || shownAtOnce)) { + void callStructuredAgentSession( + target, + 'agentSession.options', + { sessionId } + ) + .then((refreshed) => { + if (isCurrent()) { + updateOptionState((latest) => + latest.record === targetRecord && optionCatalog + ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) + : latest + ) + } + }) + .catch(() => {}) + } + if (isCurrent()) { + pendingOptionRef.current = null + updateOptionState((current) => + current.record === targetRecord && current.pendingId === id + ? { ...current, pendingId: null } + : current + ) + } + } + }, + [ + activeOptionRecordRef, + launchSeedOptions, + mutate, + optionCatalog, + optionMutationGeneration, + optionStateRef, + optionView, + pendingOptionRef, + rememberOptionPicks, + sessionId, + target, + updateOptionState + ] + ) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index efb3a76dac94..9262f1ca9c7e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -1,28 +1,22 @@ import { toast } from 'sonner' import { readNativeChatNewDraft, updateNativeChatNewDraft } from './native-chat-new-draft' import { useCallback, useMemo } from 'react' -import type { - AgentSessionOptionResult, - AgentSessionOptionsResult -} from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import { - applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, - commitStructuredAgentSessionOptionValues, lockedStructuredAgentSessionOptionSnapshot, structuredAgentSessionOptionPicks, structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/structured-agent-session-option-codec' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' +import { useStructuredAgentSessionOptionWrite } from './use-structured-agent-session-option-write' import { useHostModelCatalogUpgrade } from './use-host-model-catalog-upgrade' import { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state' import { agentSessionWriteFailureText } from './agent-session-write-notice-text' @@ -130,91 +124,20 @@ export function useStructuredAgentSessionOptions(args: { }, [agent, target] ) - const sendStructuredOption = useCallback( - async (id: string, encoded: string): Promise => { - const currentState = optionStateRef.current - const targetRecord = currentState.record - const mutationGeneration = ++optionMutationGeneration.current - const isCurrent = (): boolean => - activeOptionRecordRef.current === targetRecord && - optionMutationGeneration.current === mutationGeneration - // Record-only: shown at once, so the picker stays usable while the write waits behind a start. - const shownAtOnce = id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID - if (shownAtOnce) { - updateOptionState((current) => - current.record === targetRecord - ? commitStructuredAgentSessionOptionValues(current, { [id]: encoded }) - : current - ) - } else { - pendingOptionRef.current = { id, value: encoded } - updateOptionState((current) => ({ ...current, pendingId: id })) - } - let result: AgentSessionOptionResult | null = null - try { - result = await mutate( - 'agentSession.setOption', - 'agentSession.setOption', - { key: id, value: encoded } - ) - if (result && isCurrent()) { - const committed = result.options ?? { [id]: encoded } - updateOptionState((current) => - current.record === targetRecord - ? commitStructuredAgentSessionOptionValues(current, committed) - : current - ) - // The launch seed names the model an effort-only pick was made under. - rememberOptionPicks( - optionView(currentState, launchSeedOptions, NO_HELD_OPTIONS), - committed - ) - } - return Boolean(result) - } finally { - // A settled write, or a shown one that failed, reads back what the host now holds. - if (isCurrent() && (result || shownAtOnce)) { - void callStructuredAgentSession( - target, - 'agentSession.options', - { sessionId } - ) - .then((refreshed) => { - if (isCurrent()) { - updateOptionState((latest) => - latest.record === targetRecord && optionCatalog - ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) - : latest - ) - } - }) - .catch(() => {}) - } - if (isCurrent()) { - pendingOptionRef.current = null - updateOptionState((current) => - current.record === targetRecord && current.pendingId === id - ? { ...current, pendingId: null } - : current - ) - } - } - }, - [ - activeOptionRecordRef, - launchSeedOptions, - mutate, - optionCatalog, - optionMutationGeneration, - optionStateRef, - optionView, - pendingOptionRef, - rememberOptionPicks, - sessionId, - target, - updateOptionState - ] - ) + const sendStructuredOption = useStructuredAgentSessionOptionWrite({ + activeOptionRecordRef, + launchSeedOptions, + mutate, + optionCatalog, + optionMutationGeneration, + optionStateRef, + optionView, + pendingOptionRef, + rememberOptionPicks, + sessionId, + target, + updateOptionState + }) const settleLaunchOptionPick = useCallback( (outcome: StructuredLaunchOptionOutcome) => { if (outcome.kind === 'refused') { @@ -252,7 +175,9 @@ export function useStructuredAgentSessionOptions(args: { ? { ...(draft.options[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] ? { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: draft.options[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] } : {}), [id]: encoded } : { ...draft.options, [id]: encoded } updateNativeChatNewDraft(sessionId, { options }) - if (!getStructuredAgentSessionLaunchSelection(sessionId)) return true + if (!getStructuredAgentSessionLaunchSelection(sessionId)) { + return true + } } // No fence yet: the launch holds it and applies it before anything else is sent. const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded) diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 6dcdffde46e0..2f2c942e83db 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -117,7 +117,9 @@ function buildStructuredAgentSessionLaunchIntent( requestFocus = true ): StructuredAgentSessionLaunchIntent { const state = useAppStore.getState() - if (requestFocus) recordWebSessionFocusIntent( + if (requestFocus) { + recordWebSessionFocusIntent( + } structuredAgentSessionFocusOwner(owner.target), worktreeId, `agent-session:${sessionId}`, @@ -174,7 +176,9 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { }): StructuredAgentSessionLaunchIntent { const state = useAppStore.getState() const { target } = structuredAgentSessionOwnerTarget(args.worktreeId, args.executionHostId) - if (!args.firstSubmission) recordWebSessionFocusIntent( + if (!args.firstSubmission) { + recordWebSessionFocusIntent( + } structuredAgentSessionFocusOwner(target), args.worktreeId, `agent-session:${args.sessionId}`, diff --git a/src/renderer/src/lib/native-chat-draft-owner.ts b/src/renderer/src/lib/native-chat-draft-owner.ts index e47d763497da..de801cfcb216 100644 --- a/src/renderer/src/lib/native-chat-draft-owner.ts +++ b/src/renderer/src/lib/native-chat-draft-owner.ts @@ -37,7 +37,9 @@ export function resolveNativeChatDraftOwner( ): NativeChatComposerDraftOwner | undefined { const sessionId = structuredAgentSessionIdOfDraftScope(scopeKey) const local = sessionId ? readNativeChatNewDraft(sessionId) : undefined - if (local) return local.owner + if (local) { + return local.owner + } const workspaceId = workspaceOfDraftScope(state, scopeKey) const executionHostId = workspaceId ? resolveWorktreeOperationRoute(state, workspaceId)?.executionHostId diff --git a/src/renderer/src/lib/native-chat-draft-reload.ts b/src/renderer/src/lib/native-chat-draft-reload.ts index f4ad4b38a6bd..ae86b1ec314c 100644 --- a/src/renderer/src/lib/native-chat-draft-reload.ts +++ b/src/renderer/src/lib/native-chat-draft-reload.ts @@ -19,12 +19,18 @@ async function ownerConfirmedGone(draft: NativeChatNewDraft): Promise { const state = useAppStore.getState() const host = parseExecutionHostId(executionHostId) if (host?.kind === 'runtime' && state.runtimeEnvironmentCatalogHydrated && - !state.runtimeEnvironments.some((environment) => environment.id === host.environmentId)) return true - if (findKnownWorktreeById(state, workspaceId, executionHostId)) return false + !state.runtimeEnvironments.some((environment) => environment.id === host.environmentId)) { + return true + } + if (findKnownWorktreeById(state, workspaceId, executionHostId)) { + return false + } const scope = parseWorkspaceKey(workspaceId) if (scope?.type === 'folder') { const target = structuredAgentSessionTargetForHost(executionHostId) - if (!target) return false + if (!target) { + return false + } const catalog = await fetchFolderWorkspaceCatalogForTarget(target, state.projectGroups) return catalogOwnsHost(catalog.hostId, executionHostId) && !catalog.folderWorkspaces.some((row) => row.id === scope.folderWorkspaceId && getFolderWorkspaceHostId(row, state.projectGroups) === executionHostId) @@ -38,7 +44,9 @@ async function ownerConfirmedGone(draft: NativeChatNewDraft): Promise { export async function reconcileRestoredNativeChatDrafts(): Promise { for (const [sessionId, draft] of nativeChatNewDrafts()) { try { - if (getStructuredLaunchStateBySessionId(sessionId)) continue + if (getStructuredLaunchStateBySessionId(sessionId)) { + continue + } if (await ownerConfirmedGone(draft)) { if (await retireNativeChatNewDraft(sessionId)) { deleteNativeChatComposerDraft(`agent-session:${sessionId}`) @@ -50,7 +58,9 @@ export async function reconcileRestoredNativeChatDrafts(): Promise { if (issued) { const key = `agent-session:${sessionId}` const current = readNativeChatComposerDraft(key) - if (!current.text && !current.images.length) updateNativeChatComposerDraft(key, issued.draft, 'immediate') + if (!current.text && !current.images.length) { + updateNativeChatComposerDraft(key, issued.draft, 'immediate') + } confirmStructuredLaunchFirstMessagePublication(draft.owner.workspaceId, sessionId) } else if ((useAppStore.getState().groupsByWorktree[draft.owner.workspaceId] ?? []).some((group) => group.id === draft.groupId)) { openStructuredAgentSessionProvisionalTab({ diff --git a/src/renderer/src/lib/native-chat-first-submission.ts b/src/renderer/src/lib/native-chat-first-submission.ts index db92b83a39fa..7d9d6d443a06 100644 --- a/src/renderer/src/lib/native-chat-first-submission.ts +++ b/src/renderer/src/lib/native-chat-first-submission.ts @@ -29,10 +29,13 @@ export function submitNativeChatLocalDraft( hosted = false ): boolean { const draft = readNativeChatNewDraft(sessionId) - if (!draft) return false + if (!draft) { + return false + } const prior = getStructuredLaunchStateBySessionId(sessionId) - const issued = prior?.intent.firstSubmission ?? getPersistedStructuredAgentLaunchRecord(sessionId)?.firstSubmission - if (!hosted && issued && prior?.callers.outcome !== 'failed') { + const saved = getPersistedStructuredAgentLaunchRecord(sessionId) + const issued = prior?.intent.firstSubmission ?? saved?.firstSubmission + if (!hosted && issued && (prior?.callers.outcome ?? saved?.lifecycle) !== 'failed') { confirmStructuredLaunchFirstMessagePublication(draft.owner.workspaceId, sessionId) return false } @@ -44,14 +47,20 @@ export function submitNativeChatLocalDraft( } const prepare = async (): Promise => { const target = structuredAgentSessionTargetForHost(draft.owner.executionHostId) - if (!target) throw new Error('The chat host is unavailable.') + if (!target) { + throw new Error('The chat host is unavailable.') + } if (target.kind === 'environment' && attachments.length > 0) { const owner = resolveNativeChatRuntimeSessionAttachmentOwner({ sessionId, runtimeEnvironmentId: target.environmentId }, false) - if (owner.kind !== 'runtime-session') throw new Error('The chat host is unavailable.') + if (owner.kind !== 'runtime-session') { + throw new Error('The chat host is unavailable.') + } const prepared = await prepareNativeChatSessionAttachmentUpload(owner, hosted ? undefined : { worktree: toRuntimeWorktreeSelector(draft.owner.workspaceId), agent: draft.agent }) - if (!prepared.ok) throw new Error(prepared.notice) + if (!prepared.ok) { + throw new Error(prepared.notice) + } const upload = async (): Promise => { const result = await uploadNativeChatSessionAttachmentPaths(attachments.map((image) => image.path), prepared.target) if (result.failed.length || result.skipped.length || result.uploaded.length !== attachments.length) { @@ -60,12 +69,17 @@ export function submitNativeChatLocalDraft( const stored = new Map(result.uploaded.map((image) => [image.sourcePath, image.path])) stagedPrompt.slot?.setAttachments(attachments.map((image) => { const path = stored.get(image.path) - if (!path) throw new Error('The images could not be attached. Attach them again and send.') + if (!path) { + throw new Error('The images could not be attached. Attach them again and send.') + } return { ...image, path } })) } - if (hosted || prepared.target.reservedSession) await upload() - else stagedPrompt.beforeSend = upload + if (hosted || prepared.target.reservedSession) { + await upload() + } else { + stagedPrompt.beforeSend = upload + } } } if (hosted) { diff --git a/src/renderer/src/lib/native-chat-local-draft.test.ts b/src/renderer/src/lib/native-chat-local-draft.test.ts index 9764a652fe38..63ed14145257 100644 --- a/src/renderer/src/lib/native-chat-local-draft.test.ts +++ b/src/renderer/src/lib/native-chat-local-draft.test.ts @@ -4,7 +4,7 @@ import type { Tab } from '../../../shared/tab-types' import type { ExecutionHostId } from '../../../shared/execution-host' import { openNativeChatLocalDraft } from './native-chat-local-draft' import { newAgentLaunchRequestId } from './agent-launch-request-id' -import { allocateNativeChatNewDraft, readNativeChatNewDraft, retireNativeChatNewDraft, updateNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' +import { allocateNativeChatNewDraft, invalidateNativeChatNewDraft, deleteNativeChatNewDraftsOwnedBy, readNativeChatNewDraft, retireNativeChatNewDraft, updateNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' import { clearNativeChatComposerDraftsForTests, hydrateNativeChatComposerDrafts, readNativeChatComposerDraft, updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' import { createMemoryNativeChatComposerDraftStorage, setNativeChatComposerDraftStorageForTests } from '@/components/native-chat/native-chat-composer-draft-storage' @@ -81,3 +81,48 @@ it('keeps metadata independent of stale content and refuses to resurrect it afte expect(readNativeChatComposerDraft(`agent-session:${id}`).text).toBe('stale window') expect(mocks.call).not.toHaveBeenCalled() }) + + +it('converges foreign retirement during a pick and adopts a recreated workspace draft', async () => { + const id = 'codex_foreign_retirement' + const key = `agent-session-new:${id}` + const metadata = { owner: { workspaceId: 'folder:workspace', executionHostId: 'local' as const }, agent: 'codex' as const, groupId: 'group-1', options: {} } + allocateNativeChatNewDraft(id, metadata) + await vi.waitFor(async () => expect(await storage.read(key)).toEqual(metadata)) + const update = storage.update + let commit: (() => void) | undefined + storage.update = (scope, apply) => new Promise((resolve) => { + commit = () => { void update(scope, apply).then(resolve) } + }) + updateNativeChatNewDraft(id, { options: { model: 'pick during removal' } }) + await vi.waitFor(() => expect(commit).toBeDefined()) + await storage.remove([key]) + invalidateNativeChatNewDraft(key) + commit?.() + await vi.waitFor(() => expect(readNativeChatNewDraft(id)).toBeUndefined()) + storage.update = update + allocateNativeChatNewDraft('codex_old_owner', metadata) + deleteNativeChatNewDraftsOwnedBy(metadata.owner) + await vi.waitFor(async () => expect(await storage.read('agent-session-new:codex_old_owner')).toBeUndefined()) + await storage.write('agent-session-new:codex_recreated_owner', metadata) + invalidateNativeChatNewDraft('agent-session-new:codex_recreated_owner') + await vi.waitFor(() => expect(readNativeChatNewDraft('codex_recreated_owner')).toEqual(metadata)) + const read = storage.read + let releaseRead: (() => void) | undefined + storage.read = async (scope) => { + const snapshot = await read(scope) + storage.read = read + return new Promise((resolve) => { releaseRead = () => resolve(snapshot) }) + } + invalidateNativeChatNewDraft('agent-session-new:codex_recreated_owner') + await vi.waitFor(() => expect(releaseRead).toBeDefined()) + updateNativeChatNewDraft('codex_recreated_owner', { options: { model: 'newer than foreign read' } }) + await vi.waitFor(async () => expect(await read('agent-session-new:codex_recreated_owner')).toMatchObject({ options: { model: 'newer than foreign read' } })) + releaseRead?.() + await vi.waitFor(() => expect(readNativeChatNewDraft('codex_recreated_owner')?.options.model).toBe('newer than foreign read')) + storage.refuseWrites = true + allocateNativeChatNewDraft('codex_unsaved', metadata) + invalidateNativeChatNewDraft('agent-session-new:codex_unsaved') + await vi.waitFor(() => expect(readNativeChatNewDraft('codex_unsaved')).toEqual(metadata)) + expect(mocks.call).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/lib/native-chat-local-draft.ts b/src/renderer/src/lib/native-chat-local-draft.ts index 47ce4f027083..845f3edda393 100644 --- a/src/renderer/src/lib/native-chat-local-draft.ts +++ b/src/renderer/src/lib/native-chat-local-draft.ts @@ -20,7 +20,9 @@ export function openNativeChatLocalDraft(args: { prompt?: string }): Tab | null { const owner = structuredLaunchOwner({ route: 'structured-native-chat', executionHostId: args.executionHostId }, args.worktreeId) - if (!owner) return null + if (!owner) { + return null + } const sessionId = createStructuredAgentSessionId(args.agent, () => args.requestId) const tab = flushSync(() => { const opened = openStructuredAgentSessionProvisionalTab({ ...args, ...owner, sessionId, targetGroupId: args.groupId }) @@ -29,10 +31,14 @@ export function openNativeChatLocalDraft(args: { owner: { workspaceId: args.worktreeId, executionHostId: owner.executionHostId }, agent: args.agent, groupId: opened.groupId, options: {} }) - if (args.prompt) updateNativeChatComposerDraft(`agent-session:${sessionId}`, { text: args.prompt }, 'immediate') + if (args.prompt) { + updateNativeChatComposerDraft(`agent-session:${sessionId}`, { text: args.prompt }, 'immediate') + } } return opened }) - if (args.focus !== false && args.activate !== false) requestNativeChatComposerFocus(tab.id) + if (args.focus !== false && args.activate !== false) { + requestNativeChatComposerFocus(tab.id) + } return tab } diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts index 4183025f050f..af780361a983 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts @@ -87,7 +87,9 @@ export async function launchStructuredAgentSession( try { hostSeed = await requireHostCreateSupport(intent) } catch (error) { - if (!intent.firstSubmission || error instanceof StructuredAgentSessionCreateRefusalError) throw error + if (!intent.firstSubmission || error instanceof StructuredAgentSessionCreateRefusalError) { + throw error + } throw new StructuredAgentSessionCreateRefusalError( error instanceof Error ? error.message : 'The chat host is unavailable.', 'structured_agent_session_unsupported' @@ -110,8 +112,12 @@ export async function launchStructuredAgentSession( issue = await intent.prepareCreate?.() } catch (error) { // Preparation failed before create was issued, so its sources remain safe to submit again. - if (error instanceof StructuredAgentSessionSubmissionEndedError) throw error - if (intent.firstSubmission) throw new StructuredAgentSessionCreateRefusalError( + if (error instanceof StructuredAgentSessionSubmissionEndedError) { + throw error + } + if (intent.firstSubmission) { + throw new StructuredAgentSessionCreateRefusalError( + } error instanceof Error ? error.message : 'The message could not be prepared.', 'agent_session_attachment_unavailable' ) diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index c51d2d3b21db..407deed4c836 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -51,7 +51,7 @@ import { } from './structured-agent-session-launch' import { holdStructuredAgentSessionLaunchOption } from './structured-agent-session-launch-options' import { recordHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' -import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry' +import { resetStructuredAgentLaunchRegistryForTests, getStructuredLaunchStateBySessionId } from './structured-agent-session-launch-registry' import { resetStructuredAgentLaunchPersistenceForTests, readStructuredAgentLaunchRecord @@ -69,7 +69,7 @@ import { resetStructuredAgentSessionReadOwnersForTests } from '@/components/native-chat/structured-agent-session-read-owner' import { reserveStructuredAgentSessionSend, resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender' -import { getStructuredAgentSessionPendingSends } from '@/components/native-chat/structured-agent-session-pending-sends' +import { getStructuredAgentSessionPendingSends, getStructuredAgentSessionSendNotice } from '@/components/native-chat/structured-agent-session-pending-sends' import { structuredAgentSessionStopControl } from '@/components/native-chat/structured-agent-session-stop-control' import { clearNativeChatDraftCacheForTests, @@ -174,8 +174,9 @@ describe('the first message owned by create', () => { mocks.supports.mockReturnValue(scenario === 'expiredDiscovery' ? discovery.promise : Promise.resolve(!legacy)) mocks.call.mockReturnValue(create.promise) allocateNativeChatNewDraft(sessionId, { owner: { workspaceId: 'workspace-1', executionHostId: 'local' }, agent: 'codex', groupId: 'group-1', options: {} }) + const storedSource = { ...source, savedAt: 123, owner: { workspaceId: 'workspace-1', executionHostId: 'local' as const }, unsavedText: 'bookkeeping' } const prompt = stageStructuredLaunchPrompt(sessionId, source.text, { - sourceDraft: source, options: legacy ? {} : { model: 'picked' }, + sourceDraft: storedSource, options: legacy ? {} : { model: 'picked' }, attachments: source.images.map((image) => ({ path: image.path, previewUri: image.path })) }) const prepareUpload = vi.fn(async () => { @@ -184,8 +185,11 @@ describe('the first message owned by create', () => { path: image.path.replace('/source/', '/stored/'), previewUri: image.path }))) }) - if (legacy) prompt.beforeSend = prepareUpload - else prompt.beforeCreate = prepareUpload + if (legacy) { + prompt.beforeSend = prepareUpload + } else { + prompt.beforeCreate = prepareUpload + } const launch = startStructuredAgentLaunch('workspace-1', 'codex', { requestId: newAgentLaunchRequestId(), executionHostId: 'local', reservedSessionId: sessionId, stagedPrompt: prompt, prompt: source.text, firstSubmissionOptions: legacy ? {} : { model: 'picked' } @@ -195,8 +199,16 @@ describe('the first message owned by create', () => { expect(reserveStructuredAgentSessionSend({ sessionId, text: 'duplicate' })).toBeNull() if (scenario === 'expiredDiscovery') { await vi.advanceTimersByTimeAsync(30_000) - expect(readNativeChatComposerDraft(scope)).toEqual(source) + expect(readNativeChatComposerDraft(scope)).toMatchObject(source) + expect(await result).toBeInstanceOf(StructuredAgentSessionSubmissionEndedError) + expect(retryStructuredAgentSessionLaunch('workspace-1', sessionId)).toBe(false) + expect(readStructuredAgentLaunchRecord(sessionId)).toBeUndefined() + const next = reserveStructuredAgentSessionSend({ sessionId, text: 'fresh explicit send' }) + expect(next).not.toBeNull() discovery.resolve(true) + await vi.advanceTimersByTimeAsync(0) + expect(getStructuredAgentSessionPendingSends(sessionId)[0]?.clientMessageId).toBe(next?.clientMessageId) + next?.release() } else { if (legacy) { mocks.inventory.mockResolvedValue([{ worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId }] }]) @@ -204,7 +216,9 @@ describe('the first message owned by create', () => { create.resolve(created()) } await vi.waitFor(() => expect(prepareUpload).toHaveBeenCalledOnce()) - if (!legacy) expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', sessionId)).toBe('pending') + if (!legacy) { + expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', sessionId)).toBe('pending') + } if (scenario === 'held') { await vi.advanceTimersByTimeAsync(45_000) expect(mocks.call).not.toHaveBeenCalled() @@ -243,7 +257,7 @@ describe('the first message owned by create', () => { await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: false }) expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) if (scenario === 'legacySuperseded') { - expect(readNativeChatComposerDraft(scope)).toEqual(source) + expect(readNativeChatComposerDraft(scope)).toMatchObject(source) resetStructuredAgentLaunchRegistryForTests() resetStructuredAgentLaunchPersistenceForTests() updateNativeChatComposerDraft(scope, { text: 'newer edit' }, 'immediate') @@ -263,6 +277,39 @@ describe('the first message owned by create', () => { } ) + it('keeps a definite opening failure through reload and reports refusal with newer content intact', async () => { + const sessionId = 'codex_failed_reload' + const source = { text: 'original opening', images: [] } + const scope = structuredAgentSessionDraftScopeKey(sessionId) + allocateNativeChatNewDraft(sessionId, { owner: { workspaceId: 'workspace-1', executionHostId: 'local' }, agent: 'codex', groupId: 'group-1', options: {} }) + const prompt = stageStructuredLaunchPrompt(sessionId, source.text, { sourceDraft: source }) + const response = deferred() + mocks.call.mockReturnValue(response.promise) + const launch = startStructuredAgentLaunch('workspace-1', 'codex', { requestId: newAgentLaunchRequestId(), executionHostId: 'local', reservedSessionId: sessionId, prompt: source.text, stagedPrompt: prompt }) + const result = launch.launchResult.catch((error: unknown) => error) + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + updateNativeChatComposerDraft(scope, { text: 'newer content', images: [] }, 'immediate') + response.resolve({ ok: false, refusal: { code: 'structured_agent_session_unsupported', message: 'unavailable' } }) + await result + await launch.promptDeliveryResult + expect(readNativeChatComposerDraft(scope).text).toBe('newer content') + expect(getStructuredAgentSessionSendNotice(sessionId)).toContain('not sent') + const failed = readStructuredAgentLaunchRecord(sessionId) + expect(failed?.lifecycle).toBe('failed') + if (!failed?.firstSubmission) { + throw new Error('missing failed snapshot') + } + localStorage.setItem('orca:structuredAgentLaunches:v1', JSON.stringify([{ ...failed, firstSubmission: { ...failed.firstSubmission, draft: { ...source, savedAt: 123, unsavedText: 'extra' } } }])) + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + expect(readStructuredAgentLaunchRecord(sessionId)?.firstSubmission?.draft).toEqual(source) + confirmStructuredLaunchFirstMessagePublication('workspace-1', sessionId) + expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', sessionId)).toBe('failed') + expect(mocks.call).toHaveBeenCalledOnce() + expect(submitNativeChatLocalDraft(sessionId, 'new explicit opening', [], {})).toBe(true) + expect(getStructuredLaunchStateBySessionId(sessionId)?.intent.firstSubmission?.clientMessageId).not.toBe(prompt.slot?.clientMessageId) + }) + it('confirms a closed-tab opening through paged history and retains evidence until metadata removal commits', async () => { const storage = createMemoryNativeChatComposerDraftStorage() setNativeChatComposerDraftStorageForTests(storage) diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts index b80f1f8813bd..1fb45cbfbbfc 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -64,7 +64,9 @@ function structuredLaunchCreateOptions( state: StructuredLaunchState ): Readonly> | undefined { const { intent, selection } = state - if (intent.firstSubmission) return intent.firstSubmission.options + if (intent.firstSubmission) { + return intent.firstSubmission.options + } const firstFrame = firstFrameOptionState(intent.agent, intent.target, { newLaunch: !intent.params.resumeFrom, worktree: intent.params.worktree, diff --git a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts index 53a7826af05d..d21adb3bde88 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts @@ -32,12 +32,16 @@ export async function finalizeStructuredLaunchConfirmedSubmission(sessionId: str const draft = readNativeChatNewDraft(sessionId) const state = getStructuredLaunchStateBySessionId(sessionId) ?? (draft ? restorePersistedStructuredLaunchState(draft.owner.workspaceId, sessionId) : undefined) - if (state?.intent.firstSubmission && !state.cancelled) await finalizeStructuredLaunchFirstSubmission(state) + if (state?.intent.firstSubmission && !state.cancelled) { + await finalizeStructuredLaunchFirstSubmission(state) + } } export function finalizeStructuredLaunchFirstSubmission(state: StructuredLaunchState): Promise { const active = finalizing.get(state) - if (active) return active + if (active) { + return active + } const finalized = finalizeFirstSubmission(state).finally(() => finalizing.delete(state)) finalizing.set(state, finalized) return finalized @@ -45,7 +49,9 @@ export function finalizeStructuredLaunchFirstSubmission(state: StructuredLaunchS async function finalizeFirstSubmission(state: StructuredLaunchState): Promise { const submission = state.intent.firstSubmission - if (!submission || !(await retireNativeChatNewDraft(state.intent.sessionId))) return + if (!submission || !(await retireNativeChatNewDraft(state.intent.sessionId))) { + return + } clearNativeChatComposerDraftIfUnchanged(`agent-session:${state.intent.sessionId}`, submission.draft) delete state.intent.firstSubmission settleStructuredLaunchCallers(state.callers, 'published') @@ -83,7 +89,7 @@ export function trackLaunchSettlement( ): void { void promise.then( (receipt) => { - if (state.promise !== promise) { + if (state.promise !== promise || getStructuredLaunchStateBySessionId(state.intent.sessionId) !== state) { return } settleStructuredLaunchCallers(state.callers, 'published') @@ -93,7 +99,7 @@ export function trackLaunchSettlement( notifyStructuredLaunchListeners() }, (error) => { - if (state.promise !== promise) { + if (state.promise !== promise || getStructuredLaunchStateBySessionId(state.intent.sessionId) !== state) { return } if (state.cancelled) { @@ -106,6 +112,9 @@ export function trackLaunchSettlement( return } if (error instanceof StructuredAgentSessionSubmissionEndedError) { + state.cancelled = true + state.visibilityUnknown = false + settleStructuredLaunchCallers(state.callers, 'failed') delete state.intent.firstSubmission cleanupLaunchState(state) return diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts index f36efa2c11f9..bad7d9d75b07 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts @@ -1,6 +1,7 @@ import { reportNativeChatNewDraftSaveFailure } from '@/components/native-chat/native-chat-new-draft' import { validRecord, + projectStructuredAgentFirstSubmission, type StructuredAgentLaunchPersistedRecord } from './structured-agent-session-launch-record' export { @@ -41,9 +42,10 @@ function load(): void { const seedOptions = parseStructuredLaunchSeedOptions( 'seedOptions' in value ? value.seedOptions : undefined ) - const { seedOptions: _stored, ...rest } = value + const { seedOptions: _stored, firstSubmission, ...rest } = value records.set(value.sessionId, { ...rest, + ...(firstSubmission ? { firstSubmission: projectStructuredAgentFirstSubmission(firstSubmission) } : {}), ...(seedOptions ? { seedOptions } : {}), executionHostId: parseExecutionHostId(value.executionHostId)?.id ?? LOCAL_EXECUTION_HOST_ID, @@ -118,7 +120,9 @@ function writeRecords(): void { } } catch (error) { for (const record of records.values()) { - if (record.firstSubmission) reportNativeChatNewDraftSaveFailure(record.sessionId, error) + if (record.firstSubmission) { + reportNativeChatNewDraftSaveFailure(record.sessionId, error) + } } // Why: persistence is recovery bookkeeping and must never block a launch. console.warn('[structured-agent-launch] could not persist launch state') @@ -159,7 +163,7 @@ export function writeStructuredAgentLaunchRecord( record: StructuredAgentLaunchPersistedRecord ): void { load() - records.set(record.sessionId, record) + records.set(record.sessionId, { ...record, ...(record.firstSubmission ? { firstSubmission: projectStructuredAgentFirstSubmission(record.firstSubmission) } : {}) }) writeRecords() } diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts index 35bce455b37f..cf24ea110de6 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts @@ -1,3 +1,5 @@ +import { StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' +import type { StagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt-types' import type { StructuredLaunchCaller } from './structured-agent-session-launch-callers' import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' import { finalizeStructuredLaunchFirstSubmission } from './structured-agent-session-launch-outcome-tracking' @@ -7,6 +9,25 @@ import { type StructuredLaunchState } from './structured-agent-session-launch-registry' +/** Pre-issue reservation settlement ends the command even while discovery is unresolved. */ +export function boundStructuredLaunchReservation( + state: StructuredLaunchState, + created: Promise, + prompt: StagedStructuredLaunchPrompt | null +): Promise { + const { intent } = state + if (!intent.firstSubmission || !prompt?.slot) { + return created + } + return Promise.race([created, prompt.slot.outcome.then(() => { + if (!intent.createPrepared) { + intent.prepareCreate = () => { throw new StructuredAgentSessionSubmissionEndedError() } + throw new StructuredAgentSessionSubmissionEndedError() + } + return created + })]) +} + /** The message deadline frees the composer and offers the existing Retry while create is unknown. */ export function trackStructuredLaunchPromptOutcome( state: StructuredLaunchState, @@ -14,7 +35,9 @@ export function trackStructuredLaunchPromptOutcome( ): void { const group = state.callers void caller.promptDeliveryResult?.then((outcome) => { - if (outcome.delivered && state.intent.firstSubmission) void finalizeStructuredLaunchFirstSubmission(state) + if (outcome.delivered && state.intent.firstSubmission) { + void finalizeStructuredLaunchFirstSubmission(state) + } if ( state.callers !== group || state.cancelled || diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-return.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-return.ts index 97ce967a90b2..197c5ff09b58 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-return.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-return.ts @@ -2,7 +2,6 @@ import { structuredAgentSessionSendBody } from '../../../shared/structured-agent import { handBackStructuredAgentSessionMessage } from '@/components/native-chat/structured-agent-session-message-hand-back' import { publishStructuredAgentSessionSends } from '@/components/native-chat/structured-agent-session-pending-sends' import { agentSessionWriteNoticeText } from '@/components/native-chat/agent-session-write-notice-text' -import { readNativeChatComposerDraft, updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' import type { StagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt' /** Puts a launch's text in its chat's composer, unless its sender keeps it. True when it did; a @@ -12,13 +11,6 @@ export function handBackStagedPrompt(prompt: StagedStructuredLaunchPrompt): bool return false } try { - if (prompt.sourceDraft) { - const scopeKey = `agent-session:${prompt.sessionId}` - const current = readNativeChatComposerDraft(scopeKey) - if (current.text || current.images.length) return false - updateNativeChatComposerDraft(scopeKey, prompt.sourceDraft, 'immediate') - return true - } handBackStructuredAgentSessionMessage( prompt.sessionId, `launch-${prompt.sessionId}`, diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 5d53177f5c5d..b9a9acc89359 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -9,11 +9,9 @@ import { sendStructuredAgentSessionMessage } from '@/components/native-chat/structured-agent-session-message-sender' import { noteStructuredAgentSessionFence } from '@/components/native-chat/structured-agent-session-send-attempt' -import { publishStructuredAgentSessionSends } from '@/components/native-chat/structured-agent-session-pending-sends' -import { agentSessionWriteNoticeText } from '@/components/native-chat/agent-session-write-notice-text' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import type { StructuredAgentSessionSendInput } from '@/components/native-chat/structured-agent-session-send-slot' -import type { NativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-storage' +import { projectNativeChatComposerDraft, type NativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-storage' import type { StructuredPromptDeliveryResult, StructuredLaunchPromptOptions, StagedDelivery, StagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt-types' export type { StructuredPromptDeliveryResult, StructuredLaunchPromptOptions, StagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt-types' @@ -26,6 +24,7 @@ export function stageStructuredLaunchPrompt( text: string, options: { callerKeepsText?: true; sourceDraft?: NativeChatComposerDraft } & Pick = {} ): StagedStructuredLaunchPrompt { + const source = options.sourceDraft ? { sourceDraft: projectNativeChatComposerDraft(options.sourceDraft) } : {} let discard = (): void => {} const whenDiscarded = new Promise((resolve) => { discard = resolve @@ -37,9 +36,9 @@ export function stageStructuredLaunchPrompt( const prompt: StagedStructuredLaunchPrompt = { sessionId, text, - slot: reserveStructuredAgentSessionSend({ sessionId, text, ...options, - ...(options.sourceDraft ? { callerKeepsText: true as const } : {}) }), + slot: reserveStructuredAgentSessionSend({ sessionId, text, ...options, ...source }), ...options, + ...source, whenDiscarded, discard, whenCreateSettled, @@ -49,7 +48,6 @@ export function stageStructuredLaunchPrompt( forSession.add(prompt) staged.set(sessionId, forSession) void prompt.slot?.outcome.then((outcome) => { - if (!prompt.discarded && prompt.sourceDraft && (outcome === 'returned' || outcome === 'unconfirmed')) handBackStagedPrompt(prompt) const result: StagedDelivery = { delivered: outcome === 'recorded', inComposer: !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), @@ -96,8 +94,12 @@ export function takeBackLegacyStructuredLaunchPrompts(sessionId: string): void { if (prompt.delivery || prompt.createOwned) { continue } + if (prompt.slot) { + prompt.slot.release(null) + } else { + handBackStagedPrompt(prompt) + } releaseStagedStructuredLaunchPrompt(prompt) - handBackStagedPrompt(prompt) prompt.discarded = true prompt.discard() } @@ -154,8 +156,12 @@ function sendStagedPrompt( target: RuntimeClientTarget ): Promise { prompt.delivery ??= (async () => { - if (receipt) noteStructuredAgentSessionFence(prompt.sessionId, receipt.fence) - if (prompt.sourceDraft && !prompt.slot?.prepare()) throw new StructuredAgentSessionSubmissionEndedError() + if (receipt) { + noteStructuredAgentSessionFence(prompt.sessionId, receipt.fence) + } + if (prompt.sourceDraft && !prompt.slot?.prepare()) { + throw new StructuredAgentSessionSubmissionEndedError() + } await prompt.beforeSend?.() const sent = prompt.slot ? prompt.slot.send(target) : @@ -201,23 +207,36 @@ export async function prepareStructuredLaunchFirstSubmission( const prompt = [...(staged.get(sessionId) ?? [])].find( (candidate) => candidate.slot?.clientMessageId === clientMessageId ) - if (!prompt?.slot?.prepare()) throw new StructuredAgentSessionSubmissionEndedError() + if (!prompt?.slot?.prepare()) { + throw new StructuredAgentSessionSubmissionEndedError() + } await prompt.beforeCreate?.() return () => { - if (!prompt.slot?.prepare()) throw new StructuredAgentSessionSubmissionEndedError() - if (!atomic || prompt.beforeSend) return undefined + if (!prompt.slot?.prepare()) { + throw new StructuredAgentSessionSubmissionEndedError() + } + if (!atomic || prompt.beforeSend) { + return undefined + } const message = prepareStructuredLaunchPromptForCreate(sessionId, target, prompt) - if (!message) throw new StructuredAgentSessionSubmissionEndedError() + if (!message) { + throw new StructuredAgentSessionSubmissionEndedError() + } return message } } export function failStagedStructuredLaunchPrompt(prompt: StagedStructuredLaunchPrompt): void { - if (prompt.discarded) return + if (prompt.discarded) { + return + } + if (prompt.slot) { + prompt.slot.release(['notDoneSend']) + } else { + handBackStagedPrompt(prompt) + } releaseStagedStructuredLaunchPrompt(prompt) - handBackStagedPrompt(prompt) prompt.settleCreate({ delivered: false, inComposer: true }) - publishStructuredAgentSessionSends(prompt.sessionId, { notice: agentSessionWriteNoticeText(['notDoneSend']) }) } export function settleStructuredAgentLaunchPrompt(args: { @@ -270,9 +289,7 @@ export function settleStructuredAgentLaunchPrompt(args: { } // The chat never started: its text waits in the chat's composer for the person's own Send. if (prompt && !prompt.discarded && !prompt.delivery) { - releaseStagedStructuredLaunchPrompt(prompt) - handBackStagedPrompt(prompt) - prompt.discarded = true + failStagedStructuredLaunchPrompt(prompt) } throw error } diff --git a/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts b/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts index f172a83e7e4c..ea6b77b3a038 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts @@ -1,33 +1,42 @@ +import { STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS } from '@/components/native-chat/structured-agent-session-message-sender' import { recoverPublishedSessionReceipt } from './structured-agent-session-launch-recovery' -import { publishStructuredLaunchCreateOptions } from './structured-agent-session-launch-create-message' -import { trackLaunchSettlement } from './structured-agent-session-launch-outcome-tracking' -import { trackStructuredLaunchConfirmationDeadline } from './structured-agent-session-launch-prompt-outcome' +import { finalizeStructuredLaunchFirstSubmission } from './structured-agent-session-launch-outcome-tracking' +import { settleStructuredLaunchCallers } from './structured-agent-session-launch-callers' import { restorePersistedStructuredLaunchState } from './structured-agent-session-launch-reload' import { getStructuredLaunchStateBySessionId, - notifyStructuredLaunchListeners + notifyStructuredLaunchListeners, + type StructuredLaunchState } from './structured-agent-session-launch-registry' -/** A tab proves the chat exists; its journal must also confirm the immutable opening message. */ -export function confirmStructuredLaunchFirstMessagePublication( - worktreeId: string, - sessionId: string -): void { - const state = - getStructuredLaunchStateBySessionId(sessionId) ?? +const confirming = new WeakSet() + +/** Confirmation observes positive evidence; it never replaces the execution promise or verdict. */ +export function confirmStructuredLaunchFirstMessagePublication(worktreeId: string, sessionId: string): void { + const state = getStructuredLaunchStateBySessionId(sessionId) ?? restorePersistedStructuredLaunchState(worktreeId, sessionId) - if (!state || state.callers.outcome === 'pending' || state.cancelled) { + if (!state || state.cancelled || state.callers.outcome === 'failed' || confirming.has(state)) { return } - state.callers.outcome = 'pending' - state.visibilityUnknown = false - state.promise = recoverPublishedSessionReceipt(state) - .then((receipt) => publishStructuredLaunchCreateOptions(state, receipt)) - .catch((error: unknown) => { - state.visibilityUnknown = true - throw error - }) - trackLaunchSettlement(state, state.promise) - trackStructuredLaunchConfirmationDeadline(state, state.promise) - notifyStructuredLaunchListeners() + confirming.add(state) + let deadline: ReturnType | undefined + const expired = new Promise((_resolve, reject) => { + deadline = setTimeout(() => reject(new Error('Confirmation timed out.')), STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS) + }) + void Promise.race([recoverPublishedSessionReceipt(state), expired]).then(async () => { + if (state.cancelled || getStructuredLaunchStateBySessionId(sessionId) !== state) { + return + } + if (state.intent.firstSubmission) { + await finalizeStructuredLaunchFirstSubmission(state) + } else { + settleStructuredLaunchCallers(state.callers, 'published') + notifyStructuredLaunchListeners() + } + }).catch(() => { + // An absent or unreadable journal says nothing new about the command's outcome. + }).finally(() => { + clearTimeout(deadline) + confirming.delete(state) + }) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-publication.ts b/src/renderer/src/lib/structured-agent-session-launch-publication.ts index b2328725d550..67f1790c1607 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-publication.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-publication.ts @@ -37,7 +37,9 @@ export function markStructuredAgentSessionLaunchPublished( } if (persisted.firstSubmission) { state = restorePersistedStructuredLaunchState(worktreeId, sessionId) - if (state) state.hostPublished = true + if (state) { + state.hostPublished = true + } notifyStructuredLaunchListeners() } if (persisted.firstMessage || persisted.firstSubmission) { diff --git a/src/renderer/src/lib/structured-agent-session-launch-record.ts b/src/renderer/src/lib/structured-agent-session-launch-record.ts index 6a3209a75322..060e32d31844 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-record.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-record.ts @@ -8,7 +8,7 @@ import { isAgentSessionOptions } from '../../../shared/agent-session-record' import { SendBodyStructure } from '../../../shared/rpc-contract/structured-agent-session-message-params' import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' -import { parseStoredNativeChatComposerDraft, type NativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-storage' +import { parseStoredNativeChatComposerDraft, projectNativeChatComposerDraft, type NativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-storage' export type StructuredAgentFirstSubmission = { clientMessageId: string @@ -16,6 +16,10 @@ export type StructuredAgentFirstSubmission = { options: Readonly> } +export function projectStructuredAgentFirstSubmission(submission: StructuredAgentFirstSubmission): StructuredAgentFirstSubmission { + return { clientMessageId: submission.clientMessageId, draft: projectNativeChatComposerDraft(submission.draft), options: { ...submission.options } } +} + export type StructuredAgentLaunchPersistedLifecycle = 'pending' | 'visibility-unknown' | 'failed' export type StructuredAgentLaunchPersistedRecord = { @@ -57,7 +61,7 @@ export function structuredAgentLaunchRecordFor( expectedRuntimeFence: envelope.expectedRuntimeFence, ...(resumeFrom ? { resumeFrom } : {}), ...(firstMessage ? { firstMessage } : {}), - ...(intent.firstSubmission ? { firstSubmission: intent.firstSubmission } : {}), + ...(intent.firstSubmission ? { firstSubmission: projectStructuredAgentFirstSubmission(intent.firstSubmission) } : {}), ...(options ? { options } : {}), ...(intent.createMessageSupport === undefined ? {} diff --git a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts index 5dd8534d8e73..9240ca83e9b1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts @@ -61,7 +61,9 @@ async function verifyPublishedSession(state: StructuredLaunchRecoveryState): Pro export async function recoverPublishedSessionReceipt( state: StructuredLaunchRecoveryState ): Promise { - if (!state.intent.firstSubmission) await verifyPublishedSession(state) + if (!state.intent.firstSubmission) { + await verifyPublishedSession(state) + } let history = await callStructuredAgentSession( state.intent.target, 'agentSession.history', @@ -76,7 +78,9 @@ export async function recoverPublishedSessionReceipt( let firstMessage = history.page.submissions.find((submission) => submission.clientMessageId === messageId) for (let page = 1; state.intent.firstSubmission && !firstMessage && history.page.hasOlder && page < 8; page += 1) { const cursor = history.page.window.oldest - if (!cursor) break + if (!cursor) { + break + } history = await callStructuredAgentSession(state.intent.target, 'agentSession.history', { sessionId: state.intent.sessionId, direction: 'before', cursor, limit: 200 }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index 7ef50c1eb0d4..ff56f72d2038 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -95,7 +95,9 @@ export function deleteStructuredLaunchStateIfCurrent(state: StructuredLaunchStat if (structuredLaunchesBySessionId.get(state.intent.sessionId) !== state) { return false } - if (state.intent.firstSubmission && !state.cancelled) return false + if (state.intent.firstSubmission && !state.cancelled) { + return false + } structuredLaunchesBySessionId.delete(state.intent.sessionId) deleteStructuredAgentLaunchRecord(state.intent.sessionId) return true @@ -132,7 +134,9 @@ export function launchStateLifecycle( if (state.callers.outcome === 'published') { return 'published' } - if (state.hostPublished) return 'published' + if (state.hostPublished) { + return 'published' + } if (state.visibilityUnknown || state.callers.outcome === 'unknown') { return 'visibility-unknown' } diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 3739822c6a96..1f64ab705986 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -43,9 +43,11 @@ import { trackLaunchSettlement } from './structured-agent-session-launch-outcome-tracking' import { + boundStructuredLaunchReservation, trackStructuredLaunchPromptOutcome, trackStructuredLaunchConfirmationDeadline } from './structured-agent-session-launch-prompt-outcome' +import { confirmStructuredLaunchFirstMessagePublication } from './structured-agent-session-launch-publication-confirmation' import { repeatedStructuredLaunchAttempt } from './structured-agent-session-launch-request' export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt } @@ -109,6 +111,9 @@ function adoptPairedHostSeed( state: StructuredLaunchState, seedOptions: StructuredLaunchState['selection']['seed'] ): void { + if (getStructuredLaunchStateBySessionId(state.intent.sessionId) !== state) { + return + } if (JSON.stringify(seedOptions) === JSON.stringify(state.intent.seedOptions)) { return } @@ -127,6 +132,10 @@ function resetStructuredLaunchCallers(state: StructuredLaunchState): void { } function restartStructuredLaunchState(state: StructuredLaunchState): void { + if (state.intent.firstSubmission) { + confirmStructuredLaunchFirstMessagePublication(state.intent.worktreeId, state.intent.sessionId) + return + } const wasVisibilityUnknown = state.visibilityUnknown if (!wasVisibilityUnknown && !state.intent.firstSubmission) { state.intent = retryStructuredAgentSessionLaunchIntent(state.intent) @@ -140,7 +149,7 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void { state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions) state.promise = publishWithHeldOptions( state, - (wasVisibilityUnknown || state.intent.firstSubmission) ? reconcileUnknownLaunch(state) : launchAndReconcile(state) + wasVisibilityUnknown ? reconcileUnknownLaunch(state) : launchAndReconcile(state) ) trackLaunchSettlement(state, state.promise) trackStructuredLaunchConfirmationDeadline(state, state.promise) @@ -236,7 +245,8 @@ function structuredAgentLaunchState( state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions) callers.onSettled = () => maybeCleanupLaunchState(state) setStructuredLaunchState(state) - state.promise = publishWithHeldOptions(state, launchAndReconcile(state)) + const created = launchAndReconcile(state) + state.promise = publishWithHeldOptions(state, boundStructuredLaunchReservation(state, created, stagedPrompt)) const caller = addStructuredLaunchCaller({ group: state.callers, launchResult: state.promise, diff --git a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts index 4abf9a2d57cf..9d996f95cebb 100644 --- a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts @@ -126,7 +126,9 @@ export function createTabsMoveActions( }) if (moved) { const movedTab = findTabAndWorktree(get().unifiedTabsByWorktree, tabId)?.tab - if (movedTab?.contentType === 'agent-session') updateNativeChatNewDraft(movedTab.entityId, { groupId: targetGroupId }) + if (movedTab?.contentType === 'agent-session') { + updateNativeChatNewDraft(movedTab.entityId, { groupId: targetGroupId }) + } } if (moved && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('tab-splits') From 81df423a06ad40b464ba14e71201e9540a87c111 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:31:27 -0700 Subject: [PATCH 08/27] Adopt committed draft presence after a refused allocation --- .../components/native-chat/native-chat-new-draft.ts | 1 + src/renderer/src/lib/native-chat-local-draft.test.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-new-draft.ts b/src/renderer/src/components/native-chat/native-chat-new-draft.ts index 91d3e951115d..a0ce405af02e 100644 --- a/src/renderer/src/components/native-chat/native-chat-new-draft.ts +++ b/src/renderer/src/components/native-chat/native-chat-new-draft.ts @@ -51,6 +51,7 @@ function adopt(key: string, draft: NativeChatNewDraft | null): void { return } if (draft) { + unsavedAllocations.delete(key) drafts.set(key, draft) } else if (!unsavedAllocations.has(key)) { drafts.delete(key) diff --git a/src/renderer/src/lib/native-chat-local-draft.test.ts b/src/renderer/src/lib/native-chat-local-draft.test.ts index 63ed14145257..1cab6e2f3b20 100644 --- a/src/renderer/src/lib/native-chat-local-draft.test.ts +++ b/src/renderer/src/lib/native-chat-local-draft.test.ts @@ -5,7 +5,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import { openNativeChatLocalDraft } from './native-chat-local-draft' import { newAgentLaunchRequestId } from './agent-launch-request-id' import { allocateNativeChatNewDraft, invalidateNativeChatNewDraft, deleteNativeChatNewDraftsOwnedBy, readNativeChatNewDraft, retireNativeChatNewDraft, updateNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' -import { clearNativeChatComposerDraftsForTests, hydrateNativeChatComposerDrafts, readNativeChatComposerDraft, updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import { clearNativeChatComposerDraftsForTests, hydrateNativeChatComposerDrafts, isNativeChatComposerDraftUnsaved, readNativeChatComposerDraft, updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' import { createMemoryNativeChatComposerDraftStorage, setNativeChatComposerDraftStorageForTests } from '@/components/native-chat/native-chat-composer-draft-storage' const mocks = vi.hoisted(() => { const tabs: Tab[] = []; return { tabs, focus: vi.fn(), call: vi.fn() } }) @@ -123,6 +123,14 @@ it('converges foreign retirement during a pick and adopts a recreated workspace storage.refuseWrites = true allocateNativeChatNewDraft('codex_unsaved', metadata) invalidateNativeChatNewDraft('agent-session-new:codex_unsaved') - await vi.waitFor(() => expect(readNativeChatNewDraft('codex_unsaved')).toEqual(metadata)) + await vi.waitFor(() => expect(isNativeChatComposerDraftUnsaved('agent-session:codex_unsaved')).toBe(true)) + expect(readNativeChatNewDraft('codex_unsaved')).toEqual(metadata) + storage.refuseWrites = false + await storage.write('agent-session-new:codex_unsaved', { ...metadata, options: { model: 'foreign committed row' } }) + invalidateNativeChatNewDraft('agent-session-new:codex_unsaved') + await vi.waitFor(() => expect(readNativeChatNewDraft('codex_unsaved')?.options.model).toBe('foreign committed row')) + await storage.remove(['agent-session-new:codex_unsaved']) + invalidateNativeChatNewDraft('agent-session-new:codex_unsaved') + await vi.waitFor(() => expect(readNativeChatNewDraft('codex_unsaved')).toBeUndefined()) expect(mocks.call).not.toHaveBeenCalled() }) From fa7e1360e2f4eef7fed5ff2f4d37830d13e2500b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:30:31 -0700 Subject: [PATCH 09/27] Simplify first-send recovery and preserve hosted send policy --- .../native-chat/native-chat-new-draft.ts | 13 +- ...structured-agent-session-message-sender.ts | 4 +- .../structured-agent-session-send-runtime.ts | 6 - ...se-native-chat-provisional-launch.test.tsx | 23 +++ .../use-native-chat-provisional-launch.ts | 18 ++- .../lib/launch-structured-agent-session.ts | 26 +-- .../src/lib/native-chat-draft-reload.ts | 30 ++-- .../src/lib/native-chat-first-submission.ts | 31 +--- ...structured-agent-session-launch-callers.ts | 2 +- ...ctured-agent-session-launch-create-call.ts | 14 +- ...gent-session-launch-create-message.test.ts | 118 +++++++------- ...red-agent-session-launch-create-message.ts | 57 +++++-- ...structured-agent-session-launch-options.ts | 16 +- ...d-agent-session-launch-outcome-tracking.ts | 45 +----- ...ctured-agent-session-launch-persistence.ts | 14 +- ...red-agent-session-launch-prompt-outcome.ts | 22 ++- ...ured-agent-session-launch-prompt-return.ts | 31 ---- ...tured-agent-session-launch-prompt-types.ts | 49 ------ .../structured-agent-session-launch-prompt.ts | 148 ++++++++++-------- ...session-launch-publication-confirmation.ts | 20 ++- ...ctured-agent-session-launch-publication.ts | 28 ++-- .../structured-agent-session-launch-record.ts | 23 --- ...tructured-agent-session-launch-recovery.ts | 62 ++++---- ...tructured-agent-session-launch-registry.ts | 9 +- .../structured-agent-session-launch-reload.ts | 1 - .../lib/structured-agent-session-launch.ts | 16 +- 26 files changed, 361 insertions(+), 465 deletions(-) delete mode 100644 src/renderer/src/lib/structured-agent-session-launch-prompt-return.ts delete mode 100644 src/renderer/src/lib/structured-agent-session-launch-prompt-types.ts diff --git a/src/renderer/src/components/native-chat/native-chat-new-draft.ts b/src/renderer/src/components/native-chat/native-chat-new-draft.ts index a0ce405af02e..56c7aa3a9a6f 100644 --- a/src/renderer/src/components/native-chat/native-chat-new-draft.ts +++ b/src/renderer/src/components/native-chat/native-chat-new-draft.ts @@ -7,6 +7,9 @@ import { type NativeChatNewDraft } from './native-chat-composer-draft-storage' import { notifyScope, refusedScopes, scopeListeners } from './native-chat-composer-draft-memory' +import { hostSessionTabIdsByLocalTabForWorktree } from '@/runtime/web-session-tabs-sync/tracking-mappings' +import { structuredAgentSessionFocusOwner, structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection' const PREFIX = 'agent-session-new:' const drafts = new Map() @@ -41,8 +44,12 @@ function notify(key: string): void { notifyScope(`agent-session:${key.slice(PREFIX.length)}`) } -export function reportNativeChatNewDraftSaveFailure(sessionId: string, error: unknown): false { - return failed(nativeChatNewDraftKey(sessionId), error) +/** Publication ownership is already recorded by the host mirror, independently of draft storage. */ +export function nativeChatNewDraftIsHosted(sessionId: string, draft: NativeChatNewDraft): boolean { + const target = structuredAgentSessionTargetForHost(draft.owner.executionHostId) + return target !== null && hostSessionTabIdsByLocalTabForWorktree( + structuredAgentSessionFocusOwner(target).environmentId, draft.owner.workspaceId + ).has(structuredAgentSessionTabId(sessionId)) } function adopt(key: string, draft: NativeChatNewDraft | null): void { @@ -52,8 +59,10 @@ function adopt(key: string, draft: NativeChatNewDraft | null): void { } if (draft) { unsavedAllocations.delete(key) + refusedScopes.delete(key) drafts.set(key, draft) } else if (!unsavedAllocations.has(key)) { + refusedScopes.delete(key) drafts.delete(key) } notify(key) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts index 2698b5e8ee55..4e753f25c2e5 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts @@ -184,13 +184,13 @@ export function reserveStructuredAgentSessionSend( const current = held() if (current) { updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, { - } body: { ...current.body, blocks: [ ...current.body.blocks.filter((block) => block.type !== 'image-ref'), ...attachments.map((image) => ({ type: 'image-ref' as const, path: image.path })) ] }, previewUris: attachments.map((image) => image.previewUri) - }) + }) + } }, outcome, legacy: () => clearTimeout(deadline), diff --git a/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts b/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts index ce0f15765f01..1e6543f7e266 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts @@ -7,7 +7,6 @@ import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' import { readNativeChatComposerDraft, updateNativeChatComposerDraft } from './native-chat-composer-draft-store' -import { readNativeChatNewDraft } from './native-chat-new-draft' import type { AgentSessionWriteNoticePart } from '../../../../shared/agent-session-write-notice-copy' import type { AgentSessionWriteFailure } from '../../../../shared/agent-session-write-failure' import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' @@ -49,11 +48,6 @@ export function finish( runtime.resolve(outcome) } updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, keep ?? null) - if (outcome === 'recorded' && readNativeChatNewDraft(entry.sessionId)) { - void import('@/lib/structured-agent-session-launch-outcome-tracking').then( - ({ finalizeStructuredLaunchConfirmedSubmission }) => finalizeStructuredLaunchConfirmedSubmission(entry.sessionId) - ) - } } /** Puts the text back in the chat's composer; false, reported, when the draft write threw. */ diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx index 0d39b2f1198a..ad80d9a227ea 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx @@ -54,6 +54,9 @@ import { resetStructuredAgentLaunchPersistenceForTests } from '@/lib/structured- import { resetStructuredAgentLaunchRegistryForTests } from '@/lib/structured-agent-session-launch-registry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' import { useStructuredAgentSession } from './use-structured-agent-session' +import { allocateNativeChatNewDraft, readNativeChatNewDraft } from './native-chat-new-draft' +import { createMemoryNativeChatComposerDraftStorage, setNativeChatComposerDraftStorageForTests } from './native-chat-composer-draft-storage' +import { markStructuredAgentSessionLaunchPublished } from '@/lib/structured-agent-session-launch-publication' import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' const LOCAL_TARGET = { kind: 'local' } as const @@ -142,6 +145,26 @@ describe('a chat pane over its own launch', () => { resetHostModelCatalogSnapshotsForTests() }) + it('uses ordinary hosted send policy while metadata retirement fails', async () => { + const storage = createMemoryNativeChatComposerDraftStorage() + setNativeChatComposerDraftStorageForTests(storage) + const sessionId = 'codex_hosted_cleanup' + const metadata = { owner: { workspaceId: 'wt-hosted', executionHostId: 'local' as const }, agent: 'codex' as const, groupId: 'group-1', options: {} } + allocateNativeChatNewDraft(sessionId, metadata) + await waitFor(async () => expect(await storage.read(`agent-session-new:${sessionId}`)).toEqual(metadata)) + storage.refuseWrites = true + markStructuredAgentSessionLaunchPublished('wt-hosted', sessionId, 'local') + const { result, unmount } = renderHook(() => useNativeChatProvisionalLaunch('wt-hosted', sessionId)) + const send = vi.fn(() => 'queued' as const) + expect(result.current.sendThroughLaunch('follow-up', false, send)).toBe('queued') + expect(send).toHaveBeenCalledOnce() + expect(result.current.transportEnabled).toBe(true) + expect(readNativeChatNewDraft(sessionId)).toEqual(metadata) + unmount() + storage.refuseWrites = false + setNativeChatComposerDraftStorageForTests(null) + }) + it('keeps the selection its create seeded when a pick in another chat saves a new one', async () => { saveSelection('gpt-5.5') startStructuredAgentLaunch('wt-first', 'codex', { requestId: 'request-1' }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts index 66237af5171a..04dcc4b56628 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -9,7 +9,7 @@ import { } from '@/lib/structured-agent-session-launch' import { relaunchFailedStructuredAgentSessionWithMessage } from '@/lib/structured-agent-session-launch-message' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { useNativeChatNewDraft } from './native-chat-new-draft' +import { nativeChatNewDraftIsHosted, useNativeChatNewDraft } from './native-chat-new-draft' import { submitNativeChatLocalDraft } from '@/lib/native-chat-first-submission' import type { StructuredAgentSessionAttachment } from '../../../../shared/structured-agent-session-send-mutation' import { structuredLaunchSeedOptions } from '../../../../shared/native-chat-session-option-defaults' @@ -73,7 +73,8 @@ export function useNativeChatProvisionalLaunch( ) { const lifecycle = useStructuredAgentSessionLaunchLifecycle(worktreeId ?? '', sessionId) const draft = useNativeChatNewDraft(sessionId) - const localDraft = draft !== undefined && lifecycle !== 'published' + const hosted = useAppStore(() => draft !== undefined && nativeChatNewDraftIsHosted(sessionId, draft)) + const localDraft = draft !== undefined && lifecycle !== 'published' && !hosted const failure = useStructuredAgentSessionLaunchFailure(worktreeId ?? '', sessionId) const latched = useLatchedLaunchView(sessionId, worktreeId, lifecycle !== null || Boolean(draft)) const launch: StructuredAgentSessionLaunchView | undefined = localDraft && draft ? { @@ -87,16 +88,19 @@ export function useNativeChatProvisionalLaunch( } }, [sessionId, worktreeId]) // While the chat starts it takes no send, so Send stays off and the text stays in the box. - const starting = lifecycle === 'pending' || lifecycle === 'visibility-unknown' + const starting = !hosted && lifecycle !== 'published' && (lifecycle === 'pending' || lifecycle === 'visibility-unknown') const sendThroughLaunch = useCallback( (text: string, withImages: boolean, send: () => boolean | 'queued', submission?: { attachments: readonly StructuredAgentSessionAttachment[]; options: Readonly> }): boolean | 'queued' => { + if (hosted || lifecycle === 'published') { + return send() + } if (starting) { return false } - if (draft) { - return submitNativeChatLocalDraft(sessionId, text, submission?.attachments ?? [], submission?.options ?? {}, lifecycle === 'published') + if (draft && localDraft) { + return submitNativeChatLocalDraft(sessionId, text, submission?.attachments ?? [], submission?.options ?? {}) } if (lifecycle !== 'failed' || !worktreeId) { return send() @@ -108,7 +112,7 @@ export function useNativeChatProvisionalLaunch( relaunchFailedStructuredAgentSessionForMessage(worktreeId, sessionId) return false }, - [draft, lifecycle, sessionId, starting, worktreeId] + [draft, hosted, lifecycle, localDraft, sessionId, starting, worktreeId] ) return { lifecycle, @@ -117,6 +121,6 @@ export function useNativeChatProvisionalLaunch( retry, starting, sendThroughLaunch, - transportEnabled: !localDraft && (lifecycle === null || lifecycle === 'published') + transportEnabled: !localDraft && (hosted || lifecycle === null || lifecycle === 'published') } } diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 2f2c942e83db..f795d42525ae 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -1,5 +1,4 @@ import type { TuiAgent } from '../../../shared/tui-agent' -import type { StructuredAgentFirstSubmission } from './structured-agent-session-launch-record' import { createStructuredAgentSessionId, structuredAgentSessionCreateParams, @@ -45,7 +44,6 @@ export type StructuredAgentSessionLaunchIntent = { target: RuntimeClientTarget agent: TuiAgent params: StructuredAgentSessionCreateParams - firstSubmission?: StructuredAgentFirstSubmission createMessageSupport?: boolean createPrepared?: true prepareCreate?: () => void | (() => void) | Promise void)> @@ -119,13 +117,13 @@ function buildStructuredAgentSessionLaunchIntent( const state = useAppStore.getState() if (requestFocus) { recordWebSessionFocusIntent( + structuredAgentSessionFocusOwner(owner.target), + worktreeId, + `agent-session:${sessionId}`, + undefined, + resolveWebSessionVisibleTabId(state, worktreeId) + ) } - structuredAgentSessionFocusOwner(owner.target), - worktreeId, - `agent-session:${sessionId}`, - undefined, - resolveWebSessionVisibleTabId(state, worktreeId) - ) return { sessionId, worktreeId, @@ -172,26 +170,14 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { firstMessage?: StructuredAgentSessionFirstMessage options?: Readonly> createMessageSupport?: boolean - firstSubmission?: StructuredAgentFirstSubmission }): StructuredAgentSessionLaunchIntent { - const state = useAppStore.getState() const { target } = structuredAgentSessionOwnerTarget(args.worktreeId, args.executionHostId) - if (!args.firstSubmission) { - recordWebSessionFocusIntent( - } - structuredAgentSessionFocusOwner(target), - args.worktreeId, - `agent-session:${args.sessionId}`, - undefined, - resolveWebSessionVisibleTabId(state, args.worktreeId) - ) return { sessionId: args.sessionId, worktreeId: args.worktreeId, executionHostId: args.executionHostId, target, agent: args.agent, - ...(args.firstSubmission ? { firstSubmission: args.firstSubmission } : {}), params: { envelope: { sessionId: args.sessionId, diff --git a/src/renderer/src/lib/native-chat-draft-reload.ts b/src/renderer/src/lib/native-chat-draft-reload.ts index ae86b1ec314c..7adc3fa83f9d 100644 --- a/src/renderer/src/lib/native-chat-draft-reload.ts +++ b/src/renderer/src/lib/native-chat-draft-reload.ts @@ -1,6 +1,6 @@ import { useAppStore } from '@/store' import { nativeChatNewDrafts, retireNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' -import { deleteNativeChatComposerDraft, readNativeChatComposerDraft, updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import { deleteNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' import type { NativeChatNewDraft } from '@/components/native-chat/native-chat-composer-draft-storage' import { findKnownWorktreeById } from '@/store/slices/worktrees/listing/detected-worktree-meta' import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' @@ -10,9 +10,10 @@ import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent- import { fetchFolderWorkspaceCatalogForTarget, getFolderWorkspaceHostId } from '@/store/folder-workspaces/folder-workspace-catalog' import { catalogOwnsHost } from '@/store/slices/project-group-owner-routing' import { openStructuredAgentSessionProvisionalTab } from './structured-agent-session-provisional-tab' -import { getPersistedStructuredAgentLaunchRecord, getStructuredLaunchStateBySessionId } from './structured-agent-session-launch-registry' +import { getStructuredLaunchStateBySessionId } from './structured-agent-session-launch-registry' import { deleteStructuredAgentLaunchRecord } from './structured-agent-session-launch-persistence' -import { confirmStructuredLaunchFirstMessagePublication } from './structured-agent-session-launch-publication-confirmation' +import { markStructuredAgentSessionLaunchPublished } from './structured-agent-session-launch-publication' +import { readStructuredSessionTabInventory } from '@/runtime/structured-session-tab-inventory' async function ownerConfirmedGone(draft: NativeChatNewDraft): Promise { const { workspaceId, executionHostId } = draft.owner @@ -42,11 +43,9 @@ async function ownerConfirmedGone(draft: NativeChatNewDraft): Promise { } export async function reconcileRestoredNativeChatDrafts(): Promise { + const inventories = new Map>() for (const [sessionId, draft] of nativeChatNewDrafts()) { try { - if (getStructuredLaunchStateBySessionId(sessionId)) { - continue - } if (await ownerConfirmedGone(draft)) { if (await retireNativeChatNewDraft(sessionId)) { deleteNativeChatComposerDraft(`agent-session:${sessionId}`) @@ -54,20 +53,21 @@ export async function reconcileRestoredNativeChatDrafts(): Promise { } continue } - const issued = getPersistedStructuredAgentLaunchRecord(sessionId)?.firstSubmission - if (issued) { - const key = `agent-session:${sessionId}` - const current = readNativeChatComposerDraft(key) - if (!current.text && !current.images.length) { - updateNativeChatComposerDraft(key, issued.draft, 'immediate') - } - confirmStructuredLaunchFirstMessagePublication(draft.owner.workspaceId, sessionId) - } else if ((useAppStore.getState().groupsByWorktree[draft.owner.workspaceId] ?? []).some((group) => group.id === draft.groupId)) { + if (!getStructuredLaunchStateBySessionId(sessionId) && (useAppStore.getState().groupsByWorktree[draft.owner.workspaceId] ?? []).some((group) => group.id === draft.groupId)) { openStructuredAgentSessionProvisionalTab({ worktreeId: draft.owner.workspaceId, executionHostId: draft.owner.executionHostId, sessionId, agent: draft.agent, targetGroupId: draft.groupId, activate: false }) } + const hostId = draft.owner.executionHostId + const target = structuredAgentSessionTargetForHost(hostId) + if (target && !inventories.has(hostId)) { + inventories.set(hostId, readStructuredSessionTabInventory(target)) + } + const inventory = await inventories.get(hostId) ?? [] + if (inventory.some((row) => row.worktree === draft.owner.workspaceId && row.tabs.some((tab) => tab.type === 'agent-session' && tab.sessionId === sessionId))) { + markStructuredAgentSessionLaunchPublished(draft.owner.workspaceId, sessionId, draft.owner.executionHostId) + } } catch (error) { console.warn('[native-chat-drafts] the saved chat owner could not be verified', error) } diff --git a/src/renderer/src/lib/native-chat-first-submission.ts b/src/renderer/src/lib/native-chat-first-submission.ts index 7d9d6d443a06..2c4d2b1705e2 100644 --- a/src/renderer/src/lib/native-chat-first-submission.ts +++ b/src/renderer/src/lib/native-chat-first-submission.ts @@ -4,8 +4,7 @@ import { import { readNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' import { stageStructuredLaunchPrompt, - releaseStagedStructuredLaunchPrompt, - deliverStructuredLaunchPromptToExistingSession + releaseStagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt' import { startStructuredAgentLaunch } from './structured-agent-session-launch' import { newAgentLaunchRequestId } from './agent-launch-request-id' @@ -17,28 +16,18 @@ import { } from '@/components/native-chat/native-chat-attachment-upload' import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' import type { StructuredAgentSessionAttachment } from '../../../shared/structured-agent-session-send-mutation' -import { getStructuredLaunchStateBySessionId, getPersistedStructuredAgentLaunchRecord } from './structured-agent-session-launch-registry' -import { confirmStructuredLaunchFirstMessagePublication } from './structured-agent-session-launch-publication-confirmation' /** Uses the existing launch reservation for the complete immutable opening submission. */ export function submitNativeChatLocalDraft( sessionId: string, text: string, attachments: readonly StructuredAgentSessionAttachment[], - options: Readonly>, - hosted = false + options: Readonly> ): boolean { const draft = readNativeChatNewDraft(sessionId) if (!draft) { return false } - const prior = getStructuredLaunchStateBySessionId(sessionId) - const saved = getPersistedStructuredAgentLaunchRecord(sessionId) - const issued = prior?.intent.firstSubmission ?? saved?.firstSubmission - if (!hosted && issued && (prior?.callers.outcome ?? saved?.lifecycle) !== 'failed') { - confirmStructuredLaunchFirstMessagePublication(draft.owner.workspaceId, sessionId) - return false - } const sourceDraft = readNativeChatComposerDraft(`agent-session:${sessionId}`) const stagedPrompt = stageStructuredLaunchPrompt(sessionId, text, { attachments, options, sourceDraft }) if (!stagedPrompt.slot) { @@ -55,7 +44,7 @@ export function submitNativeChatLocalDraft( if (owner.kind !== 'runtime-session') { throw new Error('The chat host is unavailable.') } - const prepared = await prepareNativeChatSessionAttachmentUpload(owner, hosted ? undefined : { + const prepared = await prepareNativeChatSessionAttachmentUpload(owner, { worktree: toRuntimeWorktreeSelector(draft.owner.workspaceId), agent: draft.agent }) if (!prepared.ok) { @@ -75,28 +64,18 @@ export function submitNativeChatLocalDraft( return { ...image, path } })) } - if (hosted || prepared.target.reservedSession) { + if (prepared.target.reservedSession) { await upload() } else { stagedPrompt.beforeSend = upload } } } - if (hosted) { - const target = structuredAgentSessionTargetForHost(draft.owner.executionHostId) - if (!target) { - releaseStagedStructuredLaunchPrompt(stagedPrompt) - return false - } - stagedPrompt.beforeSend = prepare - void deliverStructuredLaunchPromptToExistingSession(stagedPrompt, target) - return true - } stagedPrompt.beforeCreate = prepare try { const launch = startStructuredAgentLaunch(draft.owner.workspaceId, draft.agent, { requestId: newAgentLaunchRequestId(), executionHostId: draft.owner.executionHostId, - reservedSessionId: sessionId, stagedPrompt, prompt: text, firstSubmissionOptions: options + reservedSessionId: sessionId, stagedPrompt, prompt: text, openingOptions: options }) void launch.launchResult.catch(() => {}) return true diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index 63b42d9f721b..3deca7dc4787 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -12,7 +12,7 @@ import type { AgentLaunchRequestId } from './agent-launch-request-id' export type StructuredAgentLaunchOptions = { reservedSessionId?: string stagedPrompt?: StagedStructuredLaunchPrompt - firstSubmissionOptions?: Readonly> + openingOptions?: Readonly> /** The user action this start serves; only a re-delivery of it joins its chat. */ requestId: AgentLaunchRequestId prompt?: string diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts index af780361a983..8393852d8329 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts @@ -81,13 +81,14 @@ export type StructuredLaunchHostSeedListener = (seedOptions: LaunchSeed) => void export async function launchStructuredAgentSession( intent: StructuredAgentSessionLaunchIntent, onHostSeed?: StructuredLaunchHostSeedListener, - priorCreateOutcomeUnknown = false + priorCreateOutcomeUnknown = false, + reservedSubmission = false ): Promise { let hostSeed: LaunchSeed try { hostSeed = await requireHostCreateSupport(intent) } catch (error) { - if (!intent.firstSubmission || error instanceof StructuredAgentSessionCreateRefusalError) { + if (!reservedSubmission || error instanceof StructuredAgentSessionCreateRefusalError) { throw error } throw new StructuredAgentSessionCreateRefusalError( @@ -115,17 +116,14 @@ export async function launchStructuredAgentSession( if (error instanceof StructuredAgentSessionSubmissionEndedError) { throw error } - if (intent.firstSubmission) { - throw new StructuredAgentSessionCreateRefusalError( - } + throw new StructuredAgentSessionCreateRefusalError( error instanceof Error ? error.message : 'The message could not be prepared.', 'agent_session_attachment_unavailable' ) - throw error } } } - if (intent.firstSubmission && intent.target.kind === 'environment') { + if (reservedSubmission && intent.target.kind === 'environment') { try { await ensureRuntimeEnvironmentCompatible(intent.target.environmentId) } catch (error) { @@ -139,7 +137,7 @@ export async function launchStructuredAgentSession( try { issue?.() intent.createPrepared = true - result = intent.firstSubmission && intent.target.kind === 'environment' + result = reservedSubmission && intent.target.kind === 'environment' ? await callStructuredAgentSession>( intent.target, 'agentSession.create', intent.params, { skipCompatibilityCheck: true } ) diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index 407deed4c836..d06d7b540216 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -51,7 +51,7 @@ import { } from './structured-agent-session-launch' import { holdStructuredAgentSessionLaunchOption } from './structured-agent-session-launch-options' import { recordHostModelCatalogSnapshot } from '@/runtime/host-model-catalog-snapshots' -import { resetStructuredAgentLaunchRegistryForTests, getStructuredLaunchStateBySessionId } from './structured-agent-session-launch-registry' +import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry' import { resetStructuredAgentLaunchPersistenceForTests, readStructuredAgentLaunchRecord @@ -59,7 +59,7 @@ import { import { confirmStructuredLaunchFirstMessagePublication } from './structured-agent-session-launch-publication-confirmation' import { createMemoryNativeChatComposerDraftStorage, setNativeChatComposerDraftStorageForTests } from '@/components/native-chat/native-chat-composer-draft-storage' import { submitNativeChatLocalDraft } from './native-chat-first-submission' -import { allocateNativeChatNewDraft, readNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' +import { allocateNativeChatNewDraft, nativeChatNewDraftIsHosted, readNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' import { newAgentLaunchRequestId } from './agent-launch-request-id' import { markStructuredAgentSessionLaunchPublished } from './structured-agent-session-launch-publication' import { StructuredAgentSessionSubmissionEndedError, StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' @@ -76,7 +76,7 @@ import { readNativeChatDraftCache, writeNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache' -import { structuredAgentSessionDraftScopeKey, updateNativeChatComposerDraft, readNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import { clearNativeChatComposerDraftsForTests, isNativeChatComposerDraftUnsaved, structuredAgentSessionDraftScopeKey, updateNativeChatComposerDraft, readNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' import { stageStructuredLaunchPrompt, discardStructuredLaunchPrompts } from './structured-agent-session-launch-prompt' function deferred() { @@ -150,6 +150,8 @@ describe('the first message owned by create', () => { resetStructuredAgentSessionSendsForTests() resetStructuredAgentSessionReadOwnersForTests() clearNativeChatDraftCacheForTests() + clearNativeChatComposerDraftsForTests() + setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) mocks.supports.mockResolvedValue(true) mocks.admit.mockResolvedValue({ kind: 'admitted' }) mocks.inventory.mockRejectedValue(new Error('an extra inventory read must not gate create')) @@ -160,7 +162,7 @@ describe('the first message owned by create', () => { vi.useRealTimers() }) - it.each(['held', 'closed', 'releasedAfterUpload', 'expiredDiscovery', 'legacyClosed', 'legacySuperseded'] as const)( + it.each(['held', 'closed', 'releasedAfterUpload', 'expiredDiscovery', 'legacyClosed', 'legacyFailed'] as const)( 'keeps the original first submission authoritative through preparation: %s', async (scenario) => { const source = { text: 'opening with images', document: { type: 'doc' }, images: [ { id: 'one', path: '/source/one.png' }, { id: 'two', path: '/source/two.png' } @@ -192,10 +194,10 @@ describe('the first message owned by create', () => { } const launch = startStructuredAgentLaunch('workspace-1', 'codex', { requestId: newAgentLaunchRequestId(), executionHostId: 'local', reservedSessionId: sessionId, - stagedPrompt: prompt, prompt: source.text, firstSubmissionOptions: legacy ? {} : { model: 'picked' } + stagedPrompt: prompt, prompt: source.text, openingOptions: legacy ? {} : { model: 'picked' } }) const result = launch.launchResult.catch((error: unknown) => error) - expect(readStructuredAgentLaunchRecord(sessionId)?.firstSubmission?.draft).toEqual(source) + expect(readStructuredAgentLaunchRecord(sessionId)).toBeUndefined() expect(reserveStructuredAgentSessionSend({ sessionId, text: 'duplicate' })).toBeNull() if (scenario === 'expiredDiscovery') { await vi.advanceTimersByTimeAsync(30_000) @@ -222,11 +224,12 @@ describe('the first message owned by create', () => { if (scenario === 'held') { await vi.advanceTimersByTimeAsync(45_000) expect(mocks.call).not.toHaveBeenCalled() + expect(readStructuredAgentLaunchRecord(sessionId)).toBeUndefined() expect(readNativeChatComposerDraft(scope).text).toBe('') } else if (scenario === 'releasedAfterUpload') { upload.resolve() queueMicrotask(() => discardStructuredLaunchPrompts(sessionId)) - } else if (scenario === 'legacySuperseded') { + } else if (scenario === 'legacyFailed') { upload.reject(new Error('upload failed')) } else { discardStructuredLaunchPrompts(sessionId) @@ -236,6 +239,7 @@ describe('the first message owned by create', () => { if (scenario === 'held') { await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) const params = createParams() + expect(readStructuredAgentLaunchRecord(sessionId)?.firstMessage).toEqual(params.firstMessage) expect(params.firstMessage).toEqual({ clientMessageId: prompt.slot?.clientMessageId, body: { kind: 'message', role: 'user', blocks: [ { type: 'text', text: source.text }, { type: 'image-ref', path: '/stored/one.png' }, { type: 'image-ref', path: '/stored/two.png' } @@ -256,22 +260,9 @@ describe('the first message owned by create', () => { await result await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: false }) expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) - if (scenario === 'legacySuperseded') { + if (scenario === 'legacyFailed') { expect(readNativeChatComposerDraft(scope)).toMatchObject(source) - resetStructuredAgentLaunchRegistryForTests() - resetStructuredAgentLaunchPersistenceForTests() - updateNativeChatComposerDraft(scope, { text: 'newer edit' }, 'immediate') - mocks.call.mockImplementation(async (_target, method, params) => { - expect(method).toBe('agentSession.send') - const sent = SendParams.parse(params) - return { ok: true, value: { clientMessageId: sent.envelope.clientOperationId, - submission: submission(sent.envelope.clientOperationId) } } - }) - expect(submitNativeChatLocalDraft(sessionId, 'later explicit send', [], {}, true)).toBe(true) - await vi.waitFor(() => expect(readNativeChatNewDraft(sessionId)).toBeUndefined()) - expect(readStructuredAgentLaunchRecord(sessionId)).toBeUndefined() - expect(readNativeChatComposerDraft(scope).text).toBe('newer edit') - expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create', 'agentSession.send']) + } } } @@ -296,60 +287,60 @@ describe('the first message owned by create', () => { expect(getStructuredAgentSessionSendNotice(sessionId)).toContain('not sent') const failed = readStructuredAgentLaunchRecord(sessionId) expect(failed?.lifecycle).toBe('failed') - if (!failed?.firstSubmission) { - throw new Error('missing failed snapshot') - } - localStorage.setItem('orca:structuredAgentLaunches:v1', JSON.stringify([{ ...failed, firstSubmission: { ...failed.firstSubmission, draft: { ...source, savedAt: 123, unsavedText: 'extra' } } }])) + expect(failed?.firstMessage).toEqual(createParams().firstMessage) resetStructuredAgentLaunchRegistryForTests() resetStructuredAgentLaunchPersistenceForTests() - expect(readStructuredAgentLaunchRecord(sessionId)?.firstSubmission?.draft).toEqual(source) confirmStructuredLaunchFirstMessagePublication('workspace-1', sessionId) expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', sessionId)).toBe('failed') expect(mocks.call).toHaveBeenCalledOnce() expect(submitNativeChatLocalDraft(sessionId, 'new explicit opening', [], {})).toBe(true) - expect(getStructuredLaunchStateBySessionId(sessionId)?.intent.firstSubmission?.clientMessageId).not.toBe(prompt.slot?.clientMessageId) + expect(getStructuredAgentSessionPendingSends(sessionId)[0]?.clientMessageId).not.toBe(prompt.slot?.clientMessageId) }) - it('confirms a closed-tab opening through paged history and retains evidence until metadata removal commits', async () => { + it('re-derives metadata retirement after repeated refusal without launch evidence', async () => { const storage = createMemoryNativeChatComposerDraftStorage() setNativeChatComposerDraftStorageForTests(storage) - const sessionId = 'codex_paged_reload' - const source = { text: 'original', images: [] } - allocateNativeChatNewDraft(sessionId, { owner: { workspaceId: 'workspace-1', executionHostId: 'local' }, agent: 'codex', groupId: 'group-1', options: {} }) - const prompt = stageStructuredLaunchPrompt(sessionId, source.text, { sourceDraft: source }) - mocks.call.mockReturnValue(new Promise(() => {})) - const launch = startStructuredAgentLaunch('workspace-1', 'codex', { - requestId: newAgentLaunchRequestId(), executionHostId: 'local', reservedSessionId: sessionId, - stagedPrompt: prompt, prompt: source.text - }) - await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - const response = created() - const recorded = response.value.page.submissions - await vi.advanceTimersByTimeAsync(30_000) - await launch.promptDeliveryResult - resetStructuredAgentLaunchRegistryForTests() - resetStructuredAgentLaunchPersistenceForTests() + const sessionId = 'codex_published_metadata' + const source = { text: 'newer typing', images: [] } + const metadata = { owner: { workspaceId: 'workspace-1', executionHostId: 'local' as const }, agent: 'codex' as const, groupId: 'group-1', options: {} } + allocateNativeChatNewDraft(sessionId, metadata) + await vi.waitFor(async () => expect(await storage.read(`agent-session-new:${sessionId}`)).toEqual(metadata)) + updateNativeChatComposerDraft(structuredAgentSessionDraftScopeKey(sessionId), source, 'immediate') storage.refuseWrites = true - let page = 0 - mocks.call.mockImplementation(async (_target, method) => { - expect(method).toBe('agentSession.history') - page += 1 - return { ok: true, page: { ...response.value.page, submissions: page % 2 ? [] : recorded, - hasOlder: page % 2 === 1, window: { ...response.value.page.window, oldest: { epoch: 'epoch-1', sequence: 1 } } } } - }) - confirmStructuredLaunchFirstMessagePublication('workspace-1', sessionId) - await vi.waitFor(() => expect(page).toBe(2)) - await vi.advanceTimersByTimeAsync(0) - expect(readNativeChatNewDraft(sessionId)).toBeDefined() - expect(readStructuredAgentLaunchRecord(sessionId)?.firstSubmission).toBeDefined() + for (let attempt = 0; attempt < 2; attempt += 1) { + markStructuredAgentSessionLaunchPublished('workspace-1', sessionId, 'local') + await vi.waitFor(() => expect(isNativeChatComposerDraftUnsaved(structuredAgentSessionDraftScopeKey(sessionId))).toBe(true)) + expect(nativeChatNewDraftIsHosted(sessionId, metadata)).toBe(true) + expect(readNativeChatNewDraft(sessionId)).toEqual(metadata) + } storage.refuseWrites = false - updateNativeChatComposerDraft(structuredAgentSessionDraftScopeKey(sessionId), { text: 'newer typing' }, 'immediate') - confirmStructuredLaunchFirstMessagePublication('workspace-1', sessionId) + markStructuredAgentSessionLaunchPublished('workspace-1', sessionId, 'local') await vi.waitFor(() => expect(readNativeChatNewDraft(sessionId)).toBeUndefined()) expect(readStructuredAgentLaunchRecord(sessionId)).toBeUndefined() - expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey(sessionId)).text).toBe('newer typing') - expect(mocks.inventory).not.toHaveBeenCalled() - expect(mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.create')).toHaveLength(1) + expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey(sessionId))).toMatchObject(source) + expect(mocks.call).not.toHaveBeenCalled() + }) + + it('drains a post-create pick once when journal confirmation precedes the original reply', async () => { + const create = deferred() + const option = deferred() + mocks.call.mockReturnValue(create.promise) + const launch = start() + await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const response = created() + const picked = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'later-pick') + await vi.advanceTimersByTimeAsync(30_000) + await launch.promptDeliveryResult + mocks.inventory.mockResolvedValue([{ worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] }]) + mocks.call.mockImplementation((_target, method) => method === 'agentSession.history' + ? Promise.resolve({ ok: true, page: response.value.page }) : option.promise) + confirmStructuredLaunchFirstMessagePublication('workspace-1', launch.sessionId) + await vi.waitFor(() => expect(mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.setOption')).toHaveLength(1)) + create.resolve(response) + await launch.launchResult + expect(mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.setOption')).toHaveLength(1) + option.resolve({ ok: true, value: { options: { model: 'later-pick' } } }) + await expect(picked).resolves.toMatchObject({ kind: 'accepted' }) }) it('freezes the message and held options in create and publishes without another read or send', async () => { @@ -630,6 +621,7 @@ describe('the first message owned by create', () => { : { ...response, replayed: true, value: { ...response.value, firstMessage: receipt } } ) markStructuredAgentSessionLaunchPublished('workspace-1', launch.sessionId, 'local') + expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true) await vi.waitFor(() => expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined() ) @@ -692,7 +684,7 @@ describe('the first message owned by create', () => { await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) await vi.advanceTimersByTimeAsync(30_000) expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', launch.sessionId)).toBe( - 'visibility-unknown' + 'published' ) expect(draft(launch.sessionId)).toBe('opening text') mocks.inventory.mockResolvedValue([]) diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts index 1fb45cbfbbfc..e25d3a9970be 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -1,9 +1,9 @@ +import { StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' import { structuredAgentSessionCreateFingerprint } from '../../../shared/structured-agent-session-mutation' import type { StructuredLaunchState } from './structured-agent-session-launch-registry' import { notifyStructuredLaunchListeners } from './structured-agent-session-launch-registry' import { prepareStructuredLaunchPromptForCreate, - prepareStructuredLaunchFirstSubmission, prepareLegacyStructuredLaunchPrompts } from './structured-agent-session-launch-prompt' import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' @@ -24,19 +24,30 @@ export async function prepareStructuredLaunchCreateMessage(state: StructuredLaun if (intent.createPrepared) { return } - const opening = intent.firstSubmission - const takeMessage = opening ? await prepareStructuredLaunchFirstSubmission( - intent.sessionId, opening.clientMessageId, intent.target, intent.createMessageSupport === true - ) : null + const attempt = state.callers.attempt + const prompt = attempt.kind === 'first' ? attempt.stagedPrompt : null + if (prompt?.sourceDraft) { + if (!prompt.slot?.prepare()) { + throw new StructuredAgentSessionSubmissionEndedError() + } + await prompt.beforeCreate?.() + } return () => { - const reservedMessage = takeMessage?.() + if (prompt?.sourceDraft && !prompt.slot?.prepare()) { + throw new StructuredAgentSessionSubmissionEndedError() + } if (intent.createMessageSupport !== true) { prepareLegacyStructuredLaunchPrompts(intent.sessionId) intent.createPrepared = true notifyStructuredLaunchListeners() return } - const firstMessage = takeMessage ? reservedMessage : prepareStructuredLaunchPromptForCreate(intent.sessionId, intent.target) + const firstMessage = prompt?.beforeSend ? undefined : prepareStructuredLaunchPromptForCreate( + intent.sessionId, intent.target, prompt?.sourceDraft ? prompt : undefined + ) + if (prompt?.sourceDraft && !prompt.beforeSend && !firstMessage) { + throw new StructuredAgentSessionSubmissionEndedError() + } const options = structuredLaunchCreateOptions(state) const params = { ...intent.params, @@ -64,8 +75,9 @@ function structuredLaunchCreateOptions( state: StructuredLaunchState ): Readonly> | undefined { const { intent, selection } = state - if (intent.firstSubmission) { - return intent.firstSubmission.options + const attempt = state.callers.attempt + if (attempt.kind === 'first' && attempt.stagedPrompt?.sourceDraft) { + return attempt.stagedPrompt.options } const firstFrame = firstFrameOptionState(intent.agent, intent.target, { newLaunch: !intent.params.resumeFrom, @@ -87,16 +99,27 @@ function structuredLaunchCreateOptions( } /** Later picks use the normal option path without holding publication behind startup. */ +const optionPublications = new WeakMap>() + export function publishStructuredLaunchCreateOptions( state: StructuredLaunchState, receipt: StructuredAgentLaunchReceipt -): StructuredAgentLaunchReceipt | Promise { - if (state.intent.createMessageSupport !== true) { - return applyStructuredLaunchHeldOptions(state, receipt) - } - settleStructuredLaunchCreateOptions(state, state.intent.params.options ?? {}) - void applyStructuredLaunchHeldOptions(state, receipt).catch((error: unknown) => { - console.warn('[native-chat] applying a launch option failed', error) +): Promise { + const existing = optionPublications.get(state) + const publication = existing ?? Promise.resolve().then(() => { + if (state.intent.createMessageSupport === true) { + settleStructuredLaunchCreateOptions(state, state.intent.params.options ?? {}) + } + return receipt + }) + optionPublications.set(state, publication) + return publication.then(() => { + if (state.intent.createMessageSupport !== true) { + return applyStructuredLaunchHeldOptions(state, receipt) + } + void applyStructuredLaunchHeldOptions(state, receipt).catch((error: unknown) => { + console.warn('[native-chat] applying a launch option failed', error) + }) + return receipt }) - return receipt } diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 0b18e0c253ce..cc9caf440408 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -45,6 +45,7 @@ export type StructuredLaunchOptionOutcome = type OptionReply = (outcome: StructuredLaunchOptionOutcome) => void const optionReplies = new WeakMap>() +const optionDrains = new WeakMap>() function repliesFor(state: StructuredLaunchState): Map { let replies = optionReplies.get(state) @@ -163,7 +164,20 @@ export function settleStructuredLaunchCreateOptions( * effort lands under the model it was picked against, until none is left. A refused pick is * reported to its picker and never keeps the launch from publishing. */ -export async function applyStructuredLaunchHeldOptions( +export function applyStructuredLaunchHeldOptions( + state: StructuredLaunchState, + receipt: StructuredAgentLaunchReceipt +): Promise { + const draining = optionDrains.get(state) + if (draining) { + return draining + } + const drain = drainStructuredLaunchHeldOptions(state, receipt).finally(() => optionDrains.delete(state)) + optionDrains.set(state, drain) + return drain +} + +async function drainStructuredLaunchHeldOptions( state: StructuredLaunchState, receipt: StructuredAgentLaunchReceipt ): Promise { diff --git a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts index d21adb3bde88..dd5d9f4ad095 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts @@ -1,13 +1,12 @@ import { StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' -import { readNativeChatNewDraft, retireNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' -import { clearNativeChatComposerDraftIfUnchanged } from '@/components/native-chat/native-chat-composer-draft-store' import { settleStructuredLaunchCallers, structuredLaunchCallersHavePendingWork } from '@/lib/structured-agent-session-launch-callers' import type { StructuredAgentLaunchReceipt } from '@/lib/structured-agent-session-launch-recovery' import { structuredLaunchFailure } from './structured-agent-session-launch-failure' +import { markStructuredAgentSessionLaunchPublished } from './structured-agent-session-launch-publication' import { getStructuredLaunchStateBySessionId, deleteStructuredLaunchStateIfCurrent, @@ -16,7 +15,6 @@ import { type StructuredLaunchState } from './structured-agent-session-launch-registry' -import { restorePersistedStructuredLaunchState } from './structured-agent-session-launch-reload' // How a launch's create outcome settles its callers: published, refused, failed or unknown. @@ -26,42 +24,8 @@ function cleanupLaunchState(state: StructuredLaunchState): void { } } -const finalizing = new WeakMap>() - -export async function finalizeStructuredLaunchConfirmedSubmission(sessionId: string): Promise { - const draft = readNativeChatNewDraft(sessionId) - const state = getStructuredLaunchStateBySessionId(sessionId) ?? - (draft ? restorePersistedStructuredLaunchState(draft.owner.workspaceId, sessionId) : undefined) - if (state?.intent.firstSubmission && !state.cancelled) { - await finalizeStructuredLaunchFirstSubmission(state) - } -} - -export function finalizeStructuredLaunchFirstSubmission(state: StructuredLaunchState): Promise { - const active = finalizing.get(state) - if (active) { - return active - } - const finalized = finalizeFirstSubmission(state).finally(() => finalizing.delete(state)) - finalizing.set(state, finalized) - return finalized -} - -async function finalizeFirstSubmission(state: StructuredLaunchState): Promise { - const submission = state.intent.firstSubmission - if (!submission || !(await retireNativeChatNewDraft(state.intent.sessionId))) { - return - } - clearNativeChatComposerDraftIfUnchanged(`agent-session:${state.intent.sessionId}`, submission.draft) - delete state.intent.firstSubmission - settleStructuredLaunchCallers(state.callers, 'published') - maybeCleanupLaunchState(state) - notifyStructuredLaunchListeners() -} - export function maybeCleanupLaunchState(state: StructuredLaunchState): void { if ( - state.intent.firstSubmission || state.callers.outcome === 'failed' || structuredLaunchCallersHavePendingWork(state.callers) || Object.keys(state.selection.held).length > 0 @@ -88,14 +52,12 @@ export function trackLaunchSettlement( promise: Promise ): void { void promise.then( - (receipt) => { + () => { if (state.promise !== promise || getStructuredLaunchStateBySessionId(state.intent.sessionId) !== state) { return } + markStructuredAgentSessionLaunchPublished(state.intent.worktreeId, state.intent.sessionId, state.intent.executionHostId) settleStructuredLaunchCallers(state.callers, 'published') - if (receipt.firstMessage?.clientMessageId === state.intent.firstSubmission?.clientMessageId) { - void finalizeStructuredLaunchFirstSubmission(state) - } notifyStructuredLaunchListeners() }, (error) => { @@ -115,7 +77,6 @@ export function trackLaunchSettlement( state.cancelled = true state.visibilityUnknown = false settleStructuredLaunchCallers(state.callers, 'failed') - delete state.intent.firstSubmission cleanupLaunchState(state) return } diff --git a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts index bad7d9d75b07..ccd9298f380c 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-persistence.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-persistence.ts @@ -1,7 +1,5 @@ -import { reportNativeChatNewDraftSaveFailure } from '@/components/native-chat/native-chat-new-draft' import { validRecord, - projectStructuredAgentFirstSubmission, type StructuredAgentLaunchPersistedRecord } from './structured-agent-session-launch-record' export { @@ -42,10 +40,9 @@ function load(): void { const seedOptions = parseStructuredLaunchSeedOptions( 'seedOptions' in value ? value.seedOptions : undefined ) - const { seedOptions: _stored, firstSubmission, ...rest } = value + const { seedOptions: _stored, ...rest } = value records.set(value.sessionId, { ...rest, - ...(firstSubmission ? { firstSubmission: projectStructuredAgentFirstSubmission(firstSubmission) } : {}), ...(seedOptions ? { seedOptions } : {}), executionHostId: parseExecutionHostId(value.executionHostId)?.id ?? LOCAL_EXECUTION_HOST_ID, @@ -118,12 +115,7 @@ function writeRecords(): void { } else { localStorage.setItem(LAUNCH_STORAGE_KEY, JSON.stringify([...records.values()])) } - } catch (error) { - for (const record of records.values()) { - if (record.firstSubmission) { - reportNativeChatNewDraftSaveFailure(record.sessionId, error) - } - } + } catch { // Why: persistence is recovery bookkeeping and must never block a launch. console.warn('[structured-agent-launch] could not persist launch state') } @@ -163,7 +155,7 @@ export function writeStructuredAgentLaunchRecord( record: StructuredAgentLaunchPersistedRecord ): void { load() - records.set(record.sessionId, { ...record, ...(record.firstSubmission ? { firstSubmission: projectStructuredAgentFirstSubmission(record.firstSubmission) } : {}) }) + records.set(record.sessionId, record) writeRecords() } diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts index cf24ea110de6..6d71f406de6f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts @@ -1,8 +1,8 @@ +import { handBackStagedPrompt, releaseStagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt' import { StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' -import type { StagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt-types' +import type { StagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt' import type { StructuredLaunchCaller } from './structured-agent-session-launch-callers' import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' -import { finalizeStructuredLaunchFirstSubmission } from './structured-agent-session-launch-outcome-tracking' import { STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS } from '@/components/native-chat/structured-agent-session-message-sender' import { notifyStructuredLaunchListeners, @@ -16,7 +16,7 @@ export function boundStructuredLaunchReservation( prompt: StagedStructuredLaunchPrompt | null ): Promise { const { intent } = state - if (!intent.firstSubmission || !prompt?.slot) { + if (!prompt?.sourceDraft || !prompt.slot) { return created } return Promise.race([created, prompt.slot.outcome.then(() => { @@ -35,9 +35,6 @@ export function trackStructuredLaunchPromptOutcome( ): void { const group = state.callers void caller.promptDeliveryResult?.then((outcome) => { - if (outcome.delivered && state.intent.firstSubmission) { - void finalizeStructuredLaunchFirstSubmission(state) - } if ( state.callers !== group || state.cancelled || @@ -76,3 +73,16 @@ export function trackStructuredLaunchConfirmationDeadline( const clear = (): void => clearTimeout(deadline) void promise.then(clear, clear) } + +export function failStagedStructuredLaunchPrompt(prompt: StagedStructuredLaunchPrompt): void { + if (prompt.discarded) { + return + } + if (prompt.slot) { + prompt.slot.release(['notDoneSend']) + } else { + handBackStagedPrompt(prompt) + } + releaseStagedStructuredLaunchPrompt(prompt) + prompt.settleCreate({ delivered: false, inComposer: true }) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-return.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-return.ts deleted file mode 100644 index 197c5ff09b58..000000000000 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-return.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { structuredAgentSessionSendBody } from '../../../shared/structured-agent-session-send-mutation' -import { handBackStructuredAgentSessionMessage } from '@/components/native-chat/structured-agent-session-message-hand-back' -import { publishStructuredAgentSessionSends } from '@/components/native-chat/structured-agent-session-pending-sends' -import { agentSessionWriteNoticeText } from '@/components/native-chat/agent-session-write-notice-text' -import type { StagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt' - -/** Puts a launch's text in its chat's composer, unless its sender keeps it. True when it did; a - * draft write that throws is reported in the chat instead, and never stops its caller. */ -export function handBackStagedPrompt(prompt: StagedStructuredLaunchPrompt): boolean { - if (prompt.callerKeepsText || prompt.discarded) { - return false - } - try { - handBackStructuredAgentSessionMessage( - prompt.sessionId, - `launch-${prompt.sessionId}`, - structuredAgentSessionSendBody(prompt.text, []) - ) - return true - } catch (error) { - console.error( - '[native-chat-send] a launch message could not be put back in the composer', - error - ) - publishStructuredAgentSessionSends(prompt.sessionId, { - notice: agentSessionWriteNoticeText(['messageNotSaved']) - }) - return false - } -} - diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-types.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-types.ts deleted file mode 100644 index f74b30502c39..000000000000 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-types.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { NativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-storage' -import type { StructuredAgentSessionReservedSend, StructuredAgentSessionCreateSend } from '@/components/native-chat/structured-agent-session-message-sender' - -export type StructuredPromptDeliveryResult = { - delivered: boolean - failureNotified: boolean - /** Not sent, and waiting in the new chat's composer, which now owns the text. */ - inComposer?: true - /** Nobody could confirm it went: the host may hold it. */ - unconfirmed?: true - /** Another send of the chat held its one slot, so this one never went. */ - busy?: true -} - -export type StagedDelivery = Omit & { - inComposer: boolean -} - -export type StructuredLaunchPromptOptions = { - prompt?: string - promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' - onPromptDelivered?: () => void -} - -/** A launch's text, held in memory from the click until its chat exists, then sent once: every - * caller waiting on it shares the one send. From the click it holds the chat's one send slot, drawn - * as sending, so nothing typed meanwhile goes out ahead of it. */ -export type StagedStructuredLaunchPrompt = { - sessionId: string - text: string - slot: StructuredAgentSessionReservedSend | null - sourceDraft?: NativeChatComposerDraft - beforeCreate?: () => Promise - beforeSend?: () => Promise - /** Its sender keeps the text if it comes back (notes), instead of the chat's composer. */ - callerKeepsText?: true - delivery?: Promise - /** Create owns this prompt; it must never become a later send. */ - createOwned?: true - createSend?: StructuredAgentSessionCreateSend - whenCreateSettled: Promise - settleCreate: (outcome: StagedDelivery) => void - /** Its launch was cancelled: never sent, never given back. */ - discarded?: true - /** Settles once it is discarded, so its callers need not wait on a create that may never end. */ - whenDiscarded: Promise - discard: () => void -} - diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index b9a9acc89359..07ea0be3f8d5 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -1,20 +1,99 @@ -import { handBackStagedPrompt } from './structured-agent-session-launch-prompt-return' +import { structuredAgentSessionSendBody } from '../../../shared/structured-agent-session-send-mutation' +import { handBackStructuredAgentSessionMessage } from '@/components/native-chat/structured-agent-session-message-hand-back' +import { publishStructuredAgentSessionSends } from '@/components/native-chat/structured-agent-session-pending-sends' +import { agentSessionWriteNoticeText } from '@/components/native-chat/agent-session-write-notice-text' + +import { failStagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt-outcome' import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create' -import { StructuredAgentSessionCreateRefusalError, StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' +import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' import { agentSessionThrownFailure } from '../../../shared/agent-session-write-failure' import { dropStructuredAgentSessionSends, reserveStructuredAgentSessionSend, - sendStructuredAgentSessionMessage + sendStructuredAgentSessionMessage, + type StructuredAgentSessionReservedSend, + type StructuredAgentSessionCreateSend } from '@/components/native-chat/structured-agent-session-message-sender' import { noteStructuredAgentSessionFence } from '@/components/native-chat/structured-agent-session-send-attempt' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import type { StructuredAgentSessionSendInput } from '@/components/native-chat/structured-agent-session-send-slot' import { projectNativeChatComposerDraft, type NativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-storage' -import type { StructuredPromptDeliveryResult, StructuredLaunchPromptOptions, StagedDelivery, StagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt-types' -export type { StructuredPromptDeliveryResult, StructuredLaunchPromptOptions, StagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt-types' +export type StructuredPromptDeliveryResult = { + delivered: boolean + failureNotified: boolean + /** Not sent, and waiting in the new chat's composer, which now owns the text. */ + inComposer?: true + /** Nobody could confirm it went: the host may hold it. */ + unconfirmed?: true + /** Another send of the chat held its one slot, so this one never went. */ + busy?: true +} + +export type StagedDelivery = Omit & { + inComposer: boolean +} + +export type StructuredLaunchPromptOptions = { + prompt?: string + promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' + onPromptDelivered?: () => void +} + +/** A launch's text, held in memory from the click until its chat exists, then sent once: every + * caller waiting on it shares the one send. From the click it holds the chat's one send slot, drawn + * as sending, so nothing typed meanwhile goes out ahead of it. */ +export type StagedStructuredLaunchPrompt = { + sessionId: string + text: string + slot: StructuredAgentSessionReservedSend | null + sourceDraft?: NativeChatComposerDraft + options?: Readonly> + beforeCreate?: () => Promise + beforeSend?: () => Promise + /** Its sender keeps the text if it comes back (notes), instead of the chat's composer. */ + callerKeepsText?: true + delivery?: Promise + /** Create owns this prompt; it must never become a later send. */ + createOwned?: true + createSend?: StructuredAgentSessionCreateSend + whenCreateSettled: Promise + settleCreate: (outcome: StagedDelivery) => void + /** Its launch was cancelled: never sent, never given back. */ + discarded?: true + /** Settles once it is discarded, so its callers need not wait on a create that may never end. */ + whenDiscarded: Promise + discard: () => void +} + + +/** Puts a launch's text in its chat's composer, unless its sender keeps it. True when it did; a + * draft write that throws is reported in the chat instead, and never stops its caller. */ +export function handBackStagedPrompt(prompt: StagedStructuredLaunchPrompt): boolean { + if (prompt.callerKeepsText || prompt.discarded) { + return false + } + try { + handBackStructuredAgentSessionMessage( + prompt.sessionId, + `launch-${prompt.sessionId}`, + structuredAgentSessionSendBody(prompt.text, []) + ) + return true + } catch (error) { + console.error( + '[native-chat-send] a launch message could not be put back in the composer', + error + ) + publishStructuredAgentSessionSends(prompt.sessionId, { + notice: agentSessionWriteNoticeText(['messageNotSaved']) + }) + return false + } +} + + type LaunchReceipt = StructuredAgentLaunchReceipt const staged = new Map>() @@ -152,13 +231,11 @@ export function hasStagedStructuredLaunchPrompt(sessionId: string): boolean { function sendStagedPrompt( prompt: StagedStructuredLaunchPrompt, - receipt: LaunchReceipt | undefined, + receipt: LaunchReceipt, target: RuntimeClientTarget ): Promise { prompt.delivery ??= (async () => { - if (receipt) { - noteStructuredAgentSessionFence(prompt.sessionId, receipt.fence) - } + noteStructuredAgentSessionFence(prompt.sessionId, receipt.fence) if (prompt.sourceDraft && !prompt.slot?.prepare()) { throw new StructuredAgentSessionSubmissionEndedError() } @@ -186,59 +263,6 @@ function sendStagedPrompt( return prompt.delivery } -/** A later explicit Send continues the hosted chat through the same reserved sender. */ -export function deliverStructuredLaunchPromptToExistingSession( - prompt: StagedStructuredLaunchPrompt, - target: RuntimeClientTarget -): Promise { - return sendStagedPrompt(prompt, undefined, target).catch(() => { - failStagedStructuredLaunchPrompt(prompt) - return { delivered: false, inComposer: true } - }) -} - -/** Captures the original reservation; late preparation never adopts a newer send. */ -export async function prepareStructuredLaunchFirstSubmission( - sessionId: string, - clientMessageId: string, - target: RuntimeClientTarget, - atomic: boolean -): Promise<() => StructuredAgentSessionFirstMessage | undefined> { - const prompt = [...(staged.get(sessionId) ?? [])].find( - (candidate) => candidate.slot?.clientMessageId === clientMessageId - ) - if (!prompt?.slot?.prepare()) { - throw new StructuredAgentSessionSubmissionEndedError() - } - await prompt.beforeCreate?.() - return () => { - if (!prompt.slot?.prepare()) { - throw new StructuredAgentSessionSubmissionEndedError() - } - if (!atomic || prompt.beforeSend) { - return undefined - } - const message = prepareStructuredLaunchPromptForCreate(sessionId, target, prompt) - if (!message) { - throw new StructuredAgentSessionSubmissionEndedError() - } - return message - } -} - -export function failStagedStructuredLaunchPrompt(prompt: StagedStructuredLaunchPrompt): void { - if (prompt.discarded) { - return - } - if (prompt.slot) { - prompt.slot.release(['notDoneSend']) - } else { - handBackStagedPrompt(prompt) - } - releaseStagedStructuredLaunchPrompt(prompt) - prompt.settleCreate({ delivered: false, inComposer: true }) -} - export function settleStructuredAgentLaunchPrompt(args: { launchResult: Promise target: RuntimeClientTarget diff --git a/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts b/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts index ea6b77b3a038..8cf61690383f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts @@ -1,6 +1,6 @@ import { STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS } from '@/components/native-chat/structured-agent-session-message-sender' import { recoverPublishedSessionReceipt } from './structured-agent-session-launch-recovery' -import { finalizeStructuredLaunchFirstSubmission } from './structured-agent-session-launch-outcome-tracking' +import { publishStructuredLaunchCreateOptions } from './structured-agent-session-launch-create-message' import { settleStructuredLaunchCallers } from './structured-agent-session-launch-callers' import { restorePersistedStructuredLaunchState } from './structured-agent-session-launch-reload' import { @@ -23,16 +23,20 @@ export function confirmStructuredLaunchFirstMessagePublication(worktreeId: strin const expired = new Promise((_resolve, reject) => { deadline = setTimeout(() => reject(new Error('Confirmation timed out.')), STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS) }) - void Promise.race([recoverPublishedSessionReceipt(state), expired]).then(async () => { + void Promise.race([recoverPublishedSessionReceipt(state, false), expired]).then(async (receipt) => { if (state.cancelled || getStructuredLaunchStateBySessionId(sessionId) !== state) { return } - if (state.intent.firstSubmission) { - await finalizeStructuredLaunchFirstSubmission(state) - } else { - settleStructuredLaunchCallers(state.callers, 'published') - notifyStructuredLaunchListeners() - } + await publishStructuredLaunchCreateOptions(state, receipt) + state.hostPublished = true + notifyStructuredLaunchListeners() + // The original command still owns refusal; a restored observation has an already settled promise. + void state.promise.then(() => { + if (!state.cancelled && getStructuredLaunchStateBySessionId(sessionId) === state) { + settleStructuredLaunchCallers(state.callers, 'published') + notifyStructuredLaunchListeners() + } + }, () => {}) }).catch(() => { // An absent or unreadable journal says nothing new about the command's outcome. }).finally(() => { diff --git a/src/renderer/src/lib/structured-agent-session-launch-publication.ts b/src/renderer/src/lib/structured-agent-session-launch-publication.ts index 67f1790c1607..5252b42a94d2 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-publication.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-publication.ts @@ -1,7 +1,10 @@ -import { restorePersistedStructuredLaunchState } from './structured-agent-session-launch-reload' +import { readNativeChatNewDraft, retireNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' import type { ExecutionHostId } from '../../../shared/execution-host' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { deleteStructuredAgentLaunchRecord } from './structured-agent-session-launch-persistence' +import { resolveHostSessionGroupIdForWebSessionTab, setHostSessionTabIdMapping } from '@/runtime/web-session-tabs-sync/tracking-mappings' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { structuredAgentSessionFocusOwner, structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' import { getPersistedStructuredAgentLaunchRecord, getStructuredLaunchStateBySessionId, @@ -29,20 +32,23 @@ export function markStructuredAgentSessionLaunchPublished( sessionId: string, executionHostId: ExecutionHostId ): boolean { - let state = getStructuredLaunchStateBySessionId(sessionId) + const draft = readNativeChatNewDraft(sessionId) + if (draft?.owner.workspaceId === worktreeId && draft.owner.executionHostId === executionHostId) { + const target = structuredAgentSessionTargetForHost(executionHostId) + if (target) { + const tabId = structuredAgentSessionTabId(sessionId) + const mapping = { ...structuredAgentSessionFocusOwner(target), worktreeId, tabId } + setHostSessionTabIdMapping(mapping, tabId, resolveHostSessionGroupIdForWebSessionTab(mapping) ?? undefined) + } + void retireNativeChatNewDraft(sessionId) + } + const state = getStructuredLaunchStateBySessionId(sessionId) if (!state) { const persisted = getPersistedStructuredAgentLaunchRecord(sessionId) if (persisted?.executionHostId !== executionHostId) { return false } - if (persisted.firstSubmission) { - state = restorePersistedStructuredLaunchState(worktreeId, sessionId) - if (state) { - state.hostPublished = true - } - notifyStructuredLaunchListeners() - } - if (persisted.firstMessage || persisted.firstSubmission) { + if (persisted.firstMessage) { confirmStructuredLaunchFirstMessagePublication(worktreeId, sessionId) return false } @@ -66,7 +72,7 @@ export function markStructuredAgentSessionLaunchPublished( if (state.callers.outcome === 'pending') { return true } - if (state.intent.params.firstMessage || state.intent.firstSubmission) { + if (state.intent.params.firstMessage) { confirmStructuredLaunchFirstMessagePublication(worktreeId, sessionId) return false } diff --git a/src/renderer/src/lib/structured-agent-session-launch-record.ts b/src/renderer/src/lib/structured-agent-session-launch-record.ts index 060e32d31844..0f631ef58116 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-record.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-record.ts @@ -8,17 +8,6 @@ import { isAgentSessionOptions } from '../../../shared/agent-session-record' import { SendBodyStructure } from '../../../shared/rpc-contract/structured-agent-session-message-params' import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' -import { parseStoredNativeChatComposerDraft, projectNativeChatComposerDraft, type NativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-storage' - -export type StructuredAgentFirstSubmission = { - clientMessageId: string - draft: NativeChatComposerDraft - options: Readonly> -} - -export function projectStructuredAgentFirstSubmission(submission: StructuredAgentFirstSubmission): StructuredAgentFirstSubmission { - return { clientMessageId: submission.clientMessageId, draft: projectNativeChatComposerDraft(submission.draft), options: { ...submission.options } } -} export type StructuredAgentLaunchPersistedLifecycle = 'pending' | 'visibility-unknown' | 'failed' @@ -34,7 +23,6 @@ export type StructuredAgentLaunchPersistedRecord = { expectedRuntimeFence: number | null resumeFrom?: StructuredAgentSessionResumeSource firstMessage?: StructuredAgentSessionFirstMessage - firstSubmission?: StructuredAgentFirstSubmission options?: Readonly> createMessageSupport?: boolean /** A paired server's reported seed, which this machine cannot re-derive after a reload. */ @@ -61,7 +49,6 @@ export function structuredAgentLaunchRecordFor( expectedRuntimeFence: envelope.expectedRuntimeFence, ...(resumeFrom ? { resumeFrom } : {}), ...(firstMessage ? { firstMessage } : {}), - ...(intent.firstSubmission ? { firstSubmission: projectStructuredAgentFirstSubmission(intent.firstSubmission) } : {}), ...(options ? { options } : {}), ...(intent.createMessageSupport === undefined ? {} @@ -101,7 +88,6 @@ export function validRecord(value: unknown): value is Omit< const executionHostId = 'executionHostId' in value ? value.executionHostId : undefined const failedAt = 'failedAt' in value ? value.failedAt : undefined const firstMessage = 'firstMessage' in value ? value.firstMessage : undefined - const firstSubmission = 'firstSubmission' in value ? value.firstSubmission : undefined const options = 'options' in value ? value.options : undefined const createMessageSupport = 'createMessageSupport' in value ? value.createMessageSupport : undefined @@ -117,7 +103,6 @@ export function validRecord(value: unknown): value is Omit< (expectedRuntimeFence === null || typeof expectedRuntimeFence === 'number') && (failedAt === undefined || Number.isFinite(failedAt)) && (firstMessage === undefined || validFirstMessage(firstMessage)) && - (firstSubmission === undefined || validFirstSubmission(firstSubmission)) && (options === undefined || isAgentSessionOptions(options)) && (createMessageSupport === undefined || typeof createMessageSupport === 'boolean') && (resumeFrom === undefined || @@ -128,14 +113,6 @@ export function validRecord(value: unknown): value is Omit< ) } -function validFirstSubmission(value: unknown): value is StructuredAgentFirstSubmission { - return typeof value === 'object' && value !== null && - 'clientMessageId' in value && typeof value.clientMessageId === 'string' && - 'options' in value && isAgentSessionOptions(value.options) && - 'draft' in value && typeof value.draft === 'object' && value.draft !== null && - parseStoredNativeChatComposerDraft({ ...value.draft, savedAt: 0 }) !== null -} - function validFirstMessage(value: unknown): value is StructuredAgentSessionFirstMessage { return ( typeof value === 'object' && diff --git a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts index 9240ca83e9b1..d7aca786219e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts @@ -13,6 +13,7 @@ import { import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { readStructuredSessionTabInventory } from '@/runtime/structured-session-tab-inventory' import { publishStructuredAgentSessionCreateHydration } from './structured-agent-session-create-hydration' +import type { StructuredLaunchAttempt } from './structured-agent-session-launch-request' export type StructuredAgentLaunchReceipt = { sessionId: string @@ -22,6 +23,7 @@ export type StructuredAgentLaunchReceipt = { } export type StructuredLaunchRecoveryState = { + callers?: { attempt: StructuredLaunchAttempt } intent: StructuredAgentSessionLaunchIntent promise: Promise visibilityUnknown: boolean @@ -30,6 +32,17 @@ export type StructuredLaunchRecoveryState = { onHostSeed?: StructuredLaunchHostSeedListener } +function issueLaunch(state: StructuredLaunchRecoveryState, unknown = false): Promise { + const attempt = state.callers?.attempt + const reservedSubmission = attempt?.kind === 'first' && attempt.stagedPrompt?.sourceDraft !== undefined + if (reservedSubmission) { + return launchStructuredAgentSession(state.intent, state.onHostSeed, unknown, true) + } + return unknown + ? launchStructuredAgentSession(state.intent, state.onHostSeed, true) + : launchStructuredAgentSession(state.intent, state.onHostSeed) +} + export class StructuredAgentSessionLaunchCancelledError extends Error { constructor() { super('structured session launch cancelled') @@ -59,39 +72,28 @@ async function verifyPublishedSession(state: StructuredLaunchRecoveryState): Pro } export async function recoverPublishedSessionReceipt( - state: StructuredLaunchRecoveryState + state: StructuredLaunchRecoveryState, + replayMissingMessage = true ): Promise { - if (!state.intent.firstSubmission) { - await verifyPublishedSession(state) - } - let history = await callStructuredAgentSession( + await verifyPublishedSession(state) + const history = await callStructuredAgentSession( state.intent.target, 'agentSession.history', - { sessionId: state.intent.sessionId, direction: 'tail', limit: state.intent.firstSubmission ? 200 : 1 } + { sessionId: state.intent.sessionId, direction: 'tail', limit: 1 } ) throwIfLaunchCancelled(state) const fence = history.page.fence ?? (!history.ok ? history.fence : undefined) if (typeof fence !== 'number') { throw new Error('structured session fence publication unavailable') } - const messageId = state.intent.firstSubmission?.clientMessageId ?? state.intent.params.firstMessage?.clientMessageId - let firstMessage = history.page.submissions.find((submission) => submission.clientMessageId === messageId) - for (let page = 1; state.intent.firstSubmission && !firstMessage && history.page.hasOlder && page < 8; page += 1) { - const cursor = history.page.window.oldest - if (!cursor) { - break - } - history = await callStructuredAgentSession(state.intent.target, 'agentSession.history', { - sessionId: state.intent.sessionId, direction: 'before', cursor, limit: 200 - }) - throwIfLaunchCancelled(state) - firstMessage = history.page.submissions.find((submission) => submission.clientMessageId === messageId) - } - if (state.intent.firstSubmission && !firstMessage) { - throw new Error('The opening message has not been confirmed.') - } + const firstMessage = state.intent.params.firstMessage + ? history.page.submissions.find((submission) => submission.clientMessageId === state.intent.params.firstMessage?.clientMessageId) + : undefined if (state.intent.params.firstMessage && !firstMessage) { - return launchStructuredAgentSession(state.intent, state.onHostSeed, true) + if (!replayMissingMessage) { + throw new Error('The opening message has not been confirmed.') + } + return issueLaunch(state, true) } if (state.intent.createMessageSupport === true) { publishStructuredAgentSessionCreateHydration(state.intent, history.page, fence) @@ -110,13 +112,8 @@ async function retrySameIntent( priorError: unknown ): Promise { throwIfLaunchCancelled(state) - if (state.intent.firstSubmission) { - state.visibilityUnknown = true - state.onVisibilityChanged?.() - throw priorError - } try { - const receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed, true) + const receipt = await issueLaunch(state, true) throwIfLaunchCancelled(state) if (state.intent.createMessageSupport !== true) { await verifyPublishedSession(state) @@ -148,7 +145,7 @@ export async function launchAndReconcile( throwIfLaunchCancelled(state) let receipt: StructuredAgentLaunchReceipt try { - receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed) + receipt = await issueLaunch(state) } catch (error) { if (state.cancelled) { throw new StructuredAgentSessionLaunchCancelledError() @@ -185,11 +182,6 @@ export async function reconcileUnknownLaunch( try { return await recoverPublishedSessionReceipt(state) } catch (error) { - if (state.intent.firstSubmission) { - state.visibilityUnknown = true - state.onVisibilityChanged?.() - throw error - } return retrySameIntent(state, error) } } diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index ff56f72d2038..df779cf74138 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -95,9 +95,6 @@ export function deleteStructuredLaunchStateIfCurrent(state: StructuredLaunchStat if (structuredLaunchesBySessionId.get(state.intent.sessionId) !== state) { return false } - if (state.intent.firstSubmission && !state.cancelled) { - return false - } structuredLaunchesBySessionId.delete(state.intent.sessionId) deleteStructuredAgentLaunchRecord(state.intent.sessionId) return true @@ -105,7 +102,11 @@ export function deleteStructuredLaunchStateIfCurrent(state: StructuredLaunchStat function persistStructuredLaunchState(state: StructuredLaunchState): void { const lifecycle = launchStateLifecycle(state) - if ((lifecycle === 'published' && !state.intent.firstSubmission) || lifecycle === 'cancelled') { + if (state.callers.outcome === 'published' || lifecycle === 'cancelled') { + deleteStructuredAgentLaunchRecord(state.intent.sessionId) + return + } + if (!state.intent.createPrepared) { deleteStructuredAgentLaunchRecord(state.intent.sessionId) return } diff --git a/src/renderer/src/lib/structured-agent-session-launch-reload.ts b/src/renderer/src/lib/structured-agent-session-launch-reload.ts index 7abe4a39c2d9..8a511ff876f0 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-reload.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-reload.ts @@ -35,7 +35,6 @@ export function restorePersistedStructuredLaunchState( ...(record.resumeFrom ? { resumeFrom: record.resumeFrom } : {}), ...(record.seedOptions ? { seedOptions: record.seedOptions } : {}), ...(record.firstMessage ? { firstMessage: record.firstMessage } : {}), - ...(record.firstSubmission ? { firstSubmission: record.firstSubmission } : {}), ...(record.options ? { options: record.options } : {}), ...(record.createMessageSupport === undefined ? {} diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 1f64ab705986..d73774072b2d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -47,7 +47,6 @@ import { trackStructuredLaunchPromptOutcome, trackStructuredLaunchConfirmationDeadline } from './structured-agent-session-launch-prompt-outcome' -import { confirmStructuredLaunchFirstMessagePublication } from './structured-agent-session-launch-publication-confirmation' import { repeatedStructuredLaunchAttempt } from './structured-agent-session-launch-request' export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt } @@ -132,12 +131,8 @@ function resetStructuredLaunchCallers(state: StructuredLaunchState): void { } function restartStructuredLaunchState(state: StructuredLaunchState): void { - if (state.intent.firstSubmission) { - confirmStructuredLaunchFirstMessagePublication(state.intent.worktreeId, state.intent.sessionId) - return - } const wasVisibilityUnknown = state.visibilityUnknown - if (!wasVisibilityUnknown && !state.intent.firstSubmission) { + if (!wasVisibilityUnknown) { state.intent = retryStructuredAgentSessionLaunchIntent(state.intent) } resetStructuredLaunchCallers(state) @@ -218,13 +213,6 @@ function structuredAgentLaunchState( const stagedPrompt = options.stagedPrompt ?? (text ? stageStructuredLaunchPrompt(intent.sessionId, text, promptOwner(options)) : null) - if (stagedPrompt?.sourceDraft && stagedPrompt.slot) { - intent.firstSubmission = { - clientMessageId: stagedPrompt.slot.clientMessageId, - draft: stagedPrompt.sourceDraft, - options: options.firstSubmissionOptions ?? {} - } - } launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options) const callers = createStructuredLaunchCallerGroup({ kind: 'first', @@ -240,7 +228,7 @@ function structuredAgentLaunchState( cancelled: false, onVisibilityChanged: notifyStructuredLaunchListeners, callers, - selection: { seed: intent.seedOptions, held: options.firstSubmissionOptions ?? {} } + selection: { seed: intent.seedOptions, held: options.openingOptions ?? {} } } state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions) callers.onSettled = () => maybeCleanupLaunchState(state) From ab3df80e29a6cfea3890309ae063208d501b54f4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:36:37 -0700 Subject: [PATCH 10/27] Remove launch prompt import cycle and tighten refusal tests --- .../native-chat-attachment-upload.ts | 9 +- ...se-native-chat-provisional-launch.test.tsx | 2 + ...gent-session-launch-create-message.test.ts | 3 + ...red-agent-session-launch-prompt-outcome.ts | 14 -- .../structured-agent-session-launch-prompt.ts | 123 +++++++++--------- .../store/slices/tabs/tabs-close-actions.ts | 3 +- 6 files changed, 73 insertions(+), 81 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index d84a02ea3c9a..fe313f666798 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -22,11 +22,12 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { RuntimeStatus } from '../../../../shared/runtime-types' import { AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../../shared/agent-session-write-notice-copy' -import type { - AgentSessionAttachmentPathUploadResult, - AgentSessionAttachmentUploadTarget +import { + AGENT_SESSION_DRAFT_ATTACHMENTS_RUNTIME_CAPABILITY, + type AgentSessionReservedAttachmentSession, + type AgentSessionAttachmentPathUploadResult, + type AgentSessionAttachmentUploadTarget } from '../../../../shared/agent-session-attachments' -import { AGENT_SESSION_DRAFT_ATTACHMENTS_RUNTIME_CAPABILITY, type AgentSessionReservedAttachmentSession } from '../../../../shared/agent-session-attachments' import { readNativeChatNewDraft } from './native-chat-new-draft' import type { AppState } from '@/store/types' import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop' diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx index ad80d9a227ea..04ee9bec94be 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx @@ -55,6 +55,7 @@ import { resetStructuredAgentLaunchRegistryForTests } from '@/lib/structured-age import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' import { useStructuredAgentSession } from './use-structured-agent-session' import { allocateNativeChatNewDraft, readNativeChatNewDraft } from './native-chat-new-draft' +import { isNativeChatComposerDraftUnsaved } from './native-chat-composer-draft-store' import { createMemoryNativeChatComposerDraftStorage, setNativeChatComposerDraftStorageForTests } from './native-chat-composer-draft-storage' import { markStructuredAgentSessionLaunchPublished } from '@/lib/structured-agent-session-launch-publication' import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' @@ -154,6 +155,7 @@ describe('a chat pane over its own launch', () => { await waitFor(async () => expect(await storage.read(`agent-session-new:${sessionId}`)).toEqual(metadata)) storage.refuseWrites = true markStructuredAgentSessionLaunchPublished('wt-hosted', sessionId, 'local') + await waitFor(() => expect(isNativeChatComposerDraftUnsaved(`agent-session:${sessionId}`)).toBe(true)) const { result, unmount } = renderHook(() => useNativeChatProvisionalLaunch('wt-hosted', sessionId)) const send = vi.fn(() => 'queued' as const) expect(result.current.sendThroughLaunch('follow-up', false, send)).toBe('queued') diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index d06d7b540216..8f0663a3e848 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -300,6 +300,7 @@ describe('the first message owned by create', () => { it('re-derives metadata retirement after repeated refusal without launch evidence', async () => { const storage = createMemoryNativeChatComposerDraftStorage() setNativeChatComposerDraftStorageForTests(storage) + const remove = vi.spyOn(storage, 'remove') const sessionId = 'codex_published_metadata' const source = { text: 'newer typing', images: [] } const metadata = { owner: { workspaceId: 'workspace-1', executionHostId: 'local' as const }, agent: 'codex' as const, groupId: 'group-1', options: {} } @@ -309,6 +310,8 @@ describe('the first message owned by create', () => { storage.refuseWrites = true for (let attempt = 0; attempt < 2; attempt += 1) { markStructuredAgentSessionLaunchPublished('workspace-1', sessionId, 'local') + await vi.waitFor(() => expect(remove).toHaveBeenCalledTimes(attempt + 1)) + await expect(remove.mock.results[attempt]?.value).rejects.toThrow('refused') await vi.waitFor(() => expect(isNativeChatComposerDraftUnsaved(structuredAgentSessionDraftScopeKey(sessionId))).toBe(true)) expect(nativeChatNewDraftIsHosted(sessionId, metadata)).toBe(true) expect(readNativeChatNewDraft(sessionId)).toEqual(metadata) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts index 6d71f406de6f..68b7c583e286 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts @@ -1,4 +1,3 @@ -import { handBackStagedPrompt, releaseStagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt' import { StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' import type { StagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt' import type { StructuredLaunchCaller } from './structured-agent-session-launch-callers' @@ -73,16 +72,3 @@ export function trackStructuredLaunchConfirmationDeadline( const clear = (): void => clearTimeout(deadline) void promise.then(clear, clear) } - -export function failStagedStructuredLaunchPrompt(prompt: StagedStructuredLaunchPrompt): void { - if (prompt.discarded) { - return - } - if (prompt.slot) { - prompt.slot.release(['notDoneSend']) - } else { - handBackStagedPrompt(prompt) - } - releaseStagedStructuredLaunchPrompt(prompt) - prompt.settleCreate({ delivered: false, inComposer: true }) -} diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 07ea0be3f8d5..c332e16aea19 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -3,17 +3,17 @@ import { handBackStructuredAgentSessionMessage } from '@/components/native-chat/ import { publishStructuredAgentSessionSends } from '@/components/native-chat/structured-agent-session-pending-sends' import { agentSessionWriteNoticeText } from '@/components/native-chat/agent-session-write-notice-text' -import { failStagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt-outcome' import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create' -import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' +import { StructuredAgentSessionCreateRefusalError, StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' import { agentSessionThrownFailure } from '../../../shared/agent-session-write-failure' import { dropStructuredAgentSessionSends, reserveStructuredAgentSessionSend, sendStructuredAgentSessionMessage, - type StructuredAgentSessionReservedSend, - type StructuredAgentSessionCreateSend + type StructuredAgentSessionCreateSend, + type StructuredAgentSessionSendOutcome, + type StructuredAgentSessionReservedSend } from '@/components/native-chat/structured-agent-session-message-sender' import { noteStructuredAgentSessionFence } from '@/components/native-chat/structured-agent-session-send-attempt' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' @@ -31,7 +31,7 @@ export type StructuredPromptDeliveryResult = { busy?: true } -export type StagedDelivery = Omit & { +type StagedDelivery = Omit & { inComposer: boolean } @@ -67,35 +67,6 @@ export type StagedStructuredLaunchPrompt = { discard: () => void } - -/** Puts a launch's text in its chat's composer, unless its sender keeps it. True when it did; a - * draft write that throws is reported in the chat instead, and never stops its caller. */ -export function handBackStagedPrompt(prompt: StagedStructuredLaunchPrompt): boolean { - if (prompt.callerKeepsText || prompt.discarded) { - return false - } - try { - handBackStructuredAgentSessionMessage( - prompt.sessionId, - `launch-${prompt.sessionId}`, - structuredAgentSessionSendBody(prompt.text, []) - ) - return true - } catch (error) { - console.error( - '[native-chat-send] a launch message could not be put back in the composer', - error - ) - publishStructuredAgentSessionSends(prompt.sessionId, { - notice: agentSessionWriteNoticeText(['messageNotSaved']) - }) - return false - } -} - - -type LaunchReceipt = StructuredAgentLaunchReceipt - const staged = new Map>() export function stageStructuredLaunchPrompt( @@ -127,11 +98,7 @@ export function stageStructuredLaunchPrompt( forSession.add(prompt) staged.set(sessionId, forSession) void prompt.slot?.outcome.then((outcome) => { - const result: StagedDelivery = { - delivered: outcome === 'recorded', - inComposer: !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), - ...(outcome === 'unconfirmed' ? { unconfirmed: true } : {}) - } + const result = stagedPromptDelivery(prompt, outcome) if (!prompt.delivery) { prompt.delivery = Promise.resolve(result) releaseStagedStructuredLaunchPrompt(prompt) @@ -205,20 +172,10 @@ export function prepareStructuredLaunchPromptForCreate( prompt.createOwned = true const sent = prompt.slot?.create(target, ownedPrompt ? true : undefined) prompt.createSend = sent ?? undefined - prompt.delivery = sent - ? sent.outcome.then((outcome) => { - releaseStagedStructuredLaunchPrompt(prompt) - return { - delivered: outcome === 'recorded', - inComposer: - !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), - ...(outcome === 'unconfirmed' ? { unconfirmed: true as const } : {}) - } - }) - : Promise.resolve({ delivered: false, inComposer: !prompt.callerKeepsText }).then((outcome) => { - releaseStagedStructuredLaunchPrompt(prompt) - return outcome - }) + prompt.delivery = (sent?.outcome ?? Promise.resolve('returned' as const)).then((outcome) => { + releaseStagedStructuredLaunchPrompt(prompt) + return stagedPromptDelivery(prompt, outcome) + }) void prompt.delivery.then(prompt.settleCreate) return sent?.message } @@ -228,10 +185,58 @@ export function hasStagedStructuredLaunchPrompt(sessionId: string): boolean { return (staged.get(sessionId)?.size ?? 0) > 0 } +/** Puts a launch's text in its chat's composer, unless its sender keeps it. True when it did; a + * draft write that throws is reported in the chat instead, and never stops its caller. */ +function handBackStagedPrompt(prompt: StagedStructuredLaunchPrompt): boolean { + if (prompt.callerKeepsText || prompt.discarded) { + return false + } + try { + handBackStructuredAgentSessionMessage( + prompt.sessionId, + `launch-${prompt.sessionId}`, + structuredAgentSessionSendBody(prompt.text, []) + ) + return true + } catch (error) { + console.error( + '[native-chat-send] a launch message could not be put back in the composer', + error + ) + publishStructuredAgentSessionSends(prompt.sessionId, { + notice: agentSessionWriteNoticeText(['messageNotSaved']) + }) + return false + } +} + +function stagedPromptDelivery( + prompt: StagedStructuredLaunchPrompt, + outcome: StructuredAgentSessionSendOutcome +): StagedDelivery { + return { + delivered: outcome === 'recorded', + inComposer: !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), + ...(outcome === 'unconfirmed' ? { unconfirmed: true as const } : {}) + } +} + +function failStagedStructuredLaunchPrompt(prompt: StagedStructuredLaunchPrompt): void { + if (prompt.discarded) { + return + } + if (prompt.slot) { + prompt.slot.release(['notDoneSend']) + } else { + handBackStagedPrompt(prompt) + } + releaseStagedStructuredLaunchPrompt(prompt) + prompt.settleCreate({ delivered: false, inComposer: true }) +} function sendStagedPrompt( prompt: StagedStructuredLaunchPrompt, - receipt: LaunchReceipt, + receipt: StructuredAgentLaunchReceipt, target: RuntimeClientTarget ): Promise { prompt.delivery ??= (async () => { @@ -249,13 +254,7 @@ function sendStagedPrompt( return { delivered: false, inComposer: handBackStagedPrompt(prompt), busy: true } } try { - const outcome = await sent.outcome - return { - delivered: outcome === 'recorded', - inComposer: - !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), - ...(outcome === 'unconfirmed' ? { unconfirmed: true as const } : {}) - } + return stagedPromptDelivery(prompt, await sent.outcome) } finally { releaseStagedStructuredLaunchPrompt(prompt) } @@ -264,7 +263,7 @@ function sendStagedPrompt( } export function settleStructuredAgentLaunchPrompt(args: { - launchResult: Promise + launchResult: Promise target: RuntimeClientTarget options: StructuredLaunchPromptOptions stagedPrompt: StagedStructuredLaunchPrompt | null diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index 53dee6434940..56513aac18b4 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -1,5 +1,4 @@ import { readNativeChatNewDraft, retireNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' -import { getPersistedStructuredAgentLaunchRecord, getStructuredLaunchStateBySessionId } from '@/lib/structured-agent-session-launch-registry' import { deleteNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' import { collapseGroupLayout } from '../../../../../shared/workspace-layout/tab-group-layout-tree' @@ -13,6 +12,8 @@ import { dedupeTabOrder } from '../../../../../shared/workspace-layout/tab-order import { buildActiveSurfacePatch } from './tabs-surface' import { beginStructuredAgentSessionTabClose } from '@/runtime/structured-agent-session-tab-retirement' import { + getPersistedStructuredAgentLaunchRecord, + getStructuredLaunchStateBySessionId, hasStructuredAgentSessionLaunchCancellationTombstone, shouldRetainStructuredAgentSessionLaunchTab } from '@/lib/structured-agent-session-launch-registry' From 04183b363f416c4f4bfad9d40a1c314df5d3e1e8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:41:24 -0700 Subject: [PATCH 11/27] Align draft storage contracts with typechecked callers --- config/tsconfig.node.json | 2 ++ ...tive-chat-composer-draft-indexeddb.test.ts | 4 +-- .../native-chat-composer-draft-store.test.ts | 28 +++++++++---------- .../lib/launch-structured-agent-session.ts | 1 + .../structured-agent-session-launch-prompt.ts | 2 +- 5 files changed, 20 insertions(+), 17 deletions(-) diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index fafa7267bc57..69424c1f2d95 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -19,6 +19,8 @@ "../src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts", "../src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts", "../src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts", + "../src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts", + "../src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts", "../src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts", "../src/renderer/src/components/terminal-pane/legacy-relay-bridge-panes.ts", "../src/preload/**/*", diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts index af2b7cf889d1..c82e85e3ae65 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts @@ -69,7 +69,7 @@ function fakeDatabase(): { } const DRAFT = { text: 'unsent', images: [], savedAt: 1 } -const settledYet = async (promise: Promise): Promise => { +const settledYet = async (promise: Promise): Promise => { let settled = false void promise.then(() => (settled = true)) await new Promise((resolve) => setTimeout(resolve, 0)) @@ -97,7 +97,7 @@ describe('the IndexedDB draft storage', () => { expect(await settledYet(update)).toBe(false) expect(puts).toEqual([DRAFT]) transactions[0]!.oncomplete?.() - await expect(update).resolves.toBeUndefined() + await expect(update).resolves.toEqual(DRAFT) }) it('rejects a write whose transaction aborts, as on a full disk', async () => { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index 9fee406bb2ae..4b10819cd674 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -121,10 +121,9 @@ describe('native-chat composer draft store', () => { const slow = { ...storage, write: (scopeKey: string, draft: Parameters[1]) => - new Promise((resolve) => { + new Promise((resolve, reject) => { pending.push(() => { - storage.drafts.set(scopeKey, draft) - resolve() + void storage.write(scopeKey, draft).then(resolve, reject) }) }) } @@ -210,11 +209,12 @@ describe('native-chat composer draft store', () => { const aStorage = { ...storage, write: (scopeKey: string, draft: Parameters[1]) => - new Promise((resolve) => { + new Promise((resolve, reject) => { commitA = () => { - storage.drafts.set(scopeKey, draft) - resolve() - aCommitted() + void storage.write(scopeKey, draft).then(() => { + resolve() + aCommitted() + }, reject) } }) } @@ -231,7 +231,7 @@ describe('native-chat composer draft store', () => { // IndexedDB orders B's write after A's, which was issued first. write: async (scopeKey: string, draft: Parameters[1]) => { await aDone - storage.drafts.set(scopeKey, draft) + await storage.write(scopeKey, draft) } } const b = await reload({ using: bStorage, hydrate: false }) @@ -274,9 +274,9 @@ describe('native-chat composer draft store', () => { storage.drafts.set('agent-session:s1', { text: 'typed earlier', images: [], savedAt: 1 }) const landsUnconfirmed = { ...storage, - write: (scopeKey: string, draft: Parameters[1]) => { - storage.drafts.set(scopeKey, draft) - return new Promise(() => {}) + write: async (scopeKey: string, draft: Parameters[1]) => { + await storage.write(scopeKey, draft) + await new Promise(() => {}) } } const crashed = await reload({ using: landsUnconfirmed }) @@ -733,9 +733,9 @@ describe('native-chat composer draft store', () => { // Tab A's write lands, but the page goes away before it is confirmed. const landsUnconfirmed = { ...storage, - write: (scopeKey: string, draft: Parameters[1]) => { - storage.drafts.set(scopeKey, draft) - return new Promise(() => {}) + write: async (scopeKey: string, draft: Parameters[1]) => { + await storage.write(scopeKey, draft) + await new Promise(() => {}) } } const tabA = await reload({ using: landsUnconfirmed }) diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index f795d42525ae..c82bb98946ee 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -171,6 +171,7 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { options?: Readonly> createMessageSupport?: boolean }): StructuredAgentSessionLaunchIntent { + const state = useAppStore.getState() const { target } = structuredAgentSessionOwnerTarget(args.worktreeId, args.executionHostId) return { sessionId: args.sessionId, diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index c332e16aea19..df28cebf8439 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -290,7 +290,7 @@ export function settleStructuredAgentLaunchPrompt(args: { prompt, receipt, args.target - ).catch(() => { + ).catch((): StagedDelivery => { failStagedStructuredLaunchPrompt(prompt) return { delivered: false, inComposer: true } }) From a4838ea033e3af2bdea161eb66227693effcc22e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:01:23 -0700 Subject: [PATCH 12/27] Preserve definite create refusal feedback and issued recovery fixtures --- ...d-agent-session-status-bridge-failed-start.test.tsx | 8 +++++++- ...nt-session-status-bridge-unconfirmed-start.test.tsx | 7 ++++++- .../src/lib/launch-agent-structured-chat-guard.test.ts | 3 +++ ...ructured-agent-session-launch-after-failure.test.ts | 9 ++++++++- ...uctured-agent-session-launch-create-message.test.ts | 8 ++++++-- .../structured-agent-session-launch-options.test.ts | 10 +++++++--- .../src/lib/structured-agent-session-launch-prompt.ts | 4 ++-- ...agent-session-launch-unconfirmed-or-retried.test.ts | 7 ++++++- .../src/lib/structured-agent-session-launch.test.ts | 2 ++ 9 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx index 1f25263f53ab..61f2d38b9300 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx @@ -200,7 +200,12 @@ let failedStarts = 0 async function failStart(): Promise { mocks.createIntent.mockReturnValueOnce(intent) - mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('refused')) + mocks.launch.mockImplementationOnce(async (intent: StructuredAgentSessionLaunchIntent) => { + await Promise.resolve() + const issue = await intent.prepareCreate?.() + issue?.() + throw new StructuredAgentSessionCreateRefusalError('refused') + }) failedStarts += 1 // Each failed start stands for its own user action. const launch = startStructuredAgentLaunch(WORKTREE_ID, 'claude', { @@ -213,6 +218,7 @@ async function failStart(): Promise { describe('a chat whose start failed', () => { beforeEach(() => { vi.clearAllMocks() + delete intent.createPrepared localStorage.clear() resetStructuredAgentLaunchRegistryForTests() resetStructuredAgentLaunchPersistenceForTests() diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx index 06d1dcd6b76f..68fa9ce36064 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx @@ -307,7 +307,12 @@ describe('a chat whose start was never confirmed', () => { it('re-checks a start a reload interrupted, without the user pressing Retry', async () => { await connect() mocks.createIntent.mockReturnValueOnce(launchIntent(CLAUDE_SESSION)) - mocks.launch.mockReturnValueOnce(new Promise(() => {})) + mocks.launch.mockImplementationOnce(async (intent: StructuredAgentSessionLaunchIntent) => { + await Promise.resolve() + const issue = await intent.prepareCreate?.() + issue?.() + return new Promise(() => {}) + }) startStructuredAgentLaunch(WORKTREE_ID, 'claude', { requestId: `request-${CLAUDE_SESSION}` }) await flush() diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 34fe80efc429..4122649c1965 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -294,6 +294,7 @@ describe('structured chat adoption guard on the launch path', () => { 'codex', 'local', undefined, + undefined, undefined ) expect(mockLaunchStructuredCodexSession).toHaveBeenCalledWith( @@ -327,6 +328,7 @@ describe('structured chat adoption guard on the launch path', () => { 'codex', 'local', undefined, + undefined, undefined ) expect(mockCreateTab).not.toHaveBeenCalled() @@ -348,6 +350,7 @@ describe('structured chat adoption guard on the launch path', () => { 'claude', 'local', undefined, + undefined, undefined ) expect(mockCreateTab).not.toHaveBeenCalled() diff --git a/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts b/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts index 12c8bf1f9757..c43ce8fda51e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts @@ -144,7 +144,12 @@ const fresh = launchIntent('session-new') async function refuseFirstLaunch(): Promise { mocks.createIntent.mockReturnValueOnce(failed).mockReturnValueOnce(fresh) - mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) + mocks.launch.mockImplementationOnce(async (intent: StructuredAgentSessionLaunchIntent) => { + await Promise.resolve() + const issue = await intent.prepareCreate?.() + issue?.() + throw new StructuredAgentSessionCreateRefusalError('unsupported') + }) startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-1', prompt: 'first task' }) await flushLaunchSettlement() expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, failed.sessionId)).toBe('failed') @@ -153,6 +158,8 @@ async function refuseFirstLaunch(): Promise { describe('a new launch after a failed one', () => { beforeEach(() => { vi.resetAllMocks() + delete failed.createPrepared + delete fresh.createPrepared localStorage.clear() resetStructuredAgentLaunchPersistenceForTests() resetStructuredAgentLaunchRegistryForTests() diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index 8f0663a3e848..7f6e296eba3d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -174,7 +174,11 @@ describe('the first message owned by create', () => { const create = deferred() const legacy = scenario.startsWith('legacy') mocks.supports.mockReturnValue(scenario === 'expiredDiscovery' ? discovery.promise : Promise.resolve(!legacy)) - mocks.call.mockReturnValue(create.promise) + let issuedAt = 0 + mocks.call.mockImplementation(() => { + issuedAt = Date.now() + return create.promise + }) allocateNativeChatNewDraft(sessionId, { owner: { workspaceId: 'workspace-1', executionHostId: 'local' }, agent: 'codex', groupId: 'group-1', options: {} }) const storedSource = { ...source, savedAt: 123, owner: { workspaceId: 'workspace-1', executionHostId: 'local' as const }, unsavedText: 'bookkeeping' } const prompt = stageStructuredLaunchPrompt(sessionId, source.text, { @@ -246,7 +250,7 @@ describe('the first message owned by create', () => { ] } }) expect(params.options).toEqual({ model: 'picked' }) updateNativeChatComposerDraft(scope, { text: 'newer edit' }, 'immediate') - await vi.advanceTimersByTimeAsync(29_999) + await vi.advanceTimersByTimeAsync(issuedAt + 29_999 - Date.now()) expect(getStructuredAgentSessionPendingSends(sessionId)[0]?.phase).toBe('sending') create.resolve(created()) await result diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts index 8d700ba9227f..3fddbd15901f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts @@ -193,14 +193,17 @@ describe('picks made while a chat launches', () => { prompt: 'first turn' }) + let commandSettled = false + void launch.launchResult.then(() => { commandSettled = true }) const modelPick = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-picked') holdStructuredAgentSessionLaunchOption(SESSION_ID, 'effort', 'high') created.resolve({ sessionId: SESSION_ID, fence: 7 }) await settle() - // The host published the tab, but the launch is not published until its picks land. + // The hosted chat is usable while its original command still waits for held picks. markStructuredAgentSessionLaunchPublished(WORKTREE_ID, SESSION_ID, 'local') - expect(lifecycle()).toBe('pending') + expect(lifecycle()).toBe('published') + expect(commandSettled).toBe(false) expect(mutations()).toEqual([ { method: 'agentSession.setOption', fence: 7, key: 'model', value: 'gpt-picked' } ]) @@ -213,7 +216,8 @@ describe('picks made while a chat launches', () => { options: { model: 'gpt-picked' } }) await settle() - expect(lifecycle()).toBe('pending') + expect(lifecycle()).toBe('published') + expect(commandSettled).toBe(false) setOptionReplies[1]!.resolve(ACCEPTED) await launch.promptDeliveryResult diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index df28cebf8439..28e1de75193a 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -6,7 +6,7 @@ import { agentSessionWriteNoticeText } from '@/components/native-chat/agent-sess import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create' import { StructuredAgentSessionCreateRefusalError, StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' -import { agentSessionThrownFailure } from '../../../shared/agent-session-write-failure' +import { structuredLaunchFailure } from './structured-agent-session-launch-failure' import { dropStructuredAgentSessionSends, reserveStructuredAgentSessionSend, @@ -306,7 +306,7 @@ export function settleStructuredAgentLaunchPrompt(args: { if (prompt?.createOwned) { prompt.createSend?.settle( error instanceof StructuredAgentSessionCreateRefusalError - ? { kind: 'refused', failure: agentSessionThrownFailure(error, error.code) } + ? { kind: 'refused', failure: structuredLaunchFailure(error) ?? { kind: 'failed' } } : { kind: 'unconfirmed' } ) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts b/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts index 2066ee816e8e..be659c18e897 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts @@ -258,7 +258,12 @@ describe('a new start beside an unconfirmed or retried chat', () => { it("opens a new chat with its text, not a draft, while a restored chat's Retry is in flight", async () => { const failed = launchIntent('session-failed') mocks.createIntent.mockReturnValueOnce(failed).mockReturnValueOnce(fresh) - mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) + mocks.launch.mockImplementationOnce(async (intent: StructuredAgentSessionLaunchIntent) => { + await Promise.resolve() + const issue = await intent.prepareCreate?.() + issue?.() + throw new StructuredAgentSessionCreateRefusalError('unsupported') + }) startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-3', prompt: 'first task' diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts index ba0fdc029505..b5c05623014b 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -367,6 +367,7 @@ describe('startStructuredAgentLaunch', () => { 'claude', undefined, undefined, + undefined, undefined ) expect(mocks.createIntent).toHaveBeenNthCalledWith( @@ -375,6 +376,7 @@ describe('startStructuredAgentLaunch', () => { 'codex', undefined, undefined, + undefined, undefined ) expect(mocks.launch).toHaveBeenCalledTimes(2) From fcf6ecf079088e2dd1cf0ad2817b0c75e63e9637 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:18:42 -0700 Subject: [PATCH 13/27] Revert the first-Send draft machinery, keeping the synchronous composer focus The redesign opens the new chat's editor at the action and lets the ordinary at-rest create run in the background, so the local draft record, saved first submission, reserved first-Send sender and pre-session upload capability are no longer needed. --- config/tsconfig.node.json | 2 - .../ipc/agent-session-attachment-upload.ts | 5 +- .../agent-session-attachment-store.ts | 3 +- .../structured-agent-session-attachments.ts | 13 +- .../app-shell/native-chat-draft-startup.ts | 8 - .../refresh-startup-remote-catalog.ts | 41 ---- .../app-shell/use-app-startup-hydration.ts | 41 +++- .../native-chat-attachment-upload.ts | 18 +- ...tive-chat-composer-draft-indexeddb.test.ts | 4 +- .../native-chat-composer-draft-indexeddb.ts | 8 +- .../native-chat-composer-draft-load.ts | 4 - .../native-chat-composer-draft-persistence.ts | 8 +- .../native-chat-composer-draft-storage.ts | 69 +----- .../native-chat-composer-draft-store.test.ts | 32 +-- .../native-chat-composer-draft-store.ts | 7 +- .../native-chat/native-chat-new-draft.ts | 214 ------------------ ...structured-agent-session-message-sender.ts | 53 +---- .../structured-agent-session-pending-sends.ts | 2 - .../structured-agent-session-send-runtime.ts | 9 - .../structured-agent-session-send-slot.ts | 3 - ...ession-status-bridge-failed-start.test.tsx | 8 +- ...n-status-bridge-unconfirmed-start.test.tsx | 7 +- ...se-native-chat-provisional-launch.test.tsx | 25 -- .../use-native-chat-provisional-launch.ts | 31 +-- ...tive-chat-structured-composer-transport.ts | 10 +- ...e-structured-agent-session-option-write.ts | 111 --------- .../use-structured-agent-session-options.ts | 120 +++++++--- ...launch-agent-structured-chat-guard.test.ts | 3 - .../lib/launch-structured-agent-session.ts | 36 +-- .../src/lib/native-chat-draft-owner.ts | 6 - .../src/lib/native-chat-draft-reload.ts | 75 ------ .../src/lib/native-chat-first-submission.ts | 86 ------- .../src/lib/native-chat-local-draft.test.ts | 136 ----------- .../src/lib/native-chat-local-draft.ts | 44 ---- ...agent-session-launch-after-failure.test.ts | 9 +- ...structured-agent-session-launch-callers.ts | 3 - ...ctured-agent-session-launch-create-call.ts | 57 +---- ...gent-session-launch-create-message.test.ts | 206 +---------------- ...red-agent-session-launch-create-message.ts | 96 +++----- .../structured-agent-session-launch-errors.ts | 3 - ...tured-agent-session-launch-options.test.ts | 10 +- ...structured-agent-session-launch-options.ts | 16 +- ...d-agent-session-launch-outcome-tracking.ts | 16 +- ...red-agent-session-launch-prompt-outcome.ts | 21 -- .../structured-agent-session-launch-prompt.ts | 130 +++++------ ...session-launch-publication-confirmation.ts | 57 ++--- ...ctured-agent-session-launch-publication.ts | 16 -- ...tructured-agent-session-launch-recovery.ts | 35 +-- ...tructured-agent-session-launch-registry.ts | 16 +- ...sion-launch-unconfirmed-or-retried.test.ts | 7 +- .../structured-agent-session-launch.test.ts | 2 - .../lib/structured-agent-session-launch.ts | 17 +- .../store/slices/tabs/tabs-close-actions.ts | 11 +- .../store/slices/tabs/tabs-move-actions.ts | 7 - src/shared/agent-session-attachments.ts | 7 +- src/shared/protocol-version.ts | 2 - .../agent-session-attachment-params.ts | 6 - 57 files changed, 357 insertions(+), 1635 deletions(-) delete mode 100644 src/renderer/src/app-shell/refresh-startup-remote-catalog.ts delete mode 100644 src/renderer/src/components/native-chat/native-chat-new-draft.ts delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-option-write.ts delete mode 100644 src/renderer/src/lib/native-chat-draft-reload.ts delete mode 100644 src/renderer/src/lib/native-chat-first-submission.ts delete mode 100644 src/renderer/src/lib/native-chat-local-draft.test.ts delete mode 100644 src/renderer/src/lib/native-chat-local-draft.ts diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 69424c1f2d95..fafa7267bc57 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -19,8 +19,6 @@ "../src/renderer/src/components/native-chat/native-chat-subagent-delegation.ts", "../src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts", "../src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts", - "../src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts", - "../src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts", "../src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts", "../src/renderer/src/components/terminal-pane/legacy-relay-bridge-panes.ts", "../src/preload/**/*", diff --git a/src/main/ipc/agent-session-attachment-upload.ts b/src/main/ipc/agent-session-attachment-upload.ts index a8aa89657ae8..258c5d355d9a 100644 --- a/src/main/ipc/agent-session-attachment-upload.ts +++ b/src/main/ipc/agent-session-attachment-upload.ts @@ -80,10 +80,7 @@ async function uploadAttachment( const { uploadId } = await callAttachmentMethod( context, 'agentSessionAttachment.uploadStart', - { - sessionId: context.sessionId, name: file.name, byteLength: file.byteLength, - ...(context.reservedSession ? { reservedSession: context.reservedSession } : {}) - } + { sessionId: context.sessionId, name: file.name, byteLength: file.byteLength } ) try { await writeBody((contentBase64, offset) => diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts index ed15e257ab8c..bd6862e4c3cf 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts @@ -74,9 +74,8 @@ export class AgentSessionAttachmentStore { sessionId: string name: string byteLength: number - reservedSessionAdmitted?: true }): Promise<{ uploadId: string }> { - if (!args.reservedSessionAdmitted && !this.options.hasSession(args.sessionId)) { + if (!this.options.hasSession(args.sessionId)) { throw new Error('This chat is not on this host') } const name = sanitizeAgentSessionAttachmentName(args.name) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts index 8456aa08dfba..eb25df049c4e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts @@ -8,7 +8,6 @@ import { defineMethod, type RpcContext } from '../core' import { requireInstalledStructuredHost, - requireStructuredCreateSupportAdmission, structuredCallerFor } from './structured-agent-session-gate' import { @@ -20,8 +19,6 @@ import { import { remoteFileContentBudget } from './files-remote-content-budget' import type { AgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store' import { getAgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store-registry' -import { isStructuredAgentSessionIdFor } from '../../../../shared/structured-agent-session-create' -import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' async function requireStore( ctx: RpcContext @@ -41,15 +38,7 @@ export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ params: AttachmentUploadStartParams, handler: async (params, ctx) => { const { store, callerKey } = await requireStore(ctx) - const reserved = params.reservedSession - if (reserved) { - requireStructuredCreateSupportAdmission(ctx, reserved.agent) - const support = await ctx.runtime.getStructuredAgentSessionCreateSupport(reserved.worktree, reserved.agent) - if (!support.supported || !isStructuredAgentSessionIdFor(reserved.agent, params.sessionId)) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) - } - } - return store.startUpload({ callerKey, ...params, ...(reserved ? { reservedSessionAdmitted: true as const } : {}) }) + return store.startUpload({ callerKey, ...params }) } }), defineMethod({ diff --git a/src/renderer/src/app-shell/native-chat-draft-startup.ts b/src/renderer/src/app-shell/native-chat-draft-startup.ts index c1aebac7f957..4d64bdd708f2 100644 --- a/src/renderer/src/app-shell/native-chat-draft-startup.ts +++ b/src/renderer/src/app-shell/native-chat-draft-startup.ts @@ -23,11 +23,3 @@ export function startNativeChatDraftLoad(): void { export function waitForNativeChatDraftsAtStartup(): Promise { return waitForNativeChatComposerDrafts(DRAFT_LOAD_WAIT_MS) } - -/** Reconciliation reads host facts; a restored marker never grants execution or focus. */ -export function reconcileNativeChatDraftsAtStartup(): void { - void hydrateNativeChatComposerDrafts().then(async () => { - const { reconcileRestoredNativeChatDrafts } = await import('../lib/native-chat-draft-reload') - await reconcileRestoredNativeChatDrafts() - }).catch((error: unknown) => console.warn('[native-chat-drafts] recovery could not finish', error)) -} diff --git a/src/renderer/src/app-shell/refresh-startup-remote-catalog.ts b/src/renderer/src/app-shell/refresh-startup-remote-catalog.ts deleted file mode 100644 index 314c1800fefc..000000000000 --- a/src/renderer/src/app-shell/refresh-startup-remote-catalog.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useAppStore } from '../store' -import { timeRendererStartupStep } from '../startup/startup-diagnostics' -import type { useStartupActions } from './use-app-startup-actions' - -/** Network refresh stays outside the hydration gate; only authoritative results prune workspaces. */ -export async function refreshStartupRemoteCatalog(actions: ReturnType, isCancelled: () => boolean): Promise { - try { - try { - // Why: remote rows must not render under a fallback visibility while their owner default is still loading. - await timeRendererStartupStep('owner-visibility-defaults', () => - actions.awaitOwnerWorktreeVisibilityDefaultsHydration() - ) - await timeRendererStartupStep('remote-catalog-refresh', async () => { - await actions.fetchReposForAllHosts() - await actions.fetchProjectGroupsForAllHosts() - await actions.fetchFolderWorkspacesForAllHosts() - }) - } catch (err) { - console.warn('Remote startup catalog refresh failed:', err) - } - if (!isCancelled()) { - try { - await timeRendererStartupStep('remote-worktree-refresh', async () => { - // Why: the full scan is not required for session recovery, so keep it off the startup-critical path. - await actions.fetchAllWorktrees() - // Why: the startup prune only saw session-referenced repos; use the deferred scan's - // authoritative results to drop deleted-worktree visit timestamps that would - // otherwise accumulate unbounded (disconnected SSH stays non-authoritative and is kept). - actions.pruneLastVisitedTimestamps() - await actions.fetchWorktreeLineage() - }) - } catch (err) { - console.warn('Deferred startup worktree refresh failed:', err) - } - } - } finally { - if (!isCancelled()) { - useAppStore.setState({ startupWorktreeRefreshCompleted: true }) - } - } -} diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index cdbdad52fa84..0c0ab0dd5436 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -4,9 +4,8 @@ import { syncZoomCSSVar } from '@/lib/ui-zoom' import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler' import { useAppStore } from '../store' import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models' -import { refreshStartupRemoteCatalog } from './refresh-startup-remote-catalog' import { useStartupActions } from './use-app-startup-actions' -import { reconcileNativeChatDraftsAtStartup, waitForNativeChatDraftsAtStartup } from './native-chat-draft-startup' +import { waitForNativeChatDraftsAtStartup } from './native-chat-draft-startup' import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees' import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep' import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-hydration' @@ -215,7 +214,6 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta useAppStore.getState().reconcileWorktreeTabModels ) }) - reconcileNativeChatDraftsAtStartup() await timeRendererStartupStep('prepare-terminal-startup-restoration', () => window.api.app.prepareTerminalStartupRestoration() ) @@ -311,7 +309,42 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta logRendererStartupDiagnostic('startup-hydration-done', { durationMs: Math.round(performance.now() - startupStartedAt) }) - void refreshStartupRemoteCatalog(actions, () => cancelled) + void (async () => { + try { + try { + // Why: remote rows must not render under a fallback visibility while their owner default is still loading. + await timeRendererStartupStep('owner-visibility-defaults', () => + actions.awaitOwnerWorktreeVisibilityDefaultsHydration() + ) + await timeRendererStartupStep('remote-catalog-refresh', async () => { + await actions.fetchReposForAllHosts() + await actions.fetchProjectGroupsForAllHosts() + await actions.fetchFolderWorkspacesForAllHosts() + }) + } catch (err) { + console.warn('Remote startup catalog refresh failed:', err) + } + if (!cancelled) { + try { + await timeRendererStartupStep('remote-worktree-refresh', async () => { + // Why: the full scan is not required for session recovery, so keep it off the startup-critical path. + await actions.fetchAllWorktrees() + // Why: the startup prune only saw session-referenced repos; use the deferred scan's + // authoritative results to drop deleted-worktree visit timestamps that would + // otherwise accumulate unbounded (disconnected SSH stays non-authoritative and is kept). + actions.pruneLastVisitedTimestamps() + await actions.fetchWorktreeLineage() + }) + } catch (err) { + console.warn('Deferred startup worktree refresh failed:', err) + } + } + } finally { + if (!cancelled) { + useAppStore.setState({ startupWorktreeRefreshCompleted: true }) + } + } + })() } } catch (error) { await recoverFromDegradedStartup({ diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index fe313f666798..552f6fed2f70 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -22,13 +22,10 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { RuntimeStatus } from '../../../../shared/runtime-types' import { AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../../shared/agent-session-write-notice-copy' -import { - AGENT_SESSION_DRAFT_ATTACHMENTS_RUNTIME_CAPABILITY, - type AgentSessionReservedAttachmentSession, - type AgentSessionAttachmentPathUploadResult, - type AgentSessionAttachmentUploadTarget +import type { + AgentSessionAttachmentPathUploadResult, + AgentSessionAttachmentUploadTarget } from '../../../../shared/agent-session-attachments' -import { readNativeChatNewDraft } from './native-chat-new-draft' import type { AppState } from '@/store/types' import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop' import { findTerminalTabWorktreeId } from './native-chat-file-link' @@ -134,10 +131,7 @@ export type NativeChatStructuredAttachmentSession = { export function resolveNativeChatRuntimeSessionAttachmentOwner(session: { sessionId: string runtimeEnvironmentId: string -}, stageDraft = true): NativeChatAttachmentOwner { - if (stageDraft && readNativeChatNewDraft(session.sessionId)) { - return { kind: 'local' } - } +}): NativeChatAttachmentOwner { const pairingRevision = getRuntimeEnvironmentRevision(session.runtimeEnvironmentId) if (pairingRevision === undefined) { return { kind: 'not-ready' } @@ -203,8 +197,7 @@ export { nativeChatLocalAttachmentUnsupportedNotice } from './native-chat-compos * upload: a path from this machine would mean nothing to its agent. */ export async function prepareNativeChatSessionAttachmentUpload( - owner: NativeChatRuntimeSessionAttachmentOwner, - reservedSession?: AgentSessionReservedAttachmentSession + owner: NativeChatRuntimeSessionAttachmentOwner ): Promise< { ok: true; target: AgentSessionAttachmentUploadTarget } | { ok: false; notice: string } > { @@ -231,7 +224,6 @@ export async function prepareNativeChatSessionAttachmentUpload( sessionId: owner.sessionId, expectedEnvironmentPairingRevision: owner.pairingRevision, expectedEnvironmentRuntimeId: status.runtimeId - ,...(reservedSession && status.capabilities?.includes(AGENT_SESSION_DRAFT_ATTACHMENTS_RUNTIME_CAPABILITY) ? { reservedSession } : {}) } } } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts index c82e85e3ae65..af2b7cf889d1 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.test.ts @@ -69,7 +69,7 @@ function fakeDatabase(): { } const DRAFT = { text: 'unsent', images: [], savedAt: 1 } -const settledYet = async (promise: Promise): Promise => { +const settledYet = async (promise: Promise): Promise => { let settled = false void promise.then(() => (settled = true)) await new Promise((resolve) => setTimeout(resolve, 0)) @@ -97,7 +97,7 @@ describe('the IndexedDB draft storage', () => { expect(await settledYet(update)).toBe(false) expect(puts).toEqual([DRAFT]) transactions[0]!.oncomplete?.() - await expect(update).resolves.toEqual(DRAFT) + await expect(update).resolves.toBeUndefined() }) it('rejects a write whose transaction aborts, as on a full disk', async () => { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts index 9c376b70f4c2..32864af94ce5 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-indexeddb.ts @@ -3,7 +3,7 @@ import type { NativeChatComposerDraftStorage, - NativeChatStoredDraft + StoredNativeChatComposerDraft } from './native-chat-composer-draft-storage' const DATABASE_NAME = 'orca-native-chat-composer-drafts' @@ -85,7 +85,7 @@ export function createIndexedDbNativeChatComposerDraftStorage( }, read: async (scopeKey) => settled((await database()).transaction(DRAFTS, 'readonly').objectStore(DRAFTS).get(scopeKey)), - write: (scopeKey, draft: NativeChatStoredDraft) => + write: (scopeKey, draft: StoredNativeChatComposerDraft) => change((drafts) => drafts.put(draft, scopeKey)), remove: (scopeKeys) => change((drafts) => { @@ -97,11 +97,10 @@ export function createIndexedDbNativeChatComposerDraftStorage( const transaction = (await database()).transaction(DRAFTS, 'readwrite') const done = committed(transaction) const drafts = transaction.objectStore(DRAFTS) - let next: NativeChatStoredDraft | null = null const read = drafts.get(scopeKey) // Why inside the read's callback: the write must land in the same transaction as the read. read.onsuccess = () => { - next = apply(read.result) + const next = apply(read.result) if (next) { drafts.put(next, scopeKey) } else { @@ -110,7 +109,6 @@ export function createIndexedDbNativeChatComposerDraftStorage( transaction.commit?.() } await done - return next } } } diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts index 45d4a2996128..4912869e3332 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-load.ts @@ -25,7 +25,6 @@ import { removeLegacyLocalStorageNativeChatComposerDrafts, type StoredNativeChatComposerDraft } from './native-chat-composer-draft-storage' -import { loadNativeChatNewDraft } from './native-chat-new-draft' // Why bounded: a database that will not open must not be retried for the whole run; after the // last try the drafts stay in memory and a refused save shows as one. @@ -60,9 +59,6 @@ function applyLoaded( ): void { const drafts = new Map() for (const [scopeKey, value] of loaded) { - if (loadNativeChatNewDraft(scopeKey, value)) { - continue - } drafts.set(scopeKey, parseStoredNativeChatComposerDraft(value)) } for (const scopeKey of replayNativeChatComposerDraftJournal(drafts, journal)) { diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts index 2827c7bb6dcf..dc9e0a3d139e 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts @@ -2,7 +2,6 @@ // storage has not confirmed when the window goes away, and follows other windows' writes. import { basename } from '@/lib/path' -import { invalidateNativeChatNewDraft } from './native-chat-new-draft' import { isNativeChatKeptPastePath, isNativeChatPastedImagePath } from './native-chat-image-paste' import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' import { @@ -41,11 +40,6 @@ const inFlight = new Set>() const scopeWrites = new Map>>() let channel: BroadcastChannel | null = null -export function broadcastNativeChatComposerDraftChange(scopeKey: string): void { - installNativeChatComposerDraftBroadcast() - channel?.postMessage({ scopeKey }) -} - /** An image the draft names but can no longer send, so the user can attach it again. */ export function unavailableNativeChatComposerDraftImage( image: NativeChatComposerDraftImage @@ -263,7 +257,7 @@ function adoptForeignChange(message: unknown): void { typeof message === 'object' && message !== null && 'scopeKey' in message ? message.scopeKey : null - if (typeof scopeKey !== 'string' || invalidateNativeChatNewDraft(scopeKey) || hasLocalChange(scopeKey)) { + if (typeof scopeKey !== 'string' || hasLocalChange(scopeKey)) { return } void nativeChatComposerDraftStorage() diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts index 0904cd7379f5..bc38203f2154 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-storage.ts @@ -5,17 +5,6 @@ import type { JSONContent } from '@tiptap/react' import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { createIndexedDbNativeChatComposerDraftStorage } from './native-chat-composer-draft-indexeddb' -import { isTuiAgent } from '../../../../shared/tui-agent-config' -import type { TuiAgent } from '../../../../shared/tui-agent' - -export type NativeChatNewDraft = { - readonly owner: NativeChatComposerDraftOwner - readonly agent: TuiAgent - readonly groupId: string - readonly options: Readonly> -} - -export type NativeChatStoredDraft = StoredNativeChatComposerDraft | NativeChatNewDraft export type NativeChatComposerDraftImage = { id: string @@ -49,13 +38,13 @@ export type StoredNativeChatComposerDraft = NativeChatComposerDraft & { export type NativeChatComposerDraftStorage = { loadAll(): Promise> read(scopeKey: string): Promise - write(scopeKey: string, draft: NativeChatStoredDraft): Promise + write(scopeKey: string, draft: StoredNativeChatComposerDraft): Promise remove(scopeKeys: readonly string[]): Promise /** Reads the stored record and writes what `apply` makes of it, as one change. */ update( scopeKey: string, - apply: (stored: unknown) => NativeChatStoredDraft | null - ): Promise + apply: (stored: unknown) => StoredNativeChatComposerDraft | null + ): Promise } /** Drafts kept for this run only: the storage of an environment without IndexedDB (unit tests), @@ -65,51 +54,38 @@ export function createMemoryNativeChatComposerDraftStorage(): NativeChatComposer refuseWrites: boolean } { const drafts = new Map() - const metadata = new Map() - const put = (key: string, draft: NativeChatStoredDraft): void => { - if ('text' in draft) { - drafts.set(key, draft) - } else { - metadata.set(key, draft) - } - } const storage = { drafts, refuseWrites: false, - loadAll: async () => new Map([...drafts, ...metadata]), - read: async (scopeKey: string) => drafts.get(scopeKey) ?? metadata.get(scopeKey), - write: (scopeKey: string, draft: NativeChatStoredDraft) => { + loadAll: async () => new Map(drafts), + read: async (scopeKey: string) => drafts.get(scopeKey), + write: (scopeKey: string, draft: StoredNativeChatComposerDraft) => { if (storage.refuseWrites) { return Promise.reject(new DOMException('refused', 'QuotaExceededError')) } - put(scopeKey, draft) + drafts.set(scopeKey, draft) return Promise.resolve() }, remove: (scopeKeys: readonly string[]) => { - if (storage.refuseWrites) { - return Promise.reject(new DOMException('refused', 'QuotaExceededError')) - } for (const scopeKey of scopeKeys) { drafts.delete(scopeKey) - metadata.delete(scopeKey) } return Promise.resolve() }, update: ( scopeKey: string, - apply: (stored: unknown) => NativeChatStoredDraft | null + apply: (stored: unknown) => StoredNativeChatComposerDraft | null ) => { if (storage.refuseWrites) { return Promise.reject(new DOMException('refused', 'QuotaExceededError')) } - const next = apply(drafts.get(scopeKey) ?? metadata.get(scopeKey)) + const next = apply(drafts.get(scopeKey)) if (next) { - put(scopeKey, next) + drafts.set(scopeKey, next) } else { drafts.delete(scopeKey) - metadata.delete(scopeKey) } - return Promise.resolve(next) + return Promise.resolve() } } return storage @@ -168,23 +144,6 @@ function parseOwner(value: unknown): NativeChatComposerDraftOwner | undefined { : undefined } -export function parseNativeChatNewDraft(value: unknown): NativeChatNewDraft | null { - if (!isRecord(value) || !isTuiAgent(value.agent) || typeof value.groupId !== 'string') { - return null - } - const owner = parseOwner(value.owner) - if (!owner || !isRecord(value.options) || !Object.values(value.options).every((v) => typeof v === 'string')) { - return null - } - const options: Record = {} - for (const [key, pick] of Object.entries(value.options)) { - if (typeof pick === 'string') { - options[key] = pick - } - } - return { owner, agent: value.agent, groupId: value.groupId, options } -} - /** A stored value as a draft, or null when it isn't one (a newer or damaged record). */ export function parseStoredNativeChatComposerDraft( value: unknown @@ -206,12 +165,6 @@ export function parseStoredNativeChatComposerDraft( } } -/** Select recovery content without retaining mutable draft-store bookkeeping. */ -export function projectNativeChatComposerDraft(draft: NativeChatComposerDraft): NativeChatComposerDraft { - const { text, document, images } = parseStoredNativeChatComposerDraft({ ...draft, savedAt: 0 }) ?? { text: '', images: [] } - return { text, ...(document ? { document } : {}), images } -} - const LEGACY_LOCAL_STORAGE_PREFIX = 'orca:nativeChatComposerDraft:v1:' /** Drafts this change kept in localStorage before it moved them; never released, so they go. */ diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index 4b10819cd674..c25c67cd0343 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -121,9 +121,10 @@ describe('native-chat composer draft store', () => { const slow = { ...storage, write: (scopeKey: string, draft: Parameters[1]) => - new Promise((resolve, reject) => { + new Promise((resolve) => { pending.push(() => { - void storage.write(scopeKey, draft).then(resolve, reject) + storage.drafts.set(scopeKey, draft) + resolve() }) }) } @@ -161,7 +162,7 @@ describe('native-chat composer draft store', () => { ...storage, loadAll: () => new Promise>(() => {}), update: (scopeKey: string, apply: Parameters[1]) => - new Promise>>((resolve) => { + new Promise((resolve) => { complete = () => { void storage.update(scopeKey, apply).then(resolve) } @@ -209,12 +210,11 @@ describe('native-chat composer draft store', () => { const aStorage = { ...storage, write: (scopeKey: string, draft: Parameters[1]) => - new Promise((resolve, reject) => { + new Promise((resolve) => { commitA = () => { - void storage.write(scopeKey, draft).then(() => { - resolve() - aCommitted() - }, reject) + storage.drafts.set(scopeKey, draft) + resolve() + aCommitted() } }) } @@ -231,7 +231,7 @@ describe('native-chat composer draft store', () => { // IndexedDB orders B's write after A's, which was issued first. write: async (scopeKey: string, draft: Parameters[1]) => { await aDone - await storage.write(scopeKey, draft) + storage.drafts.set(scopeKey, draft) } } const b = await reload({ using: bStorage, hydrate: false }) @@ -274,9 +274,9 @@ describe('native-chat composer draft store', () => { storage.drafts.set('agent-session:s1', { text: 'typed earlier', images: [], savedAt: 1 }) const landsUnconfirmed = { ...storage, - write: async (scopeKey: string, draft: Parameters[1]) => { - await storage.write(scopeKey, draft) - await new Promise(() => {}) + write: (scopeKey: string, draft: Parameters[1]) => { + storage.drafts.set(scopeKey, draft) + return new Promise(() => {}) } } const crashed = await reload({ using: landsUnconfirmed }) @@ -302,7 +302,7 @@ describe('native-chat composer draft store', () => { land = () => resolve(snapshot) }) }, - update: () => new Promise>>(() => {}) + update: () => new Promise(() => {}) } const crashed = await reload({ using: crashing, hydrate: false }) await crashed.store.waitForNativeChatComposerDrafts(1) @@ -733,9 +733,9 @@ describe('native-chat composer draft store', () => { // Tab A's write lands, but the page goes away before it is confirmed. const landsUnconfirmed = { ...storage, - write: async (scopeKey: string, draft: Parameters[1]) => { - await storage.write(scopeKey, draft) - await new Promise(() => {}) + write: (scopeKey: string, draft: Parameters[1]) => { + storage.drafts.set(scopeKey, draft) + return new Promise(() => {}) } } const tabA = await reload({ using: landsUnconfirmed }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts index b64602616a4a..f263a569cd25 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -4,7 +4,6 @@ // skipped write is repaired by the next one. import type { JSONContent } from '@tiptap/react' -import { deleteNativeChatNewDraftsOwnedBy, resetNativeChatNewDraftsForTests, nativeChatNewDraftKey } from './native-chat-new-draft' import { sameNativeChatComposerDraftDocument, sameNativeChatComposerDraftImages @@ -120,9 +119,7 @@ export function markNativeChatComposerDraftVerified(scopeKey: string): void { /** A draft storage refused to save: it is kept in memory only until a later write lands. */ export function isNativeChatComposerDraftUnsaved(scopeKey: string): boolean { - const sessionId = structuredAgentSessionIdOfDraftScope(scopeKey) - return (refusedScopes.has(scopeKey) && records.has(scopeKey)) || - (sessionId !== null && refusedScopes.has(nativeChatNewDraftKey(sessionId))) + return refusedScopes.has(scopeKey) && records.has(scopeKey) } function isEmptyDraft(draft: NativeChatComposerDraft): boolean { @@ -302,7 +299,6 @@ export function deleteNativeChatComposerDraftsForTab(tabId: string): void { /** Drops every draft written in a workspace the user removed, open and closed chats alike. */ export function deleteNativeChatComposerDraftsOwnedBy(owner: NativeChatComposerDraftOwner): void { - deleteNativeChatNewDraftsOwnedBy(owner) deleteDraftsWhere( (_scopeKey, draft) => draft?.owner?.workspaceId === owner.workspaceId && @@ -311,7 +307,6 @@ export function deleteNativeChatComposerDraftsOwnedBy(owner: NativeChatComposerD } export function clearNativeChatComposerDraftsForTests(): void { - resetNativeChatNewDraftsForTests() clearDraftMemoryForTests() resetNativeChatComposerDraftPersistenceForTests() resetNativeChatComposerDraftLoadForTests() diff --git a/src/renderer/src/components/native-chat/native-chat-new-draft.ts b/src/renderer/src/components/native-chat/native-chat-new-draft.ts deleted file mode 100644 index 56c7aa3a9a6f..000000000000 --- a/src/renderer/src/components/native-chat/native-chat-new-draft.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { useSyncExternalStore } from 'react' -import { getAuthoritativelyRemovedWorktreeIds } from '@/store/slices/worktrees/listing/authoritative-worktree-removal-memory' -import { - nativeChatComposerDraftStorage, - parseNativeChatNewDraft, - type NativeChatComposerDraftOwner, - type NativeChatNewDraft -} from './native-chat-composer-draft-storage' -import { notifyScope, refusedScopes, scopeListeners } from './native-chat-composer-draft-memory' -import { hostSessionTabIdsByLocalTabForWorktree } from '@/runtime/web-session-tabs-sync/tracking-mappings' -import { structuredAgentSessionFocusOwner, structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' -import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection' - -const PREFIX = 'agent-session-new:' -const drafts = new Map() -const changed = new Set() -const pending = new Map>() -const invalidated = new Set() -const revisions = new Map() -const reading = new Set() -const unsavedAllocations = new Set() - -function broadcastNativeChatComposerDraftChange(key: string): void { - // Persistence reads draft metadata; only a committed write needs its broadcast channel. - void import('./native-chat-composer-draft-persistence').then((persistence) => { - persistence.broadcastNativeChatComposerDraftChange(key) - }).catch((error: unknown) => console.warn('[native-chat-drafts] the saved draft could not be announced', error)) -} - -export function nativeChatNewDraftKey(sessionId: string): string { - return `${PREFIX}${sessionId}` -} - -export function readNativeChatNewDraft(sessionId: string): NativeChatNewDraft | undefined { - return drafts.get(nativeChatNewDraftKey(sessionId)) -} - -export function nativeChatNewDrafts(): [string, NativeChatNewDraft][] { - return [...drafts].map<[string, NativeChatNewDraft]>(([key, draft]) => [key.slice(PREFIX.length), draft]) -} - -function notify(key: string): void { - notifyScope(key) - notifyScope(`agent-session:${key.slice(PREFIX.length)}`) -} - -/** Publication ownership is already recorded by the host mirror, independently of draft storage. */ -export function nativeChatNewDraftIsHosted(sessionId: string, draft: NativeChatNewDraft): boolean { - const target = structuredAgentSessionTargetForHost(draft.owner.executionHostId) - return target !== null && hostSessionTabIdsByLocalTabForWorktree( - structuredAgentSessionFocusOwner(target).environmentId, draft.owner.workspaceId - ).has(structuredAgentSessionTabId(sessionId)) -} - -function adopt(key: string, draft: NativeChatNewDraft | null): void { - if (draft && getAuthoritativelyRemovedWorktreeIds(draft.owner.executionHostId)?.has(draft.owner.workspaceId)) { - void retireNativeChatNewDraft(key.slice(PREFIX.length)) - return - } - if (draft) { - unsavedAllocations.delete(key) - refusedScopes.delete(key) - drafts.set(key, draft) - } else if (!unsavedAllocations.has(key)) { - refusedScopes.delete(key) - drafts.delete(key) - } - notify(key) -} - -function reconcile(key: string): void { - if (pending.has(key) || reading.has(key) || !invalidated.has(key)) { - return - } - const revision = revisions.get(key) - invalidated.delete(key) - reading.add(key) - void nativeChatComposerDraftStorage().read(key).then((value) => { - reading.delete(key) - if (revision !== revisions.get(key) || pending.has(key)) { - invalidated.add(key) - } else { - adopt(key, parseNativeChatNewDraft(value)) - } - reconcile(key) - }, (error: unknown) => { - reading.delete(key) - invalidated.add(key) - failed(key, error) - }) -} - -/** Foreign messages invalidate; the owner reads after local transactions have drained. */ -export function invalidateNativeChatNewDraft(key: string): boolean { - if (!key.startsWith(PREFIX)) { - return false - } - revisions.set(key, Symbol()) - invalidated.add(key) - reconcile(key) - return true -} - -function track(key: string, apply: () => Promise): Promise { - changed.add(key) - revisions.set(key, Symbol()) - const previous = pending.get(key) - const write = (previous ? previous.catch(() => null) : Promise.resolve()).then(apply) - pending.set(key, write) - return write.then((draft) => { - refusedScopes.delete(key) - broadcastNativeChatComposerDraftChange(key) - if (pending.get(key) === write) { - adopt(key, draft) - } - return true - }, (error: unknown) => failed(key, error)).finally(() => { - if (pending.get(key) === write) { - pending.delete(key) - reconcile(key) - } - }) -} - -function failed(key: string, error: unknown): false { - console.warn('[native-chat-drafts] a new chat could not be saved', error) - refusedScopes.add(key) - notify(key) - return false -} - -export function loadNativeChatNewDraft(key: string, value: unknown, foreign = false): boolean { - if (foreign) { - return invalidateNativeChatNewDraft(key) - } - if (!key.startsWith(PREFIX)) { - return false - } - if (!changed.has(key)) { - adopt(key, parseNativeChatNewDraft(value)) - } - return true -} - -export function allocateNativeChatNewDraft(sessionId: string, draft: NativeChatNewDraft): void { - const key = nativeChatNewDraftKey(sessionId) - unsavedAllocations.add(key) - drafts.set(key, draft) - notify(key) - void track(key, async () => { - await nativeChatComposerDraftStorage().write(key, draft) - unsavedAllocations.delete(key) - return draft - }) -} - -export function updateNativeChatNewDraft( - sessionId: string, - change: Partial> -): void { - const key = nativeChatNewDraftKey(sessionId) - const current = drafts.get(key) - if (!current) { - return - } - const next = { ...current, ...change } - drafts.set(key, next) - notify(key) - // Only allocation inserts metadata, so a stale window cannot revive a retired draft. - void track(key, async () => parseNativeChatNewDraft(await nativeChatComposerDraftStorage().update(key, (stored) => { - const saved = parseNativeChatNewDraft(stored) - return saved ? { ...saved, ...change } : null - }))) -} - -export async function retireNativeChatNewDraft(sessionId: string): Promise { - const key = nativeChatNewDraftKey(sessionId) - return track(key, async () => { - await nativeChatComposerDraftStorage().remove([key]) - unsavedAllocations.delete(key) - return null - }) -} - -export function deleteNativeChatNewDraftsOwnedBy(owner: NativeChatComposerDraftOwner): void { - for (const [key, draft] of drafts) { - if (draft.owner.workspaceId === owner.workspaceId && draft.owner.executionHostId === owner.executionHostId) { - void retireNativeChatNewDraft(key.slice(PREFIX.length)) - } - } -} - -export function useNativeChatNewDraft(sessionId: string): NativeChatNewDraft | undefined { - return useSyncExternalStore( - (listener) => { - const key = nativeChatNewDraftKey(sessionId) - const listeners = scopeListeners.get(key) ?? new Set() - scopeListeners.set(key, listeners) - listeners.add(listener) - return () => { listeners.delete(listener) } - }, - () => readNativeChatNewDraft(sessionId) - ) -} - -export function resetNativeChatNewDraftsForTests(): void { - drafts.clear() - changed.clear() - pending.clear() - invalidated.clear() - revisions.clear() - reading.clear() - unsavedAllocations.clear() -} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts index 4e753f25c2e5..d61a06959652 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-sender.ts @@ -17,7 +17,6 @@ import { agentSessionUnconfirmedSendParts, agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' -import type { AgentSessionWriteNoticePart } from '../../../../shared/agent-session-write-notice-copy' import type { AgentSessionWriteFailure } from '../../../../shared/agent-session-write-failure' import { structuredAgentSessionReadCoversSend, @@ -113,18 +112,15 @@ export function sendStructuredAgentSessionMessage( } export type StructuredAgentSessionReservedSend = { - clientMessageId: string - setAttachments: (attachments: NonNullable) => void - prepare: () => boolean outcome: Promise /** Older creates wait for acquisition; their send budget still begins at send. */ legacy: () => void /** Sends it once the chat exists; null when the reservation was released or dropped meanwhile. */ send: (target: RuntimeClientTarget) => StructuredAgentSessionSent | null /** The same message, owned by create instead of a later send request. */ - create: (target: RuntimeClientTarget, prepared?: true) => StructuredAgentSessionCreateSend | null + create: (target: RuntimeClientTarget) => StructuredAgentSessionCreateSend | null /** Gives the slot back unsent. */ - release: (notice?: readonly AgentSessionWriteNoticePart[] | null) => void + release: () => void } export type StructuredAgentSessionCreateSend = StructuredAgentSessionSent & { @@ -156,7 +152,6 @@ export function reserveStructuredAgentSessionSend( }) const held = (): StructuredAgentSessionPendingSend | undefined => sent ? undefined : findStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId) - let preparing = false const deadline = setTimeout(() => { const current = held() if (current) { @@ -165,33 +160,6 @@ export function reserveStructuredAgentSessionSend( } }, STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS) return { - clientMessageId: entry.clientMessageId, - prepare: () => { - const current = held() - if (!current) { - return false - } - if (!preparing && Date.now() - current.queuedAt >= STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS) { - handBack(current, ['unreachable', ...agentSessionWriteNotDoneParts('composer-send')]) - settleReservation('returned') - return false - } - preparing = true - clearTimeout(deadline) - return true - }, - setAttachments: (attachments) => { - const current = held() - if (current) { - updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, { - body: { ...current.body, blocks: [ - ...current.body.blocks.filter((block) => block.type !== 'image-ref'), - ...attachments.map((image) => ({ type: 'image-ref' as const, path: image.path })) - ] }, - previewUris: attachments.map((image) => image.previewUri) - }) - } - }, outcome, legacy: () => clearTimeout(deadline), send: (target) => { @@ -204,14 +172,14 @@ export function reserveStructuredAgentSessionSend( } return sending }, - create: (target, prepared) => { + create: (target) => { const current = held() if (!current) { return null } sent = true clearTimeout(deadline) - const remaining = prepared ? STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS : STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS - (Date.now() - current.queuedAt) + const remaining = STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS - (Date.now() - current.queuedAt) if (remaining <= 0) { handBack(current, ['unreachable', ...agentSessionWriteNotDoneParts('composer-send')]) settleReservation('returned') @@ -248,16 +216,11 @@ export function reserveStructuredAgentSessionSend( } } }, - release: (notice) => { + release: () => { clearTimeout(deadline) - const current = held() - if (current) { - if (notice === undefined) { - updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, null) - } else { - handBack(current, notice) - } - settleReservation(notice === undefined ? 'dropped' : 'returned') + if (held()) { + updateStructuredAgentSessionPendingSend(entry.sessionId, entry.clientMessageId, null) + settleReservation('dropped') } } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts b/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts index a3828ee00b16..a96c533f8b30 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts @@ -2,7 +2,6 @@ // chat's view reads. import type { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-send-mutation' -import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' import type { AgentJournalCursor } from '../../../../shared/agent-session-journal-types' export type StructuredAgentSessionPendingSend = { @@ -24,7 +23,6 @@ export type StructuredAgentSessionPendingSend = { issued: boolean /** A sender outside the chat keeps its own copy, so nothing goes to the chat's composer. */ callerKeepsText?: true - sourceDraft?: NativeChatComposerDraft /** Made while the chat read Stopping: drawn after that turn until the host records it. */ sentWhileStopping?: true /** Each image's SSH connection, in body order, so a returned image still opens remotely. */ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts b/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts index 1e6543f7e266..e15628df6c0b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-runtime.ts @@ -6,7 +6,6 @@ import { agentSessionWriteNoticeParts, agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' -import { readNativeChatComposerDraft, updateNativeChatComposerDraft } from './native-chat-composer-draft-store' import type { AgentSessionWriteNoticePart } from '../../../../shared/agent-session-write-notice-copy' import type { AgentSessionWriteFailure } from '../../../../shared/agent-session-write-failure' import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' @@ -53,14 +52,6 @@ export function finish( /** Puts the text back in the chat's composer; false, reported, when the draft write threw. */ function returnToComposer(entry: StructuredAgentSessionPendingSend): boolean { try { - if (entry.sourceDraft) { - const scopeKey = `agent-session:${entry.sessionId}` - const current = readNativeChatComposerDraft(scopeKey) - if (!current.text && !current.images.length) { - updateNativeChatComposerDraft(scopeKey, entry.sourceDraft, 'immediate') - } - return true - } handBackStructuredAgentSessionMessage( entry.sessionId, entry.clientMessageId, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts b/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts index 15587f249503..aad2f2edbc5e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-send-slot.ts @@ -6,7 +6,6 @@ import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment } from '../../../../shared/structured-agent-session-send-mutation' -import type { NativeChatComposerDraft } from './native-chat-composer-draft-storage' import { createBrowserUuid } from '@/lib/browser-uuid' import { getStructuredAgentSessionPendingSends, @@ -25,7 +24,6 @@ export type StructuredAgentSessionSendInput = { options?: Readonly> /** The caller keeps the text if it comes back, instead of the chat's composer. */ callerKeepsText?: true - sourceDraft?: NativeChatComposerDraft /** Made while the chat read Stopping: drawn after that turn until the host records it. */ sentWhileStopping?: true now?: number @@ -45,7 +43,6 @@ export function takeStructuredAgentSessionSendSlot( body: structuredAgentSessionSendBody(input.text, attachments), previewUris: attachments.map((attachment) => attachment.previewUri), queuedAt: input.now ?? Date.now(), - ...(input.sourceDraft ? { sourceDraft: input.sourceDraft } : {}), ...(input.delivery ? { delivery: input.delivery } : {}), ...(input.options ? { options: input.options } : {}), ...(input.callerKeepsText ? { callerKeepsText: true as const } : {}), diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx index 61f2d38b9300..1f25263f53ab 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-failed-start.test.tsx @@ -200,12 +200,7 @@ let failedStarts = 0 async function failStart(): Promise { mocks.createIntent.mockReturnValueOnce(intent) - mocks.launch.mockImplementationOnce(async (intent: StructuredAgentSessionLaunchIntent) => { - await Promise.resolve() - const issue = await intent.prepareCreate?.() - issue?.() - throw new StructuredAgentSessionCreateRefusalError('refused') - }) + mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('refused')) failedStarts += 1 // Each failed start stands for its own user action. const launch = startStructuredAgentLaunch(WORKTREE_ID, 'claude', { @@ -218,7 +213,6 @@ async function failStart(): Promise { describe('a chat whose start failed', () => { beforeEach(() => { vi.clearAllMocks() - delete intent.createPrepared localStorage.clear() resetStructuredAgentLaunchRegistryForTests() resetStructuredAgentLaunchPersistenceForTests() diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx index 68fa9ce36064..06d1dcd6b76f 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx @@ -307,12 +307,7 @@ describe('a chat whose start was never confirmed', () => { it('re-checks a start a reload interrupted, without the user pressing Retry', async () => { await connect() mocks.createIntent.mockReturnValueOnce(launchIntent(CLAUDE_SESSION)) - mocks.launch.mockImplementationOnce(async (intent: StructuredAgentSessionLaunchIntent) => { - await Promise.resolve() - const issue = await intent.prepareCreate?.() - issue?.() - return new Promise(() => {}) - }) + mocks.launch.mockReturnValueOnce(new Promise(() => {})) startStructuredAgentLaunch(WORKTREE_ID, 'claude', { requestId: `request-${CLAUDE_SESSION}` }) await flush() diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx index 04ee9bec94be..0d39b2f1198a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx @@ -54,10 +54,6 @@ import { resetStructuredAgentLaunchPersistenceForTests } from '@/lib/structured- import { resetStructuredAgentLaunchRegistryForTests } from '@/lib/structured-agent-session-launch-registry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' import { useStructuredAgentSession } from './use-structured-agent-session' -import { allocateNativeChatNewDraft, readNativeChatNewDraft } from './native-chat-new-draft' -import { isNativeChatComposerDraftUnsaved } from './native-chat-composer-draft-store' -import { createMemoryNativeChatComposerDraftStorage, setNativeChatComposerDraftStorageForTests } from './native-chat-composer-draft-storage' -import { markStructuredAgentSessionLaunchPublished } from '@/lib/structured-agent-session-launch-publication' import { resetHostModelCatalogSnapshotsForTests } from '@/runtime/host-model-catalog-snapshots' const LOCAL_TARGET = { kind: 'local' } as const @@ -146,27 +142,6 @@ describe('a chat pane over its own launch', () => { resetHostModelCatalogSnapshotsForTests() }) - it('uses ordinary hosted send policy while metadata retirement fails', async () => { - const storage = createMemoryNativeChatComposerDraftStorage() - setNativeChatComposerDraftStorageForTests(storage) - const sessionId = 'codex_hosted_cleanup' - const metadata = { owner: { workspaceId: 'wt-hosted', executionHostId: 'local' as const }, agent: 'codex' as const, groupId: 'group-1', options: {} } - allocateNativeChatNewDraft(sessionId, metadata) - await waitFor(async () => expect(await storage.read(`agent-session-new:${sessionId}`)).toEqual(metadata)) - storage.refuseWrites = true - markStructuredAgentSessionLaunchPublished('wt-hosted', sessionId, 'local') - await waitFor(() => expect(isNativeChatComposerDraftUnsaved(`agent-session:${sessionId}`)).toBe(true)) - const { result, unmount } = renderHook(() => useNativeChatProvisionalLaunch('wt-hosted', sessionId)) - const send = vi.fn(() => 'queued' as const) - expect(result.current.sendThroughLaunch('follow-up', false, send)).toBe('queued') - expect(send).toHaveBeenCalledOnce() - expect(result.current.transportEnabled).toBe(true) - expect(readNativeChatNewDraft(sessionId)).toEqual(metadata) - unmount() - storage.refuseWrites = false - setNativeChatComposerDraftStorageForTests(null) - }) - it('keeps the selection its create seeded when a pick in another chat saves a new one', async () => { saveSelection('gpt-5.5') startStructuredAgentLaunch('wt-first', 'codex', { requestId: 'request-1' }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts index 04dcc4b56628..193037770d4d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -9,11 +9,6 @@ import { } from '@/lib/structured-agent-session-launch' import { relaunchFailedStructuredAgentSessionWithMessage } from '@/lib/structured-agent-session-launch-message' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { nativeChatNewDraftIsHosted, useNativeChatNewDraft } from './native-chat-new-draft' -import { submitNativeChatLocalDraft } from '@/lib/native-chat-first-submission' -import type { StructuredAgentSessionAttachment } from '../../../../shared/structured-agent-session-send-mutation' -import { structuredLaunchSeedOptions } from '../../../../shared/native-chat-session-option-defaults' -import { useAppStore } from '@/store' /** A chat this view launched: a new conversation, or one resumed from history. */ export type StructuredAgentSessionLaunchView = { @@ -72,36 +67,20 @@ export function useNativeChatProvisionalLaunch( sessionId: string ) { const lifecycle = useStructuredAgentSessionLaunchLifecycle(worktreeId ?? '', sessionId) - const draft = useNativeChatNewDraft(sessionId) - const hosted = useAppStore(() => draft !== undefined && nativeChatNewDraftIsHosted(sessionId, draft)) - const localDraft = draft !== undefined && lifecycle !== 'published' && !hosted const failure = useStructuredAgentSessionLaunchFailure(worktreeId ?? '', sessionId) - const latched = useLatchedLaunchView(sessionId, worktreeId, lifecycle !== null || Boolean(draft)) - const launch: StructuredAgentSessionLaunchView | undefined = localDraft && draft ? { - kind: 'new', heldOptions: draft.options, - ...(draft.owner.executionHostId === 'local' ? { seedOptions: structuredLaunchSeedOptions(useAppStore.getState().settings, draft.agent) } : {}), - worktree: toRuntimeWorktreeSelector(draft.owner.workspaceId) - } : latched + const launch = useLatchedLaunchView(sessionId, worktreeId, lifecycle !== null) const retry = useCallback(() => { if (worktreeId) { retryStructuredAgentSessionLaunch(worktreeId, sessionId) } }, [sessionId, worktreeId]) // While the chat starts it takes no send, so Send stays off and the text stays in the box. - const starting = !hosted && lifecycle !== 'published' && (lifecycle === 'pending' || lifecycle === 'visibility-unknown') + const starting = lifecycle === 'pending' || lifecycle === 'visibility-unknown' const sendThroughLaunch = useCallback( - (text: string, withImages: boolean, send: () => boolean | 'queued', submission?: { - attachments: readonly StructuredAgentSessionAttachment[]; options: Readonly> - }): boolean | 'queued' => { - if (hosted || lifecycle === 'published') { - return send() - } + (text: string, withImages: boolean, send: () => boolean | 'queued'): boolean | 'queued' => { if (starting) { return false } - if (draft && localDraft) { - return submitNativeChatLocalDraft(sessionId, text, submission?.attachments ?? [], submission?.options ?? {}) - } if (lifecycle !== 'failed' || !worktreeId) { return send() } @@ -112,7 +91,7 @@ export function useNativeChatProvisionalLaunch( relaunchFailedStructuredAgentSessionForMessage(worktreeId, sessionId) return false }, - [draft, hosted, lifecycle, localDraft, sessionId, starting, worktreeId] + [lifecycle, sessionId, starting, worktreeId] ) return { lifecycle, @@ -121,6 +100,6 @@ export function useNativeChatProvisionalLaunch( retry, starting, sendThroughLaunch, - transportEnabled: !localDraft && (hosted || lifecycle === null || lifecycle === 'published') + transportEnabled: lifecycle === null || lifecycle === 'published' } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts index 451f6d760afa..3e4a90395a76 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts @@ -7,9 +7,6 @@ import type { } from './native-chat-composer-types' import type { NativeChatStructuredViewProps } from './native-chat-view-types' import type { useStructuredAgentSession } from './use-structured-agent-session' -import type { StructuredAgentSessionAttachment } from '../../../../shared/structured-agent-session-send-mutation' -import { structuredAgentSessionSendSelection } from '../../../../shared/structured-agent-session-option-view' -import { readNativeChatNewDraft } from './native-chat-new-draft' /** What the composer of a structured chat sends, picks and runs through, from the chat's controller. */ export function useNativeChatStructuredComposerTransport(args: { @@ -19,8 +16,7 @@ export function useNativeChatStructuredComposerTransport(args: { sendThroughLaunch: ( text: string, withImages: boolean, - send: () => boolean | 'queued', - submission?: { attachments: readonly StructuredAgentSessionAttachment[]; options: Readonly> } + send: () => boolean | 'queued' ) => boolean | 'queued' /** The chat is starting: it takes no send yet. */ starting: boolean @@ -52,9 +48,7 @@ export function useNativeChatStructuredComposerTransport(args: { previewUri: path, ...(connectionId ? { connectionId } : {}) })) - ), - { attachments: attachments.map(({ path, connectionId }) => ({ path, previewUri: path, ...(connectionId ? { connectionId } : {}) })), - options: { ...readNativeChatNewDraft(props.sessionId)?.options, ...structuredAgentSessionSendSelection(props.agent, controller.optionSnapshot) } } + ) ), dispatchCommand: (text: string) => dispatchStructuredAgentSessionComposerCommand(text, { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-write.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-write.ts deleted file mode 100644 index 34374b084ef7..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-write.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { useCallback } from 'react' -import type { AgentSessionOptionResult, AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' -import type { AgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' -import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' -import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' -import { applyStructuredAgentSessionOptions, commitStructuredAgentSessionOptionValues, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -import type { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state' -import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' - -const NO_HELD_OPTIONS: Readonly> = {} - -type OptionState = ReturnType -type OptionWrite = Pick & { - launchSeedOptions: Readonly> | undefined - mutate: StructuredAgentSessionMutate - optionCatalog: AgentSessionOptionCatalog | null - rememberOptionPicks: (view: StructuredAgentSessionOptionState, committed: Readonly>) => void - sessionId: string - target: RuntimeClientTarget -} - -/** Applies a hosted pick and reconciles it only with the same session's live option record. */ -export function useStructuredAgentSessionOptionWrite(args: OptionWrite) { - const { activeOptionRecordRef, launchSeedOptions, mutate, optionCatalog, optionMutationGeneration, optionStateRef, optionView, pendingOptionRef, rememberOptionPicks, sessionId, target, updateOptionState } = args - return useCallback( - async (id: string, encoded: string): Promise => { - const currentState = optionStateRef.current - const targetRecord = currentState.record - const mutationGeneration = ++optionMutationGeneration.current - const isCurrent = (): boolean => - activeOptionRecordRef.current === targetRecord && - optionMutationGeneration.current === mutationGeneration - // Record-only: shown at once, so the picker stays usable while the write waits behind a start. - const shownAtOnce = id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID - if (shownAtOnce) { - updateOptionState((current) => - current.record === targetRecord - ? commitStructuredAgentSessionOptionValues(current, { [id]: encoded }) - : current - ) - } else { - pendingOptionRef.current = { id, value: encoded } - updateOptionState((current) => ({ ...current, pendingId: id })) - } - let result: AgentSessionOptionResult | null = null - try { - result = await mutate( - 'agentSession.setOption', - 'agentSession.setOption', - { key: id, value: encoded } - ) - if (result && isCurrent()) { - const committed = result.options ?? { [id]: encoded } - updateOptionState((current) => - current.record === targetRecord - ? commitStructuredAgentSessionOptionValues(current, committed) - : current - ) - // The launch seed names the model an effort-only pick was made under. - rememberOptionPicks( - optionView(currentState, launchSeedOptions, NO_HELD_OPTIONS), - committed - ) - } - return Boolean(result) - } finally { - // A settled write, or a shown one that failed, reads back what the host now holds. - if (isCurrent() && (result || shownAtOnce)) { - void callStructuredAgentSession( - target, - 'agentSession.options', - { sessionId } - ) - .then((refreshed) => { - if (isCurrent()) { - updateOptionState((latest) => - latest.record === targetRecord && optionCatalog - ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) - : latest - ) - } - }) - .catch(() => {}) - } - if (isCurrent()) { - pendingOptionRef.current = null - updateOptionState((current) => - current.record === targetRecord && current.pendingId === id - ? { ...current, pendingId: null } - : current - ) - } - } - }, - [ - activeOptionRecordRef, - launchSeedOptions, - mutate, - optionCatalog, - optionMutationGeneration, - optionStateRef, - optionView, - pendingOptionRef, - rememberOptionPicks, - sessionId, - target, - updateOptionState - ] - ) -} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 9262f1ca9c7e..11a1ab3b4a96 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -1,22 +1,27 @@ import { toast } from 'sonner' -import { readNativeChatNewDraft, updateNativeChatNewDraft } from './native-chat-new-draft' import { useCallback, useMemo } from 'react' +import type { + AgentSessionOptionResult, + AgentSessionOptionsResult +} from '../../../../shared/agent-session-wire' import type { AgentType } from '../../../../shared/agent-status-types' import { structuredAgentSessionSeedCatalog } from '../../../../shared/structured-agent-session-seed-catalog' import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../../../shared/agent-chat-permission-mode' import { + applyStructuredAgentSessionOptions, canSetStructuredAgentSessionOption, + commitStructuredAgentSessionOptionValues, lockedStructuredAgentSessionOptionSnapshot, structuredAgentSessionOptionPicks, structuredAgentSessionOptionSnapshot, type StructuredAgentSessionOptionState } from '../../../../shared/structured-agent-session-options' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { enqueueSessionOptionSettingsWrite } from './native-chat-session-option-settings-write' import { encodeStructuredAgentSessionOptionValue } from '../../../../shared/structured-agent-session-option-codec' import type { StructuredAgentSessionMutate } from './use-structured-agent-session-mutate' -import { useStructuredAgentSessionOptionWrite } from './use-structured-agent-session-option-write' import { useHostModelCatalogUpgrade } from './use-host-model-catalog-upgrade' import { useStructuredAgentSessionOptionState } from './use-structured-agent-session-option-state' import { agentSessionWriteFailureText } from './agent-session-write-notice-text' @@ -124,20 +129,91 @@ export function useStructuredAgentSessionOptions(args: { }, [agent, target] ) - const sendStructuredOption = useStructuredAgentSessionOptionWrite({ - activeOptionRecordRef, - launchSeedOptions, - mutate, - optionCatalog, - optionMutationGeneration, - optionStateRef, - optionView, - pendingOptionRef, - rememberOptionPicks, - sessionId, - target, - updateOptionState - }) + const sendStructuredOption = useCallback( + async (id: string, encoded: string): Promise => { + const currentState = optionStateRef.current + const targetRecord = currentState.record + const mutationGeneration = ++optionMutationGeneration.current + const isCurrent = (): boolean => + activeOptionRecordRef.current === targetRecord && + optionMutationGeneration.current === mutationGeneration + // Record-only: shown at once, so the picker stays usable while the write waits behind a start. + const shownAtOnce = id === AGENT_CHAT_PERMISSION_MODE_OPTION_ID + if (shownAtOnce) { + updateOptionState((current) => + current.record === targetRecord + ? commitStructuredAgentSessionOptionValues(current, { [id]: encoded }) + : current + ) + } else { + pendingOptionRef.current = { id, value: encoded } + updateOptionState((current) => ({ ...current, pendingId: id })) + } + let result: AgentSessionOptionResult | null = null + try { + result = await mutate( + 'agentSession.setOption', + 'agentSession.setOption', + { key: id, value: encoded } + ) + if (result && isCurrent()) { + const committed = result.options ?? { [id]: encoded } + updateOptionState((current) => + current.record === targetRecord + ? commitStructuredAgentSessionOptionValues(current, committed) + : current + ) + // The launch seed names the model an effort-only pick was made under. + rememberOptionPicks( + optionView(currentState, launchSeedOptions, NO_HELD_OPTIONS), + committed + ) + } + return Boolean(result) + } finally { + // A settled write, or a shown one that failed, reads back what the host now holds. + if (isCurrent() && (result || shownAtOnce)) { + void callStructuredAgentSession( + target, + 'agentSession.options', + { sessionId } + ) + .then((refreshed) => { + if (isCurrent()) { + updateOptionState((latest) => + latest.record === targetRecord && optionCatalog + ? applyStructuredAgentSessionOptions(latest, optionCatalog, refreshed) + : latest + ) + } + }) + .catch(() => {}) + } + if (isCurrent()) { + pendingOptionRef.current = null + updateOptionState((current) => + current.record === targetRecord && current.pendingId === id + ? { ...current, pendingId: null } + : current + ) + } + } + }, + [ + activeOptionRecordRef, + launchSeedOptions, + mutate, + optionCatalog, + optionMutationGeneration, + optionStateRef, + optionView, + pendingOptionRef, + rememberOptionPicks, + sessionId, + target, + updateOptionState + ] + ) const settleLaunchOptionPick = useCallback( (outcome: StructuredLaunchOptionOutcome) => { if (outcome.kind === 'refused') { @@ -169,16 +245,6 @@ export function useStructuredAgentSessionOptions(args: { return false } if (!transportEnabled || fence === null) { - const draft = readNativeChatNewDraft(sessionId) - if (draft) { - const options = id === 'model' - ? { ...(draft.options[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] ? { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: draft.options[AGENT_CHAT_PERMISSION_MODE_OPTION_ID] } : {}), [id]: encoded } - : { ...draft.options, [id]: encoded } - updateNativeChatNewDraft(sessionId, { options }) - if (!getStructuredAgentSessionLaunchSelection(sessionId)) { - return true - } - } // No fence yet: the launch holds it and applies it before anything else is sent. const applied = holdStructuredAgentSessionLaunchOption(sessionId, id, encoded) void applied?.then(settleLaunchOptionPick) @@ -207,7 +273,7 @@ export function useStructuredAgentSessionOptions(args: { async (id: string, value: string | boolean) => { await setStructuredOption(id, value) // Read, not rendered: a pick the launch just took is not in this render's props yet. - const currentHeld = readNativeChatNewDraft(sessionId)?.options ?? getStructuredAgentSessionLaunchSelection(sessionId)?.held + const currentHeld = getStructuredAgentSessionLaunchSelection(sessionId)?.held return { snapshot: structuredAgentSessionOptionSnapshot( optionView(optionStateRef.current, launchSeedOptions, currentHeld ?? NO_HELD_OPTIONS) diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 4122649c1965..34fe80efc429 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -294,7 +294,6 @@ describe('structured chat adoption guard on the launch path', () => { 'codex', 'local', undefined, - undefined, undefined ) expect(mockLaunchStructuredCodexSession).toHaveBeenCalledWith( @@ -328,7 +327,6 @@ describe('structured chat adoption guard on the launch path', () => { 'codex', 'local', undefined, - undefined, undefined ) expect(mockCreateTab).not.toHaveBeenCalled() @@ -350,7 +348,6 @@ describe('structured chat adoption guard on the launch path', () => { 'claude', 'local', undefined, - undefined, undefined ) expect(mockCreateTab).not.toHaveBeenCalled() diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index c82bb98946ee..f941f9e40d7a 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -46,7 +46,7 @@ export type StructuredAgentSessionLaunchIntent = { params: StructuredAgentSessionCreateParams createMessageSupport?: boolean createPrepared?: true - prepareCreate?: () => void | (() => void) | Promise void)> + prepareCreate?: () => void /** The saved selection create seeds, read when the intent is built. */ seedOptions?: Readonly> } @@ -86,22 +86,20 @@ export function createStructuredAgentSessionLaunchIntent( agent: TuiAgent, executionHostId?: ExecutionHostId, resumeFrom?: StructuredAgentSessionResumeSource, - hostSeedOptions?: LaunchSeed, - reservedSessionId?: string + hostSeedOptions?: LaunchSeed ): StructuredAgentSessionLaunchIntent { const owner = structuredAgentSessionOwnerTarget( worktreeId, executionHostId ?? resolveStructuredAgentSessionOwner(useAppStore.getState(), worktreeId) ) - const sessionId = reservedSessionId ?? createStructuredAgentSessionId(agent, createBrowserUuid) + const sessionId = createStructuredAgentSessionId(agent, createBrowserUuid) return buildStructuredAgentSessionLaunchIntent( worktreeId, owner, agent, sessionId, resumeFrom, - hostSeedOptions, - reservedSessionId === undefined + hostSeedOptions ) } @@ -111,19 +109,16 @@ function buildStructuredAgentSessionLaunchIntent( agent: TuiAgent, sessionId: string, resumeFrom: StructuredAgentSessionResumeSource | undefined, - hostSeedOptions: LaunchSeed, - requestFocus = true + hostSeedOptions: LaunchSeed ): StructuredAgentSessionLaunchIntent { const state = useAppStore.getState() - if (requestFocus) { - recordWebSessionFocusIntent( - structuredAgentSessionFocusOwner(owner.target), - worktreeId, - `agent-session:${sessionId}`, - undefined, - resolveWebSessionVisibleTabId(state, worktreeId) - ) - } + recordWebSessionFocusIntent( + structuredAgentSessionFocusOwner(owner.target), + worktreeId, + `agent-session:${sessionId}`, + undefined, + resolveWebSessionVisibleTabId(state, worktreeId) + ) return { sessionId, worktreeId, @@ -173,6 +168,13 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { }): StructuredAgentSessionLaunchIntent { const state = useAppStore.getState() const { target } = structuredAgentSessionOwnerTarget(args.worktreeId, args.executionHostId) + recordWebSessionFocusIntent( + structuredAgentSessionFocusOwner(target), + args.worktreeId, + `agent-session:${args.sessionId}`, + undefined, + resolveWebSessionVisibleTabId(state, args.worktreeId) + ) return { sessionId: args.sessionId, worktreeId: args.worktreeId, diff --git a/src/renderer/src/lib/native-chat-draft-owner.ts b/src/renderer/src/lib/native-chat-draft-owner.ts index de801cfcb216..36923536669a 100644 --- a/src/renderer/src/lib/native-chat-draft-owner.ts +++ b/src/renderer/src/lib/native-chat-draft-owner.ts @@ -1,4 +1,3 @@ -import { readNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' import type { AppState } from '../store/types' import type { NativeChatComposerDraftOwner } from '@/components/native-chat/native-chat-composer-draft-storage' import { @@ -35,11 +34,6 @@ export function resolveNativeChatDraftOwner( state: AppState, scopeKey: string ): NativeChatComposerDraftOwner | undefined { - const sessionId = structuredAgentSessionIdOfDraftScope(scopeKey) - const local = sessionId ? readNativeChatNewDraft(sessionId) : undefined - if (local) { - return local.owner - } const workspaceId = workspaceOfDraftScope(state, scopeKey) const executionHostId = workspaceId ? resolveWorktreeOperationRoute(state, workspaceId)?.executionHostId diff --git a/src/renderer/src/lib/native-chat-draft-reload.ts b/src/renderer/src/lib/native-chat-draft-reload.ts deleted file mode 100644 index 7adc3fa83f9d..000000000000 --- a/src/renderer/src/lib/native-chat-draft-reload.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useAppStore } from '@/store' -import { nativeChatNewDrafts, retireNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' -import { deleteNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' -import type { NativeChatNewDraft } from '@/components/native-chat/native-chat-composer-draft-storage' -import { findKnownWorktreeById } from '@/store/slices/worktrees/listing/detected-worktree-meta' -import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' -import { parseWorkspaceKey } from '../../../shared/workspace-scope' -import { parseExecutionHostId } from '../../../shared/execution-host' -import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' -import { fetchFolderWorkspaceCatalogForTarget, getFolderWorkspaceHostId } from '@/store/folder-workspaces/folder-workspace-catalog' -import { catalogOwnsHost } from '@/store/slices/project-group-owner-routing' -import { openStructuredAgentSessionProvisionalTab } from './structured-agent-session-provisional-tab' -import { getStructuredLaunchStateBySessionId } from './structured-agent-session-launch-registry' -import { deleteStructuredAgentLaunchRecord } from './structured-agent-session-launch-persistence' -import { markStructuredAgentSessionLaunchPublished } from './structured-agent-session-launch-publication' -import { readStructuredSessionTabInventory } from '@/runtime/structured-session-tab-inventory' - -async function ownerConfirmedGone(draft: NativeChatNewDraft): Promise { - const { workspaceId, executionHostId } = draft.owner - const state = useAppStore.getState() - const host = parseExecutionHostId(executionHostId) - if (host?.kind === 'runtime' && state.runtimeEnvironmentCatalogHydrated && - !state.runtimeEnvironments.some((environment) => environment.id === host.environmentId)) { - return true - } - if (findKnownWorktreeById(state, workspaceId, executionHostId)) { - return false - } - const scope = parseWorkspaceKey(workspaceId) - if (scope?.type === 'folder') { - const target = structuredAgentSessionTargetForHost(executionHostId) - if (!target) { - return false - } - const catalog = await fetchFolderWorkspaceCatalogForTarget(target, state.projectGroups) - return catalogOwnsHost(catalog.hostId, executionHostId) && !catalog.folderWorkspaces.some((row) => - row.id === scope.folderWorkspaceId && getFolderWorkspaceHostId(row, state.projectGroups) === executionHostId) - } - const authoritative = await state.fetchWorktrees(getRepoIdFromWorktreeId(workspaceId), { - executionHostId, requireAuthoritative: true - }) - return authoritative && !findKnownWorktreeById(useAppStore.getState(), workspaceId, executionHostId) -} - -export async function reconcileRestoredNativeChatDrafts(): Promise { - const inventories = new Map>() - for (const [sessionId, draft] of nativeChatNewDrafts()) { - try { - if (await ownerConfirmedGone(draft)) { - if (await retireNativeChatNewDraft(sessionId)) { - deleteNativeChatComposerDraft(`agent-session:${sessionId}`) - deleteStructuredAgentLaunchRecord(sessionId) - } - continue - } - if (!getStructuredLaunchStateBySessionId(sessionId) && (useAppStore.getState().groupsByWorktree[draft.owner.workspaceId] ?? []).some((group) => group.id === draft.groupId)) { - openStructuredAgentSessionProvisionalTab({ - worktreeId: draft.owner.workspaceId, executionHostId: draft.owner.executionHostId, - sessionId, agent: draft.agent, targetGroupId: draft.groupId, activate: false - }) - } - const hostId = draft.owner.executionHostId - const target = structuredAgentSessionTargetForHost(hostId) - if (target && !inventories.has(hostId)) { - inventories.set(hostId, readStructuredSessionTabInventory(target)) - } - const inventory = await inventories.get(hostId) ?? [] - if (inventory.some((row) => row.worktree === draft.owner.workspaceId && row.tabs.some((tab) => tab.type === 'agent-session' && tab.sessionId === sessionId))) { - markStructuredAgentSessionLaunchPublished(draft.owner.workspaceId, sessionId, draft.owner.executionHostId) - } - } catch (error) { - console.warn('[native-chat-drafts] the saved chat owner could not be verified', error) - } - } -} diff --git a/src/renderer/src/lib/native-chat-first-submission.ts b/src/renderer/src/lib/native-chat-first-submission.ts deleted file mode 100644 index 2c4d2b1705e2..000000000000 --- a/src/renderer/src/lib/native-chat-first-submission.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - readNativeChatNewDraft -} from '@/components/native-chat/native-chat-new-draft' -import { readNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' -import { - stageStructuredLaunchPrompt, - releaseStagedStructuredLaunchPrompt -} from './structured-agent-session-launch-prompt' -import { startStructuredAgentLaunch } from './structured-agent-session-launch' -import { newAgentLaunchRequestId } from './agent-launch-request-id' -import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { - prepareNativeChatSessionAttachmentUpload, - resolveNativeChatRuntimeSessionAttachmentOwner, - uploadNativeChatSessionAttachmentPaths -} from '@/components/native-chat/native-chat-attachment-upload' -import { structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' -import type { StructuredAgentSessionAttachment } from '../../../shared/structured-agent-session-send-mutation' - -/** Uses the existing launch reservation for the complete immutable opening submission. */ -export function submitNativeChatLocalDraft( - sessionId: string, - text: string, - attachments: readonly StructuredAgentSessionAttachment[], - options: Readonly> -): boolean { - const draft = readNativeChatNewDraft(sessionId) - if (!draft) { - return false - } - const sourceDraft = readNativeChatComposerDraft(`agent-session:${sessionId}`) - const stagedPrompt = stageStructuredLaunchPrompt(sessionId, text, { attachments, options, sourceDraft }) - if (!stagedPrompt.slot) { - releaseStagedStructuredLaunchPrompt(stagedPrompt) - return false - } - const prepare = async (): Promise => { - const target = structuredAgentSessionTargetForHost(draft.owner.executionHostId) - if (!target) { - throw new Error('The chat host is unavailable.') - } - if (target.kind === 'environment' && attachments.length > 0) { - const owner = resolveNativeChatRuntimeSessionAttachmentOwner({ sessionId, runtimeEnvironmentId: target.environmentId }, false) - if (owner.kind !== 'runtime-session') { - throw new Error('The chat host is unavailable.') - } - const prepared = await prepareNativeChatSessionAttachmentUpload(owner, { - worktree: toRuntimeWorktreeSelector(draft.owner.workspaceId), agent: draft.agent - }) - if (!prepared.ok) { - throw new Error(prepared.notice) - } - const upload = async (): Promise => { - const result = await uploadNativeChatSessionAttachmentPaths(attachments.map((image) => image.path), prepared.target) - if (result.failed.length || result.skipped.length || result.uploaded.length !== attachments.length) { - throw new Error('The images could not be attached. Attach them again and send.') - } - const stored = new Map(result.uploaded.map((image) => [image.sourcePath, image.path])) - stagedPrompt.slot?.setAttachments(attachments.map((image) => { - const path = stored.get(image.path) - if (!path) { - throw new Error('The images could not be attached. Attach them again and send.') - } - return { ...image, path } - })) - } - if (prepared.target.reservedSession) { - await upload() - } else { - stagedPrompt.beforeSend = upload - } - } - } - stagedPrompt.beforeCreate = prepare - try { - const launch = startStructuredAgentLaunch(draft.owner.workspaceId, draft.agent, { - requestId: newAgentLaunchRequestId(), executionHostId: draft.owner.executionHostId, - reservedSessionId: sessionId, stagedPrompt, prompt: text, openingOptions: options - }) - void launch.launchResult.catch(() => {}) - return true - } catch (error) { - releaseStagedStructuredLaunchPrompt(stagedPrompt) - throw error - } -} diff --git a/src/renderer/src/lib/native-chat-local-draft.test.ts b/src/renderer/src/lib/native-chat-local-draft.test.ts deleted file mode 100644 index 1cab6e2f3b20..000000000000 --- a/src/renderer/src/lib/native-chat-local-draft.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -// @vitest-environment happy-dom -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { Tab } from '../../../shared/tab-types' -import type { ExecutionHostId } from '../../../shared/execution-host' -import { openNativeChatLocalDraft } from './native-chat-local-draft' -import { newAgentLaunchRequestId } from './agent-launch-request-id' -import { allocateNativeChatNewDraft, invalidateNativeChatNewDraft, deleteNativeChatNewDraftsOwnedBy, readNativeChatNewDraft, retireNativeChatNewDraft, updateNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' -import { clearNativeChatComposerDraftsForTests, hydrateNativeChatComposerDrafts, isNativeChatComposerDraftUnsaved, readNativeChatComposerDraft, updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' -import { createMemoryNativeChatComposerDraftStorage, setNativeChatComposerDraftStorageForTests } from '@/components/native-chat/native-chat-composer-draft-storage' - -const mocks = vi.hoisted(() => { const tabs: Tab[] = []; return { tabs, focus: vi.fn(), call: vi.fn() } }) -vi.mock('@/components/native-chat/use-native-chat-composer-reveal-focus', () => ({ requestNativeChatComposerFocus: mocks.focus })) -vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call })) -vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ - unifiedTabsByWorktree: { 'folder:workspace': mocks.tabs }, - focusGroup: vi.fn(), activateTab: vi.fn(), setActiveTabType: vi.fn(), - createUnifiedTab: (worktreeId: string, contentType: Tab['contentType'], args: Partial & { targetGroupId?: string }) => { - const tab: Tab = { id: '', entityId: '', worktreeId, groupId: args.targetGroupId ?? 'group-1', contentType, - label: '', customLabel: null, color: null, sortOrder: 0, createdAt: 1, ...args } - mocks.tabs.push(tab) - return tab - } -}) } })) -let storage: ReturnType -beforeEach(async () => { - vi.clearAllMocks() - mocks.tabs.length = 0 - clearNativeChatComposerDraftsForTests() - storage = createMemoryNativeChatComposerDraftStorage() - setNativeChatComposerDraftStorageForTests(storage) - await hydrateNativeChatComposerDrafts() -}) -afterEach(() => clearNativeChatComposerDraftsForTests()) - -it.each(['claude', 'codex', 'grok', 'opencode', 'pi'] as const)( - 'opens a quiet folder draft or explicit split destination without asking the %s host', (agent) => { - const requestId = newAgentLaunchRequestId() - const args = { requestId, worktreeId: 'folder:workspace', executionHostId: 'local' as const, agent, groupId: 'split-right', prompt: 'unique marker' } - const tab = openNativeChatLocalDraft(args) - expect(tab).toMatchObject({ groupId: 'split-right', executionHostId: 'local', agentSessionAgent: agent }) - expect(readNativeChatComposerDraft(`agent-session:${tab?.entityId}`).text).toBe('unique marker') - expect(readNativeChatNewDraft(tab?.entityId ?? '')).toMatchObject({ owner: { workspaceId: 'folder:workspace', executionHostId: 'local' }, groupId: 'split-right', agent }) - expect(mocks.focus).toHaveBeenCalledExactlyOnceWith(tab?.id) - expect(openNativeChatLocalDraft(args)?.id).toBe(tab?.id) - expect(mocks.tabs).toHaveLength(1) - expect(mocks.call).not.toHaveBeenCalled() - mocks.focus.mockClear() - const quiet = openNativeChatLocalDraft({ ...args, requestId: newAgentLaunchRequestId(), activate: false }) - expect(quiet?.entityId).not.toBe(tab?.entityId) - expect(quiet?.groupId).toBe('split-right') - expect(readNativeChatComposerDraft(`agent-session:${quiet?.entityId}`).text).toBe('unique marker') - expect(mocks.focus).not.toHaveBeenCalled() - expect(mocks.call).not.toHaveBeenCalled() - } -) - -it('keeps metadata independent of stale content and refuses to resurrect it after committed retirement', async () => { - const id = 'codex_reload' - const owner: { workspaceId: string; executionHostId: ExecutionHostId } = { workspaceId: 'folder:workspace', executionHostId: 'local' } - const metadata = { owner, agent: 'codex' as const, groupId: 'split-down', options: { model: 'picked' } } - allocateNativeChatNewDraft(id, metadata) - updateNativeChatComposerDraft(`agent-session:${id}`, { text: 'saved', document: { type: 'doc' }, images: [{ id: 'image', path: '/source/image.png' }] }, 'immediate') - await Promise.resolve() - clearNativeChatComposerDraftsForTests() - setNativeChatComposerDraftStorageForTests(storage) - await hydrateNativeChatComposerDrafts() - expect(readNativeChatNewDraft(id)).toEqual(metadata) - expect(readNativeChatComposerDraft(`agent-session:${id}`)).toMatchObject({ text: 'saved', images: [{ id: 'image', path: '/source/image.png' }] }) - storage.refuseWrites = true - expect(await retireNativeChatNewDraft(id)).toBe(false) - expect(readNativeChatNewDraft(id)).toEqual(metadata) - storage.refuseWrites = false - expect(await retireNativeChatNewDraft(id)).toBe(true) - await storage.update(`agent-session-new:${id}`, (stored) => stored ? metadata : null) - await storage.write(`agent-session:${id}`, { text: 'stale window', images: [], savedAt: 1 }) - updateNativeChatNewDraft(id, { options: { model: 'stale pick' } }) - clearNativeChatComposerDraftsForTests() - setNativeChatComposerDraftStorageForTests(storage) - await hydrateNativeChatComposerDrafts() - expect(readNativeChatNewDraft(id)).toBeUndefined() - expect(readNativeChatComposerDraft(`agent-session:${id}`).text).toBe('stale window') - expect(mocks.call).not.toHaveBeenCalled() -}) - - -it('converges foreign retirement during a pick and adopts a recreated workspace draft', async () => { - const id = 'codex_foreign_retirement' - const key = `agent-session-new:${id}` - const metadata = { owner: { workspaceId: 'folder:workspace', executionHostId: 'local' as const }, agent: 'codex' as const, groupId: 'group-1', options: {} } - allocateNativeChatNewDraft(id, metadata) - await vi.waitFor(async () => expect(await storage.read(key)).toEqual(metadata)) - const update = storage.update - let commit: (() => void) | undefined - storage.update = (scope, apply) => new Promise((resolve) => { - commit = () => { void update(scope, apply).then(resolve) } - }) - updateNativeChatNewDraft(id, { options: { model: 'pick during removal' } }) - await vi.waitFor(() => expect(commit).toBeDefined()) - await storage.remove([key]) - invalidateNativeChatNewDraft(key) - commit?.() - await vi.waitFor(() => expect(readNativeChatNewDraft(id)).toBeUndefined()) - storage.update = update - allocateNativeChatNewDraft('codex_old_owner', metadata) - deleteNativeChatNewDraftsOwnedBy(metadata.owner) - await vi.waitFor(async () => expect(await storage.read('agent-session-new:codex_old_owner')).toBeUndefined()) - await storage.write('agent-session-new:codex_recreated_owner', metadata) - invalidateNativeChatNewDraft('agent-session-new:codex_recreated_owner') - await vi.waitFor(() => expect(readNativeChatNewDraft('codex_recreated_owner')).toEqual(metadata)) - const read = storage.read - let releaseRead: (() => void) | undefined - storage.read = async (scope) => { - const snapshot = await read(scope) - storage.read = read - return new Promise((resolve) => { releaseRead = () => resolve(snapshot) }) - } - invalidateNativeChatNewDraft('agent-session-new:codex_recreated_owner') - await vi.waitFor(() => expect(releaseRead).toBeDefined()) - updateNativeChatNewDraft('codex_recreated_owner', { options: { model: 'newer than foreign read' } }) - await vi.waitFor(async () => expect(await read('agent-session-new:codex_recreated_owner')).toMatchObject({ options: { model: 'newer than foreign read' } })) - releaseRead?.() - await vi.waitFor(() => expect(readNativeChatNewDraft('codex_recreated_owner')?.options.model).toBe('newer than foreign read')) - storage.refuseWrites = true - allocateNativeChatNewDraft('codex_unsaved', metadata) - invalidateNativeChatNewDraft('agent-session-new:codex_unsaved') - await vi.waitFor(() => expect(isNativeChatComposerDraftUnsaved('agent-session:codex_unsaved')).toBe(true)) - expect(readNativeChatNewDraft('codex_unsaved')).toEqual(metadata) - storage.refuseWrites = false - await storage.write('agent-session-new:codex_unsaved', { ...metadata, options: { model: 'foreign committed row' } }) - invalidateNativeChatNewDraft('agent-session-new:codex_unsaved') - await vi.waitFor(() => expect(readNativeChatNewDraft('codex_unsaved')?.options.model).toBe('foreign committed row')) - await storage.remove(['agent-session-new:codex_unsaved']) - invalidateNativeChatNewDraft('agent-session-new:codex_unsaved') - await vi.waitFor(() => expect(readNativeChatNewDraft('codex_unsaved')).toBeUndefined()) - expect(mocks.call).not.toHaveBeenCalled() -}) diff --git a/src/renderer/src/lib/native-chat-local-draft.ts b/src/renderer/src/lib/native-chat-local-draft.ts deleted file mode 100644 index 845f3edda393..000000000000 --- a/src/renderer/src/lib/native-chat-local-draft.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { TuiAgent } from '../../../shared/tui-agent' -import type { ExecutionHostId } from '../../../shared/execution-host' -import { createStructuredAgentSessionId } from '../../../shared/structured-agent-session-create' -import { openStructuredAgentSessionProvisionalTab, structuredLaunchOwner } from './structured-agent-session-provisional-tab' -import { allocateNativeChatNewDraft, readNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' -import { updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' -import { requestNativeChatComposerFocus } from '@/components/native-chat/use-native-chat-composer-reveal-focus' -import type { Tab } from '../../../shared/tab-types' -import { flushSync } from 'react-dom' - -/** The action owns the editor; the host owns only what its first Send submits. */ -export function openNativeChatLocalDraft(args: { - requestId: string - worktreeId: string - executionHostId?: ExecutionHostId - agent: TuiAgent - groupId?: string - activate?: boolean - focus?: boolean - prompt?: string -}): Tab | null { - const owner = structuredLaunchOwner({ route: 'structured-native-chat', executionHostId: args.executionHostId }, args.worktreeId) - if (!owner) { - return null - } - const sessionId = createStructuredAgentSessionId(args.agent, () => args.requestId) - const tab = flushSync(() => { - const opened = openStructuredAgentSessionProvisionalTab({ ...args, ...owner, sessionId, targetGroupId: args.groupId }) - if (!readNativeChatNewDraft(sessionId)) { - allocateNativeChatNewDraft(sessionId, { - owner: { workspaceId: args.worktreeId, executionHostId: owner.executionHostId }, - agent: args.agent, groupId: opened.groupId, options: {} - }) - if (args.prompt) { - updateNativeChatComposerDraft(`agent-session:${sessionId}`, { text: args.prompt }, 'immediate') - } - } - return opened - }) - if (args.focus !== false && args.activate !== false) { - requestNativeChatComposerFocus(tab.id) - } - return tab -} diff --git a/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts b/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts index c43ce8fda51e..12c8bf1f9757 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-after-failure.test.ts @@ -144,12 +144,7 @@ const fresh = launchIntent('session-new') async function refuseFirstLaunch(): Promise { mocks.createIntent.mockReturnValueOnce(failed).mockReturnValueOnce(fresh) - mocks.launch.mockImplementationOnce(async (intent: StructuredAgentSessionLaunchIntent) => { - await Promise.resolve() - const issue = await intent.prepareCreate?.() - issue?.() - throw new StructuredAgentSessionCreateRefusalError('unsupported') - }) + mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-1', prompt: 'first task' }) await flushLaunchSettlement() expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, failed.sessionId)).toBe('failed') @@ -158,8 +153,6 @@ async function refuseFirstLaunch(): Promise { describe('a new launch after a failed one', () => { beforeEach(() => { vi.resetAllMocks() - delete failed.createPrepared - delete fresh.createPrepared localStorage.clear() resetStructuredAgentLaunchPersistenceForTests() resetStructuredAgentLaunchRegistryForTests() diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index 3deca7dc4787..0733941e683e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -10,9 +10,6 @@ import type { StructuredLaunchAttempt } from './structured-agent-session-launch- import type { AgentLaunchRequestId } from './agent-launch-request-id' export type StructuredAgentLaunchOptions = { - reservedSessionId?: string - stagedPrompt?: StagedStructuredLaunchPrompt - openingOptions?: Readonly> /** The user action this start serves; only a re-delivery of it joins its chat. */ requestId: AgentLaunchRequestId prompt?: string diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts index 8393852d8329..d8ea08c6e025 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts @@ -1,4 +1,3 @@ -import { ensureRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client' import type { AgentSessionAttachResult, AgentSessionMutationResult @@ -20,7 +19,6 @@ import { import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' import { StructuredAgentSessionCreateError, - StructuredAgentSessionSubmissionEndedError, StructuredAgentSessionCreateRefusalError, StructuredAgentSessionCreateUnknownOutcomeError } from './structured-agent-session-launch-errors' @@ -81,25 +79,12 @@ export type StructuredLaunchHostSeedListener = (seedOptions: LaunchSeed) => void export async function launchStructuredAgentSession( intent: StructuredAgentSessionLaunchIntent, onHostSeed?: StructuredLaunchHostSeedListener, - priorCreateOutcomeUnknown = false, - reservedSubmission = false + priorCreateOutcomeUnknown = false ): Promise { - let hostSeed: LaunchSeed - try { - hostSeed = await requireHostCreateSupport(intent) - } catch (error) { - if (!reservedSubmission || error instanceof StructuredAgentSessionCreateRefusalError) { - throw error - } - throw new StructuredAgentSessionCreateRefusalError( - error instanceof Error ? error.message : 'The chat host is unavailable.', - 'structured_agent_session_unsupported' - ) - } + const hostSeed = await requireHostCreateSupport(intent) if (intent.target.kind !== 'local') { onHostSeed?.(hostSeed) } - let issue: void | (() => void) = undefined if (!intent.createPrepared) { let supported = false try { @@ -109,41 +94,17 @@ export async function launchStructuredAgentSession( } if (!intent.createPrepared) { intent.createMessageSupport = supported - try { - issue = await intent.prepareCreate?.() - } catch (error) { - // Preparation failed before create was issued, so its sources remain safe to submit again. - if (error instanceof StructuredAgentSessionSubmissionEndedError) { - throw error - } - throw new StructuredAgentSessionCreateRefusalError( - error instanceof Error ? error.message : 'The message could not be prepared.', - 'agent_session_attachment_unavailable' - ) - } - } - } - if (reservedSubmission && intent.target.kind === 'environment') { - try { - await ensureRuntimeEnvironmentCompatible(intent.target.environmentId) - } catch (error) { - throw new StructuredAgentSessionCreateRefusalError( - error instanceof Error ? error.message : 'The chat host is unavailable.', - 'structured_agent_session_unsupported' - ) + intent.prepareCreate?.() + intent.createPrepared = true } } let result: AgentSessionMutationResult try { - issue?.() - intent.createPrepared = true - result = reservedSubmission && intent.target.kind === 'environment' - ? await callStructuredAgentSession>( - intent.target, 'agentSession.create', intent.params, { skipCompatibilityCheck: true } - ) - : await callStructuredAgentSession>( - intent.target, 'agentSession.create', intent.params - ) + result = await callStructuredAgentSession>( + intent.target, + 'agentSession.create', + intent.params + ) } catch (error) { const code = definitiveStructuredAgentSessionCreateErrorCode(error) if (code) { diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index 7f6e296eba3d..74d4a7b663fd 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -56,28 +56,23 @@ import { resetStructuredAgentLaunchPersistenceForTests, readStructuredAgentLaunchRecord } from './structured-agent-session-launch-persistence' -import { confirmStructuredLaunchFirstMessagePublication } from './structured-agent-session-launch-publication-confirmation' -import { createMemoryNativeChatComposerDraftStorage, setNativeChatComposerDraftStorageForTests } from '@/components/native-chat/native-chat-composer-draft-storage' -import { submitNativeChatLocalDraft } from './native-chat-first-submission' -import { allocateNativeChatNewDraft, nativeChatNewDraftIsHosted, readNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' import { newAgentLaunchRequestId } from './agent-launch-request-id' import { markStructuredAgentSessionLaunchPublished } from './structured-agent-session-launch-publication' -import { StructuredAgentSessionSubmissionEndedError, StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' +import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' import { getStructuredAgentSessionReadOwner, findStructuredAgentSessionReadOwner, resetStructuredAgentSessionReadOwnersForTests } from '@/components/native-chat/structured-agent-session-read-owner' -import { reserveStructuredAgentSessionSend, resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender' -import { getStructuredAgentSessionPendingSends, getStructuredAgentSessionSendNotice } from '@/components/native-chat/structured-agent-session-pending-sends' +import { resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender' +import { getStructuredAgentSessionPendingSends } from '@/components/native-chat/structured-agent-session-pending-sends' import { structuredAgentSessionStopControl } from '@/components/native-chat/structured-agent-session-stop-control' import { clearNativeChatDraftCacheForTests, readNativeChatDraftCache, writeNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache' -import { clearNativeChatComposerDraftsForTests, isNativeChatComposerDraftUnsaved, structuredAgentSessionDraftScopeKey, updateNativeChatComposerDraft, readNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' -import { stageStructuredLaunchPrompt, discardStructuredLaunchPrompts } from './structured-agent-session-launch-prompt' +import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store' function deferred() { let resolve = (_value: T): void => {} @@ -150,8 +145,6 @@ describe('the first message owned by create', () => { resetStructuredAgentSessionSendsForTests() resetStructuredAgentSessionReadOwnersForTests() clearNativeChatDraftCacheForTests() - clearNativeChatComposerDraftsForTests() - setNativeChatComposerDraftStorageForTests(createMemoryNativeChatComposerDraftStorage()) mocks.supports.mockResolvedValue(true) mocks.admit.mockResolvedValue({ kind: 'admitted' }) mocks.inventory.mockRejectedValue(new Error('an extra inventory read must not gate create')) @@ -162,194 +155,6 @@ describe('the first message owned by create', () => { vi.useRealTimers() }) - it.each(['held', 'closed', 'releasedAfterUpload', 'expiredDiscovery', 'legacyClosed', 'legacyFailed'] as const)( - 'keeps the original first submission authoritative through preparation: %s', async (scenario) => { - const source = { text: 'opening with images', document: { type: 'doc' }, images: [ - { id: 'one', path: '/source/one.png' }, { id: 'two', path: '/source/two.png' } - ] } - const sessionId = 'codex_reserved_first_send' - const scope = structuredAgentSessionDraftScopeKey(sessionId) - const upload = deferred() - const discovery = deferred() - const create = deferred() - const legacy = scenario.startsWith('legacy') - mocks.supports.mockReturnValue(scenario === 'expiredDiscovery' ? discovery.promise : Promise.resolve(!legacy)) - let issuedAt = 0 - mocks.call.mockImplementation(() => { - issuedAt = Date.now() - return create.promise - }) - allocateNativeChatNewDraft(sessionId, { owner: { workspaceId: 'workspace-1', executionHostId: 'local' }, agent: 'codex', groupId: 'group-1', options: {} }) - const storedSource = { ...source, savedAt: 123, owner: { workspaceId: 'workspace-1', executionHostId: 'local' as const }, unsavedText: 'bookkeeping' } - const prompt = stageStructuredLaunchPrompt(sessionId, source.text, { - sourceDraft: storedSource, options: legacy ? {} : { model: 'picked' }, - attachments: source.images.map((image) => ({ path: image.path, previewUri: image.path })) - }) - const prepareUpload = vi.fn(async () => { - await upload.promise - prompt.slot?.setAttachments(source.images.map((image) => ({ - path: image.path.replace('/source/', '/stored/'), previewUri: image.path - }))) - }) - if (legacy) { - prompt.beforeSend = prepareUpload - } else { - prompt.beforeCreate = prepareUpload - } - const launch = startStructuredAgentLaunch('workspace-1', 'codex', { - requestId: newAgentLaunchRequestId(), executionHostId: 'local', reservedSessionId: sessionId, - stagedPrompt: prompt, prompt: source.text, openingOptions: legacy ? {} : { model: 'picked' } - }) - const result = launch.launchResult.catch((error: unknown) => error) - expect(readStructuredAgentLaunchRecord(sessionId)).toBeUndefined() - expect(reserveStructuredAgentSessionSend({ sessionId, text: 'duplicate' })).toBeNull() - if (scenario === 'expiredDiscovery') { - await vi.advanceTimersByTimeAsync(30_000) - expect(readNativeChatComposerDraft(scope)).toMatchObject(source) - expect(await result).toBeInstanceOf(StructuredAgentSessionSubmissionEndedError) - expect(retryStructuredAgentSessionLaunch('workspace-1', sessionId)).toBe(false) - expect(readStructuredAgentLaunchRecord(sessionId)).toBeUndefined() - const next = reserveStructuredAgentSessionSend({ sessionId, text: 'fresh explicit send' }) - expect(next).not.toBeNull() - discovery.resolve(true) - await vi.advanceTimersByTimeAsync(0) - expect(getStructuredAgentSessionPendingSends(sessionId)[0]?.clientMessageId).toBe(next?.clientMessageId) - next?.release() - } else { - if (legacy) { - mocks.inventory.mockResolvedValue([{ worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId }] }]) - await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - create.resolve(created()) - } - await vi.waitFor(() => expect(prepareUpload).toHaveBeenCalledOnce()) - if (!legacy) { - expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', sessionId)).toBe('pending') - } - if (scenario === 'held') { - await vi.advanceTimersByTimeAsync(45_000) - expect(mocks.call).not.toHaveBeenCalled() - expect(readStructuredAgentLaunchRecord(sessionId)).toBeUndefined() - expect(readNativeChatComposerDraft(scope).text).toBe('') - } else if (scenario === 'releasedAfterUpload') { - upload.resolve() - queueMicrotask(() => discardStructuredLaunchPrompts(sessionId)) - } else if (scenario === 'legacyFailed') { - upload.reject(new Error('upload failed')) - } else { - discardStructuredLaunchPrompts(sessionId) - } - } - upload.resolve() - if (scenario === 'held') { - await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - const params = createParams() - expect(readStructuredAgentLaunchRecord(sessionId)?.firstMessage).toEqual(params.firstMessage) - expect(params.firstMessage).toEqual({ clientMessageId: prompt.slot?.clientMessageId, body: { kind: 'message', role: 'user', blocks: [ - { type: 'text', text: source.text }, - { type: 'image-ref', path: '/stored/one.png' }, { type: 'image-ref', path: '/stored/two.png' } - ] } }) - expect(params.options).toEqual({ model: 'picked' }) - updateNativeChatComposerDraft(scope, { text: 'newer edit' }, 'immediate') - await vi.advanceTimersByTimeAsync(issuedAt + 29_999 - Date.now()) - expect(getStructuredAgentSessionPendingSends(sessionId)[0]?.phase).toBe('sending') - create.resolve(created()) - await result - await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: true }) - expect(readNativeChatComposerDraft(scope).text).toBe('newer edit') - } else if (!legacy) { - expect(await result).toBeInstanceOf(StructuredAgentSessionSubmissionEndedError) - expect(mocks.call).not.toHaveBeenCalled() - expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', sessionId)).toBeNull() - } else { - await result - await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: false }) - expect(mocks.call.mock.calls.map(([, method]) => method)).toEqual(['agentSession.create']) - if (scenario === 'legacyFailed') { - expect(readNativeChatComposerDraft(scope)).toMatchObject(source) - - } - } - } - ) - - it('keeps a definite opening failure through reload and reports refusal with newer content intact', async () => { - const sessionId = 'codex_failed_reload' - const source = { text: 'original opening', images: [] } - const scope = structuredAgentSessionDraftScopeKey(sessionId) - allocateNativeChatNewDraft(sessionId, { owner: { workspaceId: 'workspace-1', executionHostId: 'local' }, agent: 'codex', groupId: 'group-1', options: {} }) - const prompt = stageStructuredLaunchPrompt(sessionId, source.text, { sourceDraft: source }) - const response = deferred() - mocks.call.mockReturnValue(response.promise) - const launch = startStructuredAgentLaunch('workspace-1', 'codex', { requestId: newAgentLaunchRequestId(), executionHostId: 'local', reservedSessionId: sessionId, prompt: source.text, stagedPrompt: prompt }) - const result = launch.launchResult.catch((error: unknown) => error) - await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - updateNativeChatComposerDraft(scope, { text: 'newer content', images: [] }, 'immediate') - response.resolve({ ok: false, refusal: { code: 'structured_agent_session_unsupported', message: 'unavailable' } }) - await result - await launch.promptDeliveryResult - expect(readNativeChatComposerDraft(scope).text).toBe('newer content') - expect(getStructuredAgentSessionSendNotice(sessionId)).toContain('not sent') - const failed = readStructuredAgentLaunchRecord(sessionId) - expect(failed?.lifecycle).toBe('failed') - expect(failed?.firstMessage).toEqual(createParams().firstMessage) - resetStructuredAgentLaunchRegistryForTests() - resetStructuredAgentLaunchPersistenceForTests() - confirmStructuredLaunchFirstMessagePublication('workspace-1', sessionId) - expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', sessionId)).toBe('failed') - expect(mocks.call).toHaveBeenCalledOnce() - expect(submitNativeChatLocalDraft(sessionId, 'new explicit opening', [], {})).toBe(true) - expect(getStructuredAgentSessionPendingSends(sessionId)[0]?.clientMessageId).not.toBe(prompt.slot?.clientMessageId) - }) - - it('re-derives metadata retirement after repeated refusal without launch evidence', async () => { - const storage = createMemoryNativeChatComposerDraftStorage() - setNativeChatComposerDraftStorageForTests(storage) - const remove = vi.spyOn(storage, 'remove') - const sessionId = 'codex_published_metadata' - const source = { text: 'newer typing', images: [] } - const metadata = { owner: { workspaceId: 'workspace-1', executionHostId: 'local' as const }, agent: 'codex' as const, groupId: 'group-1', options: {} } - allocateNativeChatNewDraft(sessionId, metadata) - await vi.waitFor(async () => expect(await storage.read(`agent-session-new:${sessionId}`)).toEqual(metadata)) - updateNativeChatComposerDraft(structuredAgentSessionDraftScopeKey(sessionId), source, 'immediate') - storage.refuseWrites = true - for (let attempt = 0; attempt < 2; attempt += 1) { - markStructuredAgentSessionLaunchPublished('workspace-1', sessionId, 'local') - await vi.waitFor(() => expect(remove).toHaveBeenCalledTimes(attempt + 1)) - await expect(remove.mock.results[attempt]?.value).rejects.toThrow('refused') - await vi.waitFor(() => expect(isNativeChatComposerDraftUnsaved(structuredAgentSessionDraftScopeKey(sessionId))).toBe(true)) - expect(nativeChatNewDraftIsHosted(sessionId, metadata)).toBe(true) - expect(readNativeChatNewDraft(sessionId)).toEqual(metadata) - } - storage.refuseWrites = false - markStructuredAgentSessionLaunchPublished('workspace-1', sessionId, 'local') - await vi.waitFor(() => expect(readNativeChatNewDraft(sessionId)).toBeUndefined()) - expect(readStructuredAgentLaunchRecord(sessionId)).toBeUndefined() - expect(readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey(sessionId))).toMatchObject(source) - expect(mocks.call).not.toHaveBeenCalled() - }) - - it('drains a post-create pick once when journal confirmation precedes the original reply', async () => { - const create = deferred() - const option = deferred() - mocks.call.mockReturnValue(create.promise) - const launch = start() - await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - const response = created() - const picked = holdStructuredAgentSessionLaunchOption(launch.sessionId, 'model', 'later-pick') - await vi.advanceTimersByTimeAsync(30_000) - await launch.promptDeliveryResult - mocks.inventory.mockResolvedValue([{ worktree: 'workspace-1', tabs: [{ type: 'agent-session', sessionId: launch.sessionId }] }]) - mocks.call.mockImplementation((_target, method) => method === 'agentSession.history' - ? Promise.resolve({ ok: true, page: response.value.page }) : option.promise) - confirmStructuredLaunchFirstMessagePublication('workspace-1', launch.sessionId) - await vi.waitFor(() => expect(mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.setOption')).toHaveLength(1)) - create.resolve(response) - await launch.launchResult - expect(mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.setOption')).toHaveLength(1) - option.resolve({ ok: true, value: { options: { model: 'later-pick' } } }) - await expect(picked).resolves.toMatchObject({ kind: 'accepted' }) - }) - it('freezes the message and held options in create and publishes without another read or send', async () => { const create = deferred() mocks.call.mockReturnValue(create.promise) @@ -628,7 +433,6 @@ describe('the first message owned by create', () => { : { ...response, replayed: true, value: { ...response.value, firstMessage: receipt } } ) markStructuredAgentSessionLaunchPublished('workspace-1', launch.sessionId, 'local') - expect(retryStructuredAgentSessionLaunch('workspace-1', launch.sessionId)).toBe(true) await vi.waitFor(() => expect(readStructuredAgentLaunchRecord(launch.sessionId)).toBeUndefined() ) @@ -691,7 +495,7 @@ describe('the first message owned by create', () => { await vi.waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) await vi.advanceTimersByTimeAsync(30_000) expect(getStructuredAgentSessionLaunchLifecycle('workspace-1', launch.sessionId)).toBe( - 'published' + 'visibility-unknown' ) expect(draft(launch.sessionId)).toBe('opening text') mocks.inventory.mockResolvedValue([]) diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts index e25d3a9970be..3535c9ceb0d0 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.ts @@ -1,4 +1,3 @@ -import { StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' import { structuredAgentSessionCreateFingerprint } from '../../../shared/structured-agent-session-mutation' import type { StructuredLaunchState } from './structured-agent-session-launch-registry' import { notifyStructuredLaunchListeners } from './structured-agent-session-launch-registry' @@ -19,54 +18,36 @@ import { } from './structured-agent-session-launch-options' /** Freeze what create carries once; an ambiguous reply always replays the same payload. */ -export async function prepareStructuredLaunchCreateMessage(state: StructuredLaunchState): Promise void)> { +export function prepareStructuredLaunchCreateMessage(state: StructuredLaunchState): void { const { intent } = state if (intent.createPrepared) { return } - const attempt = state.callers.attempt - const prompt = attempt.kind === 'first' ? attempt.stagedPrompt : null - if (prompt?.sourceDraft) { - if (!prompt.slot?.prepare()) { - throw new StructuredAgentSessionSubmissionEndedError() - } - await prompt.beforeCreate?.() - } - return () => { - if (prompt?.sourceDraft && !prompt.slot?.prepare()) { - throw new StructuredAgentSessionSubmissionEndedError() - } - if (intent.createMessageSupport !== true) { - prepareLegacyStructuredLaunchPrompts(intent.sessionId) - intent.createPrepared = true - notifyStructuredLaunchListeners() - return - } - const firstMessage = prompt?.beforeSend ? undefined : prepareStructuredLaunchPromptForCreate( - intent.sessionId, intent.target, prompt?.sourceDraft ? prompt : undefined - ) - if (prompt?.sourceDraft && !prompt.beforeSend && !firstMessage) { - throw new StructuredAgentSessionSubmissionEndedError() - } - const options = structuredLaunchCreateOptions(state) - const params = { - ...intent.params, - ...(firstMessage ? { firstMessage } : {}), - ...(options ? { options } : {}) - } - intent.params = { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: structuredAgentSessionCreateFingerprint({ - sessionId: intent.sessionId, - ...params - }) - } - } + if (intent.createMessageSupport !== true) { + prepareLegacyStructuredLaunchPrompts(intent.sessionId) intent.createPrepared = true notifyStructuredLaunchListeners() + return + } + const firstMessage = prepareStructuredLaunchPromptForCreate(intent.sessionId, intent.target) + const options = structuredLaunchCreateOptions(state) + const params = { + ...intent.params, + ...(firstMessage ? { firstMessage } : {}), + ...(options ? { options } : {}) } + intent.params = { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: structuredAgentSessionCreateFingerprint({ + sessionId: intent.sessionId, + ...params + }) + } + } + intent.createPrepared = true + notifyStructuredLaunchListeners() } /** Picks held so far, and the model and effort the chat shows: a first message starts its turn @@ -75,10 +56,6 @@ function structuredLaunchCreateOptions( state: StructuredLaunchState ): Readonly> | undefined { const { intent, selection } = state - const attempt = state.callers.attempt - if (attempt.kind === 'first' && attempt.stagedPrompt?.sourceDraft) { - return attempt.stagedPrompt.options - } const firstFrame = firstFrameOptionState(intent.agent, intent.target, { newLaunch: !intent.params.resumeFrom, worktree: intent.params.worktree, @@ -99,27 +76,16 @@ function structuredLaunchCreateOptions( } /** Later picks use the normal option path without holding publication behind startup. */ -const optionPublications = new WeakMap>() - export function publishStructuredLaunchCreateOptions( state: StructuredLaunchState, receipt: StructuredAgentLaunchReceipt -): Promise { - const existing = optionPublications.get(state) - const publication = existing ?? Promise.resolve().then(() => { - if (state.intent.createMessageSupport === true) { - settleStructuredLaunchCreateOptions(state, state.intent.params.options ?? {}) - } - return receipt - }) - optionPublications.set(state, publication) - return publication.then(() => { - if (state.intent.createMessageSupport !== true) { - return applyStructuredLaunchHeldOptions(state, receipt) - } - void applyStructuredLaunchHeldOptions(state, receipt).catch((error: unknown) => { - console.warn('[native-chat] applying a launch option failed', error) - }) - return receipt +): StructuredAgentLaunchReceipt | Promise { + if (state.intent.createMessageSupport !== true) { + return applyStructuredLaunchHeldOptions(state, receipt) + } + settleStructuredLaunchCreateOptions(state, state.intent.params.options ?? {}) + void applyStructuredLaunchHeldOptions(state, receipt).catch((error: unknown) => { + console.warn('[native-chat] applying a launch option failed', error) }) + return receipt } diff --git a/src/renderer/src/lib/structured-agent-session-launch-errors.ts b/src/renderer/src/lib/structured-agent-session-launch-errors.ts index 13e8aaa6e43e..bf03c9105708 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-errors.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-errors.ts @@ -43,6 +43,3 @@ export class StructuredAgentSessionOwnerUnresolvedError extends Error { this.name = 'StructuredAgentSessionOwnerUnresolvedError' } } - -/** A local submission ended before any create was issued. */ -export class StructuredAgentSessionSubmissionEndedError extends Error {} diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts index 3fddbd15901f..8d700ba9227f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.test.ts @@ -193,17 +193,14 @@ describe('picks made while a chat launches', () => { prompt: 'first turn' }) - let commandSettled = false - void launch.launchResult.then(() => { commandSettled = true }) const modelPick = holdStructuredAgentSessionLaunchOption(SESSION_ID, 'model', 'gpt-picked') holdStructuredAgentSessionLaunchOption(SESSION_ID, 'effort', 'high') created.resolve({ sessionId: SESSION_ID, fence: 7 }) await settle() - // The hosted chat is usable while its original command still waits for held picks. + // The host published the tab, but the launch is not published until its picks land. markStructuredAgentSessionLaunchPublished(WORKTREE_ID, SESSION_ID, 'local') - expect(lifecycle()).toBe('published') - expect(commandSettled).toBe(false) + expect(lifecycle()).toBe('pending') expect(mutations()).toEqual([ { method: 'agentSession.setOption', fence: 7, key: 'model', value: 'gpt-picked' } ]) @@ -216,8 +213,7 @@ describe('picks made while a chat launches', () => { options: { model: 'gpt-picked' } }) await settle() - expect(lifecycle()).toBe('published') - expect(commandSettled).toBe(false) + expect(lifecycle()).toBe('pending') setOptionReplies[1]!.resolve(ACCEPTED) await launch.promptDeliveryResult diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index cc9caf440408..0b18e0c253ce 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -45,7 +45,6 @@ export type StructuredLaunchOptionOutcome = type OptionReply = (outcome: StructuredLaunchOptionOutcome) => void const optionReplies = new WeakMap>() -const optionDrains = new WeakMap>() function repliesFor(state: StructuredLaunchState): Map { let replies = optionReplies.get(state) @@ -164,20 +163,7 @@ export function settleStructuredLaunchCreateOptions( * effort lands under the model it was picked against, until none is left. A refused pick is * reported to its picker and never keeps the launch from publishing. */ -export function applyStructuredLaunchHeldOptions( - state: StructuredLaunchState, - receipt: StructuredAgentLaunchReceipt -): Promise { - const draining = optionDrains.get(state) - if (draining) { - return draining - } - const drain = drainStructuredLaunchHeldOptions(state, receipt).finally(() => optionDrains.delete(state)) - optionDrains.set(state, drain) - return drain -} - -async function drainStructuredLaunchHeldOptions( +export async function applyStructuredLaunchHeldOptions( state: StructuredLaunchState, receipt: StructuredAgentLaunchReceipt ): Promise { diff --git a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts index dd5d9f4ad095..02fd06166181 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-outcome-tracking.ts @@ -1,4 +1,3 @@ -import { StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' import { settleStructuredLaunchCallers, @@ -6,16 +5,13 @@ import { } from '@/lib/structured-agent-session-launch-callers' import type { StructuredAgentLaunchReceipt } from '@/lib/structured-agent-session-launch-recovery' import { structuredLaunchFailure } from './structured-agent-session-launch-failure' -import { markStructuredAgentSessionLaunchPublished } from './structured-agent-session-launch-publication' import { - getStructuredLaunchStateBySessionId, deleteStructuredLaunchStateIfCurrent, notifyStructuredLaunchListeners, retireStructuredAgentSessionLaunchCancellationTombstone, type StructuredLaunchState } from './structured-agent-session-launch-registry' - // How a launch's create outcome settles its callers: published, refused, failed or unknown. function cleanupLaunchState(state: StructuredLaunchState): void { @@ -53,15 +49,14 @@ export function trackLaunchSettlement( ): void { void promise.then( () => { - if (state.promise !== promise || getStructuredLaunchStateBySessionId(state.intent.sessionId) !== state) { + if (state.promise !== promise) { return } - markStructuredAgentSessionLaunchPublished(state.intent.worktreeId, state.intent.sessionId, state.intent.executionHostId) settleStructuredLaunchCallers(state.callers, 'published') notifyStructuredLaunchListeners() }, (error) => { - if (state.promise !== promise || getStructuredLaunchStateBySessionId(state.intent.sessionId) !== state) { + if (state.promise !== promise) { return } if (state.cancelled) { @@ -73,13 +68,6 @@ export function trackLaunchSettlement( } return } - if (error instanceof StructuredAgentSessionSubmissionEndedError) { - state.cancelled = true - state.visibilityUnknown = false - settleStructuredLaunchCallers(state.callers, 'failed') - cleanupLaunchState(state) - return - } // The chat's Retry line owns the failed start's guidance. console.warn('[native-chat] structured launch failed', error) const failure = structuredLaunchFailure(error) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts index 68b7c583e286..aba3d5db48ac 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-outcome.ts @@ -1,5 +1,3 @@ -import { StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' -import type { StagedStructuredLaunchPrompt } from './structured-agent-session-launch-prompt' import type { StructuredLaunchCaller } from './structured-agent-session-launch-callers' import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' import { STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS } from '@/components/native-chat/structured-agent-session-message-sender' @@ -8,25 +6,6 @@ import { type StructuredLaunchState } from './structured-agent-session-launch-registry' -/** Pre-issue reservation settlement ends the command even while discovery is unresolved. */ -export function boundStructuredLaunchReservation( - state: StructuredLaunchState, - created: Promise, - prompt: StagedStructuredLaunchPrompt | null -): Promise { - const { intent } = state - if (!prompt?.sourceDraft || !prompt.slot) { - return created - } - return Promise.race([created, prompt.slot.outcome.then(() => { - if (!intent.createPrepared) { - intent.prepareCreate = () => { throw new StructuredAgentSessionSubmissionEndedError() } - throw new StructuredAgentSessionSubmissionEndedError() - } - return created - })]) -} - /** The message deadline frees the composer and offers the existing Retry while create is unknown. */ export function trackStructuredLaunchPromptOutcome( state: StructuredLaunchState, diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 28e1de75193a..fb9352720406 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -1,24 +1,20 @@ import { structuredAgentSessionSendBody } from '../../../shared/structured-agent-session-send-mutation' -import { handBackStructuredAgentSessionMessage } from '@/components/native-chat/structured-agent-session-message-hand-back' -import { publishStructuredAgentSessionSends } from '@/components/native-chat/structured-agent-session-pending-sends' -import { agentSessionWriteNoticeText } from '@/components/native-chat/agent-session-write-notice-text' - import type { StructuredAgentLaunchReceipt } from './structured-agent-session-launch-recovery' import type { StructuredAgentSessionFirstMessage } from '../../../shared/structured-agent-session-create' -import { StructuredAgentSessionCreateRefusalError, StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' -import { structuredLaunchFailure } from './structured-agent-session-launch-failure' +import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' +import { agentSessionThrownFailure } from '../../../shared/agent-session-write-failure' +import { handBackStructuredAgentSessionMessage } from '@/components/native-chat/structured-agent-session-message-hand-back' import { dropStructuredAgentSessionSends, reserveStructuredAgentSessionSend, sendStructuredAgentSessionMessage, type StructuredAgentSessionCreateSend, - type StructuredAgentSessionSendOutcome, type StructuredAgentSessionReservedSend } from '@/components/native-chat/structured-agent-session-message-sender' import { noteStructuredAgentSessionFence } from '@/components/native-chat/structured-agent-session-send-attempt' +import { publishStructuredAgentSessionSends } from '@/components/native-chat/structured-agent-session-pending-sends' +import { agentSessionWriteNoticeText } from '@/components/native-chat/agent-session-write-notice-text' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' -import type { StructuredAgentSessionSendInput } from '@/components/native-chat/structured-agent-session-send-slot' -import { projectNativeChatComposerDraft, type NativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-storage' export type StructuredPromptDeliveryResult = { delivered: boolean @@ -41,6 +37,8 @@ export type StructuredLaunchPromptOptions = { onPromptDelivered?: () => void } +type LaunchReceipt = StructuredAgentLaunchReceipt + /** A launch's text, held in memory from the click until its chat exists, then sent once: every * caller waiting on it shares the one send. From the click it holds the chat's one send slot, drawn * as sending, so nothing typed meanwhile goes out ahead of it. */ @@ -48,10 +46,6 @@ export type StagedStructuredLaunchPrompt = { sessionId: string text: string slot: StructuredAgentSessionReservedSend | null - sourceDraft?: NativeChatComposerDraft - options?: Readonly> - beforeCreate?: () => Promise - beforeSend?: () => Promise /** Its sender keeps the text if it comes back (notes), instead of the chat's composer. */ callerKeepsText?: true delivery?: Promise @@ -72,9 +66,8 @@ const staged = new Map>() export function stageStructuredLaunchPrompt( sessionId: string, text: string, - options: { callerKeepsText?: true; sourceDraft?: NativeChatComposerDraft } & Pick = {} + options: { callerKeepsText?: true } = {} ): StagedStructuredLaunchPrompt { - const source = options.sourceDraft ? { sourceDraft: projectNativeChatComposerDraft(options.sourceDraft) } : {} let discard = (): void => {} const whenDiscarded = new Promise((resolve) => { discard = resolve @@ -86,9 +79,8 @@ export function stageStructuredLaunchPrompt( const prompt: StagedStructuredLaunchPrompt = { sessionId, text, - slot: reserveStructuredAgentSessionSend({ sessionId, text, ...options, ...source }), + slot: reserveStructuredAgentSessionSend({ sessionId, text, ...options }), ...options, - ...source, whenDiscarded, discard, whenCreateSettled, @@ -98,17 +90,21 @@ export function stageStructuredLaunchPrompt( forSession.add(prompt) staged.set(sessionId, forSession) void prompt.slot?.outcome.then((outcome) => { - const result = stagedPromptDelivery(prompt, outcome) + const result: StagedDelivery = { + delivered: outcome === 'recorded', + inComposer: !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), + ...(outcome === 'unconfirmed' ? { unconfirmed: true } : {}) + } if (!prompt.delivery) { prompt.delivery = Promise.resolve(result) - releaseStagedStructuredLaunchPrompt(prompt) + unstage(prompt) } prompt.settleCreate(result) }) return prompt } -export function releaseStagedStructuredLaunchPrompt(prompt: StagedStructuredLaunchPrompt): void { +function unstage(prompt: StagedStructuredLaunchPrompt): void { prompt.slot?.release() const forSession = staged.get(prompt.sessionId) forSession?.delete(prompt) @@ -140,13 +136,9 @@ export function takeBackLegacyStructuredLaunchPrompts(sessionId: string): void { if (prompt.delivery || prompt.createOwned) { continue } - if (prompt.slot) { - prompt.slot.release(null) - } else { - handBackStagedPrompt(prompt) - } - releaseStagedStructuredLaunchPrompt(prompt) + unstage(prompt) prompt.discarded = true + handBackStagedPrompt(prompt) prompt.discard() } } @@ -160,22 +152,31 @@ export function prepareLegacyStructuredLaunchPrompts(sessionId: string): void { /** Transfers the first staged prompt to create, retaining the sender's deadline and bubble. */ export function prepareStructuredLaunchPromptForCreate( sessionId: string, - target: RuntimeClientTarget, - ownedPrompt?: StagedStructuredLaunchPrompt + target: RuntimeClientTarget ): StructuredAgentSessionFirstMessage | undefined { - const prompt = ownedPrompt ?? [...(staged.get(sessionId) ?? [])].find( + const prompt = [...(staged.get(sessionId) ?? [])].find( (candidate) => !candidate.discarded && !candidate.delivery ) if (!prompt) { return undefined } prompt.createOwned = true - const sent = prompt.slot?.create(target, ownedPrompt ? true : undefined) + const sent = prompt.slot?.create(target) prompt.createSend = sent ?? undefined - prompt.delivery = (sent?.outcome ?? Promise.resolve('returned' as const)).then((outcome) => { - releaseStagedStructuredLaunchPrompt(prompt) - return stagedPromptDelivery(prompt, outcome) - }) + prompt.delivery = sent + ? sent.outcome.then((outcome) => { + unstage(prompt) + return { + delivered: outcome === 'recorded', + inComposer: + !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), + ...(outcome === 'unconfirmed' ? { unconfirmed: true as const } : {}) + } + }) + : Promise.resolve({ delivered: false, inComposer: !prompt.callerKeepsText }).then((outcome) => { + unstage(prompt) + return outcome + }) void prompt.delivery.then(prompt.settleCreate) return sent?.message } @@ -188,7 +189,7 @@ export function hasStagedStructuredLaunchPrompt(sessionId: string): boolean { /** Puts a launch's text in its chat's composer, unless its sender keeps it. True when it did; a * draft write that throws is reported in the chat instead, and never stops its caller. */ function handBackStagedPrompt(prompt: StagedStructuredLaunchPrompt): boolean { - if (prompt.callerKeepsText || prompt.discarded) { + if (prompt.callerKeepsText) { return false } try { @@ -210,67 +211,45 @@ function handBackStagedPrompt(prompt: StagedStructuredLaunchPrompt): boolean { } } -function stagedPromptDelivery( - prompt: StagedStructuredLaunchPrompt, - outcome: StructuredAgentSessionSendOutcome -): StagedDelivery { - return { - delivered: outcome === 'recorded', - inComposer: !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), - ...(outcome === 'unconfirmed' ? { unconfirmed: true as const } : {}) - } -} - -function failStagedStructuredLaunchPrompt(prompt: StagedStructuredLaunchPrompt): void { - if (prompt.discarded) { - return - } - if (prompt.slot) { - prompt.slot.release(['notDoneSend']) - } else { - handBackStagedPrompt(prompt) - } - releaseStagedStructuredLaunchPrompt(prompt) - prompt.settleCreate({ delivered: false, inComposer: true }) -} - function sendStagedPrompt( prompt: StagedStructuredLaunchPrompt, - receipt: StructuredAgentLaunchReceipt, + receipt: LaunchReceipt, target: RuntimeClientTarget ): Promise { prompt.delivery ??= (async () => { noteStructuredAgentSessionFence(prompt.sessionId, receipt.fence) - if (prompt.sourceDraft && !prompt.slot?.prepare()) { - throw new StructuredAgentSessionSubmissionEndedError() - } - await prompt.beforeSend?.() const sent = - prompt.slot ? prompt.slot.send(target) : + prompt.slot?.send(target) ?? sendStructuredAgentSessionMessage({ sessionId: prompt.sessionId, target, text: prompt.text }) if (!sent) { // Another send of the chat holds its slot: the launch text waits in the composer instead. - releaseStagedStructuredLaunchPrompt(prompt) + unstage(prompt) return { delivered: false, inComposer: handBackStagedPrompt(prompt), busy: true } } try { - return stagedPromptDelivery(prompt, await sent.outcome) + const outcome = await sent.outcome + return { + delivered: outcome === 'recorded', + inComposer: + !prompt.callerKeepsText && (outcome === 'returned' || outcome === 'unconfirmed'), + ...(outcome === 'unconfirmed' ? { unconfirmed: true as const } : {}) + } } finally { - releaseStagedStructuredLaunchPrompt(prompt) + unstage(prompt) } })() return prompt.delivery } export function settleStructuredAgentLaunchPrompt(args: { - launchResult: Promise + launchResult: Promise target: RuntimeClientTarget options: StructuredLaunchPromptOptions stagedPrompt: StagedStructuredLaunchPrompt | null }): Promise | undefined { // Why: a draft has no delivery event — the composer adopts it and the user sends it — so // `onPromptDelivered` never fires and no result is reported. - if (args.options.promptDelivery === 'draft' || (!args.options.prompt?.trim() && !args.stagedPrompt)) { + if (args.options.promptDelivery === 'draft' || !args.options.prompt?.trim()) { return undefined } const prompt = args.stagedPrompt @@ -290,10 +269,7 @@ export function settleStructuredAgentLaunchPrompt(args: { prompt, receipt, args.target - ).catch((): StagedDelivery => { - failStagedStructuredLaunchPrompt(prompt) - return { delivered: false, inComposer: true } - }) + ) return { delivered, failureNotified: false, @@ -306,13 +282,15 @@ export function settleStructuredAgentLaunchPrompt(args: { if (prompt?.createOwned) { prompt.createSend?.settle( error instanceof StructuredAgentSessionCreateRefusalError - ? { kind: 'refused', failure: structuredLaunchFailure(error) ?? { kind: 'failed' } } + ? { kind: 'refused', failure: agentSessionThrownFailure(error, error.code) } : { kind: 'unconfirmed' } ) } // The chat never started: its text waits in the chat's composer for the person's own Send. if (prompt && !prompt.discarded && !prompt.delivery) { - failStagedStructuredLaunchPrompt(prompt) + unstage(prompt) + prompt.discarded = true + handBackStagedPrompt(prompt) } throw error } diff --git a/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts b/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts index 8cf61690383f..f172a83e7e4c 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-publication-confirmation.ts @@ -1,46 +1,33 @@ -import { STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS } from '@/components/native-chat/structured-agent-session-message-sender' import { recoverPublishedSessionReceipt } from './structured-agent-session-launch-recovery' import { publishStructuredLaunchCreateOptions } from './structured-agent-session-launch-create-message' -import { settleStructuredLaunchCallers } from './structured-agent-session-launch-callers' +import { trackLaunchSettlement } from './structured-agent-session-launch-outcome-tracking' +import { trackStructuredLaunchConfirmationDeadline } from './structured-agent-session-launch-prompt-outcome' import { restorePersistedStructuredLaunchState } from './structured-agent-session-launch-reload' import { getStructuredLaunchStateBySessionId, - notifyStructuredLaunchListeners, - type StructuredLaunchState + notifyStructuredLaunchListeners } from './structured-agent-session-launch-registry' -const confirming = new WeakSet() - -/** Confirmation observes positive evidence; it never replaces the execution promise or verdict. */ -export function confirmStructuredLaunchFirstMessagePublication(worktreeId: string, sessionId: string): void { - const state = getStructuredLaunchStateBySessionId(sessionId) ?? +/** A tab proves the chat exists; its journal must also confirm the immutable opening message. */ +export function confirmStructuredLaunchFirstMessagePublication( + worktreeId: string, + sessionId: string +): void { + const state = + getStructuredLaunchStateBySessionId(sessionId) ?? restorePersistedStructuredLaunchState(worktreeId, sessionId) - if (!state || state.cancelled || state.callers.outcome === 'failed' || confirming.has(state)) { + if (!state || state.callers.outcome === 'pending' || state.cancelled) { return } - confirming.add(state) - let deadline: ReturnType | undefined - const expired = new Promise((_resolve, reject) => { - deadline = setTimeout(() => reject(new Error('Confirmation timed out.')), STRUCTURED_AGENT_SESSION_SEND_BUDGET_MS) - }) - void Promise.race([recoverPublishedSessionReceipt(state, false), expired]).then(async (receipt) => { - if (state.cancelled || getStructuredLaunchStateBySessionId(sessionId) !== state) { - return - } - await publishStructuredLaunchCreateOptions(state, receipt) - state.hostPublished = true - notifyStructuredLaunchListeners() - // The original command still owns refusal; a restored observation has an already settled promise. - void state.promise.then(() => { - if (!state.cancelled && getStructuredLaunchStateBySessionId(sessionId) === state) { - settleStructuredLaunchCallers(state.callers, 'published') - notifyStructuredLaunchListeners() - } - }, () => {}) - }).catch(() => { - // An absent or unreadable journal says nothing new about the command's outcome. - }).finally(() => { - clearTimeout(deadline) - confirming.delete(state) - }) + state.callers.outcome = 'pending' + state.visibilityUnknown = false + state.promise = recoverPublishedSessionReceipt(state) + .then((receipt) => publishStructuredLaunchCreateOptions(state, receipt)) + .catch((error: unknown) => { + state.visibilityUnknown = true + throw error + }) + trackLaunchSettlement(state, state.promise) + trackStructuredLaunchConfirmationDeadline(state, state.promise) + notifyStructuredLaunchListeners() } diff --git a/src/renderer/src/lib/structured-agent-session-launch-publication.ts b/src/renderer/src/lib/structured-agent-session-launch-publication.ts index 5252b42a94d2..3a8161a2dfe3 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-publication.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-publication.ts @@ -1,10 +1,6 @@ -import { readNativeChatNewDraft, retireNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' import type { ExecutionHostId } from '../../../shared/execution-host' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { deleteStructuredAgentLaunchRecord } from './structured-agent-session-launch-persistence' -import { resolveHostSessionGroupIdForWebSessionTab, setHostSessionTabIdMapping } from '@/runtime/web-session-tabs-sync/tracking-mappings' -import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' -import { structuredAgentSessionFocusOwner, structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' import { getPersistedStructuredAgentLaunchRecord, getStructuredLaunchStateBySessionId, @@ -32,16 +28,6 @@ export function markStructuredAgentSessionLaunchPublished( sessionId: string, executionHostId: ExecutionHostId ): boolean { - const draft = readNativeChatNewDraft(sessionId) - if (draft?.owner.workspaceId === worktreeId && draft.owner.executionHostId === executionHostId) { - const target = structuredAgentSessionTargetForHost(executionHostId) - if (target) { - const tabId = structuredAgentSessionTabId(sessionId) - const mapping = { ...structuredAgentSessionFocusOwner(target), worktreeId, tabId } - setHostSessionTabIdMapping(mapping, tabId, resolveHostSessionGroupIdForWebSessionTab(mapping) ?? undefined) - } - void retireNativeChatNewDraft(sessionId) - } const state = getStructuredLaunchStateBySessionId(sessionId) if (!state) { const persisted = getPersistedStructuredAgentLaunchRecord(sessionId) @@ -63,8 +49,6 @@ export function markStructuredAgentSessionLaunchPublished( ) { return false } - state.hostPublished = true - notifyStructuredLaunchListeners() if (state.callers.outcome === 'published') { return true } diff --git a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts index d7aca786219e..8a376210bc0c 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts @@ -1,4 +1,3 @@ -import { StructuredAgentSessionSubmissionEndedError } from './structured-agent-session-launch-errors' import type { AgentSessionHistoryResult } from '../../../shared/agent-session-wire' import type { AgentJournalCursor, @@ -13,7 +12,6 @@ import { import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { readStructuredSessionTabInventory } from '@/runtime/structured-session-tab-inventory' import { publishStructuredAgentSessionCreateHydration } from './structured-agent-session-create-hydration' -import type { StructuredLaunchAttempt } from './structured-agent-session-launch-request' export type StructuredAgentLaunchReceipt = { sessionId: string @@ -23,7 +21,6 @@ export type StructuredAgentLaunchReceipt = { } export type StructuredLaunchRecoveryState = { - callers?: { attempt: StructuredLaunchAttempt } intent: StructuredAgentSessionLaunchIntent promise: Promise visibilityUnknown: boolean @@ -32,17 +29,6 @@ export type StructuredLaunchRecoveryState = { onHostSeed?: StructuredLaunchHostSeedListener } -function issueLaunch(state: StructuredLaunchRecoveryState, unknown = false): Promise { - const attempt = state.callers?.attempt - const reservedSubmission = attempt?.kind === 'first' && attempt.stagedPrompt?.sourceDraft !== undefined - if (reservedSubmission) { - return launchStructuredAgentSession(state.intent, state.onHostSeed, unknown, true) - } - return unknown - ? launchStructuredAgentSession(state.intent, state.onHostSeed, true) - : launchStructuredAgentSession(state.intent, state.onHostSeed) -} - export class StructuredAgentSessionLaunchCancelledError extends Error { constructor() { super('structured session launch cancelled') @@ -72,8 +58,7 @@ async function verifyPublishedSession(state: StructuredLaunchRecoveryState): Pro } export async function recoverPublishedSessionReceipt( - state: StructuredLaunchRecoveryState, - replayMissingMessage = true + state: StructuredLaunchRecoveryState ): Promise { await verifyPublishedSession(state) const history = await callStructuredAgentSession( @@ -87,13 +72,13 @@ export async function recoverPublishedSessionReceipt( throw new Error('structured session fence publication unavailable') } const firstMessage = state.intent.params.firstMessage - ? history.page.submissions.find((submission) => submission.clientMessageId === state.intent.params.firstMessage?.clientMessageId) + ? history.page.submissions.find( + (submission) => + submission.clientMessageId === state.intent.params.firstMessage?.clientMessageId + ) : undefined if (state.intent.params.firstMessage && !firstMessage) { - if (!replayMissingMessage) { - throw new Error('The opening message has not been confirmed.') - } - return issueLaunch(state, true) + return launchStructuredAgentSession(state.intent, state.onHostSeed, true) } if (state.intent.createMessageSupport === true) { publishStructuredAgentSessionCreateHydration(state.intent, history.page, fence) @@ -113,7 +98,7 @@ async function retrySameIntent( ): Promise { throwIfLaunchCancelled(state) try { - const receipt = await issueLaunch(state, true) + const receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed, true) throwIfLaunchCancelled(state) if (state.intent.createMessageSupport !== true) { await verifyPublishedSession(state) @@ -123,7 +108,7 @@ async function retrySameIntent( if (state.cancelled) { throw new StructuredAgentSessionLaunchCancelledError() } - if (error instanceof StructuredAgentSessionCreateRefusalError || error instanceof StructuredAgentSessionSubmissionEndedError) { + if (error instanceof StructuredAgentSessionCreateRefusalError) { throw error } try { @@ -145,12 +130,12 @@ export async function launchAndReconcile( throwIfLaunchCancelled(state) let receipt: StructuredAgentLaunchReceipt try { - receipt = await issueLaunch(state) + receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed) } catch (error) { if (state.cancelled) { throw new StructuredAgentSessionLaunchCancelledError() } - if (error instanceof StructuredAgentSessionCreateRefusalError || error instanceof StructuredAgentSessionSubmissionEndedError) { + if (error instanceof StructuredAgentSessionCreateRefusalError) { throw error } try { diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index df779cf74138..076ecf184862 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -1,5 +1,4 @@ import { useSyncExternalStore } from 'react' -import { readNativeChatNewDraft, retireNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' import type { TuiAgent } from '../../../shared/tui-agent' import type { StructuredLaunchFailure } from './structured-agent-session-launch-failure' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' @@ -26,7 +25,6 @@ import { } from './structured-agent-session-launch-cancellation' export type StructuredLaunchState = StructuredLaunchRecoveryState & { - hostPublished?: true identity: string /** Fixed by the caller that opened this launch so coalesced prompts use one delivery mode. */ promptDelivery: StructuredAgentLaunchOptions['promptDelivery'] @@ -102,16 +100,12 @@ export function deleteStructuredLaunchStateIfCurrent(state: StructuredLaunchStat function persistStructuredLaunchState(state: StructuredLaunchState): void { const lifecycle = launchStateLifecycle(state) - if (state.callers.outcome === 'published' || lifecycle === 'cancelled') { - deleteStructuredAgentLaunchRecord(state.intent.sessionId) - return - } - if (!state.intent.createPrepared) { + if (lifecycle === 'published' || lifecycle === 'cancelled') { deleteStructuredAgentLaunchRecord(state.intent.sessionId) return } writeStructuredAgentLaunchRecord({ - ...structuredAgentLaunchRecordFor(state.intent, lifecycle === 'published' ? 'visibility-unknown' : lifecycle), + ...structuredAgentLaunchRecordFor(state.intent, lifecycle), ...(lifecycle === 'failed' ? { failedAt: state.callers.failedAt } : {}) }) } @@ -135,9 +129,6 @@ export function launchStateLifecycle( if (state.callers.outcome === 'published') { return 'published' } - if (state.hostPublished) { - return 'published' - } if (state.visibilityUnknown || state.callers.outcome === 'unknown') { return 'visibility-unknown' } @@ -223,7 +214,7 @@ export function shouldRetainStructuredAgentSessionLaunchTab( sessionId: string ): boolean { const lifecycle = getStructuredAgentSessionLaunchLifecycle(worktreeId, sessionId) - return Boolean(readNativeChatNewDraft(sessionId)) || lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed' + return lifecycle === 'pending' || lifecycle === 'visibility-unknown' || lifecycle === 'failed' } function markStructuredAgentSessionLaunchCancelledInternal( @@ -232,7 +223,6 @@ function markStructuredAgentSessionLaunchCancelledInternal( executionHostId: ExecutionHostId, notify: boolean ): boolean { - void retireNativeChatNewDraft(sessionId) const alreadyCancelled = hasStructuredAgentLaunchCancellationTombstonePersisted(sessionId) const state = getStructuredLaunchStateBySessionId(sessionId) if (matchesLaunchWorktree(state, worktreeId) && state) { diff --git a/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts b/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts index be659c18e897..2066ee816e8e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-unconfirmed-or-retried.test.ts @@ -258,12 +258,7 @@ describe('a new start beside an unconfirmed or retried chat', () => { it("opens a new chat with its text, not a draft, while a restored chat's Retry is in flight", async () => { const failed = launchIntent('session-failed') mocks.createIntent.mockReturnValueOnce(failed).mockReturnValueOnce(fresh) - mocks.launch.mockImplementationOnce(async (intent: StructuredAgentSessionLaunchIntent) => { - await Promise.resolve() - const issue = await intent.prepareCreate?.() - issue?.() - throw new StructuredAgentSessionCreateRefusalError('unsupported') - }) + mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) startStructuredAgentLaunch(WORKTREE_ID, 'codex', { requestId: 'request-3', prompt: 'first task' diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts index b5c05623014b..ba0fdc029505 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -367,7 +367,6 @@ describe('startStructuredAgentLaunch', () => { 'claude', undefined, undefined, - undefined, undefined ) expect(mocks.createIntent).toHaveBeenNthCalledWith( @@ -376,7 +375,6 @@ describe('startStructuredAgentLaunch', () => { 'codex', undefined, undefined, - undefined, undefined ) expect(mocks.launch).toHaveBeenCalledTimes(2) diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index d73774072b2d..11468d845d99 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -43,7 +43,6 @@ import { trackLaunchSettlement } from './structured-agent-session-launch-outcome-tracking' import { - boundStructuredLaunchReservation, trackStructuredLaunchPromptOutcome, trackStructuredLaunchConfirmationDeadline } from './structured-agent-session-launch-prompt-outcome' @@ -110,9 +109,6 @@ function adoptPairedHostSeed( state: StructuredLaunchState, seedOptions: StructuredLaunchState['selection']['seed'] ): void { - if (getStructuredLaunchStateBySessionId(state.intent.sessionId) !== state) { - return - } if (JSON.stringify(seedOptions) === JSON.stringify(state.intent.seedOptions)) { return } @@ -206,13 +202,12 @@ function structuredAgentLaunchState( agent, options.executionHostId, options.resumeFrom, - options.hostSeedOptions, - options.reservedSessionId + options.hostSeedOptions ) const text = launchPromptText(options) - const stagedPrompt = options.stagedPrompt ?? (text + const stagedPrompt = text ? stageStructuredLaunchPrompt(intent.sessionId, text, promptOwner(options)) - : null) + : null launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options) const callers = createStructuredLaunchCallerGroup({ kind: 'first', @@ -228,13 +223,11 @@ function structuredAgentLaunchState( cancelled: false, onVisibilityChanged: notifyStructuredLaunchListeners, callers, - selection: { seed: intent.seedOptions, held: options.openingOptions ?? {} } + selection: { seed: intent.seedOptions, held: {} } } state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions) callers.onSettled = () => maybeCleanupLaunchState(state) - setStructuredLaunchState(state) - const created = launchAndReconcile(state) - state.promise = publishWithHeldOptions(state, boundStructuredLaunchReservation(state, created, stagedPrompt)) + state.promise = publishWithHeldOptions(state, launchAndReconcile(state)) const caller = addStructuredLaunchCaller({ group: state.callers, launchResult: state.promise, diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index 56513aac18b4..7eb871fc868f 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -1,5 +1,3 @@ -import { readNativeChatNewDraft, retireNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' -import { deleteNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' import { collapseGroupLayout } from '../../../../../shared/workspace-layout/tab-group-layout-tree' import { @@ -12,8 +10,6 @@ import { dedupeTabOrder } from '../../../../../shared/workspace-layout/tab-order import { buildActiveSurfacePatch } from './tabs-surface' import { beginStructuredAgentSessionTabClose } from '@/runtime/structured-agent-session-tab-retirement' import { - getPersistedStructuredAgentLaunchRecord, - getStructuredLaunchStateBySessionId, hasStructuredAgentSessionLaunchCancellationTombstone, shouldRetainStructuredAgentSessionLaunchTab } from '@/lib/structured-agent-session-launch-registry' @@ -62,12 +58,7 @@ export function createTabsCloseActions( shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId) || hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, tab.entityId) const target = structuredAgentSessionTargetForTab(state, tab) - const unsent = readNativeChatNewDraft(tab.entityId) && - !getStructuredLaunchStateBySessionId(tab.entityId) && !getPersistedStructuredAgentLaunchRecord(tab.entityId) - if (unsent) { - void retireNativeChatNewDraft(tab.entityId) - deleteNativeChatComposerDraft(`agent-session:${tab.entityId}`) - } else if (target) { + if (target) { if (provisional) { clearWebSessionFocusIntentIfMatches( structuredAgentSessionFocusOwner(target), diff --git a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts index 9d996f95cebb..fa28767fac45 100644 --- a/src/renderer/src/store/slices/tabs/tabs-move-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-move-actions.ts @@ -1,4 +1,3 @@ -import { updateNativeChatNewDraft } from '@/components/native-chat/native-chat-new-draft' import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' import { collapseGroupLayout } from '../../../../../shared/workspace-layout/tab-group-layout-tree' import { buildActiveSurfacePatch } from './tabs-surface' @@ -124,12 +123,6 @@ export function createTabsMoveActions( : {}) } }) - if (moved) { - const movedTab = findTabAndWorktree(get().unifiedTabsByWorktree, tabId)?.tab - if (movedTab?.contentType === 'agent-session') { - updateNativeChatNewDraft(movedTab.entityId, { groupId: targetGroupId }) - } - } if (moved && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('tab-splits') } diff --git a/src/shared/agent-session-attachments.ts b/src/shared/agent-session-attachments.ts index 055b5875df63..d2fabcabd0e0 100644 --- a/src/shared/agent-session-attachments.ts +++ b/src/shared/agent-session-attachments.ts @@ -2,10 +2,6 @@ // bytes, the server keeps them in a store it owns, and the agent only ever sees the server path. import type { ImportSkipReason } from './filesystem-import-result-types' -import type { TuiAgent } from './tui-agent' - -export const AGENT_SESSION_DRAFT_ATTACHMENTS_RUNTIME_CAPABILITY = 'agent-session-draft-attachments-v1' as const -export type AgentSessionReservedAttachmentSession = { worktree: string; agent: TuiAgent } /** Directory name of the server's store, under its userData. Also how a client recognizes a * stored attachment's path without a round trip. */ @@ -96,7 +92,6 @@ export type AgentSessionAttachmentUploadTarget = { sessionId: string expectedEnvironmentPairingRevision?: number expectedEnvironmentRuntimeId: string - reservedSession?: AgentSessionReservedAttachmentSession } export type AgentSessionAttachmentPathUploadResult = { @@ -110,7 +105,7 @@ export type AgentSessionAttachmentPathUploadResult = { * by the save's `runtimeEnvironmentId`, instead of that server's temp directory. */ export type AgentSessionAttachmentClipboardTarget = Omit< AgentSessionAttachmentUploadTarget, - 'environmentId' | 'reservedSession' + 'environmentId' > /** `orca-paste-…png`, the name every pasted image gets, so it reads as "Pasted image". */ diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 0432097459bf..18193c55454a 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,4 +1,3 @@ -import { AGENT_SESSION_DRAFT_ATTACHMENTS_RUNTIME_CAPABILITY } from './agent-session-attachments' import { AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY } from './ai-vault-resume-preparation' import { JIRA_AGENT_ACCESS_RUNTIME_CAPABILITY } from './jira-agent-capability' import { JIRA_STATUS_FILTER_RUNTIME_CAPABILITY } from './jira-quick-filters' @@ -250,7 +249,6 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, - AGENT_SESSION_DRAFT_ATTACHMENTS_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, ...AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES, diff --git a/src/shared/rpc-contract/agent-session-attachment-params.ts b/src/shared/rpc-contract/agent-session-attachment-params.ts index 70e2cdace55d..0288c1e46323 100644 --- a/src/shared/rpc-contract/agent-session-attachment-params.ts +++ b/src/shared/rpc-contract/agent-session-attachment-params.ts @@ -5,18 +5,12 @@ import { } from '../agent-session-attachments' import { isValidBase64 } from './clipboard-params' import { Identifier, SessionId } from './structured-agent-session-params' -import { isTuiAgent } from '../tui-agent-config' -import type { TuiAgent } from '../tui-agent' const UploadId = Identifier('Invalid upload id', 128) export const AttachmentUploadStartParams = z .object({ sessionId: SessionId, - reservedSession: z.object({ - worktree: z.string().min(1).max(4096), - agent: z.custom(isTuiAgent) - }).strict().optional(), name: z.string().min(1).max(1024), byteLength: z .number() From 6af401dc7fe887f433ac044bef819c73550e12b1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:30:58 -0700 Subject: [PATCH 14/27] fix(native-chat): open a New Chat's composer at once, before its host answers The new chat's tab and composer waited for the host to admit the chat, so keys typed right after New Chat went to whatever had focus. A New Chat the user asked for now opens its chat tab immediately on the host it was routed to, with focus in the composer. Its ordinary launch asks the host in the background, the same probe it already ran before create, and the chat shows that launch's starting, failed and Retry states. - A launch that carries a prompt still asks first, so a refusal hands the prompt to a terminal as before. - A refused chat nobody typed in closes and its terminal opens in its place; a chat with typing keeps it. - A paired host that would run the chat with more access than asked for gets no create; the chat's permission notice is the only one shown. - A chat closed while its host was being asked issues no create. - While the chat starts on a paired server, uploads wait and the model picker stays disabled until the server names what it seeds. --- .../native-chat-attachment-upload.ts | 2 + .../native-chat/native-chat-composer-types.ts | 2 + .../native-chat-structured-session-notices.ts | 17 +-- .../use-native-chat-external-attachments.ts | 10 +- .../use-native-chat-provisional-launch.ts | 5 +- ...tive-chat-structured-composer-transport.ts | 3 +- .../use-structured-agent-session-options.ts | 2 +- .../launch-structured-agent-session.test.ts | 24 ++++ ...ctured-agent-session-launch-create-call.ts | 31 ++++- ...gent-session-launch-create-message.test.ts | 3 + ...structured-agent-session-launch-options.ts | 2 + ...tructured-agent-session-launch-recovery.ts | 21 +++- .../lib/structured-agent-session-launch.ts | 11 +- ...ured-agent-session-provisional-tab.test.ts | 61 +++++++++- ...tructured-agent-session-provisional-tab.ts | 113 ++++++++++++++++-- 15 files changed, 273 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 552f6fed2f70..c5a50df7cd08 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -125,6 +125,8 @@ export type NativeChatStructuredAttachmentSession = { sessionId: string /** Null: the chat runs on this machine. */ runtimeEnvironmentId: string | null + /** The host has not created this chat yet, so nothing can be uploaded into it. */ + sessionPending?: boolean } /** A structured chat on a paired server owns its attachments by where it runs, not by worktree. */ diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index a0aa6cad10fe..7776414826ae 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -63,6 +63,8 @@ export type NativeChatStructuredComposerTransport = { sessionId: string /** Owning runtime for that report; null is the local runtime. */ runtimeEnvironmentId: string | null + /** The chat is still starting on its host: uploads wait for it. */ + sessionPending?: boolean } export type NativeChatOptimisticSendOutcome = { diff --git a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts index f890dc5bdfa2..aee0080a1f1f 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts @@ -95,13 +95,16 @@ export function structuredSessionNotices({ /** Why this chat's host may not take a turn, from `useNativeChatHostPermissionGate`. */ hostPermission?: NativeChatComposerNotice | null }): NativeChatComposerNotice[] { - const launchNotice = nativeChatLaunchNotice({ - lifecycle: launch.lifecycle, - failure: launch.failure, - agentLabel, - onRetry: launch.retry, - claudeSignIn - }) + // A host held for its permission mode already says why nothing starts; Retry returns with it. + const launchNotice = hostPermission + ? null + : nativeChatLaunchNotice({ + lifecycle: launch.lifecycle, + failure: launch.failure, + agentLabel, + onRetry: launch.retry, + claudeSignIn + }) return [ ...(hostPermission ? [hostPermission] : []), ...(availability ? [availability] : []), diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts index 8920f76585d8..46d1b4e39eb5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts @@ -88,6 +88,7 @@ export function useNativeChatExternalAttachments({ // re-ask the workspace the upload started in — a comparison with itself. const sessionId = structuredSession?.sessionId const runtimeEnvironmentId = structuredSession?.runtimeEnvironmentId ?? null + const sessionPending = structuredSession?.sessionPending === true const workspaceRef = useRef({ structuredWorktreeId, terminalTabId, @@ -97,17 +98,20 @@ export function useNativeChatExternalAttachments({ workspaceRef.current = { structuredWorktreeId, terminalTabId, - structuredSession: sessionId ? { sessionId, runtimeEnvironmentId } : undefined + structuredSession: sessionId ? { sessionId, runtimeEnvironmentId, sessionPending } : undefined } - }, [runtimeEnvironmentId, sessionId, structuredWorktreeId, terminalTabId]) + }, [runtimeEnvironmentId, sessionId, sessionPending, structuredWorktreeId, terminalTabId]) const pendingChipsRef = useRef(pendingChips) useLayoutEffect(() => { pendingChipsRef.current = pendingChips }, [pendingChips]) - const resolveAttachmentOwner = useCallback(() => { + const resolveAttachmentOwner = useCallback((): NativeChatAttachmentOwner => { const { structuredWorktreeId, structuredSession, terminalTabId } = workspaceRef.current if (structuredWorktreeId && structuredSession?.runtimeEnvironmentId) { + if (structuredSession.sessionPending) { + return { kind: 'not-ready' } + } return resolveNativeChatRuntimeSessionAttachmentOwner({ sessionId: structuredSession.sessionId, runtimeEnvironmentId: structuredSession.runtimeEnvironmentId diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts index 276968920557..0f13216008aa 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -20,6 +20,8 @@ export type StructuredAgentSessionLaunchView = { heldOptions: Readonly> /** Where the chat runs; its own config may replace the listed default. */ worktree?: string + /** Its paired server has not said what create seeds yet; picks wait for it. */ + seedPending?: true } const NO_HELD_OPTIONS: Readonly> = {} @@ -75,11 +77,12 @@ function useLaunchView( ? { kind: intent.kind, ...(selection?.seed ? { seedOptions: selection.seed } : {}), + ...(selection?.seedPending ? { seedPending: true as const } : {}), heldOptions: held, ...(worktreeId ? { worktree: toRuntimeWorktreeSelector(worktreeId) } : {}) } : undefined, - [held, intent.kind, selection?.seed, worktreeId] + [held, intent.kind, selection?.seed, selection?.seedPending, worktreeId] ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts index 05f783fb7030..5ec0f2655e57 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts @@ -86,7 +86,8 @@ export function useNativeChatStructuredComposerTransport(args: { runtime: props.target.kind === 'local' ? 'local' : 'remote', sessionId: props.sessionId, runtimeEnvironmentId: - props.target.kind === 'local' ? null : (props.target.environmentId ?? null) + props.target.kind === 'local' ? null : (props.target.environmentId ?? null), + ...(starting ? { sessionPending: true } : {}) } }, [ acceptsImages, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index 143ad8184cf1..0b0032278468 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -65,7 +65,7 @@ export function useStructuredAgentSessionOptions(args: { const launchSeedOptions = launch?.seedOptions const held = launch?.heldOptions ?? NO_HELD_OPTIONS // Published but not attached: the launch no longer holds picks and there is no fence to send one. - const acceptsPicks = !transportEnabled || fence !== null + const acceptsPicks = (!transportEnabled || fence !== null) && launch?.seedPending !== true const optionCatalog = useMemo(() => structuredAgentSessionSeedCatalog(agent), [agent]) const { optionView, diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts index 91f8b9839c64..99895095263a 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.test.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -16,12 +16,36 @@ import { admitStructuredLaunchOnHost } from './structured-agent-session-host-adm vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: vi.fn() })) +const hostPermission = vi.hoisted(() => + vi.fn<() => Promise<{ kind: string; hostMode?: null }>>(async () => ({ kind: 'allowed' })) +) +vi.mock('@/runtime/structured-chat-host-permission', () => ({ + readStructuredChatHostPermissionVerdict: hostPermission +})) describe('structured agent session launch', () => { beforeEach(() => { vi.mocked(callStructuredAgentSession).mockReset() }) + // The chat opens before its host answers, so the create is the last thing that can say no. + it('issues no create to a host held for its access, or after its chat closed', async () => { + vi.mocked(callStructuredAgentSession).mockResolvedValue({ supported: true }) + const held = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude') + hostPermission.mockResolvedValueOnce({ kind: 'refused', hostMode: null }) + await expect(launchStructuredAgentSession(held)).rejects.toMatchObject({ + code: 'structured_agent_session_host_permission' + }) + const closed = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude') + await expect(launchStructuredAgentSession(closed, undefined, false, () => true)).rejects.toThrow( + 'cancelled before create' + ) + expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.createSupport', + 'agentSession.createSupport' + ]) + }) + it('creates a native session with a host-verifiable launch intent', async () => { vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method, params) => method === 'agentSession.createSupport' diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts index d8ea08c6e025..ff981d09b5b8 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts @@ -12,6 +12,7 @@ import { } from '@/runtime/structured-agent-session-client' import { publishStructuredAgentSessionCreateHydration } from './structured-agent-session-create-hydration' import { askHostCreateSupport } from './structured-agent-session-host-admission' +import { readStructuredChatHostPermissionVerdict } from '@/runtime/structured-chat-host-permission' import { abandonStructuredAgentSessionLaunchIntent, type StructuredAgentSessionLaunchIntent @@ -72,6 +73,28 @@ async function requireHostCreateSupport( return support.seedOptions } +/** A paired host that would run the chat with more access than asked for gets nothing to start. + * The chat's own permission gate says why, so this refusal carries no words of its own. */ +const STRUCTURED_LAUNCH_HOST_PERMISSION_REFUSAL = 'structured_agent_session_host_permission' + +async function requireHostPermission(intent: StructuredAgentSessionLaunchIntent): Promise { + const permission = await readStructuredChatHostPermissionVerdict(intent.target, intent.agent) + if (permission.kind === 'allowed') { + return + } + if (permission.kind === 'unverified') { + throw new StructuredAgentSessionCreateUnknownOutcomeError( + 'The host could not say what access the chat would run with.', + 'runtime_unavailable' + ) + } + abandonStructuredAgentSessionLaunchIntent(intent) + throw new StructuredAgentSessionCreateRefusalError( + STRUCTURED_LAUNCH_HOST_PERMISSION_REFUSAL, + STRUCTURED_LAUNCH_HOST_PERMISSION_REFUSAL + ) +} + /** Told the seed a paired server says this create will use, which may differ from an earlier * attempt's; a local launch reads its own settings instead. */ export type StructuredLaunchHostSeedListener = (seedOptions: LaunchSeed) => void @@ -79,12 +102,15 @@ export type StructuredLaunchHostSeedListener = (seedOptions: LaunchSeed) => void export async function launchStructuredAgentSession( intent: StructuredAgentSessionLaunchIntent, onHostSeed?: StructuredLaunchHostSeedListener, - priorCreateOutcomeUnknown = false + priorCreateOutcomeUnknown = false, + /** A chat closed while its host was being asked issues no create. */ + isCancelled: () => boolean = () => false ): Promise { const hostSeed = await requireHostCreateSupport(intent) if (intent.target.kind !== 'local') { onHostSeed?.(hostSeed) } + await requireHostPermission(intent) if (!intent.createPrepared) { let supported = false try { @@ -98,6 +124,9 @@ export async function launchStructuredAgentSession( intent.createPrepared = true } } + if (isCancelled()) { + throw new Error('structured session launch cancelled before create') + } let result: AgentSessionMutationResult try { result = await callStructuredAgentSession>( diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts index 74d4a7b663fd..d693a19cca78 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-message.test.ts @@ -42,6 +42,9 @@ vi.mock('@/store', () => ({ } })) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('@/runtime/structured-chat-host-permission', () => ({ + readStructuredChatHostPermissionVerdict: async () => ({ kind: 'allowed' }) +})) import { startStructuredAgentLaunch, diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 0b18e0c253ce..79c0008f8e95 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -35,6 +35,8 @@ export type StructuredLaunchSelection = { seed?: Readonly> /** Encoded picks made before publish; the launch applies them before it publishes. */ held: Readonly> + /** A paired server has not yet said what create seeds, so nothing can be picked against it. */ + seedPending?: true } export type StructuredLaunchOptionOutcome = diff --git a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts index 8a376210bc0c..ca5512008887 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts @@ -78,7 +78,12 @@ export async function recoverPublishedSessionReceipt( ) : undefined if (state.intent.params.firstMessage && !firstMessage) { - return launchStructuredAgentSession(state.intent, state.onHostSeed, true) + return launchStructuredAgentSession( + state.intent, + state.onHostSeed, + true, + () => state.cancelled + ) } if (state.intent.createMessageSupport === true) { publishStructuredAgentSessionCreateHydration(state.intent, history.page, fence) @@ -98,7 +103,12 @@ async function retrySameIntent( ): Promise { throwIfLaunchCancelled(state) try { - const receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed, true) + const receipt = await launchStructuredAgentSession( + state.intent, + state.onHostSeed, + true, + () => state.cancelled + ) throwIfLaunchCancelled(state) if (state.intent.createMessageSupport !== true) { await verifyPublishedSession(state) @@ -130,7 +140,12 @@ export async function launchAndReconcile( throwIfLaunchCancelled(state) let receipt: StructuredAgentLaunchReceipt try { - receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed) + receipt = await launchStructuredAgentSession( + state.intent, + state.onHostSeed, + false, + () => state.cancelled + ) } catch (error) { if (state.cancelled) { throw new StructuredAgentSessionLaunchCancelledError() diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 11468d845d99..8edd6601cd4a 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -109,7 +109,8 @@ function adoptPairedHostSeed( state: StructuredLaunchState, seedOptions: StructuredLaunchState['selection']['seed'] ): void { - if (JSON.stringify(seedOptions) === JSON.stringify(state.intent.seedOptions)) { + const { seedPending, ...selection } = state.selection + if (!seedPending && JSON.stringify(seedOptions) === JSON.stringify(state.intent.seedOptions)) { return } if (seedOptions) { @@ -117,7 +118,7 @@ function adoptPairedHostSeed( } else { delete state.intent.seedOptions } - state.selection = { ...state.selection, seed: seedOptions } + state.selection = { ...selection, seed: seedOptions } notifyStructuredLaunchListeners() } @@ -223,7 +224,11 @@ function structuredAgentLaunchState( cancelled: false, onVisibilityChanged: notifyStructuredLaunchListeners, callers, - selection: { seed: intent.seedOptions, held: {} } + selection: { + seed: intent.seedOptions, + held: {}, + ...(intent.target.kind !== 'local' && !intent.seedOptions ? { seedPending: true } : {}) + } } state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions) callers.onSettled = () => maybeCleanupLaunchState(state) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts index 9acc430be544..9ceb58570513 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts @@ -5,9 +5,11 @@ import type { StructuredAgentLaunchSettlement } from './structured-agent-launch- const mocks = vi.hoisted(() => ({ createUnifiedTab: vi.fn(), + closeUnifiedTab: vi.fn(), createSupport: vi.fn(), requestFocus: vi.fn(), - cancelFocus: vi.fn() + cancelFocus: vi.fn(), + draftText: { value: '' } })) vi.mock('@/store', () => ({ @@ -16,6 +18,7 @@ vi.mock('@/store', () => ({ unifiedTabsByWorktree: {}, activeGroupIdByWorktree: {}, createUnifiedTab: mocks.createUnifiedTab, + closeUnifiedTab: mocks.closeUnifiedTab, setActiveTabType: vi.fn() }) } @@ -25,15 +28,25 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ method === 'agentSession.createSupport' ? mocks.createSupport() : new Promise(() => undefined) })) +vi.mock('@/components/native-chat/native-chat-composer-draft-store', () => ({ + readNativeChatComposerDraft: () => ({ text: mocks.draftText.value, images: [] }), + structuredAgentSessionDraftScopeKey: (sessionId: string) => `agent-session:${sessionId}` +})) + import { beginStructuredAgentSessionProvisionalLaunch } from './structured-agent-session-provisional-tab' +import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' -function planSettlingAs(settlement: StructuredAgentLaunchSettlement): AgentSessionLaunchPlan { +function planSettlingAs( + settlement: StructuredAgentLaunchSettlement, + prompt?: string +): AgentSessionLaunchPlan { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: begin() is the only member this path calls. return { route: 'structured-native-chat', agent: 'claude', worktreeId: 'wt-1', executionHostId: 'local', + ...(prompt ? { prompt } : {}), begin: () => ({ sessionId: 'claude_1', executionHostId: 'local', @@ -50,10 +63,16 @@ beforeEach(() => { (worktreeId: string, _type: string, tab: Partial) => ({ ...tab, worktreeId }) ) mocks.createSupport.mockResolvedValue({ supported: true }) + mocks.draftText.value = '' +}) + +const declined = (): StructuredAgentLaunchSettlement => ({ + kind: 'failed', + error: new StructuredAgentSessionCreateRefusalError('no', 'structured_agent_session_unsupported') }) describe('a provisional structured chat tab', () => { - it('opens once this machine admits the chat, in the caller group, on the host it was sent to', async () => { + it('opens a New Chat at once and focuses it; the launch asks the host itself', async () => { const launch = beginStructuredAgentSessionProvisionalLaunch({ plan: planSettlingAs({ kind: 'structured', sessionId: 'claude_1' }), hooks: {}, @@ -61,6 +80,42 @@ describe('a provisional structured chat tab', () => { targetGroupId: 'group-2' }) + expect(mocks.createUnifiedTab).toHaveBeenCalledWith( + 'wt-1', + 'agent-session', + expect.objectContaining({ executionHostId: 'local', targetGroupId: 'group-2' }) + ) + expect(mocks.requestFocus).toHaveBeenCalledExactlyOnceWith('structured-agent-session-claude_1') + await expect(launch?.settlement).resolves.toEqual({ kind: 'structured', sessionId: 'claude_1' }) + expect(mocks.createSupport).not.toHaveBeenCalled() + }) + + it.each([ + ['nobody typed: its terminal opens in its place', '', { kind: 'terminal' }], + ['typed in: the chat keeps it', 'half a thought', { kind: 'failed' }] + ])('a New Chat its host refuses, %s', async (_name, typed, settled) => { + const onHostDeclined = vi.fn(() => ({ opened: true })) + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: planSettlingAs(declined()), + hooks: {}, + focusIntent: { request: mocks.requestFocus, cancel: mocks.cancelFocus }, + onHostDeclined + }) + mocks.draftText.value = typed + + await expect(launch?.settlement).resolves.toMatchObject(settled) + expect(onHostDeclined).toHaveBeenCalledTimes(typed ? 0 : 1) + expect(mocks.closeUnifiedTab).toHaveBeenCalledTimes(typed ? 0 : 1) + }) + + it('asks first for a launch that carries a prompt, so a "no" can hand it on', async () => { + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: planSettlingAs({ kind: 'structured', sessionId: 'claude_1' }, 'review this'), + hooks: {}, + focusIntent: { request: mocks.requestFocus, cancel: mocks.cancelFocus }, + targetGroupId: 'group-2' + }) + expect(launch?.tab).toBeNull() expect(mocks.createUnifiedTab).not.toHaveBeenCalled() expect(mocks.requestFocus).not.toHaveBeenCalled() diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index 8235e0a2ba2e..5df1ed870adb 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -7,7 +7,8 @@ import type { } from '@/lib/agent-session-launch-plan' import type { StructuredAgentLaunchHandle, - StructuredAgentLaunchHooks + StructuredAgentLaunchHooks, + StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' import { useAppStore } from '@/store' import type { ExecutionHostId } from '../../../shared/execution-host' @@ -21,10 +22,17 @@ import { } from '@/lib/structured-agent-session-launch-admission' import { resolveStructuredAgentSessionOwner, + structuredAgentSessionFocusOwner, structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' +import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' +import { + readNativeChatComposerDraft, + structuredAgentSessionDraftScopeKey +} from '@/components/native-chat/native-chat-composer-draft-store' +import { StructuredAgentSessionCreateRefusalError } from '@/lib/structured-agent-session-launch-errors' type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } @@ -108,7 +116,95 @@ type ProvisionalLaunchArgs = { declinedTerminal?: DeclinedStructuredLaunchTerminalOptions } -/** Binds the launch to a chat tab once its host, this machine included, admits the chat. */ +type LaunchOwner = { executionHostId: ExecutionHostId; target: RuntimeClientTarget } + +function openDeclinedTerminal( + args: ProvisionalLaunchArgs, + worktreeId: string, + target: RuntimeClientTarget +): Promise | StructuredLaunchTerminal { + if (args.onHostDeclined) { + return args.onHostDeclined(target) + } + return openDeclinedStructuredLaunchTerminal({ + plan: args.plan, + worktreeId, + ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), + ...(args.declinedTerminal ? { terminal: args.declinedTerminal } : {}) + }) +} + +/** A New Chat the user just asked for opens its chat now, so the next key has a composer; its own + * launch asks the host. A prompt to deliver keeps asking first: a "no" hands it to a terminal. */ +function opensAtAction(args: ProvisionalLaunchArgs): boolean { + const { plan } = args + return ( + args.focusIntent !== undefined && + args.activate !== false && + !plan.resumeFrom && + (!plan.prompt?.trim() || plan.promptDelivery === 'draft') + ) +} + +/** A definite "no" to a chat nobody typed in yet opens its terminal in the chat's place, where + * asking first used to land. A chat with typing keeps it. */ +async function openTerminalForDeclinedChat( + args: ProvisionalLaunchArgs, + worktreeId: string, + owner: LaunchOwner, + tab: Tab, + settlement: StructuredAgentLaunchSettlement +): Promise { + const draft = readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey(tab.entityId)) + if ( + settlement.kind !== 'failed' || + !(settlement.error instanceof StructuredAgentSessionCreateRefusalError) || + settlement.error.code !== 'structured_agent_session_unsupported' || + draft.text.trim() !== '' || + draft.images.length > 0 + ) { + return settlement + } + const terminal = await openDeclinedTerminal(args, worktreeId, owner.target) + if (!terminal.opened) { + return settlement + } + useAppStore.getState().closeUnifiedTab(tab.id) + return { kind: 'terminal' } +} + +function beginActionLaunch( + args: ProvisionalLaunchArgs, + worktreeId: string, + owner: LaunchOwner, + open: (launch: LocalProvisionalLaunch | null) => LocalProvisionalLaunch | null +): LocalProvisionalLaunch | null { + const launch = open( + beginLocalProvisionalLaunch({ + ...args, + target: { ...args.target, worktreeId, executionHostId: owner.executionHostId }, + beforeOpen: undefined + }) + ) + if (!launch) { + return null + } + // The chat is already the visible tab; its host's copy arriving later must not pull focus back. + clearWebSessionFocusIntentIfMatches( + structuredAgentSessionFocusOwner(owner.target), + worktreeId, + `agent-session:${launch.sessionId}` + ) + return { + ...launch, + settlement: launch.settlement.then((settlement) => + openTerminalForDeclinedChat(args, worktreeId, owner, launch.tab, settlement) + ) + } +} + +/** Binds the launch to a chat tab: at once for a New Chat the user asked for, otherwise once its + * host, this machine included, admits the chat. */ export function beginStructuredAgentSessionProvisionalLaunch( args: ProvisionalLaunchArgs ): StructuredAgentSessionProvisionalLaunch | null { @@ -144,6 +240,9 @@ export function beginStructuredAgentSessionProvisionalLaunch( // No host to ask: the launch path refuses it with its own message. return open(beginLocalProvisionalLaunch(args)) } + if (opensAtAction(args)) { + return beginActionLaunch(args, worktreeId, owner, open) + } const launch = beginHostAdmittedStructuredLaunch({ plan: args.plan, hooks: args.hooks, @@ -163,15 +262,7 @@ export function beginStructuredAgentSessionProvisionalLaunch( beforeOpen: undefined }) ), - onHostDeclined: - args.onHostDeclined ?? - (() => - openDeclinedStructuredLaunchTerminal({ - plan: args.plan, - worktreeId, - ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), - ...(args.declinedTerminal ? { terminal: args.declinedTerminal } : {}) - })) + onHostDeclined: (target) => openDeclinedTerminal(args, worktreeId, target) }) void launch.settlement.then(cancelIfNoTab, cancelIfNoTab) return launch From 7cfabd24026c76c3119cd46c247adbd1c2c19947 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:34:27 -0700 Subject: [PATCH 15/27] Keep chat launch recovery and composer actions with their owners --- .../NativeChatStructuredSession.tsx | 1 + .../native-chat-attachment-upload.ts | 25 +++++- .../native-chat/native-chat-composer-types.ts | 3 +- .../native-chat-pending-attachment-cache.ts | 11 +++ .../use-native-chat-composer-paste.ts | 2 +- ...-native-chat-external-attachments.test.tsx | 23 ++++- .../use-native-chat-external-attachments.ts | 18 +++- ...se-native-chat-provisional-launch.test.tsx | 85 ++++++++++++++++++- .../use-native-chat-provisional-launch.ts | 7 +- ...hat-structured-composer-transport.test.tsx | 23 +++-- ...tive-chat-structured-composer-transport.ts | 13 ++- src/renderer/src/i18n/locales/en.json | 2 + .../launch-structured-agent-session.test.ts | 31 ++++++- .../lib/launch-structured-agent-session.ts | 26 +++--- .../lib/structured-agent-launch-settlement.ts | 6 +- ...structured-agent-session-launch-callers.ts | 2 + ...structured-agent-session-launch-options.ts | 18 +++- .../structured-agent-session-launch-reload.ts | 3 +- .../structured-agent-session-launch.test.ts | 2 + .../lib/structured-agent-session-launch.ts | 12 ++- ...ured-agent-session-provisional-tab.test.ts | 32 +++++-- ...tructured-agent-session-provisional-tab.ts | 15 ++-- 22 files changed, 293 insertions(+), 67 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 0e6d1ccb080e..d494cf6ad4f0 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -224,6 +224,7 @@ export function NativeChatStructuredSession( controller, sendThroughLaunch, starting: provisionalLaunch.starting, + transportEnabled: provisionalLaunch.transportEnabled, worktreeId: ownerWorktreeId ?? undefined, optionPickerRequest, setOptionPickerRequest, diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index c5a50df7cd08..6ca57123d3a8 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -28,6 +28,8 @@ import type { } from '../../../../shared/agent-session-attachments' import type { AppState } from '@/store/types' import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop' +import type { AgentType } from '../../../../shared/agent-status-types' +import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { findTerminalTabWorktreeId } from './native-chat-file-link' /** A structured chat on a paired server: files upload into that server's attachment store, which @@ -54,7 +56,7 @@ export type NativeChatAttachmentOwner = /** Store not hydrated / worktree unknown. Callers must not attach local * paths in this window — the worktree may turn out to be remote, and the * agent would silently receive paths it cannot read (see #6648). */ - | { kind: 'not-ready' } + | { kind: 'not-ready'; notice?: string } type NativeChatAttachmentOwnerState = Pick< AppState, @@ -125,8 +127,9 @@ export type NativeChatStructuredAttachmentSession = { sessionId: string /** Null: the chat runs on this machine. */ runtimeEnvironmentId: string | null - /** The host has not created this chat yet, so nothing can be uploaded into it. */ + /** The host has not confirmed this chat, so nothing can be uploaded into it. */ sessionPending?: boolean + sessionPendingNotice?: string } /** A structured chat on a paired server owns its attachments by where it runs, not by worktree. */ @@ -153,6 +156,24 @@ export function nativeChatWorktreeNotReadyNotice(): string { ) } +export function nativeChatSessionAttachmentNotReadyNotice( + agent: AgentType, + starting: boolean +): string { + const label = structuredAgentLabel(agent) + return starting + ? translate( + 'components.native-chat.composer.sessionAttachmentsStarting', + '{{agent}} chat is still opening. Wait for it to open, then attach the files again.', + { agent: label } + ) + : translate( + 'components.native-chat.composer.sessionAttachmentsFailed', + '{{agent}} chat could not open. Use Retry in the chat, then attach the files again.', + { agent: label } + ) +} + export function nativeChatAttachmentOwnerChangedNotice(): string { return translate( 'components.native-chat.composer.attachmentOwnerChanged', diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 7776414826ae..325d1dfa6e4a 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -63,8 +63,9 @@ export type NativeChatStructuredComposerTransport = { sessionId: string /** Owning runtime for that report; null is the local runtime. */ runtimeEnvironmentId: string | null - /** The chat is still starting on its host: uploads wait for it. */ + /** The host has not confirmed this chat: session uploads wait for publication. */ sessionPending?: boolean + sessionPendingNotice?: string } export type NativeChatOptimisticSendOutcome = { diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts index a2c771a6568d..b4aadd140fdc 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts @@ -9,6 +9,7 @@ import { formatNativeChatFileReference } from '../../../../shared/agent-image-pa import { appendToNativeChatComposerDraft, nativeChatDraftScopeTabId, + readNativeChatComposerDraft, resolveNativeChatComposerDraftOwner, structuredAgentSessionIdOfDraftScope } from './native-chat-composer-draft-store' @@ -26,6 +27,16 @@ export function nativeChatPendingAttachmentSnapshot( return pendingCache.get(scopeKey) ?? EMPTY } +/** Pending chips are user content before their file paths settle into the draft. */ +export function nativeChatComposerHasUserContent(scopeKey: string): boolean { + const draft = readNativeChatComposerDraft(scopeKey) + return ( + draft.text.trim() !== '' || + draft.images.length > 0 || + nativeChatPendingAttachmentSnapshot(scopeKey).length > 0 + ) +} + function writePending(scopeKey: string, next: readonly NativeChatComposerImageAttachment[]): void { if (next.length === 0) { pendingCache.delete(scopeKey) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index 36f1c1e0f3fd..2313e55e24f0 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -105,7 +105,7 @@ export function useNativeChatComposerPaste({ (pendingId: string | null, path: string, originalOwner: NativeChatAttachmentOwner) => { if (!nativeChatAttachmentOwnerUnchanged(originalOwner, resolveAttachmentOwner())) { dropImagePaste(pendingId) - setNotice(nativeChatWorktreeNotReadyNotice()) + setNotice(owner.notice ?? nativeChatWorktreeNotReadyNotice()) return } if (pendingId) { diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index e2e7a41c105e..c1b11f230810 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -36,6 +36,7 @@ import { useNativeChatExternalAttachments } from './use-native-chat-external-att import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop' import type { AgentSessionAttachmentPathUploadResult } from '../../../../shared/agent-session-attachments' import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import type { NativeChatStructuredAttachmentSession } from './native-chat-attachment-upload' type HookApi = ReturnType @@ -67,7 +68,7 @@ function Probe({ }: { disabled: boolean structuredWorktreeId?: string - structuredSession?: { sessionId: string; runtimeEnvironmentId: string | null } + structuredSession?: NativeChatStructuredAttachmentSession attachResolvedPaths: (paths: string[]) => void pendingChips: NativeChatPendingAttachmentChips setNotice: (notice: string | null) => void @@ -95,7 +96,7 @@ let root: Root | null = null async function renderProbe(args: { disabled?: boolean structuredWorktreeId?: string - structuredSession?: { sessionId: string; runtimeEnvironmentId: string | null } + structuredSession?: NativeChatStructuredAttachmentSession attachResolvedPaths: (paths: string[]) => void pendingChips?: NativeChatPendingAttachmentChips setNotice?: (notice: string | null) => void @@ -159,6 +160,24 @@ afterEach(() => { }) describe('useNativeChatExternalAttachments', () => { + it('keeps a failed paired chat from issuing an upload and says how to retry', async () => { + const attachResolvedPaths = vi.fn() + const setNotice = vi.fn() + const notice = 'Codex chat could not open. Use Retry in the chat, then attach the files again.' + const probe = await renderProbe({ + structuredWorktreeId: 'folder-1', + structuredSession: { + sessionId: 'codex-1', runtimeEnvironmentId: 'server-1', + sessionPending: true, sessionPendingNotice: notice + }, + attachResolvedPaths, setNotice + }) + await act(async () => { await probe.latest().captureExternalDrop()(['/local/a.txt']) }) + expect(setNotice).toHaveBeenCalledWith(notice) + expect(mocks.prepareNativeChatSessionAttachmentUpload).not.toHaveBeenCalled() + expect(mocks.uploadNativeChatSessionAttachmentPaths).not.toHaveBeenCalled() + expect(attachResolvedPaths).not.toHaveBeenCalled() + }) it('attaches local worktree paths unchanged', async () => { mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'local' }) const attachResolvedPaths = vi.fn() diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts index 46d1b4e39eb5..d95b3e586b66 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts @@ -89,6 +89,7 @@ export function useNativeChatExternalAttachments({ const sessionId = structuredSession?.sessionId const runtimeEnvironmentId = structuredSession?.runtimeEnvironmentId ?? null const sessionPending = structuredSession?.sessionPending === true + const sessionPendingNotice = structuredSession?.sessionPendingNotice const workspaceRef = useRef({ structuredWorktreeId, terminalTabId, @@ -98,9 +99,18 @@ export function useNativeChatExternalAttachments({ workspaceRef.current = { structuredWorktreeId, terminalTabId, - structuredSession: sessionId ? { sessionId, runtimeEnvironmentId, sessionPending } : undefined + structuredSession: sessionId + ? { sessionId, runtimeEnvironmentId, sessionPending, sessionPendingNotice } + : undefined } - }, [runtimeEnvironmentId, sessionId, sessionPending, structuredWorktreeId, terminalTabId]) + }, [ + runtimeEnvironmentId, + sessionId, + sessionPending, + sessionPendingNotice, + structuredWorktreeId, + terminalTabId + ]) const pendingChipsRef = useRef(pendingChips) useLayoutEffect(() => { pendingChipsRef.current = pendingChips @@ -110,7 +120,7 @@ export function useNativeChatExternalAttachments({ const { structuredWorktreeId, structuredSession, terminalTabId } = workspaceRef.current if (structuredWorktreeId && structuredSession?.runtimeEnvironmentId) { if (structuredSession.sessionPending) { - return { kind: 'not-ready' } + return { kind: 'not-ready', notice: structuredSession.sessionPendingNotice } } return resolveNativeChatRuntimeSessionAttachmentOwner({ sessionId: structuredSession.sessionId, @@ -149,7 +159,7 @@ export function useNativeChatExternalAttachments({ return } if (owner.kind === 'not-ready') { - setNotice(nativeChatWorktreeNotReadyNotice()) + setNotice(owner.notice ?? nativeChatWorktreeNotReadyNotice()) return } if (owner.kind === 'remote') { diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx index 8712f0877fa8..e60ce0599b4b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx @@ -7,7 +7,12 @@ import type * as LaunchIntentModule from '@/lib/launch-structured-agent-session' const mocks = vi.hoisted(() => ({ call: vi.fn<(target: unknown, method: string, params?: unknown) => Promise>(), - launch: vi.fn<(intent: { sessionId: string }) => Promise<{ sessionId: string; fence: number }>>(), + launch: vi.fn< + ( + intent: { sessionId: string }, + onSeed?: LaunchIntentModule.StructuredLaunchHostSeedListener + ) => Promise<{ sessionId: string; fence: number }> + >(), toastError: vi.fn() })) @@ -30,6 +35,9 @@ vi.mock('@/lib/launch-structured-agent-session', async () => { vi.mock('@/runtime/local-structured-session-tabs-sync/inventory-refresh', () => ({ refreshLocalStructuredSessionTabs: () => Promise.resolve(publishedTabs) })) +vi.mock('@/runtime/structured-session-tab-inventory', () => ({ + readStructuredSessionTabInventory: () => Promise.resolve(publishedTabs) +})) vi.mock('./use-structured-agent-session-hold', () => ({ useStructuredAgentSessionHold: () => undefined @@ -49,7 +57,12 @@ vi.mock('./native-chat-session-option-settings-write', () => ({ import { getDefaultSettings } from '../../../../shared/constants' import { useAppStore } from '../../store' -import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' +import { + startStructuredAgentLaunch, + retryStructuredAgentSessionLaunch +} from '@/lib/structured-agent-session-launch' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { resetStructuredAgentLaunchPersistenceForTests } from '@/lib/structured-agent-session-launch-persistence' import { resetStructuredAgentLaunchRegistryForTests } from '@/lib/structured-agent-session-launch-registry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' @@ -118,13 +131,14 @@ function publishedSnapshot(worktreeId: string, sessionId: string) { function renderLaunchedChat( worktreeId: string, sessionId: string, - agent: 'codex' | 'claude' = 'codex' + agent: 'codex' | 'claude' = 'codex', + target: RuntimeClientTarget = LOCAL_TARGET ) { return renderHook((selectedSessionId: string = sessionId) => { const provisional = useNativeChatProvisionalLaunch(worktreeId, selectedSessionId) const chat = useStructuredAgentSession({ sessionId: selectedSessionId, - target: LOCAL_TARGET, + target, agent, providerStarting: true, isVisible: true, @@ -157,6 +171,69 @@ describe('a chat pane over its own launch', () => { afterEach(() => resetStructuredAgentSessionSendsForTests()) + it.each([undefined, { model: 'gpt-5.5' }])( + 'holds paired picks through reload and Retry until the host answers (%s)', + async (seed) => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.modelCatalog' + ? Promise.resolve({ + ...SAVED_LIST, + models: SAVED_LIST.models.map((model) => ({ + ...model, + isDefault: model.id === 'gpt-5.5', + efforts: [{ value: 'low', label: 'Low' }, { value: 'high', label: 'High' }] + })) + }) + : new Promise(() => {}) + ) + const worktreeId = 'paired-pending' + const target = { kind: 'environment', environmentId: 'server-1' } as const + const launch = startStructuredAgentLaunch(worktreeId, 'codex', { + requestId: 'paired-reload', + executionHostId: 'runtime:server-1' + }) + const fresh = renderLaunchedChat(worktreeId, launch.sessionId, 'codex', target) + await waitFor(() => + expect(fresh.result.current.optionSnapshot.find((entry) => entry.id === 'model')).toMatchObject({ + settable: false, + kind: { choices: expect.arrayContaining([expect.objectContaining({ value: 'gpt-5.5' })]) } + }) + ) + fresh.unmount() + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + const restored = renderLaunchedChat(worktreeId, launch.sessionId, 'codex', target) + const command = (text: string) => + dispatchStructuredAgentSessionComposerCommand(text, { + snapshot: restored.result.current.optionSnapshot, + invokeAction: async () => false, + setOption: restored.result.current.setStructuredOption + }) + await waitFor(() => + expect(restored.result.current.optionSnapshot.find((entry) => entry.id === 'model')).toMatchObject({ + settable: false, + kind: { choices: expect.arrayContaining([expect.objectContaining({ value: 'gpt-5.5' })]) } + }) + ) + await expect(command('/model gpt-5.5')).resolves.toMatchObject({ accepted: false }) + act(() => { + retryStructuredAgentSessionLaunch(worktreeId, launch.sessionId) + }) + await waitFor(() => expect(mocks.launch).toHaveBeenCalledTimes(2)) + await expect(command('/effort high')).resolves.toMatchObject({ accepted: false }) + act(() => { + mocks.launch.mock.lastCall?.[1]?.(seed) + }) + expect( + restored.result.current.optionSnapshot.find((entry) => entry.id === 'model')?.settable + ).toBe(true) + await act(async () => { + await expect(command('/model gpt-5.5')).resolves.toMatchObject({ accepted: true }) + await expect(command('/effort high')).resolves.toMatchObject({ accepted: true }) + }) + } + ) + it.each([false, true])( 'keeps launch intent after retirement and on first send (accepted picks: %s)', async (picked) => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts index 0f13216008aa..be375c94d79b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.ts @@ -38,7 +38,7 @@ function useLaunchView( if (current) { snapshot = current } else if (snapshot && snapshot.held !== NO_HELD_OPTIONS) { - snapshot = { seed: snapshot.seed, held: NO_HELD_OPTIONS } + snapshot = { seed: snapshot.seed, held: NO_HELD_OPTIONS, seedPending: true } } return snapshot } @@ -71,18 +71,19 @@ function useLaunchView( }) } const held = selection?.held ?? NO_HELD_OPTIONS + const seedPending = launching && (!selection || selection.seedPending === true) return useMemo( () => intent.kind ? { kind: intent.kind, ...(selection?.seed ? { seedOptions: selection.seed } : {}), - ...(selection?.seedPending ? { seedPending: true as const } : {}), + ...(seedPending ? { seedPending: true as const } : {}), heldOptions: held, ...(worktreeId ? { worktree: toRuntimeWorktreeSelector(worktreeId) } : {}) } : undefined, - [held, intent.kind, selection?.seed, selection?.seedPending, worktreeId] + [held, intent.kind, selection?.seed, seedPending, worktreeId] ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.test.tsx index a8cb8be50a83..6915c1fc7d77 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.test.tsx @@ -26,7 +26,7 @@ vi.mock('@/runtime/host-structured-agents', () => ({ import { useNativeChatStructuredComposerTransport } from './use-native-chat-structured-composer-transport' import type { useStructuredAgentSession } from './use-structured-agent-session' -function composerAcceptsImages(reported: boolean | undefined): boolean | undefined { +function composerTransport(reported: boolean | undefined, transportEnabled = true, starting = false) { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: building the transport reads only these members; nothing is sent. const controller = { acceptsImages: reported } as unknown as ReturnType< typeof useStructuredAgentSession @@ -36,7 +36,8 @@ function composerAcceptsImages(reported: boolean | undefined): boolean | undefin props: { agent: 'grok', sessionId: 'session-1', target: { kind: 'local' } }, controller, sendThroughLaunch: () => false, - starting: false, + starting, + transportEnabled, worktreeId: undefined, optionPickerRequest: null, setOptionPickerRequest: () => {}, @@ -44,14 +45,24 @@ function composerAcceptsImages(reported: boolean | undefined): boolean | undefin onSubmitted: () => {} }) ) - return result.current.acceptsImages + return result.current } describe('the structured composer image gate', () => { it("follows the session's options answer over the host's agent list", () => { - expect(composerAcceptsImages(false)).toBe(false) - expect(composerAcceptsImages(true)).toBe(true) + expect(composerTransport(false).acceptsImages).toBe(false) + expect(composerTransport(true).acceptsImages).toBe(true) // No answer yet, or an older host: the agent list decides. - expect(composerAcceptsImages(undefined)).toBe(true) + expect(composerTransport(undefined).acceptsImages).toBe(true) + }) + + it('keeps failed uploads gated after starting ends and unlocks only on publication', () => { + const pending = composerTransport(true, false, true) + const failed = composerTransport(true, false, false) + expect(pending.sessionPending).toBe(true) + expect(pending.sessionPendingNotice).toContain('Grok chat is still opening') + expect(failed.sessionPending).toBe(true) + expect(failed.sessionPendingNotice).toContain('Use Retry in the chat') + expect(composerTransport(true, true).sessionPending).toBeUndefined() }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts index 5ec0f2655e57..19af6af0263e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts @@ -7,6 +7,7 @@ import type { } from './native-chat-composer-types' import type { NativeChatStructuredViewProps } from './native-chat-view-types' import type { useStructuredAgentSession } from './use-structured-agent-session' +import { nativeChatSessionAttachmentNotReadyNotice } from './native-chat-attachment-upload' /** What the composer of a structured chat sends, picks and runs through, from the chat's controller. */ export function useNativeChatStructuredComposerTransport(args: { @@ -20,6 +21,7 @@ export function useNativeChatStructuredComposerTransport(args: { ) => boolean | 'queued' /** The chat is starting: it takes no send yet. */ starting: boolean + transportEnabled: boolean worktreeId: string | undefined optionPickerRequest: NativeChatOptionPickerRequest | null setOptionPickerRequest: Dispatch> @@ -30,6 +32,7 @@ export function useNativeChatStructuredComposerTransport(args: { }): NativeChatStructuredComposerTransport { const { props, controller, sendThroughLaunch, starting, worktreeId, optionPickerRequest } = args const { setOptionPickerRequest, onError, onSubmitted } = args + const { transportEnabled } = args const sendBlockedReason = args.sendBlockedReason ?? null const acceptsImages = useStructuredAgentAcceptsImages( props.target, @@ -87,7 +90,12 @@ export function useNativeChatStructuredComposerTransport(args: { sessionId: props.sessionId, runtimeEnvironmentId: props.target.kind === 'local' ? null : (props.target.environmentId ?? null), - ...(starting ? { sessionPending: true } : {}) + ...(!transportEnabled + ? { + sessionPending: true, + sessionPendingNotice: nativeChatSessionAttachmentNotReadyNotice(props.agent, starting) + } + : {}) } }, [ acceptsImages, @@ -102,6 +110,7 @@ export function useNativeChatStructuredComposerTransport(args: { sendBlockedReason, sendThroughLaunch, setOptionPickerRequest, - starting + starting, + transportEnabled ]) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1700f0de34b5..7f08b2aac716 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18499,6 +18499,8 @@ "imagePasteFailed": "Image paste failed.", "imageSaving": "Saving pasted image…", "worktreeNotReady": "Worktree not ready — try again in a moment.", + "sessionAttachmentsStarting": "{{agent}} chat is still opening. Wait for it to open, then attach the files again.", + "sessionAttachmentsFailed": "{{agent}} chat could not open. Use Retry in the chat, then attach the files again.", "pasteUnavailable": "Can't paste — this chat isn't accepting input right now.", "model": "Model", "effort": "Effort", diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts index 99895095263a..7adc6ac3afc0 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.test.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -12,6 +12,10 @@ import { StructuredAgentSessionOwnerUnresolvedError } from './launch-structured-agent-session' import { admitStructuredLaunchOnHost } from './structured-agent-session-host-admission' +import { + clearWebSessionFocusIntent, + peekWebSessionFocusIntent +} from '@/runtime/web-session-focus-intent' vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: vi.fn() @@ -28,6 +32,27 @@ describe('structured agent session launch', () => { vi.mocked(callStructuredAgentSession).mockReset() }) + it('only a fresh foreground launch grants publication permission to select its chat', () => { + const worktreeId = 'focus-lifecycle' + const owner = { environmentId: 'server-1' } + const intent = createStructuredAgentSessionLaunchIntent(worktreeId, 'codex', 'runtime:server-1') + expect(peekWebSessionFocusIntent(owner, worktreeId)?.hostTabId).toBe( + `agent-session:${intent.sessionId}` + ) + clearWebSessionFocusIntent(owner, worktreeId) + retryStructuredAgentSessionLaunchIntent(intent) + restoreStructuredAgentSessionLaunchIntent({ + worktreeId, + executionHostId: intent.executionHostId, + agent: intent.agent, + ...intent.params.envelope + }) + createStructuredAgentSessionLaunchIntent( + worktreeId, 'codex', 'runtime:server-1', undefined, undefined, false + ) + expect(peekWebSessionFocusIntent(owner, worktreeId)).toBeNull() + }) + // The chat opens before its host answers, so the create is the last thing that can say no. it('issues no create to a host held for its access, or after its chat closed', async () => { vi.mocked(callStructuredAgentSession).mockResolvedValue({ supported: true }) @@ -347,10 +372,10 @@ describe('structured agent session launch', () => { } }) - it("reports a paired server's current seed from the create probe, and no local one", async () => { + it.each([undefined, { model: 'sonnet' }])("reports the paired host's seed answer, including a seedless older host (%s)", async (seedOptions) => { vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) => method === 'agentSession.createSupport' - ? { supported: true, seedOptions: { model: 'sonnet' } } + ? { supported: true, seedOptions } : { ok: true, replayed: false, value: { sessionId: 'claude_1', fence: 1 } } ) const paired = vi.fn() @@ -365,7 +390,7 @@ describe('structured agent session launch', () => { local ) - expect(paired).toHaveBeenCalledWith({ model: 'sonnet' }) + expect(paired).toHaveBeenCalledWith(seedOptions) expect(local).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index f941f9e40d7a..6af70e5ba7b0 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -86,13 +86,23 @@ export function createStructuredAgentSessionLaunchIntent( agent: TuiAgent, executionHostId?: ExecutionHostId, resumeFrom?: StructuredAgentSessionResumeSource, - hostSeedOptions?: LaunchSeed + hostSeedOptions?: LaunchSeed, + activate = true ): StructuredAgentSessionLaunchIntent { const owner = structuredAgentSessionOwnerTarget( worktreeId, executionHostId ?? resolveStructuredAgentSessionOwner(useAppStore.getState(), worktreeId) ) const sessionId = createStructuredAgentSessionId(agent, createBrowserUuid) + if (activate) { + recordWebSessionFocusIntent( + structuredAgentSessionFocusOwner(owner.target), + worktreeId, + `agent-session:${sessionId}`, + undefined, + resolveWebSessionVisibleTabId(useAppStore.getState(), worktreeId) + ) + } return buildStructuredAgentSessionLaunchIntent( worktreeId, owner, @@ -112,13 +122,6 @@ function buildStructuredAgentSessionLaunchIntent( hostSeedOptions: LaunchSeed ): StructuredAgentSessionLaunchIntent { const state = useAppStore.getState() - recordWebSessionFocusIntent( - structuredAgentSessionFocusOwner(owner.target), - worktreeId, - `agent-session:${sessionId}`, - undefined, - resolveWebSessionVisibleTabId(state, worktreeId) - ) return { sessionId, worktreeId, @@ -168,13 +171,6 @@ export function restoreStructuredAgentSessionLaunchIntent(args: { }): StructuredAgentSessionLaunchIntent { const state = useAppStore.getState() const { target } = structuredAgentSessionOwnerTarget(args.worktreeId, args.executionHostId) - recordWebSessionFocusIntent( - structuredAgentSessionFocusOwner(target), - args.worktreeId, - `agent-session:${args.sessionId}`, - undefined, - resolveWebSessionVisibleTabId(state, args.worktreeId) - ) return { sessionId: args.sessionId, worktreeId: args.worktreeId, diff --git a/src/renderer/src/lib/structured-agent-launch-settlement.ts b/src/renderer/src/lib/structured-agent-launch-settlement.ts index 044f61b32130..46b1ec167014 100644 --- a/src/renderer/src/lib/structured-agent-launch-settlement.ts +++ b/src/renderer/src/lib/structured-agent-launch-settlement.ts @@ -26,6 +26,7 @@ export type StructuredAgentLaunchSettlement = | { kind: 'terminal' } export type StructuredAgentLaunchHooks = { + activate?: boolean onStructuredReady?: (sessionId: string) => void /** Abort the moment the caller abandons the launch. The loop cancels on the event, not only by * polling after awaits, so a staged prompt is discarded before it can reach the provider. */ @@ -101,7 +102,10 @@ export function beginStructuredAgentLaunchSettlement( options: StructuredAgentLaunchOptions, hooks: StructuredAgentLaunchHooks ): StructuredAgentLaunchHandle { - const launch = startStructuredAgentLaunch(worktreeId, agent, options) + const launch = startStructuredAgentLaunch(worktreeId, agent, { + ...options, + ...(hooks.activate !== undefined ? { activate: hooks.activate } : {}) + }) return { sessionId: launch.sessionId, executionHostId: launch.executionHostId, diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index 0733941e683e..e32bda4cc1b9 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -12,6 +12,8 @@ import type { AgentLaunchRequestId } from './agent-launch-request-id' export type StructuredAgentLaunchOptions = { /** The user action this start serves; only a re-delivery of it joins its chat. */ requestId: AgentLaunchRequestId + /** Background launches never ask host publication to select their tab. */ + activate?: boolean prompt?: string promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' onPromptDelivered?: () => void diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 79c0008f8e95..f434bd1f8539 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -28,6 +28,7 @@ import { type AgentSessionWriteFailure } from '../../../shared/agent-session-write-failure' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session' /** The options a launch starts with, replaced whole so readers can compare by identity. */ export type StructuredLaunchSelection = { @@ -39,6 +40,16 @@ export type StructuredLaunchSelection = { seedPending?: true } +export function initialStructuredLaunchSelection( + intent: StructuredAgentSessionLaunchIntent +): StructuredLaunchSelection { + return { + seed: intent.seedOptions, + held: {}, + ...(intent.target.kind !== 'local' && !intent.seedOptions ? { seedPending: true } : {}) + } +} + export type StructuredLaunchOptionOutcome = | { kind: 'accepted'; options: Readonly> } | { kind: 'refused'; failure: AgentSessionWriteFailure } @@ -67,7 +78,12 @@ export function holdStructuredAgentSessionLaunchOption( encoded: string ): Promise | null { const state = getStructuredLaunchStateBySessionId(sessionId) - if (!state || state.cancelled || state.callers.outcome === 'published') { + if ( + !state || + state.cancelled || + state.callers.outcome === 'published' || + state.selection.seedPending + ) { return null } const replies = repliesFor(state) diff --git a/src/renderer/src/lib/structured-agent-session-launch-reload.ts b/src/renderer/src/lib/structured-agent-session-launch-reload.ts index 8a511ff876f0..1f3450b47056 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-reload.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-reload.ts @@ -13,6 +13,7 @@ import { structuredLaunchIdentity, type StructuredLaunchState } from './structured-agent-session-launch-registry' +import { initialStructuredLaunchSelection } from './structured-agent-session-launch-options' export function restorePersistedStructuredLaunchState( worktreeId: string, @@ -58,7 +59,7 @@ export function restorePersistedStructuredLaunchState( cancelled: false, onVisibilityChanged: undefined, callers, - selection: { seed: intent.seedOptions, held: {} } + selection: initialStructuredLaunchSelection(intent) } callers.outcome = record.lifecycle === 'failed' ? 'failed' : 'unknown' callers.failedAt = record.failedAt diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts index ba0fdc029505..b5c05623014b 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -367,6 +367,7 @@ describe('startStructuredAgentLaunch', () => { 'claude', undefined, undefined, + undefined, undefined ) expect(mocks.createIntent).toHaveBeenNthCalledWith( @@ -375,6 +376,7 @@ describe('startStructuredAgentLaunch', () => { 'codex', undefined, undefined, + undefined, undefined ) expect(mocks.launch).toHaveBeenCalledTimes(2) diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 8edd6601cd4a..75c4da5f2ae3 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -47,6 +47,7 @@ import { trackStructuredLaunchConfirmationDeadline } from './structured-agent-session-launch-prompt-outcome' import { repeatedStructuredLaunchAttempt } from './structured-agent-session-launch-request' +import { initialStructuredLaunchSelection } from './structured-agent-session-launch-options' export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt } export { @@ -137,7 +138,7 @@ function restartStructuredLaunchState(state: StructuredLaunchState): void { state.callers.outcome = 'pending' // A new create seeds from the settings of now (a paired server's arrive with its probe); picks // held through the failure still apply. - state.selection = { ...state.selection, seed: state.intent.seedOptions } + state.selection = { ...initialStructuredLaunchSelection(state.intent), held: state.selection.held } state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions) state.promise = publishWithHeldOptions( state, @@ -203,7 +204,8 @@ function structuredAgentLaunchState( agent, options.executionHostId, options.resumeFrom, - options.hostSeedOptions + options.hostSeedOptions, + options.activate ) const text = launchPromptText(options) const stagedPrompt = text @@ -224,11 +226,7 @@ function structuredAgentLaunchState( cancelled: false, onVisibilityChanged: notifyStructuredLaunchListeners, callers, - selection: { - seed: intent.seedOptions, - held: {}, - ...(intent.target.kind !== 'local' && !intent.seedOptions ? { seedPending: true } : {}) - } + selection: initialStructuredLaunchSelection(intent) } state.onHostSeed = (seedOptions) => adoptPairedHostSeed(state, seedOptions) callers.onSettled = () => maybeCleanupLaunchState(state) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts index 9ceb58570513..f444eff323d3 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts @@ -30,11 +30,13 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('@/components/native-chat/native-chat-composer-draft-store', () => ({ readNativeChatComposerDraft: () => ({ text: mocks.draftText.value, images: [] }), + resolveNativeChatComposerDraftOwner: () => undefined, structuredAgentSessionDraftScopeKey: (sessionId: string) => `agent-session:${sessionId}` })) import { beginStructuredAgentSessionProvisionalLaunch } from './structured-agent-session-provisional-tab' import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' +import { addNativeChatPendingAttachment, clearNativeChatPendingAttachmentsForTests } from '@/components/native-chat/native-chat-pending-attachment-cache' function planSettlingAs( settlement: StructuredAgentLaunchSettlement, @@ -59,6 +61,7 @@ function planSettlingAs( beforeEach(() => { vi.clearAllMocks() + clearNativeChatPendingAttachmentsForTests() mocks.createUnifiedTab.mockImplementation( (worktreeId: string, _type: string, tab: Partial) => ({ ...tab, worktreeId }) ) @@ -91,9 +94,10 @@ describe('a provisional structured chat tab', () => { }) it.each([ - ['nobody typed: its terminal opens in its place', '', { kind: 'terminal' }], - ['typed in: the chat keeps it', 'half a thought', { kind: 'failed' }] - ])('a New Chat its host refuses, %s', async (_name, typed, settled) => { + ['nobody typed: its terminal opens in its place', '', false, { kind: 'terminal' }], + ['typed in: the chat keeps it', 'half a thought', false, { kind: 'failed' }], + ['pasting an image: the chat keeps it', '', true, { kind: 'failed' }] + ])('a New Chat its host refuses, %s', async (_name, typed, pasting, settled) => { const onHostDeclined = vi.fn(() => ({ opened: true })) const launch = beginStructuredAgentSessionProvisionalLaunch({ plan: planSettlingAs(declined()), @@ -102,10 +106,28 @@ describe('a provisional structured chat tab', () => { onHostDeclined }) mocks.draftText.value = typed + if (pasting) { + addNativeChatPendingAttachment('agent-session:claude_1', { id: 'paste-1', path: '', pending: true }) + } await expect(launch?.settlement).resolves.toMatchObject(settled) - expect(onHostDeclined).toHaveBeenCalledTimes(typed ? 0 : 1) - expect(mocks.closeUnifiedTab).toHaveBeenCalledTimes(typed ? 0 : 1) + expect(onHostDeclined).toHaveBeenCalledTimes(typed || pasting ? 0 : 1) + expect(mocks.closeUnifiedTab).toHaveBeenCalledTimes(typed || pasting ? 0 : 1) + }) + + it('keeps text entered while its fallback terminal is opening', async () => { + let open!: (terminal: { opened: boolean }) => void + const onHostDeclined = vi.fn(() => new Promise<{ opened: boolean }>((resolve) => { open = resolve })) + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: planSettlingAs(declined()), hooks: {}, + focusIntent: { request: mocks.requestFocus, cancel: mocks.cancelFocus }, onHostDeclined + }) + await Promise.resolve() + expect(onHostDeclined).toHaveBeenCalledOnce() + mocks.draftText.value = 'keep this thought' + open({ opened: true }) + await expect(launch?.settlement).resolves.toMatchObject({ kind: 'failed' }) + expect(mocks.closeUnifiedTab).not.toHaveBeenCalled() }) it('asks first for a launch that carries a prompt, so a "no" can hand it on', async () => { diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index 5df1ed870adb..8d39f1f84b5a 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -28,10 +28,8 @@ import { import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' -import { - readNativeChatComposerDraft, - structuredAgentSessionDraftScopeKey -} from '@/components/native-chat/native-chat-composer-draft-store' +import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store' +import { nativeChatComposerHasUserContent } from '@/components/native-chat/native-chat-pending-attachment-cache' import { StructuredAgentSessionCreateRefusalError } from '@/lib/structured-agent-session-launch-errors' type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } @@ -155,18 +153,17 @@ async function openTerminalForDeclinedChat( tab: Tab, settlement: StructuredAgentLaunchSettlement ): Promise { - const draft = readNativeChatComposerDraft(structuredAgentSessionDraftScopeKey(tab.entityId)) + const scopeKey = structuredAgentSessionDraftScopeKey(tab.entityId) if ( settlement.kind !== 'failed' || !(settlement.error instanceof StructuredAgentSessionCreateRefusalError) || settlement.error.code !== 'structured_agent_session_unsupported' || - draft.text.trim() !== '' || - draft.images.length > 0 + nativeChatComposerHasUserContent(scopeKey) ) { return settlement } const terminal = await openDeclinedTerminal(args, worktreeId, owner.target) - if (!terminal.opened) { + if (!terminal.opened || nativeChatComposerHasUserContent(scopeKey)) { return settlement } useAppStore.getState().closeUnifiedTab(tab.id) @@ -273,7 +270,7 @@ export function beginStructuredAgentSessionProvisionalLaunch( } function beginLocalProvisionalLaunch(args: ProvisionalLaunchArgs): LocalProvisionalLaunch | null { - const handle = args.plan.begin(args.hooks, args.target) + const handle = args.plan.begin({ ...args.hooks, activate: args.activate !== false }, args.target) if (!handle) { return null } From dd55317d1510e2ee0003f2c4b38ffc9df6805f10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:36:43 -0700 Subject: [PATCH 16/27] Clarify paired launch admission fixtures and recovery checks --- ...-native-chat-external-attachments.test.tsx | 14 ++++-- ...se-native-chat-provisional-launch.test.tsx | 13 +++-- .../launch-structured-agent-session.test.ts | 48 +++++++++++-------- ...ured-agent-session-provisional-tab.test.ts | 19 ++++++-- 4 files changed, 58 insertions(+), 36 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index c1b11f230810..7b42640218d3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -167,17 +167,23 @@ describe('useNativeChatExternalAttachments', () => { const probe = await renderProbe({ structuredWorktreeId: 'folder-1', structuredSession: { - sessionId: 'codex-1', runtimeEnvironmentId: 'server-1', - sessionPending: true, sessionPendingNotice: notice + sessionId: 'codex-1', + runtimeEnvironmentId: 'server-1', + sessionPending: true, + sessionPendingNotice: notice }, - attachResolvedPaths, setNotice + attachResolvedPaths, + setNotice + }) + await act(async () => { + await probe.latest().captureExternalDrop()(['/local/a.txt']) }) - await act(async () => { await probe.latest().captureExternalDrop()(['/local/a.txt']) }) expect(setNotice).toHaveBeenCalledWith(notice) expect(mocks.prepareNativeChatSessionAttachmentUpload).not.toHaveBeenCalled() expect(mocks.uploadNativeChatSessionAttachmentPaths).not.toHaveBeenCalled() expect(attachResolvedPaths).not.toHaveBeenCalled() }) + it('attaches local worktree paths unchanged', async () => { mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'local' }) const attachResolvedPaths = vi.fn() diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx index e60ce0599b4b..153e133b456c 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx @@ -178,10 +178,14 @@ describe('a chat pane over its own launch', () => { method === 'agentSession.modelCatalog' ? Promise.resolve({ ...SAVED_LIST, + listingNamesConfiguredModel: true, models: SAVED_LIST.models.map((model) => ({ ...model, isDefault: model.id === 'gpt-5.5', - efforts: [{ value: 'low', label: 'Low' }, { value: 'high', label: 'High' }] + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] })) }) : new Promise(() => {}) @@ -193,12 +197,7 @@ describe('a chat pane over its own launch', () => { executionHostId: 'runtime:server-1' }) const fresh = renderLaunchedChat(worktreeId, launch.sessionId, 'codex', target) - await waitFor(() => - expect(fresh.result.current.optionSnapshot.find((entry) => entry.id === 'model')).toMatchObject({ - settable: false, - kind: { choices: expect.arrayContaining([expect.objectContaining({ value: 'gpt-5.5' })]) } - }) - ) + expect(fresh.result.current.launchView?.seedPending).toBe(true) fresh.unmount() resetStructuredAgentLaunchRegistryForTests() resetStructuredAgentLaunchPersistenceForTests() diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts index 7adc6ac3afc0..6dbe87187aec 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.test.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -48,7 +48,12 @@ describe('structured agent session launch', () => { ...intent.params.envelope }) createStructuredAgentSessionLaunchIntent( - worktreeId, 'codex', 'runtime:server-1', undefined, undefined, false + worktreeId, + 'codex', + 'runtime:server-1', + undefined, + undefined, + false ) expect(peekWebSessionFocusIntent(owner, worktreeId)).toBeNull() }) @@ -372,27 +377,30 @@ describe('structured agent session launch', () => { } }) - it.each([undefined, { model: 'sonnet' }])("reports the paired host's seed answer, including a seedless older host (%s)", async (seedOptions) => { - vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) => - method === 'agentSession.createSupport' - ? { supported: true, seedOptions } - : { ok: true, replayed: false, value: { sessionId: 'claude_1', fence: 1 } } - ) - const paired = vi.fn() - const local = vi.fn() + it.each([undefined, { model: 'sonnet' }])( + "reports the paired host's seed answer, including a seedless older host (%s)", + async (seedOptions) => { + vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) => + method === 'agentSession.createSupport' + ? { supported: true, seedOptions } + : { ok: true, replayed: false, value: { sessionId: 'claude_1', fence: 1 } } + ) + const paired = vi.fn() + const local = vi.fn() - await launchStructuredAgentSession( - createStructuredAgentSessionLaunchIntent('workspace-1', 'claude', 'runtime:server-1'), - paired - ) - await launchStructuredAgentSession( - createStructuredAgentSessionLaunchIntent('workspace-1', 'claude', 'local'), - local - ) + await launchStructuredAgentSession( + createStructuredAgentSessionLaunchIntent('workspace-1', 'claude', 'runtime:server-1'), + paired + ) + await launchStructuredAgentSession( + createStructuredAgentSessionLaunchIntent('workspace-1', 'claude', 'local'), + local + ) - expect(paired).toHaveBeenCalledWith(seedOptions) - expect(local).not.toHaveBeenCalled() - }) + expect(paired).toHaveBeenCalledWith(seedOptions) + expect(local).not.toHaveBeenCalled() + } + ) it("seeds a paired chat with the server's reported selection, kept through a retry", () => { const intent = createStructuredAgentSessionLaunchIntent( diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts index f444eff323d3..b0dc5835265c 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts @@ -36,7 +36,10 @@ vi.mock('@/components/native-chat/native-chat-composer-draft-store', () => ({ import { beginStructuredAgentSessionProvisionalLaunch } from './structured-agent-session-provisional-tab' import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' -import { addNativeChatPendingAttachment, clearNativeChatPendingAttachmentsForTests } from '@/components/native-chat/native-chat-pending-attachment-cache' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachmentsForTests +} from '@/components/native-chat/native-chat-pending-attachment-cache' function planSettlingAs( settlement: StructuredAgentLaunchSettlement, @@ -107,7 +110,9 @@ describe('a provisional structured chat tab', () => { }) mocks.draftText.value = typed if (pasting) { - addNativeChatPendingAttachment('agent-session:claude_1', { id: 'paste-1', path: '', pending: true }) + addNativeChatPendingAttachment('agent-session:claude_1', { + id: 'paste-1', path: '', pending: true + }) } await expect(launch?.settlement).resolves.toMatchObject(settled) @@ -117,10 +122,14 @@ describe('a provisional structured chat tab', () => { it('keeps text entered while its fallback terminal is opening', async () => { let open!: (terminal: { opened: boolean }) => void - const onHostDeclined = vi.fn(() => new Promise<{ opened: boolean }>((resolve) => { open = resolve })) + const onHostDeclined = vi.fn( + () => new Promise<{ opened: boolean }>((resolve) => { open = resolve }) + ) const launch = beginStructuredAgentSessionProvisionalLaunch({ - plan: planSettlingAs(declined()), hooks: {}, - focusIntent: { request: mocks.requestFocus, cancel: mocks.cancelFocus }, onHostDeclined + plan: planSettlingAs(declined()), + hooks: {}, + focusIntent: { request: mocks.requestFocus, cancel: mocks.cancelFocus }, + onHostDeclined }) await Promise.resolve() expect(onHostDeclined).toHaveBeenCalledOnce() From 267bb3cb9974e88a8b12118b8c43e2bc43588866 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:40:34 -0700 Subject: [PATCH 17/27] Apply chat attachment guidance at both paste entry points --- .../native-chat/use-native-chat-composer-paste.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index 2313e55e24f0..da244fd97563 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -105,7 +105,7 @@ export function useNativeChatComposerPaste({ (pendingId: string | null, path: string, originalOwner: NativeChatAttachmentOwner) => { if (!nativeChatAttachmentOwnerUnchanged(originalOwner, resolveAttachmentOwner())) { dropImagePaste(pendingId) - setNotice(owner.notice ?? nativeChatWorktreeNotReadyNotice()) + setNotice(nativeChatWorktreeNotReadyNotice()) return } if (pendingId) { @@ -162,7 +162,7 @@ export function useNativeChatComposerPaste({ return } if (owner.kind === 'not-ready') { - setNotice(nativeChatWorktreeNotReadyNotice()) + setNotice(owner.notice ?? nativeChatWorktreeNotReadyNotice()) return } // Why: snapshot the caret before the async temp-file round-trip — `caret` @@ -250,7 +250,7 @@ export function useNativeChatComposerPaste({ setNotice( owner.kind === 'remote' ? nativeChatLocalAttachmentUnsupportedNotice() - : nativeChatWorktreeNotReadyNotice() + : (owner.notice ?? nativeChatWorktreeNotReadyNotice()) ) } return From 1460c23d7fac98a68461e28b09798db42e571d10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:08:49 -0700 Subject: [PATCH 18/27] fix(native-chat): finish the immediate New Chat on top of the + menu's chat rows - The + menu hands focus to the new chat's composer when it closes, instead of a frame later. - A chat picked by name that its host refuses stays a chat and says why, including the WSL or SSH reason; it never becomes a terminal. - A chat the user typed in before its host refused keeps its text, and the agent's terminal opens behind it rather than taking the screen; a paired server's refusal says a terminal opened. - The paired permission check runs once, right before create, for every launch; the chat's own permission notice is the only message. - A chat closed while its host was being asked shares the existing pre-create gate, so nothing is created. - Session attachment notice copy lives with its only caller; tests that mirrored the implementation are trimmed. --- .../native-chat-attachment-upload.ts | 20 --- .../native-chat-structured-session-notices.ts | 2 +- ...n-status-bridge-unconfirmed-start.test.tsx | 3 +- ...hat-structured-composer-transport.test.tsx | 24 +-- ...tive-chat-structured-composer-transport.ts | 20 ++- .../components/tab-bar/QuickLaunchButton.tsx | 16 +- .../tab-bar/new-tab-menu-chat-focus.ts | 20 +++ .../components/tab-bar/tab-bar-surface.tsx | 5 +- .../use-tab-bar-create-menu-controller.ts | 4 +- src/renderer/src/i18n/locales/en.json | 1 - ...aunch-agent-in-new-tab-structured-route.ts | 14 +- .../src/lib/launch-agent-in-new-tab.ts | 25 +-- ...launch-agent-structured-chat-guard.test.ts | 5 + .../src/lib/launch-agent-web-host-tab.ts | 4 +- .../launch-structured-agent-session.test.ts | 49 +++--- .../structured-agent-session-action-launch.ts | 144 +++++++++++++++++ ...red-agent-session-launch-admission.test.ts | 27 ---- ...ructured-agent-session-launch-admission.ts | 44 +----- ...ctured-agent-session-launch-create-call.ts | 15 +- .../structured-agent-session-launch-errors.ts | 4 +- ...structured-agent-session-launch-failure.ts | 35 ++++- ...-agent-session-launch-permission-notice.ts | 15 -- ...-agent-session-launch-probe-outage.test.ts | 3 + ...ctured-agent-session-launch-reload.test.ts | 3 +- .../structured-agent-session-launch.test.ts | 7 +- ...ured-agent-session-provisional-tab.test.ts | 75 +++++---- ...tructured-agent-session-provisional-tab.ts | 148 +++--------------- 27 files changed, 388 insertions(+), 344 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/new-tab-menu-chat-focus.ts create mode 100644 src/renderer/src/lib/structured-agent-session-action-launch.ts diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 8d5634d00986..083a7f73b4e4 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -26,8 +26,6 @@ import type { } from '../../../../shared/agent-session-attachments' import type { AppState } from '@/store/types' import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop' -import type { AgentType } from '../../../../shared/agent-status-types' -import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { findTerminalTabWorktreeId } from './native-chat-file-link' /** A structured chat on a paired server: files upload into that server's attachment store, which @@ -159,24 +157,6 @@ export function nativeChatWorktreeNotReadyNotice(): string { ) } -export function nativeChatSessionAttachmentNotReadyNotice( - agent: AgentType, - starting: boolean -): string { - const label = structuredAgentLabel(agent) - return starting - ? translate( - 'components.native-chat.composer.sessionAttachmentsStarting', - '{{agent}} chat is still opening. Wait for it to open, then attach the files again.', - { agent: label } - ) - : translate( - 'components.native-chat.composer.sessionAttachmentsFailed', - '{{agent}} chat could not open. Use Retry in the chat, then attach the files again.', - { agent: label } - ) -} - export function nativeChatAttachmentOwnerChangedNotice(): string { return translate( 'components.native-chat.composer.attachmentOwnerChanged', diff --git a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts index aee0080a1f1f..c029001c8390 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts @@ -45,7 +45,7 @@ function nativeChatLaunchNotice({ ) const cause = lifecycle === 'failed' && failure - ? (failure.authStartupMessage ?? + ? (failure.causeText ?? agentSessionWriteNoticeText( agentSessionRefusalCauseParts(failure, agentLabel ? { agentName: agentLabel } : {}) )) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx index 784039a0e457..ad011173bb25 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx @@ -328,7 +328,8 @@ describe('a chat whose start was never confirmed', () => { }) }), expect.any(Function), - true + true, + expect.any(Function) ) expect(lifecycle()).toBeNull() expect(rows()).toEqual([]) diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.test.tsx index 6915c1fc7d77..d203fc98d56e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.test.tsx @@ -26,7 +26,7 @@ vi.mock('@/runtime/host-structured-agents', () => ({ import { useNativeChatStructuredComposerTransport } from './use-native-chat-structured-composer-transport' import type { useStructuredAgentSession } from './use-structured-agent-session' -function composerTransport(reported: boolean | undefined, transportEnabled = true, starting = false) { +function composerAcceptsImages(reported: boolean | undefined): boolean | undefined { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: building the transport reads only these members; nothing is sent. const controller = { acceptsImages: reported } as unknown as ReturnType< typeof useStructuredAgentSession @@ -36,8 +36,8 @@ function composerTransport(reported: boolean | undefined, transportEnabled = tru props: { agent: 'grok', sessionId: 'session-1', target: { kind: 'local' } }, controller, sendThroughLaunch: () => false, - starting, - transportEnabled, + starting: false, + transportEnabled: true, worktreeId: undefined, optionPickerRequest: null, setOptionPickerRequest: () => {}, @@ -45,24 +45,14 @@ function composerTransport(reported: boolean | undefined, transportEnabled = tru onSubmitted: () => {} }) ) - return result.current + return result.current.acceptsImages } describe('the structured composer image gate', () => { it("follows the session's options answer over the host's agent list", () => { - expect(composerTransport(false).acceptsImages).toBe(false) - expect(composerTransport(true).acceptsImages).toBe(true) + expect(composerAcceptsImages(false)).toBe(false) + expect(composerAcceptsImages(true)).toBe(true) // No answer yet, or an older host: the agent list decides. - expect(composerTransport(undefined).acceptsImages).toBe(true) - }) - - it('keeps failed uploads gated after starting ends and unlocks only on publication', () => { - const pending = composerTransport(true, false, true) - const failed = composerTransport(true, false, false) - expect(pending.sessionPending).toBe(true) - expect(pending.sessionPendingNotice).toContain('Grok chat is still opening') - expect(failed.sessionPending).toBe(true) - expect(failed.sessionPendingNotice).toContain('Use Retry in the chat') - expect(composerTransport(true, true).sessionPending).toBeUndefined() + expect(composerAcceptsImages(undefined)).toBe(true) }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts index 19af6af0263e..0ea79e60dad2 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-transport.ts @@ -7,7 +7,25 @@ import type { } from './native-chat-composer-types' import type { NativeChatStructuredViewProps } from './native-chat-view-types' import type { useStructuredAgentSession } from './use-structured-agent-session' -import { nativeChatSessionAttachmentNotReadyNotice } from './native-chat-attachment-upload' +import type { AgentType } from '../../../../shared/agent-status-types' +import { translate } from '@/i18n/i18n' +import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' + +/** Why a paired chat's files can't upload yet: its host has not created the chat. */ +function nativeChatSessionAttachmentNotReadyNotice(agent: AgentType, starting: boolean): string { + const label = structuredAgentLabel(agent) + return starting + ? translate( + 'components.native-chat.composer.sessionAttachmentsStarting', + '{{agent}} chat is still opening. Wait for it to open, then attach the files again.', + { agent: label } + ) + : translate( + 'components.native-chat.composer.sessionAttachmentsFailed', + '{{agent}} chat could not open. Use Retry in the chat, then attach the files again.', + { agent: label } + ) +} /** What the composer of a structured chat sends, picks and runs through, from the chat's controller. */ export function useNativeChatStructuredComposerTransport(args: { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 72f361428bb9..bebb9ae8b638 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -66,8 +66,13 @@ export function useTabAgentMenuLaunch({ onPromptDelivered, onPromptHandedOff, disabled, - onFocusHostTerminal -}: QuickLaunchAgentMenuItemsProps & { onFocusHostTerminal?: () => void }) { + onFocusHostTerminal, + onFocusChat +}: QuickLaunchAgentMenuItemsProps & { + onFocusHostTerminal?: () => void + /** A New Chat opened at once: its composer takes the focus the menu gives back. */ + onFocusChat?: (tabId: string) => void +}) { const runLaunch = useCallback( (row: NewTabAgentMenuRow) => { if (disabled || row.kind === 'chat-unavailable') { @@ -125,6 +130,10 @@ export function useTabAgentMenuLaunch({ } }) } + if (result.surface.kind === 'local-chat') { + onFocusChat?.(result.surface.tabId) + return + } if (result.surface.kind !== 'local-terminal') { onFocusHostTerminal?.() return @@ -141,7 +150,8 @@ export function useTabAgentMenuLaunch({ onPromptDelivered, onPromptHandedOff, disabled, - onFocusHostTerminal + onFocusHostTerminal, + onFocusChat ] ) diff --git a/src/renderer/src/components/tab-bar/new-tab-menu-chat-focus.ts b/src/renderer/src/components/tab-bar/new-tab-menu-chat-focus.ts new file mode 100644 index 000000000000..42e8aaa811c4 --- /dev/null +++ b/src/renderer/src/components/tab-bar/new-tab-menu-chat-focus.ts @@ -0,0 +1,20 @@ +import { requestNativeChatComposerFocus } from '@/components/native-chat/use-native-chat-composer-reveal-focus' + +// The chat a + menu choice opened at once; one menu closes at a time, so one pending tab is enough. +let pendingChatTabId: string | null = null + +export function queueChatFocusAtNewTabMenuClose(tabId: string): void { + pendingChatTabId = tabId +} + +/** The menu's focus trap is gone once it closes, so the chat's composer takes focus now, before + * the next key, rather than a frame later. False when the menu opened no chat. */ +export function focusChatAtNewTabMenuClose(): boolean { + const tabId = pendingChatTabId + pendingChatTabId = null + if (!tabId) { + return false + } + requestNativeChatComposerFocus(tabId) + return true +} diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index d166cb562736..cf8cfb134aa8 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -24,6 +24,7 @@ import type { TabBarItemProjection } from './use-tab-bar-item-projection' import type { TabBarItemActions } from './use-tab-bar-item-actions' import { renderTabBarItems } from './tab-bar-item-surface' import { TabBarStaticCreateMenu } from './tab-bar-static-create-menu' +import { focusChatAtNewTabMenuClose } from './new-tab-menu-chat-focus' import ClientHostedBrowserTabRows from './ClientHostedBrowserTabRows' import type { ClientHostedBrowserRow } from '../../../../shared/client-hosted-browser-rows' @@ -228,7 +229,9 @@ export function renderTabBarSurface({ onCloseAutoFocus={(event) => { // Why: Radix restores focus to the "+" trigger on close, stealing it from the freshly-mounted terminal. event.preventDefault() - runPendingNewTabMenuFocusAfterClose() + if (!focusChatAtNewTabMenuClose()) { + runPendingNewTabMenuFocusAfterClose() + } }} > {!terminalOnly && onOpenEntry ? ( diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index 2d65e6782e99..1000c10bea46 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -4,6 +4,7 @@ import type { WindowsTerminalCapabilities } from '@/lib/windows-terminal-capabil import { useAppStore } from '../../store' import type { TabAgentLaunchOption } from './tab-agent-launch-options' import { useTabAgentMenuLaunch } from './QuickLaunchButton' +import { queueChatFocusAtNewTabMenuClose } from './new-tab-menu-chat-focus' import { useNewTabAgentMenuRows, type NewTabAgentMenuModel } from './use-new-tab-agent-menu-rows' import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents' import { buildTabAgentLaunchOptionsFromRows } from './tab-agent-launch-options' @@ -233,7 +234,8 @@ export function useTabBarCreateMenuController({ groupId: resolvedGroupId, launchSource: 'tab_bar_quick_launch', onFocusTerminal: queueTerminalTabFocusAfterNewTabMenuClose, - onFocusHostTerminal: queueNewActiveTerminalFocusAfterNewTabMenuClose + onFocusHostTerminal: queueNewActiveTerminalFocusAfterNewTabMenuClose, + onFocusChat: queueChatFocusAtNewTabMenuClose }) const launchAgentFromNewTabEntry = (option: TabAgentLaunchOption): void => { if (option.row) { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bf568c83fd4e..e3a61190ef45 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19059,7 +19059,6 @@ }, "hostPermission": { "checking": "Checking this host's chat permissions…", - "launchRefused": "Orca did not start a {{value0}} chat", "outdatedHost": "This host runs an older Orca that ignores chat permissions, so {{value0}} would run here without asking. Update Orca on this host to use {{value1}}.", "unverified": "Orca couldn't confirm this host applies chat permissions. Reconnect to it to send." } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts index 30574b1fca1c..1d5d38d4f757 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts @@ -28,7 +28,10 @@ export function launchStructuredAgentFromNewTab(args: { focusIntent?: NativeChatComposerFocusIntent beforeSurfaceOpen?: LaunchAgentInNewTabArgs['beforeSurfaceOpen'] onHostDeclined?: () => StructuredLaunchTerminal - openTerminal: (terminalPlan: AgentSessionLaunchPlan) => { + openTerminal: ( + terminalPlan: AgentSessionLaunchPlan, + activate?: false + ) => { promptDeliveryResult?: Promise } | null }): StructuredFromNewTab | null { @@ -42,7 +45,7 @@ export function launchStructuredAgentFromNewTab(args: { ...(args.focusIntent ? { focusIntent: args.focusIntent } : {}), target: { worktreeId: args.worktreeId }, ...(args.groupId ? { targetGroupId: args.groupId } : {}), - onHostDeclined: () => { + onHostDeclined: (_target, activate) => { if (args.onHostDeclined) { return args.onHostDeclined() } @@ -52,7 +55,8 @@ export function launchStructuredAgentFromNewTab(args: { requestId: plan.requestId, agent: plan.agent, worktreeId: args.worktreeId - }) + }), + activate ) return { opened: terminal !== null, @@ -64,7 +68,9 @@ export function launchStructuredAgentFromNewTab(args: { }) return ( launch && { - surface: { kind: 'host-published' }, + surface: launch.tab + ? { kind: 'local-chat', tabId: launch.tab.id } + : { kind: 'host-published' }, pasteDraftAfterLaunch: false, structuredSettlement: launch.settlement, ...(launch.promptDeliveryResult ? { promptDeliveryResult: launch.promptDeliveryResult } : {}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index f51fe6a7d25b..c3402eece0d9 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -89,9 +89,10 @@ export type LaunchAgentInNewTabArgs = LaunchAgentInNewTabRequest & { onStructuredHostDeclined?: () => StructuredLaunchTerminal } -/** `host-published`: the surface opens once its host answers, a structured chat's included. */ +/** `host-published`: the surface opens once its host answers; `local-chat` opened before it did. */ export type AgentLaunchSurface = | { kind: 'local-terminal'; tabId: string } + | { kind: 'local-chat'; tabId: string } | { kind: 'host-published' } export type LaunchAgentInNewTabResult = { @@ -119,7 +120,7 @@ export function shouldQueueTerminalFocusAfterMenuClose( * * Returns `null` when no startup plan can be built (e.g. a whitespace-only prompt). */ -function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgentInNewTabResult { +export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentInNewTabResult { const { agent, worktreeId, @@ -193,7 +194,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent ...(args.promptKeptByCaller ? { promptKeptByCaller: true as const } : {}) }) if ('awaited' in route) { - return launchOnceHostAnswered(route, args, startupPlan, launchAgentInNewTabInternal) + return launchOnceHostAnswered(route, args, startupPlan, launchAgentInNewTab) } const { plan } = route if (plan?.route === 'structured-native-chat') { @@ -205,12 +206,13 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent ...(beforeSurfaceOpen ? { beforeSurfaceOpen } : {}), ...(args.onStructuredHostDeclined ? { onHostDeclined: args.onStructuredHostDeclined } : {}), // The host's "no" opens this same launch as a terminal, with the caller's arguments. - openTerminal: (terminalPlan) => - launchAgentInNewTabInternal({ + openTerminal: (terminalPlan, terminalActivate) => + launchAgentInNewTab({ ...args, beforeSurfaceOpen: undefined, requestId: undefined, - agentSessionLaunchPlan: terminalPlan + agentSessionLaunchPlan: terminalPlan, + ...(terminalActivate === false ? { activate: false } : {}) }) }) if (!structured) { @@ -241,6 +243,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent // Why: omission means terminal locally, but would let a paired host apply // its own default; send the client's resolved terminal choice explicitly. viewMode: 'terminal', + ...(activate === false ? { activate } : {}), onPromptDelivered }) return { @@ -341,8 +344,10 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent } // Why: without setActiveTabType('terminal') an activated launch can stay hidden behind an editor. - // Scoped to the launch's worktree so a floating or background launch leaves the main window's tab alone. - store.setActiveTabType('terminal', worktreeId) + // Scoped to the launch's worktree so a floating launch leaves the main window's tab alone. + if (activate !== false) { + store.setActiveTabType('terminal', worktreeId) + } // Why: persist tab-bar order so reconcileTabOrder doesn't fall back to terminals-first and jump the new tab to index 0. persistAgentLaunchTabOrder(worktreeId, tab.id) @@ -354,7 +359,3 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent ...(promptDeliveryResult ? { promptDeliveryResult } : {}) } } - -export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentInNewTabResult { - return launchAgentInNewTabInternal(args) -} diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index ee3898257dd5..a9c807aa15f8 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -294,10 +294,13 @@ describe('structured chat adoption guard on the launch path', () => { 'codex', 'local', undefined, + undefined, undefined ) expect(mockLaunchStructuredCodexSession).toHaveBeenCalledWith( expect.objectContaining({ worktreeId: 'wt-1' }), + expect.any(Function), + false, expect.any(Function) ) expect(mockCreateTab).not.toHaveBeenCalled() @@ -327,6 +330,7 @@ describe('structured chat adoption guard on the launch path', () => { 'codex', 'local', undefined, + undefined, undefined ) expect(mockCreateTab).not.toHaveBeenCalled() @@ -348,6 +352,7 @@ describe('structured chat adoption guard on the launch path', () => { 'claude', 'local', undefined, + undefined, undefined ) expect(mockCreateTab).not.toHaveBeenCalled() diff --git a/src/renderer/src/lib/launch-agent-web-host-tab.ts b/src/renderer/src/lib/launch-agent-web-host-tab.ts index 16fb8da79bd3..ebb01d292083 100644 --- a/src/renderer/src/lib/launch-agent-web-host-tab.ts +++ b/src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -31,6 +31,8 @@ export function launchAgentInWebHostTab(args: { submitPastedPrompt: boolean agentArgs?: string | null viewMode?: Tab['viewMode'] + /** False opens the host's tab without selecting it. */ + activate?: boolean onPromptDelivered?: () => void }): Promise<{ delivered: boolean; failureNotified: boolean }> { const { @@ -56,7 +58,7 @@ export function launchAgentInWebHostTab(args: { worktreeId, environmentId, targetGroupId: groupId, - activate: true, + activate: args.activate !== false, ...(cwd?.trim() ? { cwd } : {}), ...(viewMode ? { viewMode } : {}), agentSessionKind: 'fresh', diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts index 1863539bdd82..f53e319b0707 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.test.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -61,7 +61,11 @@ describe('structured agent session launch', () => { // The chat opens before its host answers, so the create is the last thing that can say no. it('issues no create to a host held for its access, or after its chat closed', async () => { vi.mocked(callStructuredAgentSession).mockResolvedValue({ supported: true }) - const held = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude') + const held = createStructuredAgentSessionLaunchIntent( + 'workspace-1', + 'claude', + 'runtime:server-1' + ) hostPermission.mockResolvedValueOnce({ kind: 'refused', hostMode: null }) await expect(launchStructuredAgentSession(held)).rejects.toMatchObject({ code: 'structured_agent_session_host_permission' @@ -69,7 +73,7 @@ describe('structured agent session launch', () => { const closed = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude') await expect( launchStructuredAgentSession(closed, undefined, false, () => true) - ).rejects.toThrow('cancelled before create') + ).rejects.toThrow('retired before create') expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ 'agentSession.createSupport', 'agentSession.createSupport' @@ -379,30 +383,27 @@ describe('structured agent session launch', () => { } }) - it.each([undefined, { model: 'sonnet' }])( - "reports the paired host's seed answer, including a seedless older host (%s)", - async (seedOptions) => { - vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) => - method === 'agentSession.createSupport' - ? { supported: true, seedOptions } - : { ok: true, replayed: false, value: { sessionId: 'claude_1', fence: 1 } } - ) - const paired = vi.fn() - const local = vi.fn() + it("reports a paired server's current seed from the create probe, and no local one", async () => { + vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) => + method === 'agentSession.createSupport' + ? { supported: true, seedOptions: { model: 'sonnet' } } + : { ok: true, replayed: false, value: { sessionId: 'claude_1', fence: 1 } } + ) + const paired = vi.fn() + const local = vi.fn() - await launchStructuredAgentSession( - createStructuredAgentSessionLaunchIntent('workspace-1', 'claude', 'runtime:server-1'), - paired - ) - await launchStructuredAgentSession( - createStructuredAgentSessionLaunchIntent('workspace-1', 'claude', 'local'), - local - ) + await launchStructuredAgentSession( + createStructuredAgentSessionLaunchIntent('workspace-1', 'claude', 'runtime:server-1'), + paired + ) + await launchStructuredAgentSession( + createStructuredAgentSessionLaunchIntent('workspace-1', 'claude', 'local'), + local + ) - expect(paired).toHaveBeenCalledWith(seedOptions) - expect(local).not.toHaveBeenCalled() - } - ) + expect(paired).toHaveBeenCalledWith({ model: 'sonnet' }) + expect(local).not.toHaveBeenCalled() + }) it("seeds a paired chat with the server's reported selection, kept through a retry", () => { const intent = createStructuredAgentSessionLaunchIntent( diff --git a/src/renderer/src/lib/structured-agent-session-action-launch.ts b/src/renderer/src/lib/structured-agent-session-action-launch.ts new file mode 100644 index 000000000000..0f8e42ee1b5c --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-action-launch.ts @@ -0,0 +1,144 @@ +import type { Tab } from '../../../shared/tab-types' +import type { + AgentSessionLaunchPlan, + AgentSessionLaunchTarget +} from '@/lib/agent-session-launch-plan' +import type { + StructuredAgentLaunchHandle, + StructuredAgentLaunchHooks, + StructuredAgentLaunchSettlement +} from '@/lib/structured-agent-launch-settlement' +import { useAppStore } from '@/store' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { + notifyHostDeclined, + openDeclinedStructuredLaunchTerminal, + type DeclinedStructuredLaunchTerminalOptions, + type StructuredLaunchTerminal +} from '@/lib/structured-agent-session-launch-admission' +import { structuredAgentSessionFocusOwner } from '@/runtime/structured-agent-session-owner' +import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent' +import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' +import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' +import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store' +import { nativeChatComposerHasUserContent } from '@/components/native-chat/native-chat-pending-attachment-cache' +import { StructuredAgentSessionCreateRefusalError } from '@/lib/structured-agent-session-launch-errors' + +export type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } + +export type ProvisionalLaunchArgs = { + plan: AgentSessionLaunchPlan + hooks: StructuredAgentLaunchHooks + focusIntent?: NativeChatComposerFocusIntent + target?: AgentSessionLaunchTarget + targetGroupId?: string + /** The tab the chat opens right after, in that tab's group. */ + afterTabId?: string + activate?: boolean + /** Identity-only resumes publish on commit; unknown outcomes keep their recovery tab. */ + deferPublication?: true + /** Lets workspace flows reveal before the chat's tab is owned: before its host is asked, when no + * session id exists yet. */ + beforeOpen?: (sessionId?: string) => boolean | void + /** The terminal the host's "no" opens; a caller without one gets a new agent tab's. `false` + * opens it behind a chat the user already typed in. */ + onHostDeclined?: ( + target: RuntimeClientTarget, + activate?: false + ) => Promise | StructuredLaunchTerminal + /** What that default terminal carries from the caller, e.g. a recipe's saved CLI arguments. */ + declinedTerminal?: DeclinedStructuredLaunchTerminalOptions +} + +export type LaunchOwner = { executionHostId: ExecutionHostId; target: RuntimeClientTarget } + +export function openDeclinedTerminal( + args: ProvisionalLaunchArgs, + worktreeId: string, + target: RuntimeClientTarget, + activate?: false +): Promise | StructuredLaunchTerminal { + if (args.onHostDeclined) { + return activate === false ? args.onHostDeclined(target, false) : args.onHostDeclined(target) + } + const terminal = { ...args.declinedTerminal, ...(activate === false ? { activate } : {}) } + return openDeclinedStructuredLaunchTerminal({ + plan: args.plan, + worktreeId, + ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), + terminal + }) +} + +/** A New Chat the user just asked for opens its chat now, so the next key has a composer; its own + * launch asks the host. A prompt to deliver keeps asking first: a "no" hands it to a terminal. */ +export function opensAtAction(args: ProvisionalLaunchArgs): boolean { + const { plan } = args + return ( + args.focusIntent !== undefined && + args.activate !== false && + !plan.resumeFrom && + (!plan.prompt?.trim() || plan.promptDelivery === 'draft') + ) +} + +/** A definite "no" opens the agent's terminal: in place of a chat nobody typed in yet, where + * asking first used to land, or behind one with typing, which keeps it. A chat the user picked + * by name never becomes a terminal; its own notice says why. */ +async function openTerminalForDeclinedChat( + args: ProvisionalLaunchArgs, + worktreeId: string, + owner: LaunchOwner, + tab: Tab, + settlement: StructuredAgentLaunchSettlement +): Promise { + if ( + args.plan.requestedSurface === 'chat' || + settlement.kind !== 'failed' || + !(settlement.error instanceof StructuredAgentSessionCreateRefusalError) || + settlement.error.code !== 'structured_agent_session_unsupported' + ) { + return settlement + } + const scopeKey = structuredAgentSessionDraftScopeKey(tab.entityId) + const typed = nativeChatComposerHasUserContent(scopeKey) + const terminal = await openDeclinedTerminal( + args, + worktreeId, + owner.target, + typed ? false : undefined + ) + if (!terminal.opened) { + return settlement + } + if (owner.target.kind === 'environment') { + notifyHostDeclined(args.plan.agent) + } + if (typed || nativeChatComposerHasUserContent(scopeKey)) { + return settlement + } + useAppStore.getState().closeUnifiedTab(tab.id) + return { kind: 'terminal' } +} + +/** Takes a New Chat opened at once: its chat is the visible tab, and a host's "no" may open the + * agent's terminal. */ +export function adoptActionLaunch( + args: ProvisionalLaunchArgs, + worktreeId: string, + owner: LaunchOwner, + launch: LocalProvisionalLaunch +): LocalProvisionalLaunch { + // The chat is already the visible tab; its host's copy arriving later must not pull focus back. + clearWebSessionFocusIntentIfMatches( + structuredAgentSessionFocusOwner(owner.target), + worktreeId, + `agent-session:${launch.sessionId}` + ) + return { + ...launch, + settlement: launch.settlement.then((settlement) => + openTerminalForDeclinedChat(args, worktreeId, owner, launch.tab, settlement) + ) + } +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts index 248a7ed18a03..f864097702e8 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts @@ -194,33 +194,6 @@ describe('a structured chat launch on a paired server', () => { launch?.cancel() }) - // An older server ignores the chat's mode and would run Claude with its own bypass flag. - it.each(['refused', 'unverified'] as const)( - 'starts nothing on a server whose permission verdict is %s, and says so once', - async (kind) => { - mocks.createSupport.mockResolvedValue({ supported: true }) - mocks.hostPermission.mockResolvedValue( - kind === 'refused' ? { kind, hostMode: 'full-access' } : { kind } - ) - - const launch = beginStructuredAgentSessionProvisionalLaunch({ - plan: pairedPlan(), - hooks: {}, - onHostDeclined: vi.fn() - }) - const settlement = await launch?.settlement - - expect(mocks.hostPermission).toHaveBeenCalledWith( - { kind: 'environment', environmentId: 'server-1' }, - 'claude' - ) - expect(settlement).toEqual(expect.objectContaining({ kind: 'failed', notified: true })) - expect(mocks.toastError).toHaveBeenCalledTimes(1) - expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() - expectNoChatCommitted() - } - ) - it('opens the chat on the server that admitted it', async () => { mocks.createSupport.mockResolvedValue({ supported: true }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.ts index 1be3604a8718..ab211e5e1c0c 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.ts @@ -23,8 +23,7 @@ import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-sess import type { LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { readStructuredChatHostPermissionVerdict } from '@/runtime/structured-chat-host-permission' -import { notifyHostPermissionRefused } from '@/lib/structured-agent-session-launch-permission-notice' +import { structuredChatUnavailableText } from '@/lib/structured-agent-session-launch-failure' /** What a host's "no" opens instead: the terminal the launch would have opened had it known. */ export type StructuredLaunchTerminal = { @@ -62,27 +61,11 @@ function notifyExplicitChatFailure(agent: TuiAgent, reason?: string): void { "Couldn't start a {{value0}} chat.", { value0: structuredAgentLabel(agent) } ), - { - description: - reason === 'wsl' - ? translate( - 'components.native-chat.explicitChatWsl', - 'Chat is not available in WSL. Open the agent in a terminal instead.' - ) - : reason === 'remote' - ? translate( - 'components.native-chat.explicitChatSsh', - 'Chat is not available over SSH. Open the agent in a terminal instead.' - ) - : translate( - 'components.native-chat.explicitChatUnavailable', - 'This workspace cannot run the chat. Check the agent installation or open it in a terminal.' - ) - } + { description: structuredChatUnavailableText(reason) } ) } -function notifyHostDeclined(agent: TuiAgent): void { +export function notifyHostDeclined(agent: TuiAgent): void { const agentLabel = structuredAgentLabel(agent) toast.info( translate( @@ -123,7 +106,7 @@ function notifyHostUnreachable(agent: TuiAgent, executionHostId: ExecutionHostId /** What the caller's own terminal launch would have carried beyond the plan's agent and prompt. */ export type DeclinedStructuredLaunchTerminalOptions = Pick< LaunchAgentInNewTabArgs, - 'agentArgs' | 'launchPlatform' | 'launchSource' | 'initialCwd' + 'agentArgs' | 'launchPlatform' | 'launchSource' | 'initialCwd' | 'activate' > /** A caller with no terminal path of its own gets the one a new agent tab would open. */ @@ -190,6 +173,8 @@ export function beginHostAdmittedStructuredLaunch(args: { resolveDelivery = resolve }) : undefined + // Reached by an explicit chat that carries a prompt; one without opens before its host answers, + // and its chat says why instead. const failBeforeSurface = (error: unknown, reason?: string): StructuredAgentLaunchSettlement => { if (plan.requestedSurface === 'chat' && !admitted) { notifyExplicitChatFailure(plan.agent, reason) @@ -246,23 +231,6 @@ export function beginHostAdmittedStructuredLaunch(args: { ).then(resolveDelivery, () => resolveDelivery(NOT_DELIVERED)) return terminal.opened ? { kind: 'terminal' } : { kind: 'cancelled', sessionId: null } } - // A host that would run the chat with more access than asked for gets nothing to start. - const permission = paired - ? await readStructuredChatHostPermissionVerdict(args.target, plan.agent) - : { kind: 'allowed' as const } - if (isCancelled()) { - resolveDelivery(NOT_DELIVERED) - return { kind: 'cancelled', sessionId: null } - } - if (permission.kind !== 'allowed') { - notifyHostPermissionRefused(plan.agent, permission.kind) - resolveDelivery({ delivered: false, failureNotified: true }) - return { - kind: 'failed', - error: new Error('structured chat host runs with more access than asked for'), - notified: true - } - } admitted = args.openAdmitted( admission.kind === 'admitted' ? admission.seedOptions : undefined ) diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts index 6f600060436c..bb4e3d4d986e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts @@ -71,7 +71,9 @@ async function requireHostCreateSupport( abandonStructuredAgentSessionLaunchIntent(intent) throw new StructuredAgentSessionCreateRefusalError( 'structured_agent_session_unsupported', - 'structured_agent_session_unsupported' + 'structured_agent_session_unsupported', + undefined, + support.kind === 'declined' ? support.reason : undefined ) } return support.seedOptions @@ -82,7 +84,10 @@ async function requireHostCreateSupport( const STRUCTURED_LAUNCH_HOST_PERMISSION_REFUSAL = 'structured_agent_session_host_permission' async function requireHostPermission(intent: StructuredAgentSessionLaunchIntent): Promise { - const permission = await readStructuredChatHostPermissionVerdict(intent.target, intent.agent) + const permission = + intent.target.kind === 'local' + ? { kind: 'allowed' as const } + : await readStructuredChatHostPermissionVerdict(intent.target, intent.agent) if (permission.kind === 'allowed') { return } @@ -128,11 +133,9 @@ export async function launchStructuredAgentSession( intent.createPrepared = true } } - if (isCancelled()) { - throw new Error('structured session launch cancelled before create') - } let result: AgentSessionMutationResult - if (intent.beforeCreate?.() === false) { + // Nothing is created for a chat closed while its host was asked, or whose source retired. + if (isCancelled() || intent.beforeCreate?.() === false) { abandonStructuredAgentSessionLaunchIntent(intent) throw new StructuredAgentSessionCreateAbandonedError( 'Conversation source retired before create.' diff --git a/src/renderer/src/lib/structured-agent-session-launch-errors.ts b/src/renderer/src/lib/structured-agent-session-launch-errors.ts index d60aaf523177..5a8b468e580c 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-errors.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-errors.ts @@ -17,7 +17,9 @@ export class StructuredAgentSessionCreateRefusalError extends StructuredAgentSes constructor( message: string, code: string = 'structured_agent_session_unsupported', - refusal?: AgentSessionRefusalReference + refusal?: AgentSessionRefusalReference, + /** The host's own word for why it can't run the chat here (`wsl`, `remote`), when it gave one. */ + readonly hostReason?: string ) { super(message, code, refusal) this.name = 'StructuredAgentSessionCreateRefusalError' diff --git a/src/renderer/src/lib/structured-agent-session-launch-failure.ts b/src/renderer/src/lib/structured-agent-session-launch-failure.ts index 54ea794ab283..6786083cf55d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-failure.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-failure.ts @@ -10,11 +10,42 @@ import { readAgentSessionErrorRefusal, type AgentSessionWriteRefusal } from '../../../shared/agent-session-write-failure' +import { translate } from '@/i18n/i18n' +import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' -export type StructuredLaunchFailure = AgentSessionWriteRefusal & { authStartupMessage?: string } +export type StructuredLaunchFailure = AgentSessionWriteRefusal & { + /** Words that replace the refusal's generic cause: the host's sign-in guidance, or why this + * workspace can't run the chat. */ + causeText?: string +} + +/** Why a host said it can't run a chat in this workspace, and what to do instead. */ +export function structuredChatUnavailableText(reason?: string): string { + return reason === 'wsl' + ? translate( + 'components.native-chat.explicitChatWsl', + 'Chat is not available in WSL. Open the agent in a terminal instead.' + ) + : reason === 'remote' + ? translate( + 'components.native-chat.explicitChatSsh', + 'Chat is not available over SSH. Open the agent in a terminal instead.' + ) + : translate( + 'components.native-chat.explicitChatUnavailable', + 'This workspace cannot run the chat. Check the agent installation or open it in a terminal.' + ) +} /** The host's refusal behind a failed launch; undefined when the failure carried none. */ export function structuredLaunchFailure(error: unknown): StructuredLaunchFailure | undefined { + if (error instanceof StructuredAgentSessionCreateRefusalError && error.hostReason) { + return { + kind: 'refused', + code: 'structured_agent_session_unsupported', + causeText: structuredChatUnavailableText(error.hostReason) + } + } const refusal = launchRefusal(error) if (!refusal) { return undefined @@ -25,7 +56,7 @@ export function structuredLaunchFailure(error: unknown): StructuredLaunchFailure failure.details?.reason === 'notSignedIn' && message && message !== failure.code - ? { ...failure, authStartupMessage: message } + ? { ...failure, causeText: message } : failure } diff --git a/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts b/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts index 86ff808f0a5d..e017e006e581 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts @@ -1,10 +1,7 @@ -import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { isAgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' -import type { TuiAgent } from '../../../shared/tui-agent' import { nativeChatPermissionModeLabel } from '@/components/native-chat/native-chat-permission-mode-labels' -import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' /** Why a paired host may not run this chat: it would not apply the permission mode asked for. */ export function hostPermissionRefusedText( @@ -28,15 +25,3 @@ export function hostPermissionRefusedText( } ) } - -export function notifyHostPermissionRefused(agent: TuiAgent, kind: 'refused' | 'unverified'): void { - const agentLabel = structuredAgentLabel(agent) - toast.error( - translate( - 'components.native-chat.hostPermission.launchRefused', - 'Orca did not start a {{value0}} chat', - { value0: agentLabel } - ), - { description: hostPermissionRefusedText(agentLabel, kind) } - ) -} diff --git a/src/renderer/src/lib/structured-agent-session-launch-probe-outage.test.ts b/src/renderer/src/lib/structured-agent-session-launch-probe-outage.test.ts index ede3de6ef3bb..509fd5a942dd 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-probe-outage.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-probe-outage.test.ts @@ -29,6 +29,9 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ throw offline() }) })) +vi.mock('@/runtime/structured-chat-host-permission', () => ({ + readStructuredChatHostPermissionVerdict: async () => ({ kind: 'allowed' }) +})) vi.mock('@/runtime/structured-session-tab-inventory', () => ({ readStructuredSessionTabInventory: vi.fn(async () => { calls.log.push('inventory') diff --git a/src/renderer/src/lib/structured-agent-session-launch-reload.test.ts b/src/renderer/src/lib/structured-agent-session-launch-reload.test.ts index 4ab7ee061d67..e4cd6fd2caf5 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-reload.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-reload.test.ts @@ -179,7 +179,8 @@ describe('structured agent launch reload recovery', () => { }) }), expect.any(Function), - true + true, + expect.any(Function) ) }) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts index b5c05623014b..3f65a1e0a837 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -341,7 +341,12 @@ describe('startStructuredAgentLaunch', () => { await flushLaunchSettlement() expect(mocks.launch).toHaveBeenCalledOnce() - expect(mocks.launch).toHaveBeenCalledWith(intent, expect.any(Function)) + expect(mocks.launch).toHaveBeenCalledWith( + intent, + expect.any(Function), + false, + expect.any(Function) + ) expect(toast.message).not.toHaveBeenCalled() expect(toast.error).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts index b0dc5835265c..9a29f96a6bbe 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts @@ -97,47 +97,44 @@ describe('a provisional structured chat tab', () => { }) it.each([ - ['nobody typed: its terminal opens in its place', '', false, { kind: 'terminal' }], - ['typed in: the chat keeps it', 'half a thought', false, { kind: 'failed' }], - ['pasting an image: the chat keeps it', '', true, { kind: 'failed' }] - ])('a New Chat its host refuses, %s', async (_name, typed, pasting, settled) => { - const onHostDeclined = vi.fn(() => ({ opened: true })) - const launch = beginStructuredAgentSessionProvisionalLaunch({ - plan: planSettlingAs(declined()), - hooks: {}, - focusIntent: { request: mocks.requestFocus, cancel: mocks.cancelFocus }, - onHostDeclined - }) - mocks.draftText.value = typed - if (pasting) { - addNativeChatPendingAttachment('agent-session:claude_1', { - id: 'paste-1', path: '', pending: true + ['nobody typed: its terminal opens in its place', '', false, undefined, 'front'], + [ + 'typed in: the chat keeps it, its terminal opens behind', + 'half a thought', + false, + undefined, + 'back' + ], + ['pasting an image: the chat keeps it', '', true, undefined, 'back'], + ['picked as a chat: it stays a chat and says why', '', false, 'chat' as const, null] + ])( + 'a New Chat its host refuses, %s', + async (_name, typed, pasting, requestedSurface, terminal) => { + const onHostDeclined = vi.fn(() => ({ opened: true })) + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: { ...planSettlingAs(declined()), ...(requestedSurface ? { requestedSurface } : {}) }, + hooks: {}, + focusIntent: { request: mocks.requestFocus, cancel: mocks.cancelFocus }, + onHostDeclined }) - } - - await expect(launch?.settlement).resolves.toMatchObject(settled) - expect(onHostDeclined).toHaveBeenCalledTimes(typed || pasting ? 0 : 1) - expect(mocks.closeUnifiedTab).toHaveBeenCalledTimes(typed || pasting ? 0 : 1) - }) + mocks.draftText.value = typed + if (pasting) { + addNativeChatPendingAttachment('agent-session:claude_1', { + id: 'paste-1', + path: '', + pending: true + }) + } - it('keeps text entered while its fallback terminal is opening', async () => { - let open!: (terminal: { opened: boolean }) => void - const onHostDeclined = vi.fn( - () => new Promise<{ opened: boolean }>((resolve) => { open = resolve }) - ) - const launch = beginStructuredAgentSessionProvisionalLaunch({ - plan: planSettlingAs(declined()), - hooks: {}, - focusIntent: { request: mocks.requestFocus, cancel: mocks.cancelFocus }, - onHostDeclined - }) - await Promise.resolve() - expect(onHostDeclined).toHaveBeenCalledOnce() - mocks.draftText.value = 'keep this thought' - open({ opened: true }) - await expect(launch?.settlement).resolves.toMatchObject({ kind: 'failed' }) - expect(mocks.closeUnifiedTab).not.toHaveBeenCalled() - }) + await expect(launch?.settlement).resolves.toMatchObject({ + kind: terminal === 'front' ? 'terminal' : 'failed' + }) + expect(onHostDeclined.mock.calls).toEqual( + terminal === 'back' ? [[{ kind: 'local' }, false]] : terminal ? [[{ kind: 'local' }]] : [] + ) + expect(mocks.closeUnifiedTab).toHaveBeenCalledTimes(terminal === 'front' ? 1 : 0) + } + ) it('asks first for a launch that carries a prompt, so a "no" can hand it on', async () => { const launch = beginStructuredAgentSessionProvisionalLaunch({ diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index 1d884f5a5825..bf1de3ad361d 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -1,33 +1,29 @@ -import type { Tab } from '../../../shared/tab-types' import type { AgentSessionLaunchPlan, AgentSessionLaunchTarget } from '@/lib/agent-session-launch-plan' import type { StructuredAgentLaunchHandle, - StructuredAgentLaunchHooks, - StructuredAgentLaunchSettlement + StructuredAgentLaunchHooks } from '@/lib/structured-agent-launch-settlement' import { useAppStore } from '@/store' import type { ExecutionHostId } from '../../../shared/execution-host' import { beginHostAdmittedStructuredLaunch, - openDeclinedStructuredLaunchTerminal, - type DeclinedStructuredLaunchTerminalOptions, - type HostAdmittedStructuredLaunch, - type StructuredLaunchTerminal + type HostAdmittedStructuredLaunch } from '@/lib/structured-agent-session-launch-admission' import { resolveStructuredAgentSessionOwner, - structuredAgentSessionFocusOwner, structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' -import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' -import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' -import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store' -import { nativeChatComposerHasUserContent } from '@/components/native-chat/native-chat-pending-attachment-cache' -import { StructuredAgentSessionCreateRefusalError } from '@/lib/structured-agent-session-launch-errors' +import { + adoptActionLaunch, + openDeclinedTerminal, + opensAtAction, + type LocalProvisionalLaunch, + type ProvisionalLaunchArgs +} from './structured-agent-session-action-launch' import { holdEmptyWorkspaceDefaultSurfaceForLaunch } from './empty-workspace-default-surface-claims' import { captureStructuredLaunchSurfaceAddress, @@ -35,8 +31,6 @@ import { } from './structured-agent-session-launch-surface' export { openStructuredAgentSessionProvisionalTab } from './structured-agent-session-launch-surface' -type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } - /** A chat's tab opens once its host admits it; a launch with no host to ask takes the direct path. */ export type StructuredAgentSessionProvisionalLaunch = | LocalProvisionalLaunch @@ -60,119 +54,9 @@ export function structuredLaunchOwner( return executionHostId && hostTarget ? { executionHostId, target: hostTarget } : null } -type ProvisionalLaunchArgs = { - plan: AgentSessionLaunchPlan - hooks: StructuredAgentLaunchHooks - focusIntent?: NativeChatComposerFocusIntent - target?: AgentSessionLaunchTarget - targetGroupId?: string - /** The tab the chat opens right after, in that tab's group. */ - afterTabId?: string - activate?: boolean - /** Identity-only resumes publish on commit; unknown outcomes keep their recovery tab. */ - deferPublication?: true - /** Lets workspace flows reveal before the chat's tab is owned: before its host is asked, when no - * session id exists yet. */ - beforeOpen?: (sessionId?: string) => boolean | void - /** The terminal the host's "no" opens; a caller without one gets a new agent tab's. */ - onHostDeclined?: ( - target: RuntimeClientTarget - ) => Promise | StructuredLaunchTerminal - /** What that default terminal carries from the caller, e.g. a recipe's saved CLI arguments. */ - declinedTerminal?: DeclinedStructuredLaunchTerminalOptions -} - -type LaunchOwner = { executionHostId: ExecutionHostId; target: RuntimeClientTarget } -type LocalLaunchResult = ReturnType - /** Only a launch that takes the user to its chat may ask host publication to select it. */ function launchHooks(args: ProvisionalLaunchArgs): StructuredAgentLaunchHooks { - return { ...args.hooks, activate: args.activate !== false } -} - -function openDeclinedTerminal( - args: ProvisionalLaunchArgs, - worktreeId: string, - target: RuntimeClientTarget -): Promise | StructuredLaunchTerminal { - if (args.onHostDeclined) { - return args.onHostDeclined(target) - } - return openDeclinedStructuredLaunchTerminal({ - plan: args.plan, - worktreeId, - ...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}), - ...(args.declinedTerminal ? { terminal: args.declinedTerminal } : {}) - }) -} - -/** A New Chat the user just asked for opens its chat now, so the next key has a composer; its own - * launch asks the host. A prompt to deliver keeps asking first: a "no" hands it to a terminal. */ -function opensAtAction(args: ProvisionalLaunchArgs): boolean { - const { plan } = args - return ( - args.focusIntent !== undefined && - args.activate !== false && - !plan.resumeFrom && - (!plan.prompt?.trim() || plan.promptDelivery === 'draft') - ) -} - -/** A definite "no" to a chat nobody typed in yet opens its terminal in the chat's place, where - * asking first used to land. A chat with typing keeps it. */ -async function openTerminalForDeclinedChat( - args: ProvisionalLaunchArgs, - worktreeId: string, - owner: LaunchOwner, - tab: Tab, - settlement: StructuredAgentLaunchSettlement -): Promise { - const scopeKey = structuredAgentSessionDraftScopeKey(tab.entityId) - if ( - settlement.kind !== 'failed' || - !(settlement.error instanceof StructuredAgentSessionCreateRefusalError) || - settlement.error.code !== 'structured_agent_session_unsupported' || - nativeChatComposerHasUserContent(scopeKey) - ) { - return settlement - } - const terminal = await openDeclinedTerminal(args, worktreeId, owner.target) - if (!terminal.opened || nativeChatComposerHasUserContent(scopeKey)) { - return settlement - } - useAppStore.getState().closeUnifiedTab(tab.id) - return { kind: 'terminal' } -} - -function beginActionLaunch( - args: ProvisionalLaunchArgs, - worktreeId: string, - owner: LaunchOwner, - open: (launch: LocalLaunchResult) => LocalLaunchResult -): LocalLaunchResult { - const launch = open( - beginLocalProvisionalLaunch({ - ...args, - target: { ...args.target, worktreeId, executionHostId: owner.executionHostId }, - beforeOpen: undefined - }) - ) - const tab = launch?.tab - if (!launch || !tab) { - return launch - } - // The chat is already the visible tab; its host's copy arriving later must not pull focus back. - clearWebSessionFocusIntentIfMatches( - structuredAgentSessionFocusOwner(owner.target), - worktreeId, - `agent-session:${launch.sessionId}` - ) - return { - ...launch, - settlement: launch.settlement.then((settlement) => - openTerminalForDeclinedChat(args, worktreeId, owner, tab, settlement) - ) - } + return args.activate === false ? { ...args.hooks, activate: false } : args.hooks } /** Binds the launch to a chat tab: at once for a New Chat the user asked for, otherwise once its @@ -215,7 +99,17 @@ export function beginStructuredAgentSessionProvisionalLaunch( return open(beginLocalProvisionalLaunch(args)) } if (opensAtAction(args)) { - return beginActionLaunch(args, worktreeId, owner, open) + const launch = open( + beginLocalProvisionalLaunch({ + ...args, + target: { ...args.target, worktreeId, executionHostId: owner.executionHostId }, + beforeOpen: undefined + }) + ) + if (!launch || launch.tab === null) { + return launch + } + return adoptActionLaunch(args, worktreeId, owner, { ...launch, tab: launch.tab }) } if (args.deferPublication && !args.target?.surfaceAddress) { args = { From 9bbddba31c3170acf9c101502ccb16914e78bb5e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:59:26 -0700 Subject: [PATCH 19/27] fix: preserve chat ownership through launch admission and fallback --- .../server/server-status-application.ts | 19 +- .../server/server-status-update.ts | 28 +- ...y-subprocess-spawn-file-foreground.test.ts | 12 +- .../terminal-host-process-inspection.test.ts | 39 +-- .../local-pty-child-process-verdict.test.ts | 33 +- ...al-pty-provider-foreground-process.test.ts | 10 +- .../use-native-chat-host-permission-gate.ts | 15 +- ...ructured-native-chat-submit-reveal.test.ts | 53 ++- .../tab-bar/new-tab-menu-chat-focus.ts | 20 -- .../components/tab-bar/tab-bar-surface.tsx | 5 +- .../use-tab-bar-create-menu-controller.ts | 54 +-- src/renderer/src/i18n/locales/en.json | 7 +- ...aunch-agent-in-new-tab-structured-route.ts | 4 + ...aunch-agent-in-new-tab-web-runtime.test.ts | 32 +- .../src/lib/launch-agent-in-new-tab.test.ts | 19 +- .../src/lib/launch-agent-in-new-tab.ts | 9 +- .../src/lib/launch-agent-web-host-tab.ts | 37 ++- .../structured-agent-session-action-launch.ts | 173 +++++----- ...red-agent-session-launch-admission.test.ts | 37 ++- ...ructured-agent-session-launch-admission.ts | 49 ++- ...-agent-session-launch-permission-notice.ts | 18 +- ...ured-agent-session-provisional-tab.test.ts | 307 ++++++++++-------- ...tructured-agent-session-provisional-tab.ts | 43 ++- .../structured-chat-host-permission.test.ts | 8 +- .../structured-chat-host-permission.ts | 9 +- ...session-terminal-workspace-routing.test.ts | 3 +- 26 files changed, 612 insertions(+), 431 deletions(-) delete mode 100644 src/renderer/src/components/tab-bar/new-tab-menu-chat-focus.ts diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts index 8d6d7e1372ba..d2bb321ab4c1 100644 --- a/src/main/agent-hooks/server/server-status-application.ts +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -81,18 +81,13 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt isReplay: _isReplay, ...payload } = previous - const refreshed = deriveCliWriterTopology( - { - ...payload, - receivedAt: now, - evidenceObservedAt: now, - stateStartedAt, - observation: this.stampObservation(payload, 'osc', now) - }, - mutationBefore ?? previous, - this.cliWriterExecutionMatches, - newRun - ) + const refreshed = deriveCliWriterTopology({ + ...payload, + receivedAt: now, + evidenceObservedAt: now, + stateStartedAt, + observation: this.stampObservation(payload, 'osc', now) + }, mutationBefore ?? previous, this.cliWriterExecutionMatches, newRun) const firstRuntimeObservation = !this.runtimeObservedStatusPaneKeys.has(refreshed.paneKey) this.runtimeObservedStatusPaneKeys.add(refreshed.paneKey) if (!this.writeLegacyStatusRow(refreshed)) { diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 614846a2bd74..3b92363db502 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -141,16 +141,10 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA if (terminalOwnedPayload.providerSessionOnly) { // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. onAccepted?.() - const enriched = deriveCliWriterTopology( - { - ...this.attachStatusTiming(terminalOwnedPayload, now), - observation: this.stampObservation(terminalOwnedPayload, origin, now) - }, - rowBefore, - this.cliWriterExecutionMatches, - newRun, - incoming.agentPresence?.process - ) + const enriched = deriveCliWriterTopology({ + ...this.attachStatusTiming(terminalOwnedPayload, now), + observation: this.stampObservation(terminalOwnedPayload, origin, now) + }, rowBefore, this.cliWriterExecutionMatches, newRun, incoming.agentPresence?.process) this.clearAssistantMessageRetry(enriched.paneKey) this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) if (!this.writeLegacyStatusRow(enriched)) { @@ -276,16 +270,10 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA runningNonAgentTask === undefined ? unpairedPayload : { ...unpairedPayload, claudeRunningNonAgentTask: runningNonAgentTask } - const enriched = deriveCliWriterTopology( - { - ...this.attachStatusTiming(pairedPayload, now, observedAt), - observation: this.stampObservation(pairedPayload, origin, observedAt ?? now) - }, - rowBefore, - this.cliWriterExecutionMatches, - newRun, - incoming.agentPresence?.process - ) + const enriched = deriveCliWriterTopology({ + ...this.attachStatusTiming(pairedPayload, now, observedAt), + observation: this.stampObservation(pairedPayload, origin, observedAt ?? now) + }, rowBefore, this.cliWriterExecutionMatches, newRun, incoming.agentPresence?.process) if ( typeof enriched.payload.turnCompletedAt === 'number' && Number.isFinite(enriched.payload.turnCompletedAt) diff --git a/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts index 8a373616d812..9db9b326d37c 100644 --- a/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts +++ b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts @@ -267,17 +267,13 @@ it('accepts only a complete actual-shell Git Bash launch chain', async () => { const shellPath = 'C:\\Program Files\\Git\\bin\\bash.exe' const handle = createHandle(false, shellPath) members.mockReturnValue(new Set([100, 102])) - readWindows.mockResolvedValue([ - { pid: 100, ppid: 1, name: 'bash.exe', creationTimeMs: 1 }, - { pid: 102, ppid: 100, name: 'bash.exe', creationTimeMs: 2 } - ]) + readWindows.mockResolvedValue([{ pid: 100, ppid: 1, name: 'bash.exe', creationTimeMs: 1 }, + { pid: 102, ppid: 100, name: 'bash.exe', creationTimeMs: 2 }]) expect(await handle.inspectChildProcesses?.()).toBe('no-children') readWindows.mockResolvedValue([{ pid: 100, ppid: 1, name: 'bash.exe' }]) expect(await handle.inspectChildProcesses?.()).toBe('unverifiable') - readWindows.mockResolvedValue([ - { pid: 100, ppid: 1, name: 'bash.exe' }, - { pid: 102, ppid: 100, name: 'agent.exe' } - ]) + readWindows.mockResolvedValue([{ pid: 100, ppid: 1, name: 'bash.exe' }, + { pid: 102, ppid: 100, name: 'agent.exe' }]) expect(await handle.inspectChildProcesses?.()).toBe('children') members.mockReturnValue(new Set([102])) expect(await handle.inspectChildProcesses?.()).toBe('unverifiable') diff --git a/src/main/daemon/terminal-host-process-inspection.test.ts b/src/main/daemon/terminal-host-process-inspection.test.ts index fd7000eb57c2..97b51a062533 100644 --- a/src/main/daemon/terminal-host-process-inspection.test.ts +++ b/src/main/daemon/terminal-host-process-inspection.test.ts @@ -33,38 +33,19 @@ describe('TerminalHost process inspection', () => { } return 'no-children' as const }) - const host = new TerminalHost({ - spawnSubprocess: () => ({ - ...subprocess, - getForegroundProcess: () => 'powershell.exe', - inspectChildProcesses - }) - }) + const host = new TerminalHost({ spawnSubprocess: () => ({ ...subprocess, + getForegroundProcess: () => 'powershell.exe', inspectChildProcesses }) }) try { - const created = await host.createOrAttach({ - sessionId: 'exiting', - cols: 80, - rows: 24, - streamClient: { onData: vi.fn(), onExit: vi.fn() } - }) - await expect( - host.inspectProcess('exiting', { expectedIncarnationId: created.incarnationId }) - ).resolves.toMatchObject({ - foregroundProcess: 'powershell.exe', - childProcessEvidence: 'no-children', - foregroundProcessEvidence: { - verdict: 'unverifiable', - reason: 'windows_ssh_foreground_unavailable' - } - }) + const created = await host.createOrAttach({ sessionId: 'exiting', cols: 80, rows: 24, + streamClient: { onData: vi.fn(), onExit: vi.fn() } }) + await expect(host.inspectProcess('exiting', { expectedIncarnationId: created.incarnationId })) + .resolves.toMatchObject({ foregroundProcess: 'powershell.exe', childProcessEvidence: 'no-children', + foregroundProcessEvidence: { verdict: 'unverifiable', reason: 'windows_ssh_foreground_unavailable' } }) expect(inspectChildProcesses).toHaveBeenCalledOnce() exitDuringRead = true - await expect( - host.inspectProcess('exiting', { expectedIncarnationId: created.incarnationId }) - ).resolves.toMatchObject({ - childProcessEvidence: 'unverifiable', - foregroundProcessEvidence: { verdict: 'unverifiable', reason: 'session_changed' } - }) + await expect(host.inspectProcess('exiting', { expectedIncarnationId: created.incarnationId })) + .resolves.toMatchObject({ childProcessEvidence: 'unverifiable', + foregroundProcessEvidence: { verdict: 'unverifiable', reason: 'session_changed' } }) } finally { await host.dispose() vi.restoreAllMocks() diff --git a/src/main/providers/local-pty-child-process-verdict.test.ts b/src/main/providers/local-pty-child-process-verdict.test.ts index 94f3ba1680da..72db972c80f6 100644 --- a/src/main/providers/local-pty-child-process-verdict.test.ts +++ b/src/main/providers/local-pty-child-process-verdict.test.ts @@ -4,8 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { resolveForegroundMock } = vi.hoisted(() => ({ resolveForegroundMock: vi.fn() })) const jobMembers = vi.hoisted(() => vi.fn()) vi.mock('./windows-pty-job-membership', () => ({ - readWindowsPtyJobProcessIds: jobMembers, - isWindowsPtyJobReadable: () => true + readWindowsPtyJobProcessIds: jobMembers, isWindowsPtyJobReadable: () => true })) const { confirmShellForegroundMock } = vi.hoisted(() => ({ @@ -169,33 +168,15 @@ describe('inspectPtyProviderProcess child-process evidence', () => { it('uses the owned Windows job without a static-name marker, preserving uncertain and replaced panes', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') registerPane('pty-win', 'powershell.exe', 'powershell.exe') - const inspect = () => - inspectPtyProviderProcess(provider, 'pty-win', { - expectedIncarnationId: 'incarnation:pty-win' - }) + const inspect = () => inspectPtyProviderProcess(provider, 'pty-win', { expectedIncarnationId: 'incarnation:pty-win' }) jobMembers.mockReturnValue(new Set([4242])) - await expect(inspect()).resolves.toMatchObject({ - childProcessEvidence: 'no-children', - hasChildProcesses: false - }) + await expect(inspect()).resolves.toMatchObject({ childProcessEvidence: 'no-children', hasChildProcesses: false }) jobMembers.mockReturnValue(new Set([4242, 4243])) - await expect(inspect()).resolves.toMatchObject({ - childProcessEvidence: 'children', - hasChildProcesses: true - }) + await expect(inspect()).resolves.toMatchObject({ childProcessEvidence: 'children', hasChildProcesses: true }) jobMembers.mockReturnValue(null) - await expect(inspect()).resolves.toMatchObject({ - childProcessEvidence: 'unverifiable', - hasChildProcesses: true - }) - jobMembers.mockImplementationOnce(() => { - registerPane('pty-win', 'powershell.exe', 'powershell.exe') - return new Set([4242]) - }) - await expect(inspect()).resolves.toMatchObject({ - childProcessEvidence: 'unverifiable', - hasChildProcesses: true - }) + await expect(inspect()).resolves.toMatchObject({ childProcessEvidence: 'unverifiable', hasChildProcesses: true }) + jobMembers.mockImplementationOnce(() => { registerPane('pty-win', 'powershell.exe', 'powershell.exe'); return new Set([4242]) }) + await expect(inspect()).resolves.toMatchObject({ childProcessEvidence: 'unverifiable', hasChildProcesses: true }) }) it('carries unverifiable evidence when the child read fails after foreground inspection', async () => { diff --git a/src/main/providers/local-pty-provider-foreground-process.test.ts b/src/main/providers/local-pty-provider-foreground-process.test.ts index d37e7068bd76..e6e6f40df9fc 100644 --- a/src/main/providers/local-pty-provider-foreground-process.test.ts +++ b/src/main/providers/local-pty-provider-foreground-process.test.ts @@ -160,15 +160,9 @@ describe('LocalPtyProvider', () => { describe('hasChildProcesses', () => { it('fences full inspection with the original PTY incarnation across awaits', async () => { const { id, incarnationId } = await provider.spawn({ cols: 80, rows: 24 }) - await expect( - provider.inspectProcess(id, { expectedIncarnationId: 'replacement' }) - ).resolves.toMatchObject({ childProcessEvidence: 'unverifiable' }) + await expect(provider.inspectProcess(id, { expectedIncarnationId: 'replacement' })).resolves.toMatchObject({ childProcessEvidence: 'unverifiable' }) let resolveScan!: (resolution: { available: boolean; processName: string }) => void - resolveAgentForegroundProcessMock.mockReturnValue( - new Promise((resolve) => { - resolveScan = resolve - }) - ) + resolveAgentForegroundProcessMock.mockReturnValue(new Promise((resolve) => { resolveScan = resolve })) const inspection = provider.inspectProcess(id, { expectedIncarnationId: incarnationId }) exitCb?.({ exitCode: 0 }) resolveScan({ available: true, processName: 'zsh' }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts b/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts index 08307c47565e..6e4f64972502 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-host-permission-gate.ts @@ -37,6 +37,19 @@ export function useNativeChatHostPermissionGate( const text = hostPermissionRefusedText(agentLabel, verdict.kind, requested) return { sendBlockedReason: text, - notice: { key: `host-permission:${verdict.kind}`, kind: 'host', tone: 'warning', text } + notice: { + key: `host-permission:${verdict.kind}`, + kind: 'host', + tone: 'warning', + text, + ...(verdict.kind === 'unverified' + ? { + action: { + label: translate('components.native-chat.hostPermission.checkAgain', 'Check again'), + onClick: verdict.checkAgain + } + } + : {}) + } } } diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts index 673c91cdc708..1ee077538738 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts @@ -1,8 +1,17 @@ // @vitest-environment happy-dom import { act, renderHook } from '@testing-library/react' import { expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { useNativeChatHostPermissionGate } from './use-native-chat-host-permission-gate' +import { structuredSessionNotices } from './native-chat-structured-session-notices' import { useStructuredNativeChatSubmitReveal } from './use-structured-native-chat-submit-reveal' +const permission = vi.hoisted(() => ({ capability: vi.fn(), settings: vi.fn() })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + runtimeEnvironmentSupportsCapability: permission.capability, + callRuntimeRpc: permission.settings +})) + it('reveals before launch Retry, but waits for a successful queue Resume', async () => { const order: string[] = [] const controller = { @@ -61,9 +70,34 @@ it('sends no queued card and retries no start on a host held for its permission } const controller = { respond: vi.fn(async () => null), queuedMessages } const retryLaunch = vi.fn() - const { result } = renderHook(() => - useStructuredNativeChatSubmitReveal(controller, retryLaunch, true) - ) + useAppStore.setState({ + settings: { ...useAppStore.getState().settings!, nativeChatPermissionMode: 'ask' } + }) + permission.capability.mockResolvedValue(false) + permission.settings.mockRejectedValue(new Error('unreachable')) + const { result } = renderHook(() => { + const gate = useNativeChatHostPermissionGate( + { kind: 'environment', environmentId: 'server' }, + 'claude', + 'Claude' + ) + const reveal = useStructuredNativeChatSubmitReveal( + controller, + retryLaunch, + gate.sendBlockedReason !== null + ) + return { + ...reveal, + notices: structuredSessionNotices({ + launch: { lifecycle: 'failed', failure: null, retry: reveal.retryLaunch }, + agentLabel: 'Claude', + sessionError: null, + composerError: null, + hostPermission: gate.notice + }) + } + }) + await vi.waitFor(() => expect(result.current.notices[0]?.action?.label).toBe('Check again')) await act(() => result.current.queuedMessages.resume()) await act(() => result.current.queuedMessages.steer('m1')) @@ -73,4 +107,17 @@ it('sends no queued card and retries no start on a host held for its permission expect(queuedMessages.steerNewest).not.toHaveBeenCalled() act(() => result.current.retryLaunch()) expect(retryLaunch).not.toHaveBeenCalled() + let allow!: (allowed: boolean) => void + permission.capability.mockReturnValue( + new Promise((resolve) => { + allow = resolve + }) + ) + act(() => result.current.notices[0]?.action?.onClick()) + expect(result.current.notices[0]?.action?.label).toBe('Check again') + expect(retryLaunch).not.toHaveBeenCalled() + await act(async () => allow(true)) + expect(result.current.notices[0]?.action?.label).toBe('Retry') + act(() => result.current.notices[0]?.action?.onClick()) + expect(retryLaunch).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/tab-bar/new-tab-menu-chat-focus.ts b/src/renderer/src/components/tab-bar/new-tab-menu-chat-focus.ts deleted file mode 100644 index 42e8aaa811c4..000000000000 --- a/src/renderer/src/components/tab-bar/new-tab-menu-chat-focus.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { requestNativeChatComposerFocus } from '@/components/native-chat/use-native-chat-composer-reveal-focus' - -// The chat a + menu choice opened at once; one menu closes at a time, so one pending tab is enough. -let pendingChatTabId: string | null = null - -export function queueChatFocusAtNewTabMenuClose(tabId: string): void { - pendingChatTabId = tabId -} - -/** The menu's focus trap is gone once it closes, so the chat's composer takes focus now, before - * the next key, rather than a frame later. False when the menu opened no chat. */ -export function focusChatAtNewTabMenuClose(): boolean { - const tabId = pendingChatTabId - pendingChatTabId = null - if (!tabId) { - return false - } - requestNativeChatComposerFocus(tabId) - return true -} diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index cf8cfb134aa8..d166cb562736 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -24,7 +24,6 @@ import type { TabBarItemProjection } from './use-tab-bar-item-projection' import type { TabBarItemActions } from './use-tab-bar-item-actions' import { renderTabBarItems } from './tab-bar-item-surface' import { TabBarStaticCreateMenu } from './tab-bar-static-create-menu' -import { focusChatAtNewTabMenuClose } from './new-tab-menu-chat-focus' import ClientHostedBrowserTabRows from './ClientHostedBrowserTabRows' import type { ClientHostedBrowserRow } from '../../../../shared/client-hosted-browser-rows' @@ -229,9 +228,7 @@ export function renderTabBarSurface({ onCloseAutoFocus={(event) => { // Why: Radix restores focus to the "+" trigger on close, stealing it from the freshly-mounted terminal. event.preventDefault() - if (!focusChatAtNewTabMenuClose()) { - runPendingNewTabMenuFocusAfterClose() - } + runPendingNewTabMenuFocusAfterClose() }} > {!terminalOnly && onOpenEntry ? ( diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index 1000c10bea46..6fcb2b14006a 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -4,7 +4,7 @@ import type { WindowsTerminalCapabilities } from '@/lib/windows-terminal-capabil import { useAppStore } from '../../store' import type { TabAgentLaunchOption } from './tab-agent-launch-options' import { useTabAgentMenuLaunch } from './QuickLaunchButton' -import { queueChatFocusAtNewTabMenuClose } from './new-tab-menu-chat-focus' +import { requestNativeChatComposerFocus } from '@/components/native-chat/use-native-chat-composer-reveal-focus' import { useNewTabAgentMenuRows, type NewTabAgentMenuModel } from './use-new-tab-agent-menu-rows' import type { AgentDetectionTarget } from '@/hooks/useDetectedAgents' import { buildTabAgentLaunchOptionsFromRows } from './tab-agent-launch-options' @@ -96,22 +96,20 @@ export function useTabBarCreateMenuController({ agentMenu.state === 'settled' ? agentMenu.rows : [], commandOverrides ) - const pendingNewTabMenuFocusRef = useRef<(() => void) | null>(null) + const pendingNewTabMenuFocusRef = useRef<{ focus: () => void; synchronous?: true } | null>(null) const pendingNewTabMenuFocusAnimationRef = useRef(null) const pendingNewTabMenuFocusRetryRef = useRef(null) const clearPendingNewTabMenuFocusAnimation = (): void => { - if (pendingNewTabMenuFocusAnimationRef.current === null) { - return + if (pendingNewTabMenuFocusAnimationRef.current !== null) { + cancelAnimationFrame(pendingNewTabMenuFocusAnimationRef.current) + pendingNewTabMenuFocusAnimationRef.current = null } - cancelAnimationFrame(pendingNewTabMenuFocusAnimationRef.current) - pendingNewTabMenuFocusAnimationRef.current = null } const clearPendingNewTabMenuFocusRetry = (): void => { - if (pendingNewTabMenuFocusRetryRef.current === null) { - return + if (pendingNewTabMenuFocusRetryRef.current !== null) { + window.clearTimeout(pendingNewTabMenuFocusRetryRef.current) + pendingNewTabMenuFocusRetryRef.current = null } - window.clearTimeout(pendingNewTabMenuFocusRetryRef.current) - pendingNewTabMenuFocusRetryRef.current = null } const focusNewActiveTerminalWhenReady = ( previousActiveTabId: string | null, @@ -137,20 +135,21 @@ export function useTabBarCreateMenuController({ } const queueNewActiveTerminalFocusAfterNewTabMenuClose = (): void => { const previousActiveTabId = useAppStore.getState().activeTabId - pendingNewTabMenuFocusRef.current = () => { + queueFocusAfterNewTabMenuClose(() => { // Why: paired web/SSH tab creation is async; await the host snapshot's new terminal instead of the pre-existing active tab. focusNewActiveTerminalWhenReady( previousActiveTabId, Date.now() + NEW_TAB_MENU_TERMINAL_FOCUS_TIMEOUT_MS, Date.now() ) - } - } - const queueTerminalTabFocusAfterNewTabMenuClose = (tabId: string): void => { - pendingNewTabMenuFocusRef.current = () => focusTerminalTabSurface(tabId) + }) } - const queueFocusAfterNewTabMenuClose = (focus: () => void): void => { - pendingNewTabMenuFocusRef.current = focus + const queueTerminalTabFocusAfterNewTabMenuClose = (tabId: string): void => + queueFocusAfterNewTabMenuClose(() => focusTerminalTabSurface(tabId)) + const queueFocusAfterNewTabMenuClose = (focus: () => void, synchronous?: true): void => { + clearPendingNewTabMenuFocusAnimation() + clearPendingNewTabMenuFocusRetry() + pendingNewTabMenuFocusRef.current = { focus, synchronous } } const windowsShellEntries = useMemo(() => { return buildWindowsShellMenuEntries({ @@ -235,7 +234,8 @@ export function useTabBarCreateMenuController({ launchSource: 'tab_bar_quick_launch', onFocusTerminal: queueTerminalTabFocusAfterNewTabMenuClose, onFocusHostTerminal: queueNewActiveTerminalFocusAfterNewTabMenuClose, - onFocusChat: queueChatFocusAtNewTabMenuClose + onFocusChat: (tabId) => + queueFocusAfterNewTabMenuClose(() => requestNativeChatComposerFocus(tabId), true) }) const launchAgentFromNewTabEntry = (option: TabAgentLaunchOption): void => { if (option.row) { @@ -247,27 +247,27 @@ export function useTabBarCreateMenuController({ pendingNewTabMenuFocusRef.current = null clearPendingNewTabMenuFocusAnimation() clearPendingNewTabMenuFocusRetry() - if (pendingFocus) { + if (pendingFocus?.synchronous) { + pendingFocus.focus() + } else if (pendingFocus) { pendingNewTabMenuFocusAnimationRef.current = requestAnimationFrame(() => { pendingNewTabMenuFocusAnimationRef.current = null - pendingFocus() + pendingFocus.focus() }) } } - const clearPendingNewTabMenuFocusOnUnmountRef = useRef< - ((node: HTMLDivElement | null) => void) | null - >(null) - if (clearPendingNewTabMenuFocusOnUnmountRef.current === null) { - clearPendingNewTabMenuFocusOnUnmountRef.current = (node: HTMLDivElement | null): void => { + const menuFocusUnmountRef = useRef<((node: HTMLDivElement | null) => void) | null>(null) + if (menuFocusUnmountRef.current === null) { + menuFocusUnmountRef.current = (node: HTMLDivElement | null): void => { if (node !== null) { return } // Why: cancel the delayed focus handoff via this root ref cleanup, avoiding an otherwise cleanup-only React Effect. + pendingNewTabMenuFocusRef.current = null clearPendingNewTabMenuFocusAnimation() clearPendingNewTabMenuFocusRetry() } } - const clearPendingNewTabMenuFocusOnUnmount = clearPendingNewTabMenuFocusOnUnmountRef.current useEffect(() => { if (!newTabMenuOpen) { return @@ -293,7 +293,7 @@ export function useTabBarCreateMenuController({ handleSelectCreateMenuOption, launchAgentFromNewTabEntry, runPendingNewTabMenuFocusAfterClose, - clearPendingNewTabMenuFocusOnUnmount, + clearPendingNewTabMenuFocusOnUnmount: menuFocusUnmountRef.current, queueNewActiveTerminalFocusAfterNewTabMenuClose, queueTerminalTabFocusAfterNewTabMenuClose, queueFocusAfterNewTabMenuClose, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7f48f0fef772..23ad777845ac 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19035,7 +19035,8 @@ "explicitChatSsh": "Chat is not available over SSH. Open the agent in a terminal instead.", "explicitChatUnavailable": "This workspace cannot run the chat. Check the agent installation or open it in a terminal.", "structuredSessionHostDeclined": "Opened {{value0}} in a terminal", - "structuredSessionHostDeclinedDescription": "This server can't run a {{value0}} chat in this workspace.", + "structuredSessionTerminalNotVisible": "Orca opened {{value0}} in a terminal, but it hasn't appeared here yet. Your chat draft is still here.", + "structuredSessionHostDeclinedDescription": "This workspace can't run a {{value0}} chat.", "structuredSessionHostUnreachable": "Could not reach {{value0}}", "structuredSessionHostUnreachableDescription": "Orca did not open a {{value0}} chat. Check the connection to the server and try again.", "hostOutage": { @@ -19060,9 +19061,11 @@ "replace": "Replace" }, "hostPermission": { + "checkAgain": "Check again", + "launchRefused": "Orca did not start a {{value0}} chat", "checking": "Checking this host's chat permissions…", "outdatedHost": "This host runs an older Orca that ignores chat permissions, so {{value0}} would run here without asking. Update Orca on this host to use {{value1}}.", - "unverified": "Orca couldn't confirm this host applies chat permissions. Reconnect to it to send." + "unverified": "Orca couldn't confirm the host's permissions for {{value0}}, so the chat didn't start." } }, "tab": { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts index 1d5d38d4f757..6705d5372e8b 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts @@ -32,6 +32,8 @@ export function launchStructuredAgentFromNewTab(args: { terminalPlan: AgentSessionLaunchPlan, activate?: false ) => { + surface: AgentLaunchSurface + terminalCompletion?: StructuredLaunchTerminal['completion'] promptDeliveryResult?: Promise } | null }): StructuredFromNewTab | null { @@ -60,6 +62,8 @@ export function launchStructuredAgentFromNewTab(args: { ) return { opened: terminal !== null, + ...(terminal?.surface.kind === 'local-terminal' ? { tabId: terminal.surface.tabId } : {}), + ...(terminal?.terminalCompletion ? { completion: terminal.terminalCompletion } : {}), ...(terminal?.promptDeliveryResult ? { promptDeliveryResult: terminal.promptDeliveryResult } : {}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts index 903c7d51d13b..ca38a6145f26 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts @@ -58,15 +58,12 @@ vi.mock('@/lib/telemetry', () => ({ track: vi.fn(), tuiAgentToAgentKind: (agent: string) => agent })) -vi.mock('@/runtime/web-runtime-terminal-creation', () => ({ - createWebRuntimeSessionTerminal: mocks.createWebRuntimeSessionTerminal +vi.mock('@/runtime/web-runtime-terminal-create-operation', () => ({ + createWebRuntimeSessionTerminalResult: mocks.createWebRuntimeSessionTerminal })) vi.mock('@/runtime/web-runtime-session-environment', () => ({ isWebRuntimeSessionActive: vi.fn(() => true) })) -vi.mock('../../../shared/terminal-surface-id', () => ({ - isWebTerminalSurfaceTabId: vi.fn(() => false) -})) describe('launchAgentInNewTab paired web runtime', () => { beforeEach(() => { @@ -78,15 +75,25 @@ describe('launchAgentInNewTab paired web runtime', () => { activeRuntimeEnvironmentId: 'web-runtime' } store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } - mocks.createWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) + mocks.createWebRuntimeSessionTerminal.mockResolvedValue({ + outcome: { status: 'created' }, + hostTabId: 'host-tab::leaf-1' + }) }) - it('delegates agent quick launch to the host runtime', async () => { + it.each([true, false, 'failed'] as const)('delegates agent quick launch (%s)', async (mode) => { + const activate = mode === true + const status = mode === 'failed' ? 'failed' : 'created' + mocks.createWebRuntimeSessionTerminal.mockResolvedValue({ + outcome: { status }, + hostTabId: 'host-tab::leaf-1' + }) store.tabsByWorktree['wt-1'].push({ id: 'local-agent-tab', launchAgent: 'claude' }) const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') const result = launchAgentInNewTab({ requestId: 'request-1', + activate, agent: 'claude', worktreeId: 'wt-1', groupId: 'group-1' @@ -102,15 +109,18 @@ describe('launchAgentInNewTab paired web runtime', () => { worktreeId: 'wt-1', environmentId: 'web-runtime', targetGroupId: 'group-1', - activate: true, + activate, + ...(activate ? {} : { selectWorktree: false }), agentSessionKind: 'fresh', agent: 'claude', viewMode: 'terminal' }) expect(mocks.createTab).not.toHaveBeenCalled() - await Promise.resolve() - // Why: host creation is async, so the user may be viewing another worktree by the time it lands. - expect(mocks.setActiveTabType).toHaveBeenCalledExactlyOnceWith('terminal', 'wt-1') + await expect(result?.terminalCompletion).resolves.toMatchObject({ + outcome: { status }, + ...(status === 'created' ? { tabId: 'web-terminal-host-tab' } : {}) + }) + expect(mocks.setActiveTabType).toHaveBeenCalledTimes(activate && status === 'created' ? 1 : 0) // A local tab closes locally (the host close no-ops), so a launch leaves it alone. expect(mocks.closeTab).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 885be5b6ae87..4b2d935efdb1 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import type { Project } from '../../../shared/project-types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' const mockCreateTab = vi.fn() @@ -47,13 +48,7 @@ const store = { id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' as const } } - ] as { - id: string - localWindowsRuntimePreference: - | { kind: 'inherit-global' } - | { kind: 'windows-host' } - | { kind: 'wsl'; distro: string | null } - }[], + ] as Pick[], repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], sshConnectionStates: new Map([ ['ssh-a', { status: 'connected', remotePlatform: 'linux' }], @@ -142,6 +137,11 @@ const mockCreateWebRuntimeSessionTerminal = vi.fn() const mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft = vi.fn() const mockIsWebRuntimeSessionActive = vi.fn(() => false) +vi.mock('@/runtime/web-runtime-terminal-create-operation', () => ({ + createWebRuntimeSessionTerminalResult: async (args: unknown) => ({ + outcome: await mockCreateWebRuntimeSessionTerminal(args) + }) +})) vi.mock('@/runtime/web-runtime-terminal-creation', () => ({ createWebRuntimeSessionTerminal: mockCreateWebRuntimeSessionTerminal, createWebRuntimeAgentSessionTerminalWithLaunchDraft: @@ -593,9 +593,8 @@ describe('launchAgentInNewTab', () => { } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - launchAgentInNewTab({ requestId: 'request-14', agent: 'claude', worktreeId: 'wt-1' }) - - await Promise.resolve() + await launchAgentInNewTab({ requestId: 'request-14', agent: 'claude', worktreeId: 'wt-1' }) + ?.terminalCompletion expect(mockToastError).toHaveBeenCalledWith( 'Upgrade the remote Orca host before starting or resuming agent sessions.' ) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index c3402eece0d9..0b3062db7e0c 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -101,6 +101,7 @@ export type LaunchAgentInNewTabResult = { pasteDraftAfterLaunch: boolean promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }> /** Structured route only: what the launch did once it settled. The call stays synchronous. */ + terminalCompletion?: StructuredLaunchTerminal['completion'] structuredSettlement?: Promise } | null @@ -210,16 +211,15 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI launchAgentInNewTab({ ...args, beforeSurfaceOpen: undefined, + ...(terminalActivate === false ? { prompt: undefined, activate: false } : {}), requestId: undefined, - agentSessionLaunchPlan: terminalPlan, - ...(terminalActivate === false ? { activate: false } : {}) + agentSessionLaunchPlan: terminalPlan }) }) if (!structured) { args.focusIntent?.cancel() - return null } - return { ...structured, startupPlan } + return structured ? { ...structured, startupPlan } : null } args.focusIntent?.cancel() @@ -248,6 +248,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI }) return { surface: { kind: 'host-published' }, + terminalCompletion: webHostDelivery, startupPlan, pasteDraftAfterLaunch: pasteDraftAfterLaunch !== null, ...(pasteDraftAfterLaunch !== null && promptDelivery === 'submit-after-ready' diff --git a/src/renderer/src/lib/launch-agent-web-host-tab.ts b/src/renderer/src/lib/launch-agent-web-host-tab.ts index ebb01d292083..f4ee71b9ca65 100644 --- a/src/renderer/src/lib/launch-agent-web-host-tab.ts +++ b/src/renderer/src/lib/launch-agent-web-host-tab.ts @@ -1,15 +1,19 @@ +import { createWebRuntimeSessionTerminalResult } from '@/runtime/web-runtime-terminal-create-operation' +import { webTerminalPlacementParentTabId } from '@/runtime/web-session-terminal-placement' import { toast } from 'sonner' import { useAppStore } from '@/store' import { createWebRuntimeAgentSessionTerminal, createWebRuntimeAgentSessionTerminalWithLaunchDraft, - createWebRuntimeSessionTerminal + type createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-terminal-creation' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import type { Tab } from '../../../shared/tab-types' import type { TuiAgent } from '../../../shared/tui-agent' import type { AgentPromptDelivery } from '../../../shared/agent-session-host-authority' import { translate } from '@/i18n/i18n' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import type { AgentTerminalLaunchCompletion } from './structured-agent-session-launch-admission' import { toAgentLaunchPreferences } from '../../../shared/agent-launch-preferences' /** @@ -34,7 +38,7 @@ export function launchAgentInWebHostTab(args: { /** False opens the host's tab without selecting it. */ activate?: boolean onPromptDelivered?: () => void -}): Promise<{ delivered: boolean; failureNotified: boolean }> { +}): Promise<{ delivered: boolean; failureNotified: boolean } & AgentTerminalLaunchCompletion> { const { agent, worktreeId, @@ -59,6 +63,7 @@ export function launchAgentInWebHostTab(args: { environmentId, targetGroupId: groupId, activate: args.activate !== false, + ...(!hasPrompt && args.activate === false ? { selectWorktree: false } : {}), ...(cwd?.trim() ? { cwd } : {}), ...(viewMode ? { viewMode } : {}), agentSessionKind: 'fresh', @@ -83,11 +88,13 @@ export function launchAgentInWebHostTab(args: { const handleCreation = ({ outcome, - promptDelivered + promptDelivered, + hostTabId }: { outcome: Awaited> promptDelivered: boolean - }): { delivered: boolean; failureNotified: boolean } => { + hostTabId?: string + }): { delivered: boolean; failureNotified: boolean } & AgentTerminalLaunchCompletion => { if (outcome.status === 'failed') { toast.error( outcome.message || @@ -97,13 +104,22 @@ export function launchAgentInWebHostTab(args: { { value0: agent } ) ) - return { delivered: false, failureNotified: true } + return { delivered: false, failureNotified: true, outcome } + } + if (args.activate !== false) { + useAppStore.getState().setActiveTabType('terminal', worktreeId) } - useAppStore.getState().setActiveTabType('terminal', worktreeId) if (hasPrompt && promptDelivered) { onPromptDelivered?.() } - return { delivered: promptDelivered, failureNotified: false } + return { + delivered: promptDelivered, + failureNotified: false, + outcome, + ...(hostTabId + ? { tabId: toWebTerminalSurfaceTabId(webTerminalPlacementParentTabId(hostTabId)) } + : {}) + } } if (pastePromptAfterReady !== null) { @@ -124,7 +140,10 @@ export function launchAgentInWebHostTab(args: { launchDraft: prompt }).then((outcome) => handleCreation({ outcome, promptDelivered: outcome.status === 'created' })) } - return createWebRuntimeSessionTerminal(launch).then((outcome) => - handleCreation({ outcome, promptDelivered: outcome.status === 'created' && hasPrompt }) + return createWebRuntimeSessionTerminalResult(launch).then((created) => + handleCreation({ + ...created, + promptDelivered: created.outcome.status === 'created' && hasPrompt + }) ) } diff --git a/src/renderer/src/lib/structured-agent-session-action-launch.ts b/src/renderer/src/lib/structured-agent-session-action-launch.ts index 0f8e42ee1b5c..47772a721b05 100644 --- a/src/renderer/src/lib/structured-agent-session-action-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-action-launch.ts @@ -1,57 +1,25 @@ +import { structuredAgentLabel } from './structured-agent-session-launch-label' +import { translate } from '@/i18n/i18n' +import { + getStructuredLaunchStateBySessionId, + notifyStructuredLaunchListeners +} from './structured-agent-session-launch-registry' +import { focusTerminalTabSurface } from './focus-terminal-tab-surface' import type { Tab } from '../../../shared/tab-types' -import type { - AgentSessionLaunchPlan, - AgentSessionLaunchTarget -} from '@/lib/agent-session-launch-plan' -import type { - StructuredAgentLaunchHandle, - StructuredAgentLaunchHooks, - StructuredAgentLaunchSettlement -} from '@/lib/structured-agent-launch-settlement' +import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' +import type { ProvisionalLaunchArgs } from './structured-agent-session-provisional-tab' +import { insertUnifiedTabAfterAnchor } from './unified-tab-anchor-insertion' import { useAppStore } from '@/store' -import type { ExecutionHostId } from '../../../shared/execution-host' import { notifyHostDeclined, openDeclinedStructuredLaunchTerminal, - type DeclinedStructuredLaunchTerminalOptions, type StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' -import { structuredAgentSessionFocusOwner } from '@/runtime/structured-agent-session-owner' -import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' -import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' import { structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store' import { nativeChatComposerHasUserContent } from '@/components/native-chat/native-chat-pending-attachment-cache' import { StructuredAgentSessionCreateRefusalError } from '@/lib/structured-agent-session-launch-errors' -export type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } - -export type ProvisionalLaunchArgs = { - plan: AgentSessionLaunchPlan - hooks: StructuredAgentLaunchHooks - focusIntent?: NativeChatComposerFocusIntent - target?: AgentSessionLaunchTarget - targetGroupId?: string - /** The tab the chat opens right after, in that tab's group. */ - afterTabId?: string - activate?: boolean - /** Identity-only resumes publish on commit; unknown outcomes keep their recovery tab. */ - deferPublication?: true - /** Lets workspace flows reveal before the chat's tab is owned: before its host is asked, when no - * session id exists yet. */ - beforeOpen?: (sessionId?: string) => boolean | void - /** The terminal the host's "no" opens; a caller without one gets a new agent tab's. `false` - * opens it behind a chat the user already typed in. */ - onHostDeclined?: ( - target: RuntimeClientTarget, - activate?: false - ) => Promise | StructuredLaunchTerminal - /** What that default terminal carries from the caller, e.g. a recipe's saved CLI arguments. */ - declinedTerminal?: DeclinedStructuredLaunchTerminalOptions -} - -export type LaunchOwner = { executionHostId: ExecutionHostId; target: RuntimeClientTarget } - export function openDeclinedTerminal( args: ProvisionalLaunchArgs, worktreeId: string, @@ -59,7 +27,7 @@ export function openDeclinedTerminal( activate?: false ): Promise | StructuredLaunchTerminal { if (args.onHostDeclined) { - return activate === false ? args.onHostDeclined(target, false) : args.onHostDeclined(target) + return args.onHostDeclined(target, activate) } const terminal = { ...args.declinedTerminal, ...(activate === false ? { activate } : {}) } return openDeclinedStructuredLaunchTerminal({ @@ -70,8 +38,7 @@ export function openDeclinedTerminal( }) } -/** A New Chat the user just asked for opens its chat now, so the next key has a composer; its own - * launch asks the host. A prompt to deliver keeps asking first: a "no" hands it to a terminal. */ +/** Only a foreground action without a prompt to deliver may open before admission. */ export function opensAtAction(args: ProvisionalLaunchArgs): boolean { const { plan } = args return ( @@ -82,13 +49,11 @@ export function opensAtAction(args: ProvisionalLaunchArgs): boolean { ) } -/** A definite "no" opens the agent's terminal: in place of a chat nobody typed in yet, where - * asking first used to land, or behind one with typing, which keeps it. A chat the user picked - * by name never becomes a terminal; its own notice says why. */ -async function openTerminalForDeclinedChat( +/** Prepare the replacement before relinquishing the source and its current composer content. */ +export async function openTerminalForDeclinedChat( args: ProvisionalLaunchArgs, worktreeId: string, - owner: LaunchOwner, + target: RuntimeClientTarget, tab: Tab, settlement: StructuredAgentLaunchSettlement ): Promise { @@ -100,45 +65,91 @@ async function openTerminalForDeclinedChat( ) { return settlement } + const source = () => + (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === tab.id && candidate.entityId === tab.entityId + ) + const launchState = getStructuredLaunchStateBySessionId(tab.entityId) + if (!source() || launchState?.cancelled || args.hooks.signal?.aborted) { + return settlement + } const scopeKey = structuredAgentSessionDraftScopeKey(tab.entityId) const typed = nativeChatComposerHasUserContent(scopeKey) - const terminal = await openDeclinedTerminal( - args, - worktreeId, - owner.target, - typed ? false : undefined + const attempt = launchState?.callers + const terminal = await openDeclinedTerminal(args, worktreeId, target, false) + const completed = terminal.completion + ? await terminal.completion + : { + outcome: { status: terminal.opened ? 'created' : 'failed' }, + tabId: terminal.tabId + } + if (completed.outcome.status !== 'created') { + return settlement + } + const state = useAppStore.getState() + const current = source() + const replacement = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === completed.tabId ) - if (!terminal.opened) { + const ownsFailure = + launchState && + getStructuredLaunchStateBySessionId(tab.entityId) === launchState && + launchState.callers === attempt && + attempt?.outcome === 'failed' && + !launchState.cancelled + if (current && ownsFailure) { + launchState.failure = { + ...launchState.failure, + kind: 'refused', + code: 'structured_agent_session_unsupported', + causeText: replacement + ? translate( + 'components.native-chat.structuredSessionHostDeclinedDescription', + "This workspace can't run a {{value0}} chat.", + { value0: structuredAgentLabel(args.plan.agent) } + ) + : translate( + 'components.native-chat.structuredSessionTerminalNotVisible', + "Orca opened {{value0}} in a terminal, but it hasn't appeared here yet. Your chat draft is still here.", + { value0: structuredAgentLabel(args.plan.agent) } + ) + } + notifyStructuredLaunchListeners() + } + if (!replacement) { return settlement } - if (owner.target.kind === 'environment') { + if (!current || args.hooks.signal?.aborted || (launchState && !ownsFailure)) { notifyHostDeclined(args.plan.agent) + return settlement } - if (typed || nativeChatComposerHasUserContent(scopeKey)) { + state.moveUnifiedTabToGroup(replacement.id, current.groupId, { + activate: false, + recordInteraction: false + }) + insertUnifiedTabAfterAnchor(worktreeId, replacement.id, current.id) + const placed = useAppStore.getState() + const group = (placed.groupsByWorktree[worktreeId] ?? []).find((g) => g.id === current.groupId) + if ( + !group || + group.tabOrder.indexOf(replacement.id) !== group.tabOrder.indexOf(current.id) + 1 || + typed || + nativeChatComposerHasUserContent(scopeKey) + ) { + notifyHostDeclined(args.plan.agent, false) return settlement } - useAppStore.getState().closeUnifiedTab(tab.id) - return { kind: 'terminal' } -} - -/** Takes a New Chat opened at once: its chat is the visible tab, and a host's "no" may open the - * agent's terminal. */ -export function adoptActionLaunch( - args: ProvisionalLaunchArgs, - worktreeId: string, - owner: LaunchOwner, - launch: LocalProvisionalLaunch -): LocalProvisionalLaunch { - // The chat is already the visible tab; its host's copy arriving later must not pull focus back. - clearWebSessionFocusIntentIfMatches( - structuredAgentSessionFocusOwner(owner.target), - worktreeId, - `agent-session:${launch.sessionId}` - ) - return { - ...launch, - settlement: launch.settlement.then((settlement) => - openTerminalForDeclinedChat(args, worktreeId, owner, launch.tab, settlement) - ) + const foreground = + placed.activeWorktreeId === worktreeId && + placed.activeGroupIdByWorktree[worktreeId] === current.groupId && + group.activeTabId === current.id + placed.closeUnifiedTab(tab.id) + if (foreground) { + useAppStore.getState().setActiveTab(replacement.entityId) + useAppStore.getState().activateTab(replacement.id) + useAppStore.getState().setActiveTabType('terminal', worktreeId) + focusTerminalTabSurface(replacement.id) } + notifyHostDeclined(args.plan.agent) + return { kind: 'terminal' } } diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts index f864097702e8..2134505c7677 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts @@ -105,7 +105,9 @@ describe('a structured chat launch on a paired server', () => { failureNotified: false }) expect(onHostDeclined).toHaveBeenCalledOnce() - expect(mocks.toastInfo).toHaveBeenCalledOnce() + expect(mocks.toastInfo).toHaveBeenCalledWith(expect.any(String), { + description: expect.stringContaining("can't run") + }) expect(useAppStore.getState().activeWorktreeId).toBe(WORKTREE) expectNoChatCommitted() }) @@ -194,6 +196,37 @@ describe('a structured chat launch on a paired server', () => { launch?.cancel() }) + // An older server ignores the chat's mode and would run Claude with its own bypass flag. + it.each(['refused', 'unverified'] as const)( + 'starts nothing on a server whose permission verdict is %s, and says so once', + async (kind) => { + mocks.createSupport.mockResolvedValue({ supported: true }) + mocks.hostPermission.mockResolvedValue( + kind === 'refused' ? { kind, hostMode: 'full-access' } : { kind } + ) + + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: pairedPlan(), + hooks: {}, + onHostDeclined: vi.fn() + }) + const settlement = await launch?.settlement + + expect(mocks.hostPermission).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'server-1' }, + 'claude' + ) + expect(settlement).toEqual(expect.objectContaining({ kind: 'failed', notified: true })) + expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('Claude'), { + description: expect.stringMatching( + kind === 'unverified' ? /Claude.*didn.t start/ : /older Orca/ + ) + }) + expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() + expectNoChatCommitted() + } + ) + it('opens the chat on the server that admitted it', async () => { mocks.createSupport.mockResolvedValue({ supported: true }) @@ -238,7 +271,7 @@ describe('a structured chat launch on this machine', () => { delivered: true, failureNotified: false }) - expect(onHostDeclined).toHaveBeenCalledExactlyOnceWith({ kind: 'local' }) + expect(onHostDeclined).toHaveBeenCalledExactlyOnceWith({ kind: 'local' }, undefined) expect(mocks.toastInfo).not.toHaveBeenCalled() expect(mocks.toastError).not.toHaveBeenCalled() expectNoChatCommitted() diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.ts index ab211e5e1c0c..5d76580b0428 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.ts @@ -23,11 +23,21 @@ import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-sess import type { LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { readStructuredChatHostPermissionVerdict } from '@/runtime/structured-chat-host-permission' +import { notifyHostPermissionRefused } from './structured-agent-session-launch-permission-notice' import { structuredChatUnavailableText } from '@/lib/structured-agent-session-launch-failure' +import type { WebRuntimeTerminalCreateOutcome } from '@/runtime/web-runtime-session-types' + +export type AgentTerminalLaunchCompletion = { + outcome: WebRuntimeTerminalCreateOutcome + tabId?: string +} /** What a host's "no" opens instead: the terminal the launch would have opened had it known. */ export type StructuredLaunchTerminal = { opened: boolean + tabId?: string + completion?: Promise promptDeliveryResult?: Promise } @@ -65,7 +75,7 @@ function notifyExplicitChatFailure(agent: TuiAgent, reason?: string): void { ) } -export function notifyHostDeclined(agent: TuiAgent): void { +export function notifyHostDeclined(agent: TuiAgent, explain = true): void { const agentLabel = structuredAgentLabel(agent) toast.info( translate( @@ -73,13 +83,15 @@ export function notifyHostDeclined(agent: TuiAgent): void { 'Opened {{value0}} in a terminal', { value0: agentLabel } ), - { - description: translate( - 'components.native-chat.structuredSessionHostDeclinedDescription', - "This server can't run a {{value0}} chat in this workspace.", - { value0: agentLabel } - ) - } + explain + ? { + description: translate( + 'components.native-chat.structuredSessionHostDeclinedDescription', + "This workspace can't run a {{value0}} chat.", + { value0: agentLabel } + ) + } + : undefined ) } @@ -123,7 +135,7 @@ export async function openDeclinedStructuredLaunchTerminal(args: { agent: args.plan.agent, worktreeId: args.worktreeId, ...(args.targetGroupId ? { groupId: args.targetGroupId } : {}), - ...(args.plan.prompt ? { prompt: args.plan.prompt } : {}), + ...(args.terminal?.activate !== false && args.plan.prompt ? { prompt: args.plan.prompt } : {}), ...(args.plan.promptDelivery ? { promptDelivery: args.plan.promptDelivery } : {}), ...(args.plan.onPromptDelivered ? { onPromptDelivered: args.plan.onPromptDelivered } : {}), ...(args.plan.promptKeptByCaller ? { promptKeptByCaller: true as const } : {}), @@ -136,6 +148,8 @@ export async function openDeclinedStructuredLaunchTerminal(args: { }) return { opened: result !== null, + ...(result?.surface.kind === 'local-terminal' ? { tabId: result.surface.tabId } : {}), + ...(result?.terminalCompletion ? { completion: result.terminalCompletion } : {}), ...(result?.promptDeliveryResult ? { promptDeliveryResult: result.promptDeliveryResult } : {}) } } @@ -231,6 +245,23 @@ export function beginHostAdmittedStructuredLaunch(args: { ).then(resolveDelivery, () => resolveDelivery(NOT_DELIVERED)) return terminal.opened ? { kind: 'terminal' } : { kind: 'cancelled', sessionId: null } } + // A host that would run the chat with more access than asked for gets nothing to start. + const permission = paired + ? await readStructuredChatHostPermissionVerdict(args.target, plan.agent) + : { kind: 'allowed' as const } + if (isCancelled()) { + resolveDelivery(NOT_DELIVERED) + return { kind: 'cancelled', sessionId: null } + } + if (permission.kind !== 'allowed') { + notifyHostPermissionRefused(plan.agent, permission.kind) + resolveDelivery({ delivered: false, failureNotified: true }) + return { + kind: 'failed', + error: new Error('structured chat host runs with more access than asked for'), + notified: true + } + } admitted = args.openAdmitted( admission.kind === 'admitted' ? admission.seedOptions : undefined ) diff --git a/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts b/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts index e017e006e581..69e210a74493 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts @@ -1,7 +1,10 @@ +import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { isAgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' +import type { TuiAgent } from '../../../shared/tui-agent' import { nativeChatPermissionModeLabel } from '@/components/native-chat/native-chat-permission-mode-labels' +import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' /** Why a paired host may not run this chat: it would not apply the permission mode asked for. */ export function hostPermissionRefusedText( @@ -12,7 +15,8 @@ export function hostPermissionRefusedText( return kind === 'unverified' ? translate( 'components.native-chat.hostPermission.unverified', - "Orca couldn't confirm this host applies chat permissions. Reconnect to it to send." + "Orca couldn't confirm the host's permissions for {{value0}}, so the chat didn't start.", + { value0: agentLabel } ) : translate( 'components.native-chat.hostPermission.outdatedHost', @@ -25,3 +29,15 @@ export function hostPermissionRefusedText( } ) } + +export function notifyHostPermissionRefused(agent: TuiAgent, kind: 'refused' | 'unverified'): void { + const agentLabel = structuredAgentLabel(agent) + toast.error( + translate( + 'components.native-chat.hostPermission.launchRefused', + 'Orca did not start a {{value0}} chat', + { value0: agentLabel } + ), + { description: hostPermissionRefusedText(agentLabel, kind) } + ) +} diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts index 9a29f96a6bbe..97974b2f8ba3 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts @@ -1,160 +1,207 @@ +// @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { Tab } from '../../../shared/tab-types' +import { useAppStore } from '@/store' +import type * as launchModule from './launch-structured-agent-session' import type { AgentSessionLaunchPlan } from './agent-session-launch-plan' -import type { StructuredAgentLaunchSettlement } from './structured-agent-launch-settlement' - -const mocks = vi.hoisted(() => ({ - createUnifiedTab: vi.fn(), - closeUnifiedTab: vi.fn(), - createSupport: vi.fn(), - requestFocus: vi.fn(), - cancelFocus: vi.fn(), - draftText: { value: '' } -})) - -vi.mock('@/store', () => ({ - useAppStore: { - getState: () => ({ - unifiedTabsByWorktree: {}, - activeGroupIdByWorktree: {}, - createUnifiedTab: mocks.createUnifiedTab, - closeUnifiedTab: mocks.closeUnifiedTab, - setActiveTabType: vi.fn() - }) - } -})) -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: (_target: unknown, method: string) => - method === 'agentSession.createSupport' ? mocks.createSupport() : new Promise(() => undefined) -})) - -vi.mock('@/components/native-chat/native-chat-composer-draft-store', () => ({ - readNativeChatComposerDraft: () => ({ text: mocks.draftText.value, images: [] }), - resolveNativeChatComposerDraftOwner: () => undefined, - structuredAgentSessionDraftScopeKey: (sessionId: string) => `agent-session:${sessionId}` -})) - +import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' import { beginStructuredAgentSessionProvisionalLaunch } from './structured-agent-session-provisional-tab' import { StructuredAgentSessionCreateRefusalError } from './structured-agent-session-launch-errors' +import { act, renderHook } from '@testing-library/react' +import { useNativeChatProvisionalLaunch } from '@/components/native-chat/use-native-chat-provisional-launch' +import { + getStructuredAgentSessionLaunchFailure, + resetStructuredAgentLaunchRegistryForTests +} from './structured-agent-session-launch-registry' +import { structuredSessionNotices } from '@/components/native-chat/native-chat-structured-session-notices' +import type { AgentTerminalLaunchCompletion } from './structured-agent-session-launch-admission' import { addNativeChatPendingAttachment, clearNativeChatPendingAttachmentsForTests } from '@/components/native-chat/native-chat-pending-attachment-cache' -function planSettlingAs( - settlement: StructuredAgentLaunchSettlement, - prompt?: string -): AgentSessionLaunchPlan { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: begin() is the only member this path calls. - return { +const mocks = vi.hoisted(() => ({ text: '', focus: vi.fn(), toast: vi.fn(), launch: vi.fn() })) +vi.mock('./launch-structured-agent-session', async (original) => ({ + ...(await original()), + launchStructuredAgentSession: mocks.launch +})) +vi.mock('sonner', () => ({ toast: { info: mocks.toast, error: vi.fn() } })) +vi.mock('./focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) +vi.mock('@/components/native-chat/native-chat-composer-draft-store', () => ({ + readNativeChatComposerDraft: () => ({ text: mocks.text, images: [] }), + resolveNativeChatComposerDraftOwner: () => undefined, + structuredAgentSessionDraftScopeKey: (id: string) => `agent-session:${id}` +})) + +function planSettlingAs(error: StructuredAgentSessionCreateRefusalError): AgentSessionLaunchPlan { + mocks.launch.mockRejectedValue(error) + return adoptAgentSessionLaunchVerdict({ + requestId: 'action', route: 'structured-native-chat', agent: 'claude', worktreeId: 'wt-1', - executionHostId: 'local', - ...(prompt ? { prompt } : {}), - begin: () => ({ - sessionId: 'claude_1', - executionHostId: 'local', - settlement: Promise.resolve(settlement), - cancel: vi.fn() - }), - launch: vi.fn() - } as unknown as AgentSessionLaunchPlan + executionHostId: 'local' + }) } beforeEach(() => { vi.clearAllMocks() + mocks.text = '' + resetStructuredAgentLaunchRegistryForTests() clearNativeChatPendingAttachmentsForTests() - mocks.createUnifiedTab.mockImplementation( - (worktreeId: string, _type: string, tab: Partial) => ({ ...tab, worktreeId }) - ) - mocks.createSupport.mockResolvedValue({ supported: true }) - mocks.draftText.value = '' + useAppStore.setState({ + activeWorktreeId: 'wt-1', + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + tabsByWorktree: {}, + tabBarOrderByWorktree: {} + }) }) -const declined = (): StructuredAgentLaunchSettlement => ({ - kind: 'failed', - error: new StructuredAgentSessionCreateRefusalError('no', 'structured_agent_session_unsupported') -}) +function openChat(requestedSurface?: 'chat') { + let complete!: (value: AgentTerminalLaunchCompletion) => void + const completion = new Promise((resolve) => { + complete = resolve + }) + const fallback = vi.fn(() => ({ opened: true, completion })) + const launch = beginStructuredAgentSessionProvisionalLaunch({ + plan: { + ...planSettlingAs(new StructuredAgentSessionCreateRefusalError('no')), + requestedSurface + }, + hooks: {}, + focusIntent: { request: mocks.focus, cancel: vi.fn() }, + onHostDeclined: fallback + }) + const chat = launch?.tab + if (!chat) { + throw new Error('No immediate chat') + } + return { launch, chat, fallback, complete } +} describe('a provisional structured chat tab', () => { - it('opens a New Chat at once and focuses it; the launch asks the host itself', async () => { - const launch = beginStructuredAgentSessionProvisionalLaunch({ - plan: planSettlingAs({ kind: 'structured', sessionId: 'claude_1' }), - hooks: {}, - focusIntent: { request: mocks.requestFocus, cancel: mocks.cancelFocus }, - targetGroupId: 'group-2' + it.each([ + 'empty', + 'typed-before', + 'typed-during', + 'pending-chip', + 'failed', + 'unreconciled', + 'closed', + 'explicit', + 'away-before', + 'away-during', + 'closed-during' + ])('owns the destination through terminal completion: %s', async (scenario) => { + if (scenario === 'typed-before') { + mocks.text = 'keep this' + } + const { launch, chat, fallback, complete } = openChat( + scenario === 'explicit' ? 'chat' : undefined + ) + expect(mocks.focus).toHaveBeenCalledWith(chat.id) + const view = renderHook(() => { + const current = useNativeChatProvisionalLaunch('wt-1', chat.entityId) + return structuredSessionNotices({ + launch: current, + agentLabel: 'Claude', + sessionError: null, + composerError: null + }) }) - - expect(mocks.createUnifiedTab).toHaveBeenCalledWith( - 'wt-1', - 'agent-session', - expect.objectContaining({ executionHostId: 'local', targetGroupId: 'group-2' }) + const away = () => { + useAppStore.getState().createUnifiedTab('wt-2', 'agent-session', { entityId: 'other' }) + useAppStore.setState({ activeWorktreeId: 'wt-2', activeTabType: 'agent-session' }) + } + if (scenario === 'away-before') { + away() + } + if (scenario === 'closed') { + useAppStore.getState().closeUnifiedTab(chat.id) + } + await vi.waitFor(() => + expect( + scenario === 'closed' || scenario === 'explicit' || fallback.mock.calls.length + ).toBeTruthy() ) - expect(mocks.requestFocus).toHaveBeenCalledExactlyOnceWith('structured-agent-session-claude_1') - await expect(launch?.settlement).resolves.toEqual({ kind: 'structured', sessionId: 'claude_1' }) - expect(mocks.createSupport).not.toHaveBeenCalled() - }) - - it.each([ - ['nobody typed: its terminal opens in its place', '', false, undefined, 'front'], - [ - 'typed in: the chat keeps it, its terminal opens behind', - 'half a thought', - false, - undefined, - 'back' - ], - ['pasting an image: the chat keeps it', '', true, undefined, 'back'], - ['picked as a chat: it stays a chat and says why', '', false, 'chat' as const, null] - ])( - 'a New Chat its host refuses, %s', - async (_name, typed, pasting, requestedSurface, terminal) => { - const onHostDeclined = vi.fn(() => ({ opened: true })) - const launch = beginStructuredAgentSessionProvisionalLaunch({ - plan: { ...planSettlingAs(declined()), ...(requestedSurface ? { requestedSurface } : {}) }, - hooks: {}, - focusIntent: { request: mocks.requestFocus, cancel: mocks.cancelFocus }, - onHostDeclined + if (scenario === 'closed' || scenario === 'explicit') { + await launch?.settlement + expect(fallback).not.toHaveBeenCalled() + view.unmount() + return + } + expect(useAppStore.getState().unifiedTabsByWorktree['wt-1']).toContainEqual(chat) + // Move the source while its host is preparing the terminal, then invest in the composer. + const sibling = useAppStore.getState().createUnifiedTab('wt-1', 'terminal', { + entityId: 'sibling', + split: { sourceGroupId: chat.groupId, splitDirection: 'right', activate: true } + }) + const groupId = sibling.groupId + useAppStore.getState().moveUnifiedTabToGroup(chat.id, groupId) + useAppStore.getState().reorderUnifiedTabs(groupId, [chat.id, sibling.id]) + if (scenario === 'typed-during') { + mocks.text = 'new thought' + } + if (scenario === 'pending-chip') { + addNativeChatPendingAttachment(`agent-session:${chat.entityId}`, { + id: 'paste', + path: '', + pending: true }) - mocks.draftText.value = typed - if (pasting) { - addNativeChatPendingAttachment('agent-session:claude_1', { - id: 'paste-1', - path: '', - pending: true - }) - } - - await expect(launch?.settlement).resolves.toMatchObject({ - kind: terminal === 'front' ? 'terminal' : 'failed' + } + if (scenario === 'away-during') { + away() + } + if (scenario === 'closed-during') { + useAppStore.getState().closeUnifiedTab(chat.id) + } + const priorFailure = getStructuredAgentSessionLaunchFailure('wt-1', chat.entityId) + const selected = useAppStore.getState().getActiveTab('wt-2') + const terminal = + scenario === 'failed' || scenario === 'unreconciled' + ? null + : useAppStore + .getState() + .createUnifiedTab('wt-1', 'terminal', { entityId: 'replacement', activate: false }) + await act(async () => { + complete({ + outcome: + scenario === 'failed' ? { status: 'failed', message: 'offline' } : { status: 'created' }, + tabId: terminal?.id ?? 'missing' }) - expect(onHostDeclined.mock.calls).toEqual( - terminal === 'back' ? [[{ kind: 'local' }, false]] : terminal ? [[{ kind: 'local' }]] : [] + await launch?.settlement + }) + const state = useAppStore.getState() + const tabs = state.unifiedTabsByWorktree['wt-1'] ?? [] + expect(tabs.some((t) => t.id === chat.id)).toBe( + !['empty', 'away-before', 'away-during', 'closed-during'].includes(scenario) + ) + if (terminal) { + const order = state.groupsByWorktree['wt-1']?.find((g) => g.id === groupId)?.tabOrder + expect(order).toEqual( + scenario === 'closed-during' + ? [sibling.id, terminal.id] + : ['empty', 'away-before', 'away-during'].includes(scenario) + ? [terminal.id, sibling.id] + : [chat.id, terminal.id, sibling.id] ) - expect(mocks.closeUnifiedTab).toHaveBeenCalledTimes(terminal === 'front' ? 1 : 0) + expect(mocks.toast).toHaveBeenCalledOnce() } - ) - - it('asks first for a launch that carries a prompt, so a "no" can hand it on', async () => { - const launch = beginStructuredAgentSessionProvisionalLaunch({ - plan: planSettlingAs({ kind: 'structured', sessionId: 'claude_1' }, 'review this'), - hooks: {}, - focusIntent: { request: mocks.requestFocus, cancel: mocks.cancelFocus }, - targetGroupId: 'group-2' - }) - - expect(launch?.tab).toBeNull() - expect(mocks.createUnifiedTab).not.toHaveBeenCalled() - expect(mocks.requestFocus).not.toHaveBeenCalled() - await expect(launch?.settlement).resolves.toEqual({ kind: 'structured', sessionId: 'claude_1' }) - expect(mocks.createSupport).toHaveBeenCalledOnce() - expect(mocks.createUnifiedTab).toHaveBeenCalledWith( - 'wt-1', - 'agent-session', - expect.objectContaining({ executionHostId: 'local', targetGroupId: 'group-2' }) + expect(view.result.current[0]?.text ?? '').not.toMatch(/open the agent|open it in/i) + if (scenario === 'unreconciled') { + expect(getStructuredAgentSessionLaunchFailure('wt-1', chat.entityId)).not.toBe(priorFailure) + expect(view.result.current[0]?.text).toContain("hasn't appeared") + expect(mocks.toast).not.toHaveBeenCalled() + } + if (scenario.startsWith('away-')) { + expect(state.activeWorktreeId).toBe('wt-2') + expect(state.activeTabType).toBe('agent-session') + expect(state.getActiveTab('wt-2')).toEqual(selected) + } + view.unmount() + expect(mocks.text).toBe( + scenario === 'typed-before' ? 'keep this' : scenario === 'typed-during' ? 'new thought' : '' ) - expect(mocks.requestFocus).toHaveBeenCalledExactlyOnceWith('structured-agent-session-claude_1') - expect(mocks.cancelFocus).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index bf1de3ad361d..0294e8f551f8 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -1,3 +1,4 @@ +import type { Tab } from '../../../shared/tab-types' import type { AgentSessionLaunchPlan, AgentSessionLaunchTarget @@ -10,6 +11,8 @@ import { useAppStore } from '@/store' import type { ExecutionHostId } from '../../../shared/execution-host' import { beginHostAdmittedStructuredLaunch, + type DeclinedStructuredLaunchTerminalOptions, + type StructuredLaunchTerminal, type HostAdmittedStructuredLaunch } from '@/lib/structured-agent-session-launch-admission' import { @@ -18,11 +21,9 @@ import { } from '@/runtime/structured-agent-session-owner' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { - adoptActionLaunch, + openTerminalForDeclinedChat, openDeclinedTerminal, - opensAtAction, - type LocalProvisionalLaunch, - type ProvisionalLaunchArgs + opensAtAction } from './structured-agent-session-action-launch' import { holdEmptyWorkspaceDefaultSurfaceForLaunch } from './empty-workspace-default-surface-claims' import { @@ -31,6 +32,8 @@ import { } from './structured-agent-session-launch-surface' export { openStructuredAgentSessionProvisionalTab } from './structured-agent-session-launch-surface' +type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } + /** A chat's tab opens once its host admits it; a launch with no host to ask takes the direct path. */ export type StructuredAgentSessionProvisionalLaunch = | LocalProvisionalLaunch @@ -59,6 +62,29 @@ function launchHooks(args: ProvisionalLaunchArgs): StructuredAgentLaunchHooks { return args.activate === false ? { ...args.hooks, activate: false } : args.hooks } +export type ProvisionalLaunchArgs = { + plan: AgentSessionLaunchPlan + hooks: StructuredAgentLaunchHooks + focusIntent?: NativeChatComposerFocusIntent + target?: AgentSessionLaunchTarget + targetGroupId?: string + /** The tab the chat opens right after, in that tab's group. */ + afterTabId?: string + activate?: boolean + /** Identity-only resumes publish on commit; unknown outcomes keep their recovery tab. */ + deferPublication?: true + /** Lets workspace flows reveal before the chat's tab is owned: before its host is asked, when no + * session id exists yet. */ + beforeOpen?: (sessionId?: string) => boolean | void + /** The terminal the host's "no" opens; a caller without one gets a new agent tab's. */ + onHostDeclined?: ( + target: RuntimeClientTarget, + activate?: false + ) => Promise | StructuredLaunchTerminal + /** What that default terminal carries from the caller, e.g. a recipe's saved CLI arguments. */ + declinedTerminal?: DeclinedStructuredLaunchTerminalOptions +} + /** Binds the launch to a chat tab: at once for a New Chat the user asked for, otherwise once its * host, this machine included, admits the chat. */ export function beginStructuredAgentSessionProvisionalLaunch( @@ -102,6 +128,7 @@ export function beginStructuredAgentSessionProvisionalLaunch( const launch = open( beginLocalProvisionalLaunch({ ...args, + hooks: { ...args.hooks, activate: false }, target: { ...args.target, worktreeId, executionHostId: owner.executionHostId }, beforeOpen: undefined }) @@ -109,7 +136,13 @@ export function beginStructuredAgentSessionProvisionalLaunch( if (!launch || launch.tab === null) { return launch } - return adoptActionLaunch(args, worktreeId, owner, { ...launch, tab: launch.tab }) + const tab = launch.tab + return { + ...launch, + settlement: launch.settlement.then((settlement) => + openTerminalForDeclinedChat(args, worktreeId, owner.target, tab, settlement) + ) + } } if (args.deferPublication && !args.target?.surfaceAddress) { args = { diff --git a/src/renderer/src/runtime/structured-chat-host-permission.test.ts b/src/renderer/src/runtime/structured-chat-host-permission.test.ts index b7c3b589d862..87ab6e06f2c1 100644 --- a/src/renderer/src/runtime/structured-chat-host-permission.test.ts +++ b/src/renderer/src/runtime/structured-chat-host-permission.test.ts @@ -118,13 +118,13 @@ describe('the permission verdict a chat holds', () => { mocks.contactListeners.length = 0 mocks.supportsCapability.mockResolvedValue(true) const { result } = renderHook(() => useStructuredChatHostPermissionVerdict(PAIRED, 'claude')) - expect(result.current).toEqual({ kind: 'checking' }) - await vi.waitFor(() => expect(result.current).toEqual({ kind: 'allowed' })) + expect(result.current).toMatchObject({ kind: 'checking' }) + await vi.waitFor(() => expect(result.current).toMatchObject({ kind: 'allowed' })) // The host came back as an older build: its answer is still on the way. mocks.supportsCapability.mockReturnValue(new Promise(() => undefined)) act(() => mocks.contactListeners.forEach((listener) => listener())) - expect(result.current).toEqual({ kind: 'checking' }) + expect(result.current).toMatchObject({ kind: 'checking' }) }) }) @@ -143,7 +143,7 @@ describe('a held chat', () => { await act(async () => { await vi.advanceTimersByTimeAsync(30_000) }) - expect(result.current).toEqual({ kind: 'allowed' }) + expect(result.current).toMatchObject({ kind: 'allowed' }) } finally { vi.useRealTimers() } diff --git a/src/renderer/src/runtime/structured-chat-host-permission.ts b/src/renderer/src/runtime/structured-chat-host-permission.ts index e6338a3738b3..71a411bc0d30 100644 --- a/src/renderer/src/runtime/structured-chat-host-permission.ts +++ b/src/renderer/src/runtime/structured-chat-host-permission.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { agentChatPermissionModes } from '../../../shared/agent-chat-permission-mode' import { chatPermissionHostVerdict, @@ -67,7 +67,7 @@ export async function readStructuredChatHostPermissionVerdict( export function useStructuredChatHostPermissionVerdict( target: RuntimeClientTarget, agent: string -): StructuredChatHostPermissionVerdict | { kind: 'checking' } { +): (StructuredChatHostPermissionVerdict | { kind: 'checking' }) & { checkAgain: () => void } { const gated = target.kind === 'environment' && agentChatPermissionModes(agent) !== null const environmentId = target.kind === 'environment' ? target.environmentId : null const requested = useAppStore((state) => state.settings?.nativeChatPermissionMode) @@ -81,6 +81,7 @@ export function useStructuredChatHostPermissionVerdict( } | null>(null) // Not in the key: a held chat stays held while it asks again. const [recheck, setRecheck] = useState(0) + const checkAgain = useCallback(() => setRecheck((n) => n + 1), []) const held = answer?.key === key && answer.verdict.kind !== 'allowed' useEffect(() => { if (!held) { @@ -114,7 +115,7 @@ export function useStructuredChatHostPermissionVerdict( } }, [agent, contact, environmentId, gated, key, recheck, requested]) if (!gated) { - return { kind: 'allowed' } + return { kind: 'allowed', checkAgain } } - return answer?.key === key ? answer.verdict : { kind: 'checking' } + return { ...(answer?.key === key ? answer.verdict : { kind: 'checking' as const }), checkAgain } } diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts index 5c646f56f11e..ac951fbe9b0c 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-workspace-routing.test.ts @@ -261,7 +261,7 @@ describe('web-runtime-session terminal workspace routing', () => { it('does not touch the host selection when the caller opted out of selecting', async () => { const selection: MutableSelection = { - activeWorktreeId: WORKTREE_ID, + activeWorktreeId: 'repo::/other', activeWorkspaceExecutionHostId: 'local' } stubSelectableWorkspaceStore(selection) @@ -276,6 +276,7 @@ describe('web-runtime-session terminal workspace routing', () => { }) expect(mocks.setActiveWorktree).not.toHaveBeenCalled() + expect(selection.activeWorktreeId).toBe('repo::/other') expect(selection.activeWorkspaceExecutionHostId).toBe('local') }) }) From bb295d99ed4630e7c4d40bd071ab354e177760bc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:02:47 -0700 Subject: [PATCH 20/27] test: type launcher projects without an assertion --- src/renderer/src/lib/launch-agent-in-new-tab.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 4b2d935efdb1..7c2dd8df8888 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -23,6 +23,10 @@ const mockWaitForAgentReady = vi.fn() const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const initialProjects: Pick[] = [ + { id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } } +] + const store = { activeRepoId: 'repo-1', activeWorktreeId: 'wt-1', @@ -43,12 +47,7 @@ const store = { { model?: string; valuesByModel?: Record> } > }, - projects: [ - { - id: 'repo-1', - localWindowsRuntimePreference: { kind: 'inherit-global' as const } - } - ] as Pick[], + projects: initialProjects, repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], sshConnectionStates: new Map([ ['ssh-a', { status: 'connected', remotePlatform: 'linux' }], From 1ec3793e0ea72adad7a64531a3ac22a061957158 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:06:57 -0700 Subject: [PATCH 21/27] fix: preserve matching refusal details in terminal completion --- .../src/lib/structured-agent-session-action-launch.ts | 4 +++- .../src/lib/structured-agent-session-provisional-tab.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/lib/structured-agent-session-action-launch.ts b/src/renderer/src/lib/structured-agent-session-action-launch.ts index 47772a721b05..b2eef730f3c8 100644 --- a/src/renderer/src/lib/structured-agent-session-action-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-action-launch.ts @@ -99,7 +99,9 @@ export async function openTerminalForDeclinedChat( !launchState.cancelled if (current && ownsFailure) { launchState.failure = { - ...launchState.failure, + ...(launchState.failure?.code === 'structured_agent_session_unsupported' + ? launchState.failure + : {}), kind: 'refused', code: 'structured_agent_session_unsupported', causeText: replacement diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index 0294e8f551f8..c8b0ebb16c0a 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -1,3 +1,4 @@ +import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' import type { Tab } from '../../../shared/tab-types' import type { AgentSessionLaunchPlan, From 1838f9a973458bd256a28d9c20d5e7a290a3d630 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:57:55 -0700 Subject: [PATCH 22/27] fix: preserve fallback terminal placement and appearance --- ...ructured-native-chat-submit-reveal.test.ts | 11 +++ src/renderer/src/i18n/locales/en.json | 4 +- .../src/lib/launch-agent-in-new-tab.ts | 17 +++-- .../launch-structured-agent-session.test.ts | 9 ++- .../structured-agent-session-action-launch.ts | 16 +++- ...red-agent-session-launch-admission.test.ts | 2 +- ...ructured-agent-session-launch-admission.ts | 2 +- ...-agent-session-launch-permission-notice.ts | 2 +- ...ured-agent-session-provisional-tab.test.ts | 73 +++++++++++++++++-- .../slices/tabs-label-and-pin-state.test.ts | 14 +++- .../store/slices/tabs/tabs-label-actions.ts | 12 +-- .../store/slices/tabs/tabs-slice-contract.ts | 10 ++- .../src/store/terminals/terminal-actions.ts | 2 +- .../terminals/terminal-tab-appearance.ts | 4 +- .../terminal-tab-layout-owner-actions.ts | 4 +- 15 files changed, 141 insertions(+), 41 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts index 1ee077538738..2390979b6f32 100644 --- a/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts +++ b/src/renderer/src/components/native-chat/use-structured-native-chat-submit-reveal.test.ts @@ -88,6 +88,13 @@ it('sends no queued card and retries no start on a host held for its permission ) return { ...reveal, + publishedNotices: structuredSessionNotices({ + launch: { lifecycle: 'published', failure: null, retry: reveal.retryLaunch }, + agentLabel: 'Claude', + sessionError: null, + composerError: null, + hostPermission: gate.notice + }), notices: structuredSessionNotices({ launch: { lifecycle: 'failed', failure: null, retry: reveal.retryLaunch }, agentLabel: 'Claude', @@ -99,6 +106,10 @@ it('sends no queued card and retries no start on a host held for its permission }) await vi.waitFor(() => expect(result.current.notices[0]?.action?.label).toBe('Check again')) + expect(result.current.publishedNotices[0]).toMatchObject({ + text: "Orca couldn't confirm the host's permissions for Claude.", + action: { label: 'Check again' } + }) await act(() => result.current.queuedMessages.resume()) await act(() => result.current.queuedMessages.steer('m1')) expect(result.current.queuedMessages.steerNewest()).toBe(false) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 23ad777845ac..ca412d6c3e01 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -19035,7 +19035,7 @@ "explicitChatSsh": "Chat is not available over SSH. Open the agent in a terminal instead.", "explicitChatUnavailable": "This workspace cannot run the chat. Check the agent installation or open it in a terminal.", "structuredSessionHostDeclined": "Opened {{value0}} in a terminal", - "structuredSessionTerminalNotVisible": "Orca opened {{value0}} in a terminal, but it hasn't appeared here yet. Your chat draft is still here.", + "structuredSessionTerminalNotVisible": "Orca opened {{value0}} in a terminal, but couldn't show it here when the launch finished. Your chat draft was kept.", "structuredSessionHostDeclinedDescription": "This workspace can't run a {{value0}} chat.", "structuredSessionHostUnreachable": "Could not reach {{value0}}", "structuredSessionHostUnreachableDescription": "Orca did not open a {{value0}} chat. Check the connection to the server and try again.", @@ -19065,7 +19065,7 @@ "launchRefused": "Orca did not start a {{value0}} chat", "checking": "Checking this host's chat permissions…", "outdatedHost": "This host runs an older Orca that ignores chat permissions, so {{value0}} would run here without asking. Update Orca on this host to use {{value1}}.", - "unverified": "Orca couldn't confirm the host's permissions for {{value0}}, so the chat didn't start." + "unverified": "Orca couldn't confirm the host's permissions for {{value0}}." } }, "tab": { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 0b3062db7e0c..8e2a5ad6b0fa 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -121,7 +121,7 @@ export function shouldQueueTerminalFocusAfterMenuClose( * * Returns `null` when no startup plan can be built (e.g. a whitespace-only prompt). */ -export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentInNewTabResult { +function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgentInNewTabResult { const { agent, worktreeId, @@ -146,7 +146,6 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI return null } const { resolvedLaunchPlatform, isRemote, queuedShell } = executionContext - const cmdOverrides = store.settings?.agentCmdOverrides ?? {} const effectiveAgentArgs = agentArgs !== undefined ? agentArgs @@ -155,13 +154,12 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' - const workspaceKind = workspaceKindForWorktreeId(worktreeId) // Why: a followup-path agent gets its prompt pasted unsubmitted after start, so route it as a draft. const routePromptDelivery = hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery const startupPlanBase = { agent, - cmdOverrides, + cmdOverrides: store.settings?.agentCmdOverrides ?? {}, platform: resolvedLaunchPlatform, shell: queuedShell, isRemote, @@ -187,7 +185,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, requestedSurface: args.requestedSurface, selectedExecutionHostId: args.selectedExecutionHostId, - workspace: { kind: workspaceKind, worktreeId }, + workspace: { kind: workspaceKindForWorktreeId(worktreeId), worktreeId }, prompt: trimmedPrompt, promptDelivery: routePromptDelivery, tuiCustomization: { cwd: initialCwd }, @@ -195,7 +193,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI ...(args.promptKeptByCaller ? { promptKeptByCaller: true as const } : {}) }) if ('awaited' in route) { - return launchOnceHostAnswered(route, args, startupPlan, launchAgentInNewTab) + return launchOnceHostAnswered(route, args, startupPlan, launchAgentInNewTabInternal) } const { plan } = route if (plan?.route === 'structured-native-chat') { @@ -208,7 +206,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI ...(args.onStructuredHostDeclined ? { onHostDeclined: args.onStructuredHostDeclined } : {}), // The host's "no" opens this same launch as a terminal, with the caller's arguments. openTerminal: (terminalPlan, terminalActivate) => - launchAgentInNewTab({ + launchAgentInNewTabInternal({ ...args, beforeSurfaceOpen: undefined, ...(terminalActivate === false ? { prompt: undefined, activate: false } : {}), @@ -218,8 +216,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI }) if (!structured) { args.focusIntent?.cancel() + return null } - return structured ? { ...structured, startupPlan } : null + return { ...structured, startupPlan } } args.focusIntent?.cancel() @@ -360,3 +359,5 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI ...(promptDeliveryResult ? { promptDeliveryResult } : {}) } } + +export { launchAgentInNewTabInternal as launchAgentInNewTab } diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts index f53e319b0707..84aa2af01a1d 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.test.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -74,10 +74,11 @@ describe('structured agent session launch', () => { await expect( launchStructuredAgentSession(closed, undefined, false, () => true) ).rejects.toThrow('retired before create') - expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ - 'agentSession.createSupport', - 'agentSession.createSupport' - ]) + expect( + vi + .mocked(callStructuredAgentSession) + .mock.calls.some(([, method]) => method === 'agentSession.create') + ).toBe(false) }) it('creates a native session with a host-verifiable launch intent', async () => { diff --git a/src/renderer/src/lib/structured-agent-session-action-launch.ts b/src/renderer/src/lib/structured-agent-session-action-launch.ts index b2eef730f3c8..c04772222007 100644 --- a/src/renderer/src/lib/structured-agent-session-action-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-action-launch.ts @@ -112,7 +112,7 @@ export async function openTerminalForDeclinedChat( ) : translate( 'components.native-chat.structuredSessionTerminalNotVisible', - "Orca opened {{value0}} in a terminal, but it hasn't appeared here yet. Your chat draft is still here.", + "Orca opened {{value0}} in a terminal, but couldn't show it here when the launch finished. Your chat draft was kept.", { value0: structuredAgentLabel(args.plan.agent) } ) } @@ -129,6 +129,10 @@ export async function openTerminalForDeclinedChat( activate: false, recordInteraction: false }) + if (Boolean(current.isPinned) !== Boolean(replacement.isPinned)) { + const setPinned = current.isPinned ? state.pinTab : state.unpinTab + setPinned(replacement.id, { recordInteraction: false }) + } insertUnifiedTabAfterAnchor(worktreeId, replacement.id, current.id) const placed = useAppStore.getState() const group = (placed.groupsByWorktree[worktreeId] ?? []).find((g) => g.id === current.groupId) @@ -145,7 +149,15 @@ export async function openTerminalForDeclinedChat( placed.activeWorktreeId === worktreeId && placed.activeGroupIdByWorktree[worktreeId] === current.groupId && group.activeTabId === current.id - placed.closeUnifiedTab(tab.id) + if (current.customLabel !== null) { + placed.setTabCustomTitle(replacement.entityId, current.customLabel, { + recordInteraction: false + }) + } + if (current.color !== null) { + placed.setTabColor(replacement.entityId, current.color, { recordInteraction: false }) + } + placed.closeUnifiedTab(tab.id, { recordInteraction: false }) if (foreground) { useAppStore.getState().setActiveTab(replacement.entityId) useAppStore.getState().activateTab(replacement.id) diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts index 2134505c7677..be8c2303cc4d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts @@ -219,7 +219,7 @@ describe('a structured chat launch on a paired server', () => { expect(settlement).toEqual(expect.objectContaining({ kind: 'failed', notified: true })) expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('Claude'), { description: expect.stringMatching( - kind === 'unverified' ? /Claude.*didn.t start/ : /older Orca/ + kind === 'unverified' ? /confirm the host's permissions for Claude/ : /older Orca/ ) }) expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled() diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.ts index 5d76580b0428..03d7a3411af1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.ts @@ -24,7 +24,7 @@ import type { LaunchAgentInNewTabArgs } from '@/lib/launch-agent-in-new-tab' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { readStructuredChatHostPermissionVerdict } from '@/runtime/structured-chat-host-permission' -import { notifyHostPermissionRefused } from './structured-agent-session-launch-permission-notice' +import { notifyHostPermissionRefused } from '@/lib/structured-agent-session-launch-permission-notice' import { structuredChatUnavailableText } from '@/lib/structured-agent-session-launch-failure' import type { WebRuntimeTerminalCreateOutcome } from '@/runtime/web-runtime-session-types' diff --git a/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts b/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts index 69e210a74493..0a1d004f0baf 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-permission-notice.ts @@ -15,7 +15,7 @@ export function hostPermissionRefusedText( return kind === 'unverified' ? translate( 'components.native-chat.hostPermission.unverified', - "Orca couldn't confirm the host's permissions for {{value0}}, so the chat didn't start.", + "Orca couldn't confirm the host's permissions for {{value0}}.", { value0: agentLabel } ) : translate( diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts index 97974b2f8ba3..5fa9802c1c1a 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts @@ -138,6 +138,13 @@ describe('a provisional structured chat tab', () => { split: { sourceGroupId: chat.groupId, splitDirection: 'right', activate: true } }) const groupId = sibling.groupId + const pinnedSource = scenario === 'empty' || scenario === 'typed-during' + if (pinnedSource) { + useAppStore.getState().pinTab(chat.id) + useAppStore.getState().pinTab(sibling.id) + useAppStore.getState().setTabCustomLabel(chat.id, 'My work') + useAppStore.getState().setUnifiedTabColor(chat.id, '#ff0000') + } useAppStore.getState().moveUnifiedTabToGroup(chat.id, groupId) useAppStore.getState().reorderUnifiedTabs(groupId, [chat.id, sibling.id]) if (scenario === 'typed-during') { @@ -156,6 +163,10 @@ describe('a provisional structured chat tab', () => { if (scenario === 'closed-during') { useAppStore.getState().closeUnifiedTab(chat.id) } + if (pinnedSource) { + useAppStore.getState().activateTab(chat.id) + useAppStore.getState().setActiveTabType('agent-session', 'wt-1') + } const priorFailure = getStructuredAgentSessionLaunchFailure('wt-1', chat.entityId) const selected = useAppStore.getState().getActiveTab('wt-2') const terminal = @@ -163,7 +174,30 @@ describe('a provisional structured chat tab', () => { ? null : useAppStore .getState() - .createUnifiedTab('wt-1', 'terminal', { entityId: 'replacement', activate: false }) + .createUnifiedTab('wt-1', 'terminal', { id: 'replacement', activate: false }) + if (terminal) { + useAppStore.setState({ + tabsByWorktree: { + 'wt-1': [ + { + id: terminal.entityId, + ptyId: null, + worktreeId: 'wt-1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + } + }) + if (scenario === 'typed-before') { + useAppStore.getState().pinTab(terminal.id) + } + } + const interaction = vi.fn() + useAppStore.setState({ recordFeatureInteraction: interaction }) await act(async () => { complete({ outcome: @@ -172,6 +206,7 @@ describe('a provisional structured chat tab', () => { }) await launch?.settlement }) + expect(interaction).not.toHaveBeenCalled() const state = useAppStore.getState() const tabs = state.unifiedTabsByWorktree['wt-1'] ?? [] expect(tabs.some((t) => t.id === chat.id)).toBe( @@ -187,11 +222,42 @@ describe('a provisional structured chat tab', () => { : [chat.id, terminal.id, sibling.id] ) expect(mocks.toast).toHaveBeenCalledOnce() + expect(state.getTab(terminal.id)?.isPinned ?? false).toBe(pinnedSource) + expect(state.tabsByWorktree['wt-1'][0].isPinned ?? false).toBe(pinnedSource) + if (pinnedSource) { + const replaced = scenario === 'empty' + expect(state.getTab(terminal.id)).toMatchObject({ + customLabel: replaced ? 'My work' : null, + color: replaced ? '#ff0000' : null + }) + expect(state.tabsByWorktree['wt-1'][0]).toMatchObject({ + customTitle: replaced ? 'My work' : null, + color: replaced ? '#ff0000' : null + }) + expect(state.getTab(chat.id)?.customLabel ?? null).toBe(replaced ? null : 'My work') + expect(state.getTab(chat.id)?.color ?? null).toBe(replaced ? null : '#ff0000') + expect(state.activeGroupIdByWorktree['wt-1']).toBe(groupId) + expect(state.groupsByWorktree['wt-1'].find((g) => g.id === groupId)?.activeTabId).toBe( + replaced ? terminal.id : chat.id + ) + expect(state.activeTabType).toBe(replaced ? 'terminal' : 'agent-session') + } } expect(view.result.current[0]?.text ?? '').not.toMatch(/open the agent|open it in/i) if (scenario === 'unreconciled') { expect(getStructuredAgentSessionLaunchFailure('wt-1', chat.entityId)).not.toBe(priorFailure) - expect(view.result.current[0]?.text).toContain("hasn't appeared") + const notice = view.result.current[0]?.text + expect(notice).toContain("couldn't show it here when the launch finished") + act(() => { + useAppStore.getState().createUnifiedTab('wt-1', 'terminal', { + id: 'missing', + activate: false + }) + }) + expect(useAppStore.getState().getTab(chat.id)).not.toBeNull() + expect(fallback).toHaveBeenCalledOnce() + expect(mocks.launch).toHaveBeenCalledOnce() + expect(view.result.current[0]?.text).toBe(notice) expect(mocks.toast).not.toHaveBeenCalled() } if (scenario.startsWith('away-')) { @@ -200,8 +266,5 @@ describe('a provisional structured chat tab', () => { expect(state.getActiveTab('wt-2')).toEqual(selected) } view.unmount() - expect(mocks.text).toBe( - scenario === 'typed-before' ? 'keep this' : scenario === 'typed-during' ? 'new thought' : '' - ) }) }) diff --git a/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts b/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts index f868306b2a2c..a711b61de2f1 100644 --- a/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts +++ b/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts @@ -144,11 +144,19 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { } })) - store.getState().pinTab(tab.id) + const interaction = vi.fn() + store.setState({ recordFeatureInteraction: interaction }) + store.getState().pinTab(tab.id, { recordInteraction: false }) expect(store.getState().tabsByWorktree[WT][0].isPinned).toBe(true) - - store.getState().unpinTab(tab.id) + store.getState().unpinTab(tab.id, { recordInteraction: false }) expect(store.getState().tabsByWorktree[WT][0].isPinned).toBe(false) + store.getState().setTabColor(tab.entityId, '#ff0000', { recordInteraction: false }) + expect(store.getState().tabsByWorktree[WT][0].color).toBe('#ff0000') + expect(interaction).not.toHaveBeenCalled() + store.getState().pinTab(tab.id) + store.getState().unpinTab(tab.id) + store.getState().setTabColor(tab.entityId, null) + expect(interaction).toHaveBeenCalledTimes(3) }) }) }) diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts index 9f6b1a0889c8..876bea533d67 100644 --- a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -206,15 +206,15 @@ export function createTabsLabelActions( } }, - setUnifiedTabColor: (tabId, color) => { + setUnifiedTabColor: (tabId, color, opts) => { const exists = get().getTab(tabId) !== null set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { color }) ?? state) - if (exists) { + if (exists && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') } }, - pinTab: (tabId) => { + pinTab: (tabId, opts) => { const exists = get().getTab(tabId) !== null set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) @@ -247,12 +247,12 @@ export function createTabsLabelActions( } }) mirrorTabPinnedToHost(get(), tabId, true) - if (exists) { + if (exists && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') } }, - unpinTab: (tabId) => { + unpinTab: (tabId, opts) => { const exists = get().getTab(tabId) !== null set((state) => { const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) @@ -284,7 +284,7 @@ export function createTabsLabelActions( } }) mirrorTabPinnedToHost(get(), tabId, false) - if (exists) { + if (exists && opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') } } diff --git a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts index c2e175124826..d31107acf579 100644 --- a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts +++ b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts @@ -125,9 +125,13 @@ export type TabsSlice = { label: string | null, opts?: { recordInteraction?: boolean } ) => void - setUnifiedTabColor: (tabId: string, color: string | null) => void - pinTab: (tabId: string) => void - unpinTab: (tabId: string) => void + setUnifiedTabColor: ( + tabId: string, + color: string | null, + opts?: { recordInteraction?: boolean } + ) => void + pinTab: (tabId: string, opts?: { recordInteraction?: boolean }) => void + unpinTab: (tabId: string, opts?: { recordInteraction?: boolean }) => void closeOtherTabs: (tabId: string) => string[] closeTabsToRight: (tabId: string) => string[] closeTabsToLeft: (tabId: string) => string[] diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 43c63c9883a6..c2dc4b685f85 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -123,7 +123,7 @@ export type TerminalActions = { recordInteraction?: boolean } ) => void - setTabColor: (tabId: string, color: string | null) => void + setTabColor: (tabId: string, color: string | null, opts?: { recordInteraction?: boolean }) => void /** What the tab keeps about the launch that laid out one of its panes; undefined clears it. * `remount` remounts the tab's panes, so one whose spawn was refused spawns again. */ setTabAgentLaunchPane: ( diff --git a/src/renderer/src/store/terminals/terminal-tab-appearance.ts b/src/renderer/src/store/terminals/terminal-tab-appearance.ts index 0962e65591d3..2a68c81ed99c 100644 --- a/src/renderer/src/store/terminals/terminal-tab-appearance.ts +++ b/src/renderer/src/store/terminals/terminal-tab-appearance.ts @@ -27,7 +27,7 @@ export function createTerminalTabAppearanceActions( get().setTabCustomLabel(item.id, title, opts) } }, - setTabColor: (tabId, color) => { + setTabColor: (tabId, color, opts) => { set((s) => { const next = { ...s.tabsByWorktree } for (const wId of Object.keys(next)) { @@ -37,7 +37,7 @@ export function createTerminalTabAppearanceActions( }) const item = findRenamableUnifiedTab(get().unifiedTabsByWorktree, tabId) if (item) { - get().setUnifiedTabColor(item.id, color) + get().setUnifiedTabColor(item.id, color, opts) // Why: tab color is host-authoritative for remote-server tabs; mirror it so it persists instead of reverting on the next snapshot. const state = get() const owningWorktreeId = Object.keys(state.unifiedTabsByWorktree).find((wId) => diff --git a/src/renderer/src/store/terminals/terminal-tab-layout-owner-actions.ts b/src/renderer/src/store/terminals/terminal-tab-layout-owner-actions.ts index a4575bb27d9c..643a9306cc9f 100644 --- a/src/renderer/src/store/terminals/terminal-tab-layout-owner-actions.ts +++ b/src/renderer/src/store/terminals/terminal-tab-layout-owner-actions.ts @@ -95,11 +95,11 @@ export function routeTerminalTabChangesThroughLayoutOwner( (id) => ({ type: 'renameTab', tabId: id, kind: 'custom', title }), () => actions.setTabCustomTitle(tabId, title, opts) ), - setTabColor: (tabId, color) => + setTabColor: (tabId, color, opts) => route( tabId, (id) => ({ type: 'setTabProps', tabId: id, color }), - () => actions.setTabColor(tabId, color) + () => actions.setTabColor(tabId, color, opts) ), setAiVaultTabTitle: (tabId, title) => { const current = terminalTab(get(), tabId)?.aiVaultTitle ?? null From 6496af6cf762c59c6fe584e11c56422c4d1fa5b7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:05:07 -0700 Subject: [PATCH 23/27] test: preserve host launch failure notice during integration --- src/main/github/github-remote-identity-parsing.test.ts | 7 +++---- .../src/lib/launch-agent-in-new-tab-web-runtime.test.ts | 8 +++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/github/github-remote-identity-parsing.test.ts b/src/main/github/github-remote-identity-parsing.test.ts index d65ea534d8a3..9d8ccc4abd3e 100644 --- a/src/main/github/github-remote-identity-parsing.test.ts +++ b/src/main/github/github-remote-identity-parsing.test.ts @@ -212,10 +212,9 @@ describe('SSH certificate org remotes (#30601)', () => { it('does not need an SSH config expansion for an org-id github.com remote', () => { expect(gitHubSshConfigHostAlias(certRemote)).toBeNull() - expect(parseGitHubOwnerRepoWithResolvedSshHostname(certRemote, null)).toEqual({ - owner: 'Acme', - repo: 'app' - }) + expect( + parseGitHubOwnerRepoWithResolvedSshHostname(certRemote, null) + ).toEqual({ owner: 'Acme', repo: 'app' }) }) it('still ignores non-GitHub hosts carrying an org-id user', () => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts index f42814d2cb9c..36d8dcc00458 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-web-runtime.test.ts @@ -86,7 +86,10 @@ describe('launchAgentInNewTab paired web runtime', () => { const activate = mode === true const status = mode === 'failed' ? 'failed' : 'created' mocks.createWebRuntimeSessionTerminal.mockResolvedValue({ - outcome: { status }, + outcome: { + status, + ...(status === 'failed' ? { message: 'The host refused the launch.' } : {}) + }, hostTabId: 'host-tab::leaf-1' }) store.tabsByWorktree['wt-1'].push({ id: 'local-agent-tab', launchAgent: 'claude' }) @@ -123,6 +126,9 @@ describe('launchAgentInNewTab paired web runtime', () => { }) expect(mocks.setActiveTabType).toHaveBeenCalledTimes(activate && status === 'created' ? 1 : 0) expect(mocks.toastError).toHaveBeenCalledTimes(status === 'failed' ? 1 : 0) + if (status === 'failed') { + expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith('The host refused the launch.') + } // A local tab closes locally (the host close no-ops), so a launch leaves it alone. expect(mocks.closeTab).not.toHaveBeenCalled() }) From 3b4777d74dd61b627e4761fd7d60a00e8b33bc58 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:16:52 -0700 Subject: [PATCH 24/27] style: format native chat launch changes after integration --- .../use-native-chat-composer-reveal-focus.ts | 5 ++++- .../use-native-chat-provisional-launch.test.tsx | 17 ++++++++++------- .../structured-agent-session-launch-recovery.ts | 7 +------ 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts index 29f1b88dff38..0eb328a4d696 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-reveal-focus.ts @@ -117,7 +117,10 @@ export function useNativeChatComposerRevealFocus({ const siblingComposer = [...mountedComposers].some( (ownerRef) => ownerRef !== composerRef && ownerRef.current?.contains(active) ) - if ((!shouldPreserveEditableFocus(active) || siblingComposer) && composerRef.current?.focus()) { + if ( + (!shouldPreserveEditableFocus(active) || siblingComposer) && + composerRef.current?.focus() + ) { explicitRef.current = false } setReclaimCount((count) => count + 1) diff --git a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx index 153e133b456c..b12c135c8638 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-provisional-launch.test.tsx @@ -7,12 +7,13 @@ import type * as LaunchIntentModule from '@/lib/launch-structured-agent-session' const mocks = vi.hoisted(() => ({ call: vi.fn<(target: unknown, method: string, params?: unknown) => Promise>(), - launch: vi.fn< - ( - intent: { sessionId: string }, - onSeed?: LaunchIntentModule.StructuredLaunchHostSeedListener - ) => Promise<{ sessionId: string; fence: number }> - >(), + launch: + vi.fn< + ( + intent: { sessionId: string }, + onSeed?: LaunchIntentModule.StructuredLaunchHostSeedListener + ) => Promise<{ sessionId: string; fence: number }> + >(), toastError: vi.fn() })) @@ -209,7 +210,9 @@ describe('a chat pane over its own launch', () => { setOption: restored.result.current.setStructuredOption }) await waitFor(() => - expect(restored.result.current.optionSnapshot.find((entry) => entry.id === 'model')).toMatchObject({ + expect( + restored.result.current.optionSnapshot.find((entry) => entry.id === 'model') + ).toMatchObject({ settable: false, kind: { choices: expect.arrayContaining([expect.objectContaining({ value: 'gpt-5.5' })]) } }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts index ca5512008887..938762366996 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts @@ -78,12 +78,7 @@ export async function recoverPublishedSessionReceipt( ) : undefined if (state.intent.params.firstMessage && !firstMessage) { - return launchStructuredAgentSession( - state.intent, - state.onHostSeed, - true, - () => state.cancelled - ) + return launchStructuredAgentSession(state.intent, state.onHostSeed, true, () => state.cancelled) } if (state.intent.createMessageSupport === true) { publishStructuredAgentSessionCreateHydration(state.intent, history.page, fence) From 2a18d2716510037ab51064d438cd0d815f83d451 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:08:20 -0700 Subject: [PATCH 25/27] fix: disable paired chat attachments until startup completes --- .../native-chat/NativeChatComposer.test.tsx | 12 +++++++++--- .../components/native-chat/NativeChatComposer.tsx | 5 ++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 3fb745919cb5..bfcc34d990aa 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -295,7 +295,7 @@ describe('NativeChatComposer', () => { subscribe: () => () => {} } satisfies SessionOptionsSurface const optionSnapshot = [{ id: 'model' }] as SessionOptionDescriptor[] - render( + const composer = (sessionPending = false) => ( { optionsSurface, optionSnapshot, onError: vi.fn(), - runtime: 'local', + runtime: 'remote', sessionId: 'session-test', - runtimeEnvironmentId: null + runtimeEnvironmentId: 'paired-host', + sessionPending }} /> ) + const { rerender } = render(composer()) expect(mocks.fieldProps?.sessionOptionsSurface).toBe(optionsSurface) expect(mocks.fieldProps?.sessionOptionsSnapshot).toBe(optionSnapshot) expect(mocks.fieldProps?.attachDisabled).toBe(false) + rerender(composer(true)) + expect(mocks.fieldProps?.attachDisabled).toBe(true) + rerender(composer()) + expect(mocks.fieldProps?.attachDisabled).toBe(false) await act(async () => mocks.fieldProps?.onSend?.()) expect(dispatchCommand).toHaveBeenCalledWith('hello') diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 5d254f2d6963..9d8a32a4bbcc 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -375,7 +375,10 @@ const NativeChatComposerPane = forwardRef Date: Tue, 20 Oct 2026 04:05:27 -0700 Subject: [PATCH 26/27] fix(menus): a closing menu no longer takes focus back from what its item opened A New Chat chosen from the New Tab menu kept only the first typed key: the menu stays mounted while it animates out, and Radix focuses its content when the pointer leaves an item, so the remaining keys went to the closed menu. Dropdown and context menu items now cancel that pointer focus while their menu is closing. Trims three hunks back to main's form to stay in budget. --- .../native-chat-structured-session-notices.ts | 16 +++++----- .../src/components/ui/context-menu.tsx | 5 ++++ .../src/components/ui/dropdown-menu.tsx | 5 ++++ .../ui/menu-item-closing-focus.test.ts | 29 +++++++++++++++++++ .../components/ui/menu-item-closing-focus.ts | 14 +++++++++ ...structured-agent-session-launch-options.ts | 10 +++---- ...tructured-agent-session-provisional-tab.ts | 4 +-- 7 files changed, 66 insertions(+), 17 deletions(-) create mode 100644 src/renderer/src/components/ui/menu-item-closing-focus.test.ts create mode 100644 src/renderer/src/components/ui/menu-item-closing-focus.ts diff --git a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts index a1f4cff700d8..c69c72747262 100644 --- a/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts +++ b/src/renderer/src/components/native-chat/native-chat-structured-session-notices.ts @@ -101,15 +101,13 @@ export function structuredSessionNotices({ hostPermission?: NativeChatComposerNotice | null }): NativeChatComposerNotice[] { // A host held for its permission mode already says why nothing starts; Retry returns with it. - const launchNotice = hostPermission - ? null - : nativeChatLaunchNotice({ - lifecycle: launch.lifecycle, - failure: launch.failure, - agentLabel, - onRetry: launch.retry, - claudeSignIn - }) + const launchNotice = nativeChatLaunchNotice({ + lifecycle: hostPermission ? null : launch.lifecycle, + failure: launch.failure, + agentLabel, + onRetry: launch.retry, + claudeSignIn + }) return [ ...(hostPermission ? [hostPermission] : []), ...(availability ? [availability] : []), diff --git a/src/renderer/src/components/ui/context-menu.tsx b/src/renderer/src/components/ui/context-menu.tsx index c57948cde042..f475bb0cb6e5 100644 --- a/src/renderer/src/components/ui/context-menu.tsx +++ b/src/renderer/src/components/ui/context-menu.tsx @@ -4,6 +4,7 @@ import * as ContextMenuPrimitive from 'radix-ui/context-menu' import { cn } from '@/lib/utils' import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape' +import { keepFocusWhileMenuCloses } from './menu-item-closing-focus' function ContextMenu({ ...props }: React.ComponentProps) { return @@ -99,6 +100,8 @@ function ContextMenuItem({ className, inset, variant = 'default', + onPointerMove, + onPointerLeave, ...props }: React.ComponentProps & { inset?: boolean @@ -113,6 +116,8 @@ function ContextMenuItem({ "relative flex cursor-default items-center gap-2 rounded-md px-2 py-[4px] text-[12px] leading-[17px] font-[450] outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", className )} + onPointerMove={keepFocusWhileMenuCloses(onPointerMove)} + onPointerLeave={keepFocusWhileMenuCloses(onPointerLeave)} {...props} /> ) diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx index 4e9dbe0846e4..06a422c01bb8 100644 --- a/src/renderer/src/components/ui/dropdown-menu.tsx +++ b/src/renderer/src/components/ui/dropdown-menu.tsx @@ -4,6 +4,7 @@ import * as DropdownMenuPrimitive from 'radix-ui/dropdown-menu' import { cn } from '@/lib/utils' import { handleImeOverlayEscape } from '@/lib/ime-overlay-escape' +import { keepFocusWhileMenuCloses } from './menu-item-closing-focus' function DropdownMenu({ ...props }: React.ComponentProps) { return @@ -51,6 +52,8 @@ function DropdownMenuItem({ className, inset, variant = 'default', + onPointerMove, + onPointerLeave, ...props }: React.ComponentProps & { inset?: boolean @@ -66,6 +69,8 @@ function DropdownMenuItem({ "relative flex cursor-default items-center gap-2 rounded-md px-2 py-[4px] text-[12px] leading-[17px] font-[450] outline-hidden select-none focus:bg-black/8 dark:focus:bg-white/14 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive! data-[variant=launch]:rounded-[7px] data-[variant=launch]:py-1.5 data-[variant=launch]:leading-5 data-[variant=launch]:font-medium", className )} + onPointerMove={keepFocusWhileMenuCloses(onPointerMove)} + onPointerLeave={keepFocusWhileMenuCloses(onPointerLeave)} {...props} /> ) diff --git a/src/renderer/src/components/ui/menu-item-closing-focus.test.ts b/src/renderer/src/components/ui/menu-item-closing-focus.test.ts new file mode 100644 index 000000000000..20031f2f3024 --- /dev/null +++ b/src/renderer/src/components/ui/menu-item-closing-focus.test.ts @@ -0,0 +1,29 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import type * as React from 'react' +import { keepFocusWhileMenuCloses } from './menu-item-closing-focus' + +function pointerEventOnItem(menuState: 'open' | 'closed'): React.PointerEvent { + const menu = document.createElement('div') + menu.setAttribute('role', 'menu') + menu.setAttribute('data-state', menuState) + const item = menu.appendChild(document.createElement('div')) + const event = { currentTarget: item, preventDefault: vi.fn() } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only currentTarget and preventDefault. + return event as unknown as React.PointerEvent +} + +describe('keepFocusWhileMenuCloses', () => { + // A New Chat chosen from the menu lost every key after the first: the closing menu took focus back. + it('cancels the library pointer focus only while the menu is closing, after the caller runs', () => { + const caller = vi.fn() + const closing = pointerEventOnItem('closed') + keepFocusWhileMenuCloses(caller)(closing) + expect(caller).toHaveBeenCalledWith(closing) + expect(closing.preventDefault).toHaveBeenCalled() + + const open = pointerEventOnItem('open') + keepFocusWhileMenuCloses(undefined)(open) + expect(open.preventDefault).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/ui/menu-item-closing-focus.ts b/src/renderer/src/components/ui/menu-item-closing-focus.ts new file mode 100644 index 000000000000..a93a4922c20e --- /dev/null +++ b/src/renderer/src/components/ui/menu-item-closing-focus.ts @@ -0,0 +1,14 @@ +import type * as React from 'react' + +/** A dismissed menu stays mounted while it animates out; pointer handlers on its items must not + * pull focus back from what the chosen item opened. Cancelling skips Radix's own focus handling. */ +export function keepFocusWhileMenuCloses( + handler: React.PointerEventHandler | undefined +): React.PointerEventHandler { + return (event) => { + handler?.(event) + if (event.currentTarget.closest('[role="menu"]')?.getAttribute('data-state') === 'closed') { + event.preventDefault() + } + } +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index f434bd1f8539..310412881973 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -78,12 +78,10 @@ export function holdStructuredAgentSessionLaunchOption( encoded: string ): Promise | null { const state = getStructuredLaunchStateBySessionId(sessionId) - if ( - !state || - state.cancelled || - state.callers.outcome === 'published' || - state.selection.seedPending - ) { + if (!state || state.cancelled || state.callers.outcome === 'published') { + return null + } + if (state.selection.seedPending) { return null } const replies = repliesFor(state) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index c8b0ebb16c0a..cdf6389ebeb3 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -13,8 +13,8 @@ import type { ExecutionHostId } from '../../../shared/execution-host' import { beginHostAdmittedStructuredLaunch, type DeclinedStructuredLaunchTerminalOptions, - type StructuredLaunchTerminal, - type HostAdmittedStructuredLaunch + type HostAdmittedStructuredLaunch, + type StructuredLaunchTerminal } from '@/lib/structured-agent-session-launch-admission' import { resolveStructuredAgentSessionOwner, From fb898ead02272476f0ea8bc84498bcb1b7b470fe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:16:30 -0700 Subject: [PATCH 27/27] fix(native-chat): host notices wrap instead of cutting off their instructions The paired-permission notice now shows on a New Chat as soon as it opens, and its "Update Orca on this host" instruction was cut off with an ellipsis and no tooltip. Host notices now wrap like every other composer notice. --- .../src/components/native-chat/NativeChatComposerNotices.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx index 6cb4bbbd286a..7af2726ffe84 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerNotices.tsx @@ -79,8 +79,7 @@ function NoticeRow({ notice }: { notice: NativeChatComposerNotice }): React.JSX. />