Skip to content

Commit af960a3

Browse files
committed
🤖 refactor: reintroduce ORPC migration for type-safe RPC
This commit reintroduces the ORPC refactoring that was originally merged in #763 and subsequently reverted in #777 due to regressions. ## Original PR: #763 Replaces the custom IPC layer with oRPC for type-safe RPC between browser/renderer and backend processes. ## Why it was reverted (#777) The original migration caused regressions including: - Streaming content delay from ORPC schema validation - Field stripping issues in sendMessage output - Auto-compaction trigger deletion ## What's different this time - Rebased onto latest main which includes fixes that were developed post-revert (model favorites, auto-compaction, etc.) - Conflict resolution preserves upstream features added after revert: - Workspace name collision retry with hash suffix - Mux Gateway coupon code handling with default models - AWS Bedrock credential nested structure ## Key Changes ### Architecture - New ORPC router (src/node/orpc/router.ts) - Central router with Zod schemas - Schema definitions (src/common/orpc/schemas.ts) - Shared validation - ServiceContainer (src/node/services/serviceContainer.ts) - DI container - React integration (src/browser/orpc/react.tsx) - ORPCProvider and useORPC() ### Transport - Desktop (Electron): MessagePort-based RPC via @orpc/server/message-port - Server mode: HTTP + WebSocket via @orpc/server/node and @orpc/server/ws - Auth middleware with timing-safe token comparison ### Removed - src/browser/api.ts (old HTTP/WS client) - src/node/services/ipcMain.ts (old IPC handler registration) - Old IPC method definitions in preload.ts --- _Generated with mux_ fix: add @babel/preset-react for JSX transpilation in Jest tests fix: add missing timestamp field to ToolCallEndEvent schema and usages fix: use ref for ORPCProvider cleanup to avoid stale closure The useEffect cleanup captured state at mount time ('connecting'), so even after transitioning to 'connected', cleanup never ran. Now stores cleanup function in a ref that's always current. test: add feature branch to storybook mock branch list Extend listBranches mock response with a feature branch example to better represent realistic branch listings during component development and testing. fix: batch async message yields to prevent scroll issues The oRPC async generator was yielding messages one at a time with async boundaries, causing premature React renders during history replay. This led to scroll-to-bottom firing before all messages loaded. Extract createAsyncMessageQueue utility that yields all queued messages synchronously (no async pauses within a batch). Used by both the real router and storybook mocks to match original IPC callback behavior. fix: add missing type discriminators to storybook chat messages The MarkdownTables story was emitting messages without the required `type: "message"` field. The `as WorkspaceChatMessage` type casts hid this from TypeScript, causing messages to not be recognized by the chat processor and rendering "No Messages Yet" instead. Also fix ChatEventProcessor tests that had invalid event shapes: - Remove invalid `role`/`timestamp` from stream-start events - Add missing `workspaceId`/`tokens` to delta events fix: use useLayoutEffect for initial scroll to fix Chromatic snapshots The scroll-to-bottom when workspace loads was using useEffect with requestAnimationFrame, which runs asynchronously after browser paint. Chromatic could capture the snapshot before scroll completed. Switch to useLayoutEffect which runs synchronously after DOM mutations but before paint, ensuring scroll happens before Chromatic captures. This also removes the need for the RAF wrapper since useLayoutEffect already guarantees DOM is ready. fix: add play functions to ensure Chromatic captures scrolled state - Add waitForChatScroll play function that waits for messages to load and scroll to complete before Chromatic takes screenshots - Add data-testid="message-window" to scroll container in AIView - Add data-testid="chat-message" to message wrappers for test queries - Apply play function to ActiveWorkspaceWithChat and MarkdownTables stories - Fix ProviderIcon lint error: remove redundant "mux-gateway" union type (already included in ProviderName) fix: resolve React Native typecheck errors - Add missing 'error' event handler in normalizeChatEvent.ts Converts error events to stream-error for mobile display - Fix result.metadata access in WorkspaceScreen.tsx ResultSchema wraps data, so access result.data.metadata not result.metadata - Add RequestInitWithDispatcher type in aiService.ts Extends RequestInit with undici-specific dispatcher property for Node.js fix: use queueMicrotask instead of setTimeout in story mocks Replace setTimeout(..., 100) with queueMicrotask() for message delivery in Storybook mocks. This ensures messages arrive synchronously (in the microtask queue) rather than 100ms later, eliminating timing races with Chromatic's screenshot capture. - All onChat mock callbacks now use queueMicrotask for message delivery - Add chromatic: { delay: 500 } as backup for ActiveWorkspaceWithChat and MarkdownTables stories - Fix import: use storybook/test instead of @storybook/test (Storybook 10) fix: remove deprecated @storybook/addon-interactions from config In Storybook 10, interaction testing is built into the core and the addon-interactions package was deprecated. Remove it from the addons list to fix build failures caused by version mismatch. fix: add semver 7.x to fix storybook build in CI Storybook 10 imports semver/functions/sort.js which only exists in semver 7.x. The root-level semver was 6.x (needed by babel), causing ESM resolution in Node 20.x to fail in CI. Adding semver ^7.x as a direct dependency forces the root-level version to 7.x, which is backwards compatible with 6.x consumers. fix: add missing fields to oRPC provider and workspace schemas The ProviderConfigInfoSchema was missing couponCodeSet (for Mux Gateway) and aws (for Bedrock). oRPC strips fields not in the schema, so these values were never reaching the frontend UI. Also fixes workspace.fork to use FrontendWorkspaceMetadataSchema instead of WorkspaceMetadataSchema, ensuring namedWorkspacePath is not stripped. refactor: consolidate provider types to single source of truth Eliminates triple-definition of ProviderConfigInfo/AWSCredentialStatus types that existed in providerService.ts, Settings/types.ts, and the oRPC schema. Now the Zod schema is the single source of truth, with TypeScript types derived via z.infer. Adds conformance tests that validate oRPC schemas preserve all fields when parsing - this would have caught the missing couponCodeSet/aws fields bug. refactor: improve tool part schema type safety and remove dead code 1. Refactor MuxToolPartSchema using extend pattern for DRY code: - Base schema shares common fields (type, toolCallId, toolName, input, timestamp) - Pending variant: state="input-available", no output field - Available variant: state="output-available", output required 2. Remove dead ReasoningStartEventSchema: - Schema was defined but never emitted by backend - Not in WorkspaceChatMessageSchema union - No type guard existed for it 3. Update MuxToolPart type to use Zod inference: - Type now properly discriminates based on state - Accessing output requires narrowing to output-available state refactor: simplify tool output redaction with type narrowing Remove explicit DynamicToolPart cast in favor of TypeScript's built-in type narrowing after the discriminant check. The type guard `part.type !== "dynamic-tool"` already narrows the type, making the cast redundant. Also eliminate intermediate variables (toolPart, redacted) by returning the new object directly, reducing visual noise. fix: resolve race condition in queued messages test Capture event count before interrupt to establish baseline, then poll for new events rather than waiting for next event. The clear event may arrive before or simultaneously with stream-abort, causing the previous waitForQueuedMessageEvent call to miss it or timeout. refactor: rename ORPC types and hooks to generic API naming Rename ORPCClient to APIClient, useORPC to useAPI, and ORPCProvider to APIProvider. Move the module from src/browser/orpc/react.tsx to src/browser/hooks/useAPI.tsx to better reflect its purpose as a general API client abstraction rather than being tied to the oRPC implementation detail. This decouples the public interface from the underlying transport mechanism, making future backend changes transparent to consumers. refactor: expose connection state from useAPI hook with discriminated union Changed useAPI from returning just the client to returning a discriminated union with connection state, enabling consumers to handle loading/error states with skeleton loaders instead of blocking the entire UI. Changes: - useAPI now returns `{ api, status, error, authenticate, retry }` - `api` is `APIClient | null` based on connection state (type-safe) - `status` is one of: "connecting", "connected", "auth_required", "error" - When `status === "connected"`, TypeScript knows `api` is non-null - Auth modal moved to App.tsx (rendered after all hooks) - Moved file from hooks/useAPI.tsx to contexts/API.tsx (follows codebase convention where Context+Provider+hook combinations live in contexts/) Updated ~60 consumer files with null guards before API calls.
1 parent 5e80c97 commit af960a3

File tree

251 files changed

+15286
-12173
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

251 files changed

+15286
-12173
lines changed

.claude/settings.json

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,5 @@
11
{
22
"hooks": {
3-
"PostToolUse": [
4-
{
5-
"matcher": "Edit|MultiEdit|Write|NotebookEdit",
6-
"hooks": [
7-
{
8-
"type": "command",
9-
"command": "bunx prettier --write \"$1\" 1>/dev/null 2>/dev/null || true"
10-
}
11-
]
12-
}
13-
]
3+
"PostToolUse": []
144
}
155
}

.github/actions/setup-mux/action.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,3 @@ runs:
3636
if: steps.cache-node-modules.outputs.cache-hit != 'true'
3737
shell: bash
3838
run: bun install --frozen-lockfile
39-

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ on:
66
workflow_dispatch:
77
inputs:
88
tag:
9-
description: 'Tag to release (e.g., v1.2.3). If provided, will checkout and release this tag regardless of current branch.'
9+
description: "Tag to release (e.g., v1.2.3). If provided, will checkout and release this tag regardless of current branch."
1010
required: false
1111
type: string
1212

.github/workflows/terminal-bench.yml

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,34 @@ on:
44
workflow_call:
55
inputs:
66
model_name:
7-
description: 'Model to use (e.g., anthropic:claude-sonnet-4-5)'
7+
description: "Model to use (e.g., anthropic:claude-sonnet-4-5)"
88
required: false
99
type: string
1010
thinking_level:
11-
description: 'Thinking level (off, low, medium, high)'
11+
description: "Thinking level (off, low, medium, high)"
1212
required: false
1313
type: string
1414
dataset:
15-
description: 'Terminal-Bench dataset to use'
15+
description: "Terminal-Bench dataset to use"
1616
required: false
1717
type: string
18-
default: 'terminal-bench-core==0.1.1'
18+
default: "terminal-bench-core==0.1.1"
1919
concurrency:
20-
description: 'Number of concurrent tasks (--n-concurrent)'
20+
description: "Number of concurrent tasks (--n-concurrent)"
2121
required: false
2222
type: string
23-
default: '4'
23+
default: "4"
2424
livestream:
25-
description: 'Enable livestream mode (verbose output to console)'
25+
description: "Enable livestream mode (verbose output to console)"
2626
required: false
2727
type: boolean
2828
default: false
2929
sample_size:
30-
description: 'Number of random tasks to run (empty = all tasks)'
30+
description: "Number of random tasks to run (empty = all tasks)"
3131
required: false
3232
type: string
3333
extra_args:
34-
description: 'Additional arguments to pass to terminal-bench'
34+
description: "Additional arguments to pass to terminal-bench"
3535
required: false
3636
type: string
3737
secrets:
@@ -42,34 +42,34 @@ on:
4242
workflow_dispatch:
4343
inputs:
4444
dataset:
45-
description: 'Terminal-Bench dataset to use'
45+
description: "Terminal-Bench dataset to use"
4646
required: false
47-
default: 'terminal-bench-core==0.1.1'
47+
default: "terminal-bench-core==0.1.1"
4848
type: string
4949
concurrency:
50-
description: 'Number of concurrent tasks (--n-concurrent)'
50+
description: "Number of concurrent tasks (--n-concurrent)"
5151
required: false
52-
default: '4'
52+
default: "4"
5353
type: string
5454
livestream:
55-
description: 'Enable livestream mode (verbose output to console)'
55+
description: "Enable livestream mode (verbose output to console)"
5656
required: false
5757
default: false
5858
type: boolean
5959
sample_size:
60-
description: 'Number of random tasks to run (empty = all tasks)'
60+
description: "Number of random tasks to run (empty = all tasks)"
6161
required: false
6262
type: string
6363
model_name:
64-
description: 'Model to use (e.g., anthropic:claude-sonnet-4-5, openai:gpt-5.1-codex)'
64+
description: "Model to use (e.g., anthropic:claude-sonnet-4-5, openai:gpt-5.1-codex)"
6565
required: false
6666
type: string
6767
thinking_level:
68-
description: 'Thinking level (off, low, medium, high)'
68+
description: "Thinking level (off, low, medium, high)"
6969
required: false
7070
type: string
7171
extra_args:
72-
description: 'Additional arguments to pass to terminal-bench'
72+
description: "Additional arguments to pass to terminal-bench"
7373
required: false
7474
type: string
7575

@@ -147,4 +147,3 @@ jobs:
147147
benchmark.log
148148
if-no-files-found: warn
149149
retention-days: 30
150-

.storybook/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from "path";
44

55
const config: StorybookConfig = {
66
stories: ["../src/browser/**/*.stories.@(ts|tsx)"],
7-
addons: ["@storybook/addon-links", "@storybook/addon-docs", "@storybook/addon-interactions"],
7+
addons: ["@storybook/addon-links", "@storybook/addon-docs"],
88
framework: {
99
name: "@storybook/react-vite",
1010
options: {},

.storybook/mocks/orpc.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* Mock ORPC client factory for Storybook stories.
3+
*
4+
* Creates a client that matches the AppRouter interface with configurable mock data.
5+
*/
6+
import type { APIClient } from "@/browser/contexts/API";
7+
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
8+
import type { ProjectConfig } from "@/node/config";
9+
import type { WorkspaceChatMessage } from "@/common/orpc/types";
10+
import type { ChatStats } from "@/common/types/chatStats";
11+
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
12+
import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue";
13+
14+
export interface MockORPCClientOptions {
15+
projects?: Map<string, ProjectConfig>;
16+
workspaces?: FrontendWorkspaceMetadata[];
17+
/** Per-workspace chat callback. Return messages to emit, or use the callback for streaming. */
18+
onChat?: (workspaceId: string, emit: (msg: WorkspaceChatMessage) => void) => (() => void) | void;
19+
/** Mock for executeBash per workspace */
20+
executeBash?: (
21+
workspaceId: string,
22+
script: string
23+
) => Promise<{ success: true; output: string; exitCode: number; wall_duration_ms: number }>;
24+
}
25+
26+
/**
27+
* Creates a mock ORPC client for Storybook.
28+
*
29+
* Usage:
30+
* ```tsx
31+
* const client = createMockORPCClient({
32+
* projects: new Map([...]),
33+
* workspaces: [...],
34+
* onChat: (wsId, emit) => {
35+
* emit({ type: "caught-up" });
36+
* // optionally return cleanup function
37+
* },
38+
* });
39+
*
40+
* return <AppLoader client={client} />;
41+
* ```
42+
*/
43+
export function createMockORPCClient(options: MockORPCClientOptions = {}): APIClient {
44+
const { projects = new Map(), workspaces = [], onChat, executeBash } = options;
45+
46+
const workspaceMap = new Map(workspaces.map((w) => [w.id, w]));
47+
48+
const mockStats: ChatStats = {
49+
consumers: [],
50+
totalTokens: 0,
51+
model: "mock-model",
52+
tokenizerName: "mock-tokenizer",
53+
usageHistory: [],
54+
};
55+
56+
// Cast to ORPCClient - TypeScript can't fully validate the proxy structure
57+
return {
58+
tokenizer: {
59+
countTokens: async () => 0,
60+
countTokensBatch: async (_input: { model: string; texts: string[] }) =>
61+
_input.texts.map(() => 0),
62+
calculateStats: async () => mockStats,
63+
},
64+
server: {
65+
getLaunchProject: async () => null,
66+
},
67+
providers: {
68+
list: async () => [],
69+
getConfig: async () => ({}),
70+
setProviderConfig: async () => ({ success: true, data: undefined }),
71+
setModels: async () => ({ success: true, data: undefined }),
72+
},
73+
general: {
74+
listDirectory: async () => ({ entries: [], hasMore: false }),
75+
ping: async (input: string) => `Pong: ${input}`,
76+
tick: async function* () {
77+
// No-op generator
78+
},
79+
},
80+
projects: {
81+
list: async () => Array.from(projects.entries()),
82+
create: async () => ({
83+
success: true,
84+
data: { projectConfig: { workspaces: [] }, normalizedPath: "/mock/project" },
85+
}),
86+
pickDirectory: async () => null,
87+
listBranches: async () => ({
88+
branches: ["main", "develop", "feature/new-feature"],
89+
recommendedTrunk: "main",
90+
}),
91+
remove: async () => ({ success: true, data: undefined }),
92+
secrets: {
93+
get: async () => [],
94+
update: async () => ({ success: true, data: undefined }),
95+
},
96+
},
97+
workspace: {
98+
list: async () => workspaces,
99+
create: async (input: { projectPath: string; branchName: string }) => ({
100+
success: true,
101+
metadata: {
102+
id: Math.random().toString(36).substring(2, 12),
103+
name: input.branchName,
104+
projectPath: input.projectPath,
105+
projectName: input.projectPath.split("/").pop() ?? "project",
106+
namedWorkspacePath: `/mock/workspace/${input.branchName}`,
107+
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
108+
},
109+
}),
110+
remove: async () => ({ success: true }),
111+
rename: async (input: { workspaceId: string }) => ({
112+
success: true,
113+
data: { newWorkspaceId: input.workspaceId },
114+
}),
115+
fork: async () => ({ success: false, error: "Not implemented in mock" }),
116+
sendMessage: async () => ({ success: true, data: undefined }),
117+
resumeStream: async () => ({ success: true, data: undefined }),
118+
interruptStream: async () => ({ success: true, data: undefined }),
119+
clearQueue: async () => ({ success: true, data: undefined }),
120+
truncateHistory: async () => ({ success: true, data: undefined }),
121+
replaceChatHistory: async () => ({ success: true, data: undefined }),
122+
getInfo: async (input: { workspaceId: string }) =>
123+
workspaceMap.get(input.workspaceId) ?? null,
124+
executeBash: async (input: { workspaceId: string; script: string }) => {
125+
if (executeBash) {
126+
const result = await executeBash(input.workspaceId, input.script);
127+
return { success: true, data: result };
128+
}
129+
return {
130+
success: true,
131+
data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 },
132+
};
133+
},
134+
onChat: async function* (input: { workspaceId: string }) {
135+
if (!onChat) {
136+
yield { type: "caught-up" } as WorkspaceChatMessage;
137+
return;
138+
}
139+
140+
const { push, iterate, end } = createAsyncMessageQueue<WorkspaceChatMessage>();
141+
142+
// Call the user's onChat handler
143+
const cleanup = onChat(input.workspaceId, push);
144+
145+
try {
146+
yield* iterate();
147+
} finally {
148+
end();
149+
cleanup?.();
150+
}
151+
},
152+
onMetadata: async function* () {
153+
// Empty generator - no metadata updates in mock
154+
await new Promise(() => {}); // Never resolves, keeps stream open
155+
},
156+
activity: {
157+
list: async () => ({}),
158+
subscribe: async function* () {
159+
await new Promise(() => {}); // Never resolves
160+
},
161+
},
162+
},
163+
window: {
164+
setTitle: async () => undefined,
165+
},
166+
terminal: {
167+
create: async () => ({
168+
sessionId: "mock-session",
169+
workspaceId: "mock-workspace",
170+
cols: 80,
171+
rows: 24,
172+
}),
173+
close: async () => undefined,
174+
resize: async () => undefined,
175+
sendInput: () => undefined,
176+
onOutput: async function* () {
177+
await new Promise(() => {});
178+
},
179+
onExit: async function* () {
180+
await new Promise(() => {});
181+
},
182+
openWindow: async () => undefined,
183+
closeWindow: async () => undefined,
184+
openNative: async () => undefined,
185+
},
186+
update: {
187+
check: async () => undefined,
188+
download: async () => undefined,
189+
install: () => undefined,
190+
onStatus: async function* () {
191+
await new Promise(() => {});
192+
},
193+
},
194+
} as unknown as APIClient;
195+
}

.storybook/preview.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import React from "react";
1+
import React, { useMemo } from "react";
22
import type { Preview } from "@storybook/react-vite";
33
import { ThemeProvider, type ThemeMode } from "../src/browser/contexts/ThemeContext";
4+
import { APIProvider } from "../src/browser/contexts/API";
5+
import { createMockORPCClient } from "./mocks/orpc";
46
import "../src/browser/styles/globals.css";
57
import { TUTORIAL_STATE_KEY, type TutorialState } from "../src/common/constants/storage";
68

@@ -35,6 +37,16 @@ const preview: Preview = {
3537
theme: "dark",
3638
},
3739
decorators: [
40+
// Global ORPC provider - ensures useORPC works in all stories
41+
(Story) => {
42+
const client = useMemo(() => createMockORPCClient(), []);
43+
return (
44+
<APIProvider client={client}>
45+
<Story />
46+
</APIProvider>
47+
);
48+
},
49+
// Theme provider
3850
(Story, context) => {
3951
// Default to dark if mode not set (e.g., Chromatic headless browser defaults to light)
4052
const mode = (context.globals.theme as ThemeMode | undefined) ?? "dark";

babel.config.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
module.exports = {
2+
presets: [
3+
[
4+
"@babel/preset-env",
5+
{
6+
targets: {
7+
node: "current",
8+
},
9+
modules: "commonjs",
10+
},
11+
],
12+
[
13+
"@babel/preset-typescript",
14+
{
15+
allowDeclareFields: true,
16+
},
17+
],
18+
[
19+
"@babel/preset-react",
20+
{
21+
runtime: "automatic",
22+
},
23+
],
24+
],
25+
};

0 commit comments

Comments
 (0)