Repository: jonesphillip/weft Branch: main Commit: 93efd90d3c68 Files: 171 Total size: 1.5 MB Directory structure: gitextract_09zdyiuf/ ├── .env.example ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── eslint.config.js ├── index.html ├── package.json ├── src/ │ ├── App.css │ ├── App.tsx │ ├── api/ │ │ └── client.ts │ ├── components/ │ │ ├── Approval/ │ │ │ ├── Approval.css │ │ │ ├── ApprovalFooter.tsx │ │ │ ├── ApprovalViewRegistry.tsx │ │ │ ├── DefaultApproval.tsx │ │ │ ├── EmailApproval.css │ │ │ ├── EmailApproval.tsx │ │ │ ├── GitHubPRApproval.css │ │ │ ├── GitHubPRApproval.tsx │ │ │ ├── GitHubPRReviewApproval.css │ │ │ ├── GitHubPRReviewApproval.tsx │ │ │ ├── GoogleDocsApproval.css │ │ │ ├── GoogleDocsApproval.tsx │ │ │ ├── GoogleSheetsApproval.css │ │ │ ├── GoogleSheetsApproval.tsx │ │ │ └── index.ts │ │ ├── Board/ │ │ │ ├── Board.css │ │ │ └── Board.tsx │ │ ├── Column/ │ │ │ ├── Column.css │ │ │ └── Column.tsx │ │ ├── CommandPalette/ │ │ │ ├── CommandPalette.css │ │ │ ├── CommandPalette.tsx │ │ │ └── index.ts │ │ ├── CommentableText/ │ │ │ ├── CommentableText.css │ │ │ └── CommentableText.tsx │ │ ├── DiffViewer/ │ │ │ ├── DiffViewer.css │ │ │ ├── DiffViewer.tsx │ │ │ └── index.ts │ │ ├── GitHubCallback.tsx │ │ ├── GoogleCallback.tsx │ │ ├── Header/ │ │ │ ├── Header.css │ │ │ ├── Header.tsx │ │ │ └── WeftLogo.tsx │ │ ├── Home/ │ │ │ ├── Home.css │ │ │ └── Home.tsx │ │ ├── MCP/ │ │ │ ├── MCP.css │ │ │ ├── MCPOAuthCallback.css │ │ │ ├── MCPOAuthCallback.tsx │ │ │ ├── MCPServerConnect.css │ │ │ ├── MCPServerConnect.tsx │ │ │ └── index.ts │ │ ├── Settings/ │ │ │ ├── AccountsSection.css │ │ │ ├── AccountsSection.tsx │ │ │ ├── BoardSettings.css │ │ │ ├── BoardSettings.tsx │ │ │ ├── MCPSection.css │ │ │ ├── MCPSection.tsx │ │ │ ├── index.ts │ │ │ └── sections/ │ │ │ ├── CredentialsSection.tsx │ │ │ ├── DangerSection.tsx │ │ │ ├── GeneralSection.tsx │ │ │ ├── IntegrationsSection.tsx │ │ │ └── index.ts │ │ ├── Task/ │ │ │ ├── AgentSection.css │ │ │ ├── AgentSection.tsx │ │ │ ├── RunHistory.css │ │ │ ├── RunHistory.tsx │ │ │ ├── TaskCard.css │ │ │ ├── TaskCard.tsx │ │ │ ├── TaskModal.css │ │ │ └── TaskModal.tsx │ │ ├── Toast/ │ │ │ ├── Toast.css │ │ │ ├── Toast.tsx │ │ │ ├── ToastContainer.tsx │ │ │ ├── WorkflowToastListener.tsx │ │ │ └── index.ts │ │ ├── Workflow/ │ │ │ ├── EmailViewerModal.css │ │ │ ├── EmailViewerModal.tsx │ │ │ ├── PlanReviewView.css │ │ │ ├── PlanReviewView.tsx │ │ │ ├── Workflow.css │ │ │ ├── WorkflowProgress.tsx │ │ │ └── index.ts │ │ └── common/ │ │ ├── Button.css │ │ ├── Button.tsx │ │ ├── ErrorBoundary.css │ │ ├── ErrorBoundary.tsx │ │ ├── Input.css │ │ ├── Input.tsx │ │ ├── McpIcon.tsx │ │ ├── Modal.css │ │ ├── Modal.tsx │ │ ├── RichTextEditor.css │ │ ├── RichTextEditor.tsx │ │ └── index.ts │ ├── constants.ts │ ├── context/ │ │ ├── AuthContext.tsx │ │ ├── BoardContext.tsx │ │ ├── ToastContext.tsx │ │ └── boardReducer.ts │ ├── hooks/ │ │ ├── index.ts │ │ ├── useApprovalComments.ts │ │ ├── useIsMobile.ts │ │ └── useUrlDetection.ts │ ├── index.css │ ├── main.tsx │ ├── types/ │ │ └── index.ts │ ├── utils/ │ │ └── diffParser.ts │ └── vite-env.d.ts ├── tests/ │ ├── mocks/ │ │ └── cloudflare-sandbox.ts │ └── worker/ │ ├── auth.test.ts │ ├── board-access.integration.test.ts │ └── user-isolation.test.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.worker.json ├── vite.config.ts ├── vitest.config.ts ├── vitest.config.workers.ts ├── worker/ │ ├── BoardDO.ts │ ├── UserDO.ts │ ├── auth.ts │ ├── constants.ts │ ├── db/ │ │ ├── index.ts │ │ └── schema.ts │ ├── github/ │ │ ├── GitHubMCP.ts │ │ ├── githubTools.ts │ │ ├── index.ts │ │ └── oauth.ts │ ├── google/ │ │ ├── DocsMCP.ts │ │ ├── GmailMCP.ts │ │ ├── SheetsMCP.ts │ │ ├── docsTools.ts │ │ ├── gmailTools.ts │ │ ├── index.ts │ │ ├── markdownToDocs.ts │ │ ├── oauth.ts │ │ └── sheetsTools.ts │ ├── handlers/ │ │ ├── boards.ts │ │ ├── oauth.ts │ │ └── workflows.ts │ ├── index.ts │ ├── mcp/ │ │ ├── AccountMCPRegistry.ts │ │ ├── MCPBridge.ts │ │ ├── MCPClient.ts │ │ ├── SchemaConverter.ts │ │ ├── index.ts │ │ └── oauth/ │ │ ├── discovery.ts │ │ ├── flow.ts │ │ ├── index.ts │ │ ├── pkce.ts │ │ └── types.ts │ ├── sandbox/ │ │ ├── SandboxMCP.ts │ │ └── sandboxTools.ts │ ├── services/ │ │ ├── BoardService.ts │ │ ├── CredentialService.ts │ │ ├── MCPOAuthService.ts │ │ ├── MCPService.ts │ │ ├── ScheduleService.ts │ │ ├── WorkflowService.ts │ │ └── index.ts │ ├── utils/ │ │ ├── crypto.ts │ │ ├── logger.ts │ │ ├── oauth-state.ts │ │ ├── response.ts │ │ ├── transformations.ts │ │ └── zodTools.ts │ └── workflows/ │ └── AgentWorkflow.ts ├── worker-configuration.d.ts └── wrangler.jsonc ================================================ FILE CONTENTS ================================================ ================================================ FILE: .env.example ================================================ # OAuth Credentials # Copy this file to .env and fill in your values # GitHub OAuth App credentials # Create at: https://github.com/settings/developers GITHUB_CLIENT_ID=your_github_client_id GITHUB_CLIENT_SECRET=your_github_client_secret # Google OAuth credentials # Create at: https://console.cloud.google.com/apis/credentials GOOGLE_CLIENT_ID=your_google_client_id GOOGLE_CLIENT_SECRET=your_google_client_secret # Encryption key for stored credentials (min 32 characters) # Generate with: openssl rand -base64 32 ENCRYPTION_KEY=your_encryption_key_min_32_chars ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? # wrangler files .wrangler .dev.vars* !.dev.vars.example # Environment files (secrets) .env .env.local .env.*.local !.env.example # Claude Code state .claude/ ================================================ FILE: Dockerfile ================================================ FROM docker.io/cloudflare/sandbox:0.6.7 # Install Claude Code CLI RUN npm install -g @anthropic-ai/claude-code # Longer timeout for Claude Code operations ENV COMMAND_TIMEOUT_MS=300000 # Port 3000 is used by the sandbox SDK internally EXPOSE 3000 ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to the Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS Copyright 2025 Phillip Jones Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # Weft Task management, but AI agents do your tasks. ![Weft Screenshot](assets/screenshot.png) ## What is Weft? Weft is a personal task board where AI agents work on your tasks. Create a task, assign it to an agent, and it gets to work. Agents can read your emails, draft responses, update spreadsheets, create PRs, and write code. Run as many agents in parallel as you want. Schedule recurring tasks to run daily or weekly. Get notified when tasks complete or need your approval. All actions that mutate state (sending emails, creating PRs, modifying documents) require your approval before committing. **Built-in integrations:** - Gmail (read, draft, send) - Google Docs (create, edit) - Google Sheets (create, update) - GitHub (issues, PRs, code) - [Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/) (isolated containers for code execution and coding agents) - [Remote MCP servers](https://modelcontextprotocol.io/) (bring your own tools) **Self-hosted and private.** Deploy to your own Cloudflare account. Your data stays in your account. ## Architecture Weft runs entirely on [Cloudflare's Developer Platform](https://workers.cloudflare.com/): ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ │ │ Browser ◄──────► Worker ◄──────► Durable Objects ◄──────► Workflows │ │ │ (HTTP) (BoardDO, UserDO) (AgentWorkflow) │ │ │ │ │ │ │ │ ▼ │ │ └─────── WebSocket ──────────────────►│ ┌────────────┐ │ │ (real-time updates) │ Anthropic │ │ │ │ + Tools │ │ │ └────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ ``` | Component | Purpose | |-----------|---------| | **[Workers](https://developers.cloudflare.com/workers/)** | HTTP routing, auth, serves React frontend | | **[Durable Objects](https://developers.cloudflare.com/durable-objects/)** | Persistent state (boards, tasks, credentials) and real-time WebSocket updates | | **[Workflows](https://developers.cloudflare.com/workflows/)** | Durable agent loop with automatic checkpointing and retry | ## Setup ### Prerequisites - [Node.js](https://nodejs.org/) 18+ - [Docker](https://docs.docker.com/desktop/) running (required for [Cloudflare Sandboxes](https://developers.cloudflare.com/sandbox/)) - [Cloudflare account](https://dash.cloudflare.com/sign-up) with [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/) - OAuth credentials for integrations you want to use: - [Google](#google-gmail-docs-sheets) for Gmail/Docs/Sheets - [GitHub](#github) for GitHub ### 1. Clone and install ```bash git clone https://github.com/jonesphillip/weft.git cd weft npm install ``` ### 2. Configure environment Copy the example environment file: ```bash cp .env.example .env ``` Edit `.env` with your secrets: ```bash # Encryption key for stored credentials (generate with: openssl rand -base64 32) ENCRYPTION_KEY=your-generated-key # OAuth credentials (add the ones you need) GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=... ``` See [Setting up OAuth credentials](#setting-up-oauth-credentials) for detailed instructions on creating Google and GitHub OAuth apps. ### 3. Run locally ```bash npm run dev ``` This starts both the Vite dev server (frontend) and Wrangler (worker). Open http://localhost:5174. By default, Weft runs without authentication. Boards and tasks are stored under the user defined by `USER_ID` in `wrangler.jsonc`. ### 4. Configure your board After creating your first board, go to **Settings** and add your [Anthropic API key](https://console.anthropic.com/). This key is stored per-board and is required for agents to run. ### 5. Deploy to Cloudflare Set your secrets (only set the ones you need): | Secret | Required | Description | |--------|----------|-------------| | `ENCRYPTION_KEY` | Yes | Encrypts stored credentials. Generate with `openssl rand -base64 32` | | `GOOGLE_CLIENT_ID` | For Google integrations | From [Google Cloud Console](#google-gmail-docs-sheets) | | `GOOGLE_CLIENT_SECRET` | For Google integrations | From [Google Cloud Console](#google-gmail-docs-sheets) | | `GITHUB_CLIENT_ID` | For GitHub integration | From [GitHub Developer Settings](#github) | | `GITHUB_CLIENT_SECRET` | For GitHub integration | From [GitHub Developer Settings](#github) | ```bash npx wrangler secret put ENCRYPTION_KEY --env production npx wrangler secret put GOOGLE_CLIENT_ID --env production npx wrangler secret put GOOGLE_CLIENT_SECRET --env production npx wrangler secret put GITHUB_CLIENT_ID --env production npx wrangler secret put GITHUB_CLIENT_SECRET --env production npm run deploy:prod ``` ### 6. Configure authentication Authentication is controlled by the `AUTH_MODE` variable in `wrangler.jsonc`: | AUTH_MODE | Description | Use case | |-----------|-------------|----------| | `none` | No authentication. Uses `USER_ID`/`USER_EMAIL` from config as singleton user. | Personal/single-user deployments | | `access` | Cloudflare Access JWT verification. Requires `ACCESS_AUD`/`ACCESS_TEAM` secrets. | Multi-user or login-protected deployments | By default, `AUTH_MODE` is set to `"none"` for both development and production. #### Enabling Cloudflare Access authentication For multi-user deployments or if you want login protection, enable [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/): 1. In the Cloudflare dashboard, go to **Zero Trust > Access > Applications** 2. Create a new **Self-hosted application** with your Weft URL 3. Add an **Access policy** (e.g., allow only your email address) 4. After saving, find the **Application Audience (AUD) Tag** in the application settings 5. Your **team name** is in your Zero Trust dashboard URL: `https://.cloudflareaccess.com` Set the secrets: ```bash npx wrangler secret put ACCESS_AUD --env production npx wrangler secret put ACCESS_TEAM --env production ``` Then in `wrangler.jsonc`, change `AUTH_MODE` to `"access"` in the production environment: ```jsonc "env": { "production": { "vars": { "AUTH_MODE": "access", // ... } } } ``` ## Project Structure ``` worker/ ├── index.ts # HTTP routing and auth ├── handlers/ # Request handlers ├── services/ # Business logic ├── workflows/ # Cloudflare Workflows (agent loop) ├── mcp/ # MCP server registry ├── google/ # Google integrations (Gmail, Docs, Sheets) └── github/ # GitHub integration src/ ├── components/ # React components ├── context/ # React context providers ├── hooks/ # Custom hooks └── api/ # API client ``` ## Scheduled Tasks Tasks can be configured to run on a schedule (daily, weekly, or cron). When a scheduled task runs, it operates differently from regular tasks:

Scheduled Tasks

1. A Durable Object alarm triggers at the scheduled time 2. The parent agent runs with read-only tool access. It cannot send emails, create PRs, or modify anything directly. 3. The parent agent calls `create_task` to fork child tasks for each piece of work it finds 4. Each child task gets its own agent context with full tool access and runs until it needs human approval This separation means scheduled agents can analyze your inbox or repos overnight, but actual changes still require your review. | Schedule Type | Example | |--------------|---------| | Daily | 8:00 AM in your timezone | | Weekly | Monday and Thursday at 9:00 AM | | Custom | Cron expression (`0 */6 * * *`) | ## Adding Tools to Your Board

Board Settings

Each board has its own configuration in **Settings**, organized into tabs: - **Credentials** - Add your Anthropic API key (required for agents) and connect OAuth accounts like Google and GitHub. - **Integrations** - Enable built-in tools (Gmail, Docs, Sheets, GitHub) or add any [remote MCP server](https://modelcontextprotocol.io/) for custom tools. ## Building New Integrations To add a built-in integration, Weft uses a registry-driven architecture: 1. Create your MCP server in `worker/` (see `worker/google/GmailMCP.ts` for example) 2. Register it in `worker/mcp/AccountMCPRegistry.ts` 3. Add OAuth handler if needed in `worker/handlers/oauth.ts` The registry defines tool schemas, OAuth configuration, and workflow guidance—no changes needed to the agent workflow itself. ## Setting up OAuth credentials ### Google (Gmail, Docs, Sheets) 1. Go to the [Google Cloud Console](https://console.cloud.google.com/) 2. Create a new project or select an existing one 3. Go to **APIs & Services > Library** and enable the Gmail API, Google Docs API, and Google Sheets API 4. Go to **APIs & Services > OAuth consent screen** and configure it (External is fine, add yourself as a test user) 5. Go to **APIs & Services > Credentials** 6. Click **Create Credentials > OAuth client ID** 7. Select **Web application** 8. Add authorized JavaScript origins: - `http://localhost:5174` (development) - `https://weft..workers.dev` (production) 9. Add authorized redirect URIs: - `http://localhost:5174/google/callback` (development) - `https://weft..workers.dev/google/callback` (production) 10. Copy the Client ID and Client Secret to your `.env` file ### GitHub 1. Go to [GitHub Developer Settings](https://github.com/settings/developers) 2. Click **New OAuth App** 3. Set the homepage URL to your Weft deployment 4. Set the authorization callback URL: - `http://localhost:5174/github/callback` (development) - `https://weft..workers.dev/github/callback` (production) 5. Copy the Client ID and Client Secret to your `.env` file ## License Apache License 2.0 - see [LICENSE](LICENSE) ================================================ FILE: eslint.config.js ================================================ import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' import { globalIgnores } from 'eslint/config' export default tseslint.config([ globalIgnores(['dist']), { files: ['**/*.{ts,tsx}'], extends: [ js.configs.recommended, tseslint.configs.recommended, reactRefresh.configs.vite, ], plugins: { 'react-hooks': reactHooks, }, rules: { // Standard react-hooks rules (without experimental compiler rules) 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', // Allow underscore-prefixed unused vars (convention for intentionally unused params) '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', }], }, languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, }, ]) ================================================ FILE: index.html ================================================ Weft
================================================ FILE: package.json ================================================ { "name": "weft", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "dev:wrangler": "wrangler dev", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "npm run build && vite preview", "deploy": "npm run build && wrangler deploy", "deploy:prod": "CLOUDFLARE_ENV=production npm run build && wrangler deploy --env production", "cf-typegen": "wrangler types", "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage", "test:integration": "vitest run --config vitest.config.workers.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.71.2", "@cloudflare/sandbox": "^0.6.7", "jose": "^6.1.3", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.11.0", "zod": "^4.3.4" }, "devDependencies": { "@cloudflare/vite-plugin": "^1.13.13", "@cloudflare/vitest-pool-workers": "^0.11.1", "@eslint/js": "^9.33.0", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react-swc": "^4.0.0", "@vitest/coverage-v8": "3.2", "eslint": "^9.33.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^17.0.0", "typescript": "^5.9.3", "typescript-eslint": "^8.39.1", "vite": "^7.1.2", "vitest": "3.2", "wrangler": "^4.54.0" } } ================================================ FILE: src/App.css ================================================ .app { min-height: 100vh; display: flex; flex-direction: column; } .app-main { flex: 1; display: flex; overflow: hidden; } /* Auth loading state */ .app-loading { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-3); color: var(--color-text-muted); font-family: var(--font-mono); } .app-loading-spinner { width: 32px; height: 32px; border: 3px solid var(--color-border-default); border-top-color: var(--color-accent-primary); border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Auth error state */ .app-error { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-2); padding: var(--space-6); text-align: center; } .app-error h1 { font-size: 20px; font-weight: 600; color: var(--color-text-primary); } .app-error p { color: var(--color-text-muted); font-family: var(--font-mono); font-size: 14px; } ================================================ FILE: src/App.tsx ================================================ import { useState, useEffect, useCallback } from 'react'; import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { BoardProvider, useBoard } from './context/BoardContext'; import { ToastProvider } from './context/ToastContext'; import { AuthProvider, useAuth } from './context/AuthContext'; import { Header } from './components/Header/Header'; import { Home } from './components/Home/Home'; import { Board } from './components/Board/Board'; import { GitHubCallback } from './components/GitHubCallback'; import { GoogleCallback } from './components/GoogleCallback'; import { MCPOAuthCallback } from './components/MCP/MCPOAuthCallback'; import { CommandPalette } from './components/CommandPalette'; import { ToastContainer, WorkflowToastListener } from './components/Toast'; import { ErrorBoundary } from './components/common'; import './App.css'; // Full-page callback routes (no header/layout) const CALLBACK_ROUTES = ['/github/callback', '/google/callback', '/mcp/oauth/callback']; function AuthGate({ children }: { children: React.ReactNode }) { const { isLoading, error } = useAuth(); if (isLoading) { return (

Loading...

); } if (error) { return (

Authentication Required

{error}

); } return <>{children}; } function AppContent() { const [paletteOpen, setPaletteOpen] = useState(false); const { activeBoard, setAddingToColumn } = useBoard(); const location = useLocation(); const handleNewTask = useCallback((columnIndex: number) => { if (activeBoard && activeBoard.columns[columnIndex]) { setAddingToColumn(activeBoard.columns[columnIndex].id); } }, [activeBoard, setAddingToColumn]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Don't trigger if in an input or textarea const target = e.target as HTMLElement; const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable; // Cmd/Ctrl + K - open command palette if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); setPaletteOpen(true); return; } // Escape - close palette if (e.key === 'Escape' && paletteOpen) { setPaletteOpen(false); return; } // Shortcuts that only work when not in an input if (!isInput && !paletteOpen) { // n - new task in first column if (e.key === 'n' && activeBoard) { e.preventDefault(); handleNewTask(0); return; } // 1, 2, 3 - new task in column 1, 2, or 3 if (['1', '2', '3'].includes(e.key) && activeBoard) { const colIndex = parseInt(e.key) - 1; if (activeBoard.columns[colIndex]) { e.preventDefault(); handleNewTask(colIndex); } return; } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [paletteOpen, activeBoard, handleNewTask]); // Render full-page callbacks without the app layout const isCallbackRoute = CALLBACK_ROUTES.includes(location.pathname); if (isCallbackRoute) { return ( } /> } /> } /> ); } return ( <>
} /> } /> } />
setPaletteOpen(false)} onNewTask={handleNewTask} /> {/* Toast notifications */} ); } function App() { return ( ); } export default App; ================================================ FILE: src/api/client.ts ================================================ import type { Board, Column, Task, ApiResponse, TaskPriority, BoardCredential, MCPServer, MCPTool, WorkflowPlan, WorkflowLog, User, ScheduleConfig, ScheduledRun, } from '../types'; const API_BASE = '/api'; // ============================================ // AUTH // ============================================ export async function getMe(): Promise> { const response = await fetch(`${API_BASE}/me`); return response.json(); } async function request( path: string, options: RequestInit = {} ): Promise> { const response = await fetch(`${API_BASE}${path}`, { headers: { 'Content-Type': 'application/json', ...options.headers, }, ...options, }); const data = await response.json(); if (!response.ok) { // Server returns { success: false, error: { code, message } } const errorObj = data.error || {}; return { success: false, error: { code: errorObj.code || String(response.status), message: errorObj.message || 'Request failed', }, }; } return data; } // ============================================ // BOARDS // ============================================ export interface BoardWithDetails extends Board { columns: Column[]; tasks: Task[]; } export async function getBoards(): Promise> { return request('/boards'); } export async function getBoard(id: string): Promise> { return request(`/boards/${id}`); } export async function createBoard(name: string): Promise> { return request('/boards', { method: 'POST', body: JSON.stringify({ name }), }); } export async function updateBoard( id: string, data: { name?: string } ): Promise> { return request(`/boards/${id}`, { method: 'PUT', body: JSON.stringify(data), }); } export async function deleteBoard(id: string): Promise> { return request(`/boards/${id}`, { method: 'DELETE', }); } // ============================================ // COLUMNS // ============================================ export async function createColumn( boardId: string, name: string ): Promise> { return request(`/boards/${boardId}/columns`, { method: 'POST', body: JSON.stringify({ name }), }); } export async function updateColumn( boardId: string, id: string, data: { name?: string; position?: number } ): Promise> { return request(`/boards/${boardId}/columns/${id}`, { method: 'PUT', body: JSON.stringify(data), }); } export async function deleteColumn(boardId: string, id: string): Promise> { return request(`/boards/${boardId}/columns/${id}`, { method: 'DELETE', }); } // ============================================ // TASKS // ============================================ export async function createTask( boardId: string, data: { columnId: string; title: string; description?: string; priority?: TaskPriority; } ): Promise> { return request(`/boards/${boardId}/tasks`, { method: 'POST', body: JSON.stringify(data), }); } export async function getTask(boardId: string, id: string): Promise> { return request(`/boards/${boardId}/tasks/${id}`); } export async function updateTask( boardId: string, id: string, data: { title?: string; description?: string; priority?: TaskPriority; scheduleConfig?: ScheduleConfig | null; } ): Promise> { return request(`/boards/${boardId}/tasks/${id}`, { method: 'PUT', body: JSON.stringify(data), }); } export async function deleteTask(boardId: string, id: string): Promise> { return request(`/boards/${boardId}/tasks/${id}`, { method: 'DELETE', }); } export async function moveTask( boardId: string, id: string, columnId: string, position: number ): Promise> { return request(`/boards/${boardId}/tasks/${id}/move`, { method: 'POST', body: JSON.stringify({ columnId, position }), }); } // ============================================ // CREDENTIALS // ============================================ export async function getCredentials( boardId: string ): Promise> { return request(`/boards/${boardId}/credentials`); } export async function createCredential( boardId: string, data: { type: 'github_oauth' | 'google_oauth' | 'anthropic_api_key'; name: string; value: string; metadata?: Record; } ): Promise> { return request(`/boards/${boardId}/credentials`, { method: 'POST', body: JSON.stringify(data), }); } export async function deleteCredential( boardId: string, credentialId: string ): Promise> { return request(`/boards/${boardId}/credentials/${credentialId}`, { method: 'DELETE', }); } // ============================================ // GITHUB // ============================================ export interface GitHubRepo { id: number; name: string; fullName: string; owner: string; private: boolean; defaultBranch: string; description: string | null; } export async function getGitHubOAuthUrl( boardId: string ): Promise> { return request<{ url: string }>(`/github/oauth/url?boardId=${encodeURIComponent(boardId)}`); } export async function getGitHubRepos( boardId: string ): Promise> { return request(`/boards/${boardId}/github/repos`); } // ============================================ // GOOGLE // ============================================ export async function getGoogleOAuthUrl( boardId: string ): Promise> { return request<{ url: string }>(`/google/oauth/url?boardId=${encodeURIComponent(boardId)}`); } // ============================================ // MCP SERVERS // ============================================ export async function getMCPServers( boardId: string ): Promise> { return request(`/boards/${boardId}/mcp-servers`); } export async function getMCPServer( boardId: string, serverId: string ): Promise> { return request(`/boards/${boardId}/mcp-servers/${serverId}`); } export async function createMCPServer( boardId: string, data: { name: string; type: 'remote' | 'hosted'; endpoint?: string; authType?: 'none' | 'oauth' | 'api_key' | 'bearer'; credentialId?: string; transportType?: 'streamable-http' | 'sse'; } ): Promise> { return request(`/boards/${boardId}/mcp-servers`, { method: 'POST', body: JSON.stringify(data), }); } export async function updateMCPServer( boardId: string, serverId: string, data: { name?: string; endpoint?: string; authType?: 'none' | 'oauth' | 'api_key' | 'bearer'; credentialId?: string; transportType?: 'streamable-http' | 'sse'; enabled?: boolean; status?: 'connected' | 'disconnected' | 'error'; } ): Promise> { return request(`/boards/${boardId}/mcp-servers/${serverId}`, { method: 'PUT', body: JSON.stringify(data), }); } export async function deleteMCPServer( boardId: string, serverId: string ): Promise> { return request(`/boards/${boardId}/mcp-servers/${serverId}`, { method: 'DELETE', }); } export async function getMCPServerTools( boardId: string, serverId: string ): Promise> { return request(`/boards/${boardId}/mcp-servers/${serverId}/tools`); } export async function connectMCPServer( boardId: string, serverId: string ): Promise }>> { return request<{ status: string; toolCount: number; tools: Array<{ name: string; description?: string }> }>( `/boards/${boardId}/mcp-servers/${serverId}/connect`, { method: 'POST' } ); } export async function cacheMCPServerTools( boardId: string, serverId: string, tools: Array<{ name: string; description?: string; inputSchema: object; }> ): Promise> { return request(`/boards/${boardId}/mcp-servers/${serverId}/tools`, { method: 'PUT', body: JSON.stringify({ tools }), }); } /** * Create an account-based MCP server (e.g., Gmail, Google Docs) * Uses the AccountMCPRegistry to create and initialize the MCP */ export async function createAccountMCP( boardId: string, accountId: string, mcpId: string ): Promise> { return request(`/boards/${boardId}/mcp-servers/account`, { method: 'POST', body: JSON.stringify({ accountId, mcpId }), }); } // ============================================ // MCP OAUTH // ============================================ /** * Discover OAuth endpoints for a remote MCP server */ export async function discoverMCPOAuth( boardId: string, serverId: string ): Promise> { return request(`/boards/${boardId}/mcp-servers/${serverId}/oauth/discover`, { method: 'POST', }); } /** * Get OAuth authorization URL for a remote MCP server */ export async function getMCPOAuthUrl( boardId: string, serverId: string, redirectUri: string ): Promise> { const params = new URLSearchParams({ redirectUri }); return request(`/boards/${boardId}/mcp-servers/${serverId}/oauth/url?${params.toString()}`); } /** * Exchange OAuth authorization code for tokens */ export async function exchangeMCPOAuthCode( boardId: string, serverId: string, code: string, state: string, redirectUri: string ): Promise> { return request(`/boards/${boardId}/mcp-servers/${serverId}/oauth/exchange`, { method: 'POST', body: JSON.stringify({ code, state, redirectUri }), }); } // ============================================ // WORKFLOW PLANS // ============================================ export async function getBoardWorkflowPlans( boardId: string ): Promise> { return request(`/boards/${boardId}/workflow-plans`); } export async function getTaskWorkflowPlan( boardId: string, taskId: string ): Promise> { return request(`/boards/${boardId}/tasks/${taskId}/plan`); } export async function createWorkflowPlan( boardId: string, taskId: string, data: { summary?: string; generatedCode?: string; steps?: object[]; } ): Promise> { return request(`/boards/${boardId}/tasks/${taskId}/plan`, { method: 'POST', body: JSON.stringify(data), }); } export async function getWorkflowPlan( boardId: string, planId: string ): Promise> { return request(`/boards/${boardId}/plans/${planId}`); } export async function updateWorkflowPlan( boardId: string, planId: string, data: { status?: string; summary?: string; generatedCode?: string; steps?: object[]; currentStepIndex?: number; checkpointData?: object; } ): Promise> { return request(`/boards/${boardId}/plans/${planId}`, { method: 'PUT', body: JSON.stringify(data), }); } export async function deleteWorkflowPlan( boardId: string, planId: string ): Promise> { return request(`/boards/${boardId}/plans/${planId}`, { method: 'DELETE', }); } export async function approveWorkflowPlan( boardId: string, planId: string ): Promise> { return request(`/boards/${boardId}/plans/${planId}/approve`, { method: 'POST', }); } export async function cancelWorkflow( boardId: string, planId: string ): Promise> { return request(`/boards/${boardId}/plans/${planId}/cancel`, { method: 'POST', }); } export async function resolveWorkflowCheckpoint( boardId: string, planId: string, data: { action: 'approve' | 'request_changes' | 'cancel'; data?: object; feedback?: string; } ): Promise> { return request(`/boards/${boardId}/plans/${planId}/checkpoint`, { method: 'POST', body: JSON.stringify(data), }); } export async function getWorkflowLogs( boardId: string, planId: string, options?: { limit?: number; offset?: number } ): Promise> { const params = new URLSearchParams(); if (options?.limit) params.set('limit', String(options.limit)); if (options?.offset) params.set('offset', String(options.offset)); const query = params.toString() ? `?${params.toString()}` : ''; return request(`/boards/${boardId}/plans/${planId}/logs${query}`); } export async function generateWorkflowPlan( boardId: string, taskId: string ): Promise> { return request(`/boards/${boardId}/tasks/${taskId}/generate-plan`, { method: 'POST', }); } // ============================================ // LINK METADATA (for link pills) // ============================================ export interface LinkMetadata { type: 'google_doc' | 'google_sheet' | 'github_pr' | 'github_issue' | 'github_repo'; title: string; id: string; } export async function getLinkMetadata( boardId: string, url: string ): Promise> { return request(`/boards/${boardId}/links/metadata`, { method: 'POST', body: JSON.stringify({ url }), }); } // ============================================ // SCHEDULED TASKS // ============================================ /** * Get scheduled tasks for a board */ export async function getScheduledTasks( boardId: string ): Promise> { return request(`/boards/${boardId}/scheduled-tasks`); } /** * Get scheduled runs for a task */ export async function getScheduledRuns( boardId: string, taskId: string, limit?: number ): Promise> { const params = limit ? `?limit=${limit}` : ''; return request(`/boards/${boardId}/tasks/${taskId}/runs${params}`); } /** * Get child tasks for a scheduled run */ export async function getRunTasks( boardId: string, runId: string ): Promise> { return request(`/boards/${boardId}/runs/${runId}/tasks`); } /** * Delete a scheduled run from history */ export async function deleteScheduledRun( boardId: string, runId: string ): Promise> { return request(`/boards/${boardId}/runs/${runId}`, { method: 'DELETE', }); } /** * Get child tasks for a parent scheduled task */ export async function getChildTasks( boardId: string, parentTaskId: string ): Promise> { return request(`/boards/${boardId}/tasks/${parentTaskId}/children`); } /** * Trigger a scheduled run manually ("Run Now") */ export async function triggerScheduledRun( boardId: string, taskId: string ): Promise> { return request<{ runId: string }>(`/boards/${boardId}/tasks/${taskId}/run`, { method: 'POST', }); } ================================================ FILE: src/components/Approval/Approval.css ================================================ /** * Approval View Styles * * Shared styles for all approval view components. * These styles are used by both DefaultApproval and specialized views. */ /* Base approval card */ .approval-card { display: flex; flex-direction: column; gap: var(--space-4); } /* Header with icon and action title */ .approval-header { display: flex; align-items: center; gap: var(--space-3); padding-bottom: var(--space-3); border-bottom: 1px solid var(--color-border-default); } .approval-icon { flex-shrink: 0; color: var(--color-text-secondary); } .approval-title { font-size: 15px; font-weight: 600; color: var(--color-text-primary); } /* Field display (key-value pairs) */ .approval-fields { display: flex; flex-direction: column; gap: var(--space-4); } .approval-field { display: flex; flex-direction: column; gap: var(--space-2); } .approval-field-label { font-size: 12px; font-weight: 500; color: var(--color-text-secondary); } .approval-field-value { padding: var(--space-3); background: var(--color-bg-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); font-size: 13px; line-height: 1.5; color: var(--color-text-primary); white-space: pre-wrap; word-break: break-word; max-height: 200px; overflow-y: auto; } /* Actions (approve/reject buttons) */ .approval-actions { display: flex; justify-content: flex-end; gap: var(--space-2); padding-top: var(--space-4); border-top: 1px solid var(--color-border-default); } /* Generic label styling */ .approval-label { font-size: 12px; font-weight: 500; color: var(--color-text-muted); margin-right: var(--space-2); } /* ============================================ * GitHub Write File Approval Styles * ============================================ */ .approval-github-write .approval-file-info { display: flex; flex-direction: column; gap: var(--space-2); } .approval-github-write .approval-file-path { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); background: var(--color-success-subtle); border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); } .approval-github-write .approval-file-path-icon { color: var(--color-success-text); font-weight: 600; font-size: 14px; } .approval-github-write .approval-file-path code { font-family: var(--font-mono); font-size: 13px; color: var(--color-text-primary); } .approval-github-write .approval-file-branch, .approval-github-write .approval-file-message { display: flex; align-items: baseline; gap: var(--space-2); padding: var(--space-1) 0; font-size: 13px; } .approval-github-write .approval-file-branch code { font-family: var(--font-mono); padding: var(--space-1) var(--space-2); background: var(--color-bg-secondary); border-radius: var(--radius-sm); font-size: 12px; } /* Code preview section */ .approval-code-preview { display: flex; flex-direction: column; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); overflow: hidden; } .approval-code-header { display: flex; justify-content: space-between; align-items: center; padding: var(--space-2) var(--space-3); background: var(--color-bg-secondary); border-bottom: 1px solid var(--color-border-default); font-size: 12px; font-weight: 500; color: var(--color-text-secondary); } .approval-code-lines { font-size: 11px; color: var(--color-text-muted); font-weight: normal; } .approval-code-content { max-height: 400px; overflow: auto; background: var(--color-bg-primary); } .approval-code-content pre { margin: 0; padding: var(--space-3); } .approval-code-content code { font-family: var(--font-mono); font-size: 12px; line-height: 1.5; color: var(--color-text-primary); white-space: pre; tab-size: 2; } /* ============================================ * Short Field Comments * ============================================ */ .approval-field-short { display: flex; flex-direction: column; gap: var(--space-2); } /* Editable field (input with comment support) */ .approval-field-editable { display: flex; flex-direction: column; gap: var(--space-2); } .approval-field-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); } /* Editable field label - fixed width, left-aligned */ .approval-field-editable .approval-field-label { width: 60px; flex-shrink: 0; text-align: left; } /* Editable field input */ .approval-field-input { flex: 1; padding: var(--space-2); background: var(--color-bg-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); font-family: var(--font-mono); font-size: 13px; color: var(--color-text-primary); } .approval-field-input:focus { outline: none; border-color: var(--color-accent-primary); } .approval-field-input::placeholder { color: var(--color-text-muted); } .approval-field-input:disabled { opacity: 0.6; cursor: not-allowed; } /* Show add comment on hover for editable fields */ .approval-field-editable:hover .approval-field-add-comment { opacity: 1; } .approval-field-add-comment { padding: 2px 8px; background: none; border: none; border-radius: var(--radius-sm); font-size: 11px; color: var(--color-text-muted); cursor: pointer; opacity: 0; transition: all 0.15s ease; } .approval-field-short:hover .approval-field-add-comment { opacity: 1; } .approval-field-add-comment:hover { color: var(--color-accent-primary); background: var(--color-bg-secondary); } /* Field comment input */ .approval-field-comment-input { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-2); background: var(--color-bg-secondary); border: 1px solid var(--color-accent-primary); border-radius: var(--radius-md); } .approval-field-comment-input input { width: 100%; padding: var(--space-2); background: var(--color-bg-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); font-family: inherit; font-size: 13px; color: var(--color-text-primary); } .approval-field-comment-input input:focus { outline: none; border-color: var(--color-accent-primary); } .approval-field-comment-input input::placeholder { color: var(--color-text-muted); } .approval-field-comment-actions { display: flex; justify-content: flex-end; gap: var(--space-2); } .approval-field-comment-actions button { padding: 4px 10px; border: none; border-radius: var(--radius-sm); font-size: 12px; cursor: pointer; background: transparent; color: var(--color-text-muted); } .approval-field-comment-actions button:hover:not(:disabled) { color: var(--color-text-primary); } .approval-field-comment-actions button.primary { background: var(--color-accent-primary); color: white; } .approval-field-comment-actions button.primary:hover:not(:disabled) { opacity: 0.9; } .approval-field-comment-actions button:disabled { opacity: 0.4; cursor: default; } /* Existing field comment */ .approval-field-comment { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2); background: var(--color-bg-secondary); border: 1px solid var(--color-border-default); border-left: 3px solid #3b82f6; border-radius: var(--radius-sm); font-size: 12px; } .approval-field-comment-content { flex: 1; color: var(--color-text-primary); } .approval-field-comment-edit, .approval-field-comment-remove { padding: 2px 6px; border: none; border-radius: var(--radius-sm); background: transparent; font-size: 11px; color: var(--color-text-muted); cursor: pointer; opacity: 0; transition: all 0.15s ease; } .approval-field-comment:hover .approval-field-comment-edit, .approval-field-comment:hover .approval-field-comment-remove { opacity: 1; } .approval-field-comment-edit:hover { color: var(--color-accent-primary); background: var(--color-bg-tertiary); } .approval-field-comment-remove:hover { color: #ef4444; background: rgba(239, 68, 68, 0.1); } /* ============================================ * Approval Footer (shared component) * ============================================ */ .approval-footer { display: flex; flex-direction: column; gap: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--color-border-default); } /* Footer actions */ .approval-footer-actions { display: flex; justify-content: space-between; align-items: center; } .approval-footer-primary-actions { display: flex; gap: var(--space-2); } /* ============================================ Mobile Styles ============================================ */ @media (max-width: 767px) { .approval-card { gap: var(--space-3); } .approval-header { padding-bottom: var(--space-2); } .approval-title { font-size: 14px; } .approval-fields { gap: var(--space-3); } .approval-field-value { max-height: 150px; font-size: 14px; } /* Main approval actions - stack with primary on top */ .approval-actions { flex-direction: column-reverse; gap: var(--space-2); } .approval-actions > * { width: 100%; } /* Footer actions - stack */ .approval-footer-actions { flex-direction: column-reverse; gap: var(--space-3); } .approval-footer-primary-actions { flex-direction: column-reverse; width: 100%; gap: var(--space-2); } .approval-footer-primary-actions > * { width: 100%; } /* Code preview */ .approval-code-content { max-height: 250px; } .approval-code-content code { font-size: 11px; } /* Always show comment buttons on mobile */ .approval-field-add-comment { opacity: 1; } .approval-field-comment-edit, .approval-field-comment-remove { opacity: 1; } /* Field input */ .approval-field-input { font-size: 16px; /* Prevent iOS zoom */ } .approval-field-comment-input input { font-size: 16px; } } ================================================ FILE: src/components/Approval/ApprovalFooter.tsx ================================================ /** * Approval Footer * * Shared footer for all approval views with three actions: * - Cancel (abort workflow) * - Request Changes (send feedback, agent iterates) * - Approve (accept as-is) * * Feedback is now collected inline on fields, not in a general textarea. */ import { Button } from '../common'; interface ApprovalFooterProps { onApprove: () => void; onRequestChanges: () => void; onCancel: () => void; isLoading: boolean; approveLabel?: string; approveDisabled?: boolean; commentCount?: number; requestChangesLabel?: string; requestChangesDisabled?: boolean; requestChangesDisabledTitle?: string; } export function ApprovalFooter({ onApprove, onRequestChanges, onCancel, isLoading, approveLabel = 'Approve', approveDisabled = false, commentCount = 0, requestChangesLabel, requestChangesDisabled, requestChangesDisabledTitle, }: ApprovalFooterProps) { const hasComments = commentCount > 0; const computedRequestChangesLabel = requestChangesLabel || (hasComments ? `Request Changes (${commentCount})` : 'Request Changes'); const isRequestChangesDisabled = requestChangesDisabled ?? !hasComments; const requestChangesTitle = requestChangesDisabledTitle ?? (!hasComments ? 'Add comments to request changes' : undefined); return (
); } ================================================ FILE: src/components/Approval/ApprovalViewRegistry.tsx ================================================ /** * Approval View Registry * * Maps MCP tool names to specialized approval view components. * This allows different tools to have tool-specific approval UIs. */ import { DefaultApproval } from './DefaultApproval'; import { EmailApproval } from './EmailApproval'; import { GitHubPRApproval } from './GitHubPRApproval'; import { GitHubPRReviewApproval } from './GitHubPRReviewApproval'; import { GoogleDocsApproval } from './GoogleDocsApproval'; import { GoogleSheetsApproval } from './GoogleSheetsApproval'; /** * Props passed to all approval view components */ export interface ApprovalViewProps { tool: string; action: string; data: Record; toolResults?: Record; onApprove: (responseData?: Record) => void; onRequestChanges: (feedback: string) => void; onCancel: () => void; isLoading: boolean; } type ApprovalViewComponent = React.FC; /** * Registry mapping tool names to their approval view components */ const APPROVAL_VIEW_REGISTRY: Record = { 'GitHub__createPullRequest': GitHubPRApproval, 'GitHub__create_pr': GitHubPRApproval, 'Sandbox__createPullRequest': GitHubPRApproval, 'GitHub__submitPullRequestReview': GitHubPRReviewApproval, 'GitHub__submit_pr_review': GitHubPRReviewApproval, 'Gmail__sendEmail': EmailApproval, 'Gmail__createDraft': EmailApproval, 'Google_Docs__createDocument': GoogleDocsApproval, 'Google_Docs__appendToDocument': GoogleDocsApproval, 'Google_Docs__replaceDocumentContent': GoogleDocsApproval, 'Google_Sheets__createSpreadsheet': GoogleSheetsApproval, 'Google_Sheets__appendRows': GoogleSheetsApproval, 'Google_Sheets__updateCells': GoogleSheetsApproval, 'Google_Sheets__replaceSheetContent': GoogleSheetsApproval, }; /** * Get the approval view component for a given tool name * Falls back to DefaultApproval if no specialized view exists */ export function getApprovalView(toolName: string): ApprovalViewComponent { return APPROVAL_VIEW_REGISTRY[toolName] || DefaultApproval; } ================================================ FILE: src/components/Approval/DefaultApproval.tsx ================================================ /** * Default Approval View * * Generic key-value display for tool approvals. * Uses CommentableText for multi-line text fields to allow inline comments. * Short fields get a simple "add comment" button on hover. * Used as fallback when no specialized view exists. */ import { useState } from 'react'; import { McpIcon, getIconTypeFromTool } from '../common'; import { ApprovalFooter } from './ApprovalFooter'; import { CommentableText, formatTextComments } from '../CommentableText/CommentableText'; import { useFieldComments } from '../../hooks'; import type { TextComment } from '../../types'; import type { ApprovalViewProps } from './ApprovalViewRegistry'; import './Approval.css'; // Tool-specific configuration for approval UI const TOOL_CONFIG: Record; commentableFields?: string[]; // Fields that support line-level comments editableFields?: string[]; // Fields that are editable (with comment support) }> = { 'Google_Docs__createDocument': { label: 'Create Document', buttonLabel: 'Create Document', fieldLabels: { title: 'Document Name', content: 'Content' }, commentableFields: ['content'], editableFields: ['title'], }, 'Gmail__sendMessage': { label: 'Send Email', buttonLabel: 'Send Email', fieldLabels: { to: 'To', subject: 'Subject', body: 'Message', cc: 'CC', bcc: 'BCC' }, commentableFields: ['body'], }, 'Gmail__createDraft': { label: 'Create Draft', buttonLabel: 'Save Draft', fieldLabels: { to: 'To', subject: 'Subject', body: 'Message', cc: 'CC', bcc: 'BCC' }, commentableFields: ['body'], }, 'Sandbox__runCode': { label: 'Run Code', buttonLabel: 'Run Code', fieldLabels: { code: 'Code', language: 'Language' }, commentableFields: ['code'], }, }; // Get tool config with fallback to generic labels function getToolConfig(toolName: string) { return TOOL_CONFIG[toolName] || { label: 'Execute Action', buttonLabel: 'Continue', fieldLabels: {}, }; } // Get field label with tool-specific mapping function getFieldLabel(toolName: string, fieldKey: string): string { const config = TOOL_CONFIG[toolName]; if (config?.fieldLabels[fieldKey]) { return config.fieldLabels[fieldKey]; } // Fallback: convert camelCase to Title Case return fieldKey .replace(/([A-Z])/g, ' $1') .replace(/_/g, ' ') .trim() .replace(/^\w/, c => c.toUpperCase()); } // Text comments state keyed by field name (for multi-line fields) interface TextCommentsMap { [fieldKey: string]: TextComment[]; } export function DefaultApproval({ tool, action, data, onApprove, onRequestChanges, onCancel, isLoading, }: ApprovalViewProps) { const toolConfig = getToolConfig(tool); const iconType = getIconTypeFromTool(tool); // Text comments for multi-line fields (CommentableText) const [textComments, setTextComments] = useState({}); // Field comments using shared hook const { commentingField, commentInput, setCommentInput, startFieldComment, editFieldComment, submitFieldComment, cancelFieldComment, removeFieldComment, getFieldComment, commentCount: fieldCommentCount, } = useFieldComments(); // Edited field values (for editable fields) const [editedValues, setEditedValues] = useState>({}); // Wrap hook functions to match expected signatures const handleStartFieldComment = (fieldKey: string) => startFieldComment(fieldKey); const handleEditFieldComment = (fieldKey: string, content: string) => editFieldComment(fieldKey, content); const handleSubmitFieldComment = (fieldKey: string, fieldLabel: string) => submitFieldComment(fieldKey, fieldLabel); const handleCancelFieldComment = () => cancelFieldComment(); const handleRemoveFieldComment = (fieldKey: string) => removeFieldComment(fieldKey); // Extract fields to display from data const fields: Array<{ key: string; label: string; value: string; isCommentable: boolean; isEditable: boolean }> = []; // Handle case where data might be a JSON string instead of object let dataObj = data; if (typeof dataObj === 'string') { try { dataObj = JSON.parse(dataObj); } catch { dataObj = {}; } } const commentableFields = toolConfig.commentableFields || []; const editableFields = toolConfig.editableFields || []; if (dataObj && typeof dataObj === 'object') { Object.entries(dataObj as Record).forEach(([key, value]) => { // Convert non-string values to readable strings const displayValue = typeof value === 'string' ? value : typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value); if (displayValue.length > 0) { fields.push({ key, label: getFieldLabel(tool, key), value: displayValue, isCommentable: commentableFields.includes(key), isEditable: editableFields.includes(key), }); } }); } // Handler for editable field changes const handleEditableChange = (fieldKey: string, newValue: string) => { setEditedValues((prev) => ({ ...prev, [fieldKey]: newValue })); }; // Get current value for an editable field const getEditableValue = (field: { key: string; value: string }) => { return editedValues[field.key] ?? field.value; }; // Text comment handlers (for multi-line fields) const handleAddTextComment = (fieldKey: string, comment: Omit) => { const newComment: TextComment = { ...comment, id: `comment-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, }; setTextComments((prev) => ({ ...prev, [fieldKey]: [...(prev[fieldKey] || []), newComment], })); }; const handleRemoveTextComment = (fieldKey: string, commentId: string) => { setTextComments((prev) => ({ ...prev, [fieldKey]: (prev[fieldKey] || []).filter((c) => c.id !== commentId), })); }; // Get total comment count const textCommentCount = Object.values(textComments).reduce((sum, arr) => sum + arr.length, 0); const totalComments = textCommentCount + fieldCommentCount; // Format all comments as feedback const formatAllFeedback = (): string => { const sections: string[] = []; for (const field of fields) { if (field.isCommentable) { // Multi-line field - format text comments const comments = textComments[field.key] || []; if (comments.length > 0) { sections.push(`[${field.label}]\n${formatTextComments(comments)}`); } } else { // Short field - format field comment const comment = getFieldComment(field.key); if (comment) { sections.push(`[${field.label}]: "${comment.content}"`); } } } return sections.join('\n\n'); }; const handleRequestChanges = () => { const feedback = formatAllFeedback(); onRequestChanges(feedback); }; return (
{/* Header with icon and action */}
{action || toolConfig.label}
{/* Fields */}
{fields.map((field) => { const existingComment = getFieldComment(field.key); const isCommenting = commentingField === field.key; return (
{field.isCommentable ? ( handleAddTextComment(field.key, comment)} onRemoveComment={(id) => handleRemoveTextComment(field.key, id)} disabled={isLoading} /> ) : field.isEditable ? ( /* Editable field with input + comment support */
handleEditableChange(field.key, e.target.value)} disabled={isLoading} placeholder={`Enter ${field.label.toLowerCase()}...`} /> {!isCommenting && !existingComment && !isLoading && ( )}
{/* Comment input */} {isCommenting && (
setCommentInput(e.target.value)} placeholder="Add your feedback..." autoFocus onKeyDown={(e) => { if (e.key === 'Enter' && commentInput.trim()) { handleSubmitFieldComment(field.key, field.label); } else if (e.key === 'Escape') { handleCancelFieldComment(); } }} />
)} {/* Existing comment */} {existingComment && !isCommenting && (
{existingComment.content}
)}
) : ( /* Read-only field with comment support */
{!isCommenting && !existingComment && !isLoading && ( )}
{field.value}
{/* Comment input */} {isCommenting && (
setCommentInput(e.target.value)} placeholder="Add your feedback..." autoFocus onKeyDown={(e) => { if (e.key === 'Enter' && commentInput.trim()) { handleSubmitFieldComment(field.key, field.label); } else if (e.key === 'Escape') { handleCancelFieldComment(); } }} />
)} {/* Existing comment */} {existingComment && !isCommenting && (
{existingComment.content}
)}
)}
); })}
{/* Footer with actions */} { // Pass back any edited field values if (Object.keys(editedValues).length > 0) { onApprove(editedValues); } else { onApprove(); } }} onRequestChanges={handleRequestChanges} onCancel={onCancel} isLoading={isLoading} approveLabel={toolConfig.buttonLabel} commentCount={totalComments} />
); } ================================================ FILE: src/components/Approval/EmailApproval.css ================================================ /** * Email Approval View Styles * * Email-client style layout for Gmail send/draft approvals. */ .email-approval-view { display: flex; flex-direction: column; height: 100%; min-height: 400px; } /* Header */ .email-approval-header { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--color-border-default); background: var(--color-bg-secondary); } .email-approval-header h3 { margin: 0; font-size: 15px; font-weight: 600; color: var(--color-text-primary); } /* Recipients Section */ .email-approval-recipients { display: flex; flex-direction: column; padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--color-border-default); background: var(--color-bg-primary); } .email-recipient-field { display: flex; flex-direction: column; gap: var(--space-1); } .email-recipient-field + .email-recipient-field { margin-top: var(--space-2); } .email-recipient-row { display: flex; align-items: center; gap: var(--space-2); min-height: 28px; } .email-recipient-label { width: 60px; flex-shrink: 0; font-size: 12px; font-weight: 500; color: var(--color-text-muted); text-align: left; } .email-recipient-input { flex: 1; padding: var(--space-1) var(--space-2); font-size: 13px; font-family: var(--font-mono); color: var(--color-text-primary); background: transparent; border: 1px solid transparent; border-radius: var(--radius-sm); transition: border-color 0.15s, background 0.15s; } .email-recipient-input:hover { background: var(--color-bg-secondary); border-color: var(--color-border-default); } .email-recipient-input:focus { outline: none; background: var(--color-bg-primary); border-color: var(--color-border-focus); } .email-recipient-input:disabled { color: var(--color-text-muted); cursor: not-allowed; } .email-recipient-add-comment { padding: 2px 8px; font-size: 11px; color: var(--color-text-muted); background: transparent; border: none; border-radius: var(--radius-sm); cursor: pointer; opacity: 0; transition: opacity 0.15s, background 0.15s; } .email-recipient-row:hover .email-recipient-add-comment { opacity: 1; } .email-recipient-add-comment:hover { background: var(--color-bg-tertiary); color: var(--color-text-secondary); } /* Field comment input */ .email-recipient-comment-input { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-2); margin-left: 48px; background: var(--color-bg-secondary); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); } .email-recipient-comment-input input { padding: var(--space-2); font-size: 13px; color: var(--color-text-primary); background: var(--color-bg-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-sm); } .email-recipient-comment-input input:focus { outline: none; border-color: var(--color-border-focus); } .email-recipient-comment-actions { display: flex; justify-content: flex-end; gap: var(--space-2); } .email-recipient-comment-actions button { padding: 4px 10px; font-size: 12px; border-radius: var(--radius-sm); cursor: pointer; border: none; } .email-recipient-comment-actions button:first-child { background: transparent; color: var(--color-text-secondary); } .email-recipient-comment-actions button:first-child:hover { background: var(--color-bg-tertiary); } .email-recipient-comment-actions button.primary { background: var(--color-accent-primary); color: white; font-weight: 500; } .email-recipient-comment-actions button.primary:hover:not(:disabled) { opacity: 0.9; } .email-recipient-comment-actions button.primary:disabled { opacity: 0.4; cursor: not-allowed; } /* Existing field comment */ .email-recipient-comment { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); margin-left: 68px; margin-top: var(--space-1); background: var(--color-bg-secondary); border: 1px solid var(--color-border-default); border-left: 3px solid #3b82f6; border-radius: var(--radius-md); font-size: 13px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); } .email-recipient-comment-content { flex: 1; color: var(--color-text-primary); } .email-recipient-comment-edit, .email-recipient-comment-remove { padding: 2px 6px; font-size: 11px; background: transparent; border: none; border-radius: var(--radius-sm); cursor: pointer; color: var(--color-text-muted); } .email-recipient-comment-edit:hover, .email-recipient-comment-remove:hover { background: var(--color-bg-tertiary); color: var(--color-text-secondary); } /* Subject Section */ .email-approval-subject { padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--color-border-default); background: var(--color-bg-primary); } .email-approval-subject .email-recipient-input { font-weight: 600; } /* Body Section */ .email-approval-body { flex: 1; min-height: 0; /* Required for flex child to scroll */ display: flex; flex-direction: column; padding: var(--space-3) var(--space-4); background: var(--color-bg-primary); } .email-approval-body .commentable-text { flex: 1; min-height: 0; display: flex; flex-direction: column; } .email-approval-body .commentable-text-content { flex: 1; min-height: 0; overflow-y: auto; } /* Footer override for edge-to-edge layout */ .email-approval-view .approval-footer { padding: var(--space-3) var(--space-4); } ================================================ FILE: src/components/Approval/EmailApproval.tsx ================================================ /** * Email Approval View * * Dedicated approval view for Gmail send/draft operations. * Displays email in familiar email-client style layout with * editable subject and commentable body. */ import { useState } from 'react'; import { McpIcon } from '../common'; import { ApprovalFooter } from './ApprovalFooter'; import { CommentableText, formatTextComments } from '../CommentableText/CommentableText'; import { useFieldComments, type FieldComment } from '../../hooks'; import type { TextComment } from '../../types'; import type { ApprovalViewProps } from './ApprovalViewRegistry'; import './EmailApproval.css'; interface EmailApprovalData { to?: string; cc?: string; bcc?: string; subject?: string; body?: string; } export function EmailApproval({ action, data, onApprove, onRequestChanges, onCancel, isLoading, }: ApprovalViewProps) { // Parse email data - handle JSON string case let emailData: EmailApprovalData = {}; if (typeof data === 'string') { try { emailData = JSON.parse(data); } catch { emailData = {}; } } else { emailData = data as EmailApprovalData; } const { to: proposedTo = '', cc: proposedCc = '', bcc: proposedBcc = '', subject: proposedSubject = '', body = '', } = emailData; // Editable fields const [emailTo, setEmailTo] = useState(proposedTo); const [emailCc, setEmailCc] = useState(proposedCc); const [emailBcc, setEmailBcc] = useState(proposedBcc); const [emailSubject, setEmailSubject] = useState(proposedSubject); // Body comments (line-level via CommentableText) const [bodyComments, setBodyComments] = useState([]); // Field comments using shared hook const { fieldComments, commentingField, commentInput, setCommentInput, startFieldComment, editFieldComment, submitFieldComment, cancelFieldComment, removeFieldComment, getFieldComment, commentCount: fieldCommentCount, } = useFieldComments(); // Body comment handlers const handleAddBodyComment = (comment: Omit) => { const newComment: TextComment = { ...comment, id: `comment-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, }; setBodyComments((prev) => [...prev, newComment]); }; const handleRemoveBodyComment = (id: string) => { setBodyComments((prev) => prev.filter((c) => c.id !== id)); }; // Wrapper functions for the hook (to match component expectations) const handleStartFieldComment = (fieldKey: string) => startFieldComment(fieldKey); const handleEditFieldComment = (fieldKey: string, content: string) => editFieldComment(fieldKey, content); const handleSubmitFieldComment = (fieldKey: string, fieldLabel: string) => submitFieldComment(fieldKey, fieldLabel); const handleCancelFieldComment = () => cancelFieldComment(); const handleRemoveFieldComment = (fieldKey: string) => removeFieldComment(fieldKey); // Total comments count const totalComments = bodyComments.length + fieldCommentCount; // Format all comments as feedback const formatAllFeedback = (): string => { const sections: string[] = []; // Field comments for (const fc of fieldComments) { sections.push(`[${fc.fieldLabel}]: "${fc.content}"`); } // Body comments if (bodyComments.length > 0) { sections.push(`[Message Body]\n${formatTextComments(bodyComments)}`); } return sections.join('\n\n'); }; const handleApprove = () => { // Pass back edited fields (onApprove as (responseData?: Record) => void)({ to: emailTo.trim() || proposedTo, cc: emailCc.trim() || undefined, bcc: emailBcc.trim() || undefined, subject: emailSubject.trim() || proposedSubject, }); }; const handleRequestChanges = () => { const feedback = formatAllFeedback(); onRequestChanges(feedback); }; // Determine action label const isSend = action?.toLowerCase().includes('send'); const approveLabel = isSend ? 'Send Email' : 'Save Draft'; return (
{/* Header */}

{action || (isSend ? 'Send Email' : 'Create Draft')}

{/* Recipients Section */}
handleStartFieldComment('to')} onCommentChange={setCommentInput} onSubmitComment={() => handleSubmitFieldComment('to', 'To')} onCancelComment={handleCancelFieldComment} onEditComment={(content) => handleEditFieldComment('to', content)} onRemoveComment={() => handleRemoveFieldComment('to')} disabled={isLoading} /> {(emailCc || proposedCc) && ( handleStartFieldComment('cc')} onCommentChange={setCommentInput} onSubmitComment={() => handleSubmitFieldComment('cc', 'CC')} onCancelComment={handleCancelFieldComment} onEditComment={(content) => handleEditFieldComment('cc', content)} onRemoveComment={() => handleRemoveFieldComment('cc')} disabled={isLoading} /> )} {(emailBcc || proposedBcc) && ( handleStartFieldComment('bcc')} onCommentChange={setCommentInput} onSubmitComment={() => handleSubmitFieldComment('bcc', 'BCC')} onCancelComment={handleCancelFieldComment} onEditComment={(content) => handleEditFieldComment('bcc', content)} onRemoveComment={() => handleRemoveFieldComment('bcc')} disabled={isLoading} /> )}
{/* Subject (editable with comment) */}
handleStartFieldComment('subject')} onCommentChange={setCommentInput} onSubmitComment={() => handleSubmitFieldComment('subject', 'Subject')} onCancelComment={handleCancelFieldComment} onEditComment={(content) => handleEditFieldComment('subject', content)} onRemoveComment={() => handleRemoveFieldComment('subject')} disabled={isLoading} />
{/* Body (commentable) */}
{/* Footer */}
); } /** * Recipient field with comment support */ interface RecipientFieldProps { label: string; value: string; onChange: (value: string) => void; fieldKey: string; isCommenting: boolean; existingComment?: FieldComment; commentInput: string; onStartComment: () => void; onCommentChange: (value: string) => void; onSubmitComment: () => void; onCancelComment: () => void; onEditComment: (content: string) => void; onRemoveComment: () => void; disabled: boolean; } function RecipientField({ label, value, onChange, isCommenting, existingComment, commentInput, onStartComment, onCommentChange, onSubmitComment, onCancelComment, onEditComment, onRemoveComment, disabled, }: RecipientFieldProps) { return (
{label}: onChange(e.target.value)} disabled={disabled} placeholder={`Enter ${label.toLowerCase()}...`} /> {!isCommenting && !existingComment && !disabled && ( )}
{/* Comment input */} {isCommenting && (
onCommentChange(e.target.value)} placeholder="Add your feedback..." autoFocus onKeyDown={(e) => { if (e.key === 'Enter' && commentInput.trim()) { onSubmitComment(); } else if (e.key === 'Escape') { onCancelComment(); } }} />
)} {/* Existing comment */} {existingComment && !isCommenting && (
{existingComment.content}
)}
); } ================================================ FILE: src/components/Approval/GitHubPRApproval.css ================================================ /** * GitHub PR Approval View Styles * * Full-width diff viewer layout for PR approval. * Similar to ExecutionReviewView but integrated as an approval component. */ .pr-approval-view { display: flex; flex-direction: column; height: 100%; min-height: 500px; } /* Header */ .pr-approval-header { display: flex; align-items: flex-start; gap: var(--space-4); padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--color-border-default); background: var(--color-bg-secondary); } .pr-approval-title { flex: 1; display: flex; flex-direction: column; gap: var(--space-2); } .pr-approval-title-row { display: flex; align-items: center; gap: var(--space-2); } .pr-approval-title h3 { margin: 0; font-size: 15px; font-weight: 600; color: var(--color-text-primary); } .pr-approval-repo { display: flex; align-items: center; gap: var(--space-2); font-size: 12px; color: var(--color-text-secondary); } .pr-approval-repo code { font-family: var(--font-mono); padding: 2px 6px; background: var(--color-bg-tertiary); border-radius: var(--radius-sm); } .pr-approval-branch-arrow { color: var(--color-text-muted); } .pr-approval-branch-into { color: var(--color-text-muted); } .pr-approval-branch { color: var(--color-primary); } .pr-approval-stats { display: flex; gap: var(--space-3); font-family: var(--font-mono); font-size: 12px; } .pr-approval-stats .stat-files { color: var(--color-text-secondary); } .pr-approval-stats .stat-additions { color: #22c55e; } .pr-approval-stats .stat-deletions { color: #ef4444; } /* Content */ .pr-approval-content { flex: 1; display: flex; overflow: hidden; min-height: 0; } /* Empty state when no diff */ .pr-approval-content-empty { flex: 0; min-height: 120px; } .pr-approval-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-2); padding: var(--space-4); color: var(--color-text-muted); } .pr-approval-empty-icon { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: var(--color-warning-subtle); color: var(--color-warning-text); font-weight: 600; font-size: 16px; } .pr-approval-empty-title { font-size: 14px; font-weight: 500; color: var(--color-text-secondary); } .pr-approval-empty-message { font-size: 12px; color: var(--color-text-muted); text-align: center; max-width: 300px; } .pr-approval-sidebar { width: 220px; border-right: 1px solid var(--color-border-default); overflow-y: auto; background: var(--color-bg-secondary); } .pr-approval-diff { flex: 1; overflow-y: auto; padding: var(--space-3); } /* Footer */ .pr-approval-footer { padding: var(--space-3) var(--space-4); border-top: 1px solid var(--color-border-default); background: var(--color-bg-secondary); } .pr-approval-form { display: flex; flex-direction: column; gap: var(--space-3); } .pr-approval-form-fields { display: flex; flex-direction: column; gap: var(--space-2); } .pr-approval-form-body { display: flex; flex-direction: column; gap: var(--space-1); } .pr-approval-textarea { padding: var(--space-2) var(--space-3); background: var(--color-bg-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); font-family: var(--font-mono); font-size: 13px; color: var(--color-text-primary); resize: vertical; min-height: 60px; } .pr-approval-textarea:focus { outline: none; border-color: var(--color-border-focus); } .pr-approval-textarea:disabled { background: var(--color-bg-tertiary); color: var(--color-text-muted); cursor: not-allowed; } /* Agent feedback (collapsible) */ .pr-approval-agent-feedback { display: flex; flex-direction: column; gap: var(--space-2); } .pr-approval-agent-feedback-bar { display: flex; align-items: center; justify-content: space-between; } .pr-approval-agent-feedback-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--color-text-muted); } .pr-approval-agent-feedback-dismiss { border: none; background: transparent; font-size: 12px; color: var(--color-text-muted); cursor: pointer; padding: 0; } .pr-approval-agent-feedback-dismiss:hover { color: var(--color-text-primary); } .pr-approval-agent-feedback-count { font-weight: 400; } .pr-approval-add-note-link { align-self: flex-start; border: none; background: transparent; font-size: 12px; color: var(--color-text-muted); cursor: pointer; padding: 0; } .pr-approval-add-note-link:hover { color: var(--color-text-primary); } .pr-approval-agent-textarea { width: 100%; padding: var(--space-2) var(--space-3); border: 1px dashed var(--color-border-default); border-radius: var(--radius-md); background: var(--color-bg-primary); color: var(--color-text-primary); font-size: 13px; font-family: var(--font-mono); resize: vertical; } .pr-approval-agent-textarea:focus { outline: none; border-color: var(--color-border-focus); } .pr-approval-agent-textarea:disabled { opacity: 0.6; cursor: not-allowed; } ================================================ FILE: src/components/Approval/GitHubPRApproval.tsx ================================================ /** * GitHub PR Approval View * * Full diff viewer for approving pull request creation. * Shows the complete diff with file tree, editable PR title/body. */ import { useState, useRef, useMemo } from 'react'; import { Input, McpIcon } from '../common'; import { ApprovalFooter } from './ApprovalFooter'; import { DiffViewer, FileTree } from '../DiffViewer/DiffViewer'; import { parseDiff } from '../../utils/diffParser'; import type { ApprovalViewProps } from './ApprovalViewRegistry'; import type { DiffComment } from '../../types'; import './GitHubPRApproval.css'; interface PRApprovalData { owner?: string; repo?: string; baseBranch?: string; // GitHub tool headBranch?: string; base?: string; // Sandbox tool branch?: string; title?: string; body?: string; diff?: string; stats?: { files?: number; additions?: number; deletions?: number; }; } export function GitHubPRApproval({ action, data, toolResults, onApprove, onRequestChanges, onCancel, isLoading, }: ApprovalViewProps) { const diffViewerRef = useRef(null); const [comments, setComments] = useState([]); // Parse data - handle JSON string case let prData: PRApprovalData = {}; if (typeof data === 'string') { try { prData = JSON.parse(data); } catch { prData = {}; } } else { prData = data as PRApprovalData; } // Prefer cached getDiff result for diff/stats (agent may truncate large diffs) const diffResult = toolResults?.['Sandbox__getDiff'] as PRApprovalData | undefined; if (diffResult) { if (diffResult.diff) prData.diff = diffResult.diff; if (diffResult.stats) prData.stats = diffResult.stats; } const { owner = '', repo = '', baseBranch, headBranch, base, branch, title: proposedTitle = '', body: proposedBody = '', diff = '', stats, } = prData; const targetBranch = baseBranch || base || 'main'; const sourceBranch = headBranch || branch || ''; // Parse the diff content const files = useMemo(() => parseDiff(diff), [diff]); // Editable PR title and body const [prTitle, setPrTitle] = useState(proposedTitle); const [prBody, setPrBody] = useState(proposedBody); const [selectedFile, setSelectedFile] = useState( files.length > 0 ? files[0].path : undefined ); const [showAgentFeedback, setShowAgentFeedback] = useState(false); const [revisionNote, setRevisionNote] = useState(''); // Calculate stats from parsed diff if not provided const totalFiles = stats?.files ?? files.length; const totalAdditions = stats?.additions ?? files.reduce((sum, f) => sum + f.additions, 0); const totalDeletions = stats?.deletions ?? files.reduce((sum, f) => sum + f.deletions, 0); const handleFileSelect = (path: string) => { setSelectedFile(path); // Scroll the file into view in the diff viewer if (diffViewerRef.current) { const fileElement = diffViewerRef.current.querySelector(`[data-file-path="${CSS.escape(path)}"]`); if (fileElement) { fileElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } }; // Comment handlers for diff const handleAddComment = (comment: Omit) => { const newComment: DiffComment = { ...comment, id: `comment-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, }; setComments((prev) => [...prev, newComment]); }; const handleRemoveComment = (id: string) => { setComments((prev) => prev.filter((c) => c.id !== id)); }; // Format comments as feedback for the agent const formatCommentsFeedback = (): string => { if (comments.length === 0) return ''; const lines: string[] = ['DIFF COMMENTS:']; // Group by file const byFile = comments.reduce((acc, c) => { if (!acc[c.filePath]) acc[c.filePath] = []; acc[c.filePath].push(c); return acc; }, {} as Record); for (const [filePath, fileComments] of Object.entries(byFile)) { lines.push(`\nFile: ${filePath}`); for (const c of fileComments) { const lineRef = c.endLine && c.endLine !== c.lineNumber ? `Lines ${c.lineNumber}-${c.endLine}` : `Line ${c.lineNumber}`; lines.push(` ${lineRef}: "${c.content}"`); } } return lines.join('\n'); }; const handleApprove = () => { // Pass back user-edited title/body (onApprove as (responseData?: Record) => void)({ title: prTitle.trim() || proposedTitle, body: prBody.trim(), }); }; const hasRevisionNote = revisionNote.trim().length > 0; const hasComments = comments.length > 0; const handleReviseOrExpand = () => { // If expanded or has comments: send immediately if (showAgentFeedback || hasComments) { handleRequestChanges(); return; } // Otherwise expand the feedback textarea setShowAgentFeedback(true); }; const handleRequestChanges = () => { const parts: string[] = []; if (revisionNote.trim()) { parts.push(revisionNote.trim()); } const commentsFeedback = formatCommentsFeedback(); if (commentsFeedback) { parts.push(commentsFeedback); } onRequestChanges(parts.join('\n\n')); }; const reviseLabel = showAgentFeedback ? 'Send to Agent' : hasComments ? `Send to Agent (${comments.length})` : 'Revise with Agent'; const reviseDisabled = showAgentFeedback ? !hasRevisionNote && !hasComments : false; return (
{/* Header */}

{action || 'Create Pull Request'}

{owner && repo && <>{owner}/{repo}} {sourceBranch} into {targetBranch}
{totalFiles} files +{totalAdditions} -{totalDeletions}
{/* Content */}
{files.length === 0 ? (
!
No diff provided
The agent should use Sandbox to make changes and include the diff in the approval request.
) : ( <> {/* Sidebar */}
{/* Diff View */}
)}
{/* Footer with PR form */}
setPrTitle(e.target.value)} placeholder="Enter PR title..." disabled={isLoading} />