mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
@@ -46,6 +46,7 @@
|
||||
"@solid-primitives/event-bus": "^1.1.2",
|
||||
"@solid-primitives/storage": "^4.3.3",
|
||||
"@solidjs/router": "^0.15.4",
|
||||
"@tanstack/react-query": "^5.90.3",
|
||||
"@tanstack/solid-virtual": "^3.13.19",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||
@@ -58,9 +59,14 @@
|
||||
"jsonc-parser": "^3.2.1",
|
||||
"lucide-solid": "^0.562.0",
|
||||
"marked": "^17.0.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"solid-js": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"@solid-devtools/overlay": "^0.33.5",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"solid-devtools": "^0.34.5",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
import { createOpencodeClient, type Message, type Part, type Session, type Todo } from "@opencode-ai/sdk/v2/client";
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
|
||||
import { createOpenworkServerClient, OpenworkServerError } from "./openwork-server";
|
||||
import { isTauriRuntime } from "../utils";
|
||||
|
||||
type FieldsResult<T> =
|
||||
@@ -34,6 +35,25 @@ type CommandParameters = {
|
||||
reasoning_effort?: string;
|
||||
};
|
||||
|
||||
type SessionListParameters = {
|
||||
directory?: string;
|
||||
roots?: boolean;
|
||||
start?: number;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
type SessionLookupParameters = {
|
||||
sessionID: string;
|
||||
directory?: string;
|
||||
};
|
||||
|
||||
type SessionMessagesParameters = {
|
||||
sessionID: string;
|
||||
directory?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type OpencodeAuth = {
|
||||
username?: string;
|
||||
password?: string;
|
||||
@@ -112,6 +132,83 @@ async function postSessionRequest<T>(
|
||||
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(
|
||||
fetchImpl: typeof globalThis.fetch,
|
||||
input: RequestInfo | URL,
|
||||
@@ -237,11 +334,88 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod
|
||||
});
|
||||
|
||||
const session = client.session as typeof client.session;
|
||||
const openworkMount = auth?.mode === "openwork" ? resolveOpenworkWorkspaceMount(baseUrl) : null;
|
||||
const openworkSessionClient =
|
||||
openworkMount && auth?.token
|
||||
? createOpenworkServerClient({ baseUrl: openworkMount.baseUrl, token: auth.token })
|
||||
: null;
|
||||
// TODO(2026-04-12): remove the old-server compatibility path here once all
|
||||
// OpenWork servers expose the workspace-scoped session read APIs.
|
||||
const sessionOverrides = session as any as {
|
||||
list: (parameters?: SessionListParameters, options?: { throwOnError?: boolean }) => Promise<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<{}>>;
|
||||
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);
|
||||
sessionOverrides.promptAsync = (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => {
|
||||
if (!("reasoning_effort" in parameters)) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
import type { Message, Part, Session, Todo } from "@opencode-ai/sdk/v2/client";
|
||||
import { isTauriRuntime } from "../utils";
|
||||
import type { ExecResult, OpencodeConfigFile, ScheduledJob, WorkspaceInfo, WorkspaceList } from "./tauri";
|
||||
|
||||
@@ -105,6 +106,21 @@ export type OpenworkWorkspaceList = {
|
||||
activeId?: string | null;
|
||||
};
|
||||
|
||||
export type OpenworkSessionMessage = {
|
||||
info: Message;
|
||||
parts: Part[];
|
||||
};
|
||||
|
||||
export type OpenworkSessionSnapshot = {
|
||||
session: Session;
|
||||
messages: OpenworkSessionMessage[];
|
||||
todos: Todo[];
|
||||
status:
|
||||
| { type: "idle" }
|
||||
| { type: "busy" }
|
||||
| { type: "retry"; attempt: number; message: string; next: number };
|
||||
};
|
||||
|
||||
export type OpenworkPluginItem = {
|
||||
spec: string;
|
||||
source: "config" | "dir.project" | "dir.global";
|
||||
@@ -912,6 +928,7 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
activateWorkspace: 10_000,
|
||||
deleteWorkspace: 10_000,
|
||||
deleteSession: 12_000,
|
||||
sessionRead: 12_000,
|
||||
status: 6_000,
|
||||
config: 10_000,
|
||||
opencodeRouter: 10_000,
|
||||
@@ -985,6 +1002,48 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{ token, hostToken, method: "DELETE", timeoutMs: timeouts.deleteSession },
|
||||
),
|
||||
listSessions: (
|
||||
workspaceId: string,
|
||||
options?: { roots?: boolean; start?: number; search?: string; limit?: number },
|
||||
) => {
|
||||
const query = new URLSearchParams();
|
||||
if (typeof options?.roots === "boolean") query.set("roots", String(options.roots));
|
||||
if (typeof options?.start === "number") query.set("start", String(options.start));
|
||||
if (options?.search?.trim()) query.set("search", options.search.trim());
|
||||
if (typeof options?.limit === "number") query.set("limit", String(options.limit));
|
||||
const suffix = query.size ? `?${query.toString()}` : "";
|
||||
return requestJson<{ items: Session[] }>(
|
||||
baseUrl,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/sessions${suffix}`,
|
||||
{ token, hostToken, timeoutMs: timeouts.sessionRead },
|
||||
);
|
||||
},
|
||||
getSession: (workspaceId: string, sessionId: string) =>
|
||||
requestJson<{ item: Session }>(
|
||||
baseUrl,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{ token, hostToken, timeoutMs: timeouts.sessionRead },
|
||||
),
|
||||
getSessionMessages: (workspaceId: string, sessionId: string, options?: { limit?: number }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (typeof options?.limit === "number") query.set("limit", String(options.limit));
|
||||
const suffix = query.size ? `?${query.toString()}` : "";
|
||||
return requestJson<{ items: OpenworkSessionMessage[] }>(
|
||||
baseUrl,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/messages${suffix}`,
|
||||
{ token, hostToken, timeoutMs: timeouts.sessionRead },
|
||||
);
|
||||
},
|
||||
getSessionSnapshot: (workspaceId: string, sessionId: string, options?: { limit?: number }) => {
|
||||
const query = new URLSearchParams();
|
||||
if (typeof options?.limit === "number") query.set("limit", String(options.limit));
|
||||
const suffix = query.size ? `?${query.toString()}` : "";
|
||||
return requestJson<{ item: OpenworkSessionSnapshot }>(
|
||||
baseUrl,
|
||||
`/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/snapshot${suffix}`,
|
||||
{ token, hostToken, timeoutMs: timeouts.sessionRead },
|
||||
);
|
||||
},
|
||||
exportWorkspace: (
|
||||
workspaceId: string,
|
||||
options?: { sensitiveMode?: OpenworkWorkspaceExportSensitiveMode },
|
||||
|
||||
@@ -77,6 +77,7 @@ import type {
|
||||
OpenworkServerSettings,
|
||||
OpenworkServerStatus,
|
||||
} from "../lib/openwork-server";
|
||||
import { buildOpenworkWorkspaceBaseUrl } from "../lib/openwork-server";
|
||||
import { join } from "@tauri-apps/api/path";
|
||||
import {
|
||||
isUserVisiblePart,
|
||||
@@ -111,6 +112,9 @@ import {
|
||||
saveSessionDraft,
|
||||
sessionDraftScopeKey,
|
||||
} from "../session/draft-store";
|
||||
import { ReactIsland } from "../../react/island";
|
||||
import { reactSessionEnabled } from "../../react/feature-flag";
|
||||
import { SessionSurface } from "../../react/session/session-surface.react";
|
||||
|
||||
export type SessionViewProps = {
|
||||
selectedSessionId: string | null;
|
||||
@@ -2123,6 +2127,27 @@ export default function SessionView(props: SessionViewProps) {
|
||||
workspaceId: string;
|
||||
sessionId: string;
|
||||
} | null>(null);
|
||||
const reactSessionOpencodeBaseUrl = createMemo(() => {
|
||||
const workspaceId = props.runtimeWorkspaceId?.trim() ?? "";
|
||||
const baseUrl = props.openworkServerClient?.baseUrl?.trim() ?? "";
|
||||
if (!workspaceId || !baseUrl) return "";
|
||||
const mounted = buildOpenworkWorkspaceBaseUrl(baseUrl, workspaceId) ?? baseUrl;
|
||||
return `${mounted.replace(/\/+$/, "")}/opencode`;
|
||||
});
|
||||
const reactSessionToken = createMemo(
|
||||
() => props.openworkServerClient?.token?.trim() || props.openworkServerSettings.token?.trim() || "",
|
||||
);
|
||||
const showReactSessionSurface = createMemo(
|
||||
() =>
|
||||
reactSessionEnabled() &&
|
||||
Boolean(
|
||||
props.selectedSessionId?.trim() &&
|
||||
props.runtimeWorkspaceId?.trim() &&
|
||||
props.openworkServerClient &&
|
||||
reactSessionOpencodeBaseUrl() &&
|
||||
reactSessionToken(),
|
||||
),
|
||||
);
|
||||
const hasWorkspaceConfigured = createMemo(() => props.workspaces.length > 0);
|
||||
const showWorkspaceSetupEmptyState = createMemo(
|
||||
() =>
|
||||
@@ -3228,7 +3253,8 @@ export default function SessionView(props: SessionViewProps) {
|
||||
props.messages.length === 0 &&
|
||||
!showWorkspaceSetupEmptyState() &&
|
||||
!showSessionLoadingState() &&
|
||||
!deferSessionRender()
|
||||
!deferSessionRender() &&
|
||||
!showReactSessionSurface()
|
||||
}
|
||||
>
|
||||
<div class="text-center px-6 space-y-6">
|
||||
@@ -3272,88 +3298,106 @@ export default function SessionView(props: SessionViewProps) {
|
||||
when={!showDelayedSessionLoadingState() && !deferSessionRender()}
|
||||
>
|
||||
<Show
|
||||
when={
|
||||
hiddenMessageCount() > 0 || hasServerEarlierMessages()
|
||||
when={!showReactSessionSurface()}
|
||||
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">
|
||||
<button
|
||||
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"
|
||||
onClick={() => {
|
||||
void revealEarlierMessages();
|
||||
}}
|
||||
disabled={props.loadingEarlierMessages}
|
||||
>
|
||||
{props.loadingEarlierMessages
|
||||
? t("session.loading_earlier")
|
||||
: hiddenMessageCount() > 0
|
||||
? t("session.show_earlier", undefined, { count: nextRevealCount().toLocaleString(), plural: nextRevealCount() === 1 ? "" : "s" })
|
||||
: t("session.load_earlier")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
hiddenMessageCount() > 0 || hasServerEarlierMessages()
|
||||
}
|
||||
>
|
||||
<div class="mb-4 flex justify-center">
|
||||
<button
|
||||
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"
|
||||
onClick={() => {
|
||||
void revealEarlierMessages();
|
||||
}}
|
||||
disabled={props.loadingEarlierMessages}
|
||||
>
|
||||
{props.loadingEarlierMessages
|
||||
? t("session.loading_earlier")
|
||||
: 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}>
|
||||
<MessageList
|
||||
messages={batchedRenderedMessages()}
|
||||
isStreaming={showRunIndicator()}
|
||||
developerMode={props.developerMode}
|
||||
showThinking={showThinking()}
|
||||
getSessionById={props.getSessionById}
|
||||
getMessagesBySessionId={props.getMessagesBySessionId}
|
||||
ensureSessionLoaded={props.ensureSessionLoaded}
|
||||
sessionLoadingById={props.sessionLoadingById}
|
||||
workspaceRoot={props.selectedWorkspaceRoot}
|
||||
expandedStepIds={props.expandedStepIds}
|
||||
setExpandedStepIds={props.setExpandedStepIds}
|
||||
openSessionById={(sessionId) => {
|
||||
flushComposerDraft();
|
||||
props.setView("session", sessionId);
|
||||
}}
|
||||
searchMatchMessageIds={searchMatchMessageIds()}
|
||||
activeSearchMessageId={activeSearchHit()?.messageId ?? null}
|
||||
searchHighlightQuery={searchQueryDebounced().trim()}
|
||||
scrollElement={() => chatContainerEl}
|
||||
setScrollToMessageById={(handler) => {
|
||||
scrollMessageIntoViewById = handler;
|
||||
}}
|
||||
footer={
|
||||
showRunIndicator() && showFooterRunStatus() ? (
|
||||
<div class="flex justify-start">
|
||||
<div class="w-full max-w-[760px]">
|
||||
<div
|
||||
class={`mt-3 flex items-center gap-2 py-1 text-xs ${runPhase() === "error" ? "text-red-11" : "text-gray-9"}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span
|
||||
class={`truncate ${
|
||||
runPhase() === "thinking" ||
|
||||
runPhase() === "responding"
|
||||
? "animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
<Show when={batchedRenderedMessages().length > 0}>
|
||||
<MessageList
|
||||
messages={batchedRenderedMessages()}
|
||||
isStreaming={showRunIndicator()}
|
||||
developerMode={props.developerMode}
|
||||
showThinking={showThinking()}
|
||||
getSessionById={props.getSessionById}
|
||||
getMessagesBySessionId={props.getMessagesBySessionId}
|
||||
ensureSessionLoaded={props.ensureSessionLoaded}
|
||||
sessionLoadingById={props.sessionLoadingById}
|
||||
workspaceRoot={props.selectedWorkspaceRoot}
|
||||
expandedStepIds={props.expandedStepIds}
|
||||
setExpandedStepIds={props.setExpandedStepIds}
|
||||
openSessionById={(sessionId) => {
|
||||
flushComposerDraft();
|
||||
props.setView("session", sessionId);
|
||||
}}
|
||||
searchMatchMessageIds={searchMatchMessageIds()}
|
||||
activeSearchMessageId={activeSearchHit()?.messageId ?? null}
|
||||
searchHighlightQuery={searchQueryDebounced().trim()}
|
||||
scrollElement={() => chatContainerEl}
|
||||
setScrollToMessageById={(handler) => {
|
||||
scrollMessageIntoViewById = handler;
|
||||
}}
|
||||
footer={
|
||||
showRunIndicator() && showFooterRunStatus() ? (
|
||||
<div class="flex justify-start">
|
||||
<div class="w-full max-w-[760px]">
|
||||
<div
|
||||
class={`mt-3 flex items-center gap-2 py-1 text-xs ${runPhase() === "error" ? "text-red-11" : "text-gray-9"}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{thinkingStatus() || runLabel()}
|
||||
</span>
|
||||
<Show when={props.developerMode}>
|
||||
<span class="text-[10px] text-gray-8 ml-auto shrink-0">
|
||||
{runElapsedLabel()}
|
||||
<span
|
||||
class={`truncate ${
|
||||
runPhase() === "thinking" ||
|
||||
runPhase() === "responding"
|
||||
? "animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{thinkingStatus() || runLabel()}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.developerMode}>
|
||||
<span class="text-[10px] text-gray-8 ml-auto shrink-0">
|
||||
{runElapsedLabel()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</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="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())}>
|
||||
@@ -3454,7 +3498,7 @@ export default function SessionView(props: SessionViewProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!showWorkspaceSetupEmptyState()}>
|
||||
<Show when={!showWorkspaceSetupEmptyState() && !showReactSessionSurface()}>
|
||||
<Composer
|
||||
prompt={props.prompt}
|
||||
draftMode={composerDraftMode()}
|
||||
|
||||
21
apps/app/src/react/feature-flag.ts
Normal file
21
apps/app/src/react/feature-flag.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
45
apps/app/src/react/island.tsx
Normal file
45
apps/app/src/react/island.tsx
Normal 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} />;
|
||||
}
|
||||
23
apps/app/src/react/session/debug-panel.react.tsx
Normal file
23
apps/app/src/react/session/debug-panel.react.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
231
apps/app/src/react/session/session-surface.react.tsx
Normal file
231
apps/app/src/react/session/session-surface.react.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/app/src/react/session/transition-controller.ts
Normal file
61
apps/app/src/react/session/transition-controller.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import os from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import devtools from "solid-devtools/vite";
|
||||
import solid from "vite-plugin-solid";
|
||||
@@ -26,6 +27,7 @@ if (shortHostname && shortHostname !== hostname) {
|
||||
addHost(shortHostname);
|
||||
}
|
||||
const appRoot = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
||||
const reactFiles = /\.react\.[tj]sx?$/;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@@ -39,6 +41,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
tailwindcss(),
|
||||
react({ include: reactFiles }),
|
||||
devtools({
|
||||
autoname: true,
|
||||
// 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,
|
||||
},
|
||||
}),
|
||||
solid(),
|
||||
solid({ exclude: [reactFiles] }),
|
||||
],
|
||||
server: {
|
||||
port: devPort,
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
"type": "module",
|
||||
"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: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:x64": "node ./scripts/dev-windows.mjs x64",
|
||||
"build": "tauri build",
|
||||
"build:debug:react-session": "VITE_OPENWORK_REACT_SESSION=1 tauri build --debug",
|
||||
"prepare:sidecar": "node ./scripts/prepare-sidecar.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
"dependencies": {
|
||||
"jsonc-parser": "^3.2.1",
|
||||
"minimatch": "^10.0.1",
|
||||
"yaml": "^2.6.1"
|
||||
"yaml": "^2.6.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
|
||||
@@ -36,6 +36,7 @@ import { inheritWorkspaceOpencodeConnection, resolveWorkspaceOpencodeConnection
|
||||
import { fetchSharedBundle, publishSharedBundle } from "./share-bundles.js";
|
||||
import { seedOpencodeSessionMessages } from "./opencode-db.js";
|
||||
import { listPortableFiles, planPortableFiles, writePortableFiles } from "./portable-files.js";
|
||||
import { buildSession, buildSessionList, buildSessionMessages, buildSessionSnapshot, buildSessionStatuses, buildSessionTodos } from "./session-read-model.js";
|
||||
import {
|
||||
collectWorkspaceExportWarnings,
|
||||
stripSensitiveWorkspaceExportData,
|
||||
@@ -469,7 +470,14 @@ function buildOpencodeProxyUrl(baseUrl: string, path: string, search: string) {
|
||||
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 baseUrl = connection.baseUrl?.trim() ?? "";
|
||||
if (!baseUrl) {
|
||||
@@ -478,7 +486,18 @@ async function fetchOpencodeJson(config: ServerConfig, workspace: WorkspaceInfo,
|
||||
|
||||
const url = new URL(baseUrl);
|
||||
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();
|
||||
headers.set("Content-Type", "application/json");
|
||||
@@ -500,12 +519,7 @@ async function fetchOpencodeJson(config: ServerConfig, workspace: WorkspaceInfo,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let json: any = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
const json = parseJsonResponse(text);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(502, "opencode_request_failed", "OpenCode request failed", {
|
||||
status: response.status,
|
||||
@@ -1526,6 +1540,51 @@ function createRoutes(
|
||||
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) => {
|
||||
ensureWritable(config);
|
||||
requireClientScope(ctx, "collaborator");
|
||||
@@ -3581,7 +3640,7 @@ function createRoutes(
|
||||
const bundle = await fetchSharedBundle(body.bundleUrl, {
|
||||
timeoutMs: typeof body.timeoutMs === "number" ? body.timeoutMs : undefined,
|
||||
});
|
||||
return jsonResponse(bundle);
|
||||
return jsonResponse(bundle);
|
||||
});
|
||||
|
||||
addRoute(routes, "GET", "/approvals", "host", async (ctx) => {
|
||||
@@ -3601,6 +3660,125 @@ function createRoutes(
|
||||
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> {
|
||||
const workspace = config.workspaces.find((entry) => entry.id === id);
|
||||
if (!workspace) {
|
||||
@@ -3664,6 +3842,32 @@ function parseInteger(value: string | undefined): number | 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 {
|
||||
if (value.startsWith("~/")) {
|
||||
return join(homedir(), value.slice(2));
|
||||
@@ -4839,7 +5043,8 @@ async function materializeBlueprintSessions(config: ServerConfig, workspace: Wor
|
||||
method: "POST",
|
||||
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) {
|
||||
throw new ApiError(502, "opencode_failed", "OpenCode session did not return an id");
|
||||
}
|
||||
|
||||
256
apps/server/src/session-read-model.e2e.test.ts
Normal file
256
apps/server/src/session-read-model.e2e.test.ts
Normal 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",
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
141
apps/server/src/session-read-model.ts
Normal file
141
apps/server/src/session-read-model.ts
Normal 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
50
pnpm-lock.yaml
generated
@@ -52,6 +52,9 @@ importers:
|
||||
'@solidjs/router':
|
||||
specifier: ^0.15.4
|
||||
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':
|
||||
specifier: ^3.13.19
|
||||
version: 3.13.19(solid-js@1.9.9)
|
||||
@@ -88,6 +91,12 @@ importers:
|
||||
marked:
|
||||
specifier: ^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:
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.9
|
||||
@@ -98,6 +107,15 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
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))
|
||||
'@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:
|
||||
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))
|
||||
@@ -211,6 +229,9 @@ importers:
|
||||
yaml:
|
||||
specifier: ^2.6.1
|
||||
version: 2.8.2
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
'@types/minimatch':
|
||||
specifier: ^5.1.2
|
||||
@@ -2915,6 +2936,14 @@ packages:
|
||||
peerDependencies:
|
||||
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':
|
||||
resolution: {integrity: sha512-4uCuwY/nfQzoxuJCOroEGgRQkoDuzRE4wVdDq47InQ9WPQ+1dkBUyE7k7frcTPy+l1yjCCmLGdq9h9GQyofVHw==}
|
||||
peerDependencies:
|
||||
@@ -7749,6 +7778,13 @@ snapshots:
|
||||
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)
|
||||
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.19
|
||||
@@ -7892,7 +7928,7 @@ snapshots:
|
||||
|
||||
'@types/react-dom@18.2.25':
|
||||
dependencies:
|
||||
'@types/react': 18.2.79
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||
dependencies:
|
||||
@@ -7921,6 +7957,18 @@ snapshots:
|
||||
throttleit: 2.1.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))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
|
||||
620
prds/react-incremental-adoption.md
Normal file
620
prds/react-incremental-adoption.md
Normal 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.
|
||||
Reference in New Issue
Block a user