| 1 | import type { PresentedFile, WireEvent } from "./types"; |
| 2 | import type { State } from "./useController"; |
| 3 | import { t } from "./i18n"; |
| 4 | import { RECOVERABLE_ERROR_EVENT } from "./globalCrashHandlers"; |
| 5 | |
| 6 | export interface AutoPresentedHTMLClaim { |
| 7 | file: PresentedFile; |
| 8 | toolCallId: string; |
| 9 | } |
| 10 | |
| 11 | /** Runtime-only gate for the first HTML present result in each live turn. */ |
| 12 | export class AutoPresentedHTMLGate { |
| 13 | private readonly claimedTurnByTab = new Map<string, string>(); |
| 14 | |
| 15 | observeTurnStart(tabId: string): void { |
| 16 | this.claimedTurnByTab.delete(tabId); |
| 17 | } |
| 18 | |
| 19 | claim(event: WireEvent, tabId: string, activeTabId: string | undefined, sessionGeneration: number, activeTurnId?: string, turnRevision = 0): AutoPresentedHTMLClaim | null { |
| 20 | if (event.kind !== "tool_result" || event.tool?.err || !event.tool?.id || tabId !== activeTabId) return null; |
| 21 | if (event.tool.name !== "present" && event.tool.resolvedName !== "present") return null; |
| 22 | const file = event.tool.presentedFiles?.find((candidate) => /\.html?$/i.test(candidate.path)); |
| 23 | if (!file) return null; |
| 24 | const turnKey = `${turnRevision}:${sessionGeneration}:${activeTurnId ?? event.turnId ?? "active"}`; |
| 25 | if (this.claimedTurnByTab.get(tabId) === turnKey) return null; |
| 26 | this.claimedTurnByTab.set(tabId, turnKey); |
| 27 | return { file, toolCallId: event.tool.id }; |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | export async function openAutomaticPresentedHTML( |
| 32 | gate: AutoPresentedHTMLGate, |
| 33 | event: WireEvent, |
| 34 | input: { tabId: string; activeTabId?: string; sessionGeneration: number; activeTurnId?: string; turnRevision: number }, |
| 35 | ): Promise<string | null> { |
| 36 | const claim = gate.claim(event, input.tabId, input.activeTabId, input.sessionGeneration, input.activeTurnId, input.turnRevision); |
| 37 | if (!claim) return null; |
| 38 | const { openResource } = await import("./fileNavigationCommands"); |
| 39 | const outcome = await openResource({ |
| 40 | source: "presented", hostId: "local", tabId: input.tabId, |
| 41 | toolCallId: claim.toolCallId, path: claim.file.path, |
| 42 | sessionGeneration: input.sessionGeneration, |
| 43 | }, { view: "preview" }); |
| 44 | return outcome.status === "failed" ? outcome.error.message : null; |
| 45 | } |
| 46 | |
| 47 | const automaticGate = new AutoPresentedHTMLGate(); |
| 48 | |
| 49 | /** Keep the live-event controller small; all preview policy and failures stay in this lazy chunk. */ |
| 50 | export default async function handleAutomaticPresentedHTML( |
| 51 | event: WireEvent, |
| 52 | tabId: string, |
| 53 | activeTab: { current?: string }, |
| 54 | states: { current: Map<string, State> }, |
| 55 | ): Promise<void> { |
| 56 | if (event.kind === "turn_done") { |
| 57 | automaticGate.observeTurnStart(tabId); |
| 58 | return; |
| 59 | } |
| 60 | const report = (error: unknown) => window.dispatchEvent(new CustomEvent(RECOVERABLE_ERROR_EVENT, { detail: { |
| 61 | message: t("chat.fileActionFailed", { error: error instanceof Error ? error.message : String(error) }), |
| 62 | } })); |
| 63 | try { |
| 64 | const activeTabId = activeTab.current; |
| 65 | const state = states.current.get(tabId); |
| 66 | if (!state?.meta || state.meta.remote) return; |
| 67 | const error = await openAutomaticPresentedHTML(automaticGate, event, { |
| 68 | tabId, |
| 69 | activeTabId, |
| 70 | sessionGeneration: state.meta.sessionGeneration ?? event.sessionGeneration ?? 0, |
| 71 | activeTurnId: state.activeTurnId, |
| 72 | turnRevision: 0, |
| 73 | }); |
| 74 | if (error) report(error); |
| 75 | } catch (error) { |
| 76 | report(error); |
| 77 | } |
| 78 | } |
| 79 |