Merge origin/main into NicholasKissel/yeosu-ui-updates

Resolve conflicts keeping our UI refinements (subtle focus border,
SendHorizonal icon, single-line task cards, drag-to-reorder, header styling).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicholas Kissel 2026-03-11 01:50:06 -07:00
commit c8f4cb9ef9
15 changed files with 2345 additions and 25 deletions

View file

@ -10,6 +10,7 @@ import {
initCreateSessionActivity,
initEnsureAgentActivity,
initEnsureNameActivity,
initExposeSandboxActivity,
initFailedActivity,
initStartSandboxInstanceActivity,
initStartStatusSyncActivity,
@ -90,6 +91,10 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
timeout: 60_000,
run: async () => initStartSandboxInstanceActivity(loopCtx, body, sandbox, agent),
});
await loopCtx.step(
"init-expose-sandbox",
async () => initExposeSandboxActivity(loopCtx, body, sandbox, sandboxInstanceReady),
);
const session = await loopCtx.step({
name: "init-create-session",
timeout: 180_000,

View file

@ -376,6 +376,62 @@ export async function initCreateSessionActivity(loopCtx: any, body: any, sandbox
});
}
export async function initExposeSandboxActivity(
loopCtx: any,
body: any,
sandbox: any,
sandboxInstanceReady?: { actorId?: string | null }
): Promise<void> {
const providerId = body?.providerId ?? loopCtx.state.providerId;
const now = Date.now();
const db = loopCtx.db;
const activeCwd =
sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string"
? ((sandbox.metadata as any).cwd as string)
: null;
const sandboxActorId =
typeof sandboxInstanceReady?.actorId === "string" && sandboxInstanceReady.actorId.length > 0
? sandboxInstanceReady.actorId
: null;
await db
.insert(handoffSandboxes)
.values({
sandboxId: sandbox.sandboxId,
providerId,
sandboxActorId,
switchTarget: sandbox.switchTarget,
cwd: activeCwd,
statusMessage: "sandbox ready",
createdAt: now,
updatedAt: now
})
.onConflictDoUpdate({
target: handoffSandboxes.sandboxId,
set: {
providerId,
sandboxActorId,
switchTarget: sandbox.switchTarget,
cwd: activeCwd,
statusMessage: "sandbox ready",
updatedAt: now
}
})
.run();
await db
.update(handoffRuntime)
.set({
activeSandboxId: sandbox.sandboxId,
activeSwitchTarget: sandbox.switchTarget,
activeCwd,
statusMessage: "sandbox ready",
updatedAt: now
})
.where(eq(handoffRuntime.id, HANDOFF_ROW_ID))
.run();
}
export async function initWriteDbActivity(
loopCtx: any,
body: any,

View file

@ -3,7 +3,15 @@ import { eq } from "drizzle-orm";
import { actor, queue } from "rivetkit";
import { Loop, workflow } from "rivetkit/workflow";
import type { ProviderId } from "@openhandoff/shared";
import type { SessionEvent, SessionRecord } from "sandbox-agent";
import type {
ProcessCreateRequest,
ProcessInfo,
ProcessLogFollowQuery,
ProcessLogsResponse,
ProcessSignalQuery,
SessionEvent,
SessionRecord,
} from "sandbox-agent";
import { sandboxInstanceDb } from "./db/db.js";
import { sandboxInstance as sandboxInstanceTable } from "./db/schema.js";
import { SandboxInstancePersistDriver } from "./persist.js";
@ -18,6 +26,11 @@ export interface SandboxInstanceInput {
sandboxId: string;
}
interface SandboxAgentConnection {
endpoint: string;
token?: string;
}
const SANDBOX_ROW_ID = 1;
const CREATE_SESSION_MAX_ATTEMPTS = 3;
const CREATE_SESSION_RETRY_BASE_MS = 1_000;
@ -73,7 +86,7 @@ function parseMetadata(metadataJson: string): Record<string, unknown> {
}
}
async function loadPersistedAgentConfig(c: any): Promise<{ endpoint: string; token?: string } | null> {
async function loadPersistedAgentConfig(c: any): Promise<SandboxAgentConnection | null> {
try {
const row = await c.db
.select({ metadataJson: sandboxInstanceTable.metadataJson })
@ -95,7 +108,7 @@ async function loadPersistedAgentConfig(c: any): Promise<{ endpoint: string; tok
return null;
}
async function loadFreshDaytonaAgentConfig(c: any): Promise<{ endpoint: string; token?: string }> {
async function loadFreshDaytonaAgentConfig(c: any): Promise<SandboxAgentConnection> {
const { config, driver } = getActorRuntimeContext();
const daytona = driver.daytona.createClient({
apiUrl: config.providers.daytona.endpoint,
@ -110,7 +123,7 @@ async function loadFreshDaytonaAgentConfig(c: any): Promise<{ endpoint: string;
return preview.token ? { endpoint: preview.url, token: preview.token } : { endpoint: preview.url };
}
async function loadFreshProviderAgentConfig(c: any): Promise<{ endpoint: string; token?: string }> {
async function loadFreshProviderAgentConfig(c: any): Promise<SandboxAgentConnection> {
const { providers } = getActorRuntimeContext();
const provider = providers.get(c.state.providerId);
return await provider.ensureSandboxAgent({
@ -119,7 +132,7 @@ async function loadFreshProviderAgentConfig(c: any): Promise<{ endpoint: string;
});
}
async function loadAgentConfig(c: any): Promise<{ endpoint: string; token?: string }> {
async function loadAgentConfig(c: any): Promise<SandboxAgentConnection> {
const persisted = await loadPersistedAgentConfig(c);
if (c.state.providerId === "daytona") {
// Keep one stable signed preview endpoint per sandbox-instance actor.
@ -265,6 +278,13 @@ async function getSandboxAgentClient(c: any) {
});
}
function broadcastProcessesUpdated(c: any): void {
c.broadcast("processesUpdated", {
sandboxId: c.state.sandboxId,
at: Date.now(),
});
}
async function ensureSandboxMutation(c: any, command: EnsureSandboxCommand): Promise<void> {
const now = Date.now();
const metadata = {
@ -446,6 +466,56 @@ export const sandboxInstance = actor({
sandboxId: input.sandboxId,
}),
actions: {
async sandboxAgentConnection(c: any): Promise<SandboxAgentConnection> {
return await loadAgentConfig(c);
},
async createProcess(c: any, request: ProcessCreateRequest): Promise<ProcessInfo> {
const client = await getSandboxAgentClient(c);
const created = await client.createProcess(request);
broadcastProcessesUpdated(c);
return created;
},
async listProcesses(c: any): Promise<{ processes: ProcessInfo[] }> {
const client = await getSandboxAgentClient(c);
return await client.listProcesses();
},
async getProcessLogs(
c: any,
request: { processId: string; query?: ProcessLogFollowQuery }
): Promise<ProcessLogsResponse> {
const client = await getSandboxAgentClient(c);
return await client.getProcessLogs(request.processId, request.query);
},
async stopProcess(
c: any,
request: { processId: string; query?: ProcessSignalQuery }
): Promise<ProcessInfo> {
const client = await getSandboxAgentClient(c);
const stopped = await client.stopProcess(request.processId, request.query);
broadcastProcessesUpdated(c);
return stopped;
},
async killProcess(
c: any,
request: { processId: string; query?: ProcessSignalQuery }
): Promise<ProcessInfo> {
const client = await getSandboxAgentClient(c);
const killed = await client.killProcess(request.processId, request.query);
broadcastProcessesUpdated(c);
return killed;
},
async deleteProcess(c: any, request: { processId: string }): Promise<void> {
const client = await getSandboxAgentClient(c);
await client.deleteProcess(request.processId);
broadcastProcessesUpdated(c);
},
async providerState(c: any): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> {
const at = Date.now();
const { config, driver } = getActorRuntimeContext();

View file

@ -1,8 +1,28 @@
import type { BranchSnapshot } from "./integrations/git/index.js";
import type { PullRequestSnapshot } from "./integrations/github/index.js";
import type { SandboxSession, SandboxAgentClientOptions, SandboxSessionCreateRequest } from "./integrations/sandbox-agent/client.js";
import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionRecord } from "sandbox-agent";
import type { DaytonaClientOptions, DaytonaCreateSandboxOptions, DaytonaPreviewEndpoint, DaytonaSandbox } from "./integrations/daytona/client.js";
import type {
SandboxSession,
SandboxAgentClientOptions,
SandboxSessionCreateRequest
} from "./integrations/sandbox-agent/client.js";
import type {
ListEventsRequest,
ListPage,
ListPageRequest,
ProcessCreateRequest,
ProcessInfo,
ProcessLogFollowQuery,
ProcessLogsResponse,
ProcessSignalQuery,
SessionEvent,
SessionRecord,
} from "sandbox-agent";
import type {
DaytonaClientOptions,
DaytonaCreateSandboxOptions,
DaytonaPreviewEndpoint,
DaytonaSandbox,
} from "./integrations/daytona/client.js";
import {
validateRemote,
ensureCloned,
@ -67,6 +87,12 @@ export interface SandboxAgentClientLike {
sessionStatus(sessionId: string): Promise<SandboxSession>;
listSessions(request?: ListPageRequest): Promise<ListPage<SessionRecord>>;
listEvents(request: ListEventsRequest): Promise<ListPage<SessionEvent>>;
createProcess(request: ProcessCreateRequest): Promise<ProcessInfo>;
listProcesses(): Promise<{ processes: ProcessInfo[] }>;
getProcessLogs(processId: string, query?: ProcessLogFollowQuery): Promise<ProcessLogsResponse>;
stopProcess(processId: string, query?: ProcessSignalQuery): Promise<ProcessInfo>;
killProcess(processId: string, query?: ProcessSignalQuery): Promise<ProcessInfo>;
deleteProcess(processId: string): Promise<void>;
sendPrompt(request: { sessionId: string; prompt: string; notification?: boolean }): Promise<void>;
cancelSession(sessionId: string): Promise<void>;
destroySession(sessionId: string): Promise<void>;

View file

@ -1,5 +1,17 @@
import type { AgentType } from "@openhandoff/shared";
import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
import type {
ListEventsRequest,
ListPage,
ListPageRequest,
ProcessCreateRequest,
ProcessInfo,
ProcessLogFollowQuery,
ProcessLogsResponse,
ProcessSignalQuery,
SessionEvent,
SessionPersistDriver,
SessionRecord
} from "sandbox-agent";
import { SandboxAgent } from "sandbox-agent";
export type AgentId = AgentType | "opencode";
@ -199,6 +211,39 @@ export class SandboxAgentClient {
return sdk.getEvents(request);
}
async createProcess(request: ProcessCreateRequest): Promise<ProcessInfo> {
const sdk = await this.sdk();
return await sdk.createProcess(request);
}
async listProcesses(): Promise<{ processes: ProcessInfo[] }> {
const sdk = await this.sdk();
return await sdk.listProcesses();
}
async getProcessLogs(
processId: string,
query: ProcessLogFollowQuery = {}
): Promise<ProcessLogsResponse> {
const sdk = await this.sdk();
return await sdk.getProcessLogs(processId, query);
}
async stopProcess(processId: string, query?: ProcessSignalQuery): Promise<ProcessInfo> {
const sdk = await this.sdk();
return await sdk.stopProcess(processId, query);
}
async killProcess(processId: string, query?: ProcessSignalQuery): Promise<ProcessInfo> {
const sdk = await this.sdk();
return await sdk.killProcess(processId, query);
}
async deleteProcess(processId: string): Promise<void> {
const sdk = await this.sdk();
await sdk.deleteProcess(processId);
}
async sendPrompt(request: SandboxSessionPromptRequest): Promise<void> {
const sdk = await this.sdk();
const existing = await sdk.getSession(request.sessionId);

View file

@ -9,7 +9,15 @@ import type {
SandboxAgentClientLike,
TmuxDriver,
} from "../../src/driver.js";
import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionRecord } from "sandbox-agent";
import type {
ListEventsRequest,
ListPage,
ListPageRequest,
ProcessInfo,
ProcessLogsResponse,
SessionEvent,
SessionRecord,
} from "sandbox-agent";
export function createTestDriver(overrides?: Partial<BackendDriver>): BackendDriver {
return {
@ -70,7 +78,27 @@ export function createTestSandboxAgentDriver(overrides?: Partial<SandboxAgentDri
};
}
export function createTestSandboxAgentClient(overrides?: Partial<SandboxAgentClientLike>): SandboxAgentClientLike {
export function createTestSandboxAgentClient(
overrides?: Partial<SandboxAgentClientLike>
): SandboxAgentClientLike {
const defaultProcess: ProcessInfo = {
id: "process-1",
command: "bash",
args: ["-lc", "echo test"],
createdAtMs: Date.now(),
cwd: "/workspace",
exitCode: null,
exitedAtMs: null,
interactive: true,
pid: 123,
status: "running",
tty: true,
};
const defaultLogs: ProcessLogsResponse = {
processId: defaultProcess.id,
stream: "combined",
entries: [],
};
return {
createSession: async (_prompt) => ({ id: "test-session-1", status: "running" }),
sessionStatus: async (sessionId) => ({ id: sessionId, status: "running" }),
@ -82,6 +110,12 @@ export function createTestSandboxAgentClient(overrides?: Partial<SandboxAgentCli
items: [],
nextCursor: undefined,
}),
createProcess: async () => defaultProcess,
listProcesses: async () => ({ processes: [defaultProcess] }),
getProcessLogs: async () => defaultLogs,
stopProcess: async () => ({ ...defaultProcess, status: "exited", exitCode: 0, exitedAtMs: Date.now() }),
killProcess: async () => ({ ...defaultProcess, status: "exited", exitCode: 137, exitedAtMs: Date.now() }),
deleteProcess: async () => {},
sendPrompt: async (_request) => {},
cancelSession: async (_sessionId) => {},
destroySession: async (_sessionId) => {},

View file

@ -15,7 +15,8 @@
},
"dependencies": {
"@openhandoff/shared": "workspace:*",
"rivetkit": "2.1.6"
"rivetkit": "2.1.6",
"sandbox-agent": "workspace:*"
},
"devDependencies": {
"tsup": "^8.5.0"

View file

@ -29,6 +29,14 @@ import type {
StarSandboxAgentRepoResult,
SwitchResult,
} from "@openhandoff/shared";
import type {
ProcessCreateRequest,
ProcessInfo,
ProcessLogFollowQuery,
ProcessLogsResponse,
ProcessSignalQuery,
} from "sandbox-agent";
import { createMockBackendClient } from "./mock/backend-client.js";
import { sandboxInstanceKey, workspaceKey } from "./keys.js";
export type HandoffAction = "push" | "sync" | "merge" | "archive" | "kill";
@ -61,6 +69,8 @@ export interface SandboxSessionEventRecord {
payload: unknown;
}
export type SandboxProcessRecord = ProcessInfo;
interface WorkspaceHandle {
addRepo(input: AddRepoInput): Promise<RepoRecord>;
listRepos(input: { workspaceId: string }): Promise<RepoRecord[]>;
@ -104,8 +114,15 @@ interface SandboxInstanceHandle {
}): Promise<{ id: string | null; status: "running" | "idle" | "error"; error?: string }>;
listSessions(input?: { cursor?: string; limit?: number }): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }>;
listSessionEvents(input: { sessionId: string; cursor?: string; limit?: number }): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }>;
createProcess(input: ProcessCreateRequest): Promise<SandboxProcessRecord>;
listProcesses(): Promise<{ processes: SandboxProcessRecord[] }>;
getProcessLogs(input: { processId: string; query?: ProcessLogFollowQuery }): Promise<ProcessLogsResponse>;
stopProcess(input: { processId: string; query?: ProcessSignalQuery }): Promise<SandboxProcessRecord>;
killProcess(input: { processId: string; query?: ProcessSignalQuery }): Promise<SandboxProcessRecord>;
deleteProcess(input: { processId: string }): Promise<void>;
sendPrompt(input: { sessionId: string; prompt: string; notification?: boolean }): Promise<void>;
sessionStatus(input: { sessionId: string }): Promise<{ id: string; status: "running" | "idle" | "error" }>;
sandboxAgentConnection(): Promise<{ endpoint: string; token?: string }>;
providerState(): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>;
}
@ -121,6 +138,7 @@ interface RivetClient {
export interface BackendClientOptions {
endpoint: string;
defaultWorkspaceId?: string;
mode?: "remote" | "mock";
}
export interface BackendMetadata {
@ -163,6 +181,50 @@ export interface BackendClient {
sandboxId: string,
input: { sessionId: string; cursor?: string; limit?: number },
): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }>;
createSandboxProcess(input: {
workspaceId: string;
providerId: ProviderId;
sandboxId: string;
request: ProcessCreateRequest;
}): Promise<SandboxProcessRecord>;
listSandboxProcesses(
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<{ processes: SandboxProcessRecord[] }>;
getSandboxProcessLogs(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
processId: string,
query?: ProcessLogFollowQuery
): Promise<ProcessLogsResponse>;
stopSandboxProcess(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
processId: string,
query?: ProcessSignalQuery
): Promise<SandboxProcessRecord>;
killSandboxProcess(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
processId: string,
query?: ProcessSignalQuery
): Promise<SandboxProcessRecord>;
deleteSandboxProcess(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
processId: string
): Promise<void>;
subscribeSandboxProcesses(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
listener: () => void
): () => void;
sendSandboxPrompt(input: {
workspaceId: string;
providerId: ProviderId;
@ -182,6 +244,11 @@ export interface BackendClient {
providerId: ProviderId,
sandboxId: string,
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>;
getSandboxAgentConnection(
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<{ endpoint: string; token?: string }>;
getWorkbench(workspaceId: string): Promise<HandoffWorkbenchSnapshot>;
subscribeWorkbench(workspaceId: string, listener: () => void): () => void;
createWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse>;
@ -327,6 +394,10 @@ async function probeMetadataEndpoint(endpoint: string, namespace: string | undef
}
export function createBackendClient(options: BackendClientOptions): BackendClient {
if (options.mode === "mock") {
return createMockBackendClient(options.defaultWorkspaceId);
}
let clientPromise: Promise<RivetClient> | null = null;
const workbenchSubscriptions = new Map<
string,
@ -335,6 +406,13 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
disposeConnPromise: Promise<(() => Promise<void>) | null> | null;
}
>();
const sandboxProcessSubscriptions = new Map<
string,
{
listeners: Set<() => void>;
disposeConnPromise: Promise<(() => Promise<void>) | null> | null;
}
>();
const getClient = async (): Promise<RivetClient> => {
if (clientPromise) {
@ -495,6 +573,69 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
};
};
const sandboxProcessSubscriptionKey = (
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
): string => `${workspaceId}:${providerId}:${sandboxId}`;
const subscribeSandboxProcesses = (
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
listener: () => void,
): (() => void) => {
const key = sandboxProcessSubscriptionKey(workspaceId, providerId, sandboxId);
let entry = sandboxProcessSubscriptions.get(key);
if (!entry) {
entry = {
listeners: new Set(),
disposeConnPromise: null,
};
sandboxProcessSubscriptions.set(key, entry);
}
entry.listeners.add(listener);
if (!entry.disposeConnPromise) {
entry.disposeConnPromise = (async () => {
const handle = await sandboxByKey(workspaceId, providerId, sandboxId);
const conn = (handle as any).connect();
const unsubscribeEvent = conn.on("processesUpdated", () => {
const current = sandboxProcessSubscriptions.get(key);
if (!current) {
return;
}
for (const currentListener of [...current.listeners]) {
currentListener();
}
});
const unsubscribeError = conn.onError(() => {});
return async () => {
unsubscribeEvent();
unsubscribeError();
await conn.dispose();
};
})().catch(() => null);
}
return () => {
const current = sandboxProcessSubscriptions.get(key);
if (!current) {
return;
}
current.listeners.delete(listener);
if (current.listeners.size > 0) {
return;
}
sandboxProcessSubscriptions.delete(key);
void current.disposeConnPromise?.then(async (disposeConn) => {
await disposeConn?.();
});
};
};
return {
async addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord> {
return (await workspace(workspaceId)).addRepo({ workspaceId, remoteUrl });
@ -629,6 +770,101 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.listSessionEvents(input));
},
async createSandboxProcess(input: {
workspaceId: string;
providerId: ProviderId;
sandboxId: string;
request: ProcessCreateRequest;
}): Promise<SandboxProcessRecord> {
return await withSandboxHandle(
input.workspaceId,
input.providerId,
input.sandboxId,
async (handle) => handle.createProcess(input.request)
);
},
async listSandboxProcesses(
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<{ processes: SandboxProcessRecord[] }> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.listProcesses()
);
},
async getSandboxProcessLogs(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
processId: string,
query?: ProcessLogFollowQuery
): Promise<ProcessLogsResponse> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.getProcessLogs({ processId, query })
);
},
async stopSandboxProcess(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
processId: string,
query?: ProcessSignalQuery
): Promise<SandboxProcessRecord> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.stopProcess({ processId, query })
);
},
async killSandboxProcess(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
processId: string,
query?: ProcessSignalQuery
): Promise<SandboxProcessRecord> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.killProcess({ processId, query })
);
},
async deleteSandboxProcess(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
processId: string
): Promise<void> {
await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.deleteProcess({ processId })
);
},
subscribeSandboxProcesses(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
listener: () => void
): () => void {
return subscribeSandboxProcesses(workspaceId, providerId, sandboxId, listener);
},
async sendSandboxPrompt(input: {
workspaceId: string;
providerId: ProviderId;
@ -663,6 +899,19 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.providerState());
},
async getSandboxAgentConnection(
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<{ endpoint: string; token?: string }> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.sandboxAgentConnection()
);
},
async getWorkbench(workspaceId: string): Promise<HandoffWorkbenchSnapshot> {
return (await workspace(workspaceId)).getWorkbench({ workspaceId });
},

View file

@ -0,0 +1,505 @@
import type {
AddRepoInput,
CreateHandoffInput,
HandoffRecord,
HandoffSummary,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchCreateHandoffInput,
HandoffWorkbenchCreateHandoffResponse,
HandoffWorkbenchDiffInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSelectInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
HistoryEvent,
HistoryQueryInput,
ProviderId,
RepoOverview,
RepoRecord,
RepoStackActionInput,
RepoStackActionResult,
StarSandboxAgentRepoResult,
SwitchResult,
} from "@openhandoff/shared";
import type {
ProcessCreateRequest,
ProcessLogFollowQuery,
ProcessLogsResponse,
ProcessSignalQuery,
} from "sandbox-agent";
import type {
BackendClient,
SandboxProcessRecord,
SandboxSessionEventRecord,
SandboxSessionRecord,
} from "../backend-client.js";
import { getSharedMockWorkbenchClient } from "./workbench-client.js";
interface MockProcessRecord extends SandboxProcessRecord {
logText: string;
}
function notSupported(name: string): never {
throw new Error(`${name} is not supported by the mock backend client.`);
}
function encodeBase64Utf8(value: string): string {
if (typeof Buffer !== "undefined") {
return Buffer.from(value, "utf8").toString("base64");
}
return globalThis.btoa(unescape(encodeURIComponent(value)));
}
function nowMs(): number {
return Date.now();
}
function mockRepoRemote(label: string): string {
return `https://example.test/${label}.git`;
}
function mockCwd(repoLabel: string, handoffId: string): string {
return `/mock/${repoLabel.replace(/\//g, "-")}/${handoffId}`;
}
function toHandoffStatus(status: HandoffRecord["status"], archived: boolean): HandoffRecord["status"] {
if (archived) {
return "archived";
}
return status;
}
export function createMockBackendClient(defaultWorkspaceId = "default"): BackendClient {
const workbench = getSharedMockWorkbenchClient();
const listenersBySandboxId = new Map<string, Set<() => void>>();
const processesBySandboxId = new Map<string, MockProcessRecord[]>();
let nextPid = 4000;
let nextProcessId = 1;
const requireHandoff = (handoffId: string) => {
const handoff = workbench.getSnapshot().handoffs.find((candidate) => candidate.id === handoffId);
if (!handoff) {
throw new Error(`Unknown mock handoff ${handoffId}`);
}
return handoff;
};
const ensureProcessList = (sandboxId: string): MockProcessRecord[] => {
const existing = processesBySandboxId.get(sandboxId);
if (existing) {
return existing;
}
const created: MockProcessRecord[] = [];
processesBySandboxId.set(sandboxId, created);
return created;
};
const notifySandbox = (sandboxId: string): void => {
const listeners = listenersBySandboxId.get(sandboxId);
if (!listeners) {
return;
}
for (const listener of [...listeners]) {
listener();
}
};
const buildHandoffRecord = (handoffId: string): HandoffRecord => {
const handoff = requireHandoff(handoffId);
const cwd = mockCwd(handoff.repoName, handoff.id);
const archived = handoff.status === "archived";
return {
workspaceId: defaultWorkspaceId,
repoId: handoff.repoId,
repoRemote: mockRepoRemote(handoff.repoName),
handoffId: handoff.id,
branchName: handoff.branch,
title: handoff.title,
task: handoff.title,
providerId: "local",
status: toHandoffStatus(archived ? "archived" : "running", archived),
statusMessage: archived ? "archived" : "mock sandbox ready",
activeSandboxId: handoff.id,
activeSessionId: handoff.tabs[0]?.sessionId ?? null,
sandboxes: [
{
sandboxId: handoff.id,
providerId: "local",
sandboxActorId: "mock-sandbox",
switchTarget: `mock://${handoff.id}`,
cwd,
createdAt: handoff.updatedAtMs,
updatedAt: handoff.updatedAtMs,
},
],
agentType: handoff.tabs[0]?.agent === "Codex" ? "codex" : "claude",
prSubmitted: Boolean(handoff.pullRequest),
diffStat: handoff.fileChanges.length > 0 ? `+${handoff.fileChanges.length}/-${handoff.fileChanges.length}` : "+0/-0",
prUrl: handoff.pullRequest ? `https://example.test/pr/${handoff.pullRequest.number}` : null,
prAuthor: handoff.pullRequest ? "mock" : null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
conflictsWithMain: "0",
hasUnpushed: handoff.fileChanges.length > 0 ? "1" : "0",
parentBranch: null,
createdAt: handoff.updatedAtMs,
updatedAt: handoff.updatedAtMs,
};
};
const cloneProcess = (process: MockProcessRecord): MockProcessRecord => ({ ...process });
const createProcessRecord = (
sandboxId: string,
cwd: string,
request: ProcessCreateRequest,
): MockProcessRecord => {
const processId = `proc_${nextProcessId++}`;
const createdAtMs = nowMs();
const args = request.args ?? [];
const interactive = request.interactive ?? false;
const tty = request.tty ?? false;
const statusLine = interactive && tty
? "Mock terminal session created.\nInteractive transport is unavailable in mock mode.\n"
: "Mock process created.\n";
const commandLine = `$ ${[request.command, ...args].join(" ").trim()}\n`;
return {
id: processId,
command: request.command,
args,
createdAtMs,
cwd: request.cwd ?? cwd,
exitCode: null,
exitedAtMs: null,
interactive,
pid: nextPid++,
status: "running",
tty,
logText: `${statusLine}${commandLine}`,
};
};
return {
async addRepo(_workspaceId: string, _remoteUrl: string): Promise<RepoRecord> {
notSupported("addRepo");
},
async listRepos(_workspaceId: string): Promise<RepoRecord[]> {
return workbench.getSnapshot().repos.map((repo) => ({
workspaceId: defaultWorkspaceId,
repoId: repo.id,
remoteUrl: mockRepoRemote(repo.label),
createdAt: nowMs(),
updatedAt: nowMs(),
}));
},
async createHandoff(_input: CreateHandoffInput): Promise<HandoffRecord> {
notSupported("createHandoff");
},
async listHandoffs(_workspaceId: string, repoId?: string): Promise<HandoffSummary[]> {
return workbench
.getSnapshot()
.handoffs
.filter((handoff) => !repoId || handoff.repoId === repoId)
.map((handoff) => ({
workspaceId: defaultWorkspaceId,
repoId: handoff.repoId,
handoffId: handoff.id,
branchName: handoff.branch,
title: handoff.title,
status: handoff.status === "archived" ? "archived" : "running",
updatedAt: handoff.updatedAtMs,
}));
},
async getRepoOverview(_workspaceId: string, _repoId: string): Promise<RepoOverview> {
notSupported("getRepoOverview");
},
async runRepoStackAction(_input: RepoStackActionInput): Promise<RepoStackActionResult> {
notSupported("runRepoStackAction");
},
async getHandoff(_workspaceId: string, handoffId: string): Promise<HandoffRecord> {
return buildHandoffRecord(handoffId);
},
async listHistory(_input: HistoryQueryInput): Promise<HistoryEvent[]> {
return [];
},
async switchHandoff(_workspaceId: string, handoffId: string): Promise<SwitchResult> {
return {
workspaceId: defaultWorkspaceId,
handoffId,
providerId: "local",
switchTarget: `mock://${handoffId}`,
};
},
async attachHandoff(_workspaceId: string, handoffId: string): Promise<{ target: string; sessionId: string | null }> {
return {
target: `mock://${handoffId}`,
sessionId: requireHandoff(handoffId).tabs[0]?.sessionId ?? null,
};
},
async runAction(_workspaceId: string, _handoffId: string): Promise<void> {
notSupported("runAction");
},
async createSandboxSession(): Promise<{ id: string; status: "running" | "idle" | "error" }> {
notSupported("createSandboxSession");
},
async listSandboxSessions(): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }> {
return { items: [] };
},
async listSandboxSessionEvents(): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }> {
return { items: [] };
},
async createSandboxProcess(input: {
workspaceId: string;
providerId: ProviderId;
sandboxId: string;
request: ProcessCreateRequest;
}): Promise<SandboxProcessRecord> {
const handoff = requireHandoff(input.sandboxId);
const processes = ensureProcessList(input.sandboxId);
const created = createProcessRecord(input.sandboxId, mockCwd(handoff.repoName, handoff.id), input.request);
processes.unshift(created);
notifySandbox(input.sandboxId);
return cloneProcess(created);
},
async listSandboxProcesses(
_workspaceId: string,
_providerId: ProviderId,
sandboxId: string,
): Promise<{ processes: SandboxProcessRecord[] }> {
return {
processes: ensureProcessList(sandboxId).map((process) => cloneProcess(process)),
};
},
async getSandboxProcessLogs(
_workspaceId: string,
_providerId: ProviderId,
sandboxId: string,
processId: string,
query?: ProcessLogFollowQuery,
): Promise<ProcessLogsResponse> {
const process = ensureProcessList(sandboxId).find((candidate) => candidate.id === processId);
if (!process) {
throw new Error(`Unknown mock process ${processId}`);
}
return {
processId,
stream: query?.stream ?? (process.tty ? "pty" : "combined"),
entries: process.logText
? [
{
data: encodeBase64Utf8(process.logText),
encoding: "base64",
sequence: 1,
stream: query?.stream ?? (process.tty ? "pty" : "combined"),
timestampMs: process.createdAtMs,
},
]
: [],
};
},
async stopSandboxProcess(
_workspaceId: string,
_providerId: ProviderId,
sandboxId: string,
processId: string,
_query?: ProcessSignalQuery,
): Promise<SandboxProcessRecord> {
const process = ensureProcessList(sandboxId).find((candidate) => candidate.id === processId);
if (!process) {
throw new Error(`Unknown mock process ${processId}`);
}
process.status = "exited";
process.exitCode = 0;
process.exitedAtMs = nowMs();
process.logText += "\n[stopped]\n";
notifySandbox(sandboxId);
return cloneProcess(process);
},
async killSandboxProcess(
_workspaceId: string,
_providerId: ProviderId,
sandboxId: string,
processId: string,
_query?: ProcessSignalQuery,
): Promise<SandboxProcessRecord> {
const process = ensureProcessList(sandboxId).find((candidate) => candidate.id === processId);
if (!process) {
throw new Error(`Unknown mock process ${processId}`);
}
process.status = "exited";
process.exitCode = 137;
process.exitedAtMs = nowMs();
process.logText += "\n[killed]\n";
notifySandbox(sandboxId);
return cloneProcess(process);
},
async deleteSandboxProcess(
_workspaceId: string,
_providerId: ProviderId,
sandboxId: string,
processId: string,
): Promise<void> {
processesBySandboxId.set(
sandboxId,
ensureProcessList(sandboxId).filter((candidate) => candidate.id !== processId),
);
notifySandbox(sandboxId);
},
subscribeSandboxProcesses(
_workspaceId: string,
_providerId: ProviderId,
sandboxId: string,
listener: () => void,
): () => void {
let listeners = listenersBySandboxId.get(sandboxId);
if (!listeners) {
listeners = new Set();
listenersBySandboxId.set(sandboxId, listeners);
}
listeners.add(listener);
return () => {
const current = listenersBySandboxId.get(sandboxId);
if (!current) {
return;
}
current.delete(listener);
if (current.size === 0) {
listenersBySandboxId.delete(sandboxId);
}
};
},
async sendSandboxPrompt(): Promise<void> {
notSupported("sendSandboxPrompt");
},
async sandboxSessionStatus(sessionId: string): Promise<{ id: string; status: "running" | "idle" | "error" }> {
return { id: sessionId, status: "idle" };
},
async sandboxProviderState(
_workspaceId: string,
_providerId: ProviderId,
sandboxId: string,
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> {
return { providerId: "local", sandboxId, state: "running", at: nowMs() };
},
async getSandboxAgentConnection(): Promise<{ endpoint: string; token?: string }> {
return { endpoint: "mock://terminal-unavailable" };
},
async getWorkbench(): Promise<HandoffWorkbenchSnapshot> {
return workbench.getSnapshot();
},
subscribeWorkbench(_workspaceId: string, listener: () => void): () => void {
return workbench.subscribe(listener);
},
async createWorkbenchHandoff(
_workspaceId: string,
input: HandoffWorkbenchCreateHandoffInput,
): Promise<HandoffWorkbenchCreateHandoffResponse> {
return await workbench.createHandoff(input);
},
async markWorkbenchUnread(_workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void> {
await workbench.markHandoffUnread(input);
},
async renameWorkbenchHandoff(_workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void> {
await workbench.renameHandoff(input);
},
async renameWorkbenchBranch(_workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void> {
await workbench.renameBranch(input);
},
async createWorkbenchSession(
_workspaceId: string,
input: HandoffWorkbenchSelectInput & { model?: string },
): Promise<{ tabId: string }> {
return await workbench.addTab(input);
},
async renameWorkbenchSession(_workspaceId: string, input: HandoffWorkbenchRenameSessionInput): Promise<void> {
await workbench.renameSession(input);
},
async setWorkbenchSessionUnread(
_workspaceId: string,
input: HandoffWorkbenchSetSessionUnreadInput,
): Promise<void> {
await workbench.setSessionUnread(input);
},
async updateWorkbenchDraft(_workspaceId: string, input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
await workbench.updateDraft(input);
},
async changeWorkbenchModel(_workspaceId: string, input: HandoffWorkbenchChangeModelInput): Promise<void> {
await workbench.changeModel(input);
},
async sendWorkbenchMessage(_workspaceId: string, input: HandoffWorkbenchSendMessageInput): Promise<void> {
await workbench.sendMessage(input);
},
async stopWorkbenchSession(_workspaceId: string, input: HandoffWorkbenchTabInput): Promise<void> {
await workbench.stopAgent(input);
},
async closeWorkbenchSession(_workspaceId: string, input: HandoffWorkbenchTabInput): Promise<void> {
await workbench.closeTab(input);
},
async publishWorkbenchPr(_workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void> {
await workbench.publishPr(input);
},
async revertWorkbenchFile(_workspaceId: string, input: HandoffWorkbenchDiffInput): Promise<void> {
await workbench.revertFile(input);
},
async health(): Promise<{ ok: true }> {
return { ok: true };
},
async useWorkspace(workspaceId: string): Promise<{ workspaceId: string }> {
return { workspaceId };
},
async starSandboxAgentRepo(): Promise<StarSandboxAgentRepoResult> {
return {
repo: "rivet-dev/sandbox-agent",
starredAt: nowMs(),
};
},
};
}

View file

@ -20,6 +20,7 @@
"lucide-react": "^0.542.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"sandbox-agent": "workspace:*",
"styletron-engine-atomic": "^1.6.2",
"styletron-react": "^6.1.1"
},

View file

@ -1,5 +1,16 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type PointerEvent as ReactPointerEvent,
} from "react";
import { useNavigate } from "@tanstack/react-router";
import { useStyletron } from "baseui";
import { DiffContent } from "./mock-layout/diff-content";
import { MessageList } from "./mock-layout/message-list";
@ -7,6 +18,7 @@ import { PromptComposer } from "./mock-layout/prompt-composer";
import { RightSidebar } from "./mock-layout/right-sidebar";
import { Sidebar } from "./mock-layout/sidebar";
import { TabStrip } from "./mock-layout/tab-strip";
import { TerminalPane } from "./mock-layout/terminal-pane";
import { TranscriptHeader } from "./mock-layout/transcript-header";
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
import {
@ -548,6 +560,157 @@ const TranscriptPanel = memo(function TranscriptPanel({
);
});
const RIGHT_RAIL_MIN_SECTION_HEIGHT = 180;
const RIGHT_RAIL_SPLITTER_HEIGHT = 10;
const DEFAULT_TERMINAL_HEIGHT = 320;
const TERMINAL_HEIGHT_STORAGE_KEY = "openhandoff:foundry-terminal-height";
const RightRail = memo(function RightRail({
workspaceId,
handoff,
activeTabId,
onOpenDiff,
onArchive,
onRevertFile,
onPublishPr,
}: {
workspaceId: string;
handoff: Handoff;
activeTabId: string | null;
onOpenDiff: (path: string) => void;
onArchive: () => void;
onRevertFile: (path: string) => void;
onPublishPr: () => void;
}) {
const [css] = useStyletron();
const railRef = useRef<HTMLDivElement>(null);
const [terminalHeight, setTerminalHeight] = useState(() => {
if (typeof window === "undefined") {
return DEFAULT_TERMINAL_HEIGHT;
}
const stored = window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY);
const parsed = stored ? Number.parseInt(stored, 10) : Number.NaN;
return Number.isFinite(parsed) ? parsed : DEFAULT_TERMINAL_HEIGHT;
});
const clampTerminalHeight = useCallback((nextHeight: number) => {
const railHeight = railRef.current?.getBoundingClientRect().height ?? 0;
const maxHeight = Math.max(
RIGHT_RAIL_MIN_SECTION_HEIGHT,
railHeight - RIGHT_RAIL_MIN_SECTION_HEIGHT - RIGHT_RAIL_SPLITTER_HEIGHT,
);
return Math.min(Math.max(nextHeight, RIGHT_RAIL_MIN_SECTION_HEIGHT), maxHeight);
}, []);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight));
}, [terminalHeight]);
useEffect(() => {
const handleResize = () => {
setTerminalHeight((current) => clampTerminalHeight(current));
};
window.addEventListener("resize", handleResize);
handleResize();
return () => window.removeEventListener("resize", handleResize);
}, [clampTerminalHeight]);
const startResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startY = event.clientY;
const startHeight = terminalHeight;
document.body.style.cursor = "ns-resize";
const handlePointerMove = (moveEvent: PointerEvent) => {
const deltaY = moveEvent.clientY - startY;
setTerminalHeight(clampTerminalHeight(startHeight - deltaY));
};
const stopResize = () => {
document.body.style.cursor = "";
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResize);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResize, { once: true });
},
[clampTerminalHeight, terminalHeight],
);
return (
<div
ref={railRef}
className={css({
minHeight: 0,
display: "flex",
flexDirection: "column",
backgroundColor: "#090607",
})}
>
<div
className={css({
minHeight: `${RIGHT_RAIL_MIN_SECTION_HEIGHT}px`,
flex: 1,
minWidth: 0,
})}
>
<RightSidebar
handoff={handoff}
activeTabId={activeTabId}
onOpenDiff={onOpenDiff}
onArchive={onArchive}
onRevertFile={onRevertFile}
onPublishPr={onPublishPr}
/>
</div>
<div
role="separator"
aria-orientation="horizontal"
aria-label="Resize terminal panel"
onPointerDown={startResize}
className={css({
height: `${RIGHT_RAIL_SPLITTER_HEIGHT}px`,
flexShrink: 0,
cursor: "ns-resize",
position: "relative",
backgroundColor: "#050505",
":before": {
content: '""',
position: "absolute",
left: "50%",
top: "50%",
width: "42px",
height: "4px",
borderRadius: "999px",
transform: "translate(-50%, -50%)",
backgroundColor: "rgba(255, 255, 255, 0.14)",
},
})}
/>
<div
className={css({
height: `${terminalHeight}px`,
minHeight: `${RIGHT_RAIL_MIN_SECTION_HEIGHT}px`,
backgroundColor: "#080506",
overflow: "hidden",
})}
>
<TerminalPane workspaceId={workspaceId} handoffId={handoff.id} />
</div>
</div>
);
});
interface MockLayoutProps {
workspaceId: string;
selectedHandoffId?: string | null;
@ -1057,7 +1220,8 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths }));
}}
/>
<RightSidebar
<RightRail
workspaceId={workspaceId}
handoff={activeHandoff}
activeTabId={activeTabId}
onOpenDiff={openDiffTab}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,8 @@
import { createBackendClient } from "@openhandoff/client";
import { backendEndpoint, defaultWorkspaceId } from "./env";
import { backendEndpoint, defaultWorkspaceId, frontendClientMode } from "./env";
export const backendClient = createBackendClient({
endpoint: backendEndpoint,
defaultWorkspaceId,
mode: frontendClientMode,
});

6
pnpm-lock.yaml generated
View file

@ -510,6 +510,9 @@ importers:
rivetkit:
specifier: 2.1.6
version: 2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260310.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(pg@8.20.0))(ws@8.19.0)
sandbox-agent:
specifier: workspace:*
version: link:../../../sdks/typescript
devDependencies:
tsup:
specifier: ^8.5.0
@ -547,6 +550,9 @@ importers:
react-dom:
specifier: ^19.1.1
version: 19.2.4(react@19.2.4)
sandbox-agent:
specifier: workspace:*
version: link:../../../sdks/typescript
styletron-engine-atomic:
specifier: ^1.6.2
version: 1.6.2

View file

@ -3,7 +3,12 @@
import type { FitAddon as GhosttyFitAddon, Terminal as GhosttyTerminal } from "ghostty-web";
import type { CSSProperties } from "react";
import { useEffect, useRef, useState } from "react";
import type { SandboxAgent, TerminalErrorStatus, TerminalExitStatus } from "sandbox-agent";
import type {
SandboxAgent,
TerminalErrorStatus,
TerminalExitStatus,
TerminalReadyStatus,
} from "sandbox-agent";
type ConnectionState = "connecting" | "ready" | "closed" | "error";
@ -15,7 +20,9 @@ export interface ProcessTerminalProps {
className?: string;
style?: CSSProperties;
terminalStyle?: CSSProperties;
statusBarStyleOverride?: CSSProperties;
height?: number | string;
showStatusBar?: boolean;
onExit?: (status: TerminalExitStatus) => void;
onError?: (error: TerminalErrorStatus | Error) => void;
}
@ -90,7 +97,18 @@ const getStatusColor = (state: ConnectionState): string => {
}
};
export const ProcessTerminal = ({ client, processId, className, style, terminalStyle, height = 360, onExit, onError }: ProcessTerminalProps) => {
export const ProcessTerminal = ({
client,
processId,
className,
style,
terminalStyle,
statusBarStyleOverride,
height = 360,
showStatusBar = true,
onExit,
onError,
}: ProcessTerminalProps) => {
const hostRef = useRef<HTMLDivElement | null>(null);
const [connectionState, setConnectionState] = useState<ConnectionState>("connecting");
const [statusMessage, setStatusMessage] = useState("Connecting to PTY...");
@ -165,7 +183,7 @@ export const ProcessTerminal = ({ client, processId, className, style, terminalS
const nextSession = client.connectProcessTerminal(processId);
session = nextSession;
nextSession.onReady((frame) => {
nextSession.onReady((frame: TerminalReadyStatus) => {
if (cancelled || frame.type !== "ready") {
return;
}
@ -175,14 +193,14 @@ export const ProcessTerminal = ({ client, processId, className, style, terminalS
syncSize();
});
nextSession.onData((bytes) => {
nextSession.onData((bytes: Uint8Array) => {
if (cancelled || !terminal) {
return;
}
terminal.write(bytes);
});
nextSession.onExit((frame) => {
nextSession.onExit((frame: TerminalExitStatus) => {
if (cancelled || frame.type !== "exit") {
return;
}
@ -193,7 +211,7 @@ export const ProcessTerminal = ({ client, processId, className, style, terminalS
onExit?.(frame);
});
nextSession.onError((error) => {
nextSession.onError((error: TerminalErrorStatus | Error) => {
if (cancelled) {
return;
}
@ -239,10 +257,12 @@ export const ProcessTerminal = ({ client, processId, className, style, terminalS
return (
<div className={className} style={{ ...shellStyle, ...style }}>
<div style={statusBarStyle}>
<span style={{ color: getStatusColor(connectionState) }}>{statusMessage}</span>
{exitCode != null ? <span style={exitCodeStyle}>exit={exitCode}</span> : null}
</div>
{showStatusBar ? (
<div style={{ ...statusBarStyle, ...statusBarStyleOverride }}>
<span style={{ color: getStatusColor(connectionState) }}>{statusMessage}</span>
{exitCode != null ? <span style={exitCodeStyle}>exit={exitCode}</span> : null}
</div>
) : null}
<div
ref={hostRef}
role="presentation"