Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Install web dependencies
run: cd web && npm ci

- name: Run tests
run: pnpm test

Expand Down
62 changes: 57 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,11 +293,63 @@ import type { SequencedMessage } from "beamcode";

### Web Consumer

A minimal (500-1000 LOC) HTML/JS client in `src/consumer/` handles:
- E2E decryption of messages from the relay
- Markdown rendering of assistant responses
- Permission request UI (approve/deny with signing)
- Reconnection with message replay
A React 19 + Zustand + Tailwind v4 app in `web/` that builds to a single HTML file (~300 KB, ~94 KB gzip):

- **3-panel responsive layout**: collapsible sidebar, chat view, task panel
- **Multi-adapter visibility**: adapter badges per session (Claude Code, Codex, Gemini CLI, etc.)
- **Rich message rendering**: 3-level grouping (content-block, message, subagent), markdown via `marked` + DOMPurify
- **Streaming UX**: blinking cursor, elapsed time, token count
- **Slash command menu**: categorized typeahead with keyboard navigation
- **Permission UI**: tool-specific previews (Bash commands, Edit diffs, file paths)
- **Context gauge**: color-coded token usage bar (green/yellow/red)
- **Reconnection**: WebSocket connect-on-switch pattern with message replay

#### Development

Two terminals:

```sh
# Terminal 1: Start the beamcode server
pnpm start # Runs on :3456

# Terminal 2: Start the Vite dev server with HMR
pnpm dev:web # Runs on :5174, proxies /ws and /api to :3456
```

Open `http://localhost:5174` for live development with hot module replacement.

#### Building

```sh
pnpm build:web # Builds web/ → single HTML file in web/dist/
pnpm build # Builds library + web consumer, copies to dist/consumer/
```

#### Testing the frontend

1. Verify the build produces a single HTML file under 300 KB:
```sh
cd web && npx vite build && ls -lh dist/index.html
```

2. Run the full build pipeline:
```sh
pnpm build
```

3. Test with a live session:
```sh
pnpm start # Start server on :3456
# Open http://localhost:3456 in a browser
# Send a message, verify streamed response appears
```

4. Test dev workflow with HMR:
```sh
pnpm start & # Background the server
pnpm dev:web # Start Vite dev server
# Open http://localhost:5174
# Edit web/src/ files and verify HMR updates

## Configuration

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@
"README.md"
],
"scripts": {
"build": "tsdown && node scripts/fix-dts.js && node scripts/copy-consumer.js",
"build:lib": "tsdown && node scripts/fix-dts.js",
"build:web": "cd web && npx vite build",
"build": "pnpm build:lib && pnpm build:web && node scripts/copy-consumer.js",
"dev:web": "cd web && npx vite --port 5174",
"start": "node dist/bin/beamcode.js",
"test": "vitest run",
"test:watch": "vitest",
Expand Down
19 changes: 16 additions & 3 deletions scripts/copy-consumer.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
#!/usr/bin/env node
import { cpSync, mkdirSync } from "node:fs";
import { cpSync, existsSync, mkdirSync } from "node:fs";

mkdirSync("dist/consumer", { recursive: true });
cpSync("src/consumer/index.html", "dist/consumer/index.html");
console.log("Copied src/consumer/index.html → dist/consumer/index.html");

// Prefer Vite-built consumer (single-file HTML from web/dist/)
const vitePath = "web/dist/index.html";
const legacyPath = "src/consumer/index.html";

if (existsSync(vitePath)) {
cpSync(vitePath, "dist/consumer/index.html");
console.log("Copied web/dist/index.html → dist/consumer/index.html");
} else if (existsSync(legacyPath)) {
cpSync(legacyPath, "dist/consumer/index.html");
console.log("Copied src/consumer/index.html → dist/consumer/index.html (legacy fallback)");
} else {
console.error("Error: No consumer HTML found. Run 'pnpm build:web' first.");
process.exit(1);
}
224 changes: 224 additions & 0 deletions shared/consumer-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
// ── Flattened consumer-facing types ─────────────────────────────────────────
// Standalone file for the web frontend — NO imports from core/ or backend.
// Mirrors the relevant shapes from src/types/ without pulling in the full chain.

// ── Content Blocks ──────────────────────────────────────────────────────────

export type ConsumerContentBlock =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
| {
type: "tool_result";
tool_use_id: string;
content: string | ConsumerContentBlock[];
is_error?: boolean;
}
| { type: "thinking"; thinking: string; budget_tokens?: number };

// ── Assistant Message ───────────────────────────────────────────────────────

export interface AssistantContent {
id: string;
type: "message";
role: "assistant";
model: string;
content: ConsumerContentBlock[];
stop_reason: string | null;
usage: {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens: number;
cache_read_input_tokens: number;
};
}

// ── Result ──────────────────────────────────────────────────────────────────

export interface ResultData {
subtype:
| "success"
| "error_during_execution"
| "error_max_turns"
| "error_max_budget_usd"
| "error_max_structured_output_retries";
is_error: boolean;
result?: string;
errors?: string[];
duration_ms: number;
duration_api_ms: number;
num_turns: number;
total_cost_usd: number;
stop_reason: string | null;
usage: {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens: number;
cache_read_input_tokens: number;
};
modelUsage?: Record<
string,
{
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
contextWindow: number;
maxOutputTokens: number;
costUSD: number;
}
>;
total_lines_added?: number;
total_lines_removed?: number;
}

// ── Permission Request ──────────────────────────────────────────────────────

export interface ConsumerPermissionRequest {
request_id: string;
tool_name: string;
input: Record<string, unknown>;
permission_suggestions?: unknown[];
description?: string;
tool_use_id: string;
agent_id?: string;
timestamp: number;
}

// ── Command & Model metadata ────────────────────────────────────────────────

export interface InitializeCommand {
name: string;
description: string;
argumentHint?: string;
}

export interface InitializeModel {
value: string;
displayName: string;
description?: string;
}

export interface InitializeAccount {
email?: string;
organization?: string;
subscriptionType?: string;
tokenSource?: string;
apiKeySource?: string;
}

// ── Consumer Session State (flattened subset) ───────────────────────────────

export interface ConsumerSessionState {
session_id: string;
model: string;
cwd: string;
total_cost_usd: number;
num_turns: number;
context_used_percent: number;
is_compacting: boolean;
// Optional fields from deeper state
git_branch?: string;
total_lines_added?: number;
total_lines_removed?: number;
tools?: string[];
permissionMode?: string;
mcp_servers?: { name: string; status: string }[];
last_model_usage?: Record<
string,
{
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
contextWindow: number;
costUSD: number;
}
>;
last_duration_ms?: number;
last_duration_api_ms?: number;
}

// ── Stream Events ──────────────────────────────────────────────────────────

export type StreamEvent =
| { type: "message_start"; message?: Record<string, unknown> }
| {
type: "content_block_delta";
index?: number;
delta: { type: "text_delta"; text: string } | { type: string; [key: string]: unknown };
}
| { type: "content_block_start"; index?: number; content_block?: Record<string, unknown> }
| { type: "content_block_stop"; index?: number }
| { type: "message_delta"; delta?: Record<string, unknown>; usage?: { output_tokens: number } }
| { type: "message_stop" }
| { type: string; [key: string]: unknown };

// ── Connection Status ───────────────────────────────────────────────────────

export type ConnectionStatus = "connected" | "connecting" | "disconnected";

// ── Consumer Role ───────────────────────────────────────────────────────────

export type ConsumerRole = "owner" | "operator" | "participant" | "observer";

// ── Outbound Messages (bridge → consumer) ───────────────────────────────────

export type ConsumerMessage =
| { type: "session_init"; session: ConsumerSessionState & Record<string, unknown> }
| { type: "session_update"; session: Partial<ConsumerSessionState> & Record<string, unknown> }
| { type: "assistant"; message: AssistantContent; parent_tool_use_id: string | null }
| { type: "stream_event"; event: StreamEvent; parent_tool_use_id: string | null }
| { type: "result"; data: ResultData }
| { type: "permission_request"; request: ConsumerPermissionRequest }
| { type: "permission_cancelled"; request_id: string }
| { type: "tool_progress"; tool_use_id: string; tool_name: string; elapsed_time_seconds: number }
| { type: "tool_use_summary"; summary: string; tool_use_ids: string[] }
| { type: "status_change"; status: "compacting" | "idle" | "running" | null }
| { type: "auth_status"; isAuthenticating: boolean; output: string[]; error?: string }
| { type: "error"; message: string }
| { type: "cli_disconnected" }
| { type: "cli_connected" }
| { type: "user_message"; content: string; timestamp: number }
| { type: "message_history"; messages: ConsumerMessage[] }
| { type: "session_name_update"; name: string }
| { type: "identity"; userId: string; displayName: string; role: ConsumerRole }
| {
type: "presence_update";
consumers: Array<{ userId: string; displayName: string; role: ConsumerRole }>;
}
| {
type: "slash_command_result";
command: string;
request_id?: string;
content: string;
source: "emulated" | "pty";
}
| { type: "slash_command_error"; command: string; request_id?: string; error: string }
| {
type: "capabilities_ready";
commands: InitializeCommand[];
models: InitializeModel[];
account: InitializeAccount | null;
};

// ── Inbound Messages (consumer → bridge) ────────────────────────────────────

export type InboundMessage =
| {
type: "user_message";
content: string;
session_id?: string;
images?: { media_type: string; data: string }[];
}
| {
type: "permission_response";
request_id: string;
behavior: "allow" | "deny";
updated_input?: Record<string, unknown>;
message?: string;
}
| { type: "interrupt" }
| { type: "set_model"; model: string }
| { type: "set_permission_mode"; mode: string }
| { type: "presence_query" }
| { type: "slash_command"; command: string; request_id?: string };
Loading