返回 DeepSeek-Reasonix
RemoteSessionSurface.tsx
根目录 / desktop / frontend / src / components / RemoteSessionSurface.tsx
1 import { ErrorMessage } from "./ErrorMessage";
2 import { useEffect, useRef, useState } from "react";
3 import { app, openExternal } from "../lib/bridge";
4 import { useRemoteNavigationCommand } from "../lib/remoteNavigationCommands";
5 import { Transcript, type TranscriptProps } from "./Transcript";
6 import { AskCard } from "./AskCard";
7 import { ApprovalModal } from "./ApprovalModal";
8 import { ExtensionFormDialog } from "./ExtensionFormDialog";
9 import { MCPInteractionCard } from "./MCPInteractionCard";
10 import { SessionRecoveryBanner, SessionRecoveryPlaceholder } from "./SessionRecoveryBanner";
11 import { SessionLoadingIndicator } from "./SessionLoadingIndicator";
12 import { projectSessionAvailability } from "../lib/sessionAvailability";
13 import type { RemoteSessionApi } from "../lib/useRemoteSession";
14 export { hydrateRemoteTelemetry, loadRemoteStatusSnapshot } from "../lib/remoteTelemetry";
15 import type { PromptKind, TabMeta, WireApproval, WireAsk, WireMCPInteraction } from "../lib/types";
16 import { orderedLocalSubmissions } from "../lib/localSubmissionState";
17 import { hasSessionGeneration } from "../lib/sessionIdentity";
18
19 /**
20 * RemoteSessionSurface renders the active remote tab's content area with
21 * the SAME Transcript component local tabs use — the session hook feeds the
22 * shared reducer with serve frames, so items, live streaming, approvals, and
23 * asks arrive in the local shapes. Only the connection state machine and
24 * the approval/ask cards are remote-specific; the composer lives in the
25 * app shell, shared with local tabs.
26 */
27 export function RemoteSessionSurface({ tab, session, surfaceCommitToken, onSurfacePaintReady }: {
28 tab: TabMeta; session: RemoteSessionApi;
29 } & Pick<TranscriptProps, "surfaceCommitToken" | "onSurfacePaintReady">) {
30 const navigateRemote = useRemoteNavigationCommand();
31 const availability = projectSessionAvailability({ remote: session });
32 const ready = availability.kind === "ready";
33 const localSubmissions = orderedLocalSubmissions(session.transcript);
34 const hasContent = session.transcript.items.length > 0 || localSubmissions.length > 0 || Boolean(session.transcript.live?.text || session.transcript.live?.reasoning);
35 const approval = session.transcript.approval as WireApproval | undefined;
36 const ask = session.transcript.ask as WireAsk | undefined;
37 const mcpInteraction = session.transcript.mcpInteraction as WireMCPInteraction | undefined;
38 const extensionForm = session.transcript.extensionForm;
39 const [actionError, setActionError] = useState("");
40 const formKey = extensionForm ? `${tab.id}:${extensionForm.pluginId}:${extensionForm.surfaceId}:${extensionForm.formInstanceId}` : "";
41 const visibleFormKeyRef = useRef(formKey);
42 visibleFormKeyRef.current = formKey;
43 const [busyFormKey, setBusyFormKey] = useState("");
44 const extensionFormBusy = Boolean(formKey && busyFormKey === formKey);
45 const visiblePrompt = approval || ask || mcpInteraction;
46 const promptUpgradeRequired = Boolean(visiblePrompt && !tab.interactionTargetSupported);
47 const promptIdentityUnavailable = Boolean(visiblePrompt && tab.interactionTargetSupported && (
48 // runtimeEpoch stays optional: a serve fences on it only when it has one,
49 // and a CLI serve never binds desktop routing metadata.
50 !tab.remote?.hostId || !tab.sessionId || !hasSessionGeneration(tab.sessionGeneration) || !visiblePrompt.turnId
51 ));
52 const promptActionDisabled = promptUpgradeRequired || promptIdentityUnavailable;
53 const formUpgradeRequired = Boolean(extensionForm && !tab.extensionFormInstanceSupported);
54 const formIdentityUnavailable = Boolean(extensionForm && tab.extensionFormInstanceSupported && (
55 !tab.remote?.hostId || !(extensionForm.sessionId ?? tab.sessionId) || !hasSessionGeneration(tab.sessionGeneration) ||
56 !extensionForm.generation || !extensionForm.formInstanceExact || !extensionForm.formInstanceId
57 ));
58 const formActionDisabled = formUpgradeRequired || formIdentityUnavailable;
59 const capabilityError = promptUpgradeRequired
60 ? "This remote Reasonix Serve must be upgraded before this card can be answered safely."
61 : promptIdentityUnavailable
62 ? "This card's exact request identity is unavailable. Refresh the session before answering."
63 : formUpgradeRequired
64 ? "This remote Reasonix Serve must be upgraded before this form can be submitted safely."
65 : formIdentityUnavailable
66 ? "This form's exact publication identity is unavailable. Refresh the session before submitting."
67 : "";
68 useEffect(() => { setActionError(""); setBusyFormKey(""); }, [session.state, session.surfaceGeneration, tab.id]);
69 const runAction = async (action: () => Promise<unknown>, propagate = false): Promise<void> => {
70 setActionError("");
71 try {
72 await action();
73 } catch (error) {
74 setActionError(error instanceof Error ? error.message : String(error));
75 if (propagate) throw error;
76 }
77 };
78 const exactPromptTarget = (prompt: { id: string; turnId?: string; runtimeEpoch?: string }, kind: PromptKind) => ({
79 tabId: tab.id,
80 hostId: tab.remote?.hostId ?? "",
81 sessionId: tab.sessionId ?? "",
82 sessionGeneration: tab.sessionGeneration ?? 0,
83 promptId: prompt.id,
84 turnId: prompt.turnId ?? "",
85 runtimeEpoch: prompt.runtimeEpoch ?? "",
86 kind,
87 });
88 const resolvePrompt = (prompt: { id: string; turnId?: string; runtimeEpoch?: string }, kind: PromptKind, answer: Record<string, unknown>) => {
89 if (!tab.interactionTargetSupported || !app.ResolveRemoteTabPromptExact) {
90 return Promise.reject(new Error("This remote Reasonix Serve must be upgraded before this card can be answered safely."));
91 }
92 return app.ResolveRemoteTabPromptExact(exactPromptTarget(prompt, kind), answer);
93 };
94 const submitExtensionForm = (values: Record<string, unknown>) => {
95 if (!extensionForm || extensionFormBusy) return;
96 const pending = extensionForm;
97 const requestKey = formKey;
98 setBusyFormKey(requestKey);
99 runAction(async () => {
100 if (!tab.extensionFormInstanceSupported || !app.SubmitRemoteTabExtensionFormExact) {
101 throw new Error("This remote Reasonix Serve must be upgraded before this form can be submitted safely.");
102 }
103 await app.SubmitRemoteTabExtensionFormExact({
104 tabId: tab.id,
105 hostId: tab.remote!.hostId,
106 sessionId: pending.sessionId ?? tab.sessionId ?? "",
107 sessionGeneration: tab.sessionGeneration ?? 0,
108 pluginId: pending.pluginId,
109 surfaceId: pending.surfaceId,
110 pluginGeneration: pending.generation ?? 0,
111 formInstanceId: pending.formInstanceId,
112 }, values);
113 session.clearExtensionForm(pending.pluginId, pending.surfaceId, pending.formInstanceId);
114 }).finally(() => setBusyFormKey((current) => current === requestKey ? "" : current));
115 };
116 if (!tab.remote) return null;
117
118 return (
119 <>
120 <SessionRecoveryBanner key={`${tab.id}:${session.surfaceGeneration}`} availability={availability} onRetry={async () => {
121 if (availability.source === "history") { await session.retryHydration(); return; }
122 // No new-session target: preserve the parked session when reconnecting.
123 const outcome = await navigateRemote(tab.remote!, {});
124 if (outcome.status === "failed") throw outcome.error;
125 }} />
126 <main className="main">
127 <div className="remote-surface remote-surface--ready" aria-busy={availability.kind === "loading"}>
128 <SessionLoadingIndicator active={availability.kind === "loading"}
129 identity={`${tab.id}:${session.surfaceGeneration}`} source={availability.source} />
130 {!ready && !hasContent ? <SessionRecoveryPlaceholder availability={availability} /> : <Transcript
131 items={session.transcript.items}
132 localSubmissions={localSubmissions}
133 localSubmissionSendRevision={session.transcript.localSubmissionSendRevision}
134 visibleSubmissionHandoffs={session.transcript.visibleSubmissionHandoffs}
135 live={session.transcript.live}
136 liveStore={session.liveStore}
137 tabId={tab.id}
138 hostId={tab.remote.hostId}
139 geometrySessionKey={`${tab.id}:${session.surfaceGeneration}`}
140 hydrating={!session.hydrated && !hasContent}
141 showLoadingFeedback={false}
142 surfaceCommitToken={surfaceCommitToken}
143 onSurfacePaintReady={onSurfacePaintReady}
144 running={session.transcript.running && !session.transcript.runtimeStateSnapshot?.maintenance}
145 hasOlderHistory={session.transcript.historyHasOlder}
146 hasNewerHistory={session.transcript.historyHasNewer}
147 loadingNewerHistory={session.transcript.historyNewerLoading}
148 newerHistoryError={session.transcript.historyNewerError}
149 historyStartTurn={session.transcript.historyStartTurn}
150 totalTurns={session.transcript.historyTotalTurns}
151 loadingOlderHistory={session.transcript.historyOlderLoading}
152 olderHistoryError={session.transcript.historyOlderError}
153 onLoadOlderHistory={session.loadOlderHistory}
154 onLoadNewerHistory={session.loadNewerHistory}
155 onNavigateToTurn={session.navigateToTurn}
156 onPrompt={(display, submit = display) => runAction(() => session.submit(submit, display))}
157 forkTargets={session.transcript.forkTargets}
158 // The tab's advertised capability, not the target list, decides whether
159 // this serve can create a child at all: an empty list on a capable serve
160 // means no completed turn here, which its own reason explains.
161 forkBlocked={tab.forkTargetsSupported ? null : "unsupported"}
162 onFork={tab.forkTargetsSupported ? (target) => runAction(async () => {
163 const child = await session.forkTurn(target);
164 // The child session belongs to the serve, so its surface is opened
165 // here rather than adopted from a returned desktop tab. Desktop keeps
166 // the operation until navigation succeeds, allowing a later click to
167 // recover the same child after an unknown result.
168 if (!child) return;
169 const opened = await navigateRemote(tab.remote!, { sessionId: child.sessionId });
170 if (opened.status === "completed") await session.acknowledgeFork(child.operationId);
171 }) : undefined}
172 />}
173
174 {ready && approval ? (
175 <fieldset disabled={promptActionDisabled} style={{ display: "contents" }}>
176 <div className="remote-surface__approval">
177 <ApprovalModal
178 key={`${tab.id}:${tab.sessionGeneration ?? 0}:${approval.runtimeEpoch ?? ""}:${approval.turnId ?? ""}:${approval.id}`}
179 approval={approval}
180 cwd={tab.cwd}
181 tabId={tab.id}
182 toolApprovalMode={session.composerProfile?.toolApprovalMode}
183 onAnswer={(allow, sessionScope, persist) => runAction(() => approval.tool === "exit_plan_mode"
184 ? resolvePrompt(approval, "plan", { action: allow ? "start_execution" : "revise_plan" })
185 : resolvePrompt(approval, approval.kind === "recovery" ? "recovery" : "approval", {
186 allow, session: sessionScope, persist, generation: approval.generation, permissionRevision: approval.permissionRevision,
187 }), true)}
188 onRevisePlan={(text) => runAction(() => resolvePrompt(approval, "plan", { action: "revise_plan", feedback: text }), true)}
189 onExitPlan={() => runAction(() => resolvePrompt(approval, "plan", { action: "exit_plan" }), true)}
190 onStop={() => runAction(session.cancelTurn, true)}
191 />
192 </div>
193 </fieldset>
194 ) : null}
195
196 {ready && ask?.questions?.length ? (
197 <fieldset disabled={promptActionDisabled} style={{ display: "contents" }}>
198 <AskCard
199 key={`${tab.id}:${tab.sessionGeneration ?? 0}:${ask.runtimeEpoch ?? ""}:${ask.turnId ?? ""}:${ask.id}`}
200 ask={ask}
201 draftScope={JSON.stringify([tab.remote.hostId, tab.sessionId ?? "", tab.sessionGeneration ?? 0, ask.runtimeEpoch ?? "", ask.turnId ?? "", ask.id])}
202 onAnswer={(_id, answers) => runAction(() => resolvePrompt(ask, "ask", { questions: answers }), true)}
203 onDismiss={() => runAction(() => resolvePrompt(ask, "ask", { questions: [] }), true)}
204 onStop={() => runAction(() => session.cancelTurn(), true)}
205 />
206 </fieldset>
207 ) : null}
208 {ready && mcpInteraction ? (
209 <MCPInteractionCard
210 key={`${tab.id}:${tab.sessionGeneration ?? 0}:${mcpInteraction.runtimeEpoch ?? ""}:${mcpInteraction.turnId ?? ""}:${mcpInteraction.id}`}
211 instanceKey={JSON.stringify([tab.remote.hostId, tab.sessionId ?? "", tab.sessionGeneration ?? 0, mcpInteraction.runtimeEpoch ?? "", mcpInteraction.turnId ?? "", "mcp", mcpInteraction.id])}
212 interaction={mcpInteraction}
213 busy={promptActionDisabled}
214 onAnswer={(_id, action, content) => void runAction(() => resolvePrompt(mcpInteraction, "mcp", { action, content: content ?? null }))}
215 onOpenLink={openExternal}
216 />
217 ) : null}
218 {ready && extensionForm ? (
219 <ExtensionFormDialog
220 key={formKey}
221 surface={extensionForm}
222 busy={extensionFormBusy || formActionDisabled}
223 onSubmit={submitExtensionForm}
224 onCancel={() => submitExtensionForm({ cancelled: true })}
225 />
226 ) : null}
227 {ready && (capabilityError || actionError || session.promptError || session.error) ? (
228 <div className="remote-surface__detail" role="alert"><ErrorMessage error={capabilityError || actionError || session.promptError || session.error} /></div>
229 ) : null}
230 </div>
231 </main>
232 </>
233 );
234 }
235
235 lines Plain Text