| 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 |