| 1 | // The remote turn fork: the serve owns the child, while Desktop owns the |
| 2 | // durable operation id used to recover an unknown result. The renderer carries |
| 3 | // only the anchored target and acknowledges the operation after navigation. |
| 4 | |
| 5 | import { useCallback, useEffect, useRef, type RefObject } from "react"; |
| 6 | import { forkCreateFailureText, type ForkTargetSetView, type ForkTargetView } from "./forkTargets"; |
| 7 | import { t } from "./i18n"; |
| 8 | import { reducer, type State } from "./useController"; |
| 9 | import type { ForkAnchorView, ForkCreationView } from "../generated/desktopContract.generated"; |
| 10 | |
| 11 | /** The bridge commands a remote turn fork needs, so a caller can supply its own. */ |
| 12 | export interface RemoteForkBindings { |
| 13 | ForkTargetsRemoteTab(tabID: string): Promise<ForkTargetSetView>; |
| 14 | CreateForkRemoteTab(tabID: string, anchor: ForkAnchorView): Promise<ForkCreationView>; |
| 15 | AcknowledgeForkOperation(tabID: string, operationID: string): Promise<void>; |
| 16 | } |
| 17 | |
| 18 | /** |
| 19 | * Reads a remote tab's fork boundaries, or undefined when this shell cannot ask. |
| 20 | * The serve derives them from its own committed turn records, so the read is |
| 21 | * valid while a turn runs, and an empty set means "no completed turn here" — |
| 22 | * never "this serve cannot fork", which its advertised capability decides. |
| 23 | */ |
| 24 | export async function readRemoteForkTargets(bindings: RemoteForkBindings, tabId: string): Promise<ForkTargetSetView | undefined> { |
| 25 | // A shell that predates the command reads no targets instead of failing the |
| 26 | // transcript around it. |
| 27 | if (typeof bindings.ForkTargetsRemoteTab !== "function") return undefined; |
| 28 | const set = await bindings.ForkTargetsRemoteTab(tabId).catch(() => undefined); |
| 29 | if (!set) return undefined; |
| 30 | return { ...set, targets: Array.isArray(set.targets) ? set.targets : [], verifiable: Boolean(set.verifiable) }; |
| 31 | } |
| 32 | |
| 33 | /** What one remote fork request produced: the child's id, or the refusal to show the user. */ |
| 34 | export type RemoteForkOutcome = { kind: "created"; sessionId: string; operationId: string } | { kind: "refused"; failure: string }; |
| 35 | |
| 36 | /** Creates or recovers one remote child through Desktop's durable operation. */ |
| 37 | export async function requestRemoteForkTurn( |
| 38 | bindings: RemoteForkBindings, |
| 39 | tabId: string, |
| 40 | target: ForkTargetView, |
| 41 | ): Promise<RemoteForkOutcome> { |
| 42 | try { |
| 43 | const created = await bindings.CreateForkRemoteTab(tabId, target); |
| 44 | if (created?.sessionId && created.operationId) return { kind: "created", sessionId: created.sessionId, operationId: created.operationId }; |
| 45 | // Structured reasons are localized here; ordinary diagnostics retain the |
| 46 | // host's message. |
| 47 | const detail = created?.error?.trim() ?? ""; |
| 48 | return { kind: "refused", failure: detail ? forkCreateFailureText(detail, created?.reason) : t("chat.branchFailed") }; |
| 49 | } catch (error) { |
| 50 | return { kind: "refused", failure: forkCreateFailureText(error) }; |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | /** The anchored fork operations one remote session surface exposes. */ |
| 55 | export interface RemoteForkTurnApi { |
| 56 | /** Creates or recovers the child for one anchored operation. */ |
| 57 | forkTurn: (target: ForkTargetView) => Promise<{ sessionId: string; operationId: string } | undefined>; |
| 58 | acknowledgeFork: (operationId: string) => Promise<void>; |
| 59 | /** |
| 60 | * The read the connection effect runs once hydration lands and once a turn |
| 61 | * finishes. That effect owns its own install and uninstall per connection |
| 62 | * generation, so it reaches the read through this ref rather than closing |
| 63 | * over a callback identity it cannot depend on. |
| 64 | */ |
| 65 | forkTargetsRefreshRef: RefObject<(() => Promise<void>) | null>; |
| 66 | } |
| 67 | |
| 68 | /** |
| 69 | * Owns one remote session's fork reads and creates. Each read is fenced by the |
| 70 | * current session identity and a local sequence so a response from a replaced |
| 71 | * connection cannot enter the transcript. |
| 72 | */ |
| 73 | export function useRemoteForkTurn( |
| 74 | bindings: RemoteForkBindings, |
| 75 | tabId: string | undefined, |
| 76 | sessionPath: string | undefined, |
| 77 | setTranscript: (update: (state: State) => State) => void, |
| 78 | setPromptError: (error: string) => void, |
| 79 | ): RemoteForkTurnApi { |
| 80 | const session = sessionPath ?? ""; |
| 81 | const readSeqRef = useRef(0); |
| 82 | const readIdentityRef = useRef(""); |
| 83 | readIdentityRef.current = `${tabId ?? ""}\0${session}`; |
| 84 | const forkTargetsRefreshRef = useRef<(() => Promise<void>) | null>(null); |
| 85 | const refreshForkTargets = useCallback(async (): Promise<void> => { |
| 86 | if (!tabId) return; |
| 87 | const readSeq = ++readSeqRef.current; |
| 88 | const readIdentity = readIdentityRef.current; |
| 89 | const targets = await readRemoteForkTargets(bindings, tabId); |
| 90 | if (!targets || readSeqRef.current !== readSeq || readIdentityRef.current !== readIdentity) return; |
| 91 | setTranscript((current) => reducer(current, { type: "fork_targets", targets })); |
| 92 | }, [bindings, session, setTranscript, tabId]); |
| 93 | useEffect(() => { forkTargetsRefreshRef.current = refreshForkTargets; }, [refreshForkTargets]); |
| 94 | |
| 95 | const forkTurn = useCallback(async (target: ForkTargetView): Promise<{ sessionId: string; operationId: string } | undefined> => { |
| 96 | if (!tabId || !target?.turnId) return undefined; |
| 97 | setPromptError(""); |
| 98 | const outcome = await requestRemoteForkTurn(bindings, tabId, target); |
| 99 | if (outcome.kind === "created") return { sessionId: outcome.sessionId, operationId: outcome.operationId }; |
| 100 | setPromptError(outcome.failure); |
| 101 | return undefined; |
| 102 | }, [bindings, setPromptError, tabId]); |
| 103 | |
| 104 | const acknowledgeFork = useCallback(async (operationId: string) => { |
| 105 | if (tabId && operationId) await bindings.AcknowledgeForkOperation(tabId, operationId); |
| 106 | }, [bindings, tabId]); |
| 107 | |
| 108 | return { forkTurn, acknowledgeFork, forkTargetsRefreshRef }; |
| 109 | } |
| 110 |