| 1 | import type { AppBindings } from "./bridge"; |
| 2 | import type { InvocationRequest, StructuredInvocationSubmit } from "./invocationDisplay"; |
| 3 | |
| 4 | export type InboxTarget = { tabId: string; sessionPath: string; generation: number; selection: number; remote: boolean; hostId?: string; workspace?: string }; |
| 5 | export type FollowupReceipt = { itemId: string; disposition: string; position: number; paused: boolean; idempotent?: boolean; error?: string }; |
| 6 | export interface FollowupBindings { |
| 7 | EnqueueInboxFollowup(tabID: string, display: string, submit: string, idempotency: string): Promise<FollowupReceipt>; |
| 8 | EnqueueInboxFollowupWithInvocations(tabID: string, display: string, submit: string, invocations: InvocationRequest[], idempotency: string): Promise<FollowupReceipt>; |
| 9 | CaptureInboxTarget?(tabID: string, expectedPath: string): Promise<InboxTarget>; |
| 10 | EnqueueInboxFollowupForTarget?(target: InboxTarget, display: string, submit: string, invocations: InvocationRequest[], key: string): Promise<FollowupReceipt>; |
| 11 | LookupInboxFollowupForTarget?(target: InboxTarget, key: string): Promise<FollowupReceipt>; |
| 12 | } |
| 13 | export type PendingFollowup = { |
| 14 | key: string; target?: InboxTarget; tabId: string; display: string; submit: string; |
| 15 | structured?: StructuredInvocationSubmit; draft: string; |
| 16 | }; |
| 17 | |
| 18 | // TabMeta supplies the backend's session path. UI topic/draft keys and tab |
| 19 | // generations are deliberately excluded from this durable request identity. |
| 20 | export function followupSessionKey(sessionPath?: string, hostId?: string, workspace?: string): string { |
| 21 | const path = sessionPath?.trim(); |
| 22 | if (!path || (hostId && !workspace)) return ""; |
| 23 | return JSON.stringify(hostId ? ["remote", hostId, workspace, path] : ["local", path]); |
| 24 | } |
| 25 | |
| 26 | // Process-local draft ownership survives Composer remounts. Nothing here is |
| 27 | // inferred from the currently executing turn, and unresolved entries never expire. |
| 28 | const requests = new Map<string, PendingFollowup>(); |
| 29 | const listeners = new Set<() => void>(); |
| 30 | export const pendingFollowups = { |
| 31 | get: (draft: string) => requests.get(draft), |
| 32 | subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }, |
| 33 | set(draft: string, request: PendingFollowup) { requests.set(draft, request); listeners.forEach(listener => listener()); }, |
| 34 | clear(draft: string, request: PendingFollowup) { |
| 35 | if (requests.get(draft) !== request) return; |
| 36 | requests.delete(draft); listeners.forEach(listener => listener()); |
| 37 | }, |
| 38 | }; |
| 39 | |
| 40 | export async function confirmFollowup(binding: AppBindings, request: PendingFollowup): Promise<FollowupReceipt> { |
| 41 | if (!request.target || !binding.LookupInboxFollowupForTarget) throw new Error("Follow-up receipt lookup unavailable"); |
| 42 | const receipt = await binding.LookupInboxFollowupForTarget(request.target, request.key); |
| 43 | if (!receipt?.itemId || receipt.error) throw new Error(receipt?.error || "Follow-up receipt unconfirmed"); |
| 44 | return receipt; |
| 45 | } |
| 46 | |
| 47 | export function followupNotSubmitted(error: unknown): boolean { |
| 48 | return /reasonix_error:(inbox_not_submitted|inbox_capacity_items|inbox_capacity_bytes|inbox_item_too_large|inbox_empty|inbox_schema_readonly|channel_read_only)(?:$|\b)/.test(String(error)); |
| 49 | } |
| 50 |