diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 476ed12..85f828d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,6 +11,8 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy @@ -21,6 +23,35 @@ jobs: node-version: 20 cache: pnpm - run: pnpm install + - name: Run formatter hooks + shell: bash + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch origin "${{ github.base_ref }}" --depth=1 + diff_range="origin/${{ github.base_ref }}...HEAD" + elif [ "${{ github.event_name }}" = "push" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then + diff_range="${{ github.event.before }}...${{ github.sha }}" + else + diff_range="HEAD^...HEAD" + fi + + mapfile -t changed_files < <( + git diff --name-only --diff-filter=ACMR "$diff_range" \ + | grep -E '\.(cjs|cts|js|jsx|json|jsonc|mjs|mts|rs|ts|tsx)$' \ + || true + ) + + if [ ${#changed_files[@]} -eq 0 ]; then + echo "No formatter-managed files changed." + exit 0 + fi + + args=() + for file in "${changed_files[@]}"; do + args+=(--file "$file") + done + + pnpm exec lefthook run pre-commit --no-stage-fixed --fail-on-changes "${args[@]}" - run: npm install -g tsx - name: Run checks run: ./scripts/release/main.ts --version 0.0.0 --only-steps run-ci-checks diff --git a/.mcp.json b/.mcp.json index cc04a2b..7bae219 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,10 +1,8 @@ { "mcpServers": { "everything": { - "args": [ - "@modelcontextprotocol/server-everything" - ], + "args": ["@modelcontextprotocol/server-everything"], "command": "npx" } } -} \ No newline at end of file +} diff --git a/CLAUDE.md b/CLAUDE.md index 6f5c3a1..b43ec83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,14 @@ - `Session` helpers are `prompt(...)`, `rawSend(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, `getModes()`, `respondPermission(...)`, `rawRespondPermission(...)`, and `onPermissionRequest(...)`. - Cleanup is `sdk.dispose()`. +### React Component Methodology + +- Shared React UI belongs in `sdks/react` only when it is reusable outside the Inspector. +- If the same UI pattern is shared between the Sandbox Agent Inspector and Foundry, prefer extracting it into `sdks/react` instead of maintaining parallel implementations. +- Keep shared components unstyled by default: behavior in the package, styling in the consumer via `className`, slot-level `classNames`, render overrides, and `data-*` hooks. +- Prefer extracting reusable pieces such as transcript, composer, and conversation surfaces. Keep Inspector-specific shells such as session selection, session headers, and control-plane actions in `frontend/packages/inspector/`. +- Document all shared React components in `docs/react-components.mdx`, and keep that page aligned with the exported surface in `sdks/react/src/index.ts`. + ### TypeScript SDK Naming Conventions - Use `respond(id, reply)` for SDK methods that reply to an agent-initiated request (e.g. `respondPermission`). This is the standard pattern for answering any inbound JSON-RPC request from the agent. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..4a8bd54 --- /dev/null +++ b/biome.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "formatter": { + "indentStyle": "space", + "lineWidth": 160 + } +} diff --git a/docs/docs.json b/docs/docs.json index 2d57276..a6c2087 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1,131 +1,119 @@ { - "$schema": "https://mintlify.com/docs.json", - "theme": "willow", - "name": "Sandbox Agent SDK", - "appearance": { - "default": "dark", - "strict": true - }, - "colors": { - "primary": "#ff4f00", - "light": "#ff4f00", - "dark": "#ff4f00" - }, - "favicon": "/favicon.svg", - "logo": { - "light": "/logo/light.svg", - "dark": "/logo/dark.svg" - }, - "integrations": { - "posthog": { - "apiKey": "phc_6kfTNEAVw7rn1LA51cO3D69FefbKupSWFaM7OUgEpEo", - "apiHost": "https://ph.rivet.gg", - "sessionRecording": true - } - }, - "navbar": { - "links": [ - { - "label": "Gigacode", - "icon": "terminal", - "href": "https://github.com/rivet-dev/sandbox-agent/tree/main/gigacode" - }, - { - "label": "Discord", - "icon": "discord", - "href": "https://discord.gg/auCecybynK" - }, - { - "type": "github", - "href": "https://github.com/rivet-dev/sandbox-agent" - } - ] - }, - "navigation": { - "tabs": [ - { - "tab": "Documentation", - "pages": [ - { - "group": "Getting started", - "pages": [ - "quickstart", - "sdk-overview", - "react-components", - { - "group": "Deploy", - "icon": "server", - "pages": [ - "deploy/local", - "deploy/computesdk", - "deploy/e2b", - "deploy/daytona", - "deploy/vercel", - "deploy/cloudflare", - "deploy/docker", - "deploy/boxlite" - ] - } - ] - }, - { - "group": "Agent", - "pages": [ - "agent-sessions", - "attachments", - "skills-config", - "mcp-config", - "custom-tools" - ] - }, - { - "group": "System", - "pages": ["file-system", "processes"] - }, - { - "group": "Orchestration", - "pages": [ - "architecture", - "session-persistence", - "observability", - "multiplayer", - "security" - ] - }, - { - "group": "Reference", - "pages": [ - "agent-capabilities", - "cli", - "inspector", - "opencode-compatibility", - { - "group": "More", - "pages": [ - "credentials", - "daemon", - "cors", - "session-restoration", - "telemetry", - { - "group": "AI", - "pages": ["ai/skill", "ai/llms-txt"] - } - ] - } - ] - } - ] - }, - { - "tab": "HTTP API", - "pages": [ - { - "group": "HTTP Reference", - "openapi": "openapi.json" - } - ] - } - ] - } + "$schema": "https://mintlify.com/docs.json", + "theme": "willow", + "name": "Sandbox Agent SDK", + "appearance": { + "default": "dark", + "strict": true + }, + "colors": { + "primary": "#ff4f00", + "light": "#ff4f00", + "dark": "#ff4f00" + }, + "favicon": "/favicon.svg", + "logo": { + "light": "/logo/light.svg", + "dark": "/logo/dark.svg" + }, + "integrations": { + "posthog": { + "apiKey": "phc_6kfTNEAVw7rn1LA51cO3D69FefbKupSWFaM7OUgEpEo", + "apiHost": "https://ph.rivet.gg", + "sessionRecording": true + } + }, + "navbar": { + "links": [ + { + "label": "Gigacode", + "icon": "terminal", + "href": "https://github.com/rivet-dev/sandbox-agent/tree/main/gigacode" + }, + { + "label": "Discord", + "icon": "discord", + "href": "https://discord.gg/auCecybynK" + }, + { + "type": "github", + "href": "https://github.com/rivet-dev/sandbox-agent" + } + ] + }, + "navigation": { + "tabs": [ + { + "tab": "Documentation", + "pages": [ + { + "group": "Getting started", + "pages": [ + "quickstart", + "sdk-overview", + "react-components", + { + "group": "Deploy", + "icon": "server", + "pages": [ + "deploy/local", + "deploy/computesdk", + "deploy/e2b", + "deploy/daytona", + "deploy/vercel", + "deploy/cloudflare", + "deploy/docker", + "deploy/boxlite" + ] + } + ] + }, + { + "group": "Agent", + "pages": ["agent-sessions", "attachments", "skills-config", "mcp-config", "custom-tools"] + }, + { + "group": "System", + "pages": ["file-system", "processes"] + }, + { + "group": "Orchestration", + "pages": ["architecture", "session-persistence", "observability", "multiplayer", "security"] + }, + { + "group": "Reference", + "pages": [ + "agent-capabilities", + "cli", + "inspector", + "opencode-compatibility", + { + "group": "More", + "pages": [ + "credentials", + "daemon", + "cors", + "session-restoration", + "telemetry", + { + "group": "AI", + "pages": ["ai/skill", "ai/llms-txt"] + } + ] + } + ] + } + ] + }, + { + "tab": "HTTP API", + "pages": [ + { + "group": "HTTP Reference", + "openapi": "openapi.json" + } + ] + } + ] + } } diff --git a/docs/openapi.json b/docs/openapi.json index 5735c51..a984b28 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -20,9 +20,7 @@ "paths": { "/v1/acp": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "get_v1_acp_servers", "responses": { "200": { @@ -40,9 +38,7 @@ }, "/v1/acp/{server_id}": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "get_v1_acp", "parameters": [ { @@ -92,9 +88,7 @@ } }, "post": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "post_v1_acp", "parameters": [ { @@ -204,9 +198,7 @@ } }, "delete": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "delete_v1_acp", "parameters": [ { @@ -228,9 +220,7 @@ }, "/v1/agents": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "get_v1_agents", "parameters": [ { @@ -280,9 +270,7 @@ }, "/v1/agents/{agent}": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "get_v1_agent", "parameters": [ { @@ -351,9 +339,7 @@ }, "/v1/agents/{agent}/install": { "post": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "post_v1_agent_install", "parameters": [ { @@ -412,9 +398,7 @@ }, "/v1/config/mcp": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "get_v1_config_mcp", "parameters": [ { @@ -460,9 +444,7 @@ } }, "put": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "put_v1_config_mcp", "parameters": [ { @@ -501,9 +483,7 @@ } }, "delete": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "delete_v1_config_mcp", "parameters": [ { @@ -534,9 +514,7 @@ }, "/v1/config/skills": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "get_v1_config_skills", "parameters": [ { @@ -582,9 +560,7 @@ } }, "put": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "put_v1_config_skills", "parameters": [ { @@ -623,9 +599,7 @@ } }, "delete": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "delete_v1_config_skills", "parameters": [ { @@ -656,9 +630,7 @@ }, "/v1/fs/entries": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "get_v1_fs_entries", "parameters": [ { @@ -691,9 +663,7 @@ }, "/v1/fs/entry": { "delete": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "delete_v1_fs_entry", "parameters": [ { @@ -732,9 +702,7 @@ }, "/v1/fs/file": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "get_v1_fs_file", "parameters": [ { @@ -754,9 +722,7 @@ } }, "put": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "put_v1_fs_file", "parameters": [ { @@ -796,9 +762,7 @@ }, "/v1/fs/mkdir": { "post": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "post_v1_fs_mkdir", "parameters": [ { @@ -827,9 +791,7 @@ }, "/v1/fs/move": { "post": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "post_v1_fs_move", "requestBody": { "content": { @@ -857,9 +819,7 @@ }, "/v1/fs/stat": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "get_v1_fs_stat", "parameters": [ { @@ -888,9 +848,7 @@ }, "/v1/fs/upload-batch": { "post": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "post_v1_fs_upload_batch", "parameters": [ { @@ -931,9 +889,7 @@ }, "/v1/health": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "operationId": "get_v1_health", "responses": { "200": { @@ -951,9 +907,7 @@ }, "/v1/processes": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "summary": "List all managed processes.", "description": "Returns a list of all processes (running and exited) currently tracked\nby the runtime, sorted by process ID.", "operationId": "get_v1_processes", @@ -981,9 +935,7 @@ } }, "post": { - "tags": [ - "v1" - ], + "tags": ["v1"], "summary": "Create a long-lived managed process.", "description": "Spawns a new process with the given command and arguments. Supports both\npipe-based and PTY (tty) modes. Returns the process descriptor on success.", "operationId": "post_v1_processes", @@ -1043,9 +995,7 @@ }, "/v1/processes/config": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "summary": "Get process runtime configuration.", "description": "Returns the current runtime configuration for the process management API,\nincluding limits for concurrency, timeouts, and buffer sizes.", "operationId": "get_v1_processes_config", @@ -1073,9 +1023,7 @@ } }, "post": { - "tags": [ - "v1" - ], + "tags": ["v1"], "summary": "Update process runtime configuration.", "description": "Replaces the runtime configuration for the process management API.\nValidates that all values are non-zero and clamps default timeout to max.", "operationId": "post_v1_processes_config", @@ -1125,9 +1073,7 @@ }, "/v1/processes/run": { "post": { - "tags": [ - "v1" - ], + "tags": ["v1"], "summary": "Run a one-shot command.", "description": "Executes a command to completion and returns its stdout, stderr, exit code,\nand duration. Supports configurable timeout and output size limits.", "operationId": "post_v1_processes_run", @@ -1177,9 +1123,7 @@ }, "/v1/processes/{id}": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "summary": "Get a single process by ID.", "description": "Returns the current state of a managed process including its status,\nPID, exit code, and creation/exit timestamps.", "operationId": "get_v1_process", @@ -1228,9 +1172,7 @@ } }, "delete": { - "tags": [ - "v1" - ], + "tags": ["v1"], "summary": "Delete a process record.", "description": "Removes a stopped process from the runtime. Returns 409 if the process\nis still running; stop or kill it first.", "operationId": "delete_v1_process", @@ -1284,9 +1226,7 @@ }, "/v1/processes/{id}/input": { "post": { - "tags": [ - "v1" - ], + "tags": ["v1"], "summary": "Write input to a process.", "description": "Sends data to a process's stdin (pipe mode) or PTY writer (tty mode).\nData can be encoded as base64, utf8, or text. Returns 413 if the decoded\npayload exceeds the configured `maxInputBytesPerRequest` limit.", "operationId": "post_v1_process_input", @@ -1367,9 +1307,7 @@ }, "/v1/processes/{id}/kill": { "post": { - "tags": [ - "v1" - ], + "tags": ["v1"], "summary": "Send SIGKILL to a process.", "description": "Sends SIGKILL to the process and optionally waits up to `waitMs`\nmilliseconds for the process to exit before returning.", "operationId": "post_v1_process_kill", @@ -1432,9 +1370,7 @@ }, "/v1/processes/{id}/logs": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "summary": "Fetch process logs.", "description": "Returns buffered log entries for a process. Supports filtering by stream\ntype, tail count, and sequence-based resumption. When `follow=true`,\nreturns an SSE stream that replays buffered entries then streams live output.", "operationId": "get_v1_process_logs", @@ -1532,9 +1468,7 @@ }, "/v1/processes/{id}/stop": { "post": { - "tags": [ - "v1" - ], + "tags": ["v1"], "summary": "Send SIGTERM to a process.", "description": "Sends SIGTERM to the process and optionally waits up to `waitMs`\nmilliseconds for the process to exit before returning.", "operationId": "post_v1_process_stop", @@ -1597,9 +1531,7 @@ }, "/v1/processes/{id}/terminal/resize": { "post": { - "tags": [ - "v1" - ], + "tags": ["v1"], "summary": "Resize a process terminal.", "description": "Sets the PTY window size (columns and rows) for a tty-mode process and\nsends SIGWINCH so the child process can adapt.", "operationId": "post_v1_process_terminal_resize", @@ -1680,9 +1612,7 @@ }, "/v1/processes/{id}/terminal/ws": { "get": { - "tags": [ - "v1" - ], + "tags": ["v1"], "summary": "Open an interactive WebSocket terminal session.", "description": "Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts\n`access_token` query param for browser-based auth (WebSocket API cannot\nsend custom headers). Streams raw PTY output as binary frames and accepts\nJSON control frames for input, resize, and close.", "operationId": "get_v1_process_terminal_ws", @@ -1759,9 +1689,7 @@ "schemas": { "AcpEnvelope": { "type": "object", - "required": [ - "jsonrpc" - ], + "required": ["jsonrpc"], "properties": { "error": { "nullable": true @@ -1795,11 +1723,7 @@ }, "AcpServerInfo": { "type": "object", - "required": [ - "serverId", - "agent", - "createdAtMs" - ], + "required": ["serverId", "agent", "createdAtMs"], "properties": { "agent": { "type": "string" @@ -1815,9 +1739,7 @@ }, "AcpServerListResponse": { "type": "object", - "required": [ - "servers" - ], + "required": ["servers"], "properties": { "servers": { "type": "array", @@ -1908,12 +1830,7 @@ }, "AgentInfo": { "type": "object", - "required": [ - "id", - "installed", - "credentialsAvailable", - "capabilities" - ], + "required": ["id", "installed", "credentialsAvailable", "capabilities"], "properties": { "capabilities": { "$ref": "#/components/schemas/AgentCapabilities" @@ -1956,11 +1873,7 @@ }, "AgentInstallArtifact": { "type": "object", - "required": [ - "kind", - "path", - "source" - ], + "required": ["kind", "path", "source"], "properties": { "kind": { "type": "string" @@ -1996,10 +1909,7 @@ }, "AgentInstallResponse": { "type": "object", - "required": [ - "already_installed", - "artifacts" - ], + "required": ["already_installed", "artifacts"], "properties": { "already_installed": { "type": "boolean" @@ -2014,9 +1924,7 @@ }, "AgentListResponse": { "type": "object", - "required": [ - "agents" - ], + "required": ["agents"], "properties": { "agents": { "type": "array", @@ -2049,9 +1957,7 @@ }, "FsActionResponse": { "type": "object", - "required": [ - "path" - ], + "required": ["path"], "properties": { "path": { "type": "string" @@ -2060,9 +1966,7 @@ }, "FsDeleteQuery": { "type": "object", - "required": [ - "path" - ], + "required": ["path"], "properties": { "path": { "type": "string" @@ -2084,12 +1988,7 @@ }, "FsEntry": { "type": "object", - "required": [ - "name", - "path", - "entryType", - "size" - ], + "required": ["name", "path", "entryType", "size"], "properties": { "entryType": { "$ref": "#/components/schemas/FsEntryType" @@ -2113,17 +2012,11 @@ }, "FsEntryType": { "type": "string", - "enum": [ - "file", - "directory" - ] + "enum": ["file", "directory"] }, "FsMoveRequest": { "type": "object", - "required": [ - "from", - "to" - ], + "required": ["from", "to"], "properties": { "from": { "type": "string" @@ -2139,10 +2032,7 @@ }, "FsMoveResponse": { "type": "object", - "required": [ - "from", - "to" - ], + "required": ["from", "to"], "properties": { "from": { "type": "string" @@ -2154,9 +2044,7 @@ }, "FsPathQuery": { "type": "object", - "required": [ - "path" - ], + "required": ["path"], "properties": { "path": { "type": "string" @@ -2165,11 +2053,7 @@ }, "FsStat": { "type": "object", - "required": [ - "path", - "entryType", - "size" - ], + "required": ["path", "entryType", "size"], "properties": { "entryType": { "$ref": "#/components/schemas/FsEntryType" @@ -2199,10 +2083,7 @@ }, "FsUploadBatchResponse": { "type": "object", - "required": [ - "paths", - "truncated" - ], + "required": ["paths", "truncated"], "properties": { "paths": { "type": "array", @@ -2217,10 +2098,7 @@ }, "FsWriteResponse": { "type": "object", - "required": [ - "path", - "bytesWritten" - ], + "required": ["path", "bytesWritten"], "properties": { "bytesWritten": { "type": "integer", @@ -2234,9 +2112,7 @@ }, "HealthResponse": { "type": "object", - "required": [ - "status" - ], + "required": ["status"], "properties": { "status": { "type": "string" @@ -2245,10 +2121,7 @@ }, "McpConfigQuery": { "type": "object", - "required": [ - "directory", - "mcpName" - ], + "required": ["directory", "mcpName"], "properties": { "directory": { "type": "string" @@ -2262,10 +2135,7 @@ "oneOf": [ { "type": "object", - "required": [ - "command", - "type" - ], + "required": ["command", "type"], "properties": { "args": { "type": "array", @@ -2299,18 +2169,13 @@ }, "type": { "type": "string", - "enum": [ - "local" - ] + "enum": ["local"] } } }, { "type": "object", - "required": [ - "url", - "type" - ], + "required": ["url", "type"], "properties": { "bearerTokenEnvVar": { "type": "string", @@ -2358,9 +2223,7 @@ }, "type": { "type": "string", - "enum": [ - "remote" - ] + "enum": ["remote"] }, "url": { "type": "string" @@ -2374,11 +2237,7 @@ }, "ProblemDetails": { "type": "object", - "required": [ - "type", - "title", - "status" - ], + "required": ["type", "title", "status"], "properties": { "detail": { "type": "string", @@ -2404,14 +2263,7 @@ }, "ProcessConfig": { "type": "object", - "required": [ - "maxConcurrentProcesses", - "defaultRunTimeoutMs", - "maxRunTimeoutMs", - "maxOutputBytes", - "maxLogBytesPerProcess", - "maxInputBytesPerRequest" - ], + "required": ["maxConcurrentProcesses", "defaultRunTimeoutMs", "maxRunTimeoutMs", "maxOutputBytes", "maxLogBytesPerProcess", "maxInputBytesPerRequest"], "properties": { "defaultRunTimeoutMs": { "type": "integer", @@ -2443,9 +2295,7 @@ }, "ProcessCreateRequest": { "type": "object", - "required": [ - "command" - ], + "required": ["command"], "properties": { "args": { "type": "array", @@ -2476,15 +2326,7 @@ }, "ProcessInfo": { "type": "object", - "required": [ - "id", - "command", - "args", - "tty", - "interactive", - "status", - "createdAtMs" - ], + "required": ["id", "command", "args", "tty", "interactive", "status", "createdAtMs"], "properties": { "args": { "type": "array", @@ -2535,9 +2377,7 @@ }, "ProcessInputRequest": { "type": "object", - "required": [ - "data" - ], + "required": ["data"], "properties": { "data": { "type": "string" @@ -2550,9 +2390,7 @@ }, "ProcessInputResponse": { "type": "object", - "required": [ - "bytesWritten" - ], + "required": ["bytesWritten"], "properties": { "bytesWritten": { "type": "integer", @@ -2562,9 +2400,7 @@ }, "ProcessListResponse": { "type": "object", - "required": [ - "processes" - ], + "required": ["processes"], "properties": { "processes": { "type": "array", @@ -2576,13 +2412,7 @@ }, "ProcessLogEntry": { "type": "object", - "required": [ - "sequence", - "stream", - "timestampMs", - "data", - "encoding" - ], + "required": ["sequence", "stream", "timestampMs", "data", "encoding"], "properties": { "data": { "type": "string" @@ -2634,11 +2464,7 @@ }, "ProcessLogsResponse": { "type": "object", - "required": [ - "processId", - "stream", - "entries" - ], + "required": ["processId", "stream", "entries"], "properties": { "entries": { "type": "array", @@ -2656,18 +2482,11 @@ }, "ProcessLogsStream": { "type": "string", - "enum": [ - "stdout", - "stderr", - "combined", - "pty" - ] + "enum": ["stdout", "stderr", "combined", "pty"] }, "ProcessRunRequest": { "type": "object", - "required": [ - "command" - ], + "required": ["command"], "properties": { "args": { "type": "array", @@ -2703,14 +2522,7 @@ }, "ProcessRunResponse": { "type": "object", - "required": [ - "timedOut", - "stdout", - "stderr", - "stdoutTruncated", - "stderrTruncated", - "durationMs" - ], + "required": ["timedOut", "stdout", "stderr", "stdoutTruncated", "stderrTruncated", "durationMs"], "properties": { "durationMs": { "type": "integer", @@ -2752,17 +2564,11 @@ }, "ProcessState": { "type": "string", - "enum": [ - "running", - "exited" - ] + "enum": ["running", "exited"] }, "ProcessTerminalResizeRequest": { "type": "object", - "required": [ - "cols", - "rows" - ], + "required": ["cols", "rows"], "properties": { "cols": { "type": "integer", @@ -2778,10 +2584,7 @@ }, "ProcessTerminalResizeResponse": { "type": "object", - "required": [ - "cols", - "rows" - ], + "required": ["cols", "rows"], "properties": { "cols": { "type": "integer", @@ -2797,16 +2600,11 @@ }, "ServerStatus": { "type": "string", - "enum": [ - "running", - "stopped" - ] + "enum": ["running", "stopped"] }, "ServerStatusInfo": { "type": "object", - "required": [ - "status" - ], + "required": ["status"], "properties": { "status": { "$ref": "#/components/schemas/ServerStatus" @@ -2821,10 +2619,7 @@ }, "SkillSource": { "type": "object", - "required": [ - "type", - "source" - ], + "required": ["type", "source"], "properties": { "ref": { "type": "string", @@ -2851,9 +2646,7 @@ }, "SkillsConfig": { "type": "object", - "required": [ - "sources" - ], + "required": ["sources"], "properties": { "sources": { "type": "array", @@ -2865,10 +2658,7 @@ }, "SkillsConfigQuery": { "type": "object", - "required": [ - "directory", - "skillName" - ], + "required": ["directory", "skillName"], "properties": { "directory": { "type": "string" @@ -2886,4 +2676,4 @@ "description": "ACP proxy v1 API" } ] -} \ No newline at end of file +} diff --git a/docs/react-components.mdx b/docs/react-components.mdx index e37e2a3..0fa41b0 100644 --- a/docs/react-components.mdx +++ b/docs/react-components.mdx @@ -6,6 +6,13 @@ icon: "react" `@sandbox-agent/react` exposes small React components built on top of the `sandbox-agent` SDK. +Current exports: + +- `AgentConversation` for a combined transcript + composer surface +- `ProcessTerminal` for attaching to a running tty process +- `AgentTranscript` for rendering session/message timelines without bundling any styles +- `ChatComposer` for a reusable prompt input/send surface + ## Install ```bash @@ -101,3 +108,128 @@ export default function TerminalPane() { - `onExit`, `onError`: optional lifecycle callbacks See [Processes](/processes) for the lower-level terminal APIs. + +## Headless transcript + +`AgentTranscript` is intentionally unstyled. It follows the common headless React pattern used by libraries like Radix, Headless UI, and React Aria: behavior lives in the component, while styling stays in your app through `className`, slot-level `classNames`, and `data-*` state attributes on the rendered DOM. + +```tsx TranscriptPane.tsx +import { + AgentTranscript, + type AgentTranscriptClassNames, + type TranscriptEntry, +} from "@sandbox-agent/react"; + +const transcriptClasses: Partial = { + root: "transcript", + message: "transcript-message", + messageContent: "transcript-message-content", + toolGroupContainer: "transcript-tools", + toolGroupHeader: "transcript-tools-header", + toolItem: "transcript-tool-item", + toolItemHeader: "transcript-tool-item-header", + toolItemBody: "transcript-tool-item-body", + divider: "transcript-divider", + dividerText: "transcript-divider-text", + error: "transcript-error", +}; + +export function TranscriptPane({ entries }: { entries: TranscriptEntry[] }) { + return ( +
{entry.text}
} + renderInlinePendingIndicator={() => ...} + renderToolGroupIcon={() => Events} + renderChevron={(expanded) => {expanded ? "Hide" : "Show"}} + /> + ); +} +``` + +```css +.transcript { + display: grid; + gap: 12px; +} + +.transcript [data-slot="message"][data-variant="user"] .transcript-message-content { + background: #161616; + color: white; +} + +.transcript [data-slot="message"][data-variant="assistant"] .transcript-message-content { + background: #f4f4f0; + color: #161616; +} + +.transcript [data-slot="tool-item"][data-failed="true"] { + border-color: #d33; +} + +.transcript [data-slot="tool-item-header"][data-expanded="true"] { + background: rgba(0, 0, 0, 0.06); +} +``` + +`AgentTranscript` accepts `TranscriptEntry[]`, which matches the Inspector timeline shape: + +- `message` entries render user/assistant text +- `tool` entries render expandable tool input/output sections +- `reasoning` entries render expandable reasoning blocks +- `meta` entries render status rows or expandable metadata details + +Useful props: + +- `className`: root class hook +- `classNames`: slot-level class hooks for styling from outside the package +- `renderMessageText`: custom text or markdown renderer +- `renderToolItemIcon`, `renderToolGroupIcon`, `renderChevron`, `renderEventLinkContent`: presentation overrides +- `renderInlinePendingIndicator`, `renderThinkingState`: loading/thinking UI overrides +- `isDividerEntry`, `canOpenEvent`, `getToolGroupSummary`: behavior overrides for grouping and labels + +## Composer and conversation + +`ChatComposer` is the headless message input. `AgentConversation` composes `AgentTranscript` and `ChatComposer` so apps can reuse the transcript/composer pairing without pulling in Inspector session chrome. + +```tsx ConversationPane.tsx +import { AgentConversation, type TranscriptEntry } from "@sandbox-agent/react"; + +export function ConversationPane({ + entries, + message, + onMessageChange, + onSubmit, +}: { + entries: TranscriptEntry[]; + message: string; + onMessageChange: (value: string) => void; + onSubmit: () => void; +}) { + return ( + Start the conversation.} + transcriptProps={{ + renderMessageText: (entry) =>
{entry.text}
, + }} + composerProps={{ + message, + onMessageChange, + onSubmit, + placeholder: "Send a message...", + }} + /> + ); +} +``` + +Useful `ChatComposer` props: + +- `className` and `classNames` for external styling +- `inputRef` to manage focus or autoresize from the consumer +- `textareaProps` for lower-level textarea behavior +- `allowEmptySubmit` when the submit action is valid without draft text, such as a stop button + +Use `transcriptProps` and `composerProps` when you want the shared composition but still need custom rendering or behavior. Use `transcriptClassNames` and `composerClassNames` when you want styling hooks for each subcomponent. diff --git a/examples/boxlite/src/index.ts b/examples/boxlite/src/index.ts index c2401be..bdcd53a 100644 --- a/examples/boxlite/src/index.ts +++ b/examples/boxlite/src/index.ts @@ -11,17 +11,14 @@ setupImage(); console.log("Creating BoxLite sandbox..."); const box = new SimpleBox({ - rootfsPath: OCI_DIR, - env, - ports: [{ hostPort: 3000, guestPort: 3000 }], - diskSizeGb: 4, + rootfsPath: OCI_DIR, + env, + ports: [{ hostPort: 3000, guestPort: 3000 }], + diskSizeGb: 4, }); console.log("Starting server..."); -const result = await box.exec( - "sh", "-c", - "nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &", -); +const result = await box.exec("sh", "-c", "nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &"); if (result.exitCode !== 0) throw new Error(`Failed to start server: ${result.stderr}`); const baseUrl = "http://localhost:3000"; @@ -36,9 +33,9 @@ console.log(" Press Ctrl+C to stop."); const keepAlive = setInterval(() => {}, 60_000); const cleanup = async () => { - clearInterval(keepAlive); - await box.stop(); - process.exit(0); + clearInterval(keepAlive); + await box.stop(); + process.exit(0); }; process.once("SIGINT", cleanup); process.once("SIGTERM", cleanup); diff --git a/examples/boxlite/src/setup-image.ts b/examples/boxlite/src/setup-image.ts index 25b157e..9c15c99 100644 --- a/examples/boxlite/src/setup-image.ts +++ b/examples/boxlite/src/setup-image.ts @@ -5,12 +5,12 @@ export const DOCKER_IMAGE = "sandbox-agent-boxlite"; export const OCI_DIR = new URL("../oci-image", import.meta.url).pathname; export function setupImage() { - console.log(`Building image "${DOCKER_IMAGE}" (cached after first run)...`); - execSync(`docker build -t ${DOCKER_IMAGE} ${new URL("..", import.meta.url).pathname}`, { stdio: "inherit" }); + console.log(`Building image "${DOCKER_IMAGE}" (cached after first run)...`); + execSync(`docker build -t ${DOCKER_IMAGE} ${new URL("..", import.meta.url).pathname}`, { stdio: "inherit" }); - if (!existsSync(`${OCI_DIR}/oci-layout`)) { - console.log("Exporting to OCI layout..."); - mkdirSync(OCI_DIR, { recursive: true }); - execSync(`docker save ${DOCKER_IMAGE} | tar -xf - -C ${OCI_DIR}`, { stdio: "inherit" }); - } + if (!existsSync(`${OCI_DIR}/oci-layout`)) { + console.log("Exporting to OCI layout..."); + mkdirSync(OCI_DIR, { recursive: true }); + execSync(`docker save ${DOCKER_IMAGE} | tar -xf - -C ${OCI_DIR}`, { stdio: "inherit" }); + } } diff --git a/examples/cloudflare/frontend/App.tsx b/examples/cloudflare/frontend/App.tsx index e80d693..499fc63 100644 --- a/examples/cloudflare/frontend/App.tsx +++ b/examples/cloudflare/frontend/App.tsx @@ -128,7 +128,7 @@ export function App() { console.error("Event stream error:", err); } }, - [log] + [log], ); const send = useCallback(async () => { @@ -162,12 +162,7 @@ export function App() {
+

Create the first session

+

Sessions are where you chat with the agent. Start one now to send the first prompt on this task.

+ +
- - - ) : ( - - { - void copyMessage(message); - }} - thinkingTimerLabel={thinkingTimerLabel} + + ) : ( + + { + void copyMessage(message); + }} + thinkingTimerLabel={thinkingTimerLabel} + /> + + )} + {!isTerminal && promptTab ? ( + updateDraft(value, attachments)} + onSend={sendMessage} + onStop={stopAgent} + onRemoveAttachment={removeAttachment} + onChangeModel={changeModel} + onSetDefaultModel={setDefaultModel} /> - - )} - {!isTerminal && promptTab ? ( - updateDraft(value, attachments)} - onSend={sendMessage} - onStop={stopAgent} - onRemoveAttachment={removeAttachment} - onChangeModel={changeModel} - onSetDefaultModel={setDefaultModel} - /> - ) : null} + ) : null} + ); }); +const LEFT_SIDEBAR_DEFAULT_WIDTH = 340; +const RIGHT_SIDEBAR_DEFAULT_WIDTH = 380; +const SIDEBAR_MIN_WIDTH = 220; +const SIDEBAR_MAX_WIDTH = 600; +const RESIZE_HANDLE_WIDTH = 1; +const LEFT_WIDTH_STORAGE_KEY = "openhandoff:foundry-left-sidebar-width"; +const RIGHT_WIDTH_STORAGE_KEY = "openhandoff:foundry-right-sidebar-width"; + +function readStoredWidth(key: string, fallback: number): number { + if (typeof window === "undefined") return fallback; + const stored = window.localStorage.getItem(key); + const parsed = stored ? Number.parseInt(stored, 10) : Number.NaN; + return Number.isFinite(parsed) ? Math.min(Math.max(parsed, SIDEBAR_MIN_WIDTH), SIDEBAR_MAX_WIDTH) : fallback; +} + +const PanelResizeHandle = memo(function PanelResizeHandle({ onResizeStart, onResize }: { onResizeStart: () => void; onResize: (deltaX: number) => void }) { + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault(); + const startX = event.clientX; + onResizeStart(); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + + const handlePointerMove = (moveEvent: PointerEvent) => { + onResize(moveEvent.clientX - startX); + }; + + const stopResize = () => { + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", stopResize); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", stopResize, { once: true }); + }, + [onResize, onResizeStart], + ); + + return ( +
+
+
+ ); +}); + +const RIGHT_RAIL_MIN_SECTION_HEIGHT = 180; +const RIGHT_RAIL_SPLITTER_HEIGHT = 10; +const DEFAULT_TERMINAL_HEIGHT = 320; +const TERMINAL_HEIGHT_STORAGE_KEY = "openhandoff:foundry-terminal-height"; + +const RightRail = memo(function RightRail({ + workspaceId, + handoff, + activeTabId, + onOpenDiff, + onArchive, + onRevertFile, + onPublishPr, +}: { + workspaceId: string; + handoff: Handoff; + activeTabId: string | null; + onOpenDiff: (path: string) => void; + onArchive: () => void; + onRevertFile: (path: string) => void; + onPublishPr: () => void; +}) { + const [css] = useStyletron(); + const railRef = useRef(null); + const [terminalHeight, setTerminalHeight] = useState(() => { + if (typeof window === "undefined") { + return DEFAULT_TERMINAL_HEIGHT; + } + + const stored = window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY); + const parsed = stored ? Number.parseInt(stored, 10) : Number.NaN; + return Number.isFinite(parsed) ? parsed : DEFAULT_TERMINAL_HEIGHT; + }); + + const clampTerminalHeight = useCallback((nextHeight: number) => { + const railHeight = railRef.current?.getBoundingClientRect().height ?? 0; + const maxHeight = Math.max(RIGHT_RAIL_MIN_SECTION_HEIGHT, railHeight - RIGHT_RAIL_MIN_SECTION_HEIGHT - RIGHT_RAIL_SPLITTER_HEIGHT); + + return Math.min(Math.max(nextHeight, RIGHT_RAIL_MIN_SECTION_HEIGHT), maxHeight); + }, []); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight)); + }, [terminalHeight]); + + useEffect(() => { + const handleResize = () => { + setTerminalHeight((current) => clampTerminalHeight(current)); + }; + + window.addEventListener("resize", handleResize); + handleResize(); + return () => window.removeEventListener("resize", handleResize); + }, [clampTerminalHeight]); + + const startResize = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault(); + + const startY = event.clientY; + const startHeight = terminalHeight; + document.body.style.cursor = "ns-resize"; + + const handlePointerMove = (moveEvent: PointerEvent) => { + const deltaY = moveEvent.clientY - startY; + setTerminalHeight(clampTerminalHeight(startHeight - deltaY)); + }; + + const stopResize = () => { + document.body.style.cursor = ""; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", stopResize); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", stopResize, { once: true }); + }, + [clampTerminalHeight, terminalHeight], + ); + + return ( +
+
+ +
+
+
+ +
+
+ ); +}); + interface MockLayoutProps { workspaceId: string; selectedHandoffId?: string | null; @@ -564,15 +791,78 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient), ); const handoffs = viewModel.handoffs ?? []; - const projects = viewModel.projects ?? []; + const rawProjects = viewModel.projects ?? []; + const [projectOrder, setProjectOrder] = useState(null); + const projects = useMemo(() => { + if (!projectOrder) return rawProjects; + const byId = new Map(rawProjects.map((p) => [p.id, p])); + const ordered = projectOrder.map((id) => byId.get(id)).filter(Boolean) as typeof rawProjects; + for (const p of rawProjects) { + if (!projectOrder.includes(p.id)) ordered.push(p); + } + return ordered; + }, [rawProjects, projectOrder]); + const reorderProjects = useCallback( + (fromIndex: number, toIndex: number) => { + const ids = projects.map((p) => p.id); + const [moved] = ids.splice(fromIndex, 1); + ids.splice(toIndex, 0, moved!); + setProjectOrder(ids); + }, + [projects], + ); const [activeTabIdByHandoff, setActiveTabIdByHandoff] = useState>({}); const [lastAgentTabIdByHandoff, setLastAgentTabIdByHandoff] = useState>({}); const [openDiffsByHandoff, setOpenDiffsByHandoff] = useState>({}); + const [starRepoPromptOpen, setStarRepoPromptOpen] = useState(false); + const [starRepoPending, setStarRepoPending] = useState(false); + const [starRepoError, setStarRepoError] = useState(null); + const [leftWidth, setLeftWidth] = useState(() => readStoredWidth(LEFT_WIDTH_STORAGE_KEY, LEFT_SIDEBAR_DEFAULT_WIDTH)); + const [rightWidth, setRightWidth] = useState(() => readStoredWidth(RIGHT_WIDTH_STORAGE_KEY, RIGHT_SIDEBAR_DEFAULT_WIDTH)); + const leftWidthRef = useRef(leftWidth); + const rightWidthRef = useRef(rightWidth); - const activeHandoff = useMemo( - () => handoffs.find((handoff) => handoff.id === selectedHandoffId) ?? handoffs[0] ?? null, - [handoffs, selectedHandoffId], - ); + useEffect(() => { + leftWidthRef.current = leftWidth; + window.localStorage.setItem(LEFT_WIDTH_STORAGE_KEY, String(leftWidth)); + }, [leftWidth]); + + useEffect(() => { + rightWidthRef.current = rightWidth; + window.localStorage.setItem(RIGHT_WIDTH_STORAGE_KEY, String(rightWidth)); + }, [rightWidth]); + + const startLeftRef = useRef(leftWidth); + const startRightRef = useRef(rightWidth); + + const onLeftResize = useCallback((deltaX: number) => { + setLeftWidth(Math.min(Math.max(startLeftRef.current + deltaX, SIDEBAR_MIN_WIDTH), SIDEBAR_MAX_WIDTH)); + }, []); + + const onLeftResizeStart = useCallback(() => { + startLeftRef.current = leftWidthRef.current; + }, []); + + const onRightResize = useCallback((deltaX: number) => { + setRightWidth(Math.min(Math.max(startRightRef.current - deltaX, SIDEBAR_MIN_WIDTH), SIDEBAR_MAX_WIDTH)); + }, []); + + const onRightResizeStart = useCallback(() => { + startRightRef.current = rightWidthRef.current; + }, []); + + const activeHandoff = useMemo(() => handoffs.find((handoff) => handoff.id === selectedHandoffId) ?? handoffs[0] ?? null, [handoffs, selectedHandoffId]); + + useEffect(() => { + try { + const status = globalThis.localStorage?.getItem(STAR_SANDBOX_AGENT_REPO_STORAGE_KEY); + if (status !== "completed" && status !== "dismissed") { + setStarRepoPromptOpen(true); + } + } catch { + setStarRepoPromptOpen(true); + } + }, []); useEffect(() => { if (activeHandoff) { @@ -599,9 +889,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } const openDiffs = activeHandoff ? sanitizeOpenDiffs(activeHandoff, openDiffsByHandoff[activeHandoff.id]) : []; const lastAgentTabId = activeHandoff ? sanitizeLastAgentTabId(activeHandoff, lastAgentTabIdByHandoff[activeHandoff.id]) : null; - const activeTabId = activeHandoff - ? sanitizeActiveTabId(activeHandoff, activeTabIdByHandoff[activeHandoff.id], openDiffs, lastAgentTabId) - : null; + const activeTabId = activeHandoff ? sanitizeActiveTabId(activeHandoff, activeTabIdByHandoff[activeHandoff.id], openDiffs, lastAgentTabId) : null; const syncRouteSession = useCallback( (handoffId: string, sessionId: string | null, replace = false) => { @@ -658,7 +946,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } void (async () => { const repoId = activeHandoff?.repoId ?? viewModel.repos[0]?.id ?? ""; if (!repoId) { - throw new Error("Cannot create a handoff without an available repo"); + throw new Error("Cannot create a task without an available repo"); } const task = "New task"; @@ -683,7 +971,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } const openDiffTab = useCallback( (path: string) => { if (!activeHandoff) { - throw new Error("Cannot open a diff tab without an active handoff"); + throw new Error("Cannot open a diff tab without an active task"); } setOpenDiffsByHandoff((current) => { const existing = sanitizeOpenDiffs(activeHandoff, current[activeHandoff.id]); @@ -727,10 +1015,10 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } (id: string) => { const currentHandoff = handoffs.find((handoff) => handoff.id === id); if (!currentHandoff) { - throw new Error(`Unable to rename missing handoff ${id}`); + throw new Error(`Unable to rename missing task ${id}`); } - const nextTitle = window.prompt("Rename handoff", currentHandoff.title); + const nextTitle = window.prompt("Rename task", currentHandoff.title); if (nextTitle === null) { return; } @@ -749,7 +1037,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } (id: string) => { const currentHandoff = handoffs.find((handoff) => handoff.id === id); if (!currentHandoff) { - throw new Error(`Unable to rename missing handoff ${id}`); + throw new Error(`Unable to rename missing task ${id}`); } const nextBranch = window.prompt("Rename branch", currentHandoff.branch ?? ""); @@ -769,14 +1057,14 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } const archiveHandoff = useCallback(() => { if (!activeHandoff) { - throw new Error("Cannot archive without an active handoff"); + throw new Error("Cannot archive without an active task"); } void handoffWorkbenchClient.archiveHandoff({ handoffId: activeHandoff.id }); }, [activeHandoff]); const publishPr = useCallback(() => { if (!activeHandoff) { - throw new Error("Cannot publish PR without an active handoff"); + throw new Error("Cannot publish PR without an active task"); } void handoffWorkbenchClient.publishPr({ handoffId: activeHandoff.id }); }, [activeHandoff]); @@ -784,7 +1072,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } const revertFile = useCallback( (path: string) => { if (!activeHandoff) { - throw new Error("Cannot revert a file without an active handoff"); + throw new Error("Cannot revert a file without an active task"); } setOpenDiffsByHandoff((current) => ({ ...current, @@ -795,7 +1083,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } [activeHandoff.id]: current[activeHandoff.id] === diffTabId(path) ? sanitizeLastAgentTabId(activeHandoff, lastAgentTabIdByHandoff[activeHandoff.id]) - : current[activeHandoff.id] ?? null, + : (current[activeHandoff.id] ?? null), })); void handoffWorkbenchClient.revertFile({ @@ -806,105 +1094,255 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } [activeHandoff, lastAgentTabIdByHandoff], ); + const dismissStarRepoPrompt = useCallback(() => { + setStarRepoError(null); + try { + globalThis.localStorage?.setItem(STAR_SANDBOX_AGENT_REPO_STORAGE_KEY, "dismissed"); + } catch { + // ignore storage failures + } + setStarRepoPromptOpen(false); + }, []); + + const starSandboxAgentRepo = useCallback(() => { + setStarRepoPending(true); + setStarRepoError(null); + void backendClient + .starSandboxAgentRepo(workspaceId) + .then(() => { + try { + globalThis.localStorage?.setItem(STAR_SANDBOX_AGENT_REPO_STORAGE_KEY, "completed"); + } catch { + // ignore storage failures + } + setStarRepoPromptOpen(false); + }) + .catch((error) => { + setStarRepoError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + setStarRepoPending(false); + }); + }, [workspaceId]); + + const starRepoPrompt = starRepoPromptOpen ? ( +
+
+
+
+ Welcome to Foundry +
+

Support Sandbox Agent

+

+ Star the repo to help us grow and stay up to date with new releases. +

+
+ + {starRepoError ? ( +
+ {starRepoError} +
+ ) : null} + +
+ + +
+
+
+ ) : null; + if (!activeHandoff) { return ( - - - - -
+ <> + +
+ +
+ + +
-

Create your first handoff

-

- {viewModel.repos.length > 0 - ? "Start from the sidebar to create a handoff on the first available repo." - : "No repos are available in this workspace yet."} -

- +

Create your first task

+

+ {viewModel.repos.length > 0 + ? "Start from the sidebar to create a task on the first available repo." + : "No repos are available in this workspace yet."} +

+ +
-
- - - - + + + +
+ +
+ + {starRepoPrompt} + ); } return ( - - - { - setActiveTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); - }} - onSetLastAgentTabId={(tabId) => { - setLastAgentTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); - }} - onSetOpenDiffs={(paths) => { - setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths })); - }} - /> - - + <> + +
+ +
+ +
+ { + setActiveTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); + }} + onSetLastAgentTabId={(tabId) => { + setLastAgentTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); + }} + onSetOpenDiffs={(paths) => { + setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths })); + }} + /> +
+ +
+ +
+
+ {starRepoPrompt} + ); } diff --git a/factory/packages/frontend/src/components/mock-layout/history-minimap.tsx b/factory/packages/frontend/src/components/mock-layout/history-minimap.tsx index 83c8904..b62faa1 100644 --- a/factory/packages/frontend/src/components/mock-layout/history-minimap.tsx +++ b/factory/packages/frontend/src/components/mock-layout/history-minimap.tsx @@ -4,13 +4,7 @@ import { LabelXSmall } from "baseui/typography"; import { formatMessageTimestamp, type HistoryEvent } from "./view-model"; -export const HistoryMinimap = memo(function HistoryMinimap({ - events, - onSelect, -}: { - events: HistoryEvent[]; - onSelect: (event: HistoryEvent) => void; -}) { +export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }: { events: HistoryEvent[]; onSelect: (event: HistoryEvent) => void }) { const [css, theme] = useStyletron(); const [open, setOpen] = useState(false); const [activeEventId, setActiveEventId] = useState(events[events.length - 1]?.id ?? null); @@ -49,7 +43,7 @@ export const HistoryMinimap = memo(function HistoryMinimap({ >
- Handoff Events + Task Events {events.length}
@@ -64,7 +58,11 @@ export const HistoryMinimap = memo(function HistoryMinimap({ onFocus={() => setActiveEventId(event.id)} onClick={() => onSelect(event)} className={css({ - all: "unset", + appearance: "none", + WebkitAppearance: "none", + background: "none", + border: "none", + margin: "0", display: "grid", gridTemplateColumns: "1fr auto", gap: "10px", diff --git a/factory/packages/frontend/src/components/mock-layout/message-list.tsx b/factory/packages/frontend/src/components/mock-layout/message-list.tsx index baf758f..28906ae 100644 --- a/factory/packages/frontend/src/components/mock-layout/message-list.tsx +++ b/factory/packages/frontend/src/components/mock-layout/message-list.tsx @@ -1,4 +1,5 @@ -import { memo, type MutableRefObject, type Ref } from "react"; +import { AgentTranscript, type AgentTranscriptClassNames, type TranscriptEntry } from "@sandbox-agent/react"; +import { memo, useMemo, type MutableRefObject, type Ref } from "react"; import { useStyletron } from "baseui"; import { LabelSmall, LabelXSmall } from "baseui/typography"; import { Copy } from "lucide-react"; @@ -7,6 +8,117 @@ import { HistoryMinimap } from "./history-minimap"; import { SpinnerDot } from "./ui"; import { buildDisplayMessages, formatMessageDuration, formatMessageTimestamp, type AgentTab, type HistoryEvent, type Message } from "./view-model"; +const TranscriptMessageBody = memo(function TranscriptMessageBody({ + message, + messageRefs, + copiedMessageId, + onCopyMessage, +}: { + message: Message; + messageRefs: MutableRefObject>; + copiedMessageId: string | null; + onCopyMessage: (message: Message) => void; +}) { + const [css, theme] = useStyletron(); + const isUser = message.sender === "client"; + const isCopied = copiedMessageId === message.id; + const messageTimestamp = formatMessageTimestamp(message.createdAtMs); + const displayFooter = isUser ? messageTimestamp : message.durationMs ? `${messageTimestamp} • Took ${formatMessageDuration(message.durationMs)}` : null; + + return ( +
{ + if (node) { + messageRefs.current.set(message.id, node); + } else { + messageRefs.current.delete(message.id); + } + }} + className={css({ + display: "flex", + flexDirection: "column", + alignItems: isUser ? "flex-end" : "flex-start", + gap: "6px", + })} + > +
+
+ {message.text} +
+
+
+ {displayFooter ? ( + + {displayFooter} + + ) : null} + +
+
+ ); +}); + export const MessageList = memo(function MessageList({ tab, scrollRef, @@ -27,10 +139,64 @@ export const MessageList = memo(function MessageList({ thinkingTimerLabel: string | null; }) { const [css, theme] = useStyletron(); - const messages = buildDisplayMessages(tab); + const messages = useMemo(() => buildDisplayMessages(tab), [tab]); + const messagesById = useMemo(() => new Map(messages.map((message) => [message.id, message])), [messages]); + const transcriptEntries = useMemo( + () => + messages.map((message) => ({ + id: message.id, + eventId: message.id, + kind: "message", + time: new Date(message.createdAtMs).toISOString(), + role: message.sender === "client" ? "user" : "assistant", + text: message.text, + })), + [messages], + ); + + const messageContentClass = css({ + maxWidth: "80%", + display: "flex", + flexDirection: "column", + }); + + const transcriptClassNames: Partial = { + root: css({ + display: "flex", + flexDirection: "column", + gap: "12px", + }), + message: css({ + display: "flex", + }), + messageContent: messageContentClass, + messageText: css({ + width: "100%", + }), + thinkingRow: css({ + display: "flex", + alignItems: "center", + gap: "8px", + padding: "4px 0", + }), + thinkingIndicator: css({ + display: "flex", + alignItems: "center", + gap: "8px", + color: "#ff4f00", + fontSize: "11px", + fontFamily: '"IBM Plex Mono", monospace', + letterSpacing: "0.01em", + }), + }; return ( <> + {historyEvents.length > 0 ? : null}
- {tab && messages.length === 0 ? ( + {tab && transcriptEntries.length === 0 ? (
- ) : null} - {messages.map((message) => { - const isUser = message.sender === "client"; - const isCopied = copiedMessageId === message.id; - const messageTimestamp = formatMessageTimestamp(message.createdAtMs); - const displayFooter = isUser - ? messageTimestamp - : message.durationMs - ? `${messageTimestamp} • Took ${formatMessageDuration(message.durationMs)}` - : null; + ) : ( + { + const message = messagesById.get(entry.id); + if (!message) { + return null; + } - return ( -
{ - if (node) { - messageRefs.current.set(message.id, node); - } else { - messageRefs.current.delete(message.id); - } - }} - className={css({ display: "flex", justifyContent: isUser ? "flex-end" : "flex-start" })} - > -
-
-
- {message.text} -
-
-
- {displayFooter ? ( - ; + }} + isThinking={Boolean(tab && tab.status === "running" && transcriptEntries.length > 0)} + renderThinkingState={() => ( +
+ + + Agent is thinking + {thinkingTimerLabel ? ( + - {displayFooter} - + {thinkingTimerLabel} + ) : null} - -
+
-
- ); - })} - {tab && tab.status === "running" && messages.length > 0 ? ( -
- - - Agent is thinking - {thinkingTimerLabel ? ( - - {thinkingTimerLabel} - - ) : null} - -
- ) : null} + )} + /> + )}
); diff --git a/factory/packages/frontend/src/components/mock-layout/model-picker.tsx b/factory/packages/frontend/src/components/mock-layout/model-picker.tsx index 743023a..48513a1 100644 --- a/factory/packages/frontend/src/components/mock-layout/model-picker.tsx +++ b/factory/packages/frontend/src/components/mock-layout/model-picker.tsx @@ -1,7 +1,7 @@ import { memo, useState } from "react"; import { useStyletron } from "baseui"; import { StatefulPopover, PLACEMENT } from "baseui/popover"; -import { ChevronDown, Star } from "lucide-react"; +import { ChevronDown, ChevronUp, Star } from "lucide-react"; import { AgentIcon } from "./ui"; import { MODEL_GROUPS, modelLabel, providerAgent, type ModelId } from "./view-model"; @@ -23,7 +23,7 @@ const ModelPickerContent = memo(function ModelPickerContent({ const [hoveredId, setHoveredId] = useState(null); return ( -
+
{MODEL_GROUPS.map((group) => (
@@ -100,22 +103,26 @@ export const ModelPicker = memo(function ModelPicker({ onSetDefault: (id: ModelId) => void; }) { const [css, theme] = useStyletron(); + const [isOpen, setIsOpen] = useState(false); return ( setIsOpen(true)} + onClose={() => setIsOpen(false)} overrides={{ Body: { style: { - backgroundColor: "#000000", - borderTopLeftRadius: "8px", - borderTopRightRadius: "8px", - borderBottomLeftRadius: "8px", - borderBottomRightRadius: "8px", - border: `1px solid ${theme.colors.borderOpaque}`, - boxShadow: "0 8px 24px rgba(0, 0, 0, 0.6)", + backgroundColor: "rgba(32, 32, 32, 0.98)", + backdropFilter: "blur(12px)", + borderTopLeftRadius: "10px", + borderTopRightRadius: "10px", + borderBottomLeftRadius: "10px", + borderBottomRightRadius: "10px", + border: "1px solid rgba(255, 255, 255, 0.10)", + boxShadow: "0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.04)", zIndex: 100, }, }, @@ -126,20 +133,15 @@ export const ModelPicker = memo(function ModelPicker({ }, }, }} - content={({ close }) => ( - - )} + content={({ close }) => } >
diff --git a/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx b/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx index f3cdcd2..98d5ad0 100644 --- a/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx +++ b/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx @@ -1,9 +1,10 @@ import { memo, type Ref } from "react"; import { useStyletron } from "baseui"; -import { ArrowUpFromLine, FileCode, Square, X } from "lucide-react"; +import { ChatComposer, type ChatComposerClassNames } from "@sandbox-agent/react"; +import { FileCode, SendHorizonal, Square, X } from "lucide-react"; import { ModelPicker } from "./model-picker"; -import { PROMPT_TEXTAREA_MIN_HEIGHT, PROMPT_TEXTAREA_MAX_HEIGHT } from "./ui"; +import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT } from "./ui"; import { fileName, type LineAttachment, type ModelId } from "./view-model"; export const PromptComposer = memo(function PromptComposer({ @@ -36,12 +37,83 @@ export const PromptComposer = memo(function PromptComposer({ onSetDefaultModel: (model: ModelId) => void; }) { const [css, theme] = useStyletron(); + const composerClassNames: Partial = { + form: css({ + position: "relative", + backgroundColor: "rgba(255, 255, 255, 0.06)", + border: `1px solid ${theme.colors.borderOpaque}`, + borderRadius: "16px", + minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT + 36}px`, + transition: "border-color 200ms ease", + ":focus-within": { borderColor: "rgba(255, 255, 255, 0.15)" }, + display: "flex", + flexDirection: "column", + }), + input: css({ + display: "block", + width: "100%", + minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT + 20}px`, + padding: "14px 58px 8px 14px", + background: "transparent", + border: "none", + borderRadius: "16px 16px 0 0", + color: theme.colors.contentPrimary, + fontSize: "13px", + fontFamily: "inherit", + resize: "none", + outline: "none", + lineHeight: "1.4", + maxHeight: `${PROMPT_TEXTAREA_MAX_HEIGHT + 40}px`, + boxSizing: "border-box", + overflowY: "hidden", + "::placeholder": { color: theme.colors.contentSecondary }, + }), + submit: css({ + appearance: "none", + WebkitAppearance: "none", + boxSizing: "border-box", + width: "32px", + height: "32px", + padding: "0", + margin: "0", + border: "none", + borderRadius: "6px", + cursor: "pointer", + position: "absolute", + right: "12px", + bottom: "12px", + display: "flex", + alignItems: "center", + justifyContent: "center", + lineHeight: 0, + fontSize: 0, + color: theme.colors.contentPrimary, + transition: "background 200ms ease", + backgroundColor: isRunning ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.12)", + ":hover": { + backgroundColor: isRunning ? "rgba(255, 255, 255, 0.12)" : "rgba(255, 255, 255, 0.20)", + }, + ":disabled": { + cursor: "not-allowed", + opacity: 0.45, + }, + }), + submitContent: css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "100%", + height: "100%", + lineHeight: 0, + color: isRunning ? theme.colors.contentPrimary : "#ffffff", + }), + }; return (
{fileName(attachment.filePath)}:{attachment.lineNumber} - onRemoveAttachment(attachment.id)} - /> + onRemoveAttachment(attachment.id)} />
))}
) : null} -
-