| 1 | import { useLayoutEffect, useMemo } from "react"; |
| 2 | import { useCommittedCommand } from "../lib/useCommittedCommand"; |
| 3 | import type { useNavigationSurface } from "../lib/useNavigationSurface"; |
| 4 | import type { HistoryLoadOutcome, HistoryLoadTrigger, Item } from "../lib/useController"; |
| 5 | import type { SessionAvailability } from "../lib/sessionAvailability"; |
| 6 | |
| 7 | type NavigationSurfaceApi = ReturnType<typeof useNavigationSurface>; |
| 8 | |
| 9 | export type TranscriptSurfaceProjectionInput = { |
| 10 | hydrating: boolean; |
| 11 | hydrateHistoryLoaded: boolean | undefined; |
| 12 | hydratePlaceholderItems: Item[] | undefined; |
| 13 | hydratePlaceholderActive: boolean; |
| 14 | items: Item[]; |
| 15 | remote: boolean; |
| 16 | remoteItems: Item[]; |
| 17 | activeTabId: string | undefined; |
| 18 | geometrySessionKey: string; |
| 19 | transitioning: boolean; |
| 20 | navigationDataReady: boolean; |
| 21 | preserved: NavigationSurfaceApi["preserved"]; |
| 22 | controllerReady: boolean; |
| 23 | availability: SessionAvailability; |
| 24 | sessionActivity: boolean; |
| 25 | imDetailActive: boolean; |
| 26 | sessionHasContent: boolean; |
| 27 | commitRendered: NavigationSurfaceApi["commitRendered"]; |
| 28 | commitPaint: NavigationSurfaceApi["commitPaint"]; |
| 29 | commitSingleSurface: (tabId: string) => void; |
| 30 | ports: { |
| 31 | loadOlderHistory(tabId: string, targetTurn: number | undefined, trigger: HistoryLoadTrigger): Promise<HistoryLoadOutcome>; |
| 32 | loadNewerHistory(tabId: string, latest: boolean): Promise<HistoryLoadOutcome>; |
| 33 | commitThenSend(tabId: string, displayText: string, submitText?: string): Promise<void>; |
| 34 | }; |
| 35 | }; |
| 36 | |
| 37 | /** |
| 38 | * Owns the transcript surface projection: hydration placeholders, the |
| 39 | * empty-session hero gate, the committed-surface commit effect (only |
| 40 | * committed presentation may become a retained source), the visible |
| 41 | * source-retained surface selection, surface paint receipts, the latest |
| 42 | * consumed guidance entry and the transcript prompt/load-older commands. |
| 43 | */ |
| 44 | export function useTranscriptSurfaceProjection(input: TranscriptSurfaceProjectionInput) { |
| 45 | const { activeTabId, transitioning, ports } = input; |
| 46 | const transcriptHydrating = input.hydrating && !input.hydrateHistoryLoaded; |
| 47 | // Show the hero only after history hydration settles on a truly empty session. |
| 48 | // Avoid flash while switching tabs: items may be empty while placeholders show. |
| 49 | // Exclude IM/Bot detail: hero CSS collapses .main, which also hosts that panel. |
| 50 | const emptyHero = |
| 51 | input.availability.kind === "ready" && |
| 52 | !input.sessionActivity && |
| 53 | !transitioning && |
| 54 | !input.imDetailActive && |
| 55 | !input.sessionHasContent && |
| 56 | !transcriptHydrating && |
| 57 | !input.hydratePlaceholderActive; |
| 58 | const transcriptItems = input.hydratePlaceholderActive ? input.hydratePlaceholderItems! : input.items; |
| 59 | const handleLoadOlderHistory = useCommittedCommand((targetTurn?: number, trigger: HistoryLoadTrigger = "retry") => { |
| 60 | return activeTabId ? ports.loadOlderHistory(activeTabId, targetTurn, trigger) : Promise.resolve("empty" as const); |
| 61 | }); |
| 62 | const handleLoadNewerHistory = useCommittedCommand((latest = false) => { |
| 63 | return activeTabId ? ports.loadNewerHistory(activeTabId, latest) : Promise.resolve("empty" as const); |
| 64 | }); |
| 65 | |
| 66 | // Display items: backend history is authoritative after immediate commit. |
| 67 | // rewindState only drives the undo banner, not optimistic truncation. |
| 68 | const displayItems = transcriptItems; |
| 69 | const committedSurfaceItems = input.remote ? input.remoteItems : displayItems; |
| 70 | const committedGeometryKey = input.remote ? `tab:${activeTabId ?? "preview"}` : input.geometrySessionKey; |
| 71 | // Only committed presentation can become a future retained source surface. |
| 72 | // A suspended or abandoned render must never become navigation authority. |
| 73 | const commitRendered = input.commitRendered; |
| 74 | useLayoutEffect(() => { |
| 75 | if (transitioning) return; |
| 76 | commitRendered({ |
| 77 | tabId: activeTabId, |
| 78 | items: committedSurfaceItems, |
| 79 | geometrySessionKey: committedGeometryKey, |
| 80 | }); |
| 81 | }, [activeTabId, commitRendered, committedSurfaceItems, transitioning, committedGeometryKey]); |
| 82 | const visibleTranscriptSurface = transitioning && !input.navigationDataReady && input.preserved |
| 83 | ? input.preserved |
| 84 | : null; |
| 85 | const visibleTranscriptItems = visibleTranscriptSurface?.items ?? displayItems; |
| 86 | const visibleTranscriptTabId = visibleTranscriptSurface?.tabId ?? activeTabId; |
| 87 | const visibleTranscriptGeometryKey = visibleTranscriptSurface?.geometrySessionKey ?? input.geometrySessionKey; |
| 88 | const handleSurfacePaintReady = useCommittedCommand((token: string, outcome: "ready" | "degraded") => { |
| 89 | const receipt = input.commitPaint(token, outcome); |
| 90 | if (receipt) input.commitSingleSurface(receipt.targetTabId); |
| 91 | }); |
| 92 | const latestGuidanceConsumed = useMemo(() => { |
| 93 | for (let i = input.items.length - 1; i >= 0; i--) { |
| 94 | const item = input.items[i]; |
| 95 | if (item.kind === "notice" && item.text.startsWith("↪ ")) { |
| 96 | return { key: item.id, itemId: item.inboxItemId, text: item.text.slice(2) }; |
| 97 | } |
| 98 | } |
| 99 | return null; |
| 100 | }, [input.items]); |
| 101 | |
| 102 | const handleTranscriptPrompt = useCommittedCommand((text: string, submitText = text) => { |
| 103 | if (!activeTabId || !input.controllerReady) return; |
| 104 | void ports.commitThenSend(activeTabId, text, submitText).catch((err) => { |
| 105 | console.warn("Failed to submit transcript prompt", err); |
| 106 | }); |
| 107 | }); |
| 108 | |
| 109 | return { |
| 110 | transcriptHydrating, |
| 111 | emptyHero, |
| 112 | visibleTranscriptItems, |
| 113 | visibleTranscriptTabId, |
| 114 | visibleTranscriptGeometryKey, |
| 115 | handleLoadOlderHistory, |
| 116 | handleLoadNewerHistory, |
| 117 | handleSurfacePaintReady, |
| 118 | latestGuidanceConsumed, |
| 119 | handleTranscriptPrompt, |
| 120 | }; |
| 121 | } |
| 122 |