import { DEFAULT_FILE_RESOURCE_MOUNT_ROOT, type ComposerDraft, type DraftTimelineAnnotation, type EffectiveControlResumeOption, type EffectiveSessionControl, type LatencyMode, OpenGeniApiError, type ReasoningEffort, type ResourceRef, type SaveComposerDraftRequest, type SendMessageInput, type SessionEvent, type SubmitComposerDraftResponse, } from "@opengeni/sdk"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context"; import { COMPOSER_MODEL_UNAVAILABLE_MESSAGE, COMPOSER_ACCOUNT_UNAVAILABLE_MESSAGE, ComposerReconciliationRequiredError, ComposerStateError, ComposerWorkspaceControlUnavailableError, composerSubmissionCanRetry, composerSubmissionErrorMessage, } from "../lib/format"; import { normalizeError, useErrorMessage } from "../lib/error-message"; import { notifyObserver as notifyComposerObserver } from "../lib/notify-observer"; import { useSessionEventTrigger, type SessionEventFeedOptions } from "./internal"; import { useOpenGeniText, type OpenGeniText } from "../messages/runtime"; export type ComposerPolicy = { model: string; reasoningEffort: ReasoningEffort; latencyMode: LatencyMode; }; export type ComposerSendExtras = Omit< SendMessageInput, "text" | "clientEventId" | "annotations" | "model" | "reasoningEffort" | "latencyMode" >; /** Unsent local content handed off from a new-conversation composer. */ export type InitialComposerDraft = { text: string; resources?: ResourceRef[] | undefined; annotations?: DraftTimelineAnnotation[] | undefined; policy?: ComposerPolicy | undefined; }; /** * What an ordinary Send does while the agent is working: `queue` runs it after * the current turn, `steer` has the agent read it as soon as it can, usually * within seconds. */ export type ComposerBusySend = "queue" | "steer"; export type UseComposerOptions = EmbeddedSessionClientOverride & SessionEventFeedOptions & { /** Called synchronously after local submission. Observer errors never change delivery. */ onSubmitted?: ((text: string, input: SendMessageInput) => void) | undefined; /** Called with the accepted wire input. Observer errors never turn acceptance into failure. */ onSent?: ((text: string, input: SendMessageInput) => void) | undefined; /** Called with the exact wire input after a delivery failure. */ onDeliveryError?: | ((error: Error, input: SendMessageInput, delivery: "send" | "steer") => void) | undefined; /** * Non-policy message fields merged into every send. Durable policy belongs * to this composer draft; attachments and credentials may remain host-owned. */ sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined; /** * Fail-closed delivery guard evaluated at send time. Attachment hosts use * this to preserve unresolved upload cards until the operator waits, * retries, or removes them; direct hook callers cannot bypass the UI gate. */ sendBlocked?: (() => boolean) | undefined; /** Latest server-derived workstream control; bound into Send/Steer OCC. */ effectiveControl?: EffectiveSessionControl | null | undefined; /** Immediate user-visible destination for an ordinary Send before admission replies. */ sendDestination?: (() => "chat" | "queue") | undefined; /** * What an ordinary Send does while the agent is working (while * `sendDestination` reports `queue`). `queue` (default) runs the message * after the current turn. `steer` delivers it as a Steer instead: the * agent reads it as soon as it can, usually within seconds (a reply still * being written stops; running tools are not cut off). `sendToQueue` * and `steer` stay explicit either way. * * Under `steer` the person has one Send and nothing to learn: no Queue * choice beside it, no Queue or Steer words (`singleSend`). Set * `offerQueue` to also offer Queue beside Send. */ busySend?: ComposerBusySend | undefined; /** * Under `busySend: "steer"`, also offer Queue beside Send while the agent * works (and on Cmd/Ctrl+Enter), for people who line up follow-ups. Off * by default; under `busySend: "queue"` Steer is always offered. */ offerQueue?: boolean | undefined; /** Disable remote composer-draft reads and writes for embedded hosts. */ draftPersistence?: "durable" | "disabled" | undefined; /** Required explicit authority when durable draft persistence is disabled. */ initialPolicy?: ComposerPolicy | undefined; /** * Seed local unsent content once when this session is mounted/selected. * The real server draft still supplies the revision used for persistence. * A retained uncertain submission takes precedence over this handoff. * An empty seed does not erase an existing server draft. */ initialDraft?: InitialComposerDraft | undefined; }; type ComposerDraftShadow = { text: string; resources: ResourceRef[]; annotations: DraftTimelineAnnotation[]; policy?: ComposerPolicy | undefined; }; function initialComposerShadow( seed: InitialComposerDraft | undefined, ): ComposerDraftShadow | undefined { if (!seed || (!seed.text && !seed.resources?.length && !seed.annotations?.length && !seed.policy)) return undefined; return { text: seed.text, resources: [...(seed.resources ?? [])], annotations: cloneAnnotations(seed.annotations ?? []), ...(seed.policy ? { policy: { ...seed.policy } } : {}), }; } type PendingComposerOperation = { delivery: "send" | "steer"; input: SendMessageInput; draftAtSend: string; resourcesAtSend: ResourceRef[]; annotationsAtSend: DraftTimelineAnnotation[]; /** Latest local text/resources that must survive an uncertain delivery. */ newerShadow: ComposerDraftShadow; clearDraftOnAccept: boolean; /** False after remount when the original input carried secret credentials. */ canRetry: boolean; }; type StoredPendingComposerOperation = Omit & { input: Omit; hasMcpCredentialUpdates: boolean; }; export type ComposerOptimisticMessage = { clientEventId: string; delivery: "send" | "steer"; destination: "chat" | "queue"; text: string; annotations: DraftTimelineAnnotation[]; resources: ResourceRef[]; occurredAt: string; state: "sending" | "queued" | "failed"; turnId?: string | null | undefined; triggerEventId?: string | null | undefined; appliedQueueVersion?: number | null | undefined; error?: string | undefined; /** Bounded support reference retained independently of display copy across remounts. */ correlationId?: string | undefined; /** False for a definitive refusal that requires changing payment or allowance settings. */ retryable?: boolean | undefined; outcomeUnknown?: boolean | undefined; }; type OptimisticSendOperation = ComposerOptimisticMessage & { input: SendMessageInput; draftPayload: SaveComposerDraftRequest | null; /** Latest unsent local draft that must survive this operation and remounts. */ newerShadow: ComposerDraftShadow; canRetry: boolean; }; type StoredOptimisticSendOperation = Omit & { input: Omit; hasMcpCredentialUpdates: boolean; }; const PENDING_COMPOSER_STORAGE_PREFIX = "opengeni.pending-composer.v1:"; const OPTIMISTIC_SEND_STORAGE_PREFIX = "opengeni.optimistic-sends.v1:"; // A remount must not manufacture a new operation while the previous mutation // is still outcome-unknown. Keep only non-credential request fields here; the // mounted hook retains the exact input, including any credential updates. The // safe shadow is also session-scoped so a refresh cannot lose newer edits. const pendingComposerOperations = new Map(); const optimisticSendOperations = new Map(); function pendingComposerOperationKey( workspaceId: string, sessionId: string | null | undefined, ): string | null { return sessionId ? `${workspaceId}\u0000${sessionId}` : null; } function pendingComposerStorage(): Storage | null { try { return typeof window === "undefined" ? null : window.sessionStorage; } catch { return null; } } function pendingComposerStorageKey(key: string): string { return `${PENDING_COMPOSER_STORAGE_PREFIX}${encodeURIComponent(key)}`; } function optimisticSendStorageKey(key: string): string { return `${OPTIMISTIC_SEND_STORAGE_PREFIX}${encodeURIComponent(key)}`; } function restoreOptimisticSendOperations( t: OpenGeniText, key: string | null, ): OptimisticSendOperation[] { if (!key) return []; let stored = optimisticSendOperations.get(key); if (!stored) { const storage = pendingComposerStorage(); try { const parsed: unknown = JSON.parse(storage?.getItem(optimisticSendStorageKey(key)) ?? "[]"); stored = Array.isArray(parsed) ? (parsed.filter( (candidate): candidate is StoredOptimisticSendOperation => typeof candidate === "object" && candidate !== null && typeof candidate.clientEventId === "string" && typeof candidate.text === "string" && typeof candidate.occurredAt === "string" && (candidate.state === "sending" || candidate.state === "failed") && typeof candidate.input === "object" && candidate.input !== null && typeof candidate.input.text === "string" && typeof candidate.hasMcpCredentialUpdates === "boolean", ) as StoredOptimisticSendOperation[]) : []; } catch { stored = []; } } return stored.map(({ hasMcpCredentialUpdates, ...operation }) => ({ ...operation, delivery: "send", destination: operation.destination === "queue" ? "queue" : "chat", newerShadow: operation.newerShadow ?? { text: "", resources: [], annotations: [] }, state: operation.state === "sending" || operation.state === "queued" ? "sending" : operation.state, outcomeUnknown: operation.state === "sending" || operation.state === "queued" ? true : operation.outcomeUnknown, correlationId: restoredCorrelationId(operation.correlationId), error: restoredErrorReference( t, operation.correlationId, hasMcpCredentialUpdates && (operation.state === "sending" || operation.outcomeUnknown) ? composerSubmissionErrorMessage(t, new ComposerReconciliationRequiredError()) : operation.state === "sending" ? t("hooks.useComposer.deliveryWasInterruptedRetryTo") : operation.outcomeUnknown ? t("hooks.useComposer.theRequestCouldNotBe") : operation.error ? composerSubmissionErrorMessage(t, new Error(operation.error)) : undefined, ), retryable: operation.state === "sending" || operation.outcomeUnknown ? true : (operation.retryable ?? (operation.error ? composerSubmissionCanRetry(new Error(operation.error)) : true)), input: operation.input, canRetry: !hasMcpCredentialUpdates, })); } function restoredCorrelationId(value: unknown): string | undefined { return typeof value === "string" && /^[\w.:-]{1,128}$/u.test(value) ? value : undefined; } function restoredErrorReference( t: OpenGeniText, reference: unknown, message: string | undefined, ): string | undefined { const correlationId = restoredCorrelationId(reference); return message && correlationId && message !== COMPOSER_MODEL_UNAVAILABLE_MESSAGE && message !== t("lib.format.modelUnavailable") && message !== COMPOSER_ACCOUNT_UNAVAILABLE_MESSAGE && message !== t("lib.format.accountUnavailable") ? t("hooks.useComposer.messageReferenceCorrelationid", { message, correlationId }) : message; } function rememberOptimisticSendOperations( key: string | null, operations: OptimisticSendOperation[], ): void { if (!key) return; const stored = operations .filter((operation) => operation.state !== "queued") .map((operation) => { const { canRetry: _canRetry, input, ...rest } = operation; const { mcpCredentialUpdates: _credentials, ...safeInput } = input; return { ...rest, input: safeInput, hasMcpCredentialUpdates: input.mcpCredentialUpdates !== undefined, } satisfies StoredOptimisticSendOperation; }); if (stored.length === 0) { optimisticSendOperations.delete(key); try { pendingComposerStorage()?.removeItem(optimisticSendStorageKey(key)); } catch { // Best effort; the in-memory entry is already gone. } return; } optimisticSendOperations.set(key, stored); try { pendingComposerStorage()?.setItem(optimisticSendStorageKey(key), JSON.stringify(stored)); } catch { // Best effort; the in-memory queue still owns this mount. } } function resourceList(value: unknown): value is ResourceRef[] { return ( Array.isArray(value) && value.every((candidate) => { if (typeof candidate !== "object" || candidate === null) return false; const resource = candidate as Record; return resource.kind === "file" ? typeof resource.fileId === "string" : resource.kind === "repository" && typeof resource.uri === "string" && typeof resource.ref === "string"; }) ); } function timelineAnnotationList(value: unknown): value is DraftTimelineAnnotation[] { return ( Array.isArray(value) && value.every((candidate) => { if (typeof candidate !== "object" || candidate === null) return false; const annotation = candidate as Record; const source = annotation.source; if (typeof source !== "object" || source === null) return false; const sourceRecord = source as Record; return ( typeof annotation.id === "string" && typeof annotation.quote === "string" && typeof annotation.note === "string" && typeof sourceRecord.eventId === "string" && typeof sourceRecord.eventType === "string" && typeof sourceRecord.sequence === "number" && (typeof sourceRecord.turnId === "string" || sourceRecord.turnId === null) && typeof sourceRecord.startOffset === "number" && typeof sourceRecord.endOffset === "number" && typeof sourceRecord.contextBefore === "string" && typeof sourceRecord.contextAfter === "string" ); }) ); } function cloneAnnotations( annotations: readonly DraftTimelineAnnotation[], ): DraftTimelineAnnotation[] { return annotations.map((annotation) => ({ ...annotation, source: { ...annotation.source }, })); } function readStoredPendingComposerOperation( key: string | null, ): StoredPendingComposerOperation | null { const storage = key ? pendingComposerStorage() : null; if (!storage || !key) return null; try { const parsed: unknown = JSON.parse(storage.getItem(pendingComposerStorageKey(key)) ?? "null"); if (typeof parsed !== "object" || parsed === null) return null; const record = parsed as Record; const input = record.input; const shadow = record.newerShadow; if ( typeof input !== "object" || input === null || typeof shadow !== "object" || shadow === null ) { return null; } const inputRecord = input as Record; const shadowRecord = shadow as Record; if ( (record.delivery !== "send" && record.delivery !== "steer") || typeof record.draftAtSend !== "string" || !resourceList(record.resourcesAtSend) || (record.annotationsAtSend !== undefined && !timelineAnnotationList(record.annotationsAtSend)) || typeof shadowRecord.text !== "string" || !resourceList(shadowRecord.resources) || (shadowRecord.annotations !== undefined && !timelineAnnotationList(shadowRecord.annotations)) || typeof record.clearDraftOnAccept !== "boolean" || typeof record.hasMcpCredentialUpdates !== "boolean" || typeof inputRecord.text !== "string" || typeof inputRecord.clientEventId !== "string" || "mcpCredentialUpdates" in inputRecord || ("resources" in inputRecord && !resourceList(inputRecord.resources)) || ("annotations" in inputRecord && !timelineAnnotationList(inputRecord.annotations)) ) { return null; } return { ...(record as StoredPendingComposerOperation), annotationsAtSend: timelineAnnotationList(record.annotationsAtSend) ? record.annotationsAtSend : [], newerShadow: { ...(shadow as ComposerDraftShadow), annotations: timelineAnnotationList(shadowRecord.annotations) ? shadowRecord.annotations : [], }, }; } catch { return null; } } function writePendingComposerOperation( key: string, operation: StoredPendingComposerOperation, ): void { const storage = pendingComposerStorage(); if (!storage) return; try { storage.setItem(pendingComposerStorageKey(key), JSON.stringify(operation)); } catch { // Storage is best effort; the in-memory record still protects remounts. } } function restorePendingComposerOperation(key: string | null): PendingComposerOperation | null { const stored = (key && pendingComposerOperations.get(key)) ?? readStoredPendingComposerOperation(key); if (!stored) return null; return { ...stored, input: stored.input, newerShadow: stored.newerShadow ?? { text: stored.draftAtSend, resources: stored.resourcesAtSend, annotations: stored.annotationsAtSend, }, canRetry: !stored.hasMcpCredentialUpdates, }; } function rememberPendingComposerOperation( key: string | null, operation: PendingComposerOperation, ): void { if (!key) return; const { canRetry: _retry, ...safeOperation } = operation; const { input: originalInput, ...withoutInput } = safeOperation; const { mcpCredentialUpdates: _storedMcp, ...safeInput } = originalInput; const stored = { ...withoutInput, input: safeInput, hasMcpCredentialUpdates: operation.input.mcpCredentialUpdates !== undefined, newerShadow: { text: operation.newerShadow.text, resources: [...operation.newerShadow.resources], annotations: cloneAnnotations(operation.newerShadow.annotations), ...(operation.newerShadow.policy ? { policy: { ...operation.newerShadow.policy } } : {}), }, } satisfies StoredPendingComposerOperation; pendingComposerOperations.set(key, stored); writePendingComposerOperation(key, stored); } function forgetPendingComposerOperation(key: string | null): void { if (!key) return; pendingComposerOperations.delete(key); const storage = pendingComposerStorage(); if (!storage) return; try { storage.removeItem(pendingComposerStorageKey(key)); } catch { // Ignore a blocked storage implementation; delivery has already settled. } } function updatePendingComposerShadow( key: string | null, operation: PendingComposerOperation | null, shadow: ComposerDraftShadow, ): PendingComposerOperation | null { if (!key || !operation) return operation; const next = { ...operation, newerShadow: { ...shadow, resources: [...shadow.resources], annotations: cloneAnnotations(shadow.annotations), }, }; rememberPendingComposerOperation(key, next); return next; } export type ComposerState = { value: string; setValue: (value: string) => void; annotations?: DraftTimelineAnnotation[] | undefined; addAnnotation?: ((annotation: DraftTimelineAnnotation) => void) | undefined; updateAnnotation?: ((id: string, note: string) => void) | undefined; removeAnnotation?: ((id: string) => void) | undefined; /** Newly captured annotation the review surface should focus. */ annotationReviewTargetId?: string | null | undefined; clearAnnotationReviewTarget?: (() => void) | undefined; /** Focus an annotation, or the first incomplete note when omitted. */ requestAnnotationReview?: ((id?: string) => void) | undefined; /** Read the current draft synchronously before a destructive replacement. */ hasDraftContent: () => boolean; /** * The ordinary Send. While the agent is working it follows `busySend`: * queue behind the current turn (default) or Steer. */ send: (text?: string) => Promise; /** Append the draft behind prompts already visible in the queue, whatever `busySend` says. */ sendToQueue?: ((text?: string) => Promise) | undefined; /** What an ordinary Send does while the agent is working. */ busySend?: ComposerBusySend | undefined; /** * One Send and nothing to choose (`busySend: "steer"` without * `offerQueue`): no alternate beside Send, Cmd/Ctrl+Enter sends normally, * and stock surfaces avoid Queue and Steer words. */ singleSend?: boolean | undefined; /** * The other way to send right now, or `null` when Send is not taking its * busy path (nothing running or waiting, or the workstream is paused): * `steer` when Send queues, `queue` when Send steers. Hosts offer it next to * Send exactly when it is not `null`. */ alternateSend?: ComposerBusySend | null | undefined; /** Send the draft the other way: Steer when Send queues, queue when Send steers. */ sendAlternate?: ((text?: string) => Promise) | undefined; /** Locally acknowledged ordinary sends awaiting durable timeline reconciliation. */ optimisticMessages?: ComposerOptimisticMessage[] | undefined; retryOptimisticMessage?: ((clientEventId: string) => void) | undefined; /** Move a definitively refused message into an empty composer without resending it. */ restoreOptimisticMessage?: ((clientEventId: string) => void) | undefined; removeOptimisticMessage?: ((clientEventId: string) => void) | undefined; /** Supersede current direction with the draft. */ steer: (text?: string) => Promise; /** Optimistic-to-durable projection for a Steer that has not started yet. */ steering?: ComposerSteeringState | null | undefined; /** Immediate mutation-receipt projection while physical cancellation settles. */ stoppingAttempt?: "current" | "previous" | null | undefined; sending: boolean; canSend: boolean; /** Pause the session without deleting its prompt queue. */ pause: (reason?: string) => Promise; pausing: boolean; resume: (reason?: string) => Promise; resumeScope: (option: EffectiveControlResumeOption) => Promise; resuming: boolean; /** Mutation-confirmed control while the streamed queue projection catches up. */ effectiveControl?: EffectiveSessionControl | null | undefined; draft: ComposerDraft | null; draftRevision: number; draftLoading: boolean; draftSaving: boolean; draftConflict: Error | null; /** Exact policy owned by this actor/session composer. Null while hydrating. */ policy?: ComposerPolicy | null | undefined; setModel?: ((model: string) => void) | undefined; setReasoningEffort?: ((effort: ReasoningEffort) => void) | undefined; setLatencyMode?: ((mode: LatencyMode) => void) | undefined; /** Whether this controller owns a durable server-side draft. */ draftPersistence?: "durable" | "disabled" | undefined; /** Apply an atomic queue Edit checkout without a second read. */ applyDraft: (draft: ComposerDraft) => void; reloadDraft: () => Promise; resolveDraftConflict: (choice: "keep_mine" | "use_remote") => Promise; restoredResources: ResourceRef[]; removeRestoredResource: (index: number) => void; error: Error | null; clearError: () => void; }; export type ComposerControllerState = ComposerState & { policy: ComposerPolicy | null; setModel: (model: string) => void; setReasoningEffort: (effort: ReasoningEffort) => void; setLatencyMode: (mode: LatencyMode) => void; }; export type ComposerSteeringState = { phase: "submitting" | "accepted" | "failed"; text: string; clientEventId: string | null; triggerEventId: string | null; turnId: string | null; /** True only when the accepted Steer durably interrupted a live attempt. */ stoppingPreviousAttempt?: boolean | undefined; error?: string | undefined; outcomeUnknown?: boolean | undefined; }; const STEERING_SETTLEMENT_EVENT_TYPES = new Set([ "turn.started", "turn.completed", "turn.failed", "turn.cancelled", "turn.superseded", ]); function isSteeringSettlementEvent(event: SessionEvent): boolean { return STEERING_SETTLEMENT_EVENT_TYPES.has(event.type); } function steeringAcceptedEvent( steering: ComposerSteeringState, events: readonly SessionEvent[], ): SessionEvent | undefined { return events.find( (event) => event.type === "user.message" && steering.clientEventId !== null && event.clientEventId === steering.clientEventId, ); } function promptTurnIdForTrigger( triggerEventId: string | null | undefined, events: readonly SessionEvent[], ): string | null { if (!triggerEventId) return null; const queued = events.find((event) => { if (event.type !== "turn.queued") return false; const payload = event.payload; return ( typeof payload === "object" && payload !== null && "triggerEventId" in payload && payload.triggerEventId === triggerEventId ); }); if (!queued) return null; if (queued.turnId) return queued.turnId; const payload = queued.payload; return typeof payload === "object" && payload !== null && "turnId" in payload && typeof payload.turnId === "string" ? payload.turnId : null; } function steeringSettledByEvents( steering: ComposerSteeringState, events: readonly SessionEvent[], ): boolean { const acceptedEventId = steering.triggerEventId ?? steeringAcceptedEvent(steering, events)?.id ?? null; return events.some((event) => { if (steering.turnId && event.turnId === steering.turnId && isSteeringSettlementEvent(event)) { return true; } if (event.type !== "turn.started" || !acceptedEventId) return false; const payload = event.payload; return ( typeof payload === "object" && payload !== null && "triggerEventId" in payload && payload.triggerEventId === acceptedEventId ); }); } function reconcileOptimisticSendFromEvents( operation: OptimisticSendOperation, events: readonly SessionEvent[], ): { operation: OptimisticSendOperation; admitted: boolean; settled: boolean } { const accepted = events.find( (event) => event.type === "user.message" && event.clientEventId === operation.clientEventId, ); const triggerEventId = operation.triggerEventId ?? accepted?.id ?? null; const turnId = operation.turnId ?? promptTurnIdForTrigger(triggerEventId, events); const acceptedRouting = promptRoutingFromAcceptedEvent(accepted ?? null); const destination = acceptedRouting === "queued_for_execution" ? "queue" : acceptedRouting === "accepted_for_execution" || acceptedRouting === "accepted_for_steering" ? "chat" : operation.destination; const needsAdmissionUpdate = accepted !== undefined && (operation.state !== "queued" || operation.destination !== destination || operation.triggerEventId !== triggerEventId || operation.turnId !== turnId || operation.error !== undefined || operation.outcomeUnknown !== false); const reconciled: OptimisticSendOperation = needsAdmissionUpdate ? { ...operation, state: "queued", destination, triggerEventId, turnId, error: undefined, outcomeUnknown: false, } : operation; const withdrawnFromQueue = events.some((event) => { if (event.type !== "session.queue.changed" || !turnId) return false; const payload = event.payload; if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return false; const record = payload as Record; return ( record.turnId === turnId && (record.operation === "edit" || record.operation === "delete") ); }); return { operation: reconciled, admitted: accepted !== undefined, // Edit/Delete atomically withdraw the queued turn. Keeping its optimistic // send alive after that point lets its old `newerShadow` become the newest // composer draft again when a later turn settles, duplicating submitted // text. The queue mutation is the durable terminal fact for this send. settled: withdrawnFromQueue || steeringSettledByEvents( { phase: "accepted", text: reconciled.text, clientEventId: reconciled.clientEventId, triggerEventId: reconciled.triggerEventId ?? null, turnId: reconciled.turnId ?? null, }, events, ), }; } /** * Draft + send + Pause/Resume state for the chat composer — the only * human-to-agent input surface. The draft survives a failed send (nothing is * more hostile than losing a typed message); each send carries a generated * `clientEventId` so retries stay idempotent server-side. */ export function useComposer( sessionId: string | null | undefined, options: UseComposerOptions = {}, ): ComposerControllerState { const t = useOpenGeniText(); const { client, workspaceId, registerSessionReconciler } = useEmbeddedSession(options); const formatError = useErrorMessage(); const durableDrafts = options.draftPersistence !== "disabled"; if (!durableDrafts && !options.initialPolicy) { throw new Error("useComposer requires initialPolicy when draft persistence is disabled"); } const targetKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${durableDrafts ? "durable" : "disabled"}`; const pendingOperationKey = pendingComposerOperationKey(workspaceId, sessionId); const initialPendingOperation = restorePendingComposerOperation(pendingOperationKey); const initialOptimisticSends = restoreOptimisticSendOperations(t, pendingOperationKey); const initialShadow = initialPendingOperation?.newerShadow ?? initialOptimisticSends.at(-1)?.newerShadow ?? initialComposerShadow(options.initialDraft); const [value, setValue] = useState(() => initialShadow?.text ?? ""); const [annotations, setAnnotations] = useState( () => initialShadow?.annotations ?? [], ); const [annotationReviewTargetId, setAnnotationReviewTargetId] = useState(null); // Keep rendered state behind the committed target identity for one frame: // a parent may switch sessionId without remounting this public hook. const [stateTargetKey, setStateTargetKey] = useState(targetKey); const [sending, setSending] = useState(false); const [optimisticSends, setOptimisticSends] = useState(initialOptimisticSends); const [steering, setSteering] = useState(() => initialPendingOperation?.delivery === "steer" ? { phase: "failed", text: initialPendingOperation.input.text, clientEventId: initialPendingOperation.input.clientEventId ?? null, triggerEventId: null, turnId: null, error: t("hooks.useComposer.notConfirmedRetryToCheck"), outcomeUnknown: true, } : null, ); const [pausing, setPausing] = useState(false); const [acceptedControl, setAcceptedControl] = useState(null); const [resuming, setResuming] = useState(false); const [error, setError] = useState(null); // Background draft reads recover independently of Send/Steer/control failures. const [draftReadError, setDraftReadError] = useState(null); const [draft, setDraft] = useState(null); const [draftLoading, setDraftLoading] = useState(Boolean(sessionId) && durableDrafts); const [draftSaving, setDraftSaving] = useState(false); const [draftConflict, setDraftConflict] = useState(null); const [policy, setPolicy] = useState( () => initialShadow?.policy ?? options.initialPolicy ?? null, ); const [restoredResources, setRestoredResources] = useState( () => initialShadow?.resources ?? [], ); const pendingOperationRef = useRef(initialPendingOperation); const optimisticSendsRef = useRef(initialOptimisticSends); const optimisticProcessorBusyRef = useRef(false); const optimisticCallbackIdsRef = useRef(new Set()); const steeringSettlementEventsRef = useRef([]); const steeringRef = useRef(steering); const steeringOccurredAtRef = useRef(new Date().toISOString()); const pendingClientEventId = useRef( initialPendingOperation?.input.clientEventId ?? null, ); const valueRef = useRef(initialShadow?.text ?? ""); const annotationsRef = useRef(initialShadow?.annotations ?? []); const policyRef = useRef( initialShadow?.policy ?? options.initialPolicy ?? null, ); const draftRef = useRef(null); const restoredResourcesRef = useRef(initialShadow?.resources ?? []); const localEditRevision = useRef(initialShadow ? 1 : 0); const targetGeneration = useRef(0); const draftReadGeneration = useRef(0); const draftReadInFlightRef = useRef(null); const draftReadAbortRef = useRef(null); const draftReadRetryRef = useRef<{ targetKey: string; failures: number; timer: ReturnType | null; }>({ targetKey, failures: 0, timer: null }); const lastSavedSignature = useRef(null); const saveChain = useRef>(Promise.resolve()); const onSent = useCallback( (text: string, input: SendMessageInput) => { notifyComposerObserver(options.onSent, text, input); }, [options.onSent], ); const onSubmitted = useCallback( (text: string, input: SendMessageInput) => { notifyComposerObserver(options.onSubmitted, text, input); }, [options.onSubmitted], ); const onDeliveryErrorRef = useRef(options.onDeliveryError); onDeliveryErrorRef.current = options.onDeliveryError; useLayoutEffect(() => { steeringRef.current = steering; }, [steering]); // Read through a ref so a new extras closure (created every render by // callers passing inline functions) does not invalidate `send`. const sendExtrasRef = useRef(options.sendExtras); sendExtrasRef.current = options.sendExtras; const sendDestinationRef = useRef(options.sendDestination); sendDestinationRef.current = options.sendDestination; const busySend: ComposerBusySend = options.busySend ?? "queue"; const busySendRef = useRef(busySend); busySendRef.current = busySend; const singleSend = busySend === "steer" && options.offerQueue !== true; const sendBlockedRef = useRef(options.sendBlocked); sendBlockedRef.current = options.sendBlocked; const effectiveControlRef = useRef( acceptedControl ?? options.effectiveControl ?? null, ); effectiveControlRef.current = acceptedControl ?? options.effectiveControl ?? null; const liveExtrasVersion = JSON.stringify(resolveSendExtras(options.sendExtras)); // A composer is bound to one session: switching targets must not leak the // previous session's draft, error, or retry idempotency key. const targetKeyRef = useRef(targetKey); useLayoutEffect(() => { if (targetKeyRef.current === targetKey) return; if (draftReadRetryRef.current.timer !== null) { clearTimeout(draftReadRetryRef.current.timer); } draftReadAbortRef.current?.abort(); draftReadAbortRef.current = null; draftReadRetryRef.current = { targetKey, failures: 0, timer: null }; targetKeyRef.current = targetKey; targetGeneration.current += 1; draftReadGeneration.current += 1; pendingOperationRef.current = restorePendingComposerOperation(pendingOperationKey); const restoredOptimisticSends = restoreOptimisticSendOperations(t, pendingOperationKey); optimisticSendsRef.current = restoredOptimisticSends; optimisticProcessorBusyRef.current = false; optimisticCallbackIdsRef.current = new Set(); steeringSettlementEventsRef.current = []; pendingClientEventId.current = pendingOperationRef.current?.input.clientEventId ?? null; const shadow = pendingOperationRef.current?.newerShadow ?? restoredOptimisticSends.at(-1)?.newerShadow ?? initialComposerShadow(options.initialDraft); localEditRevision.current = shadow ? 1 : 0; valueRef.current = shadow?.text ?? ""; annotationsRef.current = shadow?.annotations ?? []; policyRef.current = shadow?.policy ?? options.initialPolicy ?? null; draftRef.current = null; restoredResourcesRef.current = shadow?.resources ?? []; lastSavedSignature.current = null; // Old saves may still be awaiting the network. Their generation fence // prevents settlement, and a fresh chain avoids blocking this target. saveChain.current = Promise.resolve(); setStateTargetKey(targetKey); setValue(shadow?.text ?? ""); setAnnotations(shadow?.annotations ?? []); setPolicy(policyRef.current); setAnnotationReviewTargetId(null); setSending(false); setOptimisticSends(restoredOptimisticSends); setSteering( pendingOperationRef.current?.delivery === "steer" ? { phase: "failed", text: pendingOperationRef.current.input.text, clientEventId: pendingOperationRef.current.input.clientEventId ?? null, triggerEventId: null, turnId: null, error: t("hooks.useComposer.notConfirmedRetryToCheck"), outcomeUnknown: true, } : null, ); setPausing(false); setAcceptedControl(null); setResuming(false); setError(null); setDraftReadError(null); setDraft(null); setDraftLoading(Boolean(sessionId) && durableDrafts); setDraftSaving(false); setDraftConflict(null); setRestoredResources(shadow?.resources ?? []); }, [ durableDrafts, options.initialPolicy, options.initialDraft, pendingOperationKey, sessionId, targetKey, t, ]); useEffect( () => () => { if (draftReadRetryRef.current.timer !== null) { clearTimeout(draftReadRetryRef.current.timer); } targetGeneration.current += 1; draftReadGeneration.current += 1; draftReadInFlightRef.current = null; draftReadAbortRef.current?.abort(); draftReadAbortRef.current = null; }, [], ); const setOptimisticDraftShadow = useCallback( (shadow: ComposerDraftShadow): void => { if (optimisticSendsRef.current.length === 0) return; const next = optimisticSendsRef.current.map((operation) => ({ ...operation, newerShadow: { text: shadow.text, resources: [...shadow.resources], annotations: cloneAnnotations(shadow.annotations), ...(shadow.policy ? { policy: { ...shadow.policy } } : {}), }, })); optimisticSendsRef.current = next; rememberOptimisticSendOperations(pendingOperationKey, next); setOptimisticSends(next); }, [pendingOperationKey], ); const applyDraft = useCallback( (next: ComposerDraft): void => { if (targetKeyRef.current !== targetKey) return; if (!durableDrafts) { const nextPolicy = policyFromDraft(next); localEditRevision.current += 1; valueRef.current = next.text; annotationsRef.current = next.annotations ?? []; policyRef.current = nextPolicy; restoredResourcesRef.current = next.resources; draftRef.current = null; lastSavedSignature.current = null; setDraft(null); setValue(next.text); setAnnotations(next.annotations ?? []); setPolicy(nextPolicy); setRestoredResources(next.resources); pendingOperationRef.current = updatePendingComposerShadow( pendingOperationKey, pendingOperationRef.current, { text: next.text, resources: mergeResources( next.resources, resolveSendExtras(sendExtrasRef.current).resources ?? [], ), annotations: next.annotations ?? [], policy: nextPolicy, }, ); setOptimisticDraftShadow({ text: next.text, resources: mergeResources( next.resources, resolveSendExtras(sendExtrasRef.current).resources ?? [], ), annotations: next.annotations ?? [], policy: nextPolicy, }); setDraftConflict(null); return; } const nextPolicy = policyFromDraft(next); valueRef.current = next.text; annotationsRef.current = next.annotations ?? []; policyRef.current = nextPolicy; draftRef.current = next; restoredResourcesRef.current = next.resources; lastSavedSignature.current = draftSignature(draftPayload(next)); localEditRevision.current += 1; setDraft(next); setValue(next.text); setAnnotations(next.annotations ?? []); setPolicy(nextPolicy); setRestoredResources(next.resources); pendingOperationRef.current = updatePendingComposerShadow( pendingOperationKey, pendingOperationRef.current, { text: next.text, resources: mergeResources( next.resources, resolveSendExtras(sendExtrasRef.current).resources ?? [], ), annotations: next.annotations ?? [], policy: nextPolicy, }, ); setOptimisticDraftShadow({ text: next.text, resources: mergeResources( next.resources, resolveSendExtras(sendExtrasRef.current).resources ?? [], ), annotations: next.annotations ?? [], policy: nextPolicy, }); setDraftConflict(null); }, [durableDrafts, pendingOperationKey, setOptimisticDraftShadow, targetKey], ); const loadDraft = useCallback( async (replaceLocal: boolean): Promise => { if (targetKeyRef.current !== targetKey) return; if (!sessionId || !durableDrafts) { setDraftLoading(false); setDraftReadError(null); return; } let retry = draftReadRetryRef.current; if (retry.targetKey !== targetKey) { if (retry.timer !== null) clearTimeout(retry.timer); retry = { targetKey, failures: 0, timer: null }; draftReadRetryRef.current = retry; } const scheduleRetry = (): void => { if (retry.timer !== null) return; retry.timer = setTimeout( () => { if (draftReadRetryRef.current !== retry || targetKeyRef.current !== targetKey) return; retry.timer = null; void loadDraft(false); }, Math.min(25_000, 1_000 * 2 ** (retry.failures - 1)) * (0.8 + Math.random() * 0.4), ); }; if (!replaceLocal && retry.timer !== null) return; if (draftReadInFlightRef.current === targetKey) return; if (retry.timer !== null) { clearTimeout(retry.timer); retry.timer = null; } draftReadInFlightRef.current = targetKey; const generation = targetGeneration.current; const readTicket = ++draftReadGeneration.current; const requestAbort = new AbortController(); draftReadAbortRef.current?.abort(); draftReadAbortRef.current = requestAbort; const localAtStart = localEditRevision.current; const baseAtStart = draftRef.current; const policyAtStart = policyRef.current; const localSignatureAtStart = baseAtStart ? policyAtStart ? draftSignature( composerDraftPayload( baseAtStart, valueRef.current, restoredResourcesRef.current, annotationsRef.current, policyAtStart, resolveSendExtras(sendExtrasRef.current).resources ?? [], ), ) : null : null; const localWasDirtyAtStart = localSignatureAtStart === null ? localAtStart !== 0 : localSignatureAtStart !== lastSavedSignature.current; // Only blank the picker on first hydrate / hard reload. Reconcile and // event-triggered soft reloads (loadOlder SSE reconnect) must not flicker // draftLoading — stale-while-revalidate keeps the settled UI mounted. const showLoading = replaceLocal || draftRef.current === null; if (showLoading) { setDraftLoading(true); } try { const fetched = await client.getComposerDraft(workspaceId, sessionId, { signal: requestAbort.signal, }); if ( generation !== targetGeneration.current || targetKeyRef.current !== targetKey || readTicket !== draftReadGeneration.current ) { return; } if (retry.timer !== null) clearTimeout(retry.timer); retry.failures = 0; retry.timer = null; // Recovery also counts when the server returns the same draft revision. setDraftReadError(null); const currentRevision = draftRef.current?.revision ?? -1; // Reconnect and client-generation effects can legitimately ask for the // draft again. A same-revision response is not new authority: publishing // its freshly decoded object back into React creates a response -> render // -> read feedback loop. Hard reload remains the explicit exception. if (replaceLocal || fetched.revision > currentRevision) { draftRef.current = fetched; setDraft(fetched); setDraftConflict(null); const shadow = pendingOperationRef.current?.newerShadow ?? optimisticSendsRef.current.at(-1)?.newerShadow; if (shadow) { // The server can only know the original operation. Never replace // newer local edits while that operation is still uncertain. valueRef.current = shadow.text; restoredResourcesRef.current = shadow.resources; annotationsRef.current = shadow.annotations; if (shadow.policy) policyRef.current = shadow.policy; localEditRevision.current ||= 1; setValue(shadow.text); setRestoredResources(shadow.resources); setAnnotations(shadow.annotations); if (shadow.policy) setPolicy(shadow.policy); } else if ( replaceLocal || (!localWasDirtyAtStart && localAtStart === localEditRevision.current) ) { const fetchedPolicy = policyFromDraft(fetched); valueRef.current = fetched.text; restoredResourcesRef.current = fetched.resources; annotationsRef.current = fetched.annotations ?? []; policyRef.current = fetchedPolicy; lastSavedSignature.current = draftSignature(draftPayload(fetched)); setValue(fetched.text); setRestoredResources(fetched.resources); setAnnotations(fetched.annotations ?? []); setPolicy(fetchedPolicy); } else { // Keep the in-flight edit, but the fetched row is still the OCC // base. First hydrate has no local policy until this read lands; // without it autosave cannot persist the typed text. lastSavedSignature.current = draftSignature(draftPayload(fetched)); if (!policyRef.current) { const fetchedPolicy = policyFromDraft(fetched); policyRef.current = fetchedPolicy; setPolicy(fetchedPolicy); } } } } catch (cause) { if ( !requestAbort.signal.aborted && generation === targetGeneration.current && targetKeyRef.current === targetKey && readTicket === draftReadGeneration.current ) { const problem = asError(cause); const timedOut = problem.name === "TimeoutError"; setDraftReadError( timedOut ? new ComposerStateError(t("hooks.useComposer.draftSyncTimedOutRetrying"), { cause }) : problem, ); if ( timedOut || cause instanceof TypeError || (cause && typeof cause === "object" && "retryable" in cause && cause.retryable === true) ) { retry.failures += 1; scheduleRetry(); } else { if (retry.timer !== null) clearTimeout(retry.timer); retry.failures = 0; retry.timer = null; } } } finally { if ( generation === targetGeneration.current && targetKeyRef.current === targetKey && readTicket === draftReadGeneration.current ) { setDraftLoading(false); } if (draftReadInFlightRef.current === targetKey) { draftReadInFlightRef.current = null; } if (draftReadAbortRef.current === requestAbort) { draftReadAbortRef.current = null; } } }, [client, durableDrafts, sessionId, targetKey, workspaceId, t], ); useEffect(() => { if (!sessionId || !durableDrafts) { setDraftLoading(false); return; } void loadDraft(false); }, [durableDrafts, loadDraft, sessionId]); // After long background / sleep the in-memory revision is often stale while // a prior autosave already advanced the server. Soft-reload on wake so the // next keystroke does not OCC against a dead revision. useEffect(() => { if (!sessionId || !durableDrafts) return; const onWake = () => { if (typeof document !== "undefined" && document.visibilityState === "hidden") return; void loadDraft(false); }; document.addEventListener("visibilitychange", onWake); window.addEventListener("pageshow", onWake); return () => { document.removeEventListener("visibilitychange", onWake); window.removeEventListener("pageshow", onWake); }; }, [durableDrafts, loadDraft, sessionId]); useEffect(() => { if (!sessionId || !durableDrafts) return; return registerSessionReconciler(sessionId, "composer", async () => await loadDraft(false)); }, [durableDrafts, loadDraft, registerSessionReconciler, sessionId]); const reconcileSteering = useCallback(async (): Promise => { if (!sessionId || !steeringRef.current) return; const ownedTargetKey = targetKey; let events: SessionEvent[]; try { events = await client.listEvents(workspaceId, sessionId, { includeTypes: [ "user.message", "turn.started", "turn.completed", "turn.failed", "turn.cancelled", "turn.superseded", ], limit: 250, payloadMode: "full", }); } catch { // Best effort: the live stream still settles steering when its event arrives. return; } if (targetKeyRef.current !== ownedTargetKey) return; setSteering((current) => { if (!current) return current; if (steeringSettledByEvents(current, events)) { steeringSettlementEventsRef.current = []; return null; } const accepted = steeringAcceptedEvent(current, events); if (!accepted || current.triggerEventId) return current; return { ...current, phase: "accepted", triggerEventId: accepted.id, }; }); }, [client, sessionId, targetKey, workspaceId]); useSessionEventTrigger( client, workspaceId, sessionId, (event) => isComposerDraftEvent(event) || isSteeringSettlementEvent(event), (event) => { if (isComposerDraftEvent(event)) void loadDraft(false); if (!isSteeringSettlementEvent(event)) return; steeringSettlementEventsRef.current = [ ...steeringSettlementEventsRef.current.slice(-15), event, ]; setSteering((current) => { if (!current || !steeringSettledByEvents(current, [event])) return current; steeringSettlementEventsRef.current = []; return null; }); }, { enabled: Boolean(sessionId) && (durableDrafts || steering !== null), ...(options.events !== undefined ? { events: options.events } : {}), }, reconcileSteering, ); useEffect(() => { if (!steering) return; const observed = [...(options.events ?? []), ...steeringSettlementEventsRef.current]; if (!steeringSettledByEvents(steering, observed)) return; steeringSettlementEventsRef.current = []; setSteering(null); }, [options.events, steering]); useEffect(() => { if (!acceptedControl) return; const effectiveControl = options.effectiveControl; if (!effectiveControl || effectiveControl.controlVersion < acceptedControl.controlVersion) { return; } setAcceptedControl((current) => (current === acceptedControl ? null : current)); }, [acceptedControl, options.effectiveControl]); const currentDraftPayload = useCallback((): SaveComposerDraftRequest | null => { if (!durableDrafts || targetKeyRef.current !== targetKey) return null; const base = draftRef.current; const currentPolicy = policyRef.current; if (!base || !currentPolicy) return null; return composerDraftPayload( base, value, restoredResources, annotations, currentPolicy, resolveSendExtras(sendExtrasRef.current).resources ?? [], ); }, [annotations, durableDrafts, restoredResources, targetKey, value]); const adoptDraftBase = useCallback( (next: ComposerDraft): void => { if (targetKeyRef.current !== targetKey) return; draftRef.current = next; setDraft(next); lastSavedSignature.current = draftSignature(draftPayload(next)); setDraftConflict(null); setError(null); }, [targetKey], ); const persistPayload = useCallback( async (payload: SaveComposerDraftRequest): Promise => { const ownedTargetKey = targetKey; const ownedGeneration = targetGeneration.current; if (!sessionId || !durableDrafts || targetKeyRef.current !== ownedTargetKey) { return false; } let success = false; const run = async () => { if ( targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration ) { return; } const current = draftRef.current; if (!current) return; const request = { ...payload, expectedRevision: current.revision }; const signature = draftSignature(request); if (signature === lastSavedSignature.current) { success = true; return; } setDraftSaving(true); try { const saved = await client.saveComposerDraft(workspaceId, sessionId, request); if ( targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration ) { return; } adoptDraftBase(saved); success = true; } catch (cause) { if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { const problem = asError(cause); if (isDraftConflictError(problem)) setDraftConflict(problem); setError(problem); } } finally { if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { setDraftSaving(false); } } }; saveChain.current = saveChain.current.then(run, run); await saveChain.current; return success; }, [adoptDraftBase, client, durableDrafts, sessionId, targetKey, workspaceId], ); const replaceOptimisticSends = useCallback( ( update: (current: OptimisticSendOperation[]) => OptimisticSendOperation[], ): OptimisticSendOperation[] => { const next = update(optimisticSendsRef.current); optimisticSendsRef.current = next; rememberOptimisticSendOperations(pendingOperationKey, next); setOptimisticSends(next); return next; }, [pendingOperationKey], ); const markOptimisticAccepted = useCallback( (operation: OptimisticSendOperation): void => { if (!optimisticCallbackIdsRef.current.has(operation.clientEventId)) { optimisticCallbackIdsRef.current.add(operation.clientEventId); onSent?.(operation.input.text, operation.input); } replaceOptimisticSends((current) => current.filter((candidate) => candidate.clientEventId !== operation.clientEventId), ); }, [onSent, replaceOptimisticSends], ); useEffect(() => { const events = options.events ?? []; if (events.length === 0 || optimisticSendsRef.current.length === 0) return; const reconciled = optimisticSendsRef.current.map((operation) => reconcileOptimisticSendFromEvents(operation, events), ); const settledIds = new Set( reconciled.filter((item) => item.settled).map((item) => item.operation.clientEventId), ); const changed = reconciled.some( (item, index) => item.operation !== optimisticSendsRef.current[index] && !settledIds.has(item.operation.clientEventId), ); for (const item of reconciled) { if (item.admitted && !optimisticCallbackIdsRef.current.has(item.operation.clientEventId)) { optimisticCallbackIdsRef.current.add(item.operation.clientEventId); onSent?.(item.operation.input.text, item.operation.input); } if (item.settled) markOptimisticAccepted(item.operation); } if (changed) { replaceOptimisticSends((current) => current .map( (operation) => reconciled.find((item) => item.operation.clientEventId === operation.clientEventId) ?.operation ?? operation, ) .filter((operation) => !settledIds.has(operation.clientEventId)), ); } if (durableDrafts && reconciled.some((item) => item.admitted)) void loadDraft(false); }, [ durableDrafts, loadDraft, markOptimisticAccepted, onSent, options.events, replaceOptimisticSends, ]); const processOptimisticSends = useCallback((): void => { if (!sessionId || optimisticProcessorBusyRef.current) return; const currentOperations = optimisticSendsRef.current; const operationIndex = currentOperations.findIndex( (candidate) => candidate.state === "sending", ); const operation = operationIndex < 0 ? undefined : currentOperations[operationIndex]; if (!operation) return; const ownedTargetKey = targetKey; const ownedGeneration = targetGeneration.current; optimisticProcessorBusyRef.current = true; void (async () => { let mutationFailureObserved = false; const markFailed = (cause: unknown, outcomeUnknown: boolean): void => { if ( targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration ) { return; } const problem = asError(cause); replaceOptimisticSends((current) => current.map((candidate) => candidate.clientEventId === operation.clientEventId ? { ...candidate, state: "failed", error: formatError( cause, outcomeUnknown && !isOutcomeUnknownError(problem) && !(problem instanceof ComposerReconciliationRequiredError) ? problem instanceof OpenGeniApiError && problem.correlationId ? t("hooks.useComposer.notConfirmedCheckStatusWithReference", { reference: problem.correlationId, }) : t("hooks.useComposer.notConfirmedCheckStatus") : composerSubmissionErrorMessage(t, problem), ), retryable: outcomeUnknown || composerSubmissionCanRetry(problem), outcomeUnknown, correlationId: problem instanceof OpenGeniApiError ? problem.correlationId : candidate.correlationId, } : candidate, ), ); }; try { if (operation.outcomeUnknown) { let events: SessionEvent[]; try { events = await client.listEvents(workspaceId, sessionId, { includeTypes: [ "user.message", "turn.queued", "turn.started", "turn.completed", "turn.failed", "turn.cancelled", "turn.superseded", ], limit: 250, payloadMode: "full", }); } catch (cause) { // This read cannot settle the prior mutation in either direction. // Preserve its exact key and uncertain status until reconciliation // succeeds or that same mutation is actually replayed. markFailed(cause, true); return; } const reconciled = reconcileOptimisticSendFromEvents(operation, events); if (reconciled.admitted) { if (durableDrafts) await loadDraft(false); if (reconciled.settled) { markOptimisticAccepted(reconciled.operation); } else { replaceOptimisticSends((current) => current.map((candidate) => candidate.clientEventId === operation.clientEventId ? reconciled.operation : candidate, ), ); if (!optimisticCallbackIdsRef.current.has(operation.clientEventId)) { optimisticCallbackIdsRef.current.add(operation.clientEventId); onSent?.(reconciled.operation.input.text, reconciled.operation.input); } } return; } if (!operation.canRetry) { throw new ComposerReconciliationRequiredError(); } } let expectedDraftRevision: number | undefined; if (durableDrafts && operation.draftPayload) { if (!(await persistPayload(operation.draftPayload))) { throw new ComposerStateError(t("hooks.useComposer.theMessageDraftCouldNot")); } expectedDraftRevision = draftRef.current?.revision; } if ( targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration ) { return; } const wireInput = { ...operation.input, ...(expectedDraftRevision !== undefined ? { expectedDraftRevision } : {}), }; let acceptedResult: SubmitComposerDraftResponse | null = null; let acceptedEvent: SessionEvent | null = null; if (durableDrafts) { if (!operation.draftPayload || expectedDraftRevision === undefined) { throw new ComposerStateError(t("hooks.useComposer.theDurableComposerDraftIs")); } const result = await client .submitComposerDraft(workspaceId, sessionId, { text: operation.draftPayload.text, annotations: operation.draftPayload.annotations, resources: operation.draftPayload.resources, model: operation.draftPayload.model, reasoningEffort: operation.draftPayload.reasoningEffort, latencyMode: operation.draftPayload.latencyMode, expectedDraftRevision, clientEventId: operation.clientEventId, delivery: "send", ...(wireInput.controlEtag ? { controlEtag: wireInput.controlEtag } : {}), ...(wireInput.modelContext ? { modelContext: wireInput.modelContext } : {}), ...(wireInput.mcpCredentialUpdates ? { mcpCredentialUpdates: wireInput.mcpCredentialUpdates } : {}), ...(wireInput.connectionAccounts ? { connectionAccounts: wireInput.connectionAccounts } : {}), ...(wireInput.personalResourceAttachment ? { personalResourceAttachment: wireInput.personalResourceAttachment } : {}), }) .catch((cause: unknown) => { mutationFailureObserved = true; throw cause; }); acceptedResult = result; adoptDraftBase(result.draft); } else { acceptedEvent = await client .sendMessage(workspaceId, sessionId, wireInput) .catch((cause: unknown) => { mutationFailureObserved = true; throw cause; }); } if ( targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration ) { return; } const acceptedRouting = acceptedResult?.routing ?? promptRoutingFromAcceptedEvent(acceptedEvent); const acceptedDestination = acceptedRouting === "queued_for_execution" ? "queue" : acceptedRouting === "accepted_for_execution" || acceptedRouting === "accepted_for_steering" ? "chat" : operation.destination; if (options.events === undefined && acceptedDestination === "chat") { markOptimisticAccepted({ ...operation, input: wireInput }); } else { replaceOptimisticSends((current) => current.map((candidate) => candidate.clientEventId === operation.clientEventId ? { ...candidate, input: wireInput, destination: acceptedDestination, turnId: acceptedResult?.turn.id ?? candidate.turnId, triggerEventId: acceptedResult?.accepted.id ?? acceptedEvent?.id ?? candidate.triggerEventId, appliedQueueVersion: acceptedResult?.receipt?.appliedQueueVersion ?? candidate.appliedQueueVersion, state: "queued", error: undefined, } : candidate, ), ); } if (!optimisticCallbackIdsRef.current.has(operation.clientEventId)) { optimisticCallbackIdsRef.current.add(operation.clientEventId); onSent?.(wireInput.text, wireInput); } } catch (cause) { const current = optimisticSendsRef.current.find( (candidate) => candidate.clientEventId === operation.clientEventId, ); // A live durable event can prove admission before the original HTTP // response times out. Never regress that confirmed state to failure. if (!current || (current.state === "queued" && current.outcomeUnknown === false)) { return; } const problem = asError(cause); notifyComposerObserver(onDeliveryErrorRef.current, problem, operation.input, "send"); // Only an actual mutation replay can replace prior uncertainty with a // definitive response. Local preparation failures remain retry-only. const outcomeUnknown = operation.outcomeUnknown && !mutationFailureObserved ? true : isOutcomeUnknownError(cause); markFailed(cause, outcomeUnknown); } finally { optimisticProcessorBusyRef.current = false; if (targetKeyRef.current === ownedTargetKey) { setOptimisticSends([...optimisticSendsRef.current]); } } })(); }, [ adoptDraftBase, client, durableDrafts, formatError, markOptimisticAccepted, loadDraft, onSent, options.events, persistPayload, replaceOptimisticSends, sessionId, targetKey, workspaceId, t, ]); useEffect(() => { processOptimisticSends(); }, [optimisticSends, processOptimisticSends]); // Private durable autosave. A newer local edit is never replaced by an older // response; saves serialize and each reads the latest acknowledged revision. useEffect(() => { const pending = pendingOperationRef.current; if (pending) { const shadow = { text: valueRef.current, resources: mergeResources( restoredResourcesRef.current, resolveSendExtras(sendExtrasRef.current).resources ?? [], ), annotations: annotationsRef.current, ...(policyRef.current ? { policy: policyRef.current } : {}), }; if ( pending.newerShadow.text !== shadow.text || JSON.stringify(pending.newerShadow.resources) !== JSON.stringify(shadow.resources) || JSON.stringify(pending.newerShadow.annotations) !== JSON.stringify(shadow.annotations) || JSON.stringify(pending.newerShadow.policy) !== JSON.stringify(shadow.policy) ) { pendingOperationRef.current = updatePendingComposerShadow( pendingOperationKey, pending, shadow, ); } return; } const optimistic = optimisticSendsRef.current.at(-1); if (optimistic) { const shadow = { text: valueRef.current, resources: mergeResources( restoredResourcesRef.current, resolveSendExtras(sendExtrasRef.current).resources ?? [], ), annotations: annotationsRef.current, ...(policyRef.current ? { policy: policyRef.current } : {}), }; if ( optimistic.newerShadow.text !== shadow.text || JSON.stringify(optimistic.newerShadow.resources) !== JSON.stringify(shadow.resources) || JSON.stringify(optimistic.newerShadow.annotations) !== JSON.stringify(shadow.annotations) || JSON.stringify(optimistic.newerShadow.policy) !== JSON.stringify(shadow.policy) ) { setOptimisticDraftShadow(shadow); } } if (optimisticSendsRef.current.some((operation) => operation.state === "sending")) { return; } if ( !durableDrafts || !sessionId || draftLoading || sending || !draftRef.current || draftConflict ) return; const payload = currentDraftPayload(); if (!payload || draftSignature(payload) === lastSavedSignature.current) return; const timer = window.setTimeout(() => void persistPayload(payload), 500); return () => window.clearTimeout(timer); }, [ currentDraftPayload, durableDrafts, draftConflict, draftLoading, liveExtrasVersion, pendingOperationKey, persistPayload, setOptimisticDraftShadow, sending, sessionId, ]); const dispatch = useCallback( async (delivery: "send" | "steer", explicit?: string): Promise => { const ownedTargetKey = targetKey; const ownedGeneration = targetGeneration.current; const operationKey = pendingComposerOperationKey(workspaceId, sessionId); const pending = pendingOperationRef.current ?? restorePendingComposerOperation(operationKey); if (pending && !pendingOperationRef.current) { pendingOperationRef.current = pending; pendingClientEventId.current = pending.input.clientEventId ?? null; } const draftAtSend = value; const annotationsAtSend = annotations; const rawText = explicit ?? draftAtSend; const hasText = rawText.trim().length > 0; const hasAnnotations = annotationsAtSend.length > 0; const currentPolicy = policyRef.current; const annotationsComplete = annotationsAtSend.every( (annotation) => annotation.note.trim().length > 0, ); // Resolve the extras once: a file-only message (empty text + ≥1 ready // resource) is legitimate, so we must not bail on empty text alone. const extras = pending ? {} : resolveSendExtras(sendExtrasRef.current); const hasResources = restoredResources.length > 0 || (extras.resources?.length ?? 0) > 0; if ( (!pending && (!annotationsComplete || (!hasText && !hasResources && !hasAnnotations))) || !sessionId || !currentPolicy || sending || sendBlockedRef.current?.() === true || targetKeyRef.current !== ownedTargetKey ) { return false; } const clearPending = (): void => { pendingOperationRef.current = null; pendingClientEventId.current = null; forgetPendingComposerOperation(operationKey); }; let keepSteering = pending?.delivery === "steer"; const settleAccepted = (operation: PendingComposerOperation): void => { clearPending(); const draftWasUnchanged = valueRef.current === operation.draftAtSend; const resourcesWereUnchanged = JSON.stringify(restoredResourcesRef.current) === JSON.stringify(operation.resourcesAtSend); const annotationsWereUnchanged = JSON.stringify(annotationsRef.current) === JSON.stringify(operation.annotationsAtSend); if (resourcesWereUnchanged) { restoredResourcesRef.current = []; setRestoredResources([]); } if (annotationsWereUnchanged) { annotationsRef.current = []; setAnnotations([]); setAnnotationReviewTargetId(null); } if (operation.clearDraftOnAccept && draftWasUnchanged) { valueRef.current = ""; setValue(""); } onSent?.(operation.input.text, operation.input); }; const deliver = async (operation: PendingComposerOperation) => { if (durableDrafts) { const input = operation.input; if ( input.expectedDraftRevision === undefined || input.clientEventId === undefined || input.model === undefined || input.reasoningEffort === undefined || input.latencyMode === undefined ) { throw new ComposerStateError(t("hooks.useComposer.theDurableComposerDraftIs")); } const result = await client.submitComposerDraft(workspaceId, sessionId, { text: input.text, annotations: input.annotations ?? [], resources: input.resources ?? [], model: input.model, reasoningEffort: input.reasoningEffort, latencyMode: input.latencyMode, expectedDraftRevision: input.expectedDraftRevision, clientEventId: input.clientEventId, delivery: operation.delivery, ...(input.controlEtag ? { controlEtag: input.controlEtag } : {}), ...(input.modelContext ? { modelContext: input.modelContext } : {}), ...(input.mcpCredentialUpdates ? { mcpCredentialUpdates: input.mcpCredentialUpdates } : {}), ...(input.connectionAccounts ? { connectionAccounts: input.connectionAccounts } : {}), ...(input.personalResourceAttachment ? { personalResourceAttachment: input.personalResourceAttachment } : {}), }); adoptDraftBase(result.draft); return result; } if (operation.delivery === "steer") { return await client.steerMessage(workspaceId, sessionId, operation.input); } await client.sendMessage(workspaceId, sessionId, operation.input); return null; }; if (delivery === "steer") { if (steeringRef.current?.clientEventId !== pending?.input.clientEventId) { steeringOccurredAtRef.current = new Date().toISOString(); } setSteering({ phase: "submitting", text: rawText, clientEventId: pending?.input.clientEventId ?? pendingClientEventId.current, triggerEventId: null, turnId: null, }); } setSending(true); setError(null); try { if (pending) { let acceptedEvent: SessionEvent | null = null; try { const events = await client.listEvents(workspaceId, sessionId, { includeTypes: ["user.message"], limit: 100, payloadMode: "none", }); acceptedEvent = events.find( (event) => event.type === "user.message" && event.clientEventId === pending.input.clientEventId, ) ?? null; } catch (cause) { if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { const problem = asError(cause); setError(problem); if (pending.delivery === "steer") { keepSteering = true; setSteering({ phase: "failed", text: pending.input.text, clientEventId: pending.input.clientEventId ?? null, triggerEventId: null, turnId: null, error: t("hooks.useComposer.notConfirmedRetry"), outcomeUnknown: true, }); } } return false; } if ( targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration ) { return false; } if (acceptedEvent) { if (durableDrafts) await loadDraft(false); if (pending.delivery === "steer") { keepSteering = true; setSteering({ phase: "accepted", text: pending.input.text, clientEventId: pending.input.clientEventId ?? null, triggerEventId: acceptedEvent.id, turnId: null, }); } settleAccepted(pending); return true; } if (!pending.canRetry) { setError(new ComposerReconciliationRequiredError()); return false; } try { const result = await deliver(pending); if (pending.delivery === "steer" && result) { keepSteering = true; setSteering({ phase: "accepted", text: pending.input.text, clientEventId: pending.input.clientEventId ?? null, triggerEventId: result.accepted.id, turnId: result.turn.id, stoppingPreviousAttempt: result.replay !== true && (result.interruptionCount ?? 0) > 0, }); } } catch (cause) { const problem = asError(cause); const outcomeUnknown = isOutcomeUnknownError(cause); notifyComposerObserver( onDeliveryErrorRef.current, problem, pending.input, pending.delivery, ); if (!outcomeUnknown) { clearPending(); keepSteering = false; } else if (pending.delivery === "steer") { keepSteering = true; setSteering({ phase: "failed", text: pending.input.text, clientEventId: pending.input.clientEventId ?? null, triggerEventId: null, turnId: null, error: t("hooks.useComposer.notConfirmedRetry"), outcomeUnknown: true, }); } if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { setError(problem); } return false; } if ( targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration ) { return false; } settleAccepted(pending); return true; } // Trimming is only an emptiness check. A non-blank prompt is persisted // and submitted byte-for-byte, while file-only sends use the same // placeholder for both operations so the server content fence cannot // reject its own client. const sendText = hasText ? rawText : hasAnnotations ? "" : FILE_ONLY_MESSAGE_TEXT(t); const currentPayload = currentDraftPayload(); const payload = currentPayload ? { ...currentPayload, text: sendText } : null; if (payload && !(await persistPayload(payload))) return false; if ( targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration ) { return false; } // The wire contract requires non-empty text (z.string().min(1)) and the // worker rejects whitespace-only text; a file-only message therefore // carries a minimal default so the attachments still get delivered. pendingClientEventId.current ??= generateClientEventId(); const effectiveControl = effectiveControlRef.current; const input = composeSendInput(sendText, pendingClientEventId.current, extras, { ...currentPolicy, ...(effectiveControl?.controlEtag ? { controlEtag: effectiveControl.controlEtag } : {}), ...(durableDrafts && draftRef.current ? { expectedDraftRevision: draftRef.current.revision } : {}), resources: mergeResources(restoredResources, extras.resources ?? []), annotations: cloneAnnotations(annotationsAtSend), }); const operation: PendingComposerOperation = { delivery, input, draftAtSend, resourcesAtSend: [...restoredResources], annotationsAtSend: cloneAnnotations(annotationsAtSend), newerShadow: { text: draftAtSend, resources: mergeResources(restoredResources, extras.resources ?? []), annotations: cloneAnnotations(annotationsAtSend), policy: currentPolicy, }, clearDraftOnAccept: explicit === undefined, canRetry: true, }; pendingOperationRef.current = operation; rememberPendingComposerOperation(operationKey, operation); if (delivery === "steer") { setSteering({ phase: "submitting", text: sendText, clientEventId: input.clientEventId ?? null, triggerEventId: null, turnId: null, }); } try { const result = await deliver(operation); if (delivery === "steer" && result) { keepSteering = true; setSteering({ phase: "accepted", text: sendText, clientEventId: input.clientEventId ?? null, triggerEventId: result.accepted.id, turnId: result.turn.id, stoppingPreviousAttempt: result.replay !== true && (result.interruptionCount ?? 0) > 0, }); } } catch (cause) { notifyComposerObserver( onDeliveryErrorRef.current, asError(cause), operation.input, operation.delivery, ); if (!isOutcomeUnknownError(cause)) { clearPending(); } else if (delivery === "steer") { keepSteering = true; setSteering({ phase: "failed", text: operation.input.text, clientEventId: operation.input.clientEventId ?? null, triggerEventId: null, turnId: null, error: t("hooks.useComposer.notConfirmedRetry"), outcomeUnknown: true, }); } if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { setError(asError(cause)); } return false; } if ( targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration ) { return false; } settleAccepted(operation); return true; } finally { if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { setSending(false); if (delivery === "steer" && !keepSteering) setSteering(null); } } }, [ client, currentDraftPayload, durableDrafts, onSent, persistPayload, restoredResources, annotations, adoptDraftBase, sending, sessionId, targetKey, value, workspaceId, loadDraft, t, ], ); const sendToQueue = useCallback( async (explicit?: string): Promise => { if (pendingOperationRef.current?.delivery === "send") { return await dispatch("send", explicit); } const rawText = explicit ?? valueRef.current; const annotationsAtSend = cloneAnnotations(annotationsRef.current); const currentPolicy = policyRef.current; const hasText = rawText.trim().length > 0; const hasAnnotations = annotationsAtSend.length > 0; const annotationsComplete = annotationsAtSend.every( (annotation) => annotation.note.trim().length > 0, ); const extras = resolveSendExtras(sendExtrasRef.current); const resources = mergeResources(restoredResourcesRef.current, extras.resources ?? []); if ( !sessionId || !currentPolicy || sending || !annotationsComplete || (!hasText && !hasAnnotations && resources.length === 0) || sendBlockedRef.current?.() === true || targetKeyRef.current !== targetKey ) { return false; } const sendText = hasText ? rawText : hasAnnotations ? "" : FILE_ONLY_MESSAGE_TEXT(t); const clientEventId = generateClientEventId(); const effectiveControl = effectiveControlRef.current; const input = composeSendInput(sendText, clientEventId, extras, { ...currentPolicy, ...(effectiveControl?.controlEtag ? { controlEtag: effectiveControl.controlEtag } : {}), resources, annotations: annotationsAtSend, }); const currentPayload = currentDraftPayload(); const hasEarlierUnsettledSend = optimisticSendsRef.current.some( (candidate) => candidate.state !== "failed" || candidate.outcomeUnknown === true, ); const operation: OptimisticSendOperation = { clientEventId, delivery: "send", destination: effectiveControl?.state === "paused" || hasEarlierUnsettledSend ? "queue" : (sendDestinationRef.current?.() ?? "chat"), text: sendText, annotations: annotationsAtSend, resources, occurredAt: new Date().toISOString(), state: "sending", input, draftPayload: currentPayload ? { ...currentPayload, text: sendText } : null, newerShadow: { text: explicit === undefined ? "" : valueRef.current, resources: explicit === undefined ? [] : [...restoredResourcesRef.current], annotations: explicit === undefined ? [] : cloneAnnotations(annotationsRef.current), policy: currentPolicy, }, canRetry: true, }; // The local submission acknowledgement owns the composer before any // host callback or queued processor can observe it. Hosts commonly use // onSubmitted to remove attachment/repository state, and those updates // may synchronously re-render the controlled composer. Clear the refs // first so that handoff cannot re-project the submitted text as the next // draft. The immutable operation above still owns the exact retry input. if (explicit === undefined) { valueRef.current = ""; annotationsRef.current = []; restoredResourcesRef.current = []; localEditRevision.current += 1; setValue(""); setAnnotations([]); setAnnotationReviewTargetId(null); setRestoredResources([]); } replaceOptimisticSends((current) => [...current, operation]); queueMicrotask(processOptimisticSends); setError(null); onSubmitted?.(sendText, input); return true; }, [ currentDraftPayload, dispatch, onSubmitted, processOptimisticSends, replaceOptimisticSends, sending, sessionId, targetKey, t, ], ); const steer = useCallback(async (text?: string) => await dispatch("steer", text), [dispatch]); // Send takes its busy path while a turn runs or prompts wait, except in a // paused workstream, where it keeps ordinary queueing so a Send never // resumes it implicitly. const takesBusyPath = useCallback( (): boolean => effectiveControlRef.current?.state !== "paused" && sendDestinationRef.current?.() === "queue", [], ); const send = useCallback( async (explicit?: string): Promise => { // A retained uncertain Send keeps its own delivery; only fresh input // follows the busy-send preference. if ( busySendRef.current === "steer" && pendingOperationRef.current?.delivery !== "send" && takesBusyPath() ) { return await steer(explicit); } return await sendToQueue(explicit); }, [sendToQueue, steer, takesBusyPath], ); const sendAlternate = useCallback( async (explicit?: string): Promise => busySendRef.current === "steer" ? await sendToQueue(explicit) : await steer(explicit), [sendToQueue, steer], ); const alternateSend: ComposerBusySend | null = !singleSend && takesBusyPath() ? (busySend === "steer" ? "queue" : "steer") : null; const retryOptimisticMessage = useCallback( (clientEventId: string): void => { if (sendBlockedRef.current?.() === true) return; const currentSteering = steeringRef.current; if (currentSteering?.phase === "failed" && currentSteering.clientEventId === clientEventId) { void dispatch("steer"); return; } replaceOptimisticSends((current) => current.map((operation) => { if (operation.clientEventId !== clientEventId || operation.state !== "failed") { return operation; } if (operation.outcomeUnknown) { return { ...operation, state: "sending", error: undefined }; } if (operation.retryable === false) return operation; const nextClientEventId = generateClientEventId(); const currentPersonalResourceAttachment = resolveSendExtras( sendExtrasRef.current, ).personalResourceAttachment; const retryInput = composeSendInput( operation.text, nextClientEventId, { ...operation.input, ...(currentPersonalResourceAttachment ? { personalResourceAttachment: currentPersonalResourceAttachment } : {}), }, { ...(effectiveControlRef.current?.controlEtag ? { controlEtag: effectiveControlRef.current.controlEtag } : {}), resources: operation.resources, annotations: operation.annotations, }, ); return { ...operation, clientEventId: nextClientEventId, input: retryInput, draftPayload: operation.draftPayload, occurredAt: new Date().toISOString(), state: "sending", error: undefined, retryable: undefined, outcomeUnknown: false, canRetry: true, }; }), ); queueMicrotask(processOptimisticSends); }, [dispatch, processOptimisticSends, replaceOptimisticSends], ); const removeOptimisticMessage = useCallback( (clientEventId: string): void => { if (steeringRef.current?.clientEventId === clientEventId) { pendingOperationRef.current = null; pendingClientEventId.current = null; forgetPendingComposerOperation(pendingComposerOperationKey(workspaceId, sessionId)); setSteering(null); setError(null); return; } replaceOptimisticSends((current) => current.filter((operation) => operation.clientEventId !== clientEventId), ); }, [replaceOptimisticSends, sessionId, workspaceId], ); // A send is possible with non-empty text OR with ≥1 attached resource (a // file-only message). Resources ride in `sendExtras`, so we resolve them here // — keeping useComposer attachment-agnostic while still lighting up the send // affordance the moment a file is ready. Attachment hosts bind `sendBlocked` // to unresolved uploads so direct send()/steer() calls fail closed too. const hasReadyResources = restoredResources.length > 0 || (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0; const hasPendingOperation = pendingOperationRef.current !== null; const annotationsComplete = annotations.every((annotation) => annotation.note.trim().length > 0); const pause = useCallback( async (reason?: string): Promise => { const ownedTargetKey = targetKey; const ownedGeneration = targetGeneration.current; if (!sessionId || pausing || targetKeyRef.current !== ownedTargetKey) { return; } setPausing(true); setError(null); try { const clientEventId = generateClientEventId(); const effectiveControl = effectiveControlRef.current; const result = await replayOutcomeUnknown(() => client.pauseSession(workspaceId, sessionId, { clientEventId, ...(reason !== undefined ? { reason } : {}), ...(effectiveControl?.controlEtag ? { expectedControlEtag: effectiveControl.controlEtag } : {}), }), ); if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { setAcceptedControl(result.effectiveControl); } } catch (cause) { if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { setError(normalizeError(cause)); } } finally { if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { setPausing(false); } } }, [client, workspaceId, sessionId, pausing, targetKey], ); const resume = useCallback( async (reason?: string): Promise => { const ownedTargetKey = targetKey; const ownedGeneration = targetGeneration.current; if (!sessionId || resuming || targetKeyRef.current !== ownedTargetKey) return; setResuming(true); setError(null); try { const clientEventId = generateClientEventId(); const effectiveControl = effectiveControlRef.current; const result = await replayOutcomeUnknown(() => client.resumeSession(workspaceId, sessionId, { clientEventId, ...(reason !== undefined ? { reason } : {}), ...(effectiveControl?.controlEtag ? { expectedControlEtag: effectiveControl.controlEtag } : {}), }), ); if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { setAcceptedControl(result.effectiveControl); } } catch (cause) { if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { setError(normalizeError(cause)); } } finally { if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { setResuming(false); } } }, [client, workspaceId, sessionId, resuming, targetKey], ); const resumeScope = useCallback( async (option: EffectiveControlResumeOption): Promise => { const ownedTargetKey = targetKey; const ownedGeneration = targetGeneration.current; if (!sessionId || resuming || targetKeyRef.current !== ownedTargetKey) return; setResuming(true); setError(null); try { if (option.scope === "workspace") { const workspaceBlocker = effectiveControlRef.current?.blockers.find( (blocker) => blocker.kind === "workspace", ); if (!client.setWorkspaceInferenceState) { throw new ComposerWorkspaceControlUnavailableError(); } const clientEventId = generateClientEventId(); await replayOutcomeUnknown(() => client.setWorkspaceInferenceState!(workspaceId, { action: "resume", clientEventId, ...(workspaceBlocker ? { expectedRevision: workspaceBlocker.revision } : {}), }), ); } else if (option.scope === "session" && option.targetId) { const target = await client.getQueue(workspaceId, option.targetId); if ( targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration ) { return; } const clientEventId = generateClientEventId(); await replayOutcomeUnknown(() => client.resumeSession(workspaceId, option.targetId!, { clientEventId, expectedControlEtag: target.effectiveControl.controlEtag, }), ); } else { const clientEventId = generateClientEventId(); const effectiveControl = effectiveControlRef.current; const result = await replayOutcomeUnknown(() => client.resumeSession(workspaceId, sessionId, { clientEventId, ...(effectiveControl?.controlEtag ? { expectedControlEtag: effectiveControl.controlEtag } : {}), }), ); if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { setAcceptedControl(result.effectiveControl); } } } catch (cause) { if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { setError(asError(cause)); } } finally { if ( targetKeyRef.current === ownedTargetKey && targetGeneration.current === ownedGeneration ) { setResuming(false); } } }, [client, resuming, sessionId, targetKey, workspaceId], ); const updateValue = useCallback( (next: string) => { if (targetKeyRef.current !== targetKey) return; localEditRevision.current += 1; valueRef.current = next; pendingOperationRef.current = updatePendingComposerShadow( pendingOperationKey, pendingOperationRef.current, { text: next, resources: mergeResources( restoredResourcesRef.current, resolveSendExtras(sendExtrasRef.current).resources ?? [], ), annotations: annotationsRef.current, policy: policyRef.current ?? undefined, }, ); setOptimisticDraftShadow({ text: next, resources: mergeResources( restoredResourcesRef.current, resolveSendExtras(sendExtrasRef.current).resources ?? [], ), annotations: annotationsRef.current, policy: policyRef.current ?? undefined, }); setValue(next); }, [pendingOperationKey, setOptimisticDraftShadow, targetKey], ); const updateAnnotations = useCallback( (next: DraftTimelineAnnotation[], reviewTargetId: string | null = null) => { if (targetKeyRef.current !== targetKey) return; localEditRevision.current += 1; annotationsRef.current = next; pendingOperationRef.current = updatePendingComposerShadow( pendingOperationKey, pendingOperationRef.current, { text: valueRef.current, resources: mergeResources( restoredResourcesRef.current, resolveSendExtras(sendExtrasRef.current).resources ?? [], ), annotations: next, policy: policyRef.current ?? undefined, }, ); setOptimisticDraftShadow({ text: valueRef.current, resources: mergeResources( restoredResourcesRef.current, resolveSendExtras(sendExtrasRef.current).resources ?? [], ), annotations: next, policy: policyRef.current ?? undefined, }); setAnnotations(next); setAnnotationReviewTargetId(reviewTargetId); }, [pendingOperationKey, setOptimisticDraftShadow, targetKey], ); const addAnnotation = useCallback( (annotation: DraftTimelineAnnotation) => { const next = [...annotationsRef.current, annotation]; updateAnnotations(next, annotation.id); }, [updateAnnotations], ); const updateAnnotation = useCallback( (id: string, note: string) => { updateAnnotations( annotationsRef.current.map((annotation) => annotation.id === id ? { ...annotation, note } : annotation, ), ); }, [updateAnnotations], ); const removeAnnotation = useCallback( (id: string) => { updateAnnotations(annotationsRef.current.filter((annotation) => annotation.id !== id)); }, [updateAnnotations], ); const clearAnnotationReviewTarget = useCallback(() => { setAnnotationReviewTargetId(null); }, []); const requestAnnotationReview = useCallback((id?: string) => { const current = annotationsRef.current; if (current.length === 0) { setAnnotationReviewTargetId(null); return; } if (id && current.some((annotation) => annotation.id === id)) { setAnnotationReviewTargetId(id); return; } const incomplete = current.find((annotation) => annotation.note.trim().length === 0); setAnnotationReviewTargetId(incomplete?.id ?? current[0]!.id); }, []); const updatePolicy = useCallback( (next: ComposerPolicy): void => { if (targetKeyRef.current !== targetKey) return; localEditRevision.current += 1; policyRef.current = next; const shadow = { text: valueRef.current, resources: mergeResources( restoredResourcesRef.current, resolveSendExtras(sendExtrasRef.current).resources ?? [], ), annotations: annotationsRef.current, policy: next, }; pendingOperationRef.current = updatePendingComposerShadow( pendingOperationKey, pendingOperationRef.current, shadow, ); setOptimisticDraftShadow(shadow); setPolicy(next); }, [pendingOperationKey, setOptimisticDraftShadow, targetKey], ); const updateModel = useCallback( (model: string): void => { const current = policyRef.current; if (current) updatePolicy({ ...current, model }); }, [updatePolicy], ); const updateReasoningEffort = useCallback( (reasoningEffort: ReasoningEffort): void => { const current = policyRef.current; if (current) updatePolicy({ ...current, reasoningEffort }); }, [updatePolicy], ); const updateLatencyMode = useCallback( (latencyMode: LatencyMode): void => { const current = policyRef.current; if (current) updatePolicy({ ...current, latencyMode }); }, [updatePolicy], ); const removeRestoredResource = useCallback( (index: number) => { if (targetKeyRef.current !== targetKey) return; localEditRevision.current += 1; const next = restoredResourcesRef.current.filter((_, candidate) => candidate !== index); restoredResourcesRef.current = next; pendingOperationRef.current = updatePendingComposerShadow( pendingOperationKey, pendingOperationRef.current, { text: valueRef.current, resources: mergeResources(next, resolveSendExtras(sendExtrasRef.current).resources ?? []), annotations: annotationsRef.current, policy: policyRef.current ?? undefined, }, ); setOptimisticDraftShadow({ text: valueRef.current, resources: mergeResources(next, resolveSendExtras(sendExtrasRef.current).resources ?? []), annotations: annotationsRef.current, policy: policyRef.current ?? undefined, }); setRestoredResources(next); }, [pendingOperationKey, setOptimisticDraftShadow, targetKey], ); const hasDraftContent = useCallback((): boolean => { const current = draftRef.current; const extras = resolveSendExtras(sendExtrasRef.current); return ( valueRef.current.length > 0 || annotationsRef.current.length > 0 || restoredResourcesRef.current.length > 0 || (extras.resources?.length ?? 0) > 0 || (current?.sourceTurnId !== null && current?.sourceTurnId !== undefined) ); }, []); const restoreOptimisticMessage = useCallback( (clientEventId: string): void => { if ( targetKeyRef.current !== targetKey || draftLoading || pendingOperationRef.current || hasDraftContent() ) return; const operation = optimisticSendsRef.current.find( (candidate) => candidate.clientEventId === clientEventId, ); if (!operation || operation.state !== "failed" || operation.outcomeUnknown) return; // Restoring is a new draft edit, not a retry. Keep the user's current model // selection and never copy credentials from the old rejected request. localEditRevision.current += 1; valueRef.current = operation.text; restoredResourcesRef.current = [...operation.resources]; annotationsRef.current = cloneAnnotations(operation.annotations); setValue(operation.text); setRestoredResources(restoredResourcesRef.current); setAnnotations(annotationsRef.current); setError(null); setOptimisticDraftShadow({ text: operation.text, resources: restoredResourcesRef.current, annotations: annotationsRef.current, policy: policyRef.current ?? undefined, }); replaceOptimisticSends((current) => current.filter((candidate) => candidate.clientEventId !== clientEventId), ); }, [draftLoading, hasDraftContent, replaceOptimisticSends, setOptimisticDraftShadow, targetKey], ); const resolveDraftConflict = useCallback( async (choice: "keep_mine" | "use_remote"): Promise => { const ownedTargetKey = targetKey; const ownedGeneration = targetGeneration.current; if (!sessionId || !durableDrafts || targetKeyRef.current !== ownedTargetKey) return; const remote = await client.getComposerDraft(workspaceId, sessionId); if (targetKeyRef.current !== ownedTargetKey || targetGeneration.current !== ownedGeneration) { return; } if (choice === "use_remote") { applyDraft(remote); setError(null); return; } draftRef.current = remote; setDraft(remote); setDraftConflict(null); setError(null); const payload = currentDraftPayload(); if (payload) await persistPayload({ ...payload, expectedRevision: remote.revision }); }, [ applyDraft, client, currentDraftPayload, durableDrafts, persistPayload, sessionId, targetKey, workspaceId, ], ); const identityMatches = stateTargetKey === targetKey; const visibleSteering = identityMatches ? steering : null; const visibleOptimisticMessages: ComposerOptimisticMessage[] = identityMatches ? [ ...optimisticSends.map( ({ input: _input, draftPayload: _payload, canRetry: _retry, ...item }) => item, ), ...(visibleSteering?.clientEventId ? [ { clientEventId: visibleSteering.clientEventId, delivery: "steer" as const, destination: "chat" as const, text: visibleSteering.text, annotations: pendingOperationRef.current?.delivery === "steer" ? pendingOperationRef.current.annotationsAtSend : [], resources: pendingOperationRef.current?.delivery === "steer" ? pendingOperationRef.current.resourcesAtSend : [], occurredAt: steeringOccurredAtRef.current, state: visibleSteering.phase === "submitting" ? ("sending" as const) : visibleSteering.phase === "failed" ? ("failed" as const) : ("queued" as const), turnId: visibleSteering.turnId, triggerEventId: visibleSteering.triggerEventId, ...(visibleSteering.error ? { error: visibleSteering.error } : {}), ...(visibleSteering.outcomeUnknown !== undefined ? { outcomeUnknown: visibleSteering.outcomeUnknown } : {}), }, ] : []), ] : []; const reloadDraft = useCallback(async () => await loadDraft(true), [loadDraft]); const clearError = useCallback(() => { if (targetKeyRef.current !== targetKey) return; setError(null); setDraftReadError(null); setDraftConflict(null); }, [targetKey]); // `valueRef` is the synchronous composer authority. React state exists to // schedule renders, but a concurrent autosave settlement can render the // previous state lane after a newer input event has already updated the ref. // Projecting that stale state into a controlled textarea rewrites the old // draft for one commit, which moves the caret and can briefly resurrect a // submitted message. Never let an incidental render outrank the latest // local lifecycle decision. const visibleValue = identityMatches ? valueRef.current : ""; return { value: visibleValue, setValue: updateValue, annotations: identityMatches ? annotations : [], addAnnotation, updateAnnotation, removeAnnotation, annotationReviewTargetId: identityMatches ? annotationReviewTargetId : null, clearAnnotationReviewTarget, requestAnnotationReview, hasDraftContent, send, sendToQueue, busySend, singleSend, alternateSend: identityMatches ? alternateSend : null, sendAlternate: singleSend ? undefined : sendAlternate, optimisticMessages: visibleOptimisticMessages, retryOptimisticMessage, restoreOptimisticMessage: !draftLoading && !pendingOperationRef.current && !hasDraftContent() ? restoreOptimisticMessage : undefined, removeOptimisticMessage, steer, steering: visibleSteering, stoppingAttempt: identityMatches ? visibleSteering?.phase === "accepted" && visibleSteering.stoppingPreviousAttempt === true ? "previous" : null : null, sending: identityMatches ? sending : false, canSend: identityMatches && Boolean(sessionId) && !sending && !draftLoading && policy !== null && sendBlockedRef.current?.() !== true && annotationsComplete && (hasPendingOperation || visibleValue.trim().length > 0 || hasReadyResources || annotations.length > 0), pause, pausing: identityMatches ? pausing : false, resume, resumeScope, resuming: identityMatches ? resuming : false, effectiveControl: identityMatches ? effectiveControlRef.current : null, draft: identityMatches ? draft : null, draftRevision: identityMatches ? (draft?.revision ?? 0) : 0, draftLoading: identityMatches ? draftLoading : Boolean(sessionId) && durableDrafts, draftSaving: identityMatches ? draftSaving : false, draftConflict: identityMatches ? draftConflict : null, policy: identityMatches ? policy : null, setModel: updateModel, setReasoningEffort: updateReasoningEffort, setLatencyMode: updateLatencyMode, draftPersistence: durableDrafts ? "durable" : "disabled", applyDraft, reloadDraft, resolveDraftConflict, restoredResources: identityMatches ? restoredResources : [], removeRestoredResource, error: identityMatches ? (error ?? draftReadError) : null, clearError, }; } /** Events that can atomically replace or clear this subject's durable draft. */ export function isComposerDraftEvent(event: Pick): boolean { return event.type === "user.message" || event.type === "session.queue.changed"; } /** * Default text for a resource-only message (attachments present, no typed * draft). Kept non-empty so the wire contract (`text: z.string().min(1)`) and * the worker's non-whitespace guard accept it. The export name is retained for * compatibility now that repositories can ride beside files in `resources`. */ export const FILE_ONLY_MESSAGE_TEXT = (t: OpenGeniText) => t("hooks.useComposer.seeAttachedContext"); /** Resolve possibly-deferred extras to a concrete bag (function evaluated now). */ export function resolveSendExtras( extras: ComposerSendExtras | (() => ComposerSendExtras) | undefined, ): ComposerSendExtras { return (typeof extras === "function" ? extras() : extras) ?? {}; } /** * Merge the draft text + idempotency key with caller-provided extras. The * text and clientEventId always win over extras. Exported for tests. */ export function composeSendInput( text: string, clientEventId: string, extras: ComposerSendExtras | (() => ComposerSendExtras) | undefined, bound: Partial = {}, ): SendMessageInput { return { ...resolveSendExtras(extras), ...bound, text, clientEventId }; } /** Submit on plain Enter; Shift+Enter inserts a newline. Exported for tests. */ export function shouldSubmitOnKey(event: { key: string; shiftKey: boolean; metaKey?: boolean; ctrlKey?: boolean; nativeEvent?: { isComposing?: boolean }; }): boolean { if (event.key !== "Enter" || event.shiftKey) { return false; } return event.nativeEvent?.isComposing !== true; } /** Cmd/Ctrl+Enter steers; ordinary Enter appends to the queue. */ export function shouldSteerOnKey(event: { metaKey?: boolean; ctrlKey?: boolean }): boolean { return event.metaKey === true || event.ctrlKey === true; } function generateClientEventId(): string { return globalThis.crypto.randomUUID(); } function isOutcomeUnknownError(cause: unknown): boolean { return ( typeof cause === "object" && cause !== null && (cause as { outcomeUnknown?: unknown }).outcomeUnknown === true ); } async function replayOutcomeUnknown(command: () => Promise): Promise { try { return await command(); } catch (cause) { if (!isOutcomeUnknownError(cause)) throw cause; return await command(); } } function asError(cause: unknown): Error { return normalizeError(cause); } function promptRoutingFromAcceptedEvent( event: SessionEvent | null, ): "accepted_for_execution" | "queued_for_execution" | "accepted_for_steering" | null { if ( typeof event?.payload !== "object" || event.payload === null || Array.isArray(event.payload) ) { return null; } const routing = (event.payload as Record).routing; return routing === "accepted_for_execution" || routing === "queued_for_execution" || routing === "accepted_for_steering" ? routing : null; } function isDraftConflictError(error: Error): boolean { const apiError = error as Partial; return ( apiError.status === 409 && apiError.outcomeUnknown !== true && apiError.code === "DRAFT_CHANGED" ); } function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest { return { expectedRevision: draft.revision, text: draft.text, resources: draft.resources, annotations: draft.annotations ?? [], model: draft.model, reasoningEffort: draft.reasoningEffort, latencyMode: draft.latencyMode, }; } function policyFromDraft(draft: ComposerDraft): ComposerPolicy { return { model: draft.model, reasoningEffort: draft.reasoningEffort, latencyMode: draft.latencyMode, }; } function composerDraftPayload( base: ComposerDraft, text: string, restoredResources: ResourceRef[], annotations: DraftTimelineAnnotation[], policy: ComposerPolicy, additionalResources: ResourceRef[], ): SaveComposerDraftRequest { return { expectedRevision: base.revision, text, resources: mergeResources(restoredResources, additionalResources), annotations, model: policy.model, reasoningEffort: policy.reasoningEffort, latencyMode: policy.latencyMode, }; } function draftSignature(payload: SaveComposerDraftRequest): string { const { expectedRevision: _revision, ...content } = payload; return JSON.stringify(content); } function mergeResources(base: ResourceRef[], additions: ResourceRef[]): ResourceRef[] { const seen = new Set(); return [...base, ...additions].filter((resource) => { // Reconnect reconciliation can restore the canonical server form while // the still-mounted upload card supplies the same ready file without its // default mount. Treat those two wire shapes as one selected attachment; // preserving the first representation keeps custom mounts and ordering // intact while preventing the draft and command paths from seeing // different duplicate counts after server normalization. const key = resource.kind === "file" ? `file:${resource.fileId}\u0000${resource.mountPath ?? `${DEFAULT_FILE_RESOURCE_MOUNT_ROOT}/${resource.fileId}`}` : JSON.stringify(resource); if (seen.has(key)) return false; seen.add(key); return true; }); }