| 1 | // Fork eligibility comes from the host's persisted turn records, never from |
| 2 | // checkpoints: a checkpoint knows a message count, while a turn record proves an |
| 3 | // atomic commit boundary. One transcript turn maps to its boundary through the |
| 4 | // assistant message identity, so live completion, history paging, and cold |
| 5 | // restore all resolve the same target. |
| 6 | |
| 7 | import type { ForkAnchorView, ForkCreationView, ForkTargetSetView, ForkTargetView } from "../generated/desktopContract.generated"; |
| 8 | import { t, type DictKey } from "./i18n"; |
| 9 | |
| 10 | export type { ForkTargetSetView, ForkTargetView }; |
| 11 | |
| 12 | /** The create-only fork commands the host binds per surface: local tabs and remote ones. */ |
| 13 | export interface ForkTargetsBindings { |
| 14 | ForkTargetsForTab(tabID: string): Promise<ForkTargetSetView>; |
| 15 | CreateForkForTab(tabID: string, anchor: ForkAnchorView): Promise<ForkCreationView>; |
| 16 | AcknowledgeForkOperation(tabID: string, operationID: string): Promise<void>; |
| 17 | } |
| 18 | |
| 19 | /** |
| 20 | * Why one transcript turn's fork entry offers no fork. Each state keeps its own |
| 21 | * explanation, because the collapsed "unavailable" it replaces could not tell a |
| 22 | * running turn from legacy history or from a surface that cannot create a child. |
| 23 | */ |
| 24 | export type ForkBlockReason = "loading" | "turn_open" | "unverifiable" | "active_authority" | "stale_source" | "read_only" | "unsupported" | "creating"; |
| 25 | |
| 26 | const FORK_REASON_KEYS: Record<ForkBlockReason, DictKey> = { |
| 27 | loading: "chat.branchLoading", |
| 28 | turn_open: "chat.branchTurnOpen", |
| 29 | unverifiable: "chat.branchUnverifiable", |
| 30 | active_authority: "chat.branchActiveAuthority", |
| 31 | stale_source: "chat.branchStaleSource", |
| 32 | read_only: "chat.branchReadOnly", |
| 33 | unsupported: "chat.branchUnsupported", |
| 34 | creating: "chat.branchCreating", |
| 35 | }; |
| 36 | |
| 37 | /** The locale key explaining one block reason, shared by the tooltip and its screen-reader text. */ |
| 38 | export function forkReasonKey(reason: ForkBlockReason): DictKey { |
| 39 | return FORK_REASON_KEYS[reason]; |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * The persisted message identity a transcript item key names, or undefined when |
| 44 | * the key carries none. Assistant and user items are keyed `m:<messageId>`; |
| 45 | * history entries without a message id keep a positional key that names no |
| 46 | * durable message, so no boundary can be resolved from it. |
| 47 | */ |
| 48 | export function forkAnswerMessageId(itemKey: string | undefined): string | undefined { |
| 49 | return itemKey?.startsWith("m:") ? itemKey.slice(2) : undefined; |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * The persisted boundary for one turn's answer. Identity is the only match: an |
| 54 | * array position or a page offset moves to another turn as soon as history is |
| 55 | * prepended or a page is reloaded. |
| 56 | */ |
| 57 | export function forkTargetForAnswer(set: ForkTargetSetView | undefined, answerKey: string | undefined): ForkTargetView | undefined { |
| 58 | const messageId = forkAnswerMessageId(answerKey); |
| 59 | if (!messageId || !set) return undefined; |
| 60 | return set.targets.find((target) => target.messageId === messageId); |
| 61 | } |
| 62 | |
| 63 | /** |
| 64 | * The refusal one target carries, or null when it may start a child. Each reason |
| 65 | * the host names keeps its own explanation: a boundary it could not prove and |
| 66 | * one it refused as unsafe are different facts, and only the first is an absent |
| 67 | * boundary. |
| 68 | */ |
| 69 | export function forkTargetReason(target: ForkTargetView): ForkBlockReason | null { |
| 70 | if (target.available) return null; |
| 71 | if (target.reason === "turn_open") return "turn_open"; |
| 72 | return target.reason === "active_authority" ? "active_authority" : "unverifiable"; |
| 73 | } |
| 74 | |
| 75 | /** |
| 76 | * The block reason for one tail node, or null when its target may fork. An |
| 77 | * unmatched answer means the source proves boundaries but holds none for this |
| 78 | * message: the open turn before its reply exists, or a turn that committed no |
| 79 | * reply at all. |
| 80 | */ |
| 81 | export function forkBlockReason(input: { |
| 82 | target: ForkTargetView | undefined; |
| 83 | loaded: boolean; |
| 84 | verifiable: boolean; |
| 85 | blocked: ForkBlockReason | null; |
| 86 | latest: boolean; |
| 87 | }): ForkBlockReason | null { |
| 88 | if (input.blocked) return input.blocked; |
| 89 | if (!input.loaded) return "loading"; |
| 90 | if (!input.verifiable) return "unverifiable"; |
| 91 | if (!input.target) return input.latest ? "turn_open" : "unverifiable"; |
| 92 | return forkTargetReason(input.target); |
| 93 | } |
| 94 | |
| 95 | /** The notice text for a refused create-fork request. */ |
| 96 | export function forkCreateFailureText(error: unknown, reason?: string): string { |
| 97 | const detail = error instanceof Error ? error.message : String(error ?? ""); |
| 98 | const blockReason: ForkBlockReason | undefined = reason === "history_unverifiable" ? "unverifiable" |
| 99 | : reason === "turn_open" || reason === "active_authority" || reason === "stale_source" || reason === "unsupported" ? reason : undefined; |
| 100 | return blockReason ? t("chat.branchFailedDetail", { detail: t(forkReasonKey(blockReason)) }) : t("chat.branchFailedDetail", { detail }); |
| 101 | } |
| 102 |