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/event-bus": "^1.1.2",
|
||||||
"@solid-primitives/storage": "^4.3.3",
|
"@solid-primitives/storage": "^4.3.3",
|
||||||
"@solidjs/router": "^0.15.4",
|
"@solidjs/router": "^0.15.4",
|
||||||
|
"@tanstack/react-query": "^5.90.3",
|
||||||
"@tanstack/solid-virtual": "^3.13.19",
|
"@tanstack/solid-virtual": "^3.13.19",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||||
@@ -58,9 +59,14 @@
|
|||||||
"jsonc-parser": "^3.2.1",
|
"jsonc-parser": "^3.2.1",
|
||||||
"lucide-solid": "^0.562.0",
|
"lucide-solid": "^0.562.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
"solid-js": "^1.9.0"
|
"solid-js": "^1.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.2",
|
||||||
|
"@types/react-dom": "^19.2.2",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"@solid-devtools/overlay": "^0.33.5",
|
"@solid-devtools/overlay": "^0.33.5",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"solid-devtools": "^0.34.5",
|
"solid-devtools": "^0.34.5",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
import { createOpencodeClient, type Message, type Part, type Session, type Todo } from "@opencode-ai/sdk/v2/client";
|
||||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||||
|
|
||||||
|
import { createOpenworkServerClient, OpenworkServerError } from "./openwork-server";
|
||||||
import { isTauriRuntime } from "../utils";
|
import { isTauriRuntime } from "../utils";
|
||||||
|
|
||||||
type FieldsResult<T> =
|
type FieldsResult<T> =
|
||||||
@@ -34,6 +35,25 @@ type CommandParameters = {
|
|||||||
reasoning_effort?: string;
|
reasoning_effort?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SessionListParameters = {
|
||||||
|
directory?: string;
|
||||||
|
roots?: boolean;
|
||||||
|
start?: number;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionLookupParameters = {
|
||||||
|
sessionID: string;
|
||||||
|
directory?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionMessagesParameters = {
|
||||||
|
sessionID: string;
|
||||||
|
directory?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type OpencodeAuth = {
|
export type OpencodeAuth = {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -112,6 +132,83 @@ async function postSessionRequest<T>(
|
|||||||
return { error, request, response };
|
return { error, request, response };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveOpenworkWorkspaceMount(baseUrl: string): { baseUrl: string; workspaceId: string } | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
const match = url.pathname.replace(/\/+$/, "").match(/^(.*\/w\/([^/]+))\/opencode$/);
|
||||||
|
if (!match?.[1] || !match[2]) return null;
|
||||||
|
url.pathname = match[1];
|
||||||
|
url.search = "";
|
||||||
|
return {
|
||||||
|
baseUrl: url.toString().replace(/\/+$/, ""),
|
||||||
|
workspaceId: decodeURIComponent(match[2]),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSyntheticResult<T>(
|
||||||
|
url: string,
|
||||||
|
method: string,
|
||||||
|
input:
|
||||||
|
| { ok: true; data: T; status?: number }
|
||||||
|
| { ok: false; error: unknown; status?: number },
|
||||||
|
): FieldsResult<T> {
|
||||||
|
const request = new Request(url, { method });
|
||||||
|
const response = new Response(input.ok ? JSON.stringify(input.data) : null, {
|
||||||
|
status: input.status ?? (input.ok ? 200 : 500),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
if (input.ok) {
|
||||||
|
return { data: input.data, request, response };
|
||||||
|
}
|
||||||
|
return { error: input.error, request, response };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wrapOpenworkRead<T>(
|
||||||
|
url: string,
|
||||||
|
read: () => Promise<T>,
|
||||||
|
options?: { throwOnError?: boolean },
|
||||||
|
): Promise<FieldsResult<T>> {
|
||||||
|
try {
|
||||||
|
return createSyntheticResult(url, "GET", { ok: true, data: await read() });
|
||||||
|
} catch (error) {
|
||||||
|
if (options?.throwOnError) throw error;
|
||||||
|
return createSyntheticResult(url, "GET", {
|
||||||
|
ok: false,
|
||||||
|
error,
|
||||||
|
status: error instanceof OpenworkServerError ? error.status : 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldFallbackToLegacySessionRead(error: unknown): boolean {
|
||||||
|
if (!(error instanceof OpenworkServerError)) return false;
|
||||||
|
return error.status === 404 || error.status === 405 || error.status === 501;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wrapOpenworkReadWithFallback<T>(
|
||||||
|
url: string,
|
||||||
|
read: () => Promise<T>,
|
||||||
|
fallback: () => Promise<FieldsResult<T>>,
|
||||||
|
options?: { throwOnError?: boolean },
|
||||||
|
): Promise<FieldsResult<T>> {
|
||||||
|
try {
|
||||||
|
return createSyntheticResult(url, "GET", { ok: true, data: await read() });
|
||||||
|
} catch (error) {
|
||||||
|
if (!shouldFallbackToLegacySessionRead(error)) {
|
||||||
|
if (options?.throwOnError) throw error;
|
||||||
|
return createSyntheticResult(url, "GET", {
|
||||||
|
ok: false,
|
||||||
|
error,
|
||||||
|
status: error instanceof OpenworkServerError ? error.status : 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchWithTimeout(
|
async function fetchWithTimeout(
|
||||||
fetchImpl: typeof globalThis.fetch,
|
fetchImpl: typeof globalThis.fetch,
|
||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
@@ -237,11 +334,88 @@ export function createClient(baseUrl: string, directory?: string, auth?: Opencod
|
|||||||
});
|
});
|
||||||
|
|
||||||
const session = client.session as typeof client.session;
|
const session = client.session as typeof client.session;
|
||||||
|
const openworkMount = auth?.mode === "openwork" ? resolveOpenworkWorkspaceMount(baseUrl) : null;
|
||||||
|
const openworkSessionClient =
|
||||||
|
openworkMount && auth?.token
|
||||||
|
? createOpenworkServerClient({ baseUrl: openworkMount.baseUrl, token: auth.token })
|
||||||
|
: null;
|
||||||
|
// TODO(2026-04-12): remove the old-server compatibility path here once all
|
||||||
|
// OpenWork servers expose the workspace-scoped session read APIs.
|
||||||
const sessionOverrides = session as any as {
|
const sessionOverrides = session as any as {
|
||||||
|
list: (parameters?: SessionListParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<Session[]>>;
|
||||||
|
get: (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<Session>>;
|
||||||
|
messages: (parameters: SessionMessagesParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<Array<{ info: Message; parts: Part[] }>>>;
|
||||||
|
todo: (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<Todo[]>>;
|
||||||
promptAsync: (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<{}>>;
|
promptAsync: (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<{}>>;
|
||||||
command: (parameters: CommandParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<{}>>;
|
command: (parameters: CommandParameters, options?: { throwOnError?: boolean }) => Promise<FieldsResult<{}>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const listOriginal = sessionOverrides.list.bind(session);
|
||||||
|
sessionOverrides.list = (parameters?: SessionListParameters, options?: { throwOnError?: boolean }) => {
|
||||||
|
if (!openworkMount || !openworkSessionClient) {
|
||||||
|
return listOriginal(parameters, options);
|
||||||
|
}
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (typeof parameters?.roots === "boolean") query.set("roots", String(parameters.roots));
|
||||||
|
if (typeof parameters?.start === "number") query.set("start", String(parameters.start));
|
||||||
|
if (parameters?.search?.trim()) query.set("search", parameters.search.trim());
|
||||||
|
if (typeof parameters?.limit === "number") query.set("limit", String(parameters.limit));
|
||||||
|
const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions${query.size ? `?${query.toString()}` : ""}`;
|
||||||
|
return wrapOpenworkReadWithFallback(
|
||||||
|
url,
|
||||||
|
async () => (await openworkSessionClient.listSessions(openworkMount.workspaceId, parameters)).items,
|
||||||
|
() => listOriginal(parameters, options),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOriginal = sessionOverrides.get.bind(session);
|
||||||
|
sessionOverrides.get = (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => {
|
||||||
|
if (!openworkMount || !openworkSessionClient) {
|
||||||
|
return getOriginal(parameters, options);
|
||||||
|
}
|
||||||
|
const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}`;
|
||||||
|
return wrapOpenworkReadWithFallback(
|
||||||
|
url,
|
||||||
|
async () => (await openworkSessionClient.getSession(openworkMount.workspaceId, parameters.sessionID)).item,
|
||||||
|
() => getOriginal(parameters, options),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const messagesOriginal = sessionOverrides.messages.bind(session);
|
||||||
|
sessionOverrides.messages = (parameters: SessionMessagesParameters, options?: { throwOnError?: boolean }) => {
|
||||||
|
if (!openworkMount || !openworkSessionClient) {
|
||||||
|
return messagesOriginal(parameters, options);
|
||||||
|
}
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (typeof parameters.limit === "number") query.set("limit", String(parameters.limit));
|
||||||
|
const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}/messages${query.size ? `?${query.toString()}` : ""}`;
|
||||||
|
return wrapOpenworkReadWithFallback(
|
||||||
|
url,
|
||||||
|
async () =>
|
||||||
|
(await openworkSessionClient.getSessionMessages(openworkMount.workspaceId, parameters.sessionID, {
|
||||||
|
limit: parameters.limit,
|
||||||
|
})).items,
|
||||||
|
() => messagesOriginal(parameters, options),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const todoOriginal = sessionOverrides.todo.bind(session);
|
||||||
|
sessionOverrides.todo = (parameters: SessionLookupParameters, options?: { throwOnError?: boolean }) => {
|
||||||
|
if (!openworkMount || !openworkSessionClient) {
|
||||||
|
return todoOriginal(parameters, options);
|
||||||
|
}
|
||||||
|
const url = `${openworkMount.baseUrl}/workspace/${encodeURIComponent(openworkMount.workspaceId)}/sessions/${encodeURIComponent(parameters.sessionID)}/snapshot`;
|
||||||
|
return wrapOpenworkReadWithFallback(
|
||||||
|
url,
|
||||||
|
async () => (await openworkSessionClient.getSessionSnapshot(openworkMount.workspaceId, parameters.sessionID)).item.todos,
|
||||||
|
() => todoOriginal(parameters, options),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const promptAsyncOriginal = sessionOverrides.promptAsync.bind(session);
|
const promptAsyncOriginal = sessionOverrides.promptAsync.bind(session);
|
||||||
sessionOverrides.promptAsync = (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => {
|
sessionOverrides.promptAsync = (parameters: PromptAsyncParameters, options?: { throwOnError?: boolean }) => {
|
||||||
if (!("reasoning_effort" in parameters)) {
|
if (!("reasoning_effort" in parameters)) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||||
|
import type { Message, Part, Session, Todo } from "@opencode-ai/sdk/v2/client";
|
||||||
import { isTauriRuntime } from "../utils";
|
import { isTauriRuntime } from "../utils";
|
||||||
import type { ExecResult, OpencodeConfigFile, ScheduledJob, WorkspaceInfo, WorkspaceList } from "./tauri";
|
import type { ExecResult, OpencodeConfigFile, ScheduledJob, WorkspaceInfo, WorkspaceList } from "./tauri";
|
||||||
|
|
||||||
@@ -105,6 +106,21 @@ export type OpenworkWorkspaceList = {
|
|||||||
activeId?: string | null;
|
activeId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OpenworkSessionMessage = {
|
||||||
|
info: Message;
|
||||||
|
parts: Part[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenworkSessionSnapshot = {
|
||||||
|
session: Session;
|
||||||
|
messages: OpenworkSessionMessage[];
|
||||||
|
todos: Todo[];
|
||||||
|
status:
|
||||||
|
| { type: "idle" }
|
||||||
|
| { type: "busy" }
|
||||||
|
| { type: "retry"; attempt: number; message: string; next: number };
|
||||||
|
};
|
||||||
|
|
||||||
export type OpenworkPluginItem = {
|
export type OpenworkPluginItem = {
|
||||||
spec: string;
|
spec: string;
|
||||||
source: "config" | "dir.project" | "dir.global";
|
source: "config" | "dir.project" | "dir.global";
|
||||||
@@ -912,6 +928,7 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
|||||||
activateWorkspace: 10_000,
|
activateWorkspace: 10_000,
|
||||||
deleteWorkspace: 10_000,
|
deleteWorkspace: 10_000,
|
||||||
deleteSession: 12_000,
|
deleteSession: 12_000,
|
||||||
|
sessionRead: 12_000,
|
||||||
status: 6_000,
|
status: 6_000,
|
||||||
config: 10_000,
|
config: 10_000,
|
||||||
opencodeRouter: 10_000,
|
opencodeRouter: 10_000,
|
||||||
@@ -985,6 +1002,48 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
|
|||||||
`/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`,
|
`/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
{ token, hostToken, method: "DELETE", timeoutMs: timeouts.deleteSession },
|
{ token, hostToken, method: "DELETE", timeoutMs: timeouts.deleteSession },
|
||||||
),
|
),
|
||||||
|
listSessions: (
|
||||||
|
workspaceId: string,
|
||||||
|
options?: { roots?: boolean; start?: number; search?: string; limit?: number },
|
||||||
|
) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (typeof options?.roots === "boolean") query.set("roots", String(options.roots));
|
||||||
|
if (typeof options?.start === "number") query.set("start", String(options.start));
|
||||||
|
if (options?.search?.trim()) query.set("search", options.search.trim());
|
||||||
|
if (typeof options?.limit === "number") query.set("limit", String(options.limit));
|
||||||
|
const suffix = query.size ? `?${query.toString()}` : "";
|
||||||
|
return requestJson<{ items: Session[] }>(
|
||||||
|
baseUrl,
|
||||||
|
`/workspace/${encodeURIComponent(workspaceId)}/sessions${suffix}`,
|
||||||
|
{ token, hostToken, timeoutMs: timeouts.sessionRead },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getSession: (workspaceId: string, sessionId: string) =>
|
||||||
|
requestJson<{ item: Session }>(
|
||||||
|
baseUrl,
|
||||||
|
`/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`,
|
||||||
|
{ token, hostToken, timeoutMs: timeouts.sessionRead },
|
||||||
|
),
|
||||||
|
getSessionMessages: (workspaceId: string, sessionId: string, options?: { limit?: number }) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (typeof options?.limit === "number") query.set("limit", String(options.limit));
|
||||||
|
const suffix = query.size ? `?${query.toString()}` : "";
|
||||||
|
return requestJson<{ items: OpenworkSessionMessage[] }>(
|
||||||
|
baseUrl,
|
||||||
|
`/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/messages${suffix}`,
|
||||||
|
{ token, hostToken, timeoutMs: timeouts.sessionRead },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getSessionSnapshot: (workspaceId: string, sessionId: string, options?: { limit?: number }) => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (typeof options?.limit === "number") query.set("limit", String(options.limit));
|
||||||
|
const suffix = query.size ? `?${query.toString()}` : "";
|
||||||
|
return requestJson<{ item: OpenworkSessionSnapshot }>(
|
||||||
|
baseUrl,
|
||||||
|
`/workspace/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/snapshot${suffix}`,
|
||||||
|
{ token, hostToken, timeoutMs: timeouts.sessionRead },
|
||||||
|
);
|
||||||
|
},
|
||||||
exportWorkspace: (
|
exportWorkspace: (
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
options?: { sensitiveMode?: OpenworkWorkspaceExportSensitiveMode },
|
options?: { sensitiveMode?: OpenworkWorkspaceExportSensitiveMode },
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import type {
|
|||||||
OpenworkServerSettings,
|
OpenworkServerSettings,
|
||||||
OpenworkServerStatus,
|
OpenworkServerStatus,
|
||||||
} from "../lib/openwork-server";
|
} from "../lib/openwork-server";
|
||||||
|
import { buildOpenworkWorkspaceBaseUrl } from "../lib/openwork-server";
|
||||||
import { join } from "@tauri-apps/api/path";
|
import { join } from "@tauri-apps/api/path";
|
||||||
import {
|
import {
|
||||||
isUserVisiblePart,
|
isUserVisiblePart,
|
||||||
@@ -111,6 +112,9 @@ import {
|
|||||||
saveSessionDraft,
|
saveSessionDraft,
|
||||||
sessionDraftScopeKey,
|
sessionDraftScopeKey,
|
||||||
} from "../session/draft-store";
|
} from "../session/draft-store";
|
||||||
|
import { ReactIsland } from "../../react/island";
|
||||||
|
import { reactSessionEnabled } from "../../react/feature-flag";
|
||||||
|
import { SessionSurface } from "../../react/session/session-surface.react";
|
||||||
|
|
||||||
export type SessionViewProps = {
|
export type SessionViewProps = {
|
||||||
selectedSessionId: string | null;
|
selectedSessionId: string | null;
|
||||||
@@ -2123,6 +2127,27 @@ export default function SessionView(props: SessionViewProps) {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const reactSessionOpencodeBaseUrl = createMemo(() => {
|
||||||
|
const workspaceId = props.runtimeWorkspaceId?.trim() ?? "";
|
||||||
|
const baseUrl = props.openworkServerClient?.baseUrl?.trim() ?? "";
|
||||||
|
if (!workspaceId || !baseUrl) return "";
|
||||||
|
const mounted = buildOpenworkWorkspaceBaseUrl(baseUrl, workspaceId) ?? baseUrl;
|
||||||
|
return `${mounted.replace(/\/+$/, "")}/opencode`;
|
||||||
|
});
|
||||||
|
const reactSessionToken = createMemo(
|
||||||
|
() => props.openworkServerClient?.token?.trim() || props.openworkServerSettings.token?.trim() || "",
|
||||||
|
);
|
||||||
|
const showReactSessionSurface = createMemo(
|
||||||
|
() =>
|
||||||
|
reactSessionEnabled() &&
|
||||||
|
Boolean(
|
||||||
|
props.selectedSessionId?.trim() &&
|
||||||
|
props.runtimeWorkspaceId?.trim() &&
|
||||||
|
props.openworkServerClient &&
|
||||||
|
reactSessionOpencodeBaseUrl() &&
|
||||||
|
reactSessionToken(),
|
||||||
|
),
|
||||||
|
);
|
||||||
const hasWorkspaceConfigured = createMemo(() => props.workspaces.length > 0);
|
const hasWorkspaceConfigured = createMemo(() => props.workspaces.length > 0);
|
||||||
const showWorkspaceSetupEmptyState = createMemo(
|
const showWorkspaceSetupEmptyState = createMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -3228,7 +3253,8 @@ export default function SessionView(props: SessionViewProps) {
|
|||||||
props.messages.length === 0 &&
|
props.messages.length === 0 &&
|
||||||
!showWorkspaceSetupEmptyState() &&
|
!showWorkspaceSetupEmptyState() &&
|
||||||
!showSessionLoadingState() &&
|
!showSessionLoadingState() &&
|
||||||
!deferSessionRender()
|
!deferSessionRender() &&
|
||||||
|
!showReactSessionSurface()
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="text-center px-6 space-y-6">
|
<div class="text-center px-6 space-y-6">
|
||||||
@@ -3272,88 +3298,106 @@ export default function SessionView(props: SessionViewProps) {
|
|||||||
when={!showDelayedSessionLoadingState() && !deferSessionRender()}
|
when={!showDelayedSessionLoadingState() && !deferSessionRender()}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={!showReactSessionSurface()}
|
||||||
hiddenMessageCount() > 0 || hasServerEarlierMessages()
|
fallback={
|
||||||
|
<ReactIsland
|
||||||
|
class="pb-4"
|
||||||
|
component={SessionSurface}
|
||||||
|
props={{
|
||||||
|
client: props.openworkServerClient!,
|
||||||
|
workspaceId: props.runtimeWorkspaceId!,
|
||||||
|
sessionId: props.selectedSessionId!,
|
||||||
|
opencodeBaseUrl: reactSessionOpencodeBaseUrl(),
|
||||||
|
openworkToken: reactSessionToken(),
|
||||||
|
developerMode: props.developerMode,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="mb-4 flex justify-center">
|
<Show
|
||||||
<button
|
when={
|
||||||
type="button"
|
hiddenMessageCount() > 0 || hasServerEarlierMessages()
|
||||||
class="rounded-full border border-dls-border bg-dls-hover/70 px-3 py-1 text-xs text-dls-secondary transition-colors hover:bg-dls-active hover:text-dls-text"
|
}
|
||||||
onClick={() => {
|
>
|
||||||
void revealEarlierMessages();
|
<div class="mb-4 flex justify-center">
|
||||||
}}
|
<button
|
||||||
disabled={props.loadingEarlierMessages}
|
type="button"
|
||||||
>
|
class="rounded-full border border-dls-border bg-dls-hover/70 px-3 py-1 text-xs text-dls-secondary transition-colors hover:bg-dls-active hover:text-dls-text"
|
||||||
{props.loadingEarlierMessages
|
onClick={() => {
|
||||||
? t("session.loading_earlier")
|
void revealEarlierMessages();
|
||||||
: hiddenMessageCount() > 0
|
}}
|
||||||
? t("session.show_earlier", undefined, { count: nextRevealCount().toLocaleString(), plural: nextRevealCount() === 1 ? "" : "s" })
|
disabled={props.loadingEarlierMessages}
|
||||||
: t("session.load_earlier")}
|
>
|
||||||
</button>
|
{props.loadingEarlierMessages
|
||||||
</div>
|
? t("session.loading_earlier")
|
||||||
</Show>
|
: hiddenMessageCount() > 0
|
||||||
|
? t("session.show_earlier", undefined, { count: nextRevealCount().toLocaleString(), plural: nextRevealCount() === 1 ? "" : "s" })
|
||||||
|
: t("session.load_earlier")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={batchedRenderedMessages().length > 0}>
|
<Show when={batchedRenderedMessages().length > 0}>
|
||||||
<MessageList
|
<MessageList
|
||||||
messages={batchedRenderedMessages()}
|
messages={batchedRenderedMessages()}
|
||||||
isStreaming={showRunIndicator()}
|
isStreaming={showRunIndicator()}
|
||||||
developerMode={props.developerMode}
|
developerMode={props.developerMode}
|
||||||
showThinking={showThinking()}
|
showThinking={showThinking()}
|
||||||
getSessionById={props.getSessionById}
|
getSessionById={props.getSessionById}
|
||||||
getMessagesBySessionId={props.getMessagesBySessionId}
|
getMessagesBySessionId={props.getMessagesBySessionId}
|
||||||
ensureSessionLoaded={props.ensureSessionLoaded}
|
ensureSessionLoaded={props.ensureSessionLoaded}
|
||||||
sessionLoadingById={props.sessionLoadingById}
|
sessionLoadingById={props.sessionLoadingById}
|
||||||
workspaceRoot={props.selectedWorkspaceRoot}
|
workspaceRoot={props.selectedWorkspaceRoot}
|
||||||
expandedStepIds={props.expandedStepIds}
|
expandedStepIds={props.expandedStepIds}
|
||||||
setExpandedStepIds={props.setExpandedStepIds}
|
setExpandedStepIds={props.setExpandedStepIds}
|
||||||
openSessionById={(sessionId) => {
|
openSessionById={(sessionId) => {
|
||||||
flushComposerDraft();
|
flushComposerDraft();
|
||||||
props.setView("session", sessionId);
|
props.setView("session", sessionId);
|
||||||
}}
|
}}
|
||||||
searchMatchMessageIds={searchMatchMessageIds()}
|
searchMatchMessageIds={searchMatchMessageIds()}
|
||||||
activeSearchMessageId={activeSearchHit()?.messageId ?? null}
|
activeSearchMessageId={activeSearchHit()?.messageId ?? null}
|
||||||
searchHighlightQuery={searchQueryDebounced().trim()}
|
searchHighlightQuery={searchQueryDebounced().trim()}
|
||||||
scrollElement={() => chatContainerEl}
|
scrollElement={() => chatContainerEl}
|
||||||
setScrollToMessageById={(handler) => {
|
setScrollToMessageById={(handler) => {
|
||||||
scrollMessageIntoViewById = handler;
|
scrollMessageIntoViewById = handler;
|
||||||
}}
|
}}
|
||||||
footer={
|
footer={
|
||||||
showRunIndicator() && showFooterRunStatus() ? (
|
showRunIndicator() && showFooterRunStatus() ? (
|
||||||
<div class="flex justify-start">
|
<div class="flex justify-start">
|
||||||
<div class="w-full max-w-[760px]">
|
<div class="w-full max-w-[760px]">
|
||||||
<div
|
<div
|
||||||
class={`mt-3 flex items-center gap-2 py-1 text-xs ${runPhase() === "error" ? "text-red-11" : "text-gray-9"}`}
|
class={`mt-3 flex items-center gap-2 py-1 text-xs ${runPhase() === "error" ? "text-red-11" : "text-gray-9"}`}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
|
||||||
<span
|
|
||||||
class={`truncate ${
|
|
||||||
runPhase() === "thinking" ||
|
|
||||||
runPhase() === "responding"
|
|
||||||
? "animate-pulse"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{thinkingStatus() || runLabel()}
|
<span
|
||||||
</span>
|
class={`truncate ${
|
||||||
<Show when={props.developerMode}>
|
runPhase() === "thinking" ||
|
||||||
<span class="text-[10px] text-gray-8 ml-auto shrink-0">
|
runPhase() === "responding"
|
||||||
{runElapsedLabel()}
|
? "animate-pulse"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{thinkingStatus() || runLabel()}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
<Show when={props.developerMode}>
|
||||||
|
<span class="text-[10px] text-gray-8 ml-auto shrink-0">
|
||||||
|
{runElapsedLabel()}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : undefined
|
||||||
) : undefined
|
}
|
||||||
}
|
/>
|
||||||
/>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={!showDelayedSessionLoadingState() && !deferSessionRender() && props.messages.length > 0 && !jumpControlsSuppressed() && (!sessionScroll.isAtBottom() || Boolean(sessionScroll.topClippedMessageId()))}>
|
<Show when={!showReactSessionSurface() && !showDelayedSessionLoadingState() && !deferSessionRender() && props.messages.length > 0 && !jumpControlsSuppressed() && (!sessionScroll.isAtBottom() || Boolean(sessionScroll.topClippedMessageId()))}>
|
||||||
<div class="absolute bottom-4 left-0 right-0 z-20 flex justify-center pointer-events-none">
|
<div class="absolute bottom-4 left-0 right-0 z-20 flex justify-center pointer-events-none">
|
||||||
<div class="pointer-events-auto flex items-center gap-2 rounded-full border border-dls-border bg-dls-surface/95 p-1 shadow-[var(--dls-card-shadow)] backdrop-blur-md">
|
<div class="pointer-events-auto flex items-center gap-2 rounded-full border border-dls-border bg-dls-surface/95 p-1 shadow-[var(--dls-card-shadow)] backdrop-blur-md">
|
||||||
<Show when={Boolean(sessionScroll.topClippedMessageId())}>
|
<Show when={Boolean(sessionScroll.topClippedMessageId())}>
|
||||||
@@ -3454,7 +3498,7 @@ export default function SessionView(props: SessionViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!showWorkspaceSetupEmptyState()}>
|
<Show when={!showWorkspaceSetupEmptyState() && !showReactSessionSurface()}>
|
||||||
<Composer
|
<Composer
|
||||||
prompt={props.prompt}
|
prompt={props.prompt}
|
||||||
draftMode={composerDraftMode()}
|
draftMode={composerDraftMode()}
|
||||||
|
|||||||
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 { resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import devtools from "solid-devtools/vite";
|
import devtools from "solid-devtools/vite";
|
||||||
import solid from "vite-plugin-solid";
|
import solid from "vite-plugin-solid";
|
||||||
@@ -26,6 +27,7 @@ if (shortHostname && shortHostname !== hostname) {
|
|||||||
addHost(shortHostname);
|
addHost(shortHostname);
|
||||||
}
|
}
|
||||||
const appRoot = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
const appRoot = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
||||||
|
const reactFiles = /\.react\.[tj]sx?$/;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -39,6 +41,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
react({ include: reactFiles }),
|
||||||
devtools({
|
devtools({
|
||||||
autoname: true,
|
autoname: true,
|
||||||
// jsxLocation is required for in-page locator: map DOM → Solid components (hold Option/Alt while hovering).
|
// jsxLocation is required for in-page locator: map DOM → Solid components (hold Option/Alt while hovering).
|
||||||
@@ -48,7 +51,7 @@ export default defineConfig({
|
|||||||
componentLocation: true,
|
componentLocation: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
solid(),
|
solid({ exclude: [reactFiles] }),
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
port: devPort,
|
port: devPort,
|
||||||
|
|||||||
@@ -6,9 +6,11 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "OPENWORK_DEV_MODE=1 OPENWORK_DATA_DIR=\"$HOME/.openwork/openwork-orchestrator-dev\" tauri dev --config src-tauri/tauri.dev.conf.json --config \"{\\\"build\\\":{\\\"devUrl\\\":\\\"http://localhost:${PORT:-5173}\\\"}}\"",
|
"dev": "OPENWORK_DEV_MODE=1 OPENWORK_DATA_DIR=\"$HOME/.openwork/openwork-orchestrator-dev\" tauri dev --config src-tauri/tauri.dev.conf.json --config \"{\\\"build\\\":{\\\"devUrl\\\":\\\"http://localhost:${PORT:-5173}\\\"}}\"",
|
||||||
|
"dev:react-session": "OPENWORK_DEV_MODE=1 VITE_OPENWORK_REACT_SESSION=1 OPENWORK_DATA_DIR=\"$HOME/.openwork/openwork-orchestrator-dev-react\" tauri dev --config src-tauri/tauri.dev.conf.json --config \"{\\\"build\\\":{\\\"devUrl\\\":\\\"http://localhost:${PORT:-5173}\\\"}}\"",
|
||||||
"dev:windows": "node ./scripts/dev-windows.mjs",
|
"dev:windows": "node ./scripts/dev-windows.mjs",
|
||||||
"dev:windows:x64": "node ./scripts/dev-windows.mjs x64",
|
"dev:windows:x64": "node ./scripts/dev-windows.mjs x64",
|
||||||
"build": "tauri build",
|
"build": "tauri build",
|
||||||
|
"build:debug:react-session": "VITE_OPENWORK_REACT_SESSION=1 tauri build --debug",
|
||||||
"prepare:sidecar": "node ./scripts/prepare-sidecar.mjs"
|
"prepare:sidecar": "node ./scripts/prepare-sidecar.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jsonc-parser": "^3.2.1",
|
"jsonc-parser": "^3.2.1",
|
||||||
"minimatch": "^10.0.1",
|
"minimatch": "^10.0.1",
|
||||||
"yaml": "^2.6.1"
|
"yaml": "^2.6.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { inheritWorkspaceOpencodeConnection, resolveWorkspaceOpencodeConnection
|
|||||||
import { fetchSharedBundle, publishSharedBundle } from "./share-bundles.js";
|
import { fetchSharedBundle, publishSharedBundle } from "./share-bundles.js";
|
||||||
import { seedOpencodeSessionMessages } from "./opencode-db.js";
|
import { seedOpencodeSessionMessages } from "./opencode-db.js";
|
||||||
import { listPortableFiles, planPortableFiles, writePortableFiles } from "./portable-files.js";
|
import { listPortableFiles, planPortableFiles, writePortableFiles } from "./portable-files.js";
|
||||||
|
import { buildSession, buildSessionList, buildSessionMessages, buildSessionSnapshot, buildSessionStatuses, buildSessionTodos } from "./session-read-model.js";
|
||||||
import {
|
import {
|
||||||
collectWorkspaceExportWarnings,
|
collectWorkspaceExportWarnings,
|
||||||
stripSensitiveWorkspaceExportData,
|
stripSensitiveWorkspaceExportData,
|
||||||
@@ -469,7 +470,14 @@ function buildOpencodeProxyUrl(baseUrl: string, path: string, search: string) {
|
|||||||
return target.toString();
|
return target.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchOpencodeJson(config: ServerConfig, workspace: WorkspaceInfo, path: string, init: { method: string; body?: unknown }) {
|
type OpencodeQueryValue = string | number | boolean | null | undefined;
|
||||||
|
|
||||||
|
async function fetchOpencodeJson(
|
||||||
|
config: ServerConfig,
|
||||||
|
workspace: WorkspaceInfo,
|
||||||
|
path: string,
|
||||||
|
init: { method: string; body?: unknown; query?: URLSearchParams | Record<string, OpencodeQueryValue> },
|
||||||
|
) {
|
||||||
const connection = resolveWorkspaceOpencodeConnection(config, workspace);
|
const connection = resolveWorkspaceOpencodeConnection(config, workspace);
|
||||||
const baseUrl = connection.baseUrl?.trim() ?? "";
|
const baseUrl = connection.baseUrl?.trim() ?? "";
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
@@ -478,7 +486,18 @@ async function fetchOpencodeJson(config: ServerConfig, workspace: WorkspaceInfo,
|
|||||||
|
|
||||||
const url = new URL(baseUrl);
|
const url = new URL(baseUrl);
|
||||||
url.pathname = path.startsWith("/") ? path : `/${path}`;
|
url.pathname = path.startsWith("/") ? path : `/${path}`;
|
||||||
url.search = "";
|
if (init.query instanceof URLSearchParams) {
|
||||||
|
url.search = init.query.toString();
|
||||||
|
} else if (init.query) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(init.query)) {
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
url.search = params.toString();
|
||||||
|
} else {
|
||||||
|
url.search = "";
|
||||||
|
}
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
@@ -500,12 +519,7 @@ async function fetchOpencodeJson(config: ServerConfig, workspace: WorkspaceInfo,
|
|||||||
});
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
let json: any = null;
|
const json = parseJsonResponse(text);
|
||||||
try {
|
|
||||||
json = text ? JSON.parse(text) : null;
|
|
||||||
} catch {
|
|
||||||
json = null;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ApiError(502, "opencode_request_failed", "OpenCode request failed", {
|
throw new ApiError(502, "opencode_request_failed", "OpenCode request failed", {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
@@ -1526,6 +1540,51 @@ function createRoutes(
|
|||||||
return jsonResponse({ items });
|
return jsonResponse({ items });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
addRoute(routes, "GET", "/workspace/:id/sessions", "client", async (ctx) => {
|
||||||
|
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||||
|
const items = await listWorkspaceSessions(config, workspace, {
|
||||||
|
roots: parseOptionalBoolean(ctx.url.searchParams.get("roots"), "roots"),
|
||||||
|
start: parseOptionalNonNegativeInteger(ctx.url.searchParams.get("start"), "start"),
|
||||||
|
search: ctx.url.searchParams.get("search")?.trim() || undefined,
|
||||||
|
limit: parseOptionalPositiveInteger(ctx.url.searchParams.get("limit"), "limit"),
|
||||||
|
});
|
||||||
|
return jsonResponse({ items });
|
||||||
|
});
|
||||||
|
|
||||||
|
addRoute(routes, "GET", "/workspace/:id/sessions/:sessionId", "client", async (ctx) => {
|
||||||
|
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||||
|
const sessionId = (ctx.params.sessionId ?? "").trim();
|
||||||
|
if (!sessionId) {
|
||||||
|
throw new ApiError(400, "invalid_payload", "sessionId is required");
|
||||||
|
}
|
||||||
|
const item = await readWorkspaceSession(config, workspace, sessionId);
|
||||||
|
return jsonResponse({ item });
|
||||||
|
});
|
||||||
|
|
||||||
|
addRoute(routes, "GET", "/workspace/:id/sessions/:sessionId/messages", "client", async (ctx) => {
|
||||||
|
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||||
|
const sessionId = (ctx.params.sessionId ?? "").trim();
|
||||||
|
if (!sessionId) {
|
||||||
|
throw new ApiError(400, "invalid_payload", "sessionId is required");
|
||||||
|
}
|
||||||
|
const items = await readWorkspaceSessionMessages(config, workspace, sessionId, {
|
||||||
|
limit: parseOptionalPositiveInteger(ctx.url.searchParams.get("limit"), "limit"),
|
||||||
|
});
|
||||||
|
return jsonResponse({ items });
|
||||||
|
});
|
||||||
|
|
||||||
|
addRoute(routes, "GET", "/workspace/:id/sessions/:sessionId/snapshot", "client", async (ctx) => {
|
||||||
|
const workspace = await resolveWorkspace(config, ctx.params.id);
|
||||||
|
const sessionId = (ctx.params.sessionId ?? "").trim();
|
||||||
|
if (!sessionId) {
|
||||||
|
throw new ApiError(400, "invalid_payload", "sessionId is required");
|
||||||
|
}
|
||||||
|
const item = await readWorkspaceSessionSnapshot(config, workspace, sessionId, {
|
||||||
|
limit: parseOptionalPositiveInteger(ctx.url.searchParams.get("limit"), "limit"),
|
||||||
|
});
|
||||||
|
return jsonResponse({ item });
|
||||||
|
});
|
||||||
|
|
||||||
addRoute(routes, "DELETE", "/workspace/:id/sessions/:sessionId", "client", async (ctx) => {
|
addRoute(routes, "DELETE", "/workspace/:id/sessions/:sessionId", "client", async (ctx) => {
|
||||||
ensureWritable(config);
|
ensureWritable(config);
|
||||||
requireClientScope(ctx, "collaborator");
|
requireClientScope(ctx, "collaborator");
|
||||||
@@ -3581,7 +3640,7 @@ function createRoutes(
|
|||||||
const bundle = await fetchSharedBundle(body.bundleUrl, {
|
const bundle = await fetchSharedBundle(body.bundleUrl, {
|
||||||
timeoutMs: typeof body.timeoutMs === "number" ? body.timeoutMs : undefined,
|
timeoutMs: typeof body.timeoutMs === "number" ? body.timeoutMs : undefined,
|
||||||
});
|
});
|
||||||
return jsonResponse(bundle);
|
return jsonResponse(bundle);
|
||||||
});
|
});
|
||||||
|
|
||||||
addRoute(routes, "GET", "/approvals", "host", async (ctx) => {
|
addRoute(routes, "GET", "/approvals", "host", async (ctx) => {
|
||||||
@@ -3601,6 +3660,125 @@ function createRoutes(
|
|||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function remapSessionReadError(error: unknown): never {
|
||||||
|
if (error instanceof ApiError && error.code === "opencode_request_failed") {
|
||||||
|
const details = error.details;
|
||||||
|
const upstreamStatus =
|
||||||
|
details && typeof details === "object" && "status" in details ? Number((details as { status?: unknown }).status) : NaN;
|
||||||
|
if (upstreamStatus === 400) {
|
||||||
|
throw new ApiError(400, "invalid_query", "OpenCode rejected the session read request", details);
|
||||||
|
}
|
||||||
|
if (upstreamStatus === 404) {
|
||||||
|
throw new ApiError(404, "session_not_found", "Session not found", details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listWorkspaceSessions(
|
||||||
|
config: ServerConfig,
|
||||||
|
workspace: WorkspaceInfo,
|
||||||
|
input: { roots?: boolean; start?: number; search?: string; limit?: number },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return buildSessionList(
|
||||||
|
await fetchOpencodeJson(config, workspace, "/session", {
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
roots: input.roots,
|
||||||
|
start: input.start,
|
||||||
|
search: input.search,
|
||||||
|
limit: input.limit,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
remapSessionReadError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWorkspaceSession(config: ServerConfig, workspace: WorkspaceInfo, sessionId: string) {
|
||||||
|
try {
|
||||||
|
return buildSession(
|
||||||
|
await fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}`, {
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
remapSessionReadError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWorkspaceSessionMessages(
|
||||||
|
config: ServerConfig,
|
||||||
|
workspace: WorkspaceInfo,
|
||||||
|
sessionId: string,
|
||||||
|
input: { limit?: number },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return buildSessionMessages(
|
||||||
|
await fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}/message`, {
|
||||||
|
method: "GET",
|
||||||
|
query: { limit: input.limit },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
remapSessionReadError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWorkspaceSessionTodos(config: ServerConfig, workspace: WorkspaceInfo, sessionId: string) {
|
||||||
|
try {
|
||||||
|
return buildSessionTodos(
|
||||||
|
await fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}/todo`, {
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
remapSessionReadError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWorkspaceSessionStatuses(config: ServerConfig, workspace: WorkspaceInfo) {
|
||||||
|
try {
|
||||||
|
return buildSessionStatuses(
|
||||||
|
await fetchOpencodeJson(config, workspace, "/session/status", {
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
remapSessionReadError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWorkspaceSessionSnapshot(
|
||||||
|
config: ServerConfig,
|
||||||
|
workspace: WorkspaceInfo,
|
||||||
|
sessionId: string,
|
||||||
|
input: { limit?: number },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const [session, messages, todos, statuses] = await Promise.all([
|
||||||
|
fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}`, {
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}/message`, {
|
||||||
|
method: "GET",
|
||||||
|
query: { limit: input.limit },
|
||||||
|
}),
|
||||||
|
fetchOpencodeJson(config, workspace, `/session/${encodeURIComponent(sessionId)}/todo`, {
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
fetchOpencodeJson(config, workspace, "/session/status", {
|
||||||
|
method: "GET",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return buildSessionSnapshot({ session, messages, todos, statuses });
|
||||||
|
} catch (error) {
|
||||||
|
remapSessionReadError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveWorkspace(config: ServerConfig, id: string): Promise<WorkspaceInfo> {
|
async function resolveWorkspace(config: ServerConfig, id: string): Promise<WorkspaceInfo> {
|
||||||
const workspace = config.workspaces.find((entry) => entry.id === id);
|
const workspace = config.workspaces.find((entry) => entry.id === id);
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
@@ -3664,6 +3842,32 @@ function parseInteger(value: string | undefined): number | null {
|
|||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOptionalPositiveInteger(value: string | null, name: string): number | undefined {
|
||||||
|
if (value === null) return undefined;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
throw new ApiError(400, "invalid_query", `${name} must be a positive integer`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalNonNegativeInteger(value: string | null, name: string): number | undefined {
|
||||||
|
if (value === null) return undefined;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
|
throw new ApiError(400, "invalid_query", `${name} must be a non-negative integer`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalBoolean(value: string | null, name: string): boolean | undefined {
|
||||||
|
if (value === null) return undefined;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
||||||
|
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
||||||
|
throw new ApiError(400, "invalid_query", `${name} must be a boolean`);
|
||||||
|
}
|
||||||
|
|
||||||
function expandHome(value: string): string {
|
function expandHome(value: string): string {
|
||||||
if (value.startsWith("~/")) {
|
if (value.startsWith("~/")) {
|
||||||
return join(homedir(), value.slice(2));
|
return join(homedir(), value.slice(2));
|
||||||
@@ -4839,7 +5043,8 @@ async function materializeBlueprintSessions(config: ServerConfig, workspace: Wor
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: template.title ? { title: template.title } : undefined,
|
body: template.title ? { title: template.title } : undefined,
|
||||||
});
|
});
|
||||||
const sessionId = typeof result?.id === "string" ? result.id.trim() : "";
|
const sessionId =
|
||||||
|
result && typeof result === "object" && "id" in result && typeof result.id === "string" ? result.id.trim() : "";
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
throw new ApiError(502, "opencode_failed", "OpenCode session did not return an id");
|
throw new ApiError(502, "opencode_failed", "OpenCode session did not return an id");
|
||||||
}
|
}
|
||||||
|
|||||||
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':
|
'@solidjs/router':
|
||||||
specifier: ^0.15.4
|
specifier: ^0.15.4
|
||||||
version: 0.15.4(patch_hash=1db11a7c28fe4da76187d42efaffc6b9a70ad370462fffb794ff90e67744d770)(solid-js@1.9.9)
|
version: 0.15.4(patch_hash=1db11a7c28fe4da76187d42efaffc6b9a70ad370462fffb794ff90e67744d770)(solid-js@1.9.9)
|
||||||
|
'@tanstack/react-query':
|
||||||
|
specifier: ^5.90.3
|
||||||
|
version: 5.96.2(react@19.2.4)
|
||||||
'@tanstack/solid-virtual':
|
'@tanstack/solid-virtual':
|
||||||
specifier: ^3.13.19
|
specifier: ^3.13.19
|
||||||
version: 3.13.19(solid-js@1.9.9)
|
version: 3.13.19(solid-js@1.9.9)
|
||||||
@@ -88,6 +91,12 @@ importers:
|
|||||||
marked:
|
marked:
|
||||||
specifier: ^17.0.1
|
specifier: ^17.0.1
|
||||||
version: 17.0.1
|
version: 17.0.1
|
||||||
|
react:
|
||||||
|
specifier: ^19.1.1
|
||||||
|
version: 19.2.4
|
||||||
|
react-dom:
|
||||||
|
specifier: ^19.1.1
|
||||||
|
version: 19.2.4(react@19.2.4)
|
||||||
solid-js:
|
solid-js:
|
||||||
specifier: ^1.9.0
|
specifier: ^1.9.0
|
||||||
version: 1.9.9
|
version: 1.9.9
|
||||||
@@ -98,6 +107,15 @@ importers:
|
|||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.18
|
specifier: ^4.1.18
|
||||||
version: 4.1.18(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
|
version: 4.1.18(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^19.2.2
|
||||||
|
version: 19.2.14
|
||||||
|
'@types/react-dom':
|
||||||
|
specifier: ^19.2.2
|
||||||
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
|
'@vitejs/plugin-react':
|
||||||
|
specifier: ^5.0.4
|
||||||
|
version: 5.2.0(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
solid-devtools:
|
solid-devtools:
|
||||||
specifier: ^0.34.5
|
specifier: ^0.34.5
|
||||||
version: 0.34.5(solid-js@1.9.9)(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
|
version: 0.34.5(solid-js@1.9.9)(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
@@ -211,6 +229,9 @@ importers:
|
|||||||
yaml:
|
yaml:
|
||||||
specifier: ^2.6.1
|
specifier: ^2.6.1
|
||||||
version: 2.8.2
|
version: 2.8.2
|
||||||
|
zod:
|
||||||
|
specifier: ^4.3.6
|
||||||
|
version: 4.3.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/minimatch':
|
'@types/minimatch':
|
||||||
specifier: ^5.1.2
|
specifier: ^5.1.2
|
||||||
@@ -2915,6 +2936,14 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^5.2.0 || ^6 || ^7
|
vite: ^5.2.0 || ^6 || ^7
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.96.2':
|
||||||
|
resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==}
|
||||||
|
|
||||||
|
'@tanstack/react-query@5.96.2':
|
||||||
|
resolution: {integrity: sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18 || ^19
|
||||||
|
|
||||||
'@tanstack/solid-virtual@3.13.19':
|
'@tanstack/solid-virtual@3.13.19':
|
||||||
resolution: {integrity: sha512-4uCuwY/nfQzoxuJCOroEGgRQkoDuzRE4wVdDq47InQ9WPQ+1dkBUyE7k7frcTPy+l1yjCCmLGdq9h9GQyofVHw==}
|
resolution: {integrity: sha512-4uCuwY/nfQzoxuJCOroEGgRQkoDuzRE4wVdDq47InQ9WPQ+1dkBUyE7k7frcTPy+l1yjCCmLGdq9h9GQyofVHw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -7749,6 +7778,13 @@ snapshots:
|
|||||||
tailwindcss: 4.1.18
|
tailwindcss: 4.1.18
|
||||||
vite: 6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
|
vite: 6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.96.2': {}
|
||||||
|
|
||||||
|
'@tanstack/react-query@5.96.2(react@19.2.4)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-core': 5.96.2
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
'@tanstack/solid-virtual@3.13.19(solid-js@1.9.9)':
|
'@tanstack/solid-virtual@3.13.19(solid-js@1.9.9)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/virtual-core': 3.13.19
|
'@tanstack/virtual-core': 3.13.19
|
||||||
@@ -7892,7 +7928,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/react-dom@18.2.25':
|
'@types/react-dom@18.2.25':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 18.2.79
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7921,6 +7957,18 @@ snapshots:
|
|||||||
throttleit: 2.1.0
|
throttleit: 2.1.0
|
||||||
undici: 5.29.0
|
undici: 5.29.0
|
||||||
|
|
||||||
|
'@vitejs/plugin-react@5.2.0(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
|
||||||
|
dependencies:
|
||||||
|
'@babel/core': 7.29.0
|
||||||
|
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
||||||
|
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
|
||||||
|
'@rolldown/pluginutils': 1.0.0-rc.3
|
||||||
|
'@types/babel__core': 7.20.5
|
||||||
|
react-refresh: 0.18.0
|
||||||
|
vite: 6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
|
'@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
|
|||||||
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