Integrate OpenHandoff factory workspace

This commit is contained in:
Nathan Flurry 2026-03-09 13:58:52 -07:00
parent 3d9476ed0b
commit 049504986b
251 changed files with 42824 additions and 692 deletions

View file

@ -0,0 +1,821 @@
import { createClient } from "rivetkit/client";
import type {
AgentType,
AddRepoInput,
AppConfig,
CreateHandoffInput,
HandoffRecord,
HandoffSummary,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchCreateHandoffInput,
HandoffWorkbenchCreateHandoffResponse,
HandoffWorkbenchDiffInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSelectInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
HistoryEvent,
HistoryQueryInput,
ProviderId,
RepoOverview,
RepoStackActionInput,
RepoStackActionResult,
RepoRecord,
SwitchResult
} from "@openhandoff/shared";
import { sandboxInstanceKey, workspaceKey } from "./keys.js";
export type HandoffAction = "push" | "sync" | "merge" | "archive" | "kill";
type RivetMetadataResponse = {
runtime?: string;
actorNames?: Record<string, unknown>;
clientEndpoint?: string;
clientNamespace?: string;
clientToken?: string;
};
export interface SandboxSessionRecord {
id: string;
agent: string;
agentSessionId: string;
lastConnectionId: string;
createdAt: number;
destroyedAt?: number;
status?: "running" | "idle" | "error";
}
export interface SandboxSessionEventRecord {
id: string;
eventIndex: number;
sessionId: string;
createdAt: number;
connectionId: string;
sender: "client" | "agent";
payload: unknown;
}
interface WorkspaceHandle {
addRepo(input: AddRepoInput): Promise<RepoRecord>;
listRepos(input: { workspaceId: string }): Promise<RepoRecord[]>;
createHandoff(input: CreateHandoffInput): Promise<HandoffRecord>;
listHandoffs(input: { workspaceId: string; repoId?: string }): Promise<HandoffSummary[]>;
getRepoOverview(input: { workspaceId: string; repoId: string }): Promise<RepoOverview>;
runRepoStackAction(input: RepoStackActionInput): Promise<RepoStackActionResult>;
history(input: HistoryQueryInput): Promise<HistoryEvent[]>;
switchHandoff(handoffId: string): Promise<SwitchResult>;
getHandoff(input: { workspaceId: string; handoffId: string }): Promise<HandoffRecord>;
attachHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>;
pushHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<void>;
syncHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<void>;
mergeHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<void>;
archiveHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<void>;
killHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<void>;
useWorkspace(input: { workspaceId: string }): Promise<{ workspaceId: string }>;
getWorkbench(input: { workspaceId: string }): Promise<HandoffWorkbenchSnapshot>;
createWorkbenchHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse>;
markWorkbenchUnread(input: HandoffWorkbenchSelectInput): Promise<void>;
renameWorkbenchHandoff(input: HandoffWorkbenchRenameInput): Promise<void>;
renameWorkbenchBranch(input: HandoffWorkbenchRenameInput): Promise<void>;
createWorkbenchSession(input: HandoffWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }>;
renameWorkbenchSession(input: HandoffWorkbenchRenameSessionInput): Promise<void>;
setWorkbenchSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void>;
updateWorkbenchDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void>;
changeWorkbenchModel(input: HandoffWorkbenchChangeModelInput): Promise<void>;
sendWorkbenchMessage(input: HandoffWorkbenchSendMessageInput): Promise<void>;
stopWorkbenchSession(input: HandoffWorkbenchTabInput): Promise<void>;
closeWorkbenchSession(input: HandoffWorkbenchTabInput): Promise<void>;
publishWorkbenchPr(input: HandoffWorkbenchSelectInput): Promise<void>;
revertWorkbenchFile(input: HandoffWorkbenchDiffInput): Promise<void>;
}
interface SandboxInstanceHandle {
createSession(input: { prompt: string; cwd?: string; agent?: AgentType | "opencode" }): 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 }>;
sendPrompt(input: { sessionId: string; prompt: string; notification?: boolean }): Promise<void>;
sessionStatus(input: { sessionId: string }): Promise<{ id: string; status: "running" | "idle" | "error" }>;
providerState(): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>;
}
interface RivetClient {
workspace: {
getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): WorkspaceHandle;
};
sandboxInstance: {
getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): SandboxInstanceHandle;
};
}
export interface BackendClientOptions {
endpoint: string;
defaultWorkspaceId?: string;
}
export interface BackendMetadata {
runtime?: string;
actorNames?: Record<string, unknown>;
clientEndpoint?: string;
clientNamespace?: string;
clientToken?: string;
}
export interface BackendClient {
addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord>;
listRepos(workspaceId: string): Promise<RepoRecord[]>;
createHandoff(input: CreateHandoffInput): Promise<HandoffRecord>;
listHandoffs(workspaceId: string, repoId?: string): Promise<HandoffSummary[]>;
getRepoOverview(workspaceId: string, repoId: string): Promise<RepoOverview>;
runRepoStackAction(input: RepoStackActionInput): Promise<RepoStackActionResult>;
getHandoff(workspaceId: string, handoffId: string): Promise<HandoffRecord>;
listHistory(input: HistoryQueryInput): Promise<HistoryEvent[]>;
switchHandoff(workspaceId: string, handoffId: string): Promise<SwitchResult>;
attachHandoff(workspaceId: string, handoffId: string): Promise<{ target: string; sessionId: string | null }>;
runAction(workspaceId: string, handoffId: string, action: HandoffAction): Promise<void>;
createSandboxSession(input: {
workspaceId: string;
providerId: ProviderId;
sandboxId: string;
prompt: string;
cwd?: string;
agent?: AgentType | "opencode";
}): Promise<{ id: string; status: "running" | "idle" | "error" }>;
listSandboxSessions(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
input?: { cursor?: string; limit?: number }
): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }>;
listSandboxSessionEvents(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
input: { sessionId: string; cursor?: string; limit?: number }
): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }>;
sendSandboxPrompt(input: {
workspaceId: string;
providerId: ProviderId;
sandboxId: string;
sessionId: string;
prompt: string;
notification?: boolean;
}): Promise<void>;
sandboxSessionStatus(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
sessionId: string
): Promise<{ id: string; status: "running" | "idle" | "error" }>;
sandboxProviderState(
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>;
getWorkbench(workspaceId: string): Promise<HandoffWorkbenchSnapshot>;
subscribeWorkbench(workspaceId: string, listener: () => void): () => void;
createWorkbenchHandoff(
workspaceId: string,
input: HandoffWorkbenchCreateHandoffInput
): Promise<HandoffWorkbenchCreateHandoffResponse>;
markWorkbenchUnread(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void>;
renameWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void>;
renameWorkbenchBranch(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void>;
createWorkbenchSession(
workspaceId: string,
input: HandoffWorkbenchSelectInput & { model?: string }
): Promise<{ tabId: string }>;
renameWorkbenchSession(workspaceId: string, input: HandoffWorkbenchRenameSessionInput): Promise<void>;
setWorkbenchSessionUnread(
workspaceId: string,
input: HandoffWorkbenchSetSessionUnreadInput
): Promise<void>;
updateWorkbenchDraft(workspaceId: string, input: HandoffWorkbenchUpdateDraftInput): Promise<void>;
changeWorkbenchModel(workspaceId: string, input: HandoffWorkbenchChangeModelInput): Promise<void>;
sendWorkbenchMessage(workspaceId: string, input: HandoffWorkbenchSendMessageInput): Promise<void>;
stopWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise<void>;
closeWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise<void>;
publishWorkbenchPr(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void>;
revertWorkbenchFile(workspaceId: string, input: HandoffWorkbenchDiffInput): Promise<void>;
health(): Promise<{ ok: true }>;
useWorkspace(workspaceId: string): Promise<{ workspaceId: string }>;
}
export function rivetEndpoint(config: AppConfig): string {
return `http://${config.backend.host}:${config.backend.port}/api/rivet`;
}
export function createBackendClientFromConfig(config: AppConfig): BackendClient {
return createBackendClient({
endpoint: rivetEndpoint(config),
defaultWorkspaceId: config.workspace.default
});
}
function isLoopbackHost(hostname: string): boolean {
const h = hostname.toLowerCase();
return h === "127.0.0.1" || h === "localhost" || h === "0.0.0.0" || h === "::1";
}
function rewriteLoopbackClientEndpoint(clientEndpoint: string, fallbackOrigin: string): string {
const clientUrl = new URL(clientEndpoint);
if (!isLoopbackHost(clientUrl.hostname)) {
return clientUrl.toString().replace(/\/$/, "");
}
const originUrl = new URL(fallbackOrigin);
// Keep the manager port from clientEndpoint; only rewrite host/protocol to match the origin.
clientUrl.hostname = originUrl.hostname;
clientUrl.protocol = originUrl.protocol;
return clientUrl.toString().replace(/\/$/, "");
}
async function fetchJsonWithTimeout(url: string, timeoutMs: number): Promise<unknown> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`request failed: ${res.status} ${res.statusText}`);
}
return (await res.json()) as unknown;
} finally {
clearTimeout(timeout);
}
}
async function fetchMetadataWithRetry(
endpoint: string,
namespace: string | undefined,
opts: { timeoutMs: number; requestTimeoutMs: number }
): Promise<RivetMetadataResponse> {
const base = new URL(endpoint);
base.pathname = base.pathname.replace(/\/$/, "") + "/metadata";
if (namespace) {
base.searchParams.set("namespace", namespace);
}
const start = Date.now();
let delayMs = 250;
// Keep this bounded: callers (UI/CLI) should not hang forever if the backend is down.
for (;;) {
try {
const json = await fetchJsonWithTimeout(base.toString(), opts.requestTimeoutMs);
if (!json || typeof json !== "object") return {};
const data = json as Record<string, unknown>;
return {
runtime: typeof data.runtime === "string" ? data.runtime : undefined,
actorNames:
data.actorNames && typeof data.actorNames === "object"
? (data.actorNames as Record<string, unknown>)
: undefined,
clientEndpoint: typeof data.clientEndpoint === "string" ? data.clientEndpoint : undefined,
clientNamespace: typeof data.clientNamespace === "string" ? data.clientNamespace : undefined,
clientToken: typeof data.clientToken === "string" ? data.clientToken : undefined,
};
} catch (err) {
if (Date.now() - start > opts.timeoutMs) {
throw err;
}
await new Promise((r) => setTimeout(r, delayMs));
delayMs = Math.min(delayMs * 2, 2_000);
}
}
}
export async function readBackendMetadata(input: {
endpoint: string;
namespace?: string;
timeoutMs?: number;
}): Promise<BackendMetadata> {
const base = new URL(input.endpoint);
base.pathname = base.pathname.replace(/\/$/, "") + "/metadata";
if (input.namespace) {
base.searchParams.set("namespace", input.namespace);
}
const json = await fetchJsonWithTimeout(base.toString(), input.timeoutMs ?? 4_000);
if (!json || typeof json !== "object") {
return {};
}
const data = json as Record<string, unknown>;
return {
runtime: typeof data.runtime === "string" ? data.runtime : undefined,
actorNames:
data.actorNames && typeof data.actorNames === "object"
? (data.actorNames as Record<string, unknown>)
: undefined,
clientEndpoint: typeof data.clientEndpoint === "string" ? data.clientEndpoint : undefined,
clientNamespace: typeof data.clientNamespace === "string" ? data.clientNamespace : undefined,
clientToken: typeof data.clientToken === "string" ? data.clientToken : undefined,
};
}
export async function checkBackendHealth(input: {
endpoint: string;
namespace?: string;
timeoutMs?: number;
}): Promise<boolean> {
try {
const metadata = await readBackendMetadata(input);
return metadata.runtime === "rivetkit" && Boolean(metadata.actorNames);
} catch {
return false;
}
}
async function probeMetadataEndpoint(
endpoint: string,
namespace: string | undefined,
timeoutMs: number
): Promise<boolean> {
try {
const base = new URL(endpoint);
base.pathname = base.pathname.replace(/\/$/, "") + "/metadata";
if (namespace) {
base.searchParams.set("namespace", namespace);
}
await fetchJsonWithTimeout(base.toString(), timeoutMs);
return true;
} catch {
return false;
}
}
export function createBackendClient(options: BackendClientOptions): BackendClient {
let clientPromise: Promise<RivetClient> | null = null;
const workbenchSubscriptions = new Map<
string,
{
listeners: Set<() => void>;
disposeConnPromise: Promise<(() => Promise<void>) | null> | null;
}
>();
const getClient = async (): Promise<RivetClient> => {
if (clientPromise) {
return clientPromise;
}
clientPromise = (async () => {
// Use the serverless /metadata endpoint to discover the manager endpoint.
// If the server reports a loopback clientEndpoint (127.0.0.1), rewrite to the same host
// as the configured endpoint so remote browsers/clients can connect.
const configured = new URL(options.endpoint);
const configuredOrigin = `${configured.protocol}//${configured.host}`;
const initialNamespace = undefined;
const metadata = await fetchMetadataWithRetry(options.endpoint, initialNamespace, {
timeoutMs: 30_000,
requestTimeoutMs: 8_000
});
// Candidate endpoint: manager endpoint if provided, otherwise stick to the configured endpoint.
const candidateEndpoint = metadata.clientEndpoint
? rewriteLoopbackClientEndpoint(metadata.clientEndpoint, configuredOrigin)
: options.endpoint;
// If the manager port isn't reachable from this client (common behind reverse proxies),
// fall back to the configured serverless endpoint to avoid hanging requests.
const shouldUseCandidate = metadata.clientEndpoint
? await probeMetadataEndpoint(candidateEndpoint, metadata.clientNamespace, 1_500)
: true;
const resolvedEndpoint = shouldUseCandidate ? candidateEndpoint : options.endpoint;
return createClient({
endpoint: resolvedEndpoint,
namespace: metadata.clientNamespace,
token: metadata.clientToken,
// Prevent rivetkit from overriding back to a loopback endpoint (or to an unreachable manager).
disableMetadataLookup: true,
}) as unknown as RivetClient;
})();
return clientPromise;
};
const workspace = async (workspaceId: string): Promise<WorkspaceHandle> =>
(await getClient()).workspace.getOrCreate(workspaceKey(workspaceId), {
createWithInput: workspaceId
});
const sandboxByKey = async (
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<SandboxInstanceHandle> => {
const client = await getClient();
return (client as any).sandboxInstance.get(sandboxInstanceKey(workspaceId, providerId, sandboxId));
};
function isActorNotFoundError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Actor not found");
}
const sandboxByActorIdFromHandoff = async (
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<SandboxInstanceHandle | null> => {
const ws = await workspace(workspaceId);
const rows = await ws.listHandoffs({ workspaceId });
const candidates = [...rows].sort((a, b) => b.updatedAt - a.updatedAt);
for (const row of candidates) {
try {
const detail = await ws.getHandoff({ workspaceId, handoffId: row.handoffId });
if (detail.providerId !== providerId) {
continue;
}
const sandbox = detail.sandboxes.find((sb) =>
sb.sandboxId === sandboxId &&
sb.providerId === providerId &&
typeof (sb as any).sandboxActorId === "string" &&
(sb as any).sandboxActorId.length > 0
) as ({ sandboxActorId?: string } | undefined);
if (sandbox?.sandboxActorId) {
const client = await getClient();
return (client as any).sandboxInstance.getForId(sandbox.sandboxActorId);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!isActorNotFoundError(error) && !message.includes("Unknown handoff")) {
throw error;
}
// Best effort fallback path; ignore missing handoff actors here.
}
}
return null;
};
const withSandboxHandle = async <T>(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
run: (handle: SandboxInstanceHandle) => Promise<T>
): Promise<T> => {
const handle = await sandboxByKey(workspaceId, providerId, sandboxId);
try {
return await run(handle);
} catch (error) {
if (!isActorNotFoundError(error)) {
throw error;
}
const fallback = await sandboxByActorIdFromHandoff(workspaceId, providerId, sandboxId);
if (!fallback) {
throw error;
}
return await run(fallback);
}
};
const subscribeWorkbench = (workspaceId: string, listener: () => void): (() => void) => {
let entry = workbenchSubscriptions.get(workspaceId);
if (!entry) {
entry = {
listeners: new Set(),
disposeConnPromise: null,
};
workbenchSubscriptions.set(workspaceId, entry);
}
entry.listeners.add(listener);
if (!entry.disposeConnPromise) {
entry.disposeConnPromise = (async () => {
const handle = await workspace(workspaceId);
const conn = (handle as any).connect();
const unsubscribeEvent = conn.on("workbenchUpdated", () => {
const current = workbenchSubscriptions.get(workspaceId);
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 = workbenchSubscriptions.get(workspaceId);
if (!current) {
return;
}
current.listeners.delete(listener);
if (current.listeners.size > 0) {
return;
}
workbenchSubscriptions.delete(workspaceId);
void current.disposeConnPromise?.then(async (disposeConn) => {
await disposeConn?.();
});
};
};
return {
async addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord> {
return (await workspace(workspaceId)).addRepo({ workspaceId, remoteUrl });
},
async listRepos(workspaceId: string): Promise<RepoRecord[]> {
return (await workspace(workspaceId)).listRepos({ workspaceId });
},
async createHandoff(input: CreateHandoffInput): Promise<HandoffRecord> {
return (await workspace(input.workspaceId)).createHandoff(input);
},
async listHandoffs(workspaceId: string, repoId?: string): Promise<HandoffSummary[]> {
return (await workspace(workspaceId)).listHandoffs({ workspaceId, repoId });
},
async getRepoOverview(workspaceId: string, repoId: string): Promise<RepoOverview> {
return (await workspace(workspaceId)).getRepoOverview({ workspaceId, repoId });
},
async runRepoStackAction(input: RepoStackActionInput): Promise<RepoStackActionResult> {
return (await workspace(input.workspaceId)).runRepoStackAction(input);
},
async getHandoff(workspaceId: string, handoffId: string): Promise<HandoffRecord> {
return (await workspace(workspaceId)).getHandoff({
workspaceId,
handoffId
});
},
async listHistory(input: HistoryQueryInput): Promise<HistoryEvent[]> {
return (await workspace(input.workspaceId)).history(input);
},
async switchHandoff(workspaceId: string, handoffId: string): Promise<SwitchResult> {
return (await workspace(workspaceId)).switchHandoff(handoffId);
},
async attachHandoff(workspaceId: string, handoffId: string): Promise<{ target: string; sessionId: string | null }> {
return (await workspace(workspaceId)).attachHandoff({
workspaceId,
handoffId,
reason: "cli.attach"
});
},
async runAction(workspaceId: string, handoffId: string, action: HandoffAction): Promise<void> {
if (action === "push") {
await (await workspace(workspaceId)).pushHandoff({
workspaceId,
handoffId,
reason: "cli.push"
});
return;
}
if (action === "sync") {
await (await workspace(workspaceId)).syncHandoff({
workspaceId,
handoffId,
reason: "cli.sync"
});
return;
}
if (action === "merge") {
await (await workspace(workspaceId)).mergeHandoff({
workspaceId,
handoffId,
reason: "cli.merge"
});
return;
}
if (action === "archive") {
await (await workspace(workspaceId)).archiveHandoff({
workspaceId,
handoffId,
reason: "cli.archive"
});
return;
}
await (await workspace(workspaceId)).killHandoff({
workspaceId,
handoffId,
reason: "cli.kill"
});
},
async createSandboxSession(input: {
workspaceId: string;
providerId: ProviderId;
sandboxId: string;
prompt: string;
cwd?: string;
agent?: AgentType | "opencode";
}): Promise<{ id: string; status: "running" | "idle" | "error" }> {
const created = await withSandboxHandle(
input.workspaceId,
input.providerId,
input.sandboxId,
async (handle) =>
handle.createSession({
prompt: input.prompt,
cwd: input.cwd,
agent: input.agent
})
);
if (!created.id) {
throw new Error(created.error ?? "sandbox session creation failed");
}
return {
id: created.id,
status: created.status
};
},
async listSandboxSessions(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
input?: { cursor?: string; limit?: number }
): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.listSessions(input ?? {})
);
},
async listSandboxSessionEvents(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
input: { sessionId: string; cursor?: string; limit?: number }
): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.listSessionEvents(input)
);
},
async sendSandboxPrompt(input: {
workspaceId: string;
providerId: ProviderId;
sandboxId: string;
sessionId: string;
prompt: string;
notification?: boolean;
}): Promise<void> {
await withSandboxHandle(
input.workspaceId,
input.providerId,
input.sandboxId,
async (handle) =>
handle.sendPrompt({
sessionId: input.sessionId,
prompt: input.prompt,
notification: input.notification
})
);
},
async sandboxSessionStatus(
workspaceId: string,
providerId: ProviderId,
sandboxId: string,
sessionId: string
): Promise<{ id: string; status: "running" | "idle" | "error" }> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.sessionStatus({ sessionId })
);
},
async sandboxProviderState(
workspaceId: string,
providerId: ProviderId,
sandboxId: string
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> {
return await withSandboxHandle(
workspaceId,
providerId,
sandboxId,
async (handle) => handle.providerState()
);
},
async getWorkbench(workspaceId: string): Promise<HandoffWorkbenchSnapshot> {
return (await workspace(workspaceId)).getWorkbench({ workspaceId });
},
subscribeWorkbench(workspaceId: string, listener: () => void): () => void {
return subscribeWorkbench(workspaceId, listener);
},
async createWorkbenchHandoff(
workspaceId: string,
input: HandoffWorkbenchCreateHandoffInput
): Promise<HandoffWorkbenchCreateHandoffResponse> {
return (await workspace(workspaceId)).createWorkbenchHandoff(input);
},
async markWorkbenchUnread(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void> {
await (await workspace(workspaceId)).markWorkbenchUnread(input);
},
async renameWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void> {
await (await workspace(workspaceId)).renameWorkbenchHandoff(input);
},
async renameWorkbenchBranch(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise<void> {
await (await workspace(workspaceId)).renameWorkbenchBranch(input);
},
async createWorkbenchSession(
workspaceId: string,
input: HandoffWorkbenchSelectInput & { model?: string }
): Promise<{ tabId: string }> {
return await (await workspace(workspaceId)).createWorkbenchSession(input);
},
async renameWorkbenchSession(
workspaceId: string,
input: HandoffWorkbenchRenameSessionInput
): Promise<void> {
await (await workspace(workspaceId)).renameWorkbenchSession(input);
},
async setWorkbenchSessionUnread(
workspaceId: string,
input: HandoffWorkbenchSetSessionUnreadInput
): Promise<void> {
await (await workspace(workspaceId)).setWorkbenchSessionUnread(input);
},
async updateWorkbenchDraft(
workspaceId: string,
input: HandoffWorkbenchUpdateDraftInput
): Promise<void> {
await (await workspace(workspaceId)).updateWorkbenchDraft(input);
},
async changeWorkbenchModel(
workspaceId: string,
input: HandoffWorkbenchChangeModelInput
): Promise<void> {
await (await workspace(workspaceId)).changeWorkbenchModel(input);
},
async sendWorkbenchMessage(
workspaceId: string,
input: HandoffWorkbenchSendMessageInput
): Promise<void> {
await (await workspace(workspaceId)).sendWorkbenchMessage(input);
},
async stopWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise<void> {
await (await workspace(workspaceId)).stopWorkbenchSession(input);
},
async closeWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise<void> {
await (await workspace(workspaceId)).closeWorkbenchSession(input);
},
async publishWorkbenchPr(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise<void> {
await (await workspace(workspaceId)).publishWorkbenchPr(input);
},
async revertWorkbenchFile(workspaceId: string, input: HandoffWorkbenchDiffInput): Promise<void> {
await (await workspace(workspaceId)).revertWorkbenchFile(input);
},
async health(): Promise<{ ok: true }> {
const workspaceId = options.defaultWorkspaceId;
if (!workspaceId) {
throw new Error("Backend client default workspace is required for health checks");
}
await (await workspace(workspaceId)).useWorkspace({
workspaceId
});
return { ok: true };
},
async useWorkspace(workspaceId: string): Promise<{ workspaceId: string }> {
return (await workspace(workspaceId)).useWorkspace({ workspaceId });
}
};
}

View file

@ -0,0 +1,4 @@
export * from "./backend-client.js";
export * from "./keys.js";
export * from "./view-model.js";
export * from "./workbench-client.js";

View file

@ -0,0 +1,44 @@
export type ActorKey = string[];
export function workspaceKey(workspaceId: string): ActorKey {
return ["ws", workspaceId];
}
export function projectKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId];
}
export function handoffKey(workspaceId: string, repoId: string, handoffId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "handoff", handoffId];
}
export function sandboxInstanceKey(
workspaceId: string,
providerId: string,
sandboxId: string
): ActorKey {
return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId];
}
export function historyKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "history"];
}
export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "pr-sync"];
}
export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
return ["ws", workspaceId, "project", repoId, "branch-sync"];
}
export function handoffStatusSyncKey(
workspaceId: string,
repoId: string,
handoffId: string,
sandboxId: string,
sessionId: string
): ActorKey {
// Include sandbox + session so multiple sandboxes/sessions can be tracked per handoff.
return ["ws", workspaceId, "project", repoId, "handoff", handoffId, "status-sync", sandboxId, sessionId];
}

View file

@ -0,0 +1,445 @@
import {
MODEL_GROUPS,
buildInitialMockLayoutViewModel,
groupWorkbenchProjects,
nowMs,
providerAgent,
randomReply,
removeFileTreePath,
slugify,
uid,
} from "../workbench-model.js";
import type {
HandoffWorkbenchAddTabResponse,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchCreateHandoffInput,
HandoffWorkbenchCreateHandoffResponse,
HandoffWorkbenchDiffInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSelectInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
WorkbenchAgentTab as AgentTab,
WorkbenchHandoff as Handoff,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@openhandoff/shared";
import type { HandoffWorkbenchClient } from "../workbench-client.js";
function buildTranscriptEvent(params: {
sessionId: string;
sender: "client" | "agent";
createdAt: number;
payload: unknown;
eventIndex: number;
}): TranscriptEvent {
return {
id: uid(),
sessionId: params.sessionId,
sender: params.sender,
createdAt: params.createdAt,
payload: params.payload,
connectionId: "mock-connection",
eventIndex: params.eventIndex,
};
}
class MockWorkbenchStore implements HandoffWorkbenchClient {
private snapshot = buildInitialMockLayoutViewModel();
private listeners = new Set<() => void>();
private pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
getSnapshot(): HandoffWorkbenchSnapshot {
return this.snapshot;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
async createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse> {
const id = uid();
const tabId = `session-${id}`;
const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId);
if (!repo) {
throw new Error(`Cannot create mock handoff for unknown repo ${input.repoId}`);
}
const nextHandoff: Handoff = {
id,
repoId: repo.id,
title: input.title?.trim() || "New Handoff",
status: "new",
repoName: repo.label,
updatedAtMs: nowMs(),
branch: input.branch?.trim() || null,
pullRequest: null,
tabs: [
{
id: tabId,
sessionId: tabId,
sessionName: "Session 1",
agent: providerAgent(MODEL_GROUPS.find((group) => group.models.some((model) => model.id === (input.model ?? "claude-sonnet-4")))?.provider ?? "Claude"),
model: input.model ?? "claude-sonnet-4",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: false,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: [],
},
],
fileChanges: [],
diffs: {},
fileTree: [],
};
this.updateState((current) => ({
...current,
handoffs: [nextHandoff, ...current.handoffs],
}));
return { handoffId: id, tabId };
}
async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise<void> {
this.updateHandoff(input.handoffId, (handoff) => {
const targetTab = handoff.tabs[handoff.tabs.length - 1] ?? null;
if (!targetTab) {
return handoff;
}
return {
...handoff,
tabs: handoff.tabs.map((tab) => (tab.id === targetTab.id ? { ...tab, unread: true } : tab)),
};
});
}
async renameHandoff(input: HandoffWorkbenchRenameInput): Promise<void> {
const value = input.value.trim();
if (!value) {
throw new Error(`Cannot rename handoff ${input.handoffId} to an empty title`);
}
this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, title: value, updatedAtMs: nowMs() }));
}
async renameBranch(input: HandoffWorkbenchRenameInput): Promise<void> {
const value = input.value.trim();
if (!value) {
throw new Error(`Cannot rename branch for handoff ${input.handoffId} to an empty value`);
}
this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, branch: value, updatedAtMs: nowMs() }));
}
async archiveHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, status: "archived", updatedAtMs: nowMs() }));
}
async publishPr(input: HandoffWorkbenchSelectInput): Promise<void> {
const nextPrNumber = Math.max(0, ...this.snapshot.handoffs.map((handoff) => handoff.pullRequest?.number ?? 0)) + 1;
this.updateHandoff(input.handoffId, (handoff) => ({
...handoff,
updatedAtMs: nowMs(),
pullRequest: { number: nextPrNumber, status: "ready" },
}));
}
async revertFile(input: HandoffWorkbenchDiffInput): Promise<void> {
this.updateHandoff(input.handoffId, (handoff) => {
const file = handoff.fileChanges.find((entry) => entry.path === input.path);
const nextDiffs = { ...handoff.diffs };
delete nextDiffs[input.path];
return {
...handoff,
fileChanges: handoff.fileChanges.filter((entry) => entry.path !== input.path),
diffs: nextDiffs,
fileTree: file?.type === "A" ? removeFileTreePath(handoff.fileTree, input.path) : handoff.fileTree,
};
});
}
async updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
this.assertTab(input.handoffId, input.tabId);
this.updateHandoff(input.handoffId, (handoff) => ({
...handoff,
updatedAtMs: nowMs(),
tabs: handoff.tabs.map((tab) =>
tab.id === input.tabId
? {
...tab,
draft: {
text: input.text,
attachments: input.attachments,
updatedAtMs: nowMs(),
},
}
: tab,
),
}));
}
async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void> {
const text = input.text.trim();
if (!text) {
throw new Error(`Cannot send an empty mock prompt for handoff ${input.handoffId}`);
}
this.assertTab(input.handoffId, input.tabId);
const startedAtMs = nowMs();
this.updateHandoff(input.handoffId, (currentHandoff) => {
const isFirstOnHandoff = currentHandoff.status === "new";
const newTitle = isFirstOnHandoff ? (text.length > 50 ? `${text.slice(0, 47)}...` : text) : currentHandoff.title;
const newBranch = isFirstOnHandoff ? `feat/${slugify(newTitle)}` : currentHandoff.branch;
const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)];
const userEvent = buildTranscriptEvent({
sessionId: input.tabId,
sender: "client",
createdAt: startedAtMs,
eventIndex: candidateEventIndex(currentHandoff, input.tabId),
payload: {
method: "session/prompt",
params: {
prompt: userMessageLines.map((line) => ({ type: "text", text: line })),
},
},
});
return {
...currentHandoff,
title: newTitle,
branch: newBranch,
status: "running",
updatedAtMs: startedAtMs,
tabs: currentHandoff.tabs.map((candidate) =>
candidate.id === input.tabId
? {
...candidate,
created: true,
status: "running",
unread: false,
thinkingSinceMs: startedAtMs,
draft: { text: "", attachments: [], updatedAtMs: startedAtMs },
transcript: [...candidate.transcript, userEvent],
}
: candidate,
),
};
});
const existingTimer = this.pendingTimers.get(input.tabId);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
const handoff = this.requireHandoff(input.handoffId);
const replyTab = this.requireTab(handoff, input.tabId);
const completedAtMs = nowMs();
const replyEvent = buildTranscriptEvent({
sessionId: input.tabId,
sender: "agent",
createdAt: completedAtMs,
eventIndex: candidateEventIndex(handoff, input.tabId),
payload: {
result: {
text: randomReply(),
durationMs: completedAtMs - startedAtMs,
},
},
});
this.updateHandoff(input.handoffId, (currentHandoff) => {
const updatedTabs = currentHandoff.tabs.map((candidate) => {
if (candidate.id !== input.tabId) {
return candidate;
}
return {
...candidate,
status: "idle" as const,
thinkingSinceMs: null,
unread: true,
transcript: [...candidate.transcript, replyEvent],
};
});
const anyRunning = updatedTabs.some((candidate) => candidate.status === "running");
return {
...currentHandoff,
updatedAtMs: completedAtMs,
tabs: updatedTabs,
status: currentHandoff.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
};
});
this.pendingTimers.delete(input.tabId);
}, 2_500);
this.pendingTimers.set(input.tabId, timer);
}
async stopAgent(input: HandoffWorkbenchTabInput): Promise<void> {
this.assertTab(input.handoffId, input.tabId);
const existing = this.pendingTimers.get(input.tabId);
if (existing) {
clearTimeout(existing);
this.pendingTimers.delete(input.tabId);
}
this.updateHandoff(input.handoffId, (currentHandoff) => {
const updatedTabs = currentHandoff.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, status: "idle" as const, thinkingSinceMs: null } : candidate,
);
const anyRunning = updatedTabs.some((candidate) => candidate.status === "running");
return {
...currentHandoff,
updatedAtMs: nowMs(),
tabs: updatedTabs,
status: currentHandoff.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
};
});
}
async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
this.updateHandoff(input.handoffId, (currentHandoff) => ({
...currentHandoff,
tabs: currentHandoff.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, unread: input.unread } : candidate,
),
}));
}
async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise<void> {
const title = input.title.trim();
if (!title) {
throw new Error(`Cannot rename session ${input.tabId} to an empty title`);
}
this.updateHandoff(input.handoffId, (currentHandoff) => ({
...currentHandoff,
tabs: currentHandoff.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate,
),
}));
}
async closeTab(input: HandoffWorkbenchTabInput): Promise<void> {
this.updateHandoff(input.handoffId, (currentHandoff) => {
if (currentHandoff.tabs.length <= 1) {
return currentHandoff;
}
return {
...currentHandoff,
tabs: currentHandoff.tabs.filter((candidate) => candidate.id !== input.tabId),
};
});
}
async addTab(input: HandoffWorkbenchSelectInput): Promise<HandoffWorkbenchAddTabResponse> {
this.assertHandoff(input.handoffId);
const nextTab: AgentTab = {
id: uid(),
sessionId: null,
sessionName: `Session ${this.requireHandoff(input.handoffId).tabs.length + 1}`,
agent: "Claude",
model: "claude-sonnet-4",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: false,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: [],
};
this.updateHandoff(input.handoffId, (currentHandoff) => ({
...currentHandoff,
updatedAtMs: nowMs(),
tabs: [...currentHandoff.tabs, nextTab],
}));
return { tabId: nextTab.id };
}
async changeModel(input: HandoffWorkbenchChangeModelInput): Promise<void> {
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((entry) => entry.id === input.model));
if (!group) {
throw new Error(`Unable to resolve model provider for ${input.model}`);
}
this.updateHandoff(input.handoffId, (currentHandoff) => ({
...currentHandoff,
tabs: currentHandoff.tabs.map((candidate) =>
candidate.id === input.tabId ? { ...candidate, model: input.model, agent: providerAgent(group.provider) } : candidate,
),
}));
}
private updateState(updater: (current: HandoffWorkbenchSnapshot) => HandoffWorkbenchSnapshot): void {
const nextSnapshot = updater(this.snapshot);
this.snapshot = {
...nextSnapshot,
projects: groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.handoffs),
};
this.notify();
}
private updateHandoff(handoffId: string, updater: (handoff: Handoff) => Handoff): void {
this.assertHandoff(handoffId);
this.updateState((current) => ({
...current,
handoffs: current.handoffs.map((handoff) => (handoff.id === handoffId ? updater(handoff) : handoff)),
}));
}
private notify(): void {
for (const listener of this.listeners) {
listener();
}
}
private assertHandoff(handoffId: string): void {
this.requireHandoff(handoffId);
}
private assertTab(handoffId: string, tabId: string): void {
const handoff = this.requireHandoff(handoffId);
this.requireTab(handoff, tabId);
}
private requireHandoff(handoffId: string): Handoff {
const handoff = this.snapshot.handoffs.find((candidate) => candidate.id === handoffId);
if (!handoff) {
throw new Error(`Unable to find mock handoff ${handoffId}`);
}
return handoff;
}
private requireTab(handoff: Handoff, tabId: string): AgentTab {
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
if (!tab) {
throw new Error(`Unable to find mock tab ${tabId} in handoff ${handoff.id}`);
}
return tab;
}
}
function candidateEventIndex(handoff: Handoff, tabId: string): number {
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
return (tab?.transcript.length ?? 0) + 1;
}
let sharedMockWorkbenchClient: HandoffWorkbenchClient | null = null;
export function getSharedMockWorkbenchClient(): HandoffWorkbenchClient {
if (!sharedMockWorkbenchClient) {
sharedMockWorkbenchClient = new MockWorkbenchStore();
}
return sharedMockWorkbenchClient;
}

View file

@ -0,0 +1,199 @@
import type {
HandoffWorkbenchAddTabResponse,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchCreateHandoffInput,
HandoffWorkbenchCreateHandoffResponse,
HandoffWorkbenchDiffInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSelectInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
} from "@openhandoff/shared";
import type { BackendClient } from "../backend-client.js";
import { groupWorkbenchProjects } from "../workbench-model.js";
import type { HandoffWorkbenchClient } from "../workbench-client.js";
export interface RemoteWorkbenchClientOptions {
backend: BackendClient;
workspaceId: string;
}
class RemoteWorkbenchStore implements HandoffWorkbenchClient {
private readonly backend: BackendClient;
private readonly workspaceId: string;
private snapshot: HandoffWorkbenchSnapshot;
private readonly listeners = new Set<() => void>();
private unsubscribeWorkbench: (() => void) | null = null;
private refreshPromise: Promise<void> | null = null;
private refreshRetryTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(options: RemoteWorkbenchClientOptions) {
this.backend = options.backend;
this.workspaceId = options.workspaceId;
this.snapshot = {
workspaceId: options.workspaceId,
repos: [],
projects: [],
handoffs: [],
};
}
getSnapshot(): HandoffWorkbenchSnapshot {
return this.snapshot;
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
this.ensureStarted();
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0 && this.refreshRetryTimeout) {
clearTimeout(this.refreshRetryTimeout);
this.refreshRetryTimeout = null;
}
if (this.listeners.size === 0 && this.unsubscribeWorkbench) {
this.unsubscribeWorkbench();
this.unsubscribeWorkbench = null;
}
};
}
async createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse> {
const created = await this.backend.createWorkbenchHandoff(this.workspaceId, input);
await this.refresh();
return created;
}
async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise<void> {
await this.backend.markWorkbenchUnread(this.workspaceId, input);
await this.refresh();
}
async renameHandoff(input: HandoffWorkbenchRenameInput): Promise<void> {
await this.backend.renameWorkbenchHandoff(this.workspaceId, input);
await this.refresh();
}
async renameBranch(input: HandoffWorkbenchRenameInput): Promise<void> {
await this.backend.renameWorkbenchBranch(this.workspaceId, input);
await this.refresh();
}
async archiveHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
await this.backend.runAction(this.workspaceId, input.handoffId, "archive");
await this.refresh();
}
async publishPr(input: HandoffWorkbenchSelectInput): Promise<void> {
await this.backend.publishWorkbenchPr(this.workspaceId, input);
await this.refresh();
}
async revertFile(input: HandoffWorkbenchDiffInput): Promise<void> {
await this.backend.revertWorkbenchFile(this.workspaceId, input);
await this.refresh();
}
async updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void> {
await this.backend.updateWorkbenchDraft(this.workspaceId, input);
await this.refresh();
}
async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void> {
await this.backend.sendWorkbenchMessage(this.workspaceId, input);
await this.refresh();
}
async stopAgent(input: HandoffWorkbenchTabInput): Promise<void> {
await this.backend.stopWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
await this.backend.setWorkbenchSessionUnread(this.workspaceId, input);
await this.refresh();
}
async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise<void> {
await this.backend.renameWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async closeTab(input: HandoffWorkbenchTabInput): Promise<void> {
await this.backend.closeWorkbenchSession(this.workspaceId, input);
await this.refresh();
}
async addTab(input: HandoffWorkbenchSelectInput): Promise<HandoffWorkbenchAddTabResponse> {
const created = await this.backend.createWorkbenchSession(this.workspaceId, input);
await this.refresh();
return created;
}
async changeModel(input: HandoffWorkbenchChangeModelInput): Promise<void> {
await this.backend.changeWorkbenchModel(this.workspaceId, input);
await this.refresh();
}
private ensureStarted(): void {
if (!this.unsubscribeWorkbench) {
this.unsubscribeWorkbench = this.backend.subscribeWorkbench(this.workspaceId, () => {
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
});
}
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
}
private scheduleRefreshRetry(): void {
if (this.refreshRetryTimeout || this.listeners.size === 0) {
return;
}
this.refreshRetryTimeout = setTimeout(() => {
this.refreshRetryTimeout = null;
void this.refresh().catch(() => {
this.scheduleRefreshRetry();
});
}, 1_000);
}
private async refresh(): Promise<void> {
if (this.refreshPromise) {
await this.refreshPromise;
return;
}
this.refreshPromise = (async () => {
const nextSnapshot = await this.backend.getWorkbench(this.workspaceId);
if (this.refreshRetryTimeout) {
clearTimeout(this.refreshRetryTimeout);
this.refreshRetryTimeout = null;
}
this.snapshot = {
...nextSnapshot,
projects: nextSnapshot.projects ?? groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.handoffs),
};
for (const listener of [...this.listeners]) {
listener();
}
})().finally(() => {
this.refreshPromise = null;
});
await this.refreshPromise;
}
}
export function createRemoteWorkbenchClient(
options: RemoteWorkbenchClientOptions,
): HandoffWorkbenchClient {
return new RemoteWorkbenchStore(options);
}

View file

@ -0,0 +1,118 @@
import type { HandoffRecord, HandoffStatus } from "@openhandoff/shared";
export const HANDOFF_STATUS_GROUPS = [
"queued",
"running",
"idle",
"archived",
"killed",
"error"
] as const;
export type HandoffStatusGroup = (typeof HANDOFF_STATUS_GROUPS)[number];
const QUEUED_STATUSES = new Set<HandoffStatus>([
"init_bootstrap_db",
"init_enqueue_provision",
"init_ensure_name",
"init_assert_name",
"init_create_sandbox",
"init_ensure_agent",
"init_start_sandbox_instance",
"init_create_session",
"init_write_db",
"init_start_status_sync",
"init_complete",
"archive_stop_status_sync",
"archive_release_sandbox",
"archive_finalize",
"kill_destroy_sandbox",
"kill_finalize"
]);
export function groupHandoffStatus(status: HandoffStatus): HandoffStatusGroup {
if (status === "running") return "running";
if (status === "idle") return "idle";
if (status === "archived") return "archived";
if (status === "killed") return "killed";
if (status === "error") return "error";
if (QUEUED_STATUSES.has(status)) return "queued";
return "queued";
}
function emptyStatusCounts(): Record<HandoffStatusGroup, number> {
return {
queued: 0,
running: 0,
idle: 0,
archived: 0,
killed: 0,
error: 0
};
}
export interface HandoffSummary {
total: number;
byStatus: Record<HandoffStatusGroup, number>;
byProvider: Record<string, number>;
}
export function fuzzyMatch(target: string, query: string): boolean {
const haystack = target.toLowerCase();
const needle = query.toLowerCase();
let i = 0;
for (const ch of needle) {
i = haystack.indexOf(ch, i);
if (i < 0) {
return false;
}
i += 1;
}
return true;
}
export function filterHandoffs(rows: HandoffRecord[], query: string): HandoffRecord[] {
const q = query.trim();
if (!q) {
return rows;
}
return rows.filter((row) => {
const fields = [
row.branchName ?? "",
row.title ?? "",
row.handoffId,
row.task,
row.prAuthor ?? "",
row.reviewer ?? ""
];
return fields.some((field) => fuzzyMatch(field, q));
});
}
export function formatRelativeAge(updatedAt: number, now = Date.now()): string {
const deltaSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000));
if (deltaSeconds < 60) return `${deltaSeconds}s`;
const minutes = Math.floor(deltaSeconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
export function summarizeHandoffs(rows: HandoffRecord[]): HandoffSummary {
const byStatus = emptyStatusCounts();
const byProvider: Record<string, number> = {};
for (const row of rows) {
byStatus[groupHandoffStatus(row.status)] += 1;
byProvider[row.providerId] = (byProvider[row.providerId] ?? 0) + 1;
}
return {
total: rows.length,
byStatus,
byProvider
};
}

View file

@ -0,0 +1,66 @@
import type {
HandoffWorkbenchAddTabResponse,
HandoffWorkbenchChangeModelInput,
HandoffWorkbenchCreateHandoffInput,
HandoffWorkbenchCreateHandoffResponse,
HandoffWorkbenchDiffInput,
HandoffWorkbenchRenameInput,
HandoffWorkbenchRenameSessionInput,
HandoffWorkbenchSelectInput,
HandoffWorkbenchSetSessionUnreadInput,
HandoffWorkbenchSendMessageInput,
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
} from "@openhandoff/shared";
import type { BackendClient } from "./backend-client.js";
import { getSharedMockWorkbenchClient } from "./mock/workbench-client.js";
import { createRemoteWorkbenchClient } from "./remote/workbench-client.js";
export type HandoffWorkbenchClientMode = "mock" | "remote";
export interface CreateHandoffWorkbenchClientOptions {
mode: HandoffWorkbenchClientMode;
backend?: BackendClient;
workspaceId?: string;
}
export interface HandoffWorkbenchClient {
getSnapshot(): HandoffWorkbenchSnapshot;
subscribe(listener: () => void): () => void;
createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse>;
markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise<void>;
renameHandoff(input: HandoffWorkbenchRenameInput): Promise<void>;
renameBranch(input: HandoffWorkbenchRenameInput): Promise<void>;
archiveHandoff(input: HandoffWorkbenchSelectInput): Promise<void>;
publishPr(input: HandoffWorkbenchSelectInput): Promise<void>;
revertFile(input: HandoffWorkbenchDiffInput): Promise<void>;
updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void>;
sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void>;
stopAgent(input: HandoffWorkbenchTabInput): Promise<void>;
setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void>;
renameSession(input: HandoffWorkbenchRenameSessionInput): Promise<void>;
closeTab(input: HandoffWorkbenchTabInput): Promise<void>;
addTab(input: HandoffWorkbenchSelectInput): Promise<HandoffWorkbenchAddTabResponse>;
changeModel(input: HandoffWorkbenchChangeModelInput): Promise<void>;
}
export function createHandoffWorkbenchClient(
options: CreateHandoffWorkbenchClientOptions,
): HandoffWorkbenchClient {
if (options.mode === "mock") {
return getSharedMockWorkbenchClient();
}
if (!options.backend) {
throw new Error("Remote handoff workbench client requires a backend client");
}
if (!options.workspaceId) {
throw new Error("Remote handoff workbench client requires a workspace id");
}
return createRemoteWorkbenchClient({
backend: options.backend,
workspaceId: options.workspaceId,
});
}

View file

@ -0,0 +1,965 @@
import type {
WorkbenchAgentKind as AgentKind,
WorkbenchAgentTab as AgentTab,
WorkbenchDiffLineKind as DiffLineKind,
WorkbenchFileTreeNode as FileTreeNode,
WorkbenchHandoff as Handoff,
HandoffWorkbenchSnapshot,
WorkbenchHistoryEvent as HistoryEvent,
WorkbenchModelGroup as ModelGroup,
WorkbenchModelId as ModelId,
WorkbenchParsedDiffLine as ParsedDiffLine,
WorkbenchProjectSection,
WorkbenchRepo,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@openhandoff/shared";
export const MODEL_GROUPS: ModelGroup[] = [
{
provider: "Claude",
models: [
{ id: "claude-sonnet-4", label: "Sonnet 4" },
{ id: "claude-opus-4", label: "Opus 4" },
],
},
{
provider: "OpenAI",
models: [
{ id: "gpt-4o", label: "GPT-4o" },
{ id: "o3", label: "o3" },
],
},
];
const MOCK_REPLIES = [
"Got it. I'll work on that now. Let me start by examining the relevant files...",
"I've analyzed the codebase and found the relevant code. Making the changes now...",
"Working on it. I'll update you once I have the implementation ready.",
"Let me look into that. I'll trace through the code to understand the current behavior...",
"Starting on this now. I'll need to modify a few files to implement this properly.",
];
let nextId = 100;
export function uid(): string {
return String(++nextId);
}
export function nowMs(): number {
return Date.now();
}
export function formatThinkingDuration(durationMs: number): string {
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
export function formatMessageDuration(durationMs: number): string {
const totalSeconds = Math.max(1, Math.round(durationMs / 1000));
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
}
export function modelLabel(id: ModelId): string {
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((model) => model.id === id));
const model = group?.models.find((candidate) => candidate.id === id);
return model && group ? `${group.provider} ${model.label}` : id;
}
export function providerAgent(provider: string): AgentKind {
if (provider === "Claude") return "Claude";
if (provider === "OpenAI") return "Codex";
return "Cursor";
}
export function slugify(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
}
export function randomReply(): string {
return MOCK_REPLIES[Math.floor(Math.random() * MOCK_REPLIES.length)]!;
}
const DIFF_PREFIX = "diff:";
export function isDiffTab(id: string): boolean {
return id.startsWith(DIFF_PREFIX);
}
export function diffPath(id: string): string {
return id.slice(DIFF_PREFIX.length);
}
export function diffTabId(path: string): string {
return `${DIFF_PREFIX}${path}`;
}
export function fileName(path: string): string {
return path.split("/").pop() ?? path;
}
function messageOrder(id: string): number {
const match = id.match(/\d+/);
return match ? Number(match[0]) : 0;
}
interface LegacyMessage {
id: string;
role: "agent" | "user";
agent: string | null;
createdAtMs: number;
lines: string[];
durationMs?: number;
}
function transcriptText(payload: unknown): string {
if (!payload || typeof payload !== "object") {
return String(payload ?? "");
}
const envelope = payload as {
method?: unknown;
params?: unknown;
result?: unknown;
error?: unknown;
};
if (envelope.params && typeof envelope.params === "object") {
const prompt = (envelope.params as { prompt?: unknown }).prompt;
if (Array.isArray(prompt)) {
const text = prompt
.map((item) => (item && typeof item === "object" ? (item as { text?: unknown }).text : null))
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.join("\n");
if (text) {
return text;
}
}
const paramsText = (envelope.params as { text?: unknown }).text;
if (typeof paramsText === "string" && paramsText.trim().length > 0) {
return paramsText.trim();
}
}
if (envelope.result && typeof envelope.result === "object") {
const resultText = (envelope.result as { text?: unknown }).text;
if (typeof resultText === "string" && resultText.trim().length > 0) {
return resultText.trim();
}
}
if (envelope.error) {
return JSON.stringify(envelope.error);
}
if (typeof envelope.method === "string") {
return envelope.method;
}
return JSON.stringify(payload);
}
function historyPreview(event: TranscriptEvent): string {
const content = transcriptText(event.payload).trim() || "Untitled event";
return content.length > 42 ? `${content.slice(0, 39)}...` : content;
}
function historyDetail(event: TranscriptEvent): string {
const content = transcriptText(event.payload).trim();
return content || "Untitled event";
}
export function buildHistoryEvents(tabs: AgentTab[]): HistoryEvent[] {
return tabs
.flatMap((tab) =>
tab.transcript
.filter((event) => event.sender === "client")
.map((event) => ({
id: `history-${tab.id}-${event.id}`,
messageId: event.id,
preview: historyPreview(event),
sessionName: tab.sessionName,
tabId: tab.id,
createdAtMs: event.createdAt,
detail: historyDetail(event),
})),
)
.sort((left, right) => messageOrder(left.messageId) - messageOrder(right.messageId));
}
function transcriptFromLegacyMessages(sessionId: string, messages: LegacyMessage[]): TranscriptEvent[] {
return messages.map((message, index) => ({
id: message.id,
eventIndex: index + 1,
sessionId,
createdAt: message.createdAtMs,
connectionId: "mock-connection",
sender: message.role === "user" ? "client" : "agent",
payload:
message.role === "user"
? {
method: "session/prompt",
params: {
prompt: message.lines.map((line) => ({ type: "text", text: line })),
},
}
: {
result: {
text: message.lines.join("\n"),
durationMs: message.durationMs,
},
},
}));
}
const NOW_MS = Date.now();
function minutesAgo(minutes: number): number {
return NOW_MS - minutes * 60_000;
}
export function parseDiffLines(diff: string): ParsedDiffLine[] {
return diff.split("\n").map((text, index) => {
if (text.startsWith("@@")) {
return { kind: "hunk", lineNumber: index + 1, text };
}
if (text.startsWith("+")) {
return { kind: "add", lineNumber: index + 1, text };
}
if (text.startsWith("-")) {
return { kind: "remove", lineNumber: index + 1, text };
}
return { kind: "context", lineNumber: index + 1, text };
});
}
export function removeFileTreePath(nodes: FileTreeNode[], targetPath: string): FileTreeNode[] {
return nodes.flatMap((node) => {
if (node.path === targetPath) {
return [];
}
if (!node.children) {
return [node];
}
const nextChildren = removeFileTreePath(node.children, targetPath);
if (node.isDir && nextChildren.length === 0) {
return [];
}
return [{ ...node, children: nextChildren }];
});
}
export function buildInitialHandoffs(): Handoff[] {
return [
{
id: "h1",
repoId: "acme-backend",
title: "Fix auth token refresh",
status: "idle",
repoName: "acme/backend",
updatedAtMs: minutesAgo(2),
branch: "fix/auth-token-refresh",
pullRequest: { number: 47, status: "draft" },
tabs: [
{
id: "t1",
sessionId: "t1",
sessionName: "Auth token fix",
agent: "Claude",
model: "claude-sonnet-4",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: true,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: transcriptFromLegacyMessages("t1", [
{
id: "m1",
role: "agent",
agent: "claude",
createdAtMs: minutesAgo(12),
lines: [
"I'll fix the auth token refresh logic. Let me start by examining the current implementation in `src/auth/token-manager.ts`.",
"",
"Found the issue - the refresh interval is set to 1 hour but the token expires in 5 minutes. Updating now.",
],
durationMs: 12_000,
},
{
id: "m2",
role: "agent",
agent: "claude",
createdAtMs: minutesAgo(11),
lines: [
"Fixed token refresh in `src/auth/token-manager.ts`. Also updated the retry logic in `src/api/client.ts` to handle 401 responses gracefully.",
],
durationMs: 18_000,
},
{
id: "m3",
role: "user",
agent: null,
createdAtMs: minutesAgo(10),
lines: ["Can you also add unit tests for the refresh logic?"],
},
{
id: "m4",
role: "agent",
agent: "claude",
createdAtMs: minutesAgo(9),
lines: ["Writing tests now in `src/auth/__tests__/token-manager.test.ts`..."],
durationMs: 9_000,
},
]),
},
{
id: "t2",
sessionId: "t2",
sessionName: "Code analysis",
agent: "Codex",
model: "gpt-4o",
status: "idle",
thinkingSinceMs: null,
unread: true,
created: true,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: transcriptFromLegacyMessages("t2", [
{
id: "m5",
role: "agent",
agent: "codex",
createdAtMs: minutesAgo(15),
lines: ["Analyzed the codebase. The auth module uses a simple in-memory token cache with no refresh mechanism."],
durationMs: 21_000,
},
{
id: "m6",
role: "agent",
agent: "codex",
createdAtMs: minutesAgo(14),
lines: ["Suggested approach: add a refresh timer that fires before token expiry. I'll wait for instructions."],
durationMs: 7_000,
},
]),
},
],
fileChanges: [
{ path: "src/auth/token-manager.ts", added: 18, removed: 5, type: "M" },
{ path: "src/api/client.ts", added: 8, removed: 3, type: "M" },
{ path: "src/auth/__tests__/token-manager.test.ts", added: 21, removed: 0, type: "A" },
{ path: "src/types/auth.ts", added: 0, removed: 4, type: "M" },
],
diffs: {
"src/auth/token-manager.ts": [
"@@ -21,10 +21,15 @@ import { TokenCache } from './cache';",
" export class TokenManager {",
" private refreshInterval: number;",
" ",
"- const REFRESH_MS = 3_600_000; // 1 hour",
"+ const REFRESH_MS = 300_000; // 5 minutes",
" ",
"+ async refreshToken(): Promise<string> {",
"+ const newToken = await this.fetchNewToken();",
"+ this.cache.set(newToken);",
"+ return newToken;",
"+ }",
" ",
"- private async onExpiry() {",
"- console.log('token expired');",
"- this.logout();",
"- }",
"+ private async onExpiry() {",
"+ try {",
"+ await this.refreshToken();",
"+ } catch { this.logout(); }",
"+ }",
].join("\n"),
"src/api/client.ts": [
"@@ -45,8 +45,16 @@ export class ApiClient {",
" private async request<T>(url: string, opts?: RequestInit): Promise<T> {",
" const token = await this.tokenManager.getToken();",
" const res = await fetch(url, {",
"- ...opts,",
"- headers: { Authorization: `Bearer ${token}` },",
"+ ...opts, headers: {",
"+ ...opts?.headers,",
"+ Authorization: `Bearer ${token}`,",
"+ },",
" });",
"- return res.json();",
"+ if (res.status === 401) {",
"+ const freshToken = await this.tokenManager.refreshToken();",
"+ const retry = await fetch(url, {",
"+ ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${freshToken}` },",
"+ });",
"+ return retry.json();",
"+ }",
"+ return res.json() as T;",
" }",
].join("\n"),
"src/auth/__tests__/token-manager.test.ts": [
"@@ -0,0 +1,21 @@",
"+import { describe, it, expect, vi } from 'vitest';",
"+import { TokenManager } from '../token-manager';",
"+",
"+describe('TokenManager', () => {",
"+ it('refreshes token before expiry', async () => {",
"+ const mgr = new TokenManager({ expiresIn: 100 });",
"+ const first = await mgr.getToken();",
"+ await new Promise(r => setTimeout(r, 150));",
"+ const second = await mgr.getToken();",
"+ expect(second).not.toBe(first);",
"+ });",
"+",
"+ it('retries on 401', async () => {",
"+ const mgr = new TokenManager();",
"+ const spy = vi.spyOn(mgr, 'refreshToken');",
"+ await mgr.handleUnauthorized();",
"+ expect(spy).toHaveBeenCalledOnce();",
"+ });",
"+",
"+ it('logs out after max retries', async () => {",
"+ const mgr = new TokenManager({ maxRetries: 0 });",
"+ await expect(mgr.handleUnauthorized()).rejects.toThrow();",
"+ });",
"+});",
].join("\n"),
"src/types/auth.ts": [
"@@ -8,10 +8,6 @@ export interface AuthConfig {",
" clientId: string;",
" clientSecret: string;",
" redirectUri: string;",
"- /** @deprecated Use refreshInterval instead */",
"- legacyTimeout?: number;",
"- /** @deprecated */",
"- useOldRefresh?: boolean;",
" }",
" ",
" export interface TokenPayload {",
].join("\n"),
},
fileTree: [
{
name: "src",
path: "src",
isDir: true,
children: [
{
name: "api",
path: "src/api",
isDir: true,
children: [{ name: "client.ts", path: "src/api/client.ts", isDir: false }],
},
{
name: "auth",
path: "src/auth",
isDir: true,
children: [
{
name: "__tests__",
path: "src/auth/__tests__",
isDir: true,
children: [{ name: "token-manager.test.ts", path: "src/auth/__tests__/token-manager.test.ts", isDir: false }],
},
{ name: "token-manager.ts", path: "src/auth/token-manager.ts", isDir: false },
],
},
{
name: "types",
path: "src/types",
isDir: true,
children: [{ name: "auth.ts", path: "src/types/auth.ts", isDir: false }],
},
],
},
],
},
{
id: "h3",
repoId: "acme-backend",
title: "Refactor user service",
status: "idle",
repoName: "acme/backend",
updatedAtMs: minutesAgo(5),
branch: "refactor/user-service",
pullRequest: { number: 52, status: "ready" },
tabs: [
{
id: "t4",
sessionId: "t4",
sessionName: "DI refactor",
agent: "Claude",
model: "claude-opus-4",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: true,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: transcriptFromLegacyMessages("t4", [
{
id: "m20",
role: "user",
agent: null,
createdAtMs: minutesAgo(35),
lines: ["Refactor the user service to use dependency injection."],
},
{
id: "m21",
role: "agent",
agent: "claude",
createdAtMs: minutesAgo(34),
lines: ["Starting refactor. I'll extract interfaces and set up a DI container..."],
durationMs: 14_000,
},
]),
},
],
fileChanges: [
{ path: "src/services/user-service.ts", added: 45, removed: 30, type: "M" },
{ path: "src/services/interfaces.ts", added: 22, removed: 0, type: "A" },
{ path: "src/container.ts", added: 15, removed: 0, type: "A" },
],
diffs: {
"src/services/user-service.ts": [
"@@ -1,35 +1,50 @@",
"-import { db } from '../db';",
"-import { hashPassword, verifyPassword } from '../utils/crypto';",
"-import { sendEmail } from '../utils/email';",
"+import type { IUserRepository, IEmailService, IHashService } from './interfaces';",
" ",
"-export class UserService {",
"- async createUser(email: string, password: string) {",
"- const hash = await hashPassword(password);",
"- const user = await db.users.create({",
"- email,",
"- passwordHash: hash,",
"- createdAt: new Date(),",
"- });",
"- await sendEmail(email, 'Welcome!', 'Thanks for signing up.');",
"- return user;",
"+export class UserService {",
"+ constructor(",
"+ private readonly users: IUserRepository,",
"+ private readonly email: IEmailService,",
"+ private readonly hash: IHashService,",
"+ ) {}",
"+",
"+ async createUser(email: string, password: string) {",
"+ const passwordHash = await this.hash.hash(password);",
"+ const user = await this.users.create({ email, passwordHash });",
"+ await this.email.send(email, 'Welcome!', 'Thanks for signing up.');",
"+ return user;",
" }",
" ",
"- async authenticate(email: string, password: string) {",
"- const user = await db.users.findByEmail(email);",
"+ async authenticate(email: string, password: string) {",
"+ const user = await this.users.findByEmail(email);",
" if (!user) throw new Error('User not found');",
"- const valid = await verifyPassword(password, user.passwordHash);",
"+ const valid = await this.hash.verify(password, user.passwordHash);",
" if (!valid) throw new Error('Invalid password');",
" return user;",
" }",
" ",
"- async deleteUser(id: string) {",
"- await db.users.delete(id);",
"+ async deleteUser(id: string) {",
"+ const user = await this.users.findById(id);",
"+ if (!user) throw new Error('User not found');",
"+ await this.users.delete(id);",
"+ await this.email.send(user.email, 'Account deleted', 'Your account has been removed.');",
" }",
" }",
].join("\n"),
"src/services/interfaces.ts": [
"@@ -0,0 +1,22 @@",
"+export interface IUserRepository {",
"+ create(data: { email: string; passwordHash: string }): Promise<User>;",
"+ findByEmail(email: string): Promise<User | null>;",
"+ findById(id: string): Promise<User | null>;",
"+ delete(id: string): Promise<void>;",
"+}",
"+",
"+export interface IEmailService {",
"+ send(to: string, subject: string, body: string): Promise<void>;",
"+}",
"+",
"+export interface IHashService {",
"+ hash(password: string): Promise<string>;",
"+ verify(password: string, hash: string): Promise<boolean>;",
"+}",
"+",
"+export interface User {",
"+ id: string;",
"+ email: string;",
"+ passwordHash: string;",
"+ createdAt: Date;",
"+}",
].join("\n"),
"src/container.ts": [
"@@ -0,0 +1,15 @@",
"+import { UserService } from './services/user-service';",
"+import { DrizzleUserRepository } from './repos/user-repo';",
"+import { ResendEmailService } from './providers/email';",
"+import { Argon2HashService } from './providers/hash';",
"+import { db } from './db';",
"+",
"+const userRepo = new DrizzleUserRepository(db);",
"+const emailService = new ResendEmailService();",
"+const hashService = new Argon2HashService();",
"+",
"+export const userService = new UserService(",
"+ userRepo,",
"+ emailService,",
"+ hashService,",
"+);",
].join("\n"),
},
fileTree: [
{
name: "src",
path: "src",
isDir: true,
children: [
{ name: "container.ts", path: "src/container.ts", isDir: false },
{
name: "services",
path: "src/services",
isDir: true,
children: [
{ name: "interfaces.ts", path: "src/services/interfaces.ts", isDir: false },
{ name: "user-service.ts", path: "src/services/user-service.ts", isDir: false },
],
},
],
},
],
},
{
id: "h2",
repoId: "acme-frontend",
title: "Add dark mode toggle",
status: "idle",
repoName: "acme/frontend",
updatedAtMs: minutesAgo(15),
branch: "feat/dark-mode",
pullRequest: null,
tabs: [
{
id: "t3",
sessionId: "t3",
sessionName: "Dark mode",
agent: "Claude",
model: "claude-sonnet-4",
status: "idle",
thinkingSinceMs: null,
unread: true,
created: true,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: transcriptFromLegacyMessages("t3", [
{
id: "m10",
role: "user",
agent: null,
createdAtMs: minutesAgo(75),
lines: ["Add a dark mode toggle to the settings page."],
},
{
id: "m11",
role: "agent",
agent: "claude",
createdAtMs: minutesAgo(74),
lines: [
"I've added a dark mode toggle to the settings page. The implementation uses CSS custom properties for theming and persists the user's preference to localStorage.",
],
durationMs: 26_000,
},
]),
},
],
fileChanges: [
{ path: "src/components/settings.tsx", added: 32, removed: 2, type: "M" },
{ path: "src/styles/theme.css", added: 45, removed: 0, type: "A" },
],
diffs: {
"src/components/settings.tsx": [
"@@ -1,5 +1,6 @@",
" import React, { useState, useEffect } from 'react';",
"+import { useTheme } from '../hooks/use-theme';",
" import { Card } from './ui/card';",
" import { Toggle } from './ui/toggle';",
" import { Label } from './ui/label';",
"@@ -15,8 +16,38 @@ export function Settings() {",
" const [notifications, setNotifications] = useState(true);",
"+ const { theme, setTheme } = useTheme();",
"+ const [isDark, setIsDark] = useState(theme === 'dark');",
" ",
" return (",
" <div className=\"settings-page\">",
"+ <Card>",
"+ <h3>Appearance</h3>",
"+ <div className=\"setting-row\">",
"+ <Label htmlFor=\"dark-mode\">Dark Mode</Label>",
"+ <Toggle",
"+ id=\"dark-mode\"",
"+ checked={isDark}",
"+ onCheckedChange={(checked) => {",
"+ setIsDark(checked);",
"+ setTheme(checked ? 'dark' : 'light');",
"+ document.documentElement.setAttribute(",
"+ 'data-theme',",
"+ checked ? 'dark' : 'light'",
"+ );",
"+ }}",
"+ />",
"+ </div>",
"+ <p className=\"setting-description\">",
"+ Toggle between light and dark color schemes.",
"+ Your preference is saved to localStorage.",
"+ </p>",
"+ </Card>",
"+",
" <Card>",
" <h3>Notifications</h3>",
" <div className=\"setting-row\">",
"- <Label>Email notifications</Label>",
"- <Toggle checked={notifications} onCheckedChange={setNotifications} />",
"+ <Label htmlFor=\"notifs\">Email notifications</Label>",
"+ <Toggle id=\"notifs\" checked={notifications} onCheckedChange={setNotifications} />",
" </div>",
" </Card>",
].join("\n"),
"src/styles/theme.css": [
"@@ -0,0 +1,45 @@",
"+:root {",
"+ --bg-primary: #ffffff;",
"+ --bg-secondary: #f8f9fa;",
"+ --bg-tertiary: #e9ecef;",
"+ --text-primary: #212529;",
"+ --text-secondary: #495057;",
"+ --text-muted: #868e96;",
"+ --border-color: #dee2e6;",
"+ --accent: #228be6;",
"+ --accent-hover: #1c7ed6;",
"+}",
"+",
"+[data-theme='dark'] {",
"+ --bg-primary: #09090b;",
"+ --bg-secondary: #18181b;",
"+ --bg-tertiary: #27272a;",
"+ --text-primary: #fafafa;",
"+ --text-secondary: #a1a1aa;",
"+ --text-muted: #71717a;",
"+ --border-color: #3f3f46;",
"+ --accent: #ff4f00;",
"+ --accent-hover: #ff6a00;",
"+}",
"+",
"+body {",
"+ background: var(--bg-primary);",
"+ color: var(--text-primary);",
"+ transition: background 0.2s ease, color 0.2s ease;",
"+}",
"+",
"+.card {",
"+ background: var(--bg-secondary);",
"+ border: 1px solid var(--border-color);",
"+ border-radius: 8px;",
"+ padding: 16px 20px;",
"+}",
"+",
"+.setting-row {",
"+ display: flex;",
"+ align-items: center;",
"+ justify-content: space-between;",
"+ padding: 8px 0;",
"+}",
"+",
"+.setting-description {",
"+ color: var(--text-muted);",
"+ font-size: 13px;",
"+ margin-top: 4px;",
"+}",
].join("\n"),
},
fileTree: [
{
name: "src",
path: "src",
isDir: true,
children: [
{
name: "components",
path: "src/components",
isDir: true,
children: [{ name: "settings.tsx", path: "src/components/settings.tsx", isDir: false }],
},
{
name: "styles",
path: "src/styles",
isDir: true,
children: [{ name: "theme.css", path: "src/styles/theme.css", isDir: false }],
},
],
},
],
},
{
id: "h5",
repoId: "acme-infra",
title: "Update CI pipeline",
status: "archived",
repoName: "acme/infra",
updatedAtMs: minutesAgo(2 * 24 * 60 + 10),
branch: "chore/ci-pipeline",
pullRequest: { number: 38, status: "ready" },
tabs: [
{
id: "t6",
sessionId: "t6",
sessionName: "CI pipeline",
agent: "Claude",
model: "claude-sonnet-4",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: true,
draft: { text: "", attachments: [], updatedAtMs: null },
transcript: transcriptFromLegacyMessages("t6", [
{
id: "m30",
role: "agent",
agent: "claude",
createdAtMs: minutesAgo(2 * 24 * 60 + 60),
lines: ["CI pipeline updated. Added caching for node_modules and parallel test execution."],
durationMs: 33_000,
},
]),
},
],
fileChanges: [{ path: ".github/workflows/ci.yml", added: 20, removed: 8, type: "M" }],
diffs: {
".github/workflows/ci.yml": [
"@@ -12,14 +12,26 @@ jobs:",
" build:",
" runs-on: ubuntu-latest",
" steps:",
"- - uses: actions/checkout@v3",
"- - uses: actions/setup-node@v3",
"+ - uses: actions/checkout@v4",
"+ - uses: actions/setup-node@v4",
" with:",
" node-version: 20",
"- - run: npm ci",
"- - run: npm run build",
"- - run: npm test",
"+ cache: 'pnpm'",
"+ - uses: pnpm/action-setup@v4",
"+ - run: pnpm install --frozen-lockfile",
"+ - run: pnpm build",
"+",
"+ test:",
"+ runs-on: ubuntu-latest",
"+ needs: build",
"+ strategy:",
"+ matrix:",
"+ shard: [1, 2, 3]",
"+ steps:",
"+ - uses: actions/checkout@v4",
"+ - uses: actions/setup-node@v4",
"+ with:",
"+ node-version: 20",
"+ cache: 'pnpm'",
"+ - uses: pnpm/action-setup@v4",
"+ - run: pnpm install --frozen-lockfile",
"+ - run: pnpm test -- --shard=${{ matrix.shard }}/3",
" ",
"- deploy:",
"- needs: build",
"- if: github.ref == 'refs/heads/main'",
"+ deploy:",
"+ needs: [build, test]",
"+ if: github.ref == 'refs/heads/main'",
].join("\n"),
},
fileTree: [
{
name: ".github",
path: ".github",
isDir: true,
children: [
{
name: "workflows",
path: ".github/workflows",
isDir: true,
children: [{ name: "ci.yml", path: ".github/workflows/ci.yml", isDir: false }],
},
],
},
],
},
];
}
export function buildInitialMockLayoutViewModel(): HandoffWorkbenchSnapshot {
const repos: WorkbenchRepo[] = [
{ id: "acme-backend", label: "acme/backend" },
{ id: "acme-frontend", label: "acme/frontend" },
{ id: "acme-infra", label: "acme/infra" },
];
const handoffs = buildInitialHandoffs();
return {
workspaceId: "default",
repos,
projects: groupWorkbenchProjects(repos, handoffs),
handoffs,
};
}
export function groupWorkbenchProjects(repos: WorkbenchRepo[], handoffs: Handoff[]): WorkbenchProjectSection[] {
const grouped = new Map<string, WorkbenchProjectSection>();
for (const repo of repos) {
grouped.set(repo.id, {
id: repo.id,
label: repo.label,
updatedAtMs: 0,
handoffs: [],
});
}
for (const handoff of handoffs) {
const existing = grouped.get(handoff.repoId) ?? {
id: handoff.repoId,
label: handoff.repoName,
updatedAtMs: 0,
handoffs: [],
};
existing.handoffs.push(handoff);
existing.updatedAtMs = Math.max(existing.updatedAtMs, handoff.updatedAtMs);
grouped.set(handoff.repoId, existing);
}
return [...grouped.values()]
.map((project) => ({
...project,
handoffs: [...project.handoffs].sort((a, b) => b.updatedAtMs - a.updatedAtMs),
updatedAtMs:
project.handoffs.length > 0 ? Math.max(...project.handoffs.map((handoff) => handoff.updatedAtMs)) : project.updatedAtMs,
}))
.filter((project) => project.handoffs.length > 0)
.sort((a, b) => b.updatedAtMs - a.updatedAtMs);
}