返回 DeepSeek-Reasonix
useTranscriptSurfaceProjection.ts
根目录 / desktop / frontend / src / app-runtime / useTranscriptSurfaceProjection.ts
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
122 lines TYPESCRIPT