| 1 | import type { AppBindings } from "./bridge"; |
| 2 | import type { StructuredInvocationSubmit } from "./invocationDisplay"; |
| 3 | import { pendingFollowups, type PendingFollowup } from "./pendingFollowup"; |
| 4 | import { formatInboxError } from "./inboxError"; |
| 5 | import type { Translator } from "./i18n"; |
| 6 | import { TRANSIENT_GUIDANCE_RETRY_DELAYS_MS, isTransientInboxTargetError } from "./transientInboxTarget"; |
| 7 | |
| 8 | type RetryContext = { |
| 9 | app: AppBindings; |
| 10 | transientRetriesRef: { current: Map<string, { cancelled: boolean }> }; |
| 11 | pendingKeyRef: { current: string }; |
| 12 | submitPendingKey: string; |
| 13 | submitDraftKey: string; |
| 14 | submitTabId?: string; |
| 15 | inboxSessionPath?: string; |
| 16 | submittedDraft: string; |
| 17 | queueOnly: boolean; |
| 18 | followupDraftFingerprint: (key: string) => string; |
| 19 | clearSubmittedDraft: (key: string) => void; |
| 20 | setGuidanceRetryNonce: (update: (value: number) => number) => void; |
| 21 | showToast: (message: string, kind: "info" | "warn") => void; |
| 22 | t: Translator; |
| 23 | locale: Parameters<typeof formatInboxError>[1]; |
| 24 | }; |
| 25 | |
| 26 | export function createTransientGuidance(context: RetryContext) { |
| 27 | const { app, transientRetriesRef, pendingKeyRef, submitPendingKey, submitDraftKey, submitTabId, |
| 28 | inboxSessionPath, submittedDraft, queueOnly, followupDraftFingerprint, clearSubmittedDraft, |
| 29 | setGuidanceRetryNonce, showToast, t, locale } = context; |
| 30 | // Retry temporary route fences while preserving the exact pending request. |
| 31 | // Exhausted requests remain visible for manual reconciliation. |
| 32 | const retryTransientGuidanceEnqueue = ( |
| 33 | request: PendingFollowup, |
| 34 | options: { |
| 35 | pendingKey: string; |
| 36 | submitDraftKey: string; |
| 37 | submitTabId: string; |
| 38 | sessionPath: string; |
| 39 | submittedDraft: string; |
| 40 | queueOnly: boolean; |
| 41 | turnId?: string; |
| 42 | }, |
| 43 | ) => { |
| 44 | if (transientRetriesRef.current.has(request.key)) return; |
| 45 | const token = { cancelled: false }; |
| 46 | transientRetriesRef.current.set(request.key, token); |
| 47 | void (async () => { |
| 48 | const { enqueueComposerGuidance } = await import("./inboxGuidanceSubmit"); |
| 49 | for (const delayMs of TRANSIENT_GUIDANCE_RETRY_DELAYS_MS) { |
| 50 | await new Promise((resolve) => window.setTimeout(resolve, delayMs)); |
| 51 | if (token.cancelled || transientRetriesRef.current.get(request.key) !== token) return; |
| 52 | // The user moved to another session: leave the pending request for |
| 53 | // that session's own reconciliation instead of retrying a gone route. |
| 54 | if (options.pendingKey !== pendingKeyRef.current) { |
| 55 | transientRetriesRef.current.delete(request.key); |
| 56 | return; |
| 57 | } |
| 58 | try { |
| 59 | const target = app.CaptureInboxTarget |
| 60 | ? await app.CaptureInboxTarget(options.submitTabId, options.sessionPath) |
| 61 | : undefined; |
| 62 | const receipt = await enqueueComposerGuidance(app, { ...request, target }, options.queueOnly, options.turnId); |
| 63 | if (receipt?.error) throw new Error(receipt.error); |
| 64 | if (!receipt?.itemId) throw new Error("Follow-up receipt unconfirmed"); |
| 65 | pendingFollowups.clear(options.pendingKey, request); |
| 66 | if (followupDraftFingerprint(options.submitDraftKey) === options.submittedDraft) clearSubmittedDraft(options.submitDraftKey); |
| 67 | setGuidanceRetryNonce((value) => value + 1); |
| 68 | showToast(t("runtime.queued"), "info"); |
| 69 | transientRetriesRef.current.delete(request.key); |
| 70 | return; |
| 71 | } catch (error) { |
| 72 | if (isTransientInboxTargetError(error)) continue; |
| 73 | // A permanent refusal is actionable: surface it and drop the hold. |
| 74 | pendingFollowups.clear(options.pendingKey, request); |
| 75 | showToast(formatInboxError(error, locale), "warn"); |
| 76 | transientRetriesRef.current.delete(request.key); |
| 77 | return; |
| 78 | } |
| 79 | } |
| 80 | // The window outlasted the bound: keep the pending request so its |
| 81 | // banner stays visible and a later send reconciles the receipt. |
| 82 | if (transientRetriesRef.current.get(request.key) === token) transientRetriesRef.current.delete(request.key); |
| 83 | })(); |
| 84 | }; |
| 85 | const holdTransientGuidance = ( |
| 86 | error: unknown, |
| 87 | input: { display: string; submit: string; structured?: StructuredInvocationSubmit; turnId?: string }, |
| 88 | ): boolean => { |
| 89 | if (!isTransientInboxTargetError(error)) return false; |
| 90 | if (!submitPendingKey) return false; |
| 91 | const existing = pendingFollowups.get(submitPendingKey); |
| 92 | const request: PendingFollowup = existing ?? { |
| 93 | key: `followup-${crypto.randomUUID()}`, |
| 94 | tabId: submitTabId || "", |
| 95 | display: input.display, |
| 96 | submit: input.submit, |
| 97 | structured: input.structured, |
| 98 | draft: submittedDraft, |
| 99 | }; |
| 100 | pendingFollowups.set(submitPendingKey, request); |
| 101 | retryTransientGuidanceEnqueue(request, { |
| 102 | pendingKey: submitPendingKey, |
| 103 | submitDraftKey, |
| 104 | submitTabId: submitTabId || "", |
| 105 | sessionPath: inboxSessionPath || "", |
| 106 | submittedDraft, |
| 107 | queueOnly, |
| 108 | turnId: input.turnId, |
| 109 | }); |
| 110 | return true; |
| 111 | }; |
| 112 | |
| 113 | return holdTransientGuidance; |
| 114 | } |
| 115 | |
| 116 | export async function captureStableInboxTarget(app: AppBindings, tabId: string, sessionPath: string) { |
| 117 | if (!app.CaptureInboxTarget) return undefined; |
| 118 | for (let attempt = 0; ; attempt++) { |
| 119 | try { return await app.CaptureInboxTarget(tabId, sessionPath); } |
| 120 | catch (error) { |
| 121 | if (!isTransientInboxTargetError(error) || attempt >= TRANSIENT_GUIDANCE_RETRY_DELAYS_MS.length) throw error; |
| 122 | await new Promise(resolve => window.setTimeout(resolve, TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[attempt])); |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 |