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