feat(app): add incremental React session path (#1362)

* feat(server): add workspace session read APIs

Expose workspace-scoped session list, detail, message, and snapshot reads so the client can fetch session data without depending on activation choreography.

* feat(app): route mounted session reads through OpenWork APIs

Use the new workspace-scoped session read endpoints for mounted OpenWork clients so the current frontend stops depending on direct session proxy reads for list, detail, message, and todo loading.

* feat(app): add React read-only session transcript

Introduce a feature-gated React island for the session transcript so we can replace the session surface incrementally while keeping the Solid shell intact.

* feat(app): add React session composer surface

Extend the feature-gated React session island to own its draft, prompt send, stop flow, and snapshot polling so the session body can evolve independently from the Solid composer.

* feat(app): add React session transition model

Keep the React session surface stable during session switches by tracking rendered vs intended session state and exposing a developer debug panel for render-source and transition inspection.

* docs(prd): add React migration plan to repo

Copy the incremental React adoption PRD into the OpenWork repo so the migration plan lives next to the implementation and PR branch.

* docs(prd): sync full React migration plan

Replace the shortened repo copy with the full incremental React adoption PRD so the implementation branch and product plan stay in sync.

* feat(desktop): add React session launch modes

Add dedicated Tauri dev and debug-build entrypoints for the React session path and honor a build-time React session flag before local storage so the alternate shell is easy to launch and reproduce.

* fix(app): fall back to legacy mounted session reads

Keep the new app working against older OpenWork servers by falling back to the original mounted OpenCode session reads when the workspace-scoped session read APIs are unavailable.
This commit is contained in:
ben
2026-04-05 16:46:06 -07:00
committed by GitHub
parent c90a19b765
commit 9365e7d397
17 changed files with 2026 additions and 86 deletions

View File

@@ -46,6 +46,7 @@
"@solid-primitives/event-bus": "^1.1.2", "@solid-primitives/event-bus": "^1.1.2",
"@solid-primitives/storage": "^4.3.3", "@solid-primitives/storage": "^4.3.3",
"@solidjs/router": "^0.15.4", "@solidjs/router": "^0.15.4",
"@tanstack/react-query": "^5.90.3",
"@tanstack/solid-virtual": "^3.13.19", "@tanstack/solid-virtual": "^3.13.19",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-deep-link": "^2.4.7", "@tauri-apps/plugin-deep-link": "^2.4.7",
@@ -58,9 +59,14 @@
"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-dom": "^19.1.1",
"solid-js": "^1.9.0" "solid-js": "^1.9.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.0.4",
"@solid-devtools/overlay": "^0.33.5", "@solid-devtools/overlay": "^0.33.5",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"solid-devtools": "^0.34.5", "solid-devtools": "^0.34.5",

View File

@@ -1,6 +1,7 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; import { createOpencodeClient, type Message, type Part, type Session, type Todo } from "@opencode-ai/sdk/v2/client";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { createOpenworkServerClient, OpenworkServerError } from "./openwork-server";
import { isTauriRuntime } from "../utils"; import { isTauriRuntime } from "../utils";
type FieldsResult<T> = type FieldsResult<T> =
@@ -34,6 +35,25 @@ type CommandParameters = {
reasoning_effort?: string; reasoning_effort?: string;
}; };
type SessionListParameters = {
directory?: string;
roots?: boolean;
start?: number;
search?: string;
limit?: number;
};
type SessionLookupParameters = {
sessionID: string;
directory?: string;
};
type SessionMessagesParameters = {
sessionID: string;
directory?: string;
limit?: number;
};
export type OpencodeAuth = { export type OpencodeAuth = {
username?: string; username?: string;
password?: string; password?: string;
@@ -112,6 +132,83 @@ async function postSessionRequest<T>(
return { error, request, response }; return { error, request, response };
} }
function resolveOpenworkWorkspaceMount(baseUrl: string): { baseUrl: string; workspaceId: string } | null {
try {
const url = new URL(baseUrl);
const match = url.pathname.replace(/\/+$/, "").match(/^(.*\/w\/([^/]+))\/opencode$/);
if (!match?.[1] || !match[2]) return null;
url.pathname = match[1];
url.search = "";
return {
baseUrl: url.toString().replace(/\/+$/, ""),
workspaceId: decodeURIComponent(match[2]),
};
} catch {
return null;
}
}
function createSyntheticResult<T>(
url: string,
method: string,
input:
| { ok: true; data: T; status?: number }
| { ok: false; error: unknown; status?: number },
): FieldsResult<T> {
const request = new Request(url, { method });
const response = new Response(input.ok ? JSON.stringify(input.data) : null, {
status: input.status ?? (input.ok ? 200 : 500),
headers: { "Content-Type": "application/json" },
});
if (input.ok) {
return { data: input.data, request, response };
}
return { error: input.error, request, response };
}
async function wrapOpenworkRead<T>(
url: string,
read: () => Promise<T>,
options?: { throwOnError?: boolean },
): Promise<FieldsResult<T>> {
try {
return createSyntheticResult(url, "GET", { ok: true, data: await read() });
} catch (error) {
if (options?.throwOnError) throw error;
return createSyntheticResult(url, "GET", {
ok: false,
error,
status: error instanceof OpenworkServerError ? error.status : 500,
});
}
}
function shouldFallbackToLegacySessionRead(error: unknown): boolean {
if (!(error instanceof OpenworkServerError)) return false;
return error.status === 404 || error.status === 405 || error.status === 501;
}
async function wrapOpenworkReadWithFallback<T>(
url: string,
read: () => Promise<T>,
fallback: () => Promise<FieldsResult<T>>,
options?: { throwOnError?: boolean },
): Promise<FieldsResult<T>> {
try {
return createSyntheticResult(url, "GET", { ok: true, data: await read() });
} catch (error) {
if (!shouldFallbackToLegacySessionRead(error)) {
if (options?.throwOnError) throw error;
return createSyntheticResult(url, "GET", {
ok: false,
error,
status: error instanceof OpenworkServerError ? error.status : 500,
});
}
return fallback();
}
}
async function fetchWithTimeout( async function fetchWithTimeout(
fetchImpl: typeof globalThis.fetch, fetchImpl: typeof globalThis.fetch,
input: RequestInfo | URL, input: RequestInfo | URL,
@@ -237,11 +334,88 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod
}); });
const session = client.session as typeof client.session; const session = client.session as typeof client.session;
const openworkMount = auth?.mode === "openwork" ? resolveOpenworkWorkspaceMount(baseUrl) : null;
const openworkSessionClient =
openworkMount && auth?.token
? createOpenworkServerClient({ baseUrl: openworkMount.baseUrl, token: auth.token })
: null;
// TODO(2026-04-12): remove the old-server compatibility path here once all
// OpenWork servers expose the workspace-scoped session read APIs.
const sessionOverrides = session as any as { const sessionOverrides = session as any as {
list: (parameters?: SessionListParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<Session[]>>;
get: (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<Session>>;
messages: (parameters: SessionMessagesParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<Array<{ info: Message; parts: Part[] }>>>;
todo: (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<Todo[]>>;
promptAsync: (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<{}>>; promptAsync: (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<{}>>;
command: (parameters: CommandParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<{}>>; command: (parameters: CommandParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<{}>>;
}; };
const listOriginal = sessionOverrides.list.bind(session);
sessionOverrides.list = (parameters?: SessionListParameters, options?: { throwOnError?: boolean }) => {
if (!openworkMount || !openworkSessionClient) {
return listOriginal(parameters, options);
}
const query = new URLSearchParams();
if (typeof parameters?.roots === "boolean") query.set("roots", String(parameters.roots));
if (typeof parameters?.start === "number") query.set("start", String(parameters.start));
if (parameters?.search?.trim()) query.set("search", parameters.search.trim());
if (typeof parameters?.limit === "number") query.set("limit", String(parameters.limit));
const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions${query.size ? `?${query.toString()}` : ""}`;
return wrapOpenworkReadWithFallback(
url,
async () => (await openworkSessionClient.listSessions(openworkMount.workspaceId, parameters)).items,
() => listOriginal(parameters, options),
options,
);
};
const getOriginal = sessionOverrides.get.bind(session);
sessionOverrides.get = (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => {
if (!openworkMount || !openworkSessionClient) {
return getOriginal(parameters, options);
}
const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}`;
return wrapOpenworkReadWithFallback(
url,
async () => (await openworkSessionClient.getSession(openworkMount.workspaceId, parameters.sessionID)).item,
() => getOriginal(parameters, options),
options,
);
};
const messagesOriginal = sessionOverrides.messages.bind(session);
sessionOverrides.messages = (parameters: SessionMessagesParameters, options?: { throwOnError?: boolean }) => {
if (!openworkMount || !openworkSessionClient) {
return messagesOriginal(parameters, options);
}
const query = new URLSearchParams();
if (typeof parameters.limit === "number") query.set("limit", String(parameters.limit));
const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}/messages${query.size ? `?${query.toString()}` : ""}`;
return wrapOpenworkReadWithFallback(
url,
async () =>
(await openworkSessionClient.getSessionMessages(openworkMount.workspaceId, parameters.sessionID, {
limit: parameters.limit,
})).items,
() => messagesOriginal(parameters, options),
options,
);
};
const todoOriginal = sessionOverrides.todo.bind(session);
sessionOverrides.todo = (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => {
if (!openworkMount || !openworkSessionClient) {
return todoOriginal(parameters, options);
}
const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}/snapshot`;
return wrapOpenworkReadWithFallback(
url,
async () => (await openworkSessionClient.getSessionSnapshot(openworkMount.workspaceId, parameters.sessionID)).item.todos,
() => todoOriginal(parameters, options),
options,
);
};
const promptAsyncOriginal = sessionOverrides.promptAsync.bind(session); const promptAsyncOriginal = sessionOverrides.promptAsync.bind(session);
sessionOverrides.promptAsync = (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => { sessionOverrides.promptAsync = (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => {
if (!("reasoning_effort" in parameters)) { if (!("reasoning_effort" in parameters)) {

View File

@@ -1,4 +1,5 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import type { Message, Part, Session, Todo } from "@opencode-ai/sdk/v2/client";
import { isTauriRuntime } from "../utils"; import { isTauriRuntime } from "../utils";
import type { ExecResult, OpencodeConfigFile, ScheduledJob, WorkspaceInfo, WorkspaceList } from "./tauri"; import type { ExecResult, OpencodeConfigFile, ScheduledJob, WorkspaceInfo, WorkspaceList } from "./tauri";
@@ -105,6 +106,21 @@ export type OpenworkWorkspaceList = {
activeId?: string | null; activeId?: string | null;
}; };
export type OpenworkSessionMessage = {
info: Message;
parts: Part[];
};
export type OpenworkSessionSnapshot = {
session: Session;
messages: OpenworkSessionMessage[];
todos: Todo[];
status:
| { type: "idle" }
| { type: "busy" }
| { type: "retry"; attempt: number; message: string; next: number };
};
export type OpenworkPluginItem = { export type OpenworkPluginItem = {
spec: string; spec: string;
source: "config" | "dir.project" | "dir.global"; source: "config" | "dir.project" | "dir.global";
@@ -912,6 +928,7 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
activateWorkspace: 10_000, activateWorkspace: 10_000,
deleteWorkspace: 10_000, deleteWorkspace: 10_000,
deleteSession: 12_000, deleteSession: 12_000,
sessionRead: 12_000,
status: 6_000, status: 6_000,
config: 10_000, config: 10_000,
opencodeRouter: 10_000, opencodeRouter: 10_000,
@@ -985,6 +1002,48 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
`/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`, `/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`,
{ token, hostToken, method: "DELETE", timeoutMs: timeouts.deleteSession }, { token, hostToken, method: "DELETE", timeoutMs: timeouts.deleteSession },
), ),
listSessions: (
workspaceId: string,
options?: { roots?: boolean; start?: number; search?: string; limit?: number },
) => {
const query = new URLSearchParams();
if (typeof options?.roots === "boolean") query.set("roots", String(options.roots));
if (typeof options?.start === "number") query.set("start", String(options.start));
if (options?.search?.trim()) query.set("search", options.search.trim());
if (typeof options?.limit === "number") query.set("limit", String(options.limit));
const suffix = query.size ? `?${query.toString()}` : "";
return requestJson<{ items: Session[] }>(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/sessions${suffix}`,
{ token, hostToken, timeoutMs: timeouts.sessionRead },
);
},
getSession: (workspaceId: string, sessionId: string) =>
requestJson<{ item: Session }>(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`,
{ token, hostToken, timeoutMs: timeouts.sessionRead },
),
getSessionMessages: (workspaceId: string, sessionId: string, options?: { limit?: number }) => {
const query = new URLSearchParams();
if (typeof options?.limit === "number") query.set("limit", String(options.limit));
const suffix = query.size ? `?${query.toString()}` : "";
return requestJson<{ items: OpenworkSessionMessage[] }>(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/messages${suffix}`,
{ token, hostToken, timeoutMs: timeouts.sessionRead },
);
},
getSessionSnapshot: (workspaceId: string, sessionId: string, options?: { limit?: number }) => {
const query = new URLSearchParams();
if (typeof options?.limit === "number") query.set("limit", String(options.limit));
const suffix = query.size ? `?${query.toString()}` : "";
return requestJson<{ item: OpenworkSessionSnapshot }>(
baseUrl,
`/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/snapshot${suffix}`,
{ token, hostToken, timeoutMs: timeouts.sessionRead },
);
},
exportWorkspace: ( exportWorkspace: (
workspaceId: string, workspaceId: string,
options?: { sensitiveMode?: OpenworkWorkspaceExportSensitiveMode }, options?: { sensitiveMode?: OpenworkWorkspaceExportSensitiveMode },

View File

@@ -77,6 +77,7 @@ import type {
OpenworkServerSettings, OpenworkServerSettings,
OpenworkServerStatus, OpenworkServerStatus,
} from "../lib/openwork-server"; } from "../lib/openwork-server";
import { buildOpenworkWorkspaceBaseUrl } from "../lib/openwork-server";
import { join } from "@tauri-apps/api/path"; import { join } from "@tauri-apps/api/path";
import { import {
isUserVisiblePart, isUserVisiblePart,
@@ -111,6 +112,9 @@ import {
saveSessionDraft, saveSessionDraft,
sessionDraftScopeKey, sessionDraftScopeKey,
} from "../session/draft-store"; } from "../session/draft-store";
import { ReactIsland } from "../../react/island";
import { reactSessionEnabled } from "../../react/feature-flag";
import { SessionSurface } from "../../react/session/session-surface.react";
export type SessionViewProps = { export type SessionViewProps = {
selectedSessionId: string | null; selectedSessionId: string | null;
@@ -2123,6 +2127,27 @@ export default function SessionView(props: SessionViewProps) {
workspaceId: string; workspaceId: string;
sessionId: string; sessionId: string;
} | null>(null); } | null>(null);
const reactSessionOpencodeBaseUrl = createMemo(() => {
const workspaceId = props.runtimeWorkspaceId?.trim() ?? "";
const baseUrl = props.openworkServerClient?.baseUrl?.trim() ?? "";
if (!workspaceId || !baseUrl) return "";
const mounted = buildOpenworkWorkspaceBaseUrl(baseUrl, workspaceId) ?? baseUrl;
return `${mounted.replace(/\/+$/, "")}/opencode`;
});
const reactSessionToken = createMemo(
() => props.openworkServerClient?.token?.trim() || props.openworkServerSettings.token?.trim() || "",
);
const showReactSessionSurface = createMemo(
() =>
reactSessionEnabled() &&
Boolean(
props.selectedSessionId?.trim() &&
props.runtimeWorkspaceId?.trim() &&
props.openworkServerClient &&
reactSessionOpencodeBaseUrl() &&
reactSessionToken(),
),
);
const hasWorkspaceConfigured = createMemo(() => props.workspaces.length > 0); const hasWorkspaceConfigured = createMemo(() => props.workspaces.length > 0);
const showWorkspaceSetupEmptyState = createMemo( const showWorkspaceSetupEmptyState = createMemo(
() => () =>
@@ -3228,7 +3253,8 @@ export default function SessionView(props: SessionViewProps) {
props.messages.length === 0 && props.messages.length === 0 &&
!showWorkspaceSetupEmptyState() && !showWorkspaceSetupEmptyState() &&
!showSessionLoadingState() && !showSessionLoadingState() &&
!deferSessionRender() !deferSessionRender() &&
!showReactSessionSurface()
} }
> >
<div class="text-center px-6 space-y-6"> <div class="text-center px-6 space-y-6">
@@ -3272,88 +3298,106 @@ export default function SessionView(props: SessionViewProps) {
when={!showDelayedSessionLoadingState() && !deferSessionRender()} when={!showDelayedSessionLoadingState() && !deferSessionRender()}
> >
<Show <Show
when={ when={!showReactSessionSurface()}
hiddenMessageCount() > 0 || hasServerEarlierMessages() fallback={
<ReactIsland
class="pb-4"
component={SessionSurface}
props={{
client: props.openworkServerClient!,
workspaceId: props.runtimeWorkspaceId!,
sessionId: props.selectedSessionId!,
opencodeBaseUrl: reactSessionOpencodeBaseUrl(),
openworkToken: reactSessionToken(),
developerMode: props.developerMode,
}}
/>
} }
> >
<div class="mb-4 flex justify-center"> <Show
<button when={
type="button" hiddenMessageCount() > 0 || hasServerEarlierMessages()
class="rounded-full border border-dls-border bg-dls-hover/70 px-3 py-1 text-xs text-dls-secondary transition-colors hover:bg-dls-active hover:text-dls-text" }
onClick={() => { >
void revealEarlierMessages(); <div class="mb-4 flex justify-center">
}} <button
disabled={props.loadingEarlierMessages} type="button"
> class="rounded-full border border-dls-border bg-dls-hover/70 px-3 py-1 text-xs text-dls-secondary transition-colors hover:bg-dls-active hover:text-dls-text"
{props.loadingEarlierMessages onClick={() => {
? t("session.loading_earlier") void revealEarlierMessages();
: hiddenMessageCount() > 0 }}
? t("session.show_earlier", undefined, { count: nextRevealCount().toLocaleString(), plural: nextRevealCount() === 1 ? "" : "s" }) disabled={props.loadingEarlierMessages}
: t("session.load_earlier")} >
</button> {props.loadingEarlierMessages
</div> ? t("session.loading_earlier")
</Show> : hiddenMessageCount() > 0
? t("session.show_earlier", undefined, { count: nextRevealCount().toLocaleString(), plural: nextRevealCount() === 1 ? "" : "s" })
: t("session.load_earlier")}
</button>
</div>
</Show>
<Show when={batchedRenderedMessages().length > 0}> <Show when={batchedRenderedMessages().length > 0}>
<MessageList <MessageList
messages={batchedRenderedMessages()} messages={batchedRenderedMessages()}
isStreaming={showRunIndicator()} isStreaming={showRunIndicator()}
developerMode={props.developerMode} developerMode={props.developerMode}
showThinking={showThinking()} showThinking={showThinking()}
getSessionById={props.getSessionById} getSessionById={props.getSessionById}
getMessagesBySessionId={props.getMessagesBySessionId} getMessagesBySessionId={props.getMessagesBySessionId}
ensureSessionLoaded={props.ensureSessionLoaded} ensureSessionLoaded={props.ensureSessionLoaded}
sessionLoadingById={props.sessionLoadingById} sessionLoadingById={props.sessionLoadingById}
workspaceRoot={props.selectedWorkspaceRoot} workspaceRoot={props.selectedWorkspaceRoot}
expandedStepIds={props.expandedStepIds} expandedStepIds={props.expandedStepIds}
setExpandedStepIds={props.setExpandedStepIds} setExpandedStepIds={props.setExpandedStepIds}
openSessionById={(sessionId) => { openSessionById={(sessionId) => {
flushComposerDraft(); flushComposerDraft();
props.setView("session", sessionId); props.setView("session", sessionId);
}} }}
searchMatchMessageIds={searchMatchMessageIds()} searchMatchMessageIds={searchMatchMessageIds()}
activeSearchMessageId={activeSearchHit()?.messageId ?? null} activeSearchMessageId={activeSearchHit()?.messageId ?? null}
searchHighlightQuery={searchQueryDebounced().trim()} searchHighlightQuery={searchQueryDebounced().trim()}
scrollElement={() => chatContainerEl} scrollElement={() => chatContainerEl}
setScrollToMessageById={(handler) => { setScrollToMessageById={(handler) => {
scrollMessageIntoViewById = handler; scrollMessageIntoViewById = handler;
}} }}
footer={ footer={
showRunIndicator() && showFooterRunStatus() ? ( showRunIndicator() && showFooterRunStatus() ? (
<div class="flex justify-start"> <div class="flex justify-start">
<div class="w-full max-w-[760px]"> <div class="w-full max-w-[760px]">
<div <div
class={`mt-3 flex items-center gap-2 py-1 text-xs ${runPhase() === "error" ? "text-red-11" : "text-gray-9"}`} class={`mt-3 flex items-center gap-2 py-1 text-xs ${runPhase() === "error" ? "text-red-11" : "text-gray-9"}`}
role="status" role="status"
aria-live="polite" aria-live="polite"
>
<span
class={`truncate ${
runPhase() === "thinking" ||
runPhase() === "responding"
? "animate-pulse"
: ""
}`}
> >
{thinkingStatus() || runLabel()} <span
</span> class={`truncate ${
<Show when={props.developerMode}> runPhase() === "thinking" ||
<span class="text-[10px] text-gray-8 ml-auto shrink-0"> runPhase() === "responding"
{runElapsedLabel()} ? "animate-pulse"
: ""
}`}
>
{thinkingStatus() || runLabel()}
</span> </span>
</Show> <Show when={props.developerMode}>
<span class="text-[10px] text-gray-8 ml-auto shrink-0">
{runElapsedLabel()}
</span>
</Show>
</div>
</div> </div>
</div> </div>
</div> ) : undefined
) : undefined }
} />
/> </Show>
</Show> </Show>
</Show> </Show>
</div> </div>
</div> </div>
<Show when={!showDelayedSessionLoadingState() && !deferSessionRender() && props.messages.length > 0 && !jumpControlsSuppressed() && (!sessionScroll.isAtBottom() || Boolean(sessionScroll.topClippedMessageId()))}> <Show when={!showReactSessionSurface() && !showDelayedSessionLoadingState() && !deferSessionRender() && props.messages.length > 0 && !jumpControlsSuppressed() && (!sessionScroll.isAtBottom() || Boolean(sessionScroll.topClippedMessageId()))}>
<div class="absolute bottom-4 left-0 right-0 z-20 flex justify-center pointer-events-none"> <div class="absolute bottom-4 left-0 right-0 z-20 flex justify-center pointer-events-none">
<div class="pointer-events-auto flex items-center gap-2 rounded-full border border-dls-border bg-dls-surface/95 p-1 shadow-[var(--dls-card-shadow)] backdrop-blur-md"> <div class="pointer-events-auto flex items-center gap-2 rounded-full border border-dls-border bg-dls-surface/95 p-1 shadow-[var(--dls-card-shadow)] backdrop-blur-md">
<Show when={Boolean(sessionScroll.topClippedMessageId())}> <Show when={Boolean(sessionScroll.topClippedMessageId())}>
@@ -3454,7 +3498,7 @@ export default function SessionView(props: SessionViewProps) {
</div> </div>
</Show> </Show>
<Show when={!showWorkspaceSetupEmptyState()}> <Show when={!showWorkspaceSetupEmptyState() && !showReactSessionSurface()}>
<Composer <Composer
prompt={props.prompt} prompt={props.prompt}
draftMode={composerDraftMode()} draftMode={composerDraftMode()}

View File

@@ -0,0 +1,21 @@
const REACT_SESSION_FLAG = "openwork:react-session";
function isTruthyFlag(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
export function reactSessionEnabled(): boolean {
if (typeof window === "undefined") return false;
try {
if (isTruthyFlag(import.meta.env.VITE_OPENWORK_REACT_SESSION)) return true;
const query = new URLSearchParams(window.location.search).get("react");
if (query === "1" || query === "true") return true;
if (query === "0" || query === "false") return false;
const stored = window.localStorage.getItem(REACT_SESSION_FLAG);
return stored === "1" || stored === "true";
} catch {
return false;
}
}

View File

@@ -0,0 +1,45 @@
import { createEffect, onCleanup, onMount } from "solid-js";
import { createElement, type ComponentType } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot, type Root } from "react-dom/client";
type ReactIslandProps<T extends object> = {
component: ComponentType<T>;
props: T;
class?: string;
};
export function ReactIsland<T extends object>(props: ReactIslandProps<T>) {
let container: HTMLDivElement | undefined;
let root: Root | null = null;
const queryClient = new QueryClient();
const render = () => {
if (!root) return;
root.render(
createElement(
QueryClientProvider,
{ client: queryClient },
createElement(props.component, props.props),
),
);
};
onMount(() => {
if (!container) return;
root = createRoot(container);
render();
});
createEffect(() => {
props.props;
render();
});
onCleanup(() => {
root?.unmount();
root = null;
});
return <div ref={container} class={props.class} />;
}

View File

@@ -0,0 +1,23 @@
/** @jsxImportSource react */
import type { OpenworkSessionSnapshot } from "../../app/lib/openwork-server";
import type { SessionRenderModel } from "./transition-controller";
export function SessionDebugPanel(props: {
model: SessionRenderModel;
snapshot: OpenworkSessionSnapshot | null;
}) {
return (
<div className="fixed bottom-20 right-4 z-30 w-[280px] rounded-2xl border border-dls-border bg-dls-surface/95 p-3 text-xs text-dls-secondary shadow-[var(--dls-card-shadow)] backdrop-blur-md">
<div className="mb-2 text-[11px] uppercase tracking-[0.18em] text-dls-text">React Session Debug</div>
<div className="space-y-1.5">
<div>intendedSessionId: <span className="text-dls-text">{props.model.intendedSessionId || "-"}</span></div>
<div>renderedSessionId: <span className="text-dls-text">{props.model.renderedSessionId || "-"}</span></div>
<div>transitionState: <span className="text-dls-text">{props.model.transitionState}</span></div>
<div>renderSource: <span className="text-dls-text">{props.model.renderSource}</span></div>
<div>status: <span className="text-dls-text">{props.snapshot?.status.type ?? "-"}</span></div>
<div>messages: <span className="text-dls-text">{props.snapshot?.messages.length ?? 0}</span></div>
<div>todos: <span className="text-dls-text">{props.snapshot?.todos.length ?? 0}</span></div>
</div>
</div>
);
}

View File

@@ -0,0 +1,231 @@
/** @jsxImportSource react */
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { createClient, unwrap } from "../../app/lib/opencode";
import { abortSessionSafe } from "../../app/lib/opencode-session";
import type { OpenworkServerClient, OpenworkSessionMessage, OpenworkSessionSnapshot } from "../../app/lib/openwork-server";
import { SessionDebugPanel } from "./debug-panel.react";
import { deriveSessionRenderModel } from "./transition-controller";
type SessionSurfaceProps = {
client: OpenworkServerClient;
workspaceId: string;
sessionId: string;
opencodeBaseUrl: string;
openworkToken: string;
developerMode: boolean;
};
function partText(part: Record<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...";
if (snapshot?.status.type === "retry") return `Retrying: ${snapshot.status.message}`;
return "Ready";
}
function MessageCard(props: { message: OpenworkSessionMessage }) {
const role = props.message.info.role;
const bubbleClass =
role === "user"
? "border-blue-6/35 bg-blue-3/25 text-gray-12"
: "border-dls-border bg-dls-surface text-gray-12";
return (
<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) {
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 opencodeClient = useMemo(
() => createClient(props.opencodeBaseUrl, undefined, { token: props.openworkToken, mode: "openwork" }),
[props.opencodeBaseUrl, props.openworkToken],
);
const queryKey = useMemo(
() => ["react-session-snapshot", props.workspaceId, props.sessionId],
[props.workspaceId, props.sessionId],
);
const query = useQuery<OpenworkSessionSnapshot>({
queryKey,
queryFn: async () => (await props.client.getSessionSnapshot(props.workspaceId, props.sessionId, { limit: 140 })).item,
staleTime: 500,
refetchInterval: (current) =>
actionBusy || current.state.data?.status.type === "busy" || current.state.data?.status.type === "retry"
? 800
: false,
});
useEffect(() => {
if (!query.data) return;
setRendered({ sessionId: props.sessionId, snapshot: query.data });
}, [props.sessionId, query.data]);
const snapshot = query.data ?? rendered?.snapshot ?? null;
const model = deriveSessionRenderModel({
intendedSessionId: props.sessionId,
renderedSessionId: query.data ? props.sessionId : rendered?.sessionId ?? null,
hasSnapshot: Boolean(snapshot),
isFetching: query.isFetching,
isError: query.isError,
});
const handleSend = async () => {
const text = draft.trim();
if (!text || actionBusy) return;
setActionBusy(true);
setError(null);
try {
unwrap(
await opencodeClient.session.promptAsync({
sessionID: props.sessionId,
parts: [{ type: "text", text }],
}),
);
setDraft("");
await query.refetch();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : "Failed to send prompt.");
} finally {
setActionBusy(false);
}
};
const handleAbort = async () => {
if (actionBusy) return;
setActionBusy(true);
setError(null);
try {
await abortSessionSafe(opencodeClient, props.sessionId);
await query.refetch();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : "Failed to stop run.");
} finally {
setActionBusy(false);
}
};
const onComposerKeyDown = async (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!event.metaKey && !event.ctrlKey) return;
if (event.key !== "Enter") return;
event.preventDefault();
await handleSend();
};
return (
<div className="space-y-5 pb-4">
{model.transitionState === "switching" ? (
<div className="flex justify-center px-6">
<div className="rounded-full border border-dls-border bg-dls-hover/80 px-3 py-1 text-xs text-dls-secondary">
{model.renderSource === "cache" ? "Switching session from cache..." : "Switching session..."}
</div>
</div>
) : null}
{!snapshot && query.isLoading ? (
<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 ? (
<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."}
</div>
</div>
) : 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>
)}
<div className="mx-auto w-full max-w-[800px] px-4">
<div className="rounded-[28px] border border-dls-border bg-dls-surface shadow-[var(--dls-card-shadow)]">
<textarea
value={draft}
onChange={(event) => setDraft(event.currentTarget.value)}
onKeyDown={onComposerKeyDown}
rows={5}
placeholder="Describe your task..."
className="min-h-[180px] w-full resize-none bg-transparent px-6 py-5 text-base text-dls-text outline-none placeholder:text-dls-secondary"
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="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"}
>
Stop
</button>
<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"
onClick={handleSend}
disabled={actionBusy || !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}
</div>
</div>
{props.developerMode ? <SessionDebugPanel model={model} snapshot={snapshot} /> : null}
</div>
);
}

View File

@@ -0,0 +1,61 @@
export type TransitionState = "idle" | "switching" | "recovering" | "failed";
export type RenderSource = "cache" | "live" | "empty" | "error" | "recovering";
export type SessionRenderModel = {
intendedSessionId: string;
renderedSessionId: string | null;
transitionState: TransitionState;
renderSource: RenderSource;
};
export function deriveSessionRenderModel(input: {
intendedSessionId: string;
renderedSessionId: string | null;
hasSnapshot: boolean;
isFetching: boolean;
isError: boolean;
}): SessionRenderModel {
if (input.isError && input.renderedSessionId && input.renderedSessionId !== input.intendedSessionId) {
return {
intendedSessionId: input.intendedSessionId,
renderedSessionId: input.renderedSessionId,
transitionState: "recovering",
renderSource: "recovering",
};
}
if (input.isError) {
return {
intendedSessionId: input.intendedSessionId,
renderedSessionId: input.renderedSessionId,
transitionState: "failed",
renderSource: "error",
};
}
if (input.renderedSessionId && input.renderedSessionId !== input.intendedSessionId) {
return {
intendedSessionId: input.intendedSessionId,
renderedSessionId: input.renderedSessionId,
transitionState: "switching",
renderSource: "cache",
};
}
if (!input.hasSnapshot) {
return {
intendedSessionId: input.intendedSessionId,
renderedSessionId: input.renderedSessionId,
transitionState: input.isFetching ? "switching" : "idle",
renderSource: "empty",
};
}
return {
intendedSessionId: input.intendedSessionId,
renderedSessionId: input.renderedSessionId,
transitionState: input.isFetching ? "switching" : "idle",
renderSource: "live",
};
}

View File

@@ -2,6 +2,7 @@ import os from "node:os";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import devtools from "solid-devtools/vite"; import devtools from "solid-devtools/vite";
import solid from "vite-plugin-solid"; import solid from "vite-plugin-solid";
@@ -26,6 +27,7 @@ if (shortHostname && shortHostname !== hostname) {
addHost(shortHostname); addHost(shortHostname);
} }
const appRoot = resolve(fileURLToPath(new URL(".", import.meta.url))); const appRoot = resolve(fileURLToPath(new URL(".", import.meta.url)));
const reactFiles = /\.react\.[tj]sx?$/;
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@@ -39,6 +41,7 @@ export default defineConfig({
}, },
}, },
tailwindcss(), tailwindcss(),
react({ include: reactFiles }),
devtools({ devtools({
autoname: true, autoname: true,
// jsxLocation is required for in-page locator: map DOM → Solid components (hold Option/Alt while hovering). // jsxLocation is required for in-page locator: map DOM → Solid components (hold Option/Alt while hovering).
@@ -48,7 +51,7 @@ export default defineConfig({
componentLocation: true, componentLocation: true,
}, },
}), }),
solid(), solid({ exclude: [reactFiles] }),
], ],
server: { server: {
port: devPort, port: devPort,

View File

@@ -6,9 +6,11 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "OPENWORK_DEV_MODE=1 OPENWORK_DATA_DIR=\"$HOME/.openwork/openwork-orchestrator-dev\" tauri dev --config src-tauri/tauri.dev.conf.json --config \"{\\\"build\\\":{\\\"devUrl\\\":\\\"http://localhost:${PORT:-5173}\\\"}}\"", "dev": "OPENWORK_DEV_MODE=1 OPENWORK_DATA_DIR=\"$HOME/.openwork/openwork-orchestrator-dev\" tauri dev --config src-tauri/tauri.dev.conf.json --config \"{\\\"build\\\":{\\\"devUrl\\\":\\\"http://localhost:${PORT:-5173}\\\"}}\"",
"dev:react-session": "OPENWORK_DEV_MODE=1 VITE_OPENWORK_REACT_SESSION=1 OPENWORK_DATA_DIR=\"$HOME/.openwork/openwork-orchestrator-dev-react\" tauri dev --config src-tauri/tauri.dev.conf.json --config \"{\\\"build\\\":{\\\"devUrl\\\":\\\"http://localhost:${PORT:-5173}\\\"}}\"",
"dev:windows": "node ./scripts/dev-windows.mjs", "dev:windows": "node ./scripts/dev-windows.mjs",
"dev:windows:x64": "node ./scripts/dev-windows.mjs x64", "dev:windows:x64": "node ./scripts/dev-windows.mjs x64",
"build": "tauri build", "build": "tauri build",
"build:debug:react-session": "VITE_OPENWORK_REACT_SESSION=1 tauri build --debug",
"prepare:sidecar": "node ./scripts/prepare-sidecar.mjs" "prepare:sidecar": "node ./scripts/prepare-sidecar.mjs"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -44,7 +44,8 @@
"dependencies": { "dependencies": {
"jsonc-parser": "^3.2.1", "jsonc-parser": "^3.2.1",
"minimatch": "^10.0.1", "minimatch": "^10.0.1",
"yaml": "^2.6.1" "yaml": "^2.6.1",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2", "@types/node": "^22.10.2",

View File

@@ -36,6 +36,7 @@ import { inheritWorkspaceOpencodeConnection, resolveWorkspaceOpencodeConnection
import { fetchSharedBundle, publishSharedBundle } from "./share-bundles.js"; import { fetchSharedBundle, publishSharedBundle } from "./share-bundles.js";
import { seedOpencodeSessionMessages } from "./opencode-db.js"; import { seedOpencodeSessionMessages } from "./opencode-db.js";
import { listPortableFiles, planPortableFiles, writePortableFiles } from "./portable-files.js"; import { listPortableFiles, planPortableFiles, writePortableFiles } from "./portable-files.js";
import { buildSession, buildSessionList, buildSessionMessages, buildSessionSnapshot, buildSessionStatuses, buildSessionTodos } from "./session-read-model.js";
import { import {
collectWorkspaceExportWarnings, collectWorkspaceExportWarnings,
stripSensitiveWorkspaceExportData, stripSensitiveWorkspaceExportData,
@@ -469,7 +470,14 @@ function buildOpencodeProxyUrl(baseUrl: string, path: string, search: string) {
return target.toString(); return target.toString();
} }
async function fetchOpencodeJson(config: ServerConfig, workspace: WorkspaceInfo, path: string, init: { method: string; body?: unknown }) { type OpencodeQueryValue = string | number | boolean | null | undefined;
async function fetchOpencodeJson(
config: ServerConfig,
workspace: WorkspaceInfo,
path: string,
init: { method: string; body?: unknown; query?: URLSearchParams | Record<string, OpencodeQueryValue> },
) {
const connection = resolveWorkspaceOpencodeConnection(config, workspace); const connection = resolveWorkspaceOpencodeConnection(config, workspace);
const baseUrl = connection.baseUrl?.trim() ?? ""; const baseUrl = connection.baseUrl?.trim() ?? "";
if (!baseUrl) { if (!baseUrl) {
@@ -478,7 +486,18 @@ async function fetchOpencodeJson(config: ServerConfig, workspace: WorkspaceInfo,
const url = new URL(baseUrl); const url = new URL(baseUrl);
url.pathname = path.startsWith("/") ? path : `/${path}`; url.pathname = path.startsWith("/") ? path : `/${path}`;
url.search = ""; if (init.query instanceof URLSearchParams) {
url.search = init.query.toString();
} else if (init.query) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(init.query)) {
if (value === undefined || value === null) continue;
params.set(key, String(value));
}
url.search = params.toString();
} else {
url.search = "";
}
const headers = new Headers(); const headers = new Headers();
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
@@ -500,12 +519,7 @@ async function fetchOpencodeJson(config: ServerConfig, workspace: WorkspaceInfo,
}); });
const text = await response.text(); const text = await response.text();
let json: any = null; const json = parseJsonResponse(text);
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
if (!response.ok) { if (!response.ok) {
throw new ApiError(502, "opencode_request_failed", "OpenCode request failed", { throw new ApiError(502, "opencode_request_failed", "OpenCode request failed", {
status: response.status, status: response.status,
@@ -1526,6 +1540,51 @@ function createRoutes(
return jsonResponse({ items }); return jsonResponse({ items });
}); });
addRoute(routes, "GET", "/workspace/:id/sessions", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const items = await listWorkspaceSessions(config, workspace, {
roots: parseOptionalBoolean(ctx.url.searchParams.get("roots"), "roots"),
start: parseOptionalNonNegativeInteger(ctx.url.searchParams.get("start"), "start"),
search: ctx.url.searchParams.get("search")?.trim() || undefined,
limit: parseOptionalPositiveInteger(ctx.url.searchParams.get("limit"), "limit"),
});
return jsonResponse({ items });
});
addRoute(routes, "GET", "/workspace/:id/sessions/:sessionId", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const sessionId = (ctx.params.sessionId ?? "").trim();
if (!sessionId) {
throw new ApiError(400, "invalid_payload", "sessionId is required");
}
const item = await readWorkspaceSession(config, workspace, sessionId);
return jsonResponse({ item });
});
addRoute(routes, "GET", "/workspace/:id/sessions/:sessionId/messages", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const sessionId = (ctx.params.sessionId ?? "").trim();
if (!sessionId) {
throw new ApiError(400, "invalid_payload", "sessionId is required");
}
const items = await readWorkspaceSessionMessages(config, workspace, sessionId, {
limit: parseOptionalPositiveInteger(ctx.url.searchParams.get("limit"), "limit"),
});
return jsonResponse({ items });
});
addRoute(routes, "GET", "/workspace/:id/sessions/:sessionId/snapshot", "client", async (ctx) => {
const workspace = await resolveWorkspace(config, ctx.params.id);
const sessionId = (ctx.params.sessionId ?? "").trim();
if (!sessionId) {
throw new ApiError(400, "invalid_payload", "sessionId is required");
}
const item = await readWorkspaceSessionSnapshot(config, workspace, sessionId, {
limit: parseOptionalPositiveInteger(ctx.url.searchParams.get("limit"), "limit"),
});
return jsonResponse({ item });
});
addRoute(routes, "DELETE", "/workspace/:id/sessions/:sessionId", "client", async (ctx) => { addRoute(routes, "DELETE", "/workspace/:id/sessions/:sessionId", "client", async (ctx) => {
ensureWritable(config); ensureWritable(config);
requireClientScope(ctx, "collaborator"); requireClientScope(ctx, "collaborator");
@@ -3581,7 +3640,7 @@ function createRoutes(
const bundle = await fetchSharedBundle(body.bundleUrl, { const bundle = await fetchSharedBundle(body.bundleUrl, {
timeoutMs: typeof body.timeoutMs === "number" ? body.timeoutMs : undefined, timeoutMs: typeof body.timeoutMs === "number" ? body.timeoutMs : undefined,
}); });
return jsonResponse(bundle); return jsonResponse(bundle);
}); });
addRoute(routes, "GET", "/approvals", "host", async (ctx) => { addRoute(routes, "GET", "/approvals", "host", async (ctx) => {
@@ -3601,6 +3660,125 @@ function createRoutes(
return routes; return routes;
} }
function remapSessionReadError(error: unknown): never {
if (error instanceof ApiError && error.code === "opencode_request_failed") {
const details = error.details;
const upstreamStatus =
details && typeof details === "object" && "status" in details ? Number((details as { status?: unknown }).status) : NaN;
if (upstreamStatus === 400) {
throw new ApiError(400, "invalid_query", "OpenCode rejected the session read request", details);
}
if (upstreamStatus === 404) {
throw new ApiError(404, "session_not_found", "Session not found", details);
}
}
throw error;
}
async function listWorkspaceSessions(
config: ServerConfig,
workspace: WorkspaceInfo,
input: { roots?: boolean; start?: number; search?: string; limit?: number },
) {
try {
return buildSessionList(
await fetchOpencodeJson(config, workspace, "/session", {
method: "GET",
query: {
roots: input.roots,
start: input.start,
search: input.search,
limit: input.limit,
},
}),
);
} catch (error) {
remapSessionReadError(error);
}
}
async function readWorkspaceSession(config: ServerConfig, workspace: WorkspaceInfo, sessionId: string) {
try {
return buildSession(
await fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}`, {
method: "GET",
}),
);
} catch (error) {
remapSessionReadError(error);
}
}
async function readWorkspaceSessionMessages(
config: ServerConfig,
workspace: WorkspaceInfo,
sessionId: string,
input: { limit?: number },
) {
try {
return buildSessionMessages(
await fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}/message`, {
method: "GET",
query: { limit: input.limit },
}),
);
} catch (error) {
remapSessionReadError(error);
}
}
async function readWorkspaceSessionTodos(config: ServerConfig, workspace: WorkspaceInfo, sessionId: string) {
try {
return buildSessionTodos(
await fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}/todo`, {
method: "GET",
}),
);
} catch (error) {
remapSessionReadError(error);
}
}
async function readWorkspaceSessionStatuses(config: ServerConfig, workspace: WorkspaceInfo) {
try {
return buildSessionStatuses(
await fetchOpencodeJson(config, workspace, "/session/status", {
method: "GET",
}),
);
} catch (error) {
remapSessionReadError(error);
}
}
async function readWorkspaceSessionSnapshot(
config: ServerConfig,
workspace: WorkspaceInfo,
sessionId: string,
input: { limit?: number },
) {
try {
const [session, messages, todos, statuses] = await Promise.all([
fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}`, {
method: "GET",
}),
fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}/message`, {
method: "GET",
query: { limit: input.limit },
}),
fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}/todo`, {
method: "GET",
}),
fetchOpencodeJson(config, workspace, "/session/status", {
method: "GET",
}),
]);
return buildSessionSnapshot({ session, messages, todos, statuses });
} catch (error) {
remapSessionReadError(error);
}
}
async function resolveWorkspace(config: ServerConfig, id: string): Promise<WorkspaceInfo> { async function resolveWorkspace(config: ServerConfig, id: string): Promise<WorkspaceInfo> {
const workspace = config.workspaces.find((entry) => entry.id === id); const workspace = config.workspaces.find((entry) => entry.id === id);
if (!workspace) { if (!workspace) {
@@ -3664,6 +3842,32 @@ function parseInteger(value: string | undefined): number | null {
return Number.isFinite(parsed) ? parsed : null; return Number.isFinite(parsed) ? parsed : null;
} }
function parseOptionalPositiveInteger(value: string | null, name: string): number | undefined {
if (value === null) return undefined;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new ApiError(400, "invalid_query", `${name} must be a positive integer`);
}
return parsed;
}
function parseOptionalNonNegativeInteger(value: string | null, name: string): number | undefined {
if (value === null) return undefined;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new ApiError(400, "invalid_query", `${name} must be a non-negative integer`);
}
return parsed;
}
function parseOptionalBoolean(value: string | null, name: string): boolean | undefined {
if (value === null) return undefined;
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
throw new ApiError(400, "invalid_query", `${name} must be a boolean`);
}
function expandHome(value: string): string { function expandHome(value: string): string {
if (value.startsWith("~/")) { if (value.startsWith("~/")) {
return join(homedir(), value.slice(2)); return join(homedir(), value.slice(2));
@@ -4839,7 +5043,8 @@ async function materializeBlueprintSessions(config: ServerConfig, workspace: Wor
method: "POST", method: "POST",
body: template.title ? { title: template.title } : undefined, body: template.title ? { title: template.title } : undefined,
}); });
const sessionId = typeof result?.id === "string" ? result.id.trim() : ""; const sessionId =
result && typeof result === "object" && "id" in result && typeof result.id === "string" ? result.id.trim() : "";
if (!sessionId) { if (!sessionId) {
throw new ApiError(502, "opencode_failed", "OpenCode session did not return an id"); throw new ApiError(502, "opencode_failed", "OpenCode session did not return an id");
} }

View File

@@ -0,0 +1,256 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { startServer } from "./server.js";
import type { ServerConfig } from "./types.js";
type Served = {
port: number;
stop: (closeActiveConnections?: boolean) => void | Promise<void>;
};
const stops: Array<() => void | Promise<void>> = [];
const roots: string[] = [];
afterEach(async () => {
while (stops.length) {
await stops.pop()?.();
}
while (roots.length) {
await rm(roots.pop()!, { recursive: true, force: true });
}
});
async function createWorkspaceRoot() {
const root = await mkdtemp(join(tmpdir(), "openwork-session-read-"));
await mkdir(join(root, ".opencode"), { recursive: true });
roots.push(root);
return root;
}
function auth(token: string) {
return { Authorization: `Bearer ${token}` };
}
function startMockOpencode(input?: { invalidList?: boolean }) {
const requests: Array<{ pathname: string; search: string; directory: string | null }> = [];
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url);
requests.push({
pathname: url.pathname,
search: url.search,
directory: request.headers.get("x-opencode-directory"),
});
if (url.pathname === "/session") {
if (input?.invalidList) {
return Response.json({ nope: true });
}
return Response.json([
{
id: "ses_1",
title: "Hostname Check",
slug: "hostname-check",
directory: request.headers.get("x-opencode-directory"),
time: { created: 100, updated: 200 },
},
]);
}
if (url.pathname === "/session/status") {
return Response.json({ ses_1: { type: "busy" } });
}
if (url.pathname === "/session/ses_1") {
return Response.json({
id: "ses_1",
title: "Hostname Check",
slug: "hostname-check",
directory: request.headers.get("x-opencode-directory"),
time: { created: 100, updated: 200 },
});
}
if (url.pathname === "/session/ses_1/message") {
return Response.json([
{
info: {
id: "msg_1",
sessionID: "ses_1",
role: "assistant",
time: { created: 200 },
},
parts: [
{
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "text",
text: "hostname: mock-host",
},
],
},
]);
}
if (url.pathname === "/session/ses_1/todo") {
return Response.json([
{
content: "Validate session reads",
status: "completed",
priority: "high",
},
]);
}
return Response.json({ code: "not_found", message: "Not found" }, { status: 404 });
},
}) as Served;
stops.push(() => server.stop(true));
return { server, requests };
}
function startOpenworkServer(input: { workspaceRoot: string; opencodeBaseUrl: string }) {
const config: ServerConfig = {
host: "127.0.0.1",
port: 0,
token: "owt_test_token",
hostToken: "owt_host_token",
approval: { mode: "auto", timeoutMs: 1000 },
corsOrigins: ["*"],
workspaces: [
{
id: "ws_1",
name: "Workspace",
path: input.workspaceRoot,
preset: "starter",
workspaceType: "local",
baseUrl: input.opencodeBaseUrl,
},
],
authorizedRoots: [input.workspaceRoot],
readOnly: true,
startedAt: Date.now(),
tokenSource: "cli",
hostTokenSource: "cli",
logFormat: "pretty",
logRequests: false,
};
const server = startServer(config) as Served;
stops.push(() => server.stop(true));
return { server, token: config.token };
}
describe("workspace session read APIs", () => {
test("lists sessions and returns session details, messages, and snapshot", async () => {
const workspaceRoot = await createWorkspaceRoot();
const mock = startMockOpencode();
const openwork = startOpenworkServer({
workspaceRoot,
opencodeBaseUrl: `http://127.0.0.1:${mock.server.port}`,
});
const base = `http://127.0.0.1:${openwork.server.port}`;
const listResponse = await fetch(`${base}/workspace/ws_1/sessions?roots=true&limit=1&search=host&start=10`, {
headers: auth(openwork.token),
});
expect(listResponse.status).toBe(200);
const listBody = await listResponse.json();
expect(listBody).toEqual({
items: [
{
id: "ses_1",
title: "Hostname Check",
slug: "hostname-check",
directory: workspaceRoot,
time: { created: 100, updated: 200 },
},
],
});
const detailResponse = await fetch(`${base}/workspace/ws_1/sessions/ses_1`, {
headers: auth(openwork.token),
});
expect(detailResponse.status).toBe(200);
const detailBody = await detailResponse.json();
expect(detailBody.item.id).toBe("ses_1");
expect(detailBody.item.directory).toBe(workspaceRoot);
const messagesResponse = await fetch(`${base}/workspace/ws_1/sessions/ses_1/messages?limit=5`, {
headers: auth(openwork.token),
});
expect(messagesResponse.status).toBe(200);
const messagesBody = await messagesResponse.json();
expect(messagesBody.items).toHaveLength(1);
expect(messagesBody.items[0]?.info.id).toBe("msg_1");
expect(messagesBody.items[0]?.parts[0]?.text).toBe("hostname: mock-host");
const snapshotResponse = await fetch(`${base}/workspace/ws_1/sessions/ses_1/snapshot?limit=5`, {
headers: auth(openwork.token),
});
expect(snapshotResponse.status).toBe(200);
const snapshotBody = await snapshotResponse.json();
expect(snapshotBody.item.session.id).toBe("ses_1");
expect(snapshotBody.item.messages).toHaveLength(1);
expect(snapshotBody.item.todos).toEqual([
{
content: "Validate session reads",
status: "completed",
priority: "high",
},
]);
expect(snapshotBody.item.status).toEqual({ type: "busy" });
const listRequest = mock.requests.find((request) => request.pathname === "/session");
expect(listRequest?.directory).toBe(workspaceRoot);
expect(listRequest?.search).toContain("roots=true");
expect(listRequest?.search).toContain("limit=1");
expect(listRequest?.search).toContain("search=host");
expect(listRequest?.search).toContain("start=10");
});
test("returns 404 when the upstream session is missing", async () => {
const workspaceRoot = await createWorkspaceRoot();
const mock = startMockOpencode();
const openwork = startOpenworkServer({
workspaceRoot,
opencodeBaseUrl: `http://127.0.0.1:${mock.server.port}`,
});
const response = await fetch(`http://127.0.0.1:${openwork.server.port}/workspace/ws_1/sessions/ses_missing/snapshot`, {
headers: auth(openwork.token),
});
expect(response.status).toBe(404);
await expect(response.json()).resolves.toMatchObject({
code: "session_not_found",
message: "Session not found",
});
});
test("returns 502 when OpenCode returns an invalid session list payload", async () => {
const workspaceRoot = await createWorkspaceRoot();
const mock = startMockOpencode({ invalidList: true });
const openwork = startOpenworkServer({
workspaceRoot,
opencodeBaseUrl: `http://127.0.0.1:${mock.server.port}`,
});
const response = await fetch(`http://127.0.0.1:${openwork.server.port}/workspace/ws_1/sessions`, {
headers: auth(openwork.token),
});
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
code: "opencode_invalid_response",
message: "OpenCode returned invalid session list",
});
});
});

View File

@@ -0,0 +1,141 @@
import { z } from "zod";
import { ApiError } from "./errors.js";
const sessionTimeSchema = z
.object({
created: z.number().optional(),
updated: z.number().optional(),
completed: z.number().optional(),
archived: z.number().optional(),
})
.passthrough();
const sessionSummarySchema = z
.object({
additions: z.number().optional(),
deletions: z.number().optional(),
files: z.number().optional(),
})
.passthrough();
export const sessionStatusSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("idle") }),
z.object({ type: z.literal("busy") }),
z.object({ type: z.literal("retry"), attempt: z.number(), message: z.string(), next: z.number() }),
]);
export const sessionTodoSchema = z
.object({
content: z.string(),
status: z.string(),
priority: z.string(),
})
.passthrough();
export const sessionInfoSchema = z
.object({
id: z.string(),
title: z.string().nullish(),
slug: z.string().nullish(),
parentID: z.string().nullish(),
directory: z.string().nullish(),
time: sessionTimeSchema.optional(),
summary: sessionSummarySchema.optional(),
})
.passthrough();
const sessionMessageInfoSchema = z
.object({
id: z.string(),
sessionID: z.string(),
role: z.string(),
parentID: z.string().nullish(),
time: sessionTimeSchema.optional(),
})
.passthrough();
const sessionPartSchema = z
.object({
id: z.string(),
messageID: z.string(),
sessionID: z.string(),
})
.passthrough();
export const sessionMessageSchema = z
.object({
info: sessionMessageInfoSchema,
parts: z.array(sessionPartSchema),
})
.passthrough();
const sessionListSchema = z.array(sessionInfoSchema);
const sessionMessagesSchema = z.array(sessionMessageSchema);
const sessionTodosSchema = z.array(sessionTodoSchema);
const sessionStatusesSchema = z.record(z.string(), sessionStatusSchema);
const sessionSnapshotSchema = z.object({
session: sessionInfoSchema,
messages: sessionMessagesSchema,
todos: sessionTodosSchema,
status: sessionStatusSchema,
});
export type SessionInfoReadModel = z.infer<typeof sessionInfoSchema>;
export type SessionMessageReadModel = z.infer<typeof sessionMessageSchema>;
export type SessionTodoReadModel = z.infer<typeof sessionTodoSchema>;
export type SessionStatusReadModel = z.infer<typeof sessionStatusSchema>;
export type SessionSnapshotReadModel = z.infer<typeof sessionSnapshotSchema>;
const IDLE_STATUS: SessionStatusReadModel = { type: "idle" };
function parseOrThrow<T>(schema: z.ZodType<T>, value: unknown, label: string): T {
const result = schema.safeParse(value);
if (result.success) return result.data;
throw new ApiError(502, "opencode_invalid_response", `OpenCode returned invalid ${label}`, {
issues: result.error.issues,
});
}
export function buildSessionList(value: unknown): SessionInfoReadModel[] {
return parseOrThrow(sessionListSchema, value, "session list");
}
export function buildSession(value: unknown): SessionInfoReadModel {
return parseOrThrow(sessionInfoSchema, value, "session");
}
export function buildSessionMessages(value: unknown): SessionMessageReadModel[] {
return parseOrThrow(sessionMessagesSchema, value, "session messages");
}
export function buildSessionTodos(value: unknown): SessionTodoReadModel[] {
return parseOrThrow(sessionTodosSchema, value, "session todos");
}
export function buildSessionStatuses(value: unknown): Record<string, SessionStatusReadModel> {
return parseOrThrow(sessionStatusesSchema, value, "session statuses");
}
export function buildSessionSnapshot(input: {
session: unknown;
messages: unknown;
todos: unknown;
statuses: unknown;
}): SessionSnapshotReadModel {
const session = buildSession(input.session);
const messages = buildSessionMessages(input.messages);
const todos = buildSessionTodos(input.todos);
const statuses = buildSessionStatuses(input.statuses);
return parseOrThrow(
sessionSnapshotSchema,
{
session,
messages,
todos,
status: statuses[session.id] ?? IDLE_STATUS,
},
"session snapshot",
);
}

50
pnpm-lock.yaml generated
View File

@@ -52,6 +52,9 @@ importers:
'@solidjs/router': '@solidjs/router':
specifier: ^0.15.4 specifier: ^0.15.4
version: 0.15.4(patch_hash=1db11a7c28fe4da76187d42efaffc6b9a70ad370462fffb794ff90e67744d770)(solid-js@1.9.9) version: 0.15.4(patch_hash=1db11a7c28fe4da76187d42efaffc6b9a70ad370462fffb794ff90e67744d770)(solid-js@1.9.9)
'@tanstack/react-query':
specifier: ^5.90.3
version: 5.96.2(react@19.2.4)
'@tanstack/solid-virtual': '@tanstack/solid-virtual':
specifier: ^3.13.19 specifier: ^3.13.19
version: 3.13.19(solid-js@1.9.9) version: 3.13.19(solid-js@1.9.9)
@@ -88,6 +91,12 @@ importers:
marked: marked:
specifier: ^17.0.1 specifier: ^17.0.1
version: 17.0.1 version: 17.0.1
react:
specifier: ^19.1.1
version: 19.2.4
react-dom:
specifier: ^19.1.1
version: 19.2.4(react@19.2.4)
solid-js: solid-js:
specifier: ^1.9.0 specifier: ^1.9.0
version: 1.9.9 version: 1.9.9
@@ -98,6 +107,15 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.18 specifier: ^4.1.18
version: 4.1.18(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) version: 4.1.18(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
'@types/react':
specifier: ^19.2.2
version: 19.2.14
'@types/react-dom':
specifier: ^19.2.2
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^5.0.4
version: 5.2.0(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
solid-devtools: solid-devtools:
specifier: ^0.34.5 specifier: ^0.34.5
version: 0.34.5(solid-js@1.9.9)(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) version: 0.34.5(solid-js@1.9.9)(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
@@ -211,6 +229,9 @@ importers:
yaml: yaml:
specifier: ^2.6.1 specifier: ^2.6.1
version: 2.8.2 version: 2.8.2
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies: devDependencies:
'@types/minimatch': '@types/minimatch':
specifier: ^5.1.2 specifier: ^5.1.2
@@ -2915,6 +2936,14 @@ packages:
peerDependencies: peerDependencies:
vite: ^5.2.0 || ^6 || ^7 vite: ^5.2.0 || ^6 || ^7
'@tanstack/query-core@5.96.2':
resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==}
'@tanstack/react-query@5.96.2':
resolution: {integrity: sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==}
peerDependencies:
react: ^18 || ^19
'@tanstack/solid-virtual@3.13.19': '@tanstack/solid-virtual@3.13.19':
resolution: {integrity: sha512-4uCuwY/nfQzoxuJCOroEGgRQkoDuzRE4wVdDq47InQ9WPQ+1dkBUyE7k7frcTPy+l1yjCCmLGdq9h9GQyofVHw==} resolution: {integrity: sha512-4uCuwY/nfQzoxuJCOroEGgRQkoDuzRE4wVdDq47InQ9WPQ+1dkBUyE7k7frcTPy+l1yjCCmLGdq9h9GQyofVHw==}
peerDependencies: peerDependencies:
@@ -7749,6 +7778,13 @@ snapshots:
tailwindcss: 4.1.18 tailwindcss: 4.1.18
vite: 6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) vite: 6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
'@tanstack/query-core@5.96.2': {}
'@tanstack/react-query@5.96.2(react@19.2.4)':
dependencies:
'@tanstack/query-core': 5.96.2
react: 19.2.4
'@tanstack/solid-virtual@3.13.19(solid-js@1.9.9)': '@tanstack/solid-virtual@3.13.19(solid-js@1.9.9)':
dependencies: dependencies:
'@tanstack/virtual-core': 3.13.19 '@tanstack/virtual-core': 3.13.19
@@ -7892,7 +7928,7 @@ snapshots:
'@types/react-dom@18.2.25': '@types/react-dom@18.2.25':
dependencies: dependencies:
'@types/react': 18.2.79 '@types/react': 19.2.14
'@types/react-dom@19.2.3(@types/react@19.2.14)': '@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies: dependencies:
@@ -7921,6 +7957,18 @@ snapshots:
throttleit: 2.1.0 throttleit: 2.1.0
undici: 5.29.0 undici: 5.29.0
'@vitejs/plugin-react@5.2.0(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
'@rolldown/pluginutils': 1.0.0-rc.3
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
vite: 6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))': '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0

View File

@@ -0,0 +1,620 @@
# PRD: Incremental React Adoption with Isolated Testing
## Status: Draft
## Date: 2026-04-05
## Problem
The OpenWork app is 100% SolidJS. The session UI has resilience issues (white screens, flicker, route/runtime/selection mismatches) rooted in overlapping owners of truth. The plan is to incrementally adopt React for the session experience layer, then expand to replace the entire app — while keeping the existing app running at every step. Each phase must be testable in isolation against a real Docker dev stack before merging.
## Current Architecture (Ground Truth)
### Frontend
- **Framework**: SolidJS only. Zero React in `apps/app/`.
- **Monolith**: `app.tsx` (~2,500 lines) creates ~15 stores, threads ~90 props to `SessionView`.
- **Session view**: `pages/session.tsx` (~2,000 lines) — SolidJS, receives all state as props via `SessionViewProps`.
- **State**: SolidJS signals + `createStore()`. No external state libs.
- **Router**: `@solidjs/router`, imperative navigation.
- **Prepared seam**: `@openwork/ui` already exports both React and Solid components. `SessionViewProps` is a clean data-only interface.
- **Build**: Vite + `vite-plugin-solid`. No React plugin configured.
- **Platform**: Tauri 2.x for desktop/mobile. Web mode uses standard browser APIs. Platform abstraction lives in `context/platform.tsx`.
### Backend
- **Server**: `Bun.serve()`, hand-rolled router. No framework.
- **Session data**: Lives in OpenCode's SQLite DB. Client reads via OpenCode SDK or proxied through `/w/:id/opencode/*`.
- **No server-side session read endpoints**: The OpenWork server has no `GET /sessions` or `GET /session/:id`. It proxies to OpenCode.
- **Activation**: Nearly free (array reorder). The expensive part is client-side workspace bootstrapping.
- **Orchestrator**: Process supervisor that spawns server + OpenCode + router.
### Styling
- **CSS framework**: Tailwind CSS v4.1.18 via `@tailwindcss/vite`.
- **Color system**: Radix UI Colors (30+ scales, 12 steps each) + DLS semantic tokens — all CSS custom properties (~700+).
- **Dark mode**: `data-theme` attribute on `<html>` + CSS variable swap. NOT Tailwind `dark:` prefix.
- **Component styling**: Inline Tailwind `class=` strings with template literal conditionals. No `cn()`, `clsx`, or `tailwind-merge`.
- **Custom CSS classes**: `ow-*` prefixed classes in global `index.css` (buttons, cards, pills, inputs).
- **CSS-in-JS**: None.
- **Animation**: CSS-only (Tailwind transitions + custom `@keyframes`). No framer-motion or JS animation libs.
- **Fonts**: System font stack (IBM Plex Sans preferred, no bundled fonts).
- **Design language**: `DESIGN-LANGUAGE.md` (871 lines) — quiet, premium, flat-first. Shadow is last resort.
- **Key files**: `tailwind.config.ts`, `src/app/index.css`, `src/styles/colors.css`, `DESIGN-LANGUAGE.md`.
### Existing domain map (CUPID)
The app follows CUPID domain organization:
- `shell` — routing, layout, boot, global chrome
- `session` — task/session experience, composer, messages
- `workspace` — workspace lifecycle, switching, connect
- `connections` — providers, MCP
- `automations` — scheduled jobs
- `cloud` — hosted workers, den
- `app-settings` — preferences, themes
- `kernel` — tiny shared primitives
---
## Three-Stage Transition: Solid → Hybrid → React
### Stage 1: React Island (Phases 0-3)
React lives inside the Solid app as a guest. Solid owns the shell, routing, and platform layer. React renders into a div that Solid manages.
```
Tauri/Web shell
└── Solid app (owns everything)
├── Solid sidebar
├── Solid settings
└── ReactIsland (a div)
└── React session view (our new code)
```
State bridge: minimal. React gets workspace URL + token + session ID from Solid via island props. React fetches its own data. Two independent state worlds.
### Stage 2: React Expands, Island Inverts (Phases 5-8)
React takes over more surfaces. Each Solid surface migrates to its React counterpart, one domain at a time. At a tipping point (after workspace sidebar moves to React), the island inverts:
```
Tauri/Web shell
└── React app (owns the shell now)
├── React sidebar
├── React session view
├── React settings (partial)
└── SolidIsland (a div) ← for remaining Solid surfaces
└── remaining Solid components
```
### Stage 3: React Owns Everything (Phase 9+)
```
Tauri shell (just the native window + IPC)
└── React app
├── react/shell/
├── react/session/
├── react/workspace/
├── react/connections/
├── react/app-settings/
├── react/cloud/
└── react/kernel/
```
At this point `vite-plugin-solid` and `solid-js` are removed. The app is a standard React SPA that happens to run inside Tauri for desktop. The web build is the same React app without the Tauri wrapper.
---
## State Ownership Rule
**At any point in time, each piece of state has exactly one framework owning it.**
When you migrate a surface from Solid to React, you delete the Solid version of that state. You never have both frameworks managing the same concern.
| Concern | Stage 1 (React island) | Stage 2 (React expanding) | Stage 3 (React owns all) |
|---------|----------------------|--------------------------|-------------------------|
| Session messages | React (react-query) | React | React |
| Session transition | React (transition-controller) | React | React |
| Workspace list | Solid | React (after migration) | React |
| Workspace switching | Solid → passes result to React via island props | React | React |
| Routing | Solid router | Hybrid: Solid routes to React islands | React router |
| Platform (Tauri IPC) | Solid platform provider | Framework-agnostic adapter module | React calls adapter directly |
| Settings/config | Solid | Migrated domain by domain | React |
---
## Bridge Contract (Shrinks Over Time)
The island props are the formal contract between Solid and React. It starts small and shrinks to zero:
```ts
// Stage 1 — React island gets minimal props from Solid
interface IslandProps {
workspaceUrl: string
workspaceToken: string
workspaceId: string
sessionId: string | null
onNavigate: (path: string) => void // React tells Solid to route
}
// Stage 2 — React takes over sidebar, fewer props needed
interface IslandProps {
workspaces: WorkspaceConnection[] // React now owns selection
onNavigate: (path: string) => void
}
// Stage 3 — no island, no props. React owns everything.
// island.tsx deleted, solid-js removed.
```
Each time a surface migrates, the island props shrink. When they hit zero, the island is removed.
---
## File Structure (CUPID Domains, Component-Enclosed State)
Mirrors the existing CUPID domain map. Each domain colocates state, data, and UI. Components own the state they render — "general" session state sits at the session boundary, local UI state lives inside the component that needs it.
```
apps/app/src/react/
├── README.md # Why this exists, how to enable, migration status
├── island.tsx # Solid→React bridge (mounts boot.tsx into a DOM node)
├── boot.tsx # React root, providers, top-level wiring
├── feature-flag.ts # Read/write opt-in flag
├── kernel/ # Smallest shared layer (CUPID kernel rules apply)
│ ├── opencode-client.ts # Plain fetch() for OpenCode proxy — no Solid dependency
│ ├── types.ts # Session, Message, Workspace shapes
│ ├── query-provider.tsx # react-query provider + defaults
│ └── dev-panel.tsx # Dev-only: renderSource, transition, timings
├── shell/ # App-wide composition only (thin)
│ ├── layout.tsx # Sidebar + main area composition
│ ├── router.tsx # Route → domain view dispatch
│ └── index.ts
├── session/ # Domain: active task/session experience
│ │
│ │ -- General session state (shared by session components) --
│ ├── session-store.ts # renderedSessionId, intendedSessionId, renderSource
│ ├── transition-controller.ts # idle → switching → cache → live → idle
│ ├── sessions-query.ts # react-query: list sessions for a workspace
│ ├── session-snapshot-query.ts # react-query: full session + messages
│ │
│ │ -- Session view (composition root for the main area) --
│ ├── session-view.tsx # Composes message-list + composer + status
│ │ # owns: scroll position, view-level layout
│ │
│ │ -- Message list (owns its own scroll/virtualization) --
│ ├── message-list/
│ │ ├── message-list.tsx # Virtualized container
│ │ │ # owns: virtualization state, scroll anchor
│ │ ├── message-item.tsx # Single message bubble
│ │ │ # owns: collapsed/expanded, copy state
│ │ ├── part-view.tsx # Tool call, text, file, reasoning
│ │ │ # owns: expand/collapse per part
│ │ └── index.ts
│ │
│ │ -- Composer (owns its own input state) --
│ ├── composer/
│ │ ├── composer.tsx # Prompt textarea + attachments + run/abort
│ │ │ # owns: draft text, file list, submitting
│ │ ├── send-prompt.ts # Mutation: send, SSE subscribe, abort
│ │ ├── attachment-picker.tsx
│ │ │ # owns: file picker open/selected state
│ │ └── index.ts
│ │
│ │ -- Session sidebar (owns its own list state) --
│ ├── session-sidebar/
│ │ ├── session-sidebar.tsx # Session list for one workspace
│ │ │ # owns: search filter, rename-in-progress
│ │ ├── session-item.tsx # Single row
│ │ │ # owns: hover, context menu open
│ │ └── index.ts
│ │
│ │ -- Transition UX --
│ ├── transition-overlay.tsx # "Switching..." / skeleton during transitions
│ │ # owns: nothing — reads from transition-controller
│ │
│ └── index.ts # Public surface (only what shell needs)
├── workspace/ # Domain: workspace lifecycle
│ ├── workspace-store.ts # Which workspaces exist, connection info
│ ├── workspace-list.tsx # Sidebar workspace groups
│ │ # owns: collapsed state, selection highlight
│ ├── workspace-switcher.tsx # Switching logic + transition state
│ │ # owns: switching/idle/failed for workspace changes
│ ├── workspaces-query.ts # react-query: list + status
│ ├── create-workspace-modal.tsx # Add workspace flow
│ └── index.ts
├── connections/ # Domain: providers, MCP
│ └── index.ts # Placeholder — empty until needed
├── cloud/ # Domain: hosted workers, den
│ └── index.ts # Placeholder — empty until needed
├── app-settings/ # Domain: preferences, themes
│ └── index.ts # Placeholder — empty until needed
└── automations/ # Domain: scheduled jobs
└── index.ts # Placeholder — empty until needed
```
### Component-enclosed state hierarchy
Visual hierarchy = state hierarchy. A human reading the tree knows who owns what:
```
shell/layout.tsx
├── workspace/workspace-list.tsx → owns: selection, collapse
│ └── workspace-switcher.tsx → owns: workspace transition state
└── session/session-view.tsx → reads: session-store (general)
├── session/message-list/ → owns: scroll, virtualization
│ └── message-item.tsx → owns: expand/collapse per message
│ └── part-view.tsx → owns: expand/collapse per part
├── session/composer/ → owns: draft, files, submitting
├── session/session-sidebar/ → owns: search, rename-in-progress
└── session/transition-overlay.tsx → reads: transition-controller (no local state)
```
General session state (`session-store.ts`, `transition-controller.ts`, queries) lives at the `session/` root — shared by components below it. Component-local state (draft text, scroll position, expand/collapse) lives inside the component that renders it. No ambiguity.
---
## Styling Strategy
### What carries over for free
The entire styling foundation is framework-agnostic. React components inherit everything without configuration:
| Asset | Framework-dependent? | Notes |
|-------|---------------------|-------|
| Tailwind classes | No | Just CSS strings. Same classes, same output. |
| CSS custom properties (700+ Radix + DLS tokens) | No | Pure CSS, loaded in `index.css`. |
| Dark mode (`data-theme` + variable swap) | No | Works on any DOM element. |
| `ow-*` CSS classes (buttons, cards, pills) | No | Global CSS, available everywhere. |
| `@keyframes` animations | No | Pure CSS. |
| Font stack | No | System fonts, nothing to load. |
| `DESIGN-LANGUAGE.md` reference | No | Design rules are visual, not framework. |
**React components use `className=` instead of `class=`. That is the only syntax change.**
### What to add for React
One utility in `react/kernel/`:
```ts
// react/kernel/cn.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
The Solid side manages without this (template literals). The React side benefits from `cn()` for conditional classes — it's the standard React/Tailwind convention and prevents class conflicts during composition.
**New dependencies (Phase 0):** `clsx`, `tailwind-merge`.
### Styling rules for React components
1. **Use the same Tailwind classes.** Reference `DESIGN-LANGUAGE.md` for visual decisions.
2. **Use DLS tokens** (`dls-surface`, `dls-border`, `dls-accent`, etc.) via Tailwind config, not raw hex values.
3. **Use Radix color scales** (`bg-gray-3`, `text-blue-11`) for non-semantic colors.
4. **Use `ow-*` classes** where they exist (e.g., `ow-button-primary`, `ow-soft-card`).
5. **Use `cn()`** for conditional classes instead of template literals.
6. **No CSS-in-JS.** No styled-components, no emotion. Tailwind only.
7. **No `dark:` prefix.** Dark mode is handled by CSS variable swap on `[data-theme="dark"]`.
8. **Animation is CSS-only.** Use Tailwind `transition-*` and the existing custom `@keyframes`. No framer-motion.
9. **Match the Solid component's visual output exactly.** When migrating a surface, screenshot both versions and diff. Same spacing, same colors, same radius, same shadows.
### Visual parity verification
Each migrated surface gets a visual comparison test:
1. Screenshot the Solid version (Chrome DevTools).
2. Screenshot the React version (same viewport, same data).
3. Overlay or side-by-side compare. No visible difference = pass.
This is added to the test actions for each phase.
---
## Isolation & Testing Strategy
### Per-phase Docker isolation
Each phase gets tested against two independent Docker dev stacks:
```
Stack A (control): runs the existing SolidJS app
→ packaging/docker/dev-up.sh → server :PORT_A, web :PORT_A_WEB
Stack B (experiment): independent server
→ packaging/docker/dev-up.sh → server :PORT_B, web :PORT_B_WEB
```
Both stacks share the same repo (bind-mounted), but run independent servers with independent tokens and hostnames (verified via `hostname` command through the UI).
### Test actions
Every phase adds entries to `test-actions.md` with:
- Steps to exercise the new React surface
- Expected results
- Comparison against the Solid version on the control stack
- Chrome DevTools verification (using `functions.chrome-devtools_*`)
### Feature flag gate
```
localStorage.setItem('openwork:react-session', 'true')
// or
http://localhost:<WEB_PORT>/session?react=1
```
The app shell checks this flag and renders either:
- `<SessionView />` (Solid, existing)
- `<ReactIsland />` → React session view (new)
---
## Phase Roadmap
### Phase 0: Build Infrastructure
**Goal**: React components can render inside the SolidJS app.
**Deliverables**:
1. Add `@vitejs/plugin-react` to Vite config (alongside `vite-plugin-solid`).
2. File convention: `*.tsx` in `src/react/` = React. Everything else = Solid.
3. `island.tsx` — Solid component that mounts a React root into a DOM node.
4. `boot.tsx` — React root with `QueryClientProvider`.
5. Add `react`, `react-dom`, `@tanstack/react-query` to `apps/app/package.json`.
6. `feature-flag.ts` — reads localStorage / query param.
7. Verify: a trivial React component renders inside the Solid shell.
**Test**:
- Boot Docker stack.
- Navigate to session view.
- Enable feature flag.
- Confirm React island mounts (check React DevTools or a visible test banner).
**Does NOT change any user-visible behavior.**
### Phase 1: React Session View (Read-Only)
**Goal**: A React component can display a session's messages (read-only, no composer).
**Deliverables**:
1. `react/kernel/opencode-client.ts` — plain `fetch()` client for OpenCode proxy.
2. `react/kernel/types.ts` — Session, Message, Part shapes.
3. `react/session/session-store.ts``renderedSessionId`, `intendedSessionId`, `renderSource`.
4. `react/session/sessions-query.ts` — react-query: list sessions.
5. `react/session/session-snapshot-query.ts` — react-query: session + messages.
6. `react/session/session-view.tsx` — composition root.
7. `react/session/message-list/` — virtualized message rendering.
8. Feature-flagged: `?react=1` shows React view, default shows Solid.
**State ownership**: React owns all session read state. It fetches directly from the OpenCode proxy. No Solid signal subscriptions. The island props provide only: `workspaceUrl`, `workspaceToken`, `workspaceId`, `sessionId`.
**Test actions**:
- Create session in Solid view, send a prompt, get a response.
- Switch to React view (`?react=1`) — same session's messages appear.
- Switch sessions — React view transitions without white screen.
- Compare: Solid view on Stack A, React view on Stack B, same prompt, same output.
**Success criteria**:
- No blank pane during session switch.
- Messages render from cache instantly, upgrade to live data.
- `renderSource` visible in dev panel.
### Phase 2: React Composer (Send/Receive)
**Goal**: The React session view can send prompts and display streaming responses.
**Deliverables**:
1. `react/session/composer/composer.tsx` — prompt input, file attachment, run/abort.
2. `react/session/composer/send-prompt.ts` — mutation: send, SSE stream, abort.
3. `react/session/composer/attachment-picker.tsx`.
4. SSE subscription for streaming message parts.
5. `streamdown` for markdown rendering of streaming text.
**State ownership**: Composer owns draft text, file list, submitting state. Send mutation is local to composer. Streaming messages flow into react-query cache via SSE → cache invalidation.
**Test actions**:
- Type a prompt in React composer, click Run.
- Response streams in real-time.
- Abort mid-stream — session stops cleanly.
- Switch workspace mid-stream — no crash.
**Success criteria**:
- Full send/receive/abort cycle works in React view.
- Streaming feels identical to Solid view.
### Phase 3: Transition Controller + Debug Panel
**Goal**: The React path handles workspace and session switching with explicit transition states.
**Deliverables**:
1. `react/session/transition-controller.ts` — state machine:
```
idle → switching → (cache-render) → (live-upgrade) → idle
idle → switching → failed → recovering → idle
```
2. `react/session/transition-overlay.tsx` — skeleton/indicator during transitions.
3. `react/kernel/dev-panel.tsx` — shows `routeState`, `transitionState`, `renderSource`, `runtimeState`.
**Test actions**:
- Connect two Docker dev stacks as workspaces.
- Switch between workspaces rapidly.
- React view never shows white screen.
- Debug panel visible and accurate.
**Success criteria**:
- Zero white screens during any switch sequence.
- Transition states are inspectable via Chrome DevTools.
### Phase 4: Backend Read APIs (parallel track)
**Goal**: Session reads don't require client-side OpenCode proxy orchestration.
**Deliverables** (in `apps/server/src/server.ts`):
1. `GET /workspace/:id/sessions` — list sessions for a workspace.
2. `GET /workspace/:id/sessions/:sessionId` — session detail with messages.
3. `GET /workspace/:id/sessions/:sessionId/snapshot` — full session snapshot.
4. Typed response schemas (zod).
**Test actions**:
- `curl http://localhost:<PORT>/workspace/<id>/sessions` returns session list.
- `curl http://localhost:<PORT>/workspace/<id>/sessions/<sid>/snapshot` returns full snapshot.
- Works for any workspace, not just the "active" one.
- React query layer switches to these endpoints.
**Success criteria**:
- Session reads work without activation.
- Response times < 100ms for cached reads.
### Phase 5: React Session as Default
**Goal**: Flip the feature flag. React session view is the default.
**Deliverables**:
1. Feature flag default flips to `true`.
2. `?solid=1` to opt back into Solid session view.
3. Remove any Solid↔React shims that are no longer needed for session.
**Success criteria**:
- All test actions pass with React as default.
- No regression in any existing flow.
### Phase 6: Migrate Workspace Sidebar
**Goal**: React owns the workspace list and session sidebar.
**Deliverables**:
1. `react/workspace/workspace-list.tsx` — workspace groups in sidebar.
2. `react/session/session-sidebar/` — session list per workspace.
3. `react/workspace/workspace-switcher.tsx` — switching logic.
4. Island props shrink: React now receives `workspaces[]` instead of single workspace info.
**State ownership**: React owns workspace selection, sidebar collapse, session list filtering. Solid still owns settings and platform.
### Phase 7: Migrate Settings & Connections
**Goal**: React owns settings pages and provider/MCP flows.
**Deliverables**:
1. Fill `react/app-settings/` — theme, preferences, config.
2. Fill `react/connections/` — provider auth, MCP.
3. Fill `react/cloud/` — hosted workers, den.
### Phase 8: Island Inversion
**Goal**: React becomes the shell. Solid becomes the guest (if anything remains).
**Deliverables**:
1. `react/shell/layout.tsx` becomes the top-level composition.
2. `react/shell/router.tsx` owns all routing.
3. If any Solid surfaces remain, they render inside a `SolidIsland` React component.
4. Island props are now zero or near-zero.
### Phase 9: Remove Solid
**Goal**: The app is pure React.
**Deliverables**:
1. Remove `vite-plugin-solid` from Vite config.
2. Remove `solid-js`, `@solidjs/router`, `solid-primitives` from `package.json`.
3. Delete `apps/app/src/app/` (the old Solid tree).
4. `apps/app/src/react/` becomes `apps/app/src/app/` (or stays where it is).
5. Remove `island.tsx`, `feature-flag.ts`.
---
## Migration Surface Order
```
Phase 0-3 → Session view (messages, composer, transitions)
Phase 5 → Flip session default to React
Phase 6 → Workspace sidebar + session sidebar
← tipping point: React owns enough to invert the island →
Phase 7 → Settings, connections, cloud
Phase 8 → Shell/layout/routing — island inversion
Phase 9 → Remove Solid entirely
```
## Timeline Guidance
| Phase | Scope | Estimated Effort |
|-------|-------|-----------------|
| 0 | Build infra | ~1 day |
| 1 | Read-only session view | ~1 week |
| 2 | Composer + streaming | ~1 week |
| 3 | Transition controller + debug | ~1 week |
| 4 | Backend read APIs (parallel) | ~1 week |
| 5 | Flip session default | ~1 day |
| 6 | Workspace sidebar | ~1 week |
| 7 | Settings, connections, cloud | ~2-3 weeks |
| 8 | Island inversion | ~1 week |
| 9 | Remove Solid | ~1 day |
Phases 0-3 are fast and highly visible. Phase 4 can run in parallel. Phases 6+ can be paced based on stability.
---
## Files Changed Per Phase
| Phase | Files |
|-------|-------|
| 0 | `apps/app/vite.config.ts`, `apps/app/package.json`, new `src/react/island.tsx`, `src/react/boot.tsx`, `src/react/feature-flag.ts` |
| 1 | New `src/react/kernel/` (3 files), new `src/react/session/` (6-8 files), feature flag check in `app.tsx` |
| 2 | New `src/react/session/composer/` (3 files) |
| 3 | New `src/react/session/transition-controller.ts`, `transition-overlay.tsx`, `src/react/kernel/dev-panel.tsx` |
| 4 | `apps/server/src/server.ts` (add 3-4 endpoints), new `apps/server/src/session-read-model.ts` |
| 5 | `app.tsx` flag flip, cleanup |
| 6 | New `src/react/workspace/` (4-5 files), `src/react/session/session-sidebar/` (2 files) |
| 7 | Fill `src/react/connections/`, `src/react/app-settings/`, `src/react/cloud/` |
| 8 | `src/react/shell/` becomes the root, island inversion |
| 9 | Delete `src/app/`, remove Solid deps |
---
## Verification Approach
Every phase:
1. Boot two Docker dev stacks (`dev-up.sh` x2).
2. Connect Stack B as a workspace from Stack A's UI.
3. Run the phase's test actions via Chrome DevTools (`functions.chrome-devtools_*`).
4. Screenshot evidence saved to repo.
5. Update `test-actions.md` with the new test actions.
6. PR includes screenshots and test action references.
---
## Dependency Direction
Same CUPID rules apply to the React tree:
```
shell → domain public API (index.ts) → domain internals
```
- Domains may depend on `kernel/` primitives.
- Domains never reach into another domain's internals.
- Cross-domain imports go through `index.ts`.
- No bidirectional imports.
- No "super util" files.
---
## Anti-Patterns
- Adding feature logic to `shell/layout.tsx` (shell orchestrates, doesn't absorb).
- Sharing state between Solid and React for the same concern (one owner always).
- Creating `utils/` or `helpers/` buckets instead of colocating with the owning domain.
- Migrating more than one domain per phase.
- Rewriting Solid component behavior during migration (preserve behavior, change placement).
---
## Decision Heuristic
- **Immediate product feel**: start with frontend Phase 0-1 (session view).
- **Highest compounding win**: invest in backend Phase 4 (read APIs) in parallel.
- **When to invert the island**: after workspace sidebar (Phase 6) moves to React — that's when React owns enough of the visual hierarchy to be the shell.
- **When to remove Solid**: only after all domains are migrated and stable. Not before.