mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 10:05:18 +00:00
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:
commit
c8f4cb9ef9
15 changed files with 2345 additions and 25 deletions
|
|
@ -10,6 +10,7 @@ import {
|
||||||
initCreateSessionActivity,
|
initCreateSessionActivity,
|
||||||
initEnsureAgentActivity,
|
initEnsureAgentActivity,
|
||||||
initEnsureNameActivity,
|
initEnsureNameActivity,
|
||||||
|
initExposeSandboxActivity,
|
||||||
initFailedActivity,
|
initFailedActivity,
|
||||||
initStartSandboxInstanceActivity,
|
initStartSandboxInstanceActivity,
|
||||||
initStartStatusSyncActivity,
|
initStartStatusSyncActivity,
|
||||||
|
|
@ -90,6 +91,10 @@ const commandHandlers: Record<HandoffQueueName, WorkflowHandler> = {
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
run: async () => initStartSandboxInstanceActivity(loopCtx, body, sandbox, agent),
|
run: async () => initStartSandboxInstanceActivity(loopCtx, body, sandbox, agent),
|
||||||
});
|
});
|
||||||
|
await loopCtx.step(
|
||||||
|
"init-expose-sandbox",
|
||||||
|
async () => initExposeSandboxActivity(loopCtx, body, sandbox, sandboxInstanceReady),
|
||||||
|
);
|
||||||
const session = await loopCtx.step({
|
const session = await loopCtx.step({
|
||||||
name: "init-create-session",
|
name: "init-create-session",
|
||||||
timeout: 180_000,
|
timeout: 180_000,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
export async function initWriteDbActivity(
|
||||||
loopCtx: any,
|
loopCtx: any,
|
||||||
body: any,
|
body: any,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,15 @@ import { eq } from "drizzle-orm";
|
||||||
import { actor, queue } from "rivetkit";
|
import { actor, queue } from "rivetkit";
|
||||||
import { Loop, workflow } from "rivetkit/workflow";
|
import { Loop, workflow } from "rivetkit/workflow";
|
||||||
import type { ProviderId } from "@openhandoff/shared";
|
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 { sandboxInstanceDb } from "./db/db.js";
|
||||||
import { sandboxInstance as sandboxInstanceTable } from "./db/schema.js";
|
import { sandboxInstance as sandboxInstanceTable } from "./db/schema.js";
|
||||||
import { SandboxInstancePersistDriver } from "./persist.js";
|
import { SandboxInstancePersistDriver } from "./persist.js";
|
||||||
|
|
@ -18,6 +26,11 @@ export interface SandboxInstanceInput {
|
||||||
sandboxId: string;
|
sandboxId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SandboxAgentConnection {
|
||||||
|
endpoint: string;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const SANDBOX_ROW_ID = 1;
|
const SANDBOX_ROW_ID = 1;
|
||||||
const CREATE_SESSION_MAX_ATTEMPTS = 3;
|
const CREATE_SESSION_MAX_ATTEMPTS = 3;
|
||||||
const CREATE_SESSION_RETRY_BASE_MS = 1_000;
|
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 {
|
try {
|
||||||
const row = await c.db
|
const row = await c.db
|
||||||
.select({ metadataJson: sandboxInstanceTable.metadataJson })
|
.select({ metadataJson: sandboxInstanceTable.metadataJson })
|
||||||
|
|
@ -95,7 +108,7 @@ async function loadPersistedAgentConfig(c: any): Promise<{ endpoint: string; tok
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFreshDaytonaAgentConfig(c: any): Promise<{ endpoint: string; token?: string }> {
|
async function loadFreshDaytonaAgentConfig(c: any): Promise<SandboxAgentConnection> {
|
||||||
const { config, driver } = getActorRuntimeContext();
|
const { config, driver } = getActorRuntimeContext();
|
||||||
const daytona = driver.daytona.createClient({
|
const daytona = driver.daytona.createClient({
|
||||||
apiUrl: config.providers.daytona.endpoint,
|
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 };
|
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 { providers } = getActorRuntimeContext();
|
||||||
const provider = providers.get(c.state.providerId);
|
const provider = providers.get(c.state.providerId);
|
||||||
return await provider.ensureSandboxAgent({
|
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);
|
const persisted = await loadPersistedAgentConfig(c);
|
||||||
if (c.state.providerId === "daytona") {
|
if (c.state.providerId === "daytona") {
|
||||||
// Keep one stable signed preview endpoint per sandbox-instance actor.
|
// 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> {
|
async function ensureSandboxMutation(c: any, command: EnsureSandboxCommand): Promise<void> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const metadata = {
|
const metadata = {
|
||||||
|
|
@ -446,6 +466,56 @@ export const sandboxInstance = actor({
|
||||||
sandboxId: input.sandboxId,
|
sandboxId: input.sandboxId,
|
||||||
}),
|
}),
|
||||||
actions: {
|
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 }> {
|
async providerState(c: any): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> {
|
||||||
const at = Date.now();
|
const at = Date.now();
|
||||||
const { config, driver } = getActorRuntimeContext();
|
const { config, driver } = getActorRuntimeContext();
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,28 @@
|
||||||
import type { BranchSnapshot } from "./integrations/git/index.js";
|
import type { BranchSnapshot } from "./integrations/git/index.js";
|
||||||
import type { PullRequestSnapshot } from "./integrations/github/index.js";
|
import type { PullRequestSnapshot } from "./integrations/github/index.js";
|
||||||
import type { SandboxSession, SandboxAgentClientOptions, SandboxSessionCreateRequest } from "./integrations/sandbox-agent/client.js";
|
import type {
|
||||||
import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionRecord } from "sandbox-agent";
|
SandboxSession,
|
||||||
import type { DaytonaClientOptions, DaytonaCreateSandboxOptions, DaytonaPreviewEndpoint, DaytonaSandbox } from "./integrations/daytona/client.js";
|
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 {
|
import {
|
||||||
validateRemote,
|
validateRemote,
|
||||||
ensureCloned,
|
ensureCloned,
|
||||||
|
|
@ -67,6 +87,12 @@ export interface SandboxAgentClientLike {
|
||||||
sessionStatus(sessionId: string): Promise<SandboxSession>;
|
sessionStatus(sessionId: string): Promise<SandboxSession>;
|
||||||
listSessions(request?: ListPageRequest): Promise<ListPage<SessionRecord>>;
|
listSessions(request?: ListPageRequest): Promise<ListPage<SessionRecord>>;
|
||||||
listEvents(request: ListEventsRequest): Promise<ListPage<SessionEvent>>;
|
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>;
|
sendPrompt(request: { sessionId: string; prompt: string; notification?: boolean }): Promise<void>;
|
||||||
cancelSession(sessionId: string): Promise<void>;
|
cancelSession(sessionId: string): Promise<void>;
|
||||||
destroySession(sessionId: string): Promise<void>;
|
destroySession(sessionId: string): Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,17 @@
|
||||||
import type { AgentType } from "@openhandoff/shared";
|
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";
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
|
|
||||||
export type AgentId = AgentType | "opencode";
|
export type AgentId = AgentType | "opencode";
|
||||||
|
|
@ -199,6 +211,39 @@ export class SandboxAgentClient {
|
||||||
return sdk.getEvents(request);
|
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> {
|
async sendPrompt(request: SandboxSessionPromptRequest): Promise<void> {
|
||||||
const sdk = await this.sdk();
|
const sdk = await this.sdk();
|
||||||
const existing = await sdk.getSession(request.sessionId);
|
const existing = await sdk.getSession(request.sessionId);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,15 @@ import type {
|
||||||
SandboxAgentClientLike,
|
SandboxAgentClientLike,
|
||||||
TmuxDriver,
|
TmuxDriver,
|
||||||
} from "../../src/driver.js";
|
} 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 {
|
export function createTestDriver(overrides?: Partial<BackendDriver>): BackendDriver {
|
||||||
return {
|
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 {
|
return {
|
||||||
createSession: async (_prompt) => ({ id: "test-session-1", status: "running" }),
|
createSession: async (_prompt) => ({ id: "test-session-1", status: "running" }),
|
||||||
sessionStatus: async (sessionId) => ({ id: sessionId, status: "running" }),
|
sessionStatus: async (sessionId) => ({ id: sessionId, status: "running" }),
|
||||||
|
|
@ -82,6 +110,12 @@ export function createTestSandboxAgentClient(overrides?: Partial<SandboxAgentCli
|
||||||
items: [],
|
items: [],
|
||||||
nextCursor: undefined,
|
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) => {},
|
sendPrompt: async (_request) => {},
|
||||||
cancelSession: async (_sessionId) => {},
|
cancelSession: async (_sessionId) => {},
|
||||||
destroySession: async (_sessionId) => {},
|
destroySession: async (_sessionId) => {},
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openhandoff/shared": "workspace:*",
|
"@openhandoff/shared": "workspace:*",
|
||||||
"rivetkit": "2.1.6"
|
"rivetkit": "2.1.6",
|
||||||
|
"sandbox-agent": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsup": "^8.5.0"
|
"tsup": "^8.5.0"
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,14 @@ import type {
|
||||||
StarSandboxAgentRepoResult,
|
StarSandboxAgentRepoResult,
|
||||||
SwitchResult,
|
SwitchResult,
|
||||||
} from "@openhandoff/shared";
|
} 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";
|
import { sandboxInstanceKey, workspaceKey } from "./keys.js";
|
||||||
|
|
||||||
export type HandoffAction = "push" | "sync" | "merge" | "archive" | "kill";
|
export type HandoffAction = "push" | "sync" | "merge" | "archive" | "kill";
|
||||||
|
|
@ -61,6 +69,8 @@ export interface SandboxSessionEventRecord {
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SandboxProcessRecord = ProcessInfo;
|
||||||
|
|
||||||
interface WorkspaceHandle {
|
interface WorkspaceHandle {
|
||||||
addRepo(input: AddRepoInput): Promise<RepoRecord>;
|
addRepo(input: AddRepoInput): Promise<RepoRecord>;
|
||||||
listRepos(input: { workspaceId: string }): Promise<RepoRecord[]>;
|
listRepos(input: { workspaceId: string }): Promise<RepoRecord[]>;
|
||||||
|
|
@ -104,8 +114,15 @@ interface SandboxInstanceHandle {
|
||||||
}): Promise<{ id: string | null; status: "running" | "idle" | "error"; error?: string }>;
|
}): Promise<{ id: string | null; status: "running" | "idle" | "error"; error?: string }>;
|
||||||
listSessions(input?: { cursor?: string; limit?: number }): Promise<{ items: SandboxSessionRecord[]; nextCursor?: 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 }>;
|
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>;
|
sendPrompt(input: { sessionId: string; prompt: string; notification?: boolean }): Promise<void>;
|
||||||
sessionStatus(input: { sessionId: string }): Promise<{ id: string; status: "running" | "idle" | "error" }>;
|
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 }>;
|
providerState(): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,6 +138,7 @@ interface RivetClient {
|
||||||
export interface BackendClientOptions {
|
export interface BackendClientOptions {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
defaultWorkspaceId?: string;
|
defaultWorkspaceId?: string;
|
||||||
|
mode?: "remote" | "mock";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackendMetadata {
|
export interface BackendMetadata {
|
||||||
|
|
@ -163,6 +181,50 @@ export interface BackendClient {
|
||||||
sandboxId: string,
|
sandboxId: string,
|
||||||
input: { sessionId: string; cursor?: string; limit?: number },
|
input: { sessionId: string; cursor?: string; limit?: number },
|
||||||
): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }>;
|
): 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: {
|
sendSandboxPrompt(input: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
providerId: ProviderId;
|
providerId: ProviderId;
|
||||||
|
|
@ -182,6 +244,11 @@ export interface BackendClient {
|
||||||
providerId: ProviderId,
|
providerId: ProviderId,
|
||||||
sandboxId: string,
|
sandboxId: string,
|
||||||
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>;
|
): 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>;
|
getWorkbench(workspaceId: string): Promise<HandoffWorkbenchSnapshot>;
|
||||||
subscribeWorkbench(workspaceId: string, listener: () => void): () => void;
|
subscribeWorkbench(workspaceId: string, listener: () => void): () => void;
|
||||||
createWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse>;
|
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 {
|
export function createBackendClient(options: BackendClientOptions): BackendClient {
|
||||||
|
if (options.mode === "mock") {
|
||||||
|
return createMockBackendClient(options.defaultWorkspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
let clientPromise: Promise<RivetClient> | null = null;
|
let clientPromise: Promise<RivetClient> | null = null;
|
||||||
const workbenchSubscriptions = new Map<
|
const workbenchSubscriptions = new Map<
|
||||||
string,
|
string,
|
||||||
|
|
@ -335,6 +406,13 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
||||||
disposeConnPromise: Promise<(() => Promise<void>) | null> | null;
|
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> => {
|
const getClient = async (): Promise<RivetClient> => {
|
||||||
if (clientPromise) {
|
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 {
|
return {
|
||||||
async addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord> {
|
async addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord> {
|
||||||
return (await workspace(workspaceId)).addRepo({ workspaceId, remoteUrl });
|
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));
|
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: {
|
async sendSandboxPrompt(input: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
providerId: ProviderId;
|
providerId: ProviderId;
|
||||||
|
|
@ -663,6 +899,19 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
||||||
return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.providerState());
|
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> {
|
async getWorkbench(workspaceId: string): Promise<HandoffWorkbenchSnapshot> {
|
||||||
return (await workspace(workspaceId)).getWorkbench({ workspaceId });
|
return (await workspace(workspaceId)).getWorkbench({ workspaceId });
|
||||||
},
|
},
|
||||||
|
|
|
||||||
505
factory/packages/client/src/mock/backend-client.ts
Normal file
505
factory/packages/client/src/mock/backend-client.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"sandbox-agent": "workspace:*",
|
||||||
"styletron-engine-atomic": "^1.6.2",
|
"styletron-engine-atomic": "^1.6.2",
|
||||||
"styletron-react": "^6.1.1"
|
"styletron-react": "^6.1.1"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useStyletron } from "baseui";
|
||||||
|
|
||||||
import { DiffContent } from "./mock-layout/diff-content";
|
import { DiffContent } from "./mock-layout/diff-content";
|
||||||
import { MessageList } from "./mock-layout/message-list";
|
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 { RightSidebar } from "./mock-layout/right-sidebar";
|
||||||
import { Sidebar } from "./mock-layout/sidebar";
|
import { Sidebar } from "./mock-layout/sidebar";
|
||||||
import { TabStrip } from "./mock-layout/tab-strip";
|
import { TabStrip } from "./mock-layout/tab-strip";
|
||||||
|
import { TerminalPane } from "./mock-layout/terminal-pane";
|
||||||
import { TranscriptHeader } from "./mock-layout/transcript-header";
|
import { TranscriptHeader } from "./mock-layout/transcript-header";
|
||||||
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
|
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
|
||||||
import {
|
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 {
|
interface MockLayoutProps {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
selectedHandoffId?: string | null;
|
selectedHandoffId?: string | null;
|
||||||
|
|
@ -1057,7 +1220,8 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
|
||||||
setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths }));
|
setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths }));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<RightSidebar
|
<RightRail
|
||||||
|
workspaceId={workspaceId}
|
||||||
handoff={activeHandoff}
|
handoff={activeHandoff}
|
||||||
activeTabId={activeTabId}
|
activeTabId={activeTabId}
|
||||||
onOpenDiff={openDiffTab}
|
onOpenDiff={openDiffTab}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,8 @@
|
||||||
import { createBackendClient } from "@openhandoff/client";
|
import { createBackendClient } from "@openhandoff/client";
|
||||||
import { backendEndpoint, defaultWorkspaceId } from "./env";
|
import { backendEndpoint, defaultWorkspaceId, frontendClientMode } from "./env";
|
||||||
|
|
||||||
export const backendClient = createBackendClient({
|
export const backendClient = createBackendClient({
|
||||||
endpoint: backendEndpoint,
|
endpoint: backendEndpoint,
|
||||||
defaultWorkspaceId,
|
defaultWorkspaceId,
|
||||||
|
mode: frontendClientMode,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
|
@ -510,6 +510,9 @@ importers:
|
||||||
rivetkit:
|
rivetkit:
|
||||||
specifier: 2.1.6
|
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)
|
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:
|
devDependencies:
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.5.0
|
specifier: ^8.5.0
|
||||||
|
|
@ -547,6 +550,9 @@ importers:
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.1.1
|
specifier: ^19.1.1
|
||||||
version: 19.2.4(react@19.2.4)
|
version: 19.2.4(react@19.2.4)
|
||||||
|
sandbox-agent:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../sdks/typescript
|
||||||
styletron-engine-atomic:
|
styletron-engine-atomic:
|
||||||
specifier: ^1.6.2
|
specifier: ^1.6.2
|
||||||
version: 1.6.2
|
version: 1.6.2
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@
|
||||||
import type { FitAddon as GhosttyFitAddon, Terminal as GhosttyTerminal } from "ghostty-web";
|
import type { FitAddon as GhosttyFitAddon, Terminal as GhosttyTerminal } from "ghostty-web";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { useEffect, useRef, useState } 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";
|
type ConnectionState = "connecting" | "ready" | "closed" | "error";
|
||||||
|
|
||||||
|
|
@ -15,7 +20,9 @@ export interface ProcessTerminalProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
terminalStyle?: CSSProperties;
|
terminalStyle?: CSSProperties;
|
||||||
|
statusBarStyleOverride?: CSSProperties;
|
||||||
height?: number | string;
|
height?: number | string;
|
||||||
|
showStatusBar?: boolean;
|
||||||
onExit?: (status: TerminalExitStatus) => void;
|
onExit?: (status: TerminalExitStatus) => void;
|
||||||
onError?: (error: TerminalErrorStatus | Error) => 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 hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>("connecting");
|
const [connectionState, setConnectionState] = useState<ConnectionState>("connecting");
|
||||||
const [statusMessage, setStatusMessage] = useState("Connecting to PTY...");
|
const [statusMessage, setStatusMessage] = useState("Connecting to PTY...");
|
||||||
|
|
@ -165,7 +183,7 @@ export const ProcessTerminal = ({ client, processId, className, style, terminalS
|
||||||
const nextSession = client.connectProcessTerminal(processId);
|
const nextSession = client.connectProcessTerminal(processId);
|
||||||
session = nextSession;
|
session = nextSession;
|
||||||
|
|
||||||
nextSession.onReady((frame) => {
|
nextSession.onReady((frame: TerminalReadyStatus) => {
|
||||||
if (cancelled || frame.type !== "ready") {
|
if (cancelled || frame.type !== "ready") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -175,14 +193,14 @@ export const ProcessTerminal = ({ client, processId, className, style, terminalS
|
||||||
syncSize();
|
syncSize();
|
||||||
});
|
});
|
||||||
|
|
||||||
nextSession.onData((bytes) => {
|
nextSession.onData((bytes: Uint8Array) => {
|
||||||
if (cancelled || !terminal) {
|
if (cancelled || !terminal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
terminal.write(bytes);
|
terminal.write(bytes);
|
||||||
});
|
});
|
||||||
|
|
||||||
nextSession.onExit((frame) => {
|
nextSession.onExit((frame: TerminalExitStatus) => {
|
||||||
if (cancelled || frame.type !== "exit") {
|
if (cancelled || frame.type !== "exit") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +211,7 @@ export const ProcessTerminal = ({ client, processId, className, style, terminalS
|
||||||
onExit?.(frame);
|
onExit?.(frame);
|
||||||
});
|
});
|
||||||
|
|
||||||
nextSession.onError((error) => {
|
nextSession.onError((error: TerminalErrorStatus | Error) => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -239,10 +257,12 @@ export const ProcessTerminal = ({ client, processId, className, style, terminalS
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} style={{ ...shellStyle, ...style }}>
|
<div className={className} style={{ ...shellStyle, ...style }}>
|
||||||
<div style={statusBarStyle}>
|
{showStatusBar ? (
|
||||||
<span style={{ color: getStatusColor(connectionState) }}>{statusMessage}</span>
|
<div style={{ ...statusBarStyle, ...statusBarStyleOverride }}>
|
||||||
{exitCode != null ? <span style={exitCodeStyle}>exit={exitCode}</span> : null}
|
<span style={{ color: getStatusColor(connectionState) }}>{statusMessage}</span>
|
||||||
</div>
|
{exitCode != null ? <span style={exitCodeStyle}>exit={exitCode}</span> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div
|
<div
|
||||||
ref={hostRef}
|
ref={hostRef}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue