fix(app): keep React session transcript stable across switches (#1365)

* feat(app): add React markdown and transcript parity

Render React session messages with a proper markdown stack, Solid-style user and assistant layouts, and structured tool-call cards so the React path matches the existing transcript experience more closely.

* feat(app): drive React sessions through useChat streaming

Move the React session path to a custom useChat transport backed by the existing OpenCode event stream so transcript state, sending, and streaming are owned by React instead of snapshot polling.

* fix(app): remount React session island per chat

Reset the React session surface when the workspace or session changes so an in-flight useChat stream from one chat cannot continue writing into another chat after a switch.

* fix(app): scope React stream deltas to one session

Reject message.part.delta updates that do not belong to the active session so concurrent OpenCode event streams cannot interleave transcript text across chats in the React session surface.

* fix(app): cache React transcript state per session

Persist streamed UI messages in TanStack query cache per workspace/session so revisiting a running chat shows the last known partial transcript instead of starting empty on remount.

* feat(app): keep React session state in shared sync cache

Move React session transcript ownership out of the mounted chat view by sharing one query client across islands and applying workspace event updates into per-session query cache, mirroring the global sync pattern used by OpenCode.

* fix(app): keep React session sync alive across chat switches

Mount the workspace-scoped React session sync above the per-chat session island so switching chats no longer tears down the event subscription that keeps transcript state updating in the background.

* Revert "fix(app): keep React session sync alive across chat switches"

This reverts commit 15f37a09c1.

* Revert "feat(app): keep React session state in shared sync cache"

This reverts commit 49df59d6ef.

* fix(app): keep React session streams alive across switches

Move React session streaming state into a shared app-level runtime backed by one query client and a workspace-scoped event sync reducer so leaving and returning to a running session restores the current transcript instead of showing an empty pane or leaking between chats.

* fix(app): preserve beginning of streamed text across session switches

Create text parts on first delta arrival instead of silently dropping deltas that arrive before the part shell exists, and ensure the message shell is present before appending any delta. This fixes the bug where switching away from a streaming session and coming back would show only later lines, missing the beginning of the response.

* fix(app): preserve streamed text when re-selecting a busy session

When switching back to a session that is still streaming, the server snapshot returns empty text for in-progress parts. The reconcile call in selectSession was overwriting the locally accumulated text with that empty snapshot. Now both the Solid and React paths preserve the longer local text when the session status is busy.
This commit is contained in:
ben
2026-04-06 08:13:05 -07:00
committed by GitHub
parent dfb7f1b2dc
commit b3afb8a176
14 changed files with 3490 additions and 106 deletions

View File

@@ -35,6 +35,7 @@
"bump:set": "node scripts/bump-version.mjs --set"
},
"dependencies": {
"@ai-sdk/react": "^3.0.148",
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-markdown": "^6.3.3",
"@codemirror/language": "^6.11.0",
@@ -55,20 +56,24 @@
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "~2.3.1",
"@tauri-apps/plugin-updater": "~2.9.0",
"ai": "^6.0.146",
"fuzzysort": "^3.1.0",
"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"
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"solid-js": "^1.9.0",
"streamdown": "^2.5.0"
},
"devDependencies": {
"@solid-devtools/overlay": "^0.33.5",
"@tailwindcss/vite": "^4.1.18",
"@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",
"tailwindcss": "^4.1.18",
"typescript": "^5.6.3",

View File

@@ -1,5 +1,6 @@
import {
Match,
Show,
Switch,
createEffect,
createMemo,
@@ -111,6 +112,7 @@ import {
readStoredFontZoom,
} from "./lib/font-zoom";
import {
buildOpenworkWorkspaceBaseUrl,
parseOpenworkWorkspaceIdFromUrl,
readOpenworkConnectInviteFromSearch,
stripOpenworkConnectInviteFromUrl,
@@ -120,6 +122,9 @@ import {
writeOpenworkServerSettings,
type OpenworkServerSettings,
} from "./lib/openwork-server";
import { ReactIsland } from "../react/island";
import { reactSessionEnabled } from "../react/feature-flag";
import { ReactSessionRuntime } from "../react/session/runtime-sync.react";
import {
parseBundleDeepLink,
stripBundleQuery,
@@ -2283,6 +2288,24 @@ export default function App() {
error: error(),
});
const reactSessionRuntimeEnabled = createMemo(() => reactSessionEnabled());
const reactSessionRuntimeBaseUrl = createMemo(() => {
const workspaceId = runtimeWorkspaceId()?.trim() ?? "";
const baseUrl = openworkServerClient()?.baseUrl?.trim() ?? "";
if (!workspaceId || !baseUrl) return "";
const mounted = buildOpenworkWorkspaceBaseUrl(baseUrl, workspaceId) ?? baseUrl;
return `${mounted.replace(/\/+$/, "")}/opencode`;
});
const reactSessionRuntimeToken = createMemo(
() => openworkServerClient()?.token?.trim() || openworkServerSettings().token?.trim() || "",
);
const showReactSessionRuntime = createMemo(
() =>
reactSessionRuntimeEnabled() &&
openworkServerStatus() === "connected" &&
Boolean(runtimeWorkspaceId()?.trim() && reactSessionRuntimeBaseUrl() && reactSessionRuntimeToken()),
);
const settingsTabs = new Set<SettingsTab>([
"general",
"den",
@@ -2385,6 +2408,18 @@ export default function App() {
<ExtensionsProvider store={extensionsStore}>
<AutomationsProvider store={automationsStore}>
<StatusToastsProvider store={statusToastsStore}>
<Show when={showReactSessionRuntime()}>
<ReactIsland
class="hidden"
instanceKey={`react-runtime:${runtimeWorkspaceId()!}`}
component={ReactSessionRuntime}
props={{
workspaceId: runtimeWorkspaceId()!,
opencodeBaseUrl: reactSessionRuntimeBaseUrl(),
openworkToken: reactSessionRuntimeToken(),
}}
/>
</Show>
<Switch>
<Match when={currentView() === "session"}>
<SessionView {...sessionProps()} />

View File

@@ -129,16 +129,33 @@ const upsertPartInfo = (list: Part[], next: Part) => {
const index = list.findIndex((part) => part.id === next.id);
if (index === -1) return sortById([...list, next]);
const copy = list.slice();
copy[index] = next;
const existing = copy[index] as Part & Record<string, unknown>;
const incoming = next as Part & Record<string, unknown>;
if ((incoming.type === "text" || incoming.type === "reasoning") && typeof existing.text === "string") {
const nextText = typeof incoming.text === "string" ? incoming.text : "";
copy[index] = { ...existing, ...incoming, text: nextText || existing.text } as Part;
} else {
copy[index] = next;
}
return copy;
};
const removePartInfo = (list: Part[], partID: string) => list.filter((part) => part.id !== partID);
const appendPartDelta = (list: Part[], partID: string, field: string, delta: string) => {
const appendPartDelta = (list: Part[], messageID: string, sessionID: string | null, partID: string, field: string, delta: string) => {
if (!delta) return list;
const index = list.findIndex((part) => part.id === partID);
if (index === -1) return list;
if (index === -1) {
if (field !== "text" && field !== "reasoning") return list;
const synthetic = {
id: partID,
messageID,
sessionID: sessionID ?? "",
type: field === "reasoning" ? "reasoning" : "text",
text: delta,
} as Part;
return sortById([...list, synthetic]);
}
const existing = list[index] as Part & Record<string, unknown>;
const current = existing[field];
@@ -1063,11 +1080,45 @@ export function createSessionStore(options: {
.filter((info) => !!info?.id)
.map((info) => info as MessageInfo);
const isStreaming = (store.sessionStatus[sessionID] ?? "idle") !== "idle";
batch(() => {
setStore("messages", sessionID, reconcile(sortById(infos), { key: "id" }));
for (const message of list) {
const parts = message.parts.filter((part) => !!part?.id);
setStore("parts", message.info.id, reconcile(sortById(parts), { key: "id" }));
if (isStreaming) {
// During active streaming, the server snapshot may have empty/stale
// text fields for in-progress parts while the local store already
// accumulated text via message.part.delta events. Merge carefully
// so we never overwrite longer local text with shorter server text.
const existingParts = store.parts[message.info.id] ?? [];
const merged = sortById(parts).map((incoming) => {
const existing = existingParts.find((p) => p.id === incoming.id);
if (!existing) return incoming;
const incomingRecord = incoming as Part & Record<string, unknown>;
const existingRecord = existing as Part & Record<string, unknown>;
if (
(incoming.type === "text" || incoming.type === "reasoning") &&
typeof existingRecord.text === "string" &&
typeof incomingRecord.text === "string" &&
existingRecord.text.length > incomingRecord.text.length
) {
return { ...incoming, text: existingRecord.text } as Part;
}
return incoming;
});
// Also keep any local-only parts (created from early deltas) that
// the server snapshot doesn't know about yet.
for (const existing of existingParts) {
if (!merged.find((p) => p.id === existing.id)) {
merged.push(existing);
}
}
setStore("parts", message.info.id, reconcile(sortById(merged), { key: "id" }));
} else {
setStore("parts", message.info.id, reconcile(sortById(parts), { key: "id" }));
}
}
});
}
@@ -1756,6 +1807,7 @@ export function createSessionStore(options: {
if (event.type === "message.part.delta") {
if (event.properties && typeof event.properties === "object") {
const record = event.properties as Record<string, unknown>;
const sessionID = typeof record.sessionID === "string" ? record.sessionID : null;
const messageID = typeof record.messageID === "string" ? record.messageID : null;
const partID = typeof record.partID === "string" ? record.partID : null;
const field = typeof record.field === "string" ? record.field : null;
@@ -1763,10 +1815,11 @@ export function createSessionStore(options: {
const partDeltaStartedAt = perfNow();
if (messageID && partID && field && delta) {
setStore("parts", messageID, (current = []) => appendPartDelta(current, partID, field, delta));
setStore("parts", messageID, (current = []) => appendPartDelta(current, messageID, sessionID, partID, field, delta));
const partDeltaMs = Math.round((perfNow() - partDeltaStartedAt) * 100) / 100;
if (sessionDebugEnabled() && (partDeltaMs >= 8 || delta.length >= 120)) {
recordPerfLog(true, "session.event", "message.part.delta", {
sessionID,
messageID,
partID,
field,

View File

@@ -3352,6 +3352,7 @@ export default function SessionView(props: SessionViewProps) {
fallback={
<ReactIsland
class="pb-4"
instanceKey={`${props.runtimeWorkspaceId!}:${props.selectedSessionId!}`}
component={SessionSurface}
props={{
client: props.openworkServerClient!,

View File

@@ -1,5 +1,5 @@
import { createEffect, onCleanup, onMount } from "solid-js";
import { createElement, type ComponentType } from "react";
import { createElement, Fragment, type ComponentType } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot, type Root } from "react-dom/client";
@@ -7,6 +7,7 @@ type ReactIslandProps<T extends object> = {
component: ComponentType<T>;
props: T;
class?: string;
instanceKey?: string;
};
export function ReactIsland<T extends object>(props: ReactIslandProps<T>) {
@@ -20,7 +21,7 @@ export function ReactIsland<T extends object>(props: ReactIslandProps<T>) {
createElement(
QueryClientProvider,
{ client: queryClient },
createElement(props.component, props.props),
createElement(Fragment, { key: props.instanceKey }, createElement(props.component, props.props)),
),
);
};
@@ -33,6 +34,7 @@ export function ReactIsland<T extends object>(props: ReactIslandProps<T>) {
createEffect(() => {
props.props;
props.instanceKey;
render();
});

View File

@@ -0,0 +1,12 @@
import { QueryClient } from "@tanstack/react-query";
type QueryClientGlobal = typeof globalThis & {
__owReactQueryClient?: QueryClient;
};
export function getReactQueryClient() {
const target = globalThis as QueryClientGlobal;
if (target.__owReactQueryClient) return target.__owReactQueryClient;
target.__owReactQueryClient = new QueryClient();
return target.__owReactQueryClient;
}

View File

@@ -0,0 +1,84 @@
/** @jsxImportSource react */
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { Streamdown } from "streamdown";
const markdownComponents: Components = {
a({ href, children }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2 text-dls-accent hover:text-[var(--dls-accent-hover)]"
>
{children}
</a>
);
},
pre({ children }) {
return (
<pre className="my-4 overflow-x-auto rounded-[18px] border border-dls-border/70 bg-gray-1/80 px-4 py-3 text-[12px] leading-6 text-gray-12">
{children}
</pre>
);
},
code({ className, children }) {
const isBlock = Boolean(className?.includes("language-"));
if (isBlock) {
return <code className={className}>{children}</code>;
}
return (
<code className="rounded-md bg-gray-2/70 px-1.5 py-0.5 font-mono text-[0.92em] text-gray-12">
{children}
</code>
);
},
blockquote({ children }) {
return <blockquote className="my-4 border-l-4 border-dls-border pl-4 italic text-gray-11">{children}</blockquote>;
},
table({ children }) {
return <table className="my-4 w-full border-collapse">{children}</table>;
},
th({ children }) {
return <th className="border border-dls-border bg-dls-hover p-2 text-left">{children}</th>;
},
td({ children }) {
return <td className="border border-dls-border p-2 align-top">{children}</td>;
},
};
const markdownClassName = `markdown-content max-w-none text-gray-12
[&_strong]:font-semibold
[&_em]:italic
[&_h1]:my-4 [&_h1]:text-2xl [&_h1]:font-bold
[&_h2]:my-3 [&_h2]:text-xl [&_h2]:font-bold
[&_h3]:my-2 [&_h3]:text-lg [&_h3]:font-bold
[&_p]:my-3 [&_p]:leading-relaxed
[&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-6
[&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-6
[&_li]:my-1
`.trim();
export function MarkdownBlock(props: { text: string; streaming?: boolean }) {
if (!props.text.trim()) return null;
if (props.streaming) {
return (
<div className={markdownClassName}>
<Streamdown remarkPlugins={[remarkGfm]} components={markdownComponents} skipHtml>
{props.text}
</Streamdown>
</div>
);
}
return (
<div className={markdownClassName}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents} skipHtml>
{props.text}
</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,150 @@
/** @jsxImportSource react */
import { isToolUIPart, type DynamicToolUIPart, type UIMessage } from "ai";
import { MarkdownBlock } from "./markdown.react";
import { ToolCallView } from "./tool-call.react";
function isImageAttachment(mime: string) {
return mime.startsWith("image/");
}
function latestAssistantMessageId(messages: UIMessage[]) {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.role === "assistant") return message.id;
}
return null;
}
function FileCard(props: { part: { filename?: string; url: string; mediaType: string }; tone: "assistant" | "user" }) {
const title = props.part.filename || props.part.url || "File";
const detail = props.part.url || "";
return (
<div
className={`flex items-center gap-3 rounded-xl border px-3 py-2 ${
props.tone === "user" ? "border-gray-6 bg-gray-1/60" : "border-gray-6/70 bg-gray-2/40"
}`}
>
{props.part.url && isImageAttachment(props.part.mediaType ?? "") ? (
<div className="h-12 w-12 overflow-hidden rounded-xl border border-dls-border bg-dls-sidebar">
<img src={props.part.url} alt={props.part.filename ?? ""} loading="lazy" decoding="async" className="h-full w-full object-cover" />
</div>
) : (
<div className={`flex h-9 w-9 items-center justify-center rounded-lg ${props.tone === "user" ? "bg-gray-12/10 text-gray-12" : "bg-gray-2/70 text-gray-11"}`}>
<span className="text-sm">📄</span>
</div>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-gray-12">{title}</div>
{detail ? <div className="truncate text-[11px] text-gray-11">{detail}</div> : null}
</div>
{props.part.mediaType ? <div className="max-w-[160px] truncate rounded-full bg-gray-1/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-gray-9">{props.part.mediaType}</div> : null}
</div>
);
}
function ReasoningBlock(props: { text: string; developerMode: boolean }) {
const text = props.text.trim();
if (!props.developerMode || !text) return null;
return (
<details className="rounded-lg bg-gray-2/30 p-2">
<summary className="cursor-pointer text-xs text-gray-11">Thinking</summary>
<pre className="mt-2 whitespace-pre-wrap break-words text-xs text-gray-12">{text}</pre>
</details>
);
}
function AssistantBlock(props: { message: UIMessage; developerMode: boolean; isStreaming: boolean }) {
return (
<article className="flex justify-start" data-message-role="assistant" data-message-id={props.message.id}>
<div className="group relative w-full max-w-[760px] text-[15px] leading-[1.72] text-dls-text antialiased">
<div className="space-y-4">
{props.message.parts.map((part, index) => {
if (part.type === "text") {
return (
<MarkdownBlock
key={`${props.message.id}-text-${index}`}
text={part.text}
streaming={props.isStreaming && part.state === "streaming"}
/>
);
}
if (part.type === "file") {
return <FileCard key={`${props.message.id}-file-${index}`} part={part} tone="assistant" />;
}
if (part.type === "reasoning") {
return <ReasoningBlock key={`${props.message.id}-reasoning-${index}`} text={part.text} developerMode={props.developerMode} />;
}
if (part.type === "step-start") {
return <div key={`${props.message.id}-step-${index}`} className="text-[11px] uppercase tracking-[0.12em] text-gray-8">Step started</div>;
}
if (isToolUIPart(part)) {
const toolPart = (part.type === "dynamic-tool"
? part
: ({
...part,
toolName: part.type.replace(/^tool-/, ""),
type: "dynamic-tool",
} as DynamicToolUIPart));
return (
<div key={`${props.message.id}-tool-${index}`} className="mt-4 flex flex-col gap-4">
<ToolCallView part={toolPart} developerMode={props.developerMode} />
</div>
);
}
return null;
})}
</div>
</div>
</article>
);
}
function UserBlock(props: { message: UIMessage }) {
const attachments = props.message.parts.filter((part) => part.type === "file");
const text = props.message.parts.filter((part) => part.type === "text").map((part) => part.text).join("");
return (
<article className="flex justify-end" data-message-role="user" data-message-id={props.message.id}>
<div className="relative max-w-[85%] rounded-[24px] border border-dls-border bg-dls-sidebar px-6 py-4 text-[15px] leading-relaxed text-dls-text">
{attachments.length > 0 ? (
<div className="mb-3 flex flex-wrap gap-2">
{attachments.map((part, index) => (
<FileCard key={`${props.message.id}-attachment-${index}`} part={part} tone="user" />
))}
</div>
) : null}
<div className="whitespace-pre-wrap break-words text-gray-12">{text}</div>
</div>
</article>
);
}
export function SessionTranscript(props: {
messages: UIMessage[];
isStreaming: boolean;
developerMode: boolean;
}) {
const latestAssistantId = latestAssistantMessageId(props.messages);
return (
<div className="space-y-4 pb-4">
{props.messages.map((message) =>
message.role === "user" ? (
<UserBlock key={message.id} message={message} />
) : (
<AssistantBlock
key={message.id}
message={message}
developerMode={props.developerMode}
isStreaming={props.isStreaming && message.id === latestAssistantId}
/>
),
)}
</div>
);
}

View File

@@ -0,0 +1,22 @@
/** @jsxImportSource react */
import { useEffect } from "react";
import { ensureWorkspaceSessionSync } from "./session-sync";
type ReactSessionRuntimeProps = {
workspaceId: string;
opencodeBaseUrl: string;
openworkToken: string;
};
export function ReactSessionRuntime(props: ReactSessionRuntimeProps) {
useEffect(() => {
return ensureWorkspaceSessionSync({
workspaceId: props.workspaceId,
baseUrl: props.opencodeBaseUrl,
openworkToken: props.openworkToken,
});
}, [props.workspaceId, props.opencodeBaseUrl, props.openworkToken]);
return null;
}

View File

@@ -1,12 +1,22 @@
/** @jsxImportSource react */
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { UIMessage } from "ai";
import { useQuery } from "@tanstack/react-query";
import { createClient, unwrap } from "../../app/lib/opencode";
import { createClient } from "../../app/lib/opencode";
import { abortSessionSafe } from "../../app/lib/opencode-session";
import type { OpenworkServerClient, OpenworkSessionMessage, OpenworkSessionSnapshot } from "../../app/lib/openwork-server";
import type { OpenworkServerClient, OpenworkSessionSnapshot } from "../../app/lib/openwork-server";
import { SessionDebugPanel } from "./debug-panel.react";
import { SessionTranscript } from "./message-list.react";
import { deriveSessionRenderModel } from "./transition-controller";
import { getReactQueryClient } from "../kernel/query-client";
import {
seedSessionState,
statusKey as reactStatusKey,
todoKey as reactTodoKey,
transcriptKey as reactTranscriptKey,
} from "./session-sync";
import { snapshotToUIMessages } from "./usechat-adapter";
type SessionSurfaceProps = {
client: OpenworkServerClient;
@@ -17,22 +27,6 @@ type SessionSurfaceProps = {
developerMode: boolean;
};
function partText(part: Record<string, unknown>) {
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...";
@@ -40,109 +34,127 @@ function statusLabel(snapshot: OpenworkSessionSnapshot | undefined, busy: boolea
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 (
<article className={`mx-auto flex w-full max-w-[760px] ${role === "user" ? "justify-end" : "justify-start"}`}>
<div className={`w-full rounded-[24px] border px-5 py-4 shadow-[var(--dls-card-shadow)] ${bubbleClass}`}>
<div className="mb-2 text-[11px] uppercase tracking-[0.18em] text-dls-secondary">
{roleLabel(role)}
</div>
<div className="space-y-3">
{props.message.parts.map((part) => (
<div key={part.id} className="text-sm leading-7 whitespace-pre-wrap break-words">
{partText(part as Record<string, unknown>)}
</div>
))}
</div>
</div>
</article>
function useSharedQueryState<T>(queryKey: readonly unknown[], fallback: T) {
const queryClient = getReactQueryClient();
return useSyncExternalStore(
(callback) => queryClient.getQueryCache().subscribe(callback),
() => (queryClient.getQueryData<T>(queryKey) ?? fallback),
() => fallback,
);
}
export function SessionSurface(props: SessionSurfaceProps) {
const [draft, setDraft] = useState("");
const [actionBusy, setActionBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [rendered, setRendered] = useState<{
sessionId: string;
snapshot: OpenworkSessionSnapshot;
} | null>(null);
const [sending, setSending] = useState(false);
const [rendered, setRendered] = useState<{ sessionId: string; snapshot: OpenworkSessionSnapshot } | null>(null);
const hydratedKeyRef = useRef<string | null>(null);
const opencodeClient = useMemo(
() => createClient(props.opencodeBaseUrl, undefined, { token: props.openworkToken, mode: "openwork" }),
[props.opencodeBaseUrl, props.openworkToken],
);
const queryKey = useMemo(
const snapshotQueryKey = useMemo(
() => ["react-session-snapshot", props.workspaceId, props.sessionId],
[props.workspaceId, props.sessionId],
);
const transcriptQueryKey = useMemo(
() => reactTranscriptKey(props.workspaceId, props.sessionId),
[props.workspaceId, props.sessionId],
);
const statusQueryKey = useMemo(
() => reactStatusKey(props.workspaceId, props.sessionId),
[props.workspaceId, props.sessionId],
);
const todoQueryKey = useMemo(
() => reactTodoKey(props.workspaceId, props.sessionId),
[props.workspaceId, props.sessionId],
);
const query = useQuery<OpenworkSessionSnapshot>({
queryKey,
const snapshotQuery = useQuery<OpenworkSessionSnapshot>({
queryKey: snapshotQueryKey,
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 currentSnapshot = snapshotQuery.data?.session.id === props.sessionId ? snapshotQuery.data : null;
const transcriptState = useSharedQueryState<UIMessage[]>(transcriptQueryKey, []);
const statusState = useSharedQueryState(statusQueryKey, currentSnapshot?.status ?? { type: "idle" as const });
useSharedQueryState(todoQueryKey, currentSnapshot?.todos ?? []);
const snapshot = query.data ?? rendered?.snapshot ?? null;
useEffect(() => {
if (!currentSnapshot) return;
setRendered({ sessionId: props.sessionId, snapshot: currentSnapshot });
}, [props.sessionId, currentSnapshot]);
useEffect(() => {
hydratedKeyRef.current = null;
setError(null);
setSending(false);
}, [props.sessionId]);
useEffect(() => {
if (!currentSnapshot) return;
seedSessionState(props.workspaceId, currentSnapshot);
}, [currentSnapshot, props.workspaceId]);
useEffect(() => {
if (!currentSnapshot) return;
const key = `${props.sessionId}:${currentSnapshot.session.time?.updated ?? currentSnapshot.session.time?.created ?? 0}:${currentSnapshot.messages.length}`;
if (hydratedKeyRef.current === key) return;
hydratedKeyRef.current = key;
seedSessionState(props.workspaceId, currentSnapshot);
}, [props.sessionId, currentSnapshot, props.workspaceId]);
const snapshot = currentSnapshot ?? rendered?.snapshot ?? null;
const liveStatus = statusState ?? snapshot?.status ?? { type: "idle" as const };
const chatStreaming = sending || liveStatus.type === "busy" || liveStatus.type === "retry";
const renderedMessages = transcriptState ?? [];
const model = deriveSessionRenderModel({
intendedSessionId: props.sessionId,
renderedSessionId: query.data ? props.sessionId : rendered?.sessionId ?? null,
hasSnapshot: Boolean(snapshot),
isFetching: query.isFetching,
isError: query.isError,
renderedSessionId: renderedMessages.length > 0 || snapshotQuery.data ? props.sessionId : rendered?.sessionId ?? null,
hasSnapshot: Boolean(snapshot) || renderedMessages.length > 0,
isFetching: snapshotQuery.isFetching || chatStreaming,
isError: snapshotQuery.isError || Boolean(error),
});
const handleSend = async () => {
const text = draft.trim();
if (!text || actionBusy) return;
setActionBusy(true);
if (!text || chatStreaming) return;
setError(null);
setSending(true);
try {
unwrap(
await opencodeClient.session.promptAsync({
sessionID: props.sessionId,
parts: [{ type: "text", text }],
}),
);
const result = await opencodeClient.session.promptAsync({
sessionID: props.sessionId,
parts: [{ type: "text", text }],
});
if (result.error) {
throw result.error instanceof Error ? result.error : new Error(String(result.error));
}
setDraft("");
await query.refetch();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : "Failed to send prompt.");
} finally {
setActionBusy(false);
setSending(false);
}
};
const handleAbort = async () => {
if (actionBusy) return;
setActionBusy(true);
if (!chatStreaming) return;
setError(null);
try {
await abortSessionSafe(opencodeClient, props.sessionId);
await query.refetch();
await snapshotQuery.refetch();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : "Failed to stop run.");
} finally {
setActionBusy(false);
}
};
useEffect(() => {
if (liveStatus.type === "idle") {
setSending(false);
}
}, [liveStatus.type]);
const onComposerKeyDown = async (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!event.metaKey && !event.ctrlKey) return;
if (event.key !== "Enter") return;
@@ -160,30 +172,26 @@ export function SessionSurface(props: SessionSurfaceProps) {
</div>
) : null}
{!snapshot && query.isLoading ? (
{!snapshot && snapshotQuery.isLoading && renderedMessages.length === 0 ? (
<div className="px-6 py-16">
<div className="mx-auto max-w-sm rounded-3xl border border-dls-border bg-dls-hover/60 px-8 py-10 text-center">
<div className="text-sm text-dls-secondary">Loading React session view...</div>
</div>
</div>
) : query.isError && !snapshot ? (
) : (snapshotQuery.isError || error) && !snapshot && renderedMessages.length === 0 ? (
<div className="px-6 py-16">
<div className="mx-auto max-w-xl rounded-3xl border border-red-6/40 bg-red-3/20 px-6 py-5 text-sm text-red-11">
{query.error instanceof Error ? query.error.message : "Failed to load React session view."}
{error || (snapshotQuery.error instanceof Error ? snapshotQuery.error.message : "Failed to load React session view.")}
</div>
</div>
) : snapshot && snapshot.messages.length === 0 ? (
) : renderedMessages.length === 0 && snapshot && snapshot.messages.length === 0 ? (
<div className="px-6 py-16">
<div className="mx-auto max-w-sm rounded-3xl border border-dls-border bg-dls-hover/60 px-8 py-10 text-center">
<div className="text-sm text-dls-secondary">No transcript yet.</div>
</div>
</div>
) : (
<div className="space-y-4">
{snapshot?.messages.map((message) => (
<MessageCard key={message.info.id} message={message} />
))}
</div>
<SessionTranscript messages={renderedMessages} isStreaming={chatStreaming} developerMode={props.developerMode} />
)}
<div className="mx-auto w-full max-w-[800px] px-4">
@@ -198,15 +206,13 @@ export function SessionSurface(props: SessionSurfaceProps) {
disabled={model.transitionState !== "idle"}
/>
<div className="flex items-center justify-between gap-3 border-t border-dls-border px-4 py-3">
<div className="text-xs text-dls-secondary">
{statusLabel(snapshot ?? undefined, actionBusy)}
</div>
<div className="text-xs text-dls-secondary">{statusLabel(snapshot ?? undefined, chatStreaming)}</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-full border border-dls-border px-4 py-2 text-sm text-dls-secondary transition-colors hover:bg-dls-hover disabled:opacity-50"
onClick={handleAbort}
disabled={actionBusy || snapshot?.status.type !== "busy"}
disabled={!chatStreaming}
>
Stop
</button>
@@ -214,15 +220,13 @@ export function SessionSurface(props: SessionSurfaceProps) {
type="button"
className="rounded-full bg-[var(--dls-accent)] px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-[var(--dls-accent-hover)] disabled:opacity-50"
onClick={handleSend}
disabled={actionBusy || !draft.trim() || model.transitionState !== "idle"}
disabled={chatStreaming || !draft.trim() || model.transitionState !== "idle"}
>
Run task
</button>
</div>
</div>
{error ? (
<div className="border-t border-red-6/30 px-4 py-3 text-sm text-red-11">{error}</div>
) : null}
{error ? <div className="border-t border-red-6/30 px-4 py-3 text-sm text-red-11">{error}</div> : null}
</div>
</div>
{props.developerMode ? <SessionDebugPanel model={model} snapshot={snapshot} /> : null}

View File

@@ -0,0 +1,360 @@
import type { UIMessage } from "ai";
import type { Part, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client";
import { getReactQueryClient } from "../kernel/query-client";
import { createClient } from "../../app/lib/opencode";
import { normalizeEvent } from "../../app/utils";
import type { OpencodeEvent } from "../../app/types";
import { snapshotToUIMessages } from "./usechat-adapter";
import type { OpenworkSessionSnapshot } from "../../app/lib/openwork-server";
type SyncOptions = {
workspaceId: string;
baseUrl: string;
openworkToken: string;
};
type SyncEntry = {
refs: number;
stopTimer: ReturnType<typeof setTimeout> | null;
dispose: () => void;
pendingDeltas: Map<string, { messageId: string; reasoning: boolean; text: string }>;
};
const idleStatus: SessionStatus = { type: "idle" };
const syncs = new Map<string, SyncEntry>();
export const transcriptKey = (workspaceId: string, sessionId: string) =>
["react-session-transcript", workspaceId, sessionId] as const;
export const statusKey = (workspaceId: string, sessionId: string) =>
["react-session-status", workspaceId, sessionId] as const;
export const todoKey = (workspaceId: string, sessionId: string) =>
["react-session-todos", workspaceId, sessionId] as const;
function syncKey(input: SyncOptions) {
return `${input.workspaceId}:${input.baseUrl}:${input.openworkToken}`;
}
function toUIPart(part: Part): UIMessage["parts"][number] | null {
if (part.type === "text") {
return {
type: "text",
text: typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : "",
state: "done",
providerMetadata: { opencode: { partId: part.id } },
};
}
if (part.type === "reasoning") {
return {
type: "reasoning",
text: typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : "",
state: "done",
providerMetadata: { opencode: { partId: part.id } },
};
}
if (part.type === "file") {
const file = part as Part & { url?: string; filename?: string; mime?: string };
if (!file.url) return null;
return {
type: "file",
url: file.url,
filename: file.filename,
mediaType: file.mime ?? "application/octet-stream",
providerMetadata: { opencode: { partId: part.id } },
};
}
if (part.type === "tool") {
const record = part as Part & { tool?: string; state?: Record<string, unknown> };
const state = record.state ?? {};
const toolName = typeof record.tool === "string" ? record.tool : "tool";
if (typeof state.error === "string" && state.error.trim()) {
return {
type: "dynamic-tool",
toolName,
toolCallId: part.id,
state: "output-error",
input: state.input,
errorText: state.error,
};
}
if (state.output !== undefined) {
return {
type: "dynamic-tool",
toolName,
toolCallId: part.id,
state: "output-available",
input: state.input,
output: state.output,
};
}
return {
type: "dynamic-tool",
toolName,
toolCallId: part.id,
state: "input-available",
input: state.input,
};
}
if (part.type === "step-start") return { type: "step-start" };
return null;
}
function getPartMetadataId(part: UIMessage["parts"][number]) {
if (part.type !== "text" && part.type !== "reasoning" && part.type !== "file") return null;
const metadata = part.providerMetadata?.opencode;
if (!metadata || typeof metadata !== "object") return null;
return "partId" in metadata ? (metadata as { partId?: string }).partId ?? null : null;
}
function upsertMessage(messages: UIMessage[], next: UIMessage) {
const index = messages.findIndex((message) => message.id === next.id);
if (index === -1) return [...messages, next];
return messages.map((message, messageIndex) =>
messageIndex === index
? {
...message,
...next,
parts: next.parts.length > 0 ? next.parts : message.parts,
}
: message,
);
}
function upsertPart(messages: UIMessage[], messageId: string, partId: string, next: UIMessage["parts"][number]) {
return messages.map((message) => {
if (message.id !== messageId) return message;
const index = message.parts.findIndex((part) =>
("toolCallId" in part && part.toolCallId === partId) || getPartMetadataId(part) === partId,
);
if (index === -1) {
return { ...message, parts: [...message.parts, next] };
}
const parts = message.parts.slice();
parts[index] = next;
return { ...message, parts };
});
}
function appendDelta(messages: UIMessage[], messageId: string, partId: string, delta: string, reasoning: boolean) {
return messages.map((message) => {
if (message.id !== messageId) return message;
// Try to find and update an existing matching part
let matched = false;
const parts = message.parts.map((part) => {
if (reasoning && part.type === "reasoning") {
const id = getPartMetadataId(part);
if (id === partId || (!id && message.parts.at(-1) === part)) {
matched = true;
return { ...part, text: `${part.text}${delta}`, state: "streaming" as const };
}
}
if (!reasoning && part.type === "text") {
const id = getPartMetadataId(part);
if (id === partId || (!id && message.parts.at(-1) === part)) {
matched = true;
return { ...part, text: `${part.text}${delta}`, state: "streaming" as const };
}
}
if (part.type === "dynamic-tool" && part.toolCallId === partId) return part;
return part;
});
// If no existing part matched, create a new one so the delta is not lost
if (!matched) {
const newPart: UIMessage["parts"][number] = reasoning
? { type: "reasoning", text: delta, state: "streaming" as const, providerMetadata: { opencode: { partId } } }
: { type: "text", text: delta, state: "streaming" as const, providerMetadata: { opencode: { partId } } };
return { ...message, parts: [...parts, newPart] };
}
return { ...message, parts };
});
}
function applyEvent(entry: SyncEntry, workspaceId: string, event: OpencodeEvent) {
const queryClient = getReactQueryClient();
if (event.type === "session.status") {
const props = (event.properties ?? {}) as { sessionID?: string; status?: SessionStatus };
if (!props.sessionID || !props.status) return;
queryClient.setQueryData(statusKey(workspaceId, props.sessionID), props.status);
return;
}
if (event.type === "todo.updated") {
const props = (event.properties ?? {}) as { sessionID?: string; todos?: Todo[] };
if (!props.sessionID || !props.todos) return;
queryClient.setQueryData(todoKey(workspaceId, props.sessionID), props.todos);
return;
}
if (event.type === "message.updated") {
const props = (event.properties ?? {}) as { info?: { id?: string; role?: UIMessage["role"] | string; sessionID?: string } };
const info = props.info;
if (!info?.id || !info.sessionID || (info.role !== "user" && info.role !== "assistant" && info.role !== "system")) {
return;
}
const next = { id: info.id, role: info.role, parts: [] } satisfies UIMessage;
queryClient.setQueryData<UIMessage[]>(transcriptKey(workspaceId, info.sessionID), (current = []) =>
upsertMessage(current, next),
);
return;
}
if (event.type === "message.part.updated") {
const props = (event.properties ?? {}) as { part?: Part };
const part = props.part;
if (!part?.sessionID || !part.messageID) return;
const mapped = toUIPart(part);
if (!mapped) return;
const pending = entry.pendingDeltas.get(part.id);
const seededPart =
pending && ((mapped.type === "text" && !pending.reasoning) || (mapped.type === "reasoning" && pending.reasoning))
? { ...mapped, text: `${mapped.text}${pending.text}`, state: "streaming" as const }
: mapped;
queryClient.setQueryData<UIMessage[]>(transcriptKey(workspaceId, part.sessionID), (current = []) => {
const withMessage = upsertMessage(current, { id: part.messageID, role: "assistant", parts: [] });
return upsertPart(withMessage, part.messageID, part.id, seededPart);
});
if (pending) entry.pendingDeltas.delete(part.id);
return;
}
if (event.type === "message.part.delta") {
const props = (event.properties ?? {}) as {
sessionID?: string;
messageID?: string;
partID?: string;
field?: string;
delta?: string;
};
if (!props.sessionID || !props.messageID || !props.partID || !props.delta) return;
queryClient.setQueryData<UIMessage[]>(transcriptKey(workspaceId, props.sessionID), (current = []) => {
// Ensure the message shell exists before appending the delta
const withMessage = upsertMessage(current, { id: props.messageID!, role: "assistant", parts: [] });
const next = appendDelta(withMessage, props.messageID!, props.partID!, props.delta!, props.field === "reasoning");
const message = next.find((item) => item.id === props.messageID);
const matched = message?.parts.some((part) =>
(part.type === "dynamic-tool" && part.toolCallId === props.partID) || getPartMetadataId(part) === props.partID,
);
if (!matched) {
const pending = entry.pendingDeltas.get(props.partID!) ?? {
messageId: props.messageID!,
reasoning: props.field === "reasoning",
text: "",
};
pending.text += props.delta!;
entry.pendingDeltas.set(props.partID!, pending);
}
return next;
});
return;
}
if (event.type === "session.idle") {
const props = (event.properties ?? {}) as { sessionID?: string };
if (!props.sessionID) return;
queryClient.setQueryData(statusKey(workspaceId, props.sessionID), idleStatus);
}
}
function startSync(input: SyncOptions) {
const client = createClient(input.baseUrl, undefined, { token: input.openworkToken, mode: "openwork" });
const controller = new AbortController();
const entry = syncs.get(syncKey(input));
void client.event.subscribe(undefined, { signal: controller.signal }).then((sub) => {
void (async () => {
for await (const raw of sub.stream) {
if (controller.signal.aborted) return;
const event = normalizeEvent(raw);
if (!event) continue;
if (!entry) continue;
applyEvent(entry, input.workspaceId, event);
}
})();
});
return () => controller.abort();
}
export function ensureWorkspaceSessionSync(input: SyncOptions) {
const key = syncKey(input);
const existing = syncs.get(key);
if (existing) {
existing.refs += 1;
if (existing.stopTimer) {
clearTimeout(existing.stopTimer);
existing.stopTimer = null;
}
return () => releaseWorkspaceSessionSync(input);
}
syncs.set(key, {
refs: 1,
stopTimer: null,
dispose: () => {},
pendingDeltas: new Map(),
});
const created = syncs.get(key)!;
created.dispose = startSync(input);
return () => releaseWorkspaceSessionSync(input);
}
function releaseWorkspaceSessionSync(input: SyncOptions) {
const key = syncKey(input);
const existing = syncs.get(key);
if (!existing) return;
existing.refs -= 1;
if (existing.refs > 0) return;
existing.stopTimer = setTimeout(() => {
existing.dispose();
syncs.delete(key);
}, 10_000);
}
export function seedSessionState(workspaceId: string, snapshot: OpenworkSessionSnapshot) {
const queryClient = getReactQueryClient();
const key = transcriptKey(workspaceId, snapshot.session.id);
const incoming = snapshotToUIMessages(snapshot);
const existing = queryClient.getQueryData<UIMessage[]>(key);
if (existing && existing.length > 0 && (snapshot.status.type === "busy" || snapshot.status.type === "retry")) {
// During active streaming the server snapshot may have empty/stale text
// for in-progress parts while the cache already accumulated text via
// deltas. Merge so we never overwrite longer cached text with shorter
// server text.
const merged = incoming.map((incomingMsg) => {
const cachedMsg = existing.find((m) => m.id === incomingMsg.id);
if (!cachedMsg) return incomingMsg;
const parts = incomingMsg.parts.map((inPart, index) => {
const cachedPart = cachedMsg.parts[index];
if (!cachedPart) return inPart;
if (
(inPart.type === "text" || inPart.type === "reasoning") &&
(cachedPart.type === "text" || cachedPart.type === "reasoning") &&
cachedPart.text.length > inPart.text.length
) {
return { ...inPart, text: cachedPart.text };
}
return inPart;
});
// Keep any extra cached parts the server doesn't know about yet
if (cachedMsg.parts.length > incomingMsg.parts.length) {
for (let i = incomingMsg.parts.length; i < cachedMsg.parts.length; i++) {
parts.push(cachedMsg.parts[i]);
}
}
return { ...incomingMsg, parts };
});
queryClient.setQueryData(key, merged);
} else {
queryClient.setQueryData(key, incoming);
}
queryClient.setQueryData(statusKey(workspaceId, snapshot.session.id), snapshot.status);
queryClient.setQueryData(todoKey(workspaceId, snapshot.session.id), snapshot.todos);
}

View File

@@ -0,0 +1,166 @@
/** @jsxImportSource react */
import { useMemo, useState } from "react";
import type { DynamicToolUIPart } from "ai";
import { safeStringify, summarizeStep } from "../../app/utils";
function normalizeToolText(value: unknown) {
if (typeof value !== "string") return "";
return value.replace(/(?:\r?\n\s*)+$/, "");
}
function hasStructuredValue(value: unknown) {
if (value === undefined || value === null) return false;
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === "object") return Object.keys(value as Record<string, unknown>).length > 0;
return true;
}
function formatStructuredValue(value: unknown) {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value.trim();
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function diffLineClass(line: string) {
if (line.startsWith("+")) return "text-green-11 bg-green-1/40";
if (line.startsWith("-")) return "text-red-11 bg-red-1/40";
if (line.startsWith("@@")) return "text-blue-11 bg-blue-1/30";
return "text-gray-12";
}
function extractDiff(output: unknown) {
if (typeof output !== "string") return null;
if (output.includes("@@") || output.includes("+++ ") || output.includes("--- ")) {
return output;
}
return null;
}
export function ToolCallView(props: { part: DynamicToolUIPart; developerMode: boolean }) {
const [expanded, setExpanded] = useState(false);
const summary = useMemo(
() =>
summarizeStep({
id: props.part.toolCallId,
type: "tool",
sessionID: "",
messageID: "",
tool: props.part.toolName,
state: {
input: props.part.input,
output: props.part.state === "output-available" ? props.part.output : undefined,
error: props.part.state === "output-error" ? props.part.errorText : undefined,
status:
props.part.state === "output-available"
? "completed"
: props.part.state === "output-error"
? "error"
: "running",
},
} as any),
[props.part],
);
const title = summary.title?.trim() || props.part.toolName || "Tool";
const subtitle = summary.detail?.trim() || "";
const status =
props.part.state === "output-available"
? "completed"
: props.part.state === "output-error"
? "error"
: "running";
const input = props.part.input;
const output = props.part.state === "output-available" ? props.part.output : undefined;
const error = props.part.state === "output-error" ? props.part.errorText : "";
const diff = extractDiff(output);
const diffLines = diff ? normalizeToolText(diff).split("\n") : [];
const expandable = hasStructuredValue(input) || hasStructuredValue(output) || Boolean(diff) || Boolean(error);
return (
<div className="grid gap-3 text-[14px] text-gray-9">
<button
type="button"
className="w-full text-left transition-colors hover:text-dls-text disabled:cursor-default"
aria-expanded={expandable ? expanded : undefined}
disabled={!expandable}
onClick={() => {
if (!expandable) return;
setExpanded((value) => !value);
}}
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-xs font-medium text-gray-12">{title}</div>
<div className="text-[11px] text-gray-11">{props.part.toolName}</div>
{subtitle ? <div className="text-xs text-gray-11">{subtitle}</div> : null}
</div>
<div
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
status === "completed"
? "bg-green-3/15 text-green-12"
: status === "running"
? "bg-blue-3/15 text-blue-12"
: status === "error"
? "bg-red-3/15 text-red-12"
: "bg-gray-2/10 text-gray-12"
}`}
>
{status}
</div>
</div>
</button>
{expanded ? (
<div className="space-y-3 pl-[22px]">
{Boolean(diff) ? (
<div className="rounded-lg border bg-gray-2/30 p-2">
<div className="text-[11px] font-medium text-gray-11">Diff</div>
<div className="mt-2 grid gap-1 overflow-hidden rounded-md">
{diffLines.map((line, index) => (
<div
key={`${props.part.toolCallId}-diff-${index}`}
className={`whitespace-pre-wrap break-words px-2 py-0.5 font-mono text-[11px] leading-relaxed ${diffLineClass(line)}`}
>
{line || " "}
</div>
))}
</div>
</div>
) : null}
{hasStructuredValue(input) ? (
<div>
<div className="mb-1 text-[11px] font-medium uppercase tracking-[0.12em] text-gray-8">Tool request</div>
<pre className="overflow-x-auto rounded-[16px] border border-dls-border/70 bg-dls-surface px-4 py-3 text-[12px] leading-6 text-gray-10">
{formatStructuredValue(input)}
</pre>
</div>
) : null}
{hasStructuredValue(output) && normalizeToolText(output) !== normalizeToolText(diff) ? (
<div>
<div className="mb-1 text-[11px] font-medium uppercase tracking-[0.12em] text-gray-8">Tool result</div>
<pre className="overflow-x-auto rounded-[16px] border border-dls-border/70 bg-dls-surface px-4 py-3 text-[12px] leading-6 text-gray-10">
{formatStructuredValue(output)}
</pre>
</div>
) : null}
{error ? <div className="rounded-lg bg-red-1/40 p-2 text-xs text-red-12">{error}</div> : null}
{props.developerMode && !expandable ? (
<pre className="overflow-x-auto rounded-[16px] border border-dls-border/70 bg-dls-surface px-4 py-3 text-[12px] leading-6 text-gray-10">
{safeStringify({ input, output, error, state: props.part.state })}
</pre>
) : null}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,444 @@
/** @jsxImportSource react */
import type { UIMessage, UIMessageChunk, ChatTransport, DynamicToolUIPart } from "ai";
import type { Part } from "@opencode-ai/sdk/v2/client";
import { abortSessionSafe } from "../../app/lib/opencode-session";
import type { OpenworkSessionMessage, OpenworkSessionSnapshot } from "../../app/lib/openwork-server";
import { normalizeEvent, safeStringify } from "../../app/utils";
import type { OpencodeEvent } from "../../app/types";
import { createClient } from "../../app/lib/opencode";
type TransportOptions = {
baseUrl: string;
openworkToken: string;
sessionId: string;
};
type ToolStreamState = {
inputSent: boolean;
outputSent: boolean;
errorSent: boolean;
stepStarted: boolean;
stepFinished: boolean;
};
type InternalPartState = {
textStarted: Set<string>;
reasoningStarted: Set<string>;
partKinds: Map<string, Part["type"]>;
partSessions: Map<string, string>;
tools: Map<string, ToolStreamState>;
assistantMessageId: string | null;
streamFinished: boolean;
};
function getTextPartValue(part: Part) {
if (part.type === "text") {
return typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : "";
}
if (part.type === "reasoning") {
return typeof (part as { text?: unknown }).text === "string" ? (part as { text: string }).text : "";
}
return "";
}
function mapToolPart(part: Part): DynamicToolUIPart {
const record = part as Part & { tool?: string; state?: Record<string, unknown> };
const state = (record.state ?? {}) as Record<string, unknown>;
const toolName = typeof record.tool === "string" ? record.tool : "tool";
const input = state.input;
const output = state.output;
const errorText = typeof state.error === "string" ? state.error : undefined;
if (errorText) {
return {
type: "dynamic-tool",
toolName,
toolCallId: part.id,
state: "output-error",
input,
errorText,
};
}
if (output !== undefined) {
return {
type: "dynamic-tool",
toolName,
toolCallId: part.id,
state: "output-available",
input,
output,
};
}
return {
type: "dynamic-tool",
toolName,
toolCallId: part.id,
state: "input-available",
input,
};
}
export function snapshotToUIMessages(snapshot: OpenworkSessionSnapshot): UIMessage[] {
return snapshot.messages.map((message) => ({
id: message.info.id,
role: message.info.role,
parts: message.parts.flatMap<UIMessage["parts"][number]>((part) => {
if (part.type === "text") {
return [{
type: "text",
text: getTextPartValue(part),
state: "done" as const,
providerMetadata: { opencode: { partId: part.id } },
}];
}
if (part.type === "reasoning") {
return [{
type: "reasoning",
text: getTextPartValue(part),
state: "done" as const,
providerMetadata: { opencode: { partId: part.id } },
}];
}
if (part.type === "file") {
const record = part as Part & { url?: string; filename?: string; mime?: string };
return record.url
? [{
type: "file",
url: record.url,
filename: record.filename,
mediaType: record.mime ?? "application/octet-stream",
providerMetadata: { opencode: { partId: part.id } },
}]
: [];
}
if (part.type === "tool") {
return [{ ...mapToolPart(part), providerMetadata: { opencode: { partId: part.id } } }];
}
if (part.type === "step-start") {
return [{ type: "step-start", providerMetadata: { opencode: { partId: part.id } } }];
}
return [];
}),
}));
}
function extractLastUserText(messages: UIMessage[]) {
const lastUser = [...messages].reverse().find((message) => message.role === "user");
if (!lastUser) return "";
return lastUser.parts
.flatMap((part) => {
if (part.type === "text") return [part.text];
return [];
})
.join("")
.trim();
}
function createPartState(): InternalPartState {
return {
textStarted: new Set<string>(),
reasoningStarted: new Set<string>(),
partKinds: new Map<string, Part["type"]>(),
partSessions: new Map<string, string>(),
tools: new Map<string, ToolStreamState>(),
assistantMessageId: null,
streamFinished: false,
};
}
function ensureAssistantStart(
controller: ReadableStreamDefaultController<UIMessageChunk>,
state: InternalPartState,
messageId: string,
) {
if (state.assistantMessageId) return;
state.assistantMessageId = messageId;
controller.enqueue({ type: "start", messageId });
}
function finalizeOpenParts(
controller: ReadableStreamDefaultController<UIMessageChunk>,
state: InternalPartState,
) {
for (const id of state.textStarted) {
controller.enqueue({ type: "text-end", id });
}
for (const id of state.reasoningStarted) {
controller.enqueue({ type: "reasoning-end", id });
}
state.textStarted.clear();
state.reasoningStarted.clear();
}
function handleToolPart(
controller: ReadableStreamDefaultController<UIMessageChunk>,
state: InternalPartState,
part: Part,
) {
const record = part as Part & { tool?: string; state?: Record<string, unknown> };
const toolName = typeof record.tool === "string" ? record.tool : "tool";
const toolState = state.tools.get(part.id) ?? {
inputSent: false,
outputSent: false,
errorSent: false,
stepStarted: false,
stepFinished: false,
};
const current = (record.state ?? {}) as Record<string, unknown>;
if (!toolState.stepStarted) {
controller.enqueue({ type: "start-step" });
toolState.stepStarted = true;
}
if (!toolState.inputSent) {
controller.enqueue({
type: "tool-input-available",
toolCallId: part.id,
toolName,
input: current.input,
});
toolState.inputSent = true;
}
if (!toolState.errorSent && typeof current.error === "string" && current.error.trim()) {
controller.enqueue({
type: "tool-output-error",
toolCallId: part.id,
errorText: current.error,
});
toolState.errorSent = true;
if (!toolState.stepFinished) {
controller.enqueue({ type: "finish-step" });
toolState.stepFinished = true;
}
} else if (!toolState.outputSent && current.output !== undefined) {
controller.enqueue({
type: "tool-output-available",
toolCallId: part.id,
output: current.output,
});
toolState.outputSent = true;
if (!toolState.stepFinished) {
controller.enqueue({ type: "finish-step" });
toolState.stepFinished = true;
}
}
state.tools.set(part.id, toolState);
}
function handleEventChunk(
controller: ReadableStreamDefaultController<UIMessageChunk>,
state: InternalPartState,
event: OpencodeEvent,
sessionId: string,
) {
if (state.streamFinished) return;
if (event.type === "session.error") {
const record = (event.properties ?? {}) as Record<string, unknown>;
if (record.sessionID !== sessionId) return;
const errorObj = record.error;
const errorText =
typeof errorObj === "object" && errorObj && "message" in (errorObj as Record<string, unknown>)
? String((errorObj as Record<string, unknown>).message ?? "")
: typeof record.error === "string"
? record.error
: "Session failed";
finalizeOpenParts(controller, state);
controller.enqueue({ type: "error", errorText: errorText || "Session failed" });
controller.enqueue({ type: "finish", finishReason: "error" });
state.streamFinished = true;
controller.close();
return;
}
if (event.type === "session.idle") {
const record = (event.properties ?? {}) as Record<string, unknown>;
if (record.sessionID !== sessionId) return;
finalizeOpenParts(controller, state);
controller.enqueue({ type: "finish", finishReason: "stop" });
state.streamFinished = true;
controller.close();
return;
}
if (event.type === "message.updated") {
const record = (event.properties ?? {}) as Record<string, unknown>;
const info = record.info as { id?: string; role?: string; sessionID?: string } | undefined;
if (!info || info.sessionID !== sessionId || info.role !== "assistant" || typeof info.id !== "string") {
return;
}
ensureAssistantStart(controller, state, info.id);
return;
}
if (event.type === "message.part.updated") {
const record = (event.properties ?? {}) as Record<string, unknown>;
const part = record.part as Part | undefined;
const delta = typeof record.delta === "string" ? record.delta : "";
if (!part || part.sessionID !== sessionId) return;
ensureAssistantStart(controller, state, part.messageID);
state.partKinds.set(part.id, part.type);
state.partSessions.set(part.id, part.sessionID);
if (part.type === "text") {
if (!state.textStarted.has(part.id)) {
state.textStarted.add(part.id);
controller.enqueue({ type: "text-start", id: part.id });
const initial = delta || getTextPartValue(part);
if (initial) controller.enqueue({ type: "text-delta", id: part.id, delta: initial });
}
return;
}
if (part.type === "reasoning") {
if (!state.reasoningStarted.has(part.id)) {
state.reasoningStarted.add(part.id);
controller.enqueue({ type: "reasoning-start", id: part.id });
const initial = delta || getTextPartValue(part);
if (initial) controller.enqueue({ type: "reasoning-delta", id: part.id, delta: initial });
}
return;
}
if (part.type === "tool") {
handleToolPart(controller, state, part);
return;
}
if (part.type === "file") {
const file = part as Part & { url?: string; mime?: string };
if (file.url && file.mime) {
controller.enqueue({ type: "file", url: file.url, mediaType: file.mime });
}
}
return;
}
if (event.type === "message.part.delta") {
const record = (event.properties ?? {}) as Record<string, unknown>;
const messageID = typeof record.messageID === "string" ? record.messageID : null;
const partID = typeof record.partID === "string" ? record.partID : null;
const recordSessionID = typeof record.sessionID === "string" ? record.sessionID : null;
const field = typeof record.field === "string" ? record.field : null;
const delta = typeof record.delta === "string" ? record.delta : "";
if (!messageID || !partID || !field || !delta) return;
const ownerSessionID = recordSessionID ?? state.partSessions.get(partID) ?? null;
if (ownerSessionID !== sessionId) return;
ensureAssistantStart(controller, state, messageID);
const kind = state.partKinds.get(partID);
if (field === "text" && kind === "reasoning") {
if (!state.reasoningStarted.has(partID)) {
state.reasoningStarted.add(partID);
controller.enqueue({ type: "reasoning-start", id: partID });
}
controller.enqueue({ type: "reasoning-delta", id: partID, delta });
return;
}
if (field === "text") {
if (!state.textStarted.has(partID)) {
state.textStarted.add(partID);
controller.enqueue({ type: "text-start", id: partID });
}
controller.enqueue({ type: "text-delta", id: partID, delta });
return;
}
}
}
export function createOpenworkChatTransport(options: TransportOptions): ChatTransport<UIMessage> {
return {
async sendMessages({ messages, abortSignal }) {
const client = createClient(options.baseUrl, undefined, {
token: options.openworkToken,
mode: "openwork",
});
return new ReadableStream<UIMessageChunk>({
async start(controller) {
const state = createPartState();
const lastUserText = extractLastUserText(messages);
if (!lastUserText) {
controller.enqueue({ type: "error", errorText: "No user message to send." });
controller.close();
return;
}
let closed = false;
const close = () => {
if (closed) return;
closed = true;
controller.close();
};
abortSignal?.addEventListener("abort", () => {
void abortSessionSafe(client, options.sessionId).finally(() => {
if (!state.streamFinished) {
controller.enqueue({ type: "abort", reason: "user cancelled" });
}
close();
});
});
try {
const sub = await client.event.subscribe(undefined, { signal: abortSignal });
const consume = (async () => {
for await (const raw of sub.stream) {
if (closed) return;
const event = normalizeEvent(raw);
if (!event) continue;
handleEventChunk(controller, state, event, options.sessionId);
if (state.streamFinished) return;
}
})();
const result = await client.session.promptAsync({
sessionID: options.sessionId,
parts: [{ type: "text", text: lastUserText }],
});
if (result.error) {
throw new Error(
result.error instanceof Error ? result.error.message : safeStringify(result.error),
);
}
await consume;
if (!state.streamFinished && !closed) {
finalizeOpenParts(controller, state);
controller.enqueue({ type: "finish", finishReason: "stop" });
close();
}
} catch (error) {
if (closed) return;
finalizeOpenParts(controller, state);
controller.enqueue({
type: "error",
errorText: error instanceof Error ? error.message : "Failed to stream response.",
});
close();
}
},
async cancel() {
await abortSessionSafe(client, options.sessionId);
},
});
},
async reconnectToStream() {
return null;
},
};
}

2046
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff