mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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 commit15f37a09c1. * Revert "feat(app): keep React session state in shared sync cache" This reverts commit49df59d6ef. * 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:
@@ -35,6 +35,7 @@
|
|||||||
"bump:set": "node scripts/bump-version.mjs --set"
|
"bump:set": "node scripts/bump-version.mjs --set"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/react": "^3.0.148",
|
||||||
"@codemirror/commands": "^6.8.0",
|
"@codemirror/commands": "^6.8.0",
|
||||||
"@codemirror/lang-markdown": "^6.3.3",
|
"@codemirror/lang-markdown": "^6.3.3",
|
||||||
"@codemirror/language": "^6.11.0",
|
"@codemirror/language": "^6.11.0",
|
||||||
@@ -55,20 +56,24 @@
|
|||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"@tauri-apps/plugin-process": "~2.3.1",
|
"@tauri-apps/plugin-process": "~2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "~2.9.0",
|
"@tauri-apps/plugin-updater": "~2.9.0",
|
||||||
|
"ai": "^6.0.146",
|
||||||
"fuzzysort": "^3.1.0",
|
"fuzzysort": "^3.1.0",
|
||||||
"jsonc-parser": "^3.2.1",
|
"jsonc-parser": "^3.2.1",
|
||||||
"lucide-solid": "^0.562.0",
|
"lucide-solid": "^0.562.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
|
"@solid-devtools/overlay": "^0.33.5",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.2",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"@solid-devtools/overlay": "^0.33.5",
|
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
|
||||||
"solid-devtools": "^0.34.5",
|
"solid-devtools": "^0.34.5",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Match,
|
Match,
|
||||||
|
Show,
|
||||||
Switch,
|
Switch,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
@@ -111,6 +112,7 @@ import {
|
|||||||
readStoredFontZoom,
|
readStoredFontZoom,
|
||||||
} from "./lib/font-zoom";
|
} from "./lib/font-zoom";
|
||||||
import {
|
import {
|
||||||
|
buildOpenworkWorkspaceBaseUrl,
|
||||||
parseOpenworkWorkspaceIdFromUrl,
|
parseOpenworkWorkspaceIdFromUrl,
|
||||||
readOpenworkConnectInviteFromSearch,
|
readOpenworkConnectInviteFromSearch,
|
||||||
stripOpenworkConnectInviteFromUrl,
|
stripOpenworkConnectInviteFromUrl,
|
||||||
@@ -120,6 +122,9 @@ import {
|
|||||||
writeOpenworkServerSettings,
|
writeOpenworkServerSettings,
|
||||||
type OpenworkServerSettings,
|
type OpenworkServerSettings,
|
||||||
} from "./lib/openwork-server";
|
} from "./lib/openwork-server";
|
||||||
|
import { ReactIsland } from "../react/island";
|
||||||
|
import { reactSessionEnabled } from "../react/feature-flag";
|
||||||
|
import { ReactSessionRuntime } from "../react/session/runtime-sync.react";
|
||||||
import {
|
import {
|
||||||
parseBundleDeepLink,
|
parseBundleDeepLink,
|
||||||
stripBundleQuery,
|
stripBundleQuery,
|
||||||
@@ -2283,6 +2288,24 @@ export default function App() {
|
|||||||
error: error(),
|
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>([
|
const settingsTabs = new Set<SettingsTab>([
|
||||||
"general",
|
"general",
|
||||||
"den",
|
"den",
|
||||||
@@ -2385,6 +2408,18 @@ export default function App() {
|
|||||||
<ExtensionsProvider store={extensionsStore}>
|
<ExtensionsProvider store={extensionsStore}>
|
||||||
<AutomationsProvider store={automationsStore}>
|
<AutomationsProvider store={automationsStore}>
|
||||||
<StatusToastsProvider store={statusToastsStore}>
|
<StatusToastsProvider store={statusToastsStore}>
|
||||||
|
<Show when={showReactSessionRuntime()}>
|
||||||
|
<ReactIsland
|
||||||
|
class="hidden"
|
||||||
|
instanceKey={`react-runtime:${runtimeWorkspaceId()!}`}
|
||||||
|
component={ReactSessionRuntime}
|
||||||
|
props={{
|
||||||
|
workspaceId: runtimeWorkspaceId()!,
|
||||||
|
opencodeBaseUrl: reactSessionRuntimeBaseUrl(),
|
||||||
|
openworkToken: reactSessionRuntimeToken(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={currentView() === "session"}>
|
<Match when={currentView() === "session"}>
|
||||||
<SessionView {...sessionProps()} />
|
<SessionView {...sessionProps()} />
|
||||||
|
|||||||
@@ -129,16 +129,33 @@ const upsertPartInfo = (list: Part[], next: Part) => {
|
|||||||
const index = list.findIndex((part) => part.id === next.id);
|
const index = list.findIndex((part) => part.id === next.id);
|
||||||
if (index === -1) return sortById([...list, next]);
|
if (index === -1) return sortById([...list, next]);
|
||||||
const copy = list.slice();
|
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;
|
return copy;
|
||||||
};
|
};
|
||||||
|
|
||||||
const removePartInfo = (list: Part[], partID: string) => list.filter((part) => part.id !== partID);
|
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;
|
if (!delta) return list;
|
||||||
const index = list.findIndex((part) => part.id === partID);
|
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 existing = list[index] as Part & Record<string, unknown>;
|
||||||
const current = existing[field];
|
const current = existing[field];
|
||||||
@@ -1063,11 +1080,45 @@ export function createSessionStore(options: {
|
|||||||
.filter((info) => !!info?.id)
|
.filter((info) => !!info?.id)
|
||||||
.map((info) => info as MessageInfo);
|
.map((info) => info as MessageInfo);
|
||||||
|
|
||||||
|
const isStreaming = (store.sessionStatus[sessionID] ?? "idle") !== "idle";
|
||||||
|
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("messages", sessionID, reconcile(sortById(infos), { key: "id" }));
|
setStore("messages", sessionID, reconcile(sortById(infos), { key: "id" }));
|
||||||
for (const message of list) {
|
for (const message of list) {
|
||||||
const parts = message.parts.filter((part) => !!part?.id);
|
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.type === "message.part.delta") {
|
||||||
if (event.properties && typeof event.properties === "object") {
|
if (event.properties && typeof event.properties === "object") {
|
||||||
const record = event.properties as Record<string, unknown>;
|
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 messageID = typeof record.messageID === "string" ? record.messageID : null;
|
||||||
const partID = typeof record.partID === "string" ? record.partID : null;
|
const partID = typeof record.partID === "string" ? record.partID : null;
|
||||||
const field = typeof record.field === "string" ? record.field : null;
|
const field = typeof record.field === "string" ? record.field : null;
|
||||||
@@ -1763,10 +1815,11 @@ export function createSessionStore(options: {
|
|||||||
const partDeltaStartedAt = perfNow();
|
const partDeltaStartedAt = perfNow();
|
||||||
|
|
||||||
if (messageID && partID && field && delta) {
|
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;
|
const partDeltaMs = Math.round((perfNow() - partDeltaStartedAt) * 100) / 100;
|
||||||
if (sessionDebugEnabled() && (partDeltaMs >= 8 || delta.length >= 120)) {
|
if (sessionDebugEnabled() && (partDeltaMs >= 8 || delta.length >= 120)) {
|
||||||
recordPerfLog(true, "session.event", "message.part.delta", {
|
recordPerfLog(true, "session.event", "message.part.delta", {
|
||||||
|
sessionID,
|
||||||
messageID,
|
messageID,
|
||||||
partID,
|
partID,
|
||||||
field,
|
field,
|
||||||
|
|||||||
@@ -3352,6 +3352,7 @@ export default function SessionView(props: SessionViewProps) {
|
|||||||
fallback={
|
fallback={
|
||||||
<ReactIsland
|
<ReactIsland
|
||||||
class="pb-4"
|
class="pb-4"
|
||||||
|
instanceKey={`${props.runtimeWorkspaceId!}:${props.selectedSessionId!}`}
|
||||||
component={SessionSurface}
|
component={SessionSurface}
|
||||||
props={{
|
props={{
|
||||||
client: props.openworkServerClient!,
|
client: props.openworkServerClient!,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createEffect, onCleanup, onMount } from "solid-js";
|
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ type ReactIslandProps<T extends object> = {
|
|||||||
component: ComponentType<T>;
|
component: ComponentType<T>;
|
||||||
props: T;
|
props: T;
|
||||||
class?: string;
|
class?: string;
|
||||||
|
instanceKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ReactIsland<T extends object>(props: ReactIslandProps<T>) {
|
export function ReactIsland<T extends object>(props: ReactIslandProps<T>) {
|
||||||
@@ -20,7 +21,7 @@ export function ReactIsland<T extends object>(props: ReactIslandProps<T>) {
|
|||||||
createElement(
|
createElement(
|
||||||
QueryClientProvider,
|
QueryClientProvider,
|
||||||
{ client: queryClient },
|
{ 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(() => {
|
createEffect(() => {
|
||||||
props.props;
|
props.props;
|
||||||
|
props.instanceKey;
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
12
apps/app/src/react/kernel/query-client.ts
Normal file
12
apps/app/src/react/kernel/query-client.ts
Normal 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;
|
||||||
|
}
|
||||||
84
apps/app/src/react/session/markdown.react.tsx
Normal file
84
apps/app/src/react/session/markdown.react.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
apps/app/src/react/session/message-list.react.tsx
Normal file
150
apps/app/src/react/session/message-list.react.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/app/src/react/session/runtime-sync.react.tsx
Normal file
22
apps/app/src/react/session/runtime-sync.react.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,12 +1,22 @@
|
|||||||
/** @jsxImportSource react */
|
/** @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 { 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 { 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 { SessionDebugPanel } from "./debug-panel.react";
|
||||||
|
import { SessionTranscript } from "./message-list.react";
|
||||||
import { deriveSessionRenderModel } from "./transition-controller";
|
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 = {
|
type SessionSurfaceProps = {
|
||||||
client: OpenworkServerClient;
|
client: OpenworkServerClient;
|
||||||
@@ -17,22 +27,6 @@ type SessionSurfaceProps = {
|
|||||||
developerMode: boolean;
|
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) {
|
function statusLabel(snapshot: OpenworkSessionSnapshot | undefined, busy: boolean) {
|
||||||
if (busy) return "Running...";
|
if (busy) return "Running...";
|
||||||
if (snapshot?.status.type === "busy") return "Running...";
|
if (snapshot?.status.type === "busy") return "Running...";
|
||||||
@@ -40,109 +34,127 @@ function statusLabel(snapshot: OpenworkSessionSnapshot | undefined, busy: boolea
|
|||||||
return "Ready";
|
return "Ready";
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageCard(props: { message: OpenworkSessionMessage }) {
|
function useSharedQueryState<T>(queryKey: readonly unknown[], fallback: T) {
|
||||||
const role = props.message.info.role;
|
const queryClient = getReactQueryClient();
|
||||||
const bubbleClass =
|
return useSyncExternalStore(
|
||||||
role === "user"
|
(callback) => queryClient.getQueryCache().subscribe(callback),
|
||||||
? "border-blue-6/35 bg-blue-3/25 text-gray-12"
|
() => (queryClient.getQueryData<T>(queryKey) ?? fallback),
|
||||||
: "border-dls-border bg-dls-surface text-gray-12";
|
() => fallback,
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionSurface(props: SessionSurfaceProps) {
|
export function SessionSurface(props: SessionSurfaceProps) {
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [rendered, setRendered] = useState<{
|
const [sending, setSending] = useState(false);
|
||||||
sessionId: string;
|
const [rendered, setRendered] = useState<{ sessionId: string; snapshot: OpenworkSessionSnapshot } | null>(null);
|
||||||
snapshot: OpenworkSessionSnapshot;
|
const hydratedKeyRef = useRef<string | null>(null);
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const opencodeClient = useMemo(
|
const opencodeClient = useMemo(
|
||||||
() => createClient(props.opencodeBaseUrl, undefined, { token: props.openworkToken, mode: "openwork" }),
|
() => createClient(props.opencodeBaseUrl, undefined, { token: props.openworkToken, mode: "openwork" }),
|
||||||
[props.opencodeBaseUrl, props.openworkToken],
|
[props.opencodeBaseUrl, props.openworkToken],
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryKey = useMemo(
|
const snapshotQueryKey = useMemo(
|
||||||
() => ["react-session-snapshot", props.workspaceId, props.sessionId],
|
() => ["react-session-snapshot", props.workspaceId, props.sessionId],
|
||||||
[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>({
|
const snapshotQuery = useQuery<OpenworkSessionSnapshot>({
|
||||||
queryKey,
|
queryKey: snapshotQueryKey,
|
||||||
queryFn: async () => (await props.client.getSessionSnapshot(props.workspaceId, props.sessionId, { limit: 140 })).item,
|
queryFn: async () => (await props.client.getSessionSnapshot(props.workspaceId, props.sessionId, { limit: 140 })).item,
|
||||||
staleTime: 500,
|
staleTime: 500,
|
||||||
refetchInterval: (current) =>
|
|
||||||
actionBusy || current.state.data?.status.type === "busy" || current.state.data?.status.type === "retry"
|
|
||||||
? 800
|
|
||||||
: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const currentSnapshot = snapshotQuery.data?.session.id === props.sessionId ? snapshotQuery.data : null;
|
||||||
if (!query.data) return;
|
const transcriptState = useSharedQueryState<UIMessage[]>(transcriptQueryKey, []);
|
||||||
setRendered({ sessionId: props.sessionId, snapshot: query.data });
|
const statusState = useSharedQueryState(statusQueryKey, currentSnapshot?.status ?? { type: "idle" as const });
|
||||||
}, [props.sessionId, query.data]);
|
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({
|
const model = deriveSessionRenderModel({
|
||||||
intendedSessionId: props.sessionId,
|
intendedSessionId: props.sessionId,
|
||||||
renderedSessionId: query.data ? props.sessionId : rendered?.sessionId ?? null,
|
renderedSessionId: renderedMessages.length > 0 || snapshotQuery.data ? props.sessionId : rendered?.sessionId ?? null,
|
||||||
hasSnapshot: Boolean(snapshot),
|
hasSnapshot: Boolean(snapshot) || renderedMessages.length > 0,
|
||||||
isFetching: query.isFetching,
|
isFetching: snapshotQuery.isFetching || chatStreaming,
|
||||||
isError: query.isError,
|
isError: snapshotQuery.isError || Boolean(error),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!text || actionBusy) return;
|
if (!text || chatStreaming) return;
|
||||||
setActionBusy(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
unwrap(
|
const result = await opencodeClient.session.promptAsync({
|
||||||
await opencodeClient.session.promptAsync({
|
sessionID: props.sessionId,
|
||||||
sessionID: props.sessionId,
|
parts: [{ type: "text", text }],
|
||||||
parts: [{ type: "text", text }],
|
});
|
||||||
}),
|
if (result.error) {
|
||||||
);
|
throw result.error instanceof Error ? result.error : new Error(String(result.error));
|
||||||
|
}
|
||||||
setDraft("");
|
setDraft("");
|
||||||
await query.refetch();
|
|
||||||
} catch (nextError) {
|
} catch (nextError) {
|
||||||
setError(nextError instanceof Error ? nextError.message : "Failed to send prompt.");
|
setError(nextError instanceof Error ? nextError.message : "Failed to send prompt.");
|
||||||
} finally {
|
setSending(false);
|
||||||
setActionBusy(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAbort = async () => {
|
const handleAbort = async () => {
|
||||||
if (actionBusy) return;
|
if (!chatStreaming) return;
|
||||||
setActionBusy(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await abortSessionSafe(opencodeClient, props.sessionId);
|
await abortSessionSafe(opencodeClient, props.sessionId);
|
||||||
await query.refetch();
|
await snapshotQuery.refetch();
|
||||||
} catch (nextError) {
|
} catch (nextError) {
|
||||||
setError(nextError instanceof Error ? nextError.message : "Failed to stop run.");
|
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>) => {
|
const onComposerKeyDown = async (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (!event.metaKey && !event.ctrlKey) return;
|
if (!event.metaKey && !event.ctrlKey) return;
|
||||||
if (event.key !== "Enter") return;
|
if (event.key !== "Enter") return;
|
||||||
@@ -160,30 +172,26 @@ export function SessionSurface(props: SessionSurfaceProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!snapshot && query.isLoading ? (
|
{!snapshot && snapshotQuery.isLoading && renderedMessages.length === 0 ? (
|
||||||
<div className="px-6 py-16">
|
<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="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 className="text-sm text-dls-secondary">Loading React session view...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : query.isError && !snapshot ? (
|
) : (snapshotQuery.isError || error) && !snapshot && renderedMessages.length === 0 ? (
|
||||||
<div className="px-6 py-16">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : snapshot && snapshot.messages.length === 0 ? (
|
) : renderedMessages.length === 0 && snapshot && snapshot.messages.length === 0 ? (
|
||||||
<div className="px-6 py-16">
|
<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="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 className="text-sm text-dls-secondary">No transcript yet.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<SessionTranscript messages={renderedMessages} isStreaming={chatStreaming} developerMode={props.developerMode} />
|
||||||
{snapshot?.messages.map((message) => (
|
|
||||||
<MessageCard key={message.info.id} message={message} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mx-auto w-full max-w-[800px] px-4">
|
<div className="mx-auto w-full max-w-[800px] px-4">
|
||||||
@@ -198,15 +206,13 @@ export function SessionSurface(props: SessionSurfaceProps) {
|
|||||||
disabled={model.transitionState !== "idle"}
|
disabled={model.transitionState !== "idle"}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-dls-border px-4 py-3">
|
<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">
|
<div className="text-xs text-dls-secondary">{statusLabel(snapshot ?? undefined, chatStreaming)}</div>
|
||||||
{statusLabel(snapshot ?? undefined, actionBusy)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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}
|
onClick={handleAbort}
|
||||||
disabled={actionBusy || snapshot?.status.type !== "busy"}
|
disabled={!chatStreaming}
|
||||||
>
|
>
|
||||||
Stop
|
Stop
|
||||||
</button>
|
</button>
|
||||||
@@ -214,15 +220,13 @@ export function SessionSurface(props: SessionSurfaceProps) {
|
|||||||
type="button"
|
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"
|
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}
|
onClick={handleSend}
|
||||||
disabled={actionBusy || !draft.trim() || model.transitionState !== "idle"}
|
disabled={chatStreaming || !draft.trim() || model.transitionState !== "idle"}
|
||||||
>
|
>
|
||||||
Run task
|
Run task
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? <div className="border-t border-red-6/30 px-4 py-3 text-sm text-red-11">{error}</div> : null}
|
||||||
<div className="border-t border-red-6/30 px-4 py-3 text-sm text-red-11">{error}</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{props.developerMode ? <SessionDebugPanel model={model} snapshot={snapshot} /> : null}
|
{props.developerMode ? <SessionDebugPanel model={model} snapshot={snapshot} /> : null}
|
||||||
|
|||||||
360
apps/app/src/react/session/session-sync.ts
Normal file
360
apps/app/src/react/session/session-sync.ts
Normal 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);
|
||||||
|
}
|
||||||
166
apps/app/src/react/session/tool-call.react.tsx
Normal file
166
apps/app/src/react/session/tool-call.react.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
444
apps/app/src/react/session/usechat-adapter.ts
Normal file
444
apps/app/src/react/session/usechat-adapter.ts
Normal 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
2046
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user