From 9365e7d3976ae89f5a8472653cef639df4d11982 Mon Sep 17 00:00:00 2001 From: ben Date: Sun, 5 Apr 2026 16:46:06 -0700 Subject: [PATCH] feat(app): add incremental React session path (#1362) * feat(server): add workspace session read APIs Expose workspace-scoped session list, detail, message, and snapshot reads so the client can fetch session data without depending on activation choreography. * feat(app): route mounted session reads through OpenWork APIs Use the new workspace-scoped session read endpoints for mounted OpenWork clients so the current frontend stops depending on direct session proxy reads for list, detail, message, and todo loading. * feat(app): add React read-only session transcript Introduce a feature-gated React island for the session transcript so we can replace the session surface incrementally while keeping the Solid shell intact. * feat(app): add React session composer surface Extend the feature-gated React session island to own its draft, prompt send, stop flow, and snapshot polling so the session body can evolve independently from the Solid composer. * feat(app): add React session transition model Keep the React session surface stable during session switches by tracking rendered vs intended session state and exposing a developer debug panel for render-source and transition inspection. * docs(prd): add React migration plan to repo Copy the incremental React adoption PRD into the OpenWork repo so the migration plan lives next to the implementation and PR branch. * docs(prd): sync full React migration plan Replace the shortened repo copy with the full incremental React adoption PRD so the implementation branch and product plan stay in sync. * feat(desktop): add React session launch modes Add dedicated Tauri dev and debug-build entrypoints for the React session path and honor a build-time React session flag before local storage so the alternate shell is easy to launch and reproduce. * fix(app): fall back to legacy mounted session reads Keep the new app working against older OpenWork servers by falling back to the original mounted OpenCode session reads when the workspace-scoped session read APIs are unavailable. --- apps/app/package.json | 6 + apps/app/src/app/lib/opencode.ts | 176 ++++- apps/app/src/app/lib/openwork-server.ts | 59 ++ apps/app/src/app/pages/session.tsx | 188 ++++-- apps/app/src/react/feature-flag.ts | 21 + apps/app/src/react/island.tsx | 45 ++ .../src/react/session/debug-panel.react.tsx | 23 + .../react/session/session-surface.react.tsx | 231 +++++++ .../react/session/transition-controller.ts | 61 ++ apps/app/vite.config.ts | 5 +- apps/desktop/package.json | 2 + apps/server/package.json | 3 +- apps/server/src/server.ts | 225 ++++++- .../server/src/session-read-model.e2e.test.ts | 256 ++++++++ apps/server/src/session-read-model.ts | 141 ++++ pnpm-lock.yaml | 50 +- prds/react-incremental-adoption.md | 620 ++++++++++++++++++ 17 files changed, 2026 insertions(+), 86 deletions(-) create mode 100644 apps/app/src/react/feature-flag.ts create mode 100644 apps/app/src/react/island.tsx create mode 100644 apps/app/src/react/session/debug-panel.react.tsx create mode 100644 apps/app/src/react/session/session-surface.react.tsx create mode 100644 apps/app/src/react/session/transition-controller.ts create mode 100644 apps/server/src/session-read-model.e2e.test.ts create mode 100644 apps/server/src/session-read-model.ts create mode 100644 prds/react-incremental-adoption.md diff --git a/apps/app/package.json b/apps/app/package.json index 27650e6d..c15da8ed 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -46,6 +46,7 @@ "@solid-primitives/event-bus": "^1.1.2", "@solid-primitives/storage": "^4.3.3", "@solidjs/router": "^0.15.4", + "@tanstack/react-query": "^5.90.3", "@tanstack/solid-virtual": "^3.13.19", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-deep-link": "^2.4.7", @@ -58,9 +59,14 @@ "jsonc-parser": "^3.2.1", "lucide-solid": "^0.562.0", "marked": "^17.0.1", + "react": "^19.1.1", + "react-dom": "^19.1.1", "solid-js": "^1.9.0" }, "devDependencies": { + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^5.0.4", "@solid-devtools/overlay": "^0.33.5", "@tailwindcss/vite": "^4.1.18", "solid-devtools": "^0.34.5", diff --git a/apps/app/src/app/lib/opencode.ts b/apps/app/src/app/lib/opencode.ts index d9105490..ee24bb8f 100644 --- a/apps/app/src/app/lib/opencode.ts +++ b/apps/app/src/app/lib/opencode.ts @@ -1,6 +1,7 @@ -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; +import { createOpencodeClient, type Message, type Part, type Session, type Todo } from "@opencode-ai/sdk/v2/client"; import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; +import { createOpenworkServerClient, OpenworkServerError } from "./openwork-server"; import { isTauriRuntime } from "../utils"; type FieldsResult = @@ -34,6 +35,25 @@ type CommandParameters = { reasoning_effort?: string; }; +type SessionListParameters = { + directory?: string; + roots?: boolean; + start?: number; + search?: string; + limit?: number; +}; + +type SessionLookupParameters = { + sessionID: string; + directory?: string; +}; + +type SessionMessagesParameters = { + sessionID: string; + directory?: string; + limit?: number; +}; + export type OpencodeAuth = { username?: string; password?: string; @@ -112,6 +132,83 @@ async function postSessionRequest( return { error, request, response }; } +function resolveOpenworkWorkspaceMount(baseUrl: string): { baseUrl: string; workspaceId: string } | null { + try { + const url = new URL(baseUrl); + const match = url.pathname.replace(/\/+$/, "").match(/^(.*\/w\/([^/]+))\/opencode$/); + if (!match?.[1] || !match[2]) return null; + url.pathname = match[1]; + url.search = ""; + return { + baseUrl: url.toString().replace(/\/+$/, ""), + workspaceId: decodeURIComponent(match[2]), + }; + } catch { + return null; + } +} + +function createSyntheticResult( + url: string, + method: string, + input: + | { ok: true; data: T; status?: number } + | { ok: false; error: unknown; status?: number }, +): FieldsResult { + const request = new Request(url, { method }); + const response = new Response(input.ok ? JSON.stringify(input.data) : null, { + status: input.status ?? (input.ok ? 200 : 500), + headers: { "Content-Type": "application/json" }, + }); + if (input.ok) { + return { data: input.data, request, response }; + } + return { error: input.error, request, response }; +} + +async function wrapOpenworkRead( + url: string, + read: () => Promise, + options?: { throwOnError?: boolean }, +): Promise> { + try { + return createSyntheticResult(url, "GET", { ok: true, data: await read() }); + } catch (error) { + if (options?.throwOnError) throw error; + return createSyntheticResult(url, "GET", { + ok: false, + error, + status: error instanceof OpenworkServerError ? error.status : 500, + }); + } +} + +function shouldFallbackToLegacySessionRead(error: unknown): boolean { + if (!(error instanceof OpenworkServerError)) return false; + return error.status === 404 || error.status === 405 || error.status === 501; +} + +async function wrapOpenworkReadWithFallback( + url: string, + read: () => Promise, + fallback: () => Promise>, + options?: { throwOnError?: boolean }, +): Promise> { + try { + return createSyntheticResult(url, "GET", { ok: true, data: await read() }); + } catch (error) { + if (!shouldFallbackToLegacySessionRead(error)) { + if (options?.throwOnError) throw error; + return createSyntheticResult(url, "GET", { + ok: false, + error, + status: error instanceof OpenworkServerError ? error.status : 500, + }); + } + return fallback(); + } +} + async function fetchWithTimeout( fetchImpl: typeof globalThis.fetch, input: RequestInfo | URL, @@ -237,11 +334,88 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod }); const session = client.session as typeof client.session; + const openworkMount = auth?.mode === "openwork" ? resolveOpenworkWorkspaceMount(baseUrl) : null; + const openworkSessionClient = + openworkMount && auth?.token + ? createOpenworkServerClient({ baseUrl: openworkMount.baseUrl, token: auth.token }) + : null; + // TODO(2026-04-12): remove the old-server compatibility path here once all + // OpenWork servers expose the workspace-scoped session read APIs. const sessionOverrides = session as any as { + list: (parameters?: SessionListParameters, options?: { throwOnError?: boolean }) => Promise>; + get: (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => Promise>; + messages: (parameters: SessionMessagesParameters, options?: { throwOnError?: boolean }) => Promise>>; + todo: (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => Promise>; promptAsync: (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => Promise>; command: (parameters: CommandParameters, options?: { throwOnError?: boolean }) => Promise>; }; + const listOriginal = sessionOverrides.list.bind(session); + sessionOverrides.list = (parameters?: SessionListParameters, options?: { throwOnError?: boolean }) => { + if (!openworkMount || !openworkSessionClient) { + return listOriginal(parameters, options); + } + const query = new URLSearchParams(); + if (typeof parameters?.roots === "boolean") query.set("roots", String(parameters.roots)); + if (typeof parameters?.start === "number") query.set("start", String(parameters.start)); + if (parameters?.search?.trim()) query.set("search", parameters.search.trim()); + if (typeof parameters?.limit === "number") query.set("limit", String(parameters.limit)); + const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions${query.size ? `?${query.toString()}` : ""}`; + return wrapOpenworkReadWithFallback( + url, + async () => (await openworkSessionClient.listSessions(openworkMount.workspaceId, parameters)).items, + () => listOriginal(parameters, options), + options, + ); + }; + + const getOriginal = sessionOverrides.get.bind(session); + sessionOverrides.get = (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => { + if (!openworkMount || !openworkSessionClient) { + return getOriginal(parameters, options); + } + const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}`; + return wrapOpenworkReadWithFallback( + url, + async () => (await openworkSessionClient.getSession(openworkMount.workspaceId, parameters.sessionID)).item, + () => getOriginal(parameters, options), + options, + ); + }; + + const messagesOriginal = sessionOverrides.messages.bind(session); + sessionOverrides.messages = (parameters: SessionMessagesParameters, options?: { throwOnError?: boolean }) => { + if (!openworkMount || !openworkSessionClient) { + return messagesOriginal(parameters, options); + } + const query = new URLSearchParams(); + if (typeof parameters.limit === "number") query.set("limit", String(parameters.limit)); + const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}/messages${query.size ? `?${query.toString()}` : ""}`; + return wrapOpenworkReadWithFallback( + url, + async () => + (await openworkSessionClient.getSessionMessages(openworkMount.workspaceId, parameters.sessionID, { + limit: parameters.limit, + })).items, + () => messagesOriginal(parameters, options), + options, + ); + }; + + const todoOriginal = sessionOverrides.todo.bind(session); + sessionOverrides.todo = (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => { + if (!openworkMount || !openworkSessionClient) { + return todoOriginal(parameters, options); + } + const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}/snapshot`; + return wrapOpenworkReadWithFallback( + url, + async () => (await openworkSessionClient.getSessionSnapshot(openworkMount.workspaceId, parameters.sessionID)).item.todos, + () => todoOriginal(parameters, options), + options, + ); + }; + const promptAsyncOriginal = sessionOverrides.promptAsync.bind(session); sessionOverrides.promptAsync = (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => { if (!("reasoning_effort" in parameters)) { diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index 301b22df..e3ed4f8b 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -1,4 +1,5 @@ import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; +import type { Message, Part, Session, Todo } from "@opencode-ai/sdk/v2/client"; import { isTauriRuntime } from "../utils"; import type { ExecResult, OpencodeConfigFile, ScheduledJob, WorkspaceInfo, WorkspaceList } from "./tauri"; @@ -105,6 +106,21 @@ export type OpenworkWorkspaceList = { activeId?: string | null; }; +export type OpenworkSessionMessage = { + info: Message; + parts: Part[]; +}; + +export type OpenworkSessionSnapshot = { + session: Session; + messages: OpenworkSessionMessage[]; + todos: Todo[]; + status: + | { type: "idle" } + | { type: "busy" } + | { type: "retry"; attempt: number; message: string; next: number }; +}; + export type OpenworkPluginItem = { spec: string; source: "config" | "dir.project" | "dir.global"; @@ -912,6 +928,7 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s activateWorkspace: 10_000, deleteWorkspace: 10_000, deleteSession: 12_000, + sessionRead: 12_000, status: 6_000, config: 10_000, opencodeRouter: 10_000, @@ -985,6 +1002,48 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s `/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`, { token, hostToken, method: "DELETE", timeoutMs: timeouts.deleteSession }, ), + listSessions: ( + workspaceId: string, + options?: { roots?: boolean; start?: number; search?: string; limit?: number }, + ) => { + const query = new URLSearchParams(); + if (typeof options?.roots === "boolean") query.set("roots", String(options.roots)); + if (typeof options?.start === "number") query.set("start", String(options.start)); + if (options?.search?.trim()) query.set("search", options.search.trim()); + if (typeof options?.limit === "number") query.set("limit", String(options.limit)); + const suffix = query.size ? `?${query.toString()}` : ""; + return requestJson<{ items: Session[] }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/sessions${suffix}`, + { token, hostToken, timeoutMs: timeouts.sessionRead }, + ); + }, + getSession: (workspaceId: string, sessionId: string) => + requestJson<{ item: Session }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`, + { token, hostToken, timeoutMs: timeouts.sessionRead }, + ), + getSessionMessages: (workspaceId: string, sessionId: string, options?: { limit?: number }) => { + const query = new URLSearchParams(); + if (typeof options?.limit === "number") query.set("limit", String(options.limit)); + const suffix = query.size ? `?${query.toString()}` : ""; + return requestJson<{ items: OpenworkSessionMessage[] }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/messages${suffix}`, + { token, hostToken, timeoutMs: timeouts.sessionRead }, + ); + }, + getSessionSnapshot: (workspaceId: string, sessionId: string, options?: { limit?: number }) => { + const query = new URLSearchParams(); + if (typeof options?.limit === "number") query.set("limit", String(options.limit)); + const suffix = query.size ? `?${query.toString()}` : ""; + return requestJson<{ item: OpenworkSessionSnapshot }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/snapshot${suffix}`, + { token, hostToken, timeoutMs: timeouts.sessionRead }, + ); + }, exportWorkspace: ( workspaceId: string, options?: { sensitiveMode?: OpenworkWorkspaceExportSensitiveMode }, diff --git a/apps/app/src/app/pages/session.tsx b/apps/app/src/app/pages/session.tsx index ecc266b7..0dbaa633 100644 --- a/apps/app/src/app/pages/session.tsx +++ b/apps/app/src/app/pages/session.tsx @@ -77,6 +77,7 @@ import type { OpenworkServerSettings, OpenworkServerStatus, } from "../lib/openwork-server"; +import { buildOpenworkWorkspaceBaseUrl } from "../lib/openwork-server"; import { join } from "@tauri-apps/api/path"; import { isUserVisiblePart, @@ -111,6 +112,9 @@ import { saveSessionDraft, sessionDraftScopeKey, } from "../session/draft-store"; +import { ReactIsland } from "../../react/island"; +import { reactSessionEnabled } from "../../react/feature-flag"; +import { SessionSurface } from "../../react/session/session-surface.react"; export type SessionViewProps = { selectedSessionId: string | null; @@ -2123,6 +2127,27 @@ export default function SessionView(props: SessionViewProps) { workspaceId: string; sessionId: string; } | null>(null); + const reactSessionOpencodeBaseUrl = createMemo(() => { + const workspaceId = props.runtimeWorkspaceId?.trim() ?? ""; + const baseUrl = props.openworkServerClient?.baseUrl?.trim() ?? ""; + if (!workspaceId || !baseUrl) return ""; + const mounted = buildOpenworkWorkspaceBaseUrl(baseUrl, workspaceId) ?? baseUrl; + return `${mounted.replace(/\/+$/, "")}/opencode`; + }); + const reactSessionToken = createMemo( + () => props.openworkServerClient?.token?.trim() || props.openworkServerSettings.token?.trim() || "", + ); + const showReactSessionSurface = createMemo( + () => + reactSessionEnabled() && + Boolean( + props.selectedSessionId?.trim() && + props.runtimeWorkspaceId?.trim() && + props.openworkServerClient && + reactSessionOpencodeBaseUrl() && + reactSessionToken(), + ), + ); const hasWorkspaceConfigured = createMemo(() => props.workspaces.length > 0); const showWorkspaceSetupEmptyState = createMemo( () => @@ -3228,7 +3253,8 @@ export default function SessionView(props: SessionViewProps) { props.messages.length === 0 && !showWorkspaceSetupEmptyState() && !showSessionLoadingState() && - !deferSessionRender() + !deferSessionRender() && + !showReactSessionSurface() } >
@@ -3272,88 +3298,106 @@ export default function SessionView(props: SessionViewProps) { when={!showDelayedSessionLoadingState() && !deferSessionRender()} > 0 || hasServerEarlierMessages() + when={!showReactSessionSurface()} + fallback={ + } > -
- -
-
+ 0 || hasServerEarlierMessages() + } + > +
+ +
+
- 0}> - { - flushComposerDraft(); - props.setView("session", sessionId); - }} - searchMatchMessageIds={searchMatchMessageIds()} - activeSearchMessageId={activeSearchHit()?.messageId ?? null} - searchHighlightQuery={searchQueryDebounced().trim()} - scrollElement={() => chatContainerEl} - setScrollToMessageById={(handler) => { - scrollMessageIntoViewById = handler; - }} - footer={ - showRunIndicator() && showFooterRunStatus() ? ( -
-
-
- 0}> + { + flushComposerDraft(); + props.setView("session", sessionId); + }} + searchMatchMessageIds={searchMatchMessageIds()} + activeSearchMessageId={activeSearchHit()?.messageId ?? null} + searchHighlightQuery={searchQueryDebounced().trim()} + scrollElement={() => chatContainerEl} + setScrollToMessageById={(handler) => { + scrollMessageIntoViewById = handler; + }} + footer={ + showRunIndicator() && showFooterRunStatus() ? ( +
+
+
- {thinkingStatus() || runLabel()} - - - - {runElapsedLabel()} + + {thinkingStatus() || runLabel()} - + + + {runElapsedLabel()} + + +
-
- ) : undefined - } - /> + ) : undefined + } + /> +
- 0 && !jumpControlsSuppressed() && (!sessionScroll.isAtBottom() || Boolean(sessionScroll.topClippedMessageId()))}> + 0 && !jumpControlsSuppressed() && (!sessionScroll.isAtBottom() || Boolean(sessionScroll.topClippedMessageId()))}>
@@ -3454,7 +3498,7 @@ export default function SessionView(props: SessionViewProps) {
- + = { + component: ComponentType; + props: T; + class?: string; +}; + +export function ReactIsland(props: ReactIslandProps) { + let container: HTMLDivElement | undefined; + let root: Root | null = null; + const queryClient = new QueryClient(); + + const render = () => { + if (!root) return; + root.render( + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(props.component, props.props), + ), + ); + }; + + onMount(() => { + if (!container) return; + root = createRoot(container); + render(); + }); + + createEffect(() => { + props.props; + render(); + }); + + onCleanup(() => { + root?.unmount(); + root = null; + }); + + return
; +} diff --git a/apps/app/src/react/session/debug-panel.react.tsx b/apps/app/src/react/session/debug-panel.react.tsx new file mode 100644 index 00000000..decb73a1 --- /dev/null +++ b/apps/app/src/react/session/debug-panel.react.tsx @@ -0,0 +1,23 @@ +/** @jsxImportSource react */ +import type { OpenworkSessionSnapshot } from "../../app/lib/openwork-server"; +import type { SessionRenderModel } from "./transition-controller"; + +export function SessionDebugPanel(props: { + model: SessionRenderModel; + snapshot: OpenworkSessionSnapshot | null; +}) { + return ( +
+
React Session Debug
+
+
intendedSessionId: {props.model.intendedSessionId || "-"}
+
renderedSessionId: {props.model.renderedSessionId || "-"}
+
transitionState: {props.model.transitionState}
+
renderSource: {props.model.renderSource}
+
status: {props.snapshot?.status.type ?? "-"}
+
messages: {props.snapshot?.messages.length ?? 0}
+
todos: {props.snapshot?.todos.length ?? 0}
+
+
+ ); +} diff --git a/apps/app/src/react/session/session-surface.react.tsx b/apps/app/src/react/session/session-surface.react.tsx new file mode 100644 index 00000000..373dafc8 --- /dev/null +++ b/apps/app/src/react/session/session-surface.react.tsx @@ -0,0 +1,231 @@ +/** @jsxImportSource react */ +import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { createClient, unwrap } from "../../app/lib/opencode"; +import { abortSessionSafe } from "../../app/lib/opencode-session"; +import type { OpenworkServerClient, OpenworkSessionMessage, OpenworkSessionSnapshot } from "../../app/lib/openwork-server"; +import { SessionDebugPanel } from "./debug-panel.react"; +import { deriveSessionRenderModel } from "./transition-controller"; + +type SessionSurfaceProps = { + client: OpenworkServerClient; + workspaceId: string; + sessionId: string; + opencodeBaseUrl: string; + openworkToken: string; + developerMode: boolean; +}; + +function partText(part: Record) { + if (typeof part.text === "string" && part.text.trim()) return part.text.trim(); + if (typeof part.reasoning === "string" && part.reasoning.trim()) return part.reasoning.trim(); + try { + return JSON.stringify(part, null, 2); + } catch { + return "[unsupported part]"; + } +} + +function roleLabel(role: string) { + if (role === "user") return "You"; + if (role === "assistant") return "OpenWork"; + return role; +} + +function statusLabel(snapshot: OpenworkSessionSnapshot | undefined, busy: boolean) { + if (busy) return "Running..."; + if (snapshot?.status.type === "busy") return "Running..."; + if (snapshot?.status.type === "retry") return `Retrying: ${snapshot.status.message}`; + return "Ready"; +} + +function MessageCard(props: { message: OpenworkSessionMessage }) { + const role = props.message.info.role; + const bubbleClass = + role === "user" + ? "border-blue-6/35 bg-blue-3/25 text-gray-12" + : "border-dls-border bg-dls-surface text-gray-12"; + + return ( +
+
+
+ {roleLabel(role)} +
+
+ {props.message.parts.map((part) => ( +
+ {partText(part as Record)} +
+ ))} +
+
+
+ ); +} + +export function SessionSurface(props: SessionSurfaceProps) { + const [draft, setDraft] = useState(""); + const [actionBusy, setActionBusy] = useState(false); + const [error, setError] = useState(null); + const [rendered, setRendered] = useState<{ + sessionId: string; + snapshot: OpenworkSessionSnapshot; + } | null>(null); + + const opencodeClient = useMemo( + () => createClient(props.opencodeBaseUrl, undefined, { token: props.openworkToken, mode: "openwork" }), + [props.opencodeBaseUrl, props.openworkToken], + ); + + const queryKey = useMemo( + () => ["react-session-snapshot", props.workspaceId, props.sessionId], + [props.workspaceId, props.sessionId], + ); + + const query = useQuery({ + queryKey, + queryFn: async () => (await props.client.getSessionSnapshot(props.workspaceId, props.sessionId, { limit: 140 })).item, + staleTime: 500, + refetchInterval: (current) => + actionBusy || current.state.data?.status.type === "busy" || current.state.data?.status.type === "retry" + ? 800 + : false, + }); + + useEffect(() => { + if (!query.data) return; + setRendered({ sessionId: props.sessionId, snapshot: query.data }); + }, [props.sessionId, query.data]); + + const snapshot = query.data ?? rendered?.snapshot ?? null; + const model = deriveSessionRenderModel({ + intendedSessionId: props.sessionId, + renderedSessionId: query.data ? props.sessionId : rendered?.sessionId ?? null, + hasSnapshot: Boolean(snapshot), + isFetching: query.isFetching, + isError: query.isError, + }); + + const handleSend = async () => { + const text = draft.trim(); + if (!text || actionBusy) return; + setActionBusy(true); + setError(null); + try { + unwrap( + await opencodeClient.session.promptAsync({ + sessionID: props.sessionId, + parts: [{ type: "text", text }], + }), + ); + setDraft(""); + await query.refetch(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to send prompt."); + } finally { + setActionBusy(false); + } + }; + + const handleAbort = async () => { + if (actionBusy) return; + setActionBusy(true); + setError(null); + try { + await abortSessionSafe(opencodeClient, props.sessionId); + await query.refetch(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to stop run."); + } finally { + setActionBusy(false); + } + }; + + const onComposerKeyDown = async (event: React.KeyboardEvent) => { + if (!event.metaKey && !event.ctrlKey) return; + if (event.key !== "Enter") return; + event.preventDefault(); + await handleSend(); + }; + + return ( +
+ {model.transitionState === "switching" ? ( +
+
+ {model.renderSource === "cache" ? "Switching session from cache..." : "Switching session..."} +
+
+ ) : null} + + {!snapshot && query.isLoading ? ( +
+
+
Loading React session view...
+
+
+ ) : query.isError && !snapshot ? ( +
+
+ {query.error instanceof Error ? query.error.message : "Failed to load React session view."} +
+
+ ) : snapshot && snapshot.messages.length === 0 ? ( +
+
+
No transcript yet.
+
+
+ ) : ( +
+ {snapshot?.messages.map((message) => ( + + ))} +
+ )} + +
+
+