Merge branch 'RooCodeInc:main' into add-dart-codebase-index

This commit is contained in:
mahaat 2025-12-21 18:05:37 +07:00 committed by GitHub
commit 841334c78c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
766 changed files with 51031 additions and 14015 deletions

View file

@ -76,12 +76,11 @@ body:
label: API Provider (optional)
options:
- Anthropic
- AWS Bedrock
- Amazon Bedrock
- Chutes AI
- DeepSeek
- Featherless AI
- Fireworks AI
- Glama
- Google Gemini
- Google Vertex AI
- Groq

3
.gitignore vendored
View file

@ -49,3 +49,6 @@ logs
# Qdrant
qdrant_storage/
# Architect plans
plans/

View file

@ -1,25 +1,6 @@
version: "1.0"
commands:
- name: Pull latest changes
run: git pull
timeout: 60
execution_phase: task_run
- name: Install dependencies
run: pnpm install
timeout: 60
execution_phase: task_run
github_events:
- event: issues.opened
action:
name: github.issue.fix
- event: issue_comment.created
action:
name: github.issue.comment.respond
- event: pull_request.opened
action:
name: github.pr.review
- event: pull_request_review_comment.created
action:
name: github.pr.comment.respond

View file

@ -115,7 +115,7 @@
- 保留英文品牌名
- 技术术语保持一致性
- 保留英文专有名词:如"AWS Bedrock ARN"
- 保留英文专有名词:如"Amazon Bedrock ARN"
4. **用户操作**
- 操作动词统一:

View file

@ -1,5 +1,321 @@
# Roo Code Changelog
## [3.36.16] - 2025-12-19
- Fix: Normalize tool schemas for VS Code LM API to resolve error 400 when using VS Code Language Model API providers (PR #10221 by @hannesrudolph)
## [3.36.15] - 2025-12-19
![3.36.15 Release - 1M Context Window Support](/releases/3.36.15-release.png)
- Add 1M context window beta support for Claude Sonnet 4 on Vertex AI, enabling significantly larger context for complex tasks (PR #10209 by @hannesrudolph)
- Add native tool calling support for LM Studio and Qwen-Code providers, improving compatibility with local models (PR #10208 by @hannesrudolph)
- Add native tool call defaults for OpenAI-compatible providers, expanding native function calling across more configurations (PR #10213 by @hannesrudolph)
- Enable native tool calls for Requesty provider (PR #10211 by @daniel-lxs)
- Improve API error handling and visibility with clearer error messages and better user feedback (PR #10204 by @brunobergher)
- Add downloadable error diagnostics from chat errors, making it easier to troubleshoot and report issues (PR #10188 by @brunobergher)
- Fix refresh models button not properly flushing the cache, ensuring model lists update correctly (#9682 by @tl-hbk, PR #9870 by @pdecat)
- Fix additionalProperties handling for strict mode compatibility, resolving schema validation issues with certain providers (PR #10210 by @daniel-lxs)
## [3.36.14] - 2025-12-18
![3.36.14 Release - Native Tool Calling for Claude on Vertex AI](/releases/3.36.14-release.png)
- Add native tool calling support for Claude models on Vertex AI, enabling more efficient and reliable tool interactions (PR #10197 by @hannesrudolph)
- Fix JSON Schema format value stripping for OpenAI compatibility, resolving issues with unsupported format values (PR #10198 by @daniel-lxs)
- Improve "no tools used" error handling with graceful retry mechanism for better reliability when tools fail to execute (PR #10196 by @hannesrudolph)
## [3.36.13] - 2025-12-18
![3.36.13 Release - Native Tool Protocol](/releases/3.36.13-release.png)
- Change default tool protocol from XML to native for improved reliability and performance (PR #10186 by @mrubens)
- Add native tool support for VS Code Language Model API providers (PR #10191 by @daniel-lxs)
- Lock task tool protocol for consistent task resumption, ensuring tasks resume with the same protocol they started with (PR #10192 by @daniel-lxs)
- Replace edit_file tool alias with actual edit_file tool for improved diff editing capabilities (PR #9983 by @hannesrudolph)
- Fix LiteLLM router models by merging default model info for native tool calling support (PR #10187 by @daniel-lxs)
- Add PostHog exception tracking for consecutive mistake errors to improve error monitoring (PR #10193 by @daniel-lxs)
## [3.36.12] - 2025-12-18
![3.36.12 Release - Better telemetry and Bedrock fixes](/releases/3.36.12-release.png)
- Fix: Add userAgentAppId to Bedrock embedder for code indexing (#10165 by @jackrein, PR #10166 by @roomote)
- Update OpenAI and Gemini tool preferences for improved model behavior (PR #10170 by @hannesrudolph)
- Extract error messages from JSON payloads for better PostHog error grouping (PR #10163 by @daniel-lxs)
## [3.36.11] - 2025-12-17
![3.36.11 Release - Native Tool Calling Enhancements](/releases/3.36.11-release.png)
- Add support for Claude Code Provider native tool calling, improving tool execution performance and reliability (PR #10077 by @hannesrudolph)
- Enable native tool calling by default for Z.ai models for better model compatibility (PR #10158 by @app/roomote)
- Enable native tools by default for OpenAI compatible provider to improve tool calling support (PR #10159 by @daniel-lxs)
- Fix: Normalize MCP tool schemas for Bedrock and OpenAI strict mode to ensure proper tool compatibility (PR #10148 by @daniel-lxs)
- Fix: Remove dots and colons from MCP tool names for Bedrock compatibility (PR #10152 by @daniel-lxs)
- Fix: Convert tool_result to XML text when native tools disabled for Bedrock (PR #10155 by @daniel-lxs)
- Fix: Refresh Roo models cache with session token on auth state change to resolve model list refresh issues (PR #10156 by @daniel-lxs)
- Fix: Support AWS GovCloud and China region ARNs in Bedrock provider for expanded regional support (PR #10157 by @app/roomote)
## [3.36.10] - 2025-12-17
![3.36.10 Release - Gemini 3 Flash Preview](/releases/3.36.10-release.png)
- Add support for Gemini 3 Flash Preview model in the Gemini provider (PR #10151 by @hannesrudolph)
- Implement interleaved thinking mode for DeepSeek Reasoner, enabling streaming reasoning output (PR #9969 by @hannesrudolph)
- Fix: Preserve reasoning_content during tool call sequences in DeepSeek (PR #10141 by @hannesrudolph)
- Fix: Correct token counting for context truncation display (PR #9961 by @hannesrudolph)
- Update Next.js dependency to ~15.2.8 (PR #10140 by @jr)
## [3.36.9] - 2025-12-15
![3.36.9 Release - Cross-Provider Compatibility](/releases/3.36.9-release.png)
- Fix: Normalize tool call IDs for cross-provider compatibility via OpenRouter, ensuring consistent handling across different AI providers (PR #10102 by @daniel-lxs)
- Fix: Add additionalProperties: false to nested MCP tool schemas, improving schema validation and preventing unexpected properties (PR #10109 by @daniel-lxs)
- Fix: Validate tool_result IDs in delegation resume flow, preventing errors when resuming delegated tasks (PR #10135 by @daniel-lxs)
- Feat: Add full error details to streaming failure dialog, providing more comprehensive information for debugging streaming issues (PR #10131 by @roomote)
- Feat: Improve evals UI with tool groups and duration fix, enhancing the evaluation interface organization and timing accuracy (PR #10133 by @hannesrudolph)
## [3.36.8] - 2025-12-16
![3.36.8 Release - Native Tools Enabled by Default](/releases/3.36.8-release.png)
- Implement incremental token-budgeted file reading for smarter, more efficient file content retrieval (PR #10052 by @jr)
- Enable native tools by default for multiple providers including OpenAI, Azure, Google, Vertex, and more (PR #10059 by @daniel-lxs)
- Enable native tools by default for Anthropic and add telemetry tracking for tool format usage (PR #10021 by @daniel-lxs)
- Fix: Prevent race condition from deleting wrong API messages during streaming (PR #10113 by @hannesrudolph)
- Fix: Prevent duplicate MCP tools error by deduplicating servers at source (PR #10096 by @daniel-lxs)
- Remove strict ARN validation for Bedrock custom ARN users allowing more flexibility (#10108 by @wisestmumbler, PR #10110 by @roomote)
- Add metadata to error details dialog for improved debugging (PR #10050 by @roomote)
- Add configuration to control public sharing feature (PR #10105 by @mrubens)
- Remove description from Bedrock service tiers for cleaner UI (PR #10118 by @mrubens)
- Fix: Correct link to provider pricing page on web (PR #10107 by @brunobergher)
## [3.36.7] - 2025-12-15
- Improve tool configuration for OpenAI models in OpenRouter (PR #10082 by @hannesrudolph)
- Capture more detailed provider-specific error information from OpenRouter for better debugging (PR #10073 by @jr)
- Add Amazon Nova 2 Lite model to Bedrock provider (#9802 by @Smartsheet-JB-Brown, PR #9830 by @roomote)
- Add AWS Bedrock service tier support (#9874 by @Smartsheet-JB-Brown, PR #9955 by @roomote)
- Remove auto-approve toggles for to-do and retry actions to simplify the approval workflow (PR #10062 by @hannesrudolph)
- Move isToolAllowedForMode out of shared directory for better code organization (PR #10089 by @cte)
- Improve run logs and formatters in web-evals for better evaluation tracking (PR #10081 by @hannesrudolph)
## [3.36.6] - 2025-12-12
![3.36.6 Release - Tool Alias Support](/releases/3.36.6-release.png)
- Add tool alias support for model-specific tool customization, allowing users to configure how tools are presented to different AI models (PR #9989 by @daniel-lxs)
- Sanitize MCP server and tool names for API compatibility, ensuring special characters don't cause issues with API calls (PR #10054 by @daniel-lxs)
- Improve auto-approve timer visibility in follow-up suggestions for better user awareness of pending actions (PR #10048 by @brunobergher)
- Fix: Cancel auto-approval timeout when user starts typing, preventing accidental auto-approvals during user interaction (PR #9937 by @roomote)
- Add WorkspaceTaskVisibility type for organization cloud settings to support team visibility controls (PR #10020 by @roomote)
- Fix: Extract raw error message from OpenRouter metadata for clearer error reporting (PR #10039 by @daniel-lxs)
- Fix: Show tool protocol dropdown for LiteLLM provider, restoring missing configuration option (PR #10053 by @daniel-lxs)
## [3.36.5] - 2025-12-11
![3.36.5 Release - GPT-5.2](/releases/3.36.5-release.png)
- Add: GPT-5.2 model to openai-native provider (PR #10024 by @hannesrudolph)
- Add: Toggle for Enter key behavior in chat input allowing users to configure whether Enter sends or creates new line (#8555 by @lmtr0, PR #10002 by @hannesrudolph)
- Add: App version to telemetry exception captures and filter 402 errors (PR #9996 by @daniel-lxs)
- Fix: Handle empty Gemini responses and reasoning loops to prevent infinite retries (PR #10007 by @hannesrudolph)
- Fix: Add missing tool_result blocks to prevent API errors when tool results are expected (PR #10015 by @daniel-lxs)
- Fix: Filter orphaned tool_results when more results than tool_uses to prevent message validation errors (PR #10027 by @daniel-lxs)
- Fix: Add general API endpoints for Z.ai provider (#9879 by @richtong, PR #9894 by @roomote)
- Fix: Apply versioned settings on nightly builds (PR #9997 by @hannesrudolph)
- Remove: Glama provider (PR #9801 by @hannesrudolph)
- Remove: Deprecated list_code_definition_names tool (PR #10005 by @hannesrudolph)
## [3.36.4] - 2025-12-10
![3.36.4 Release - Error Details Modal](/releases/3.36.4-release.png)
- Add error details modal with on-demand display for improved error visibility when debugging issues (PR #9985 by @roomote)
- Fix: Prevent premature rawChunkTracker clearing for MCP tools, improving reliability of MCP tool streaming (PR #9993 by @daniel-lxs)
- Fix: Filter out 429 rate limit errors from API error telemetry for cleaner metrics (PR #9987 by @daniel-lxs)
- Fix: Correct TODO list display order in chat view to show items in proper sequence (PR #9991 by @roomote)
## [3.36.3] - 2025-12-09
![3.36.3 Release](/releases/3.36.3-release.png)
- Refactor: Unified context-management architecture with improved UX for better context control (PR #9795 by @hannesrudolph)
- Add new `search_replace` native tool for single-replacement operations with improved editing precision (PR #9918 by @hannesrudolph)
- Streaming tool stats and token usage throttling for better real-time feedback during generation (PR #9926 by @hannesrudolph)
- Add versioned settings support with minPluginVersion gating for Roo provider (PR #9934 by @hannesrudolph)
- Make Architect mode save plans to `/plans` directory and gitignore it (PR #9944 by @brunobergher)
- Add announcement support CTA and social icons to UI (PR #9945 by @hannesrudolph)
- Add ability to save screenshots from the browser tool (PR #9963 by @mrubens)
- Refactor: Decouple tools from system prompt for cleaner architecture (PR #9784 by @daniel-lxs)
- Update DeepSeek models to V3.2 with new pricing (PR #9962 by @hannesrudolph)
- Add minimal and medium reasoning effort levels for Gemini models (PR #9973 by @hannesrudolph)
- Update xAI models catalog with latest model options (PR #9872 by @hannesrudolph)
- Add DeepSeek V3-2 support for Baseten provider (PR #9861 by @AlexKer)
- Tweaks to Baseten model definitions for better defaults (PR #9866 by @mrubens)
- Fix: Add xhigh reasoning effort support for gpt-5.1-codex-max (#9891 by @andrewginns, PR #9900 by @andrewginns)
- Fix: Add Kimi, MiniMax, and Qwen model configurations for Bedrock (#9902 by @jbearak, PR #9905 by @app/roomote)
- Configure tool preferences for xAI models (PR #9923 by @hannesrudolph)
- Default to using native tools when supported on OpenRouter (PR #9878 by @mrubens)
- Fix: Exclude apply_diff from native tools when diffEnabled is false (#9919 by @denis-kudelin, PR #9920 by @app/roomote)
- Fix: Always show tool protocol selector for openai-compatible provider (#9965 by @bozoweed, PR #9966 by @hannesrudolph)
- Fix: Respect explicit supportsReasoningEffort array values for proper model configuration (PR #9970 by @hannesrudolph)
- Add timeout configuration to OpenAI Compatible Provider Client (PR #9898 by @dcbartlett)
- Revert default tool protocol change from xml to native for stability (PR #9956 by @mrubens)
- Remove defaultTemperature from Roo provider configuration (PR #9932 by @mrubens)
- Improve OpenAI error messages to be more useful for debugging (PR #9639 by @mrubens)
- Better error logs for parseToolCall exceptions (PR #9857 by @cte)
- Improve cloud job error logging for RCC provider errors (PR #9924 by @cte)
- Fix: Display actual API error message instead of generic text on retry (PR #9954 by @hannesrudolph)
- Add API error telemetry to OpenRouter provider for better diagnostics (PR #9953 by @daniel-lxs)
- Fix: Sanitize removed/invalid API providers to prevent infinite loop (PR #9869 by @hannesrudolph)
- Fix: Use foreground color for context-management icons (PR #9912 by @hannesrudolph)
- Fix: Suppress 'ask promise was ignored' error in handleError (PR #9914 by @daniel-lxs)
- Fix: Process finish_reason to emit tool_call_end events properly (PR #9927 by @daniel-lxs)
- Fix: Add finish_reason processing to xai.ts provider (PR #9929 by @daniel-lxs)
- Fix: Validate and fix tool_result IDs before API requests (PR #9952 by @daniel-lxs)
- Fix: Return undefined instead of 0 for disabled API timeout (PR #9960 by @hannesrudolph)
- Stop making unnecessary count_tokens requests for better performance (PR #9884 by @mrubens)
- Refactor: Consolidate ThinkingBudget components and fix disable handling (PR #9930 by @hannesrudolph)
- Forbid time estimates in architect mode for more focused planning (PR #9931 by @app/roomote)
- Web: Add product pages (PR #9865 by @brunobergher)
- Make eval runs deleteable in the web UI (PR #9909 by @mrubens)
- Feat: Change defaultToolProtocol default from xml to native (later reverted) (PR #9892 by @app/roomote)
## [3.36.2] - 2025-12-04
![3.36.2 Release - Dynamic API Settings](/releases/3.36.2-release.png)
- Restrict GPT-5 tool set to apply_patch for improved compatibility (PR #9853 by @hannesrudolph)
- Add dynamic settings support for Roo models from API, allowing model-specific configurations to be fetched dynamically (PR #9852 by @hannesrudolph)
- Fix: Resolve Chutes provider model fetching issue (PR #9854 by @cte)
## [3.36.1] - 2025-12-04
![3.36.1 Release - Message Management & Stability Improvements](/releases/3.36.1-release.png)
- Add MessageManager layer for centralized history coordination, fixing message synchronization issues (PR #9842 by @hannesrudolph)
- Fix: Prevent cascading truncation loop by only truncating visible messages (PR #9844 by @hannesrudolph)
- Fix: Handle unknown/invalid native tool calls to prevent extension freeze (PR #9834 by @daniel-lxs)
- Always enable reasoning for models that require it (PR #9836 by @cte)
- ChatView: Smoother stick-to-bottom behavior during streaming (PR #8999 by @hannesrudolph)
- UX: Improved error messages and documentation links (PR #9777 by @brunobergher)
- Fix: Overly round follow-up question suggestions styling (PR #9829 by @brunobergher)
- Add symlink support for slash commands in .roo/commands folder (PR #9838 by @mrubens)
- Ignore input to the execa terminal process for safer command execution (PR #9827 by @mrubens)
- Be safer about large file reads (PR #9843 by @jr)
- Add gpt-5.1-codex-max model to OpenAI provider (PR #9848 by @hannesrudolph)
- Evals UI: Add filtering, bulk delete, tool consolidation, and run notes (PR #9837 by @hannesrudolph)
- Evals UI: Add multi-model launch and UI improvements (PR #9845 by @hannesrudolph)
- Web: New pricing page (PR #9821 by @brunobergher)
## [3.36.0] - 2025-12-04
![3.36.0 Release - Rewind Kangaroo](/releases/3.36.0-release.png)
- Fix: Restore context when rewinding after condense (#8295 by @hannesrudolph, PR #9665 by @hannesrudolph)
- Add reasoning_details support to Roo provider for enhanced model reasoning visibility (PR #9796 by @app/roomote)
- Default to native tools for all models in the Roo provider for improved performance (PR #9811 by @mrubens)
- Enable search_and_replace for Minimax models (PR #9780 by @mrubens)
- Fix: Resolve Vercel AI Gateway model fetching issues (PR #9791 by @cte)
- Fix: Apply conservative max tokens for Cerebras provider (PR #9804 by @sebastiand-cerebras)
- Fix: Remove omission detection logic to eliminate false positives (#9785 by @Michaelzag, PR #9787 by @app/roomote)
- Refactor: Remove deprecated insert_content tool (PR #9751 by @daniel-lxs)
- Chore: Hide parallel tool calls experiment and disable feature (PR #9798 by @hannesrudolph)
- Update next.js documentation site dependencies (PR #9799 by @jr)
- Fix: Correct download count display on homepage (PR #9807 by @mrubens)
## [3.35.5] - 2025-12-03
- Feat: Add provider routing selection for OpenRouter embeddings (#9144 by @SannidhyaSah, PR #9693 by @SannidhyaSah)
- Default Minimax M2 to native tool calling (PR #9778 by @mrubens)
- Sanitize the native tool calls to fix a bug with Gemini (PR #9769 by @mrubens)
- UX: Updates to CloudView (PR #9776 by @roomote)
## [3.35.4] - 2025-12-02
- Fix: Handle malformed native tool calls to prevent hanging (PR #9758 by @daniel-lxs)
- Fix: Remove reasoning toggles for GLM-4.5 and GLM-4.6 on z.ai provider (PR #9752 by @roomote)
- Refactor: Remove line_count parameter from write_to_file tool (PR #9667 by @hannesrudolph)
## [3.35.3] - 2025-12-02
- Switch to new welcome view for improved onboarding experience (PR #9741 by @mrubens)
- Update homepage with latest changes (PR #9675 by @brunobergher)
- Improve privacy for stealth models by adding vendor confidentiality section to system prompt (PR #9742 by @mrubens)
## [3.35.2] - 2025-12-01
![3.35.2 Release - Model Default Temperatures](/releases/3.35.2-release.png)
- Allow models to contain default temperature settings for provider-specific optimal defaults (PR #9734 by @mrubens)
- Add tag-based native tool calling detection for Roo provider models (PR #9735 by @mrubens)
- Enable native tool support for all LiteLLM models by default (PR #9736 by @mrubens)
- Pass app version to provider for improved request tracking (PR #9730 by @cte)
## [3.35.1] - 2025-12-01
- Fix: Flush pending tool results before task delegation (PR #9726 by @daniel-lxs)
- Improve: Better IPC error logging for easier debugging (PR #9727 by @cte)
## [3.35.0] - 2025-12-01
![3.35.0 Release - Subtasks & Native Tools](/releases/3.35.0-release.png)
- Metadata-driven subtasks with automatic parent resume and single-open safety for improved task orchestration (#8081 by @hannesrudolph, PR #9090 by @hannesrudolph)
- Native tool calling support expanded across many providers: Bedrock (PR #9698 by @mrubens), Cerebras (PR #9692 by @mrubens), Chutes with auto-detection from API (PR #9715 by @daniel-lxs), DeepInfra (PR #9691 by @mrubens), DeepSeek and Doubao (PR #9671 by @daniel-lxs), Groq (PR #9673 by @daniel-lxs), LiteLLM (PR #9719 by @daniel-lxs), Ollama (PR #9696 by @mrubens), OpenAI-compatible providers (PR #9676 by @daniel-lxs), Requesty (PR #9672 by @daniel-lxs), Unbound (PR #9699 by @mrubens), Vercel AI Gateway (PR #9697 by @mrubens), Vertex Gemini (PR #9678 by @daniel-lxs), and xAI with new Grok 4 Fast and Grok 4.1 Fast models (PR #9690 by @mrubens)
- Fix: Preserve tool_use blocks in summary for parallel tool calls (#9700 by @SilentFlower, PR #9714 by @SilentFlower)
- Default Grok Code Fast to native tools for better performance (PR #9717 by @mrubens)
- UX improvements to the Roo Code Cloud provider-centric onboarding flow (PR #9709 by @brunobergher)
- UX toolbar cleanup and settings consolidation for a cleaner interface (PR #9710 by @brunobergher)
- Add model-specific tool customization via `excludedTools` and `includedTools` configuration (PR #9641 by @daniel-lxs)
- Add new `apply_patch` native tool for more efficient file editing operations (PR #9663 by @hannesrudolph)
- Add new `search_and_replace` tool for batch text replacements across files (PR #9549 by @hannesrudolph)
- Add debug buttons to view API and UI history for troubleshooting (PR #9684 by @hannesrudolph)
- Include tool format in environment details for better context awareness (PR #9661 by @mrubens)
- Fix: Display install count in millions instead of thousands (PR #9677 by @app/roomote)
- Web-evals improvements: add task log viewing, export failed logs, and new run options (PR #9637 by @hannesrudolph)
- Web-evals updates: add kill run functionality (PR #9681 by @hannesrudolph)
- Fix: Prevent navigation buttons from wrapping on smaller screens (PR #9721 by @app/roomote)
## [3.34.8] - 2025-11-27
![3.34.8 Release - Race Condition Fix](/releases/3.34.8-release.png)
- Fix: Race condition in new_task tool for native protocol (PR #9655 by @daniel-lxs)
## [3.34.7] - 2025-11-27
![3.34.7 Release - More Native Tool Integrations](/releases/3.34.7-release.png)
- Support native tools in the Anthropic provider for improved tool calling (PR #9644 by @mrubens)
- Enable native tool calling for z.ai models (PR #9645 by @mrubens)
- Enable native tool calling for Moonshot models (PR #9646 by @mrubens)
- Fix: OpenRouter tool calls handling improvements (PR #9642 by @mrubens)
- Fix: OpenRouter GPT-5 strict schema validation for read_file tool (PR #9633 by @daniel-lxs)
- Fix: Create parent directories early in write_to_file to prevent ENOENT errors (#9634 by @ivanenev, PR #9640 by @daniel-lxs)
- Fix: Disable native tools and temperature support for claude-code provider (PR #9643 by @hannesrudolph)
- Add 'taking you to cloud' screen after provider welcome for improved onboarding (PR #9652 by @mrubens)
## [3.34.6] - 2025-11-26
![3.34.6 Release - Bedrock Embeddings](/releases/3.34.6-release.png)
- Add support for AWS Bedrock embeddings in code indexing (#8658 by @kyle-hobbs, PR #9475 by @ggoranov-smar)
- Add native tool calling support for Mistral provider (PR #9625 by @hannesrudolph)
- Wire MULTIPLE_NATIVE_TOOL_CALLS experiment to OpenAI parallel_tool_calls for parallel tool execution (PR #9621 by @hannesrudolph)
- Add fine grained tool streaming for OpenRouter Anthropic (PR #9629 by @mrubens)
- Allow global inference selection for Bedrock when cross-region is enabled (PR #9616 by @roomote)
- Fix: Filter non-Anthropic content blocks before sending to Vertex API (#9583 by @cardil, PR #9618 by @hannesrudolph)
- Fix: Restore content undefined check in WriteToFileTool.handlePartial() (#9611 by @Lissanro, PR #9614 by @daniel-lxs)
- Fix: Prevent model cache from persisting empty API responses (#9597 by @zx2021210538, PR #9623 by @daniel-lxs)
- Fix: Exclude access_mcp_resource tool when MCP has no resources (PR #9615 by @daniel-lxs)
- Fix: Update default settings for inline terminal and codebase indexing (PR #9622 by @roomote)
- Fix: Convert line_ranges strings to lineRanges objects in native tool calls (PR #9627 by @daniel-lxs)
- Fix: Defer new_task tool_result until subtask completes for native protocol (PR #9628 by @daniel-lxs)
## [3.34.5] - 2025-11-25
![3.34.5 Release - Experimental Parallel Tool Calling](/releases/3.34.5-release.png)
@ -111,7 +427,7 @@
## [3.33.0] - 2025-11-18
![v3.33.0 Release - Twin Kangaroos and the Gemini Constellation](/releases/v3.33.0-release.png)
![3.33.0 Release - Twin Kangaroos and the Gemini Constellation](/releases/3.33.0-release.png)
- Add Gemini 3 Pro Preview model (PR #9357 by @hannesrudolph)
- Improve Google Gemini defaults with better temperature and cost reporting (PR #9327 by @hannesrudolph)
@ -374,7 +690,7 @@
## [3.28.11] - 2025-09-29
- Fix: Correct AWS Bedrock Claude Sonnet 4.5 model identifier (#8371 by @sunhyung, PR by @app/roomote)
- Fix: Correct Amazon Bedrock Claude Sonnet 4.5 model identifier (#8371 by @sunhyung, PR by @app/roomote)
- Fix: Correct Claude Sonnet 4.5 model ID format (thanks @daniel-lxs!)
## [3.28.10] - 2025-09-29
@ -706,7 +1022,7 @@
## [3.25.14] - 2025-08-13
- Fix: Only include verbosity parameter for models that support it (#7054 by @eastonmeth, PR by @app/roomote)
- Fix: AWS Bedrock 1M context - Move anthropic_beta to additionalModelRequestFields (thanks @daniel-lxs!)
- Fix: Amazon Bedrock 1M context - Move anthropic_beta to additionalModelRequestFields (thanks @daniel-lxs!)
- Fix: Make cancelling requests more responsive by reverting recent changes
## [3.25.13] - 2025-08-12
@ -1071,7 +1387,7 @@
- Add user-configurable search score threshold slider for semantic search (thanks @hannesrudolph!)
- Add default headers and testing for litellm fetcher (thanks @andrewshu2000!)
- Fix consistent cancellation error messages for thinking vs streaming phases
- Fix AWS Bedrock cross-region inference profile mapping (thanks @KevinZhao!)
- Fix Amazon Bedrock cross-region inference profile mapping (thanks @KevinZhao!)
- Fix URL loading timeout issues in @ mentions (thanks @MuriloFP!)
- Fix API retry exponential backoff capped at 10 minutes (thanks @MuriloFP!)
- Fix Qdrant URL field auto-filling with default value (thanks @SannidhyaSah!)
@ -1085,7 +1401,7 @@
- Suppress Mermaid error rendering
- Improve Mermaid buttons with light background in light mode (thanks @chrarnoldus!)
- Add .vscode/ to write-protected files/directories
- Update AWS Bedrock cross-region inference profile mapping (thanks @KevinZhao!)
- Update Amazon Bedrock cross-region inference profile mapping (thanks @KevinZhao!)
## [3.22.5] - 2025-06-28
@ -1709,7 +2025,7 @@
- Improved display of diff errors + easy copying for investigation
- Fixes to .vscodeignore (thanks @franekp!)
- Fix a zh-CN translation for model capabilities (thanks @zhangtony239!)
- Rename AWS Bedrock to Amazon Bedrock (thanks @ronyblum!)
- Rename Amazon Bedrock to Amazon Bedrock (thanks @ronyblum!)
- Update extension title and description (thanks @StevenTCramer!)
## [3.11.12] - 2025-04-09
@ -1958,12 +2274,12 @@
- PowerShell-specific command handling (thanks @KJ7LNW!)
- OpenAI-compatible DeepSeek/QwQ reasoning support (thanks @lightrabbit!)
- Anthropic-style prompt caching in the OpenAI-compatible provider (thanks @dleen!)
- Add Deepseek R1 for AWS Bedrock (thanks @ATempsch!)
- Add Deepseek R1 for Amazon Bedrock (thanks @ATempsch!)
- Fix MarkdownBlock text color for Dark High Contrast theme (thanks @cannuri!)
- Add gemini-2.0-pro-exp-02-05 model to vertex (thanks @shohei-ihaya!)
- Bring back progress status for multi-diff edits (thanks @qdaxb!)
- Refactor alert dialog styles to use the correct vscode theme (thanks @cannuri!)
- Custom ARNs in AWS Bedrock (thanks @Smartsheet-JB-Brown!)
- Custom ARNs in Amazon Bedrock (thanks @Smartsheet-JB-Brown!)
- Update MCP servers directory path for platform compatibility (thanks @hannesrudolph!)
- Fix browser system prompt inclusion rules (thanks @cannuri!)
- Publish git tags to github from CI (thanks @pdecat!)
@ -2101,7 +2417,7 @@
## [3.7.1] - 2025-02-24
- Add AWS Bedrock support for Sonnet 3.7 and update some defaults to Sonnet 3.7 instead of 3.5
- Add Amazon Bedrock support for Sonnet 3.7 and update some defaults to Sonnet 3.7 instead of 3.5
## [3.7.0] - 2025-02-24
@ -2118,7 +2434,7 @@
## [3.3.24] - 2025-02-20
- Fixed a bug with region selection preventing AWS Bedrock profiles from being saved (thanks @oprstchn!)
- Fixed a bug with region selection preventing Amazon Bedrock profiles from being saved (thanks @oprstchn!)
- Updated the price of gpt-4o (thanks @marvijo-code!)
## [3.3.23] - 2025-02-20
@ -2302,7 +2618,7 @@
- Reverts provider key entry back to checking onInput instead of onChange to hopefully address issues entering API keys (thanks @samhvw8!)
- Added explicit checkbox to use Azure for OpenAI compatible providers (thanks @samhvw8!)
- Fixed Glama usage reporting (thanks @punkpeye!)
- Added Llama 3.3 70B Instruct model to the AWS Bedrock provider options (thanks @Premshay!)
- Added Llama 3.3 70B Instruct model to the Amazon Bedrock provider options (thanks @Premshay!)
## [3.2.7]

View file

@ -15,8 +15,6 @@ suite("Roo Code Extension", function () {
"SidebarProvider.removeView",
"activationCompleted",
"plusButtonClicked",
"mcpButtonClicked",
"promptsButtonClicked",
"popoutButtonClicked",
"openInNewTab",
"settingsButtonClicked",

View file

@ -1,628 +0,0 @@
import * as assert from "assert"
import * as fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
import { waitFor, sleep } from "../utils"
import { setDefaultSuiteTimeout } from "../test-utils"
suite.skip("Roo Code insert_content Tool", function () {
setDefaultSuiteTimeout(this)
let workspaceDir: string
// Pre-created test files that will be used across tests
const testFiles = {
simpleText: {
name: `test-insert-simple-${Date.now()}.txt`,
content: "Line 1\nLine 2\nLine 3",
path: "",
},
jsFile: {
name: `test-insert-js-${Date.now()}.js`,
content: `function hello() {
console.log("Hello World")
}
function goodbye() {
console.log("Goodbye World")
}`,
path: "",
},
emptyFile: {
name: `test-insert-empty-${Date.now()}.txt`,
content: "",
path: "",
},
pythonFile: {
name: `test-insert-python-${Date.now()}.py`,
content: `def main():
print("Start")
print("End")`,
path: "",
},
}
// Get the actual workspace directory that VSCode is using and create all test files
suiteSetup(async function () {
// Get the workspace folder from VSCode
const workspaceFolders = vscode.workspace.workspaceFolders
if (!workspaceFolders || workspaceFolders.length === 0) {
throw new Error("No workspace folder found")
}
workspaceDir = workspaceFolders[0]!.uri.fsPath
console.log("Using workspace directory:", workspaceDir)
// Create all test files before any tests run
console.log("Creating test files in workspace...")
for (const [key, file] of Object.entries(testFiles)) {
file.path = path.join(workspaceDir, file.name)
await fs.writeFile(file.path, file.content)
console.log(`Created ${key} test file at:`, file.path)
}
// Verify all files exist
for (const [key, file] of Object.entries(testFiles)) {
const exists = await fs
.access(file.path)
.then(() => true)
.catch(() => false)
if (!exists) {
throw new Error(`Failed to create ${key} test file at ${file.path}`)
}
}
})
// Clean up after all tests
suiteTeardown(async () => {
// Cancel any running tasks before cleanup
test("Should insert content at the beginning of a file (line 1)", async function () {
const api = globalThis.api
// Clean up before each test
setup(async () => {
// Cancel any previous task
try {
await globalThis.api.cancelCurrentTask()
} catch {
// Task might not be running
}
// Small delay to ensure clean state
await sleep(100)
})
// Clean up after each test
teardown(async () => {
// Cancel the current task
try {
await globalThis.api.cancelCurrentTask()
} catch {
// Task might not be running
}
// Small delay to ensure clean state
await sleep(100)
})
const messages: ClineMessage[] = []
const testFile = testFiles.simpleText
const insertContent = "New first line"
const expectedContent = `${insertContent}
${testFile.content}`
let taskStarted = false
let taskCompleted = false
let errorOccurred: string | null = null
let insertContentExecuted = false
// Listen for messages
const messageHandler = ({ message }: { message: ClineMessage }) => {
messages.push(message)
// Log important messages for debugging
if (message.type === "say" && message.say === "error") {
errorOccurred = message.text || "Unknown error"
console.error("Error:", message.text)
}
if (message.type === "ask" && message.ask === "tool") {
console.log("Tool request:", message.text?.substring(0, 200))
}
if (message.type === "say" && (message.say === "completion_result" || message.say === "text")) {
console.log("AI response:", message.text?.substring(0, 200))
}
// Check for tool execution
if (message.type === "say" && message.say === "api_req_started" && message.text) {
console.log("API request started:", message.text.substring(0, 200))
try {
const requestData = JSON.parse(message.text)
if (requestData.request && requestData.request.includes("insert_content")) {
insertContentExecuted = true
console.log("insert_content tool executed!")
}
} catch (e) {
console.log("Failed to parse api_req_started message:", e)
}
}
}
api.on(RooCodeEventName.Message, messageHandler)
// Listen for task events
const taskStartedHandler = (id: string) => {
if (id === taskId) {
taskStarted = true
console.log("Task started:", id)
}
}
api.on(RooCodeEventName.TaskStarted, taskStartedHandler)
const taskCompletedHandler = (id: string) => {
if (id === taskId) {
taskCompleted = true
console.log("Task completed:", id)
}
}
api.on(RooCodeEventName.TaskCompleted, taskCompletedHandler)
let taskId: string
try {
// Start the task
taskId = await api.startNewTask({
configuration: {
mode: "code",
autoApprovalEnabled: true,
alwaysAllowWrite: true,
alwaysAllowReadOnly: true,
alwaysAllowReadOnlyOutsideWorkspace: true,
},
text: `Use insert_content to add "${insertContent}" at line 1 (beginning) of the file ${testFile.name}. The file already exists with this content:
${testFile.content}
Assume the file exists and you can modify it directly.`,
})
console.log("Task ID:", taskId)
console.log("Test filename:", testFile.name)
// Wait for task to start
await waitFor(() => taskStarted, { timeout: 45_000 })
// Check for early errors
if (errorOccurred) {
console.error("Early error detected:", errorOccurred)
}
// Wait for task completion
await waitFor(() => taskCompleted, { timeout: 45_000 })
// Give extra time for file system operations
await sleep(2000)
// Check if the file was modified correctly
const actualContent = await fs.readFile(testFile.path, "utf-8")
console.log("File content after insertion:", actualContent)
// Verify tool was executed
assert.strictEqual(insertContentExecuted, true, "insert_content tool should have been executed")
// Verify file content
assert.strictEqual(
actualContent.trim(),
expectedContent.trim(),
"Content should be inserted at the beginning of the file",
)
// Verify no errors occurred
assert.strictEqual(
errorOccurred,
null,
`Task should complete without errors, but got: ${errorOccurred}`,
)
console.log("Test passed! insert_content tool executed and content inserted at beginning successfully")
} finally {
api.off(RooCodeEventName.Message, messageHandler)
api.off(RooCodeEventName.TaskStarted, taskStartedHandler)
api.off(RooCodeEventName.TaskCompleted, taskCompletedHandler)
}
})
try {
await globalThis.api.cancelCurrentTask()
} catch {
// Task might not be running
}
// Clean up all test files
console.log("Cleaning up test files...")
for (const [key, file] of Object.entries(testFiles)) {
try {
await fs.unlink(file.path)
console.log(`Cleaned up ${key} test file`)
} catch (error) {
console.log(`Failed to clean up ${key} test file:`, error)
}
}
})
test("Should insert content at the end of a file (line 0)", async function () {
const api = globalThis.api
const messages: ClineMessage[] = []
const testFile = testFiles.simpleText
const insertContent = "New last line"
const expectedContent = `${testFile.content}
${insertContent}`
let taskStarted = false
let taskCompleted = false
let errorOccurred: string | null = null
let insertContentExecuted = false
// Listen for messages
const messageHandler = ({ message }: { message: ClineMessage }) => {
messages.push(message)
// Log important messages for debugging
if (message.type === "say" && message.say === "error") {
errorOccurred = message.text || "Unknown error"
console.error("Error:", message.text)
}
if (message.type === "ask" && message.ask === "tool") {
console.log("Tool request:", message.text?.substring(0, 200))
}
if (message.type === "say" && (message.say === "completion_result" || message.say === "text")) {
console.log("AI response:", message.text?.substring(0, 200))
}
// Check for tool execution
if (message.type === "say" && message.say === "api_req_started" && message.text) {
console.log("API request started:", message.text.substring(0, 200))
try {
const requestData = JSON.parse(message.text)
if (requestData.request && requestData.request.includes("insert_content")) {
insertContentExecuted = true
console.log("insert_content tool executed!")
}
} catch (e) {
console.log("Failed to parse api_req_started message:", e)
}
}
}
api.on(RooCodeEventName.Message, messageHandler)
// Listen for task events
const taskStartedHandler = (id: string) => {
if (id === taskId) {
taskStarted = true
console.log("Task started:", id)
}
}
api.on(RooCodeEventName.TaskStarted, taskStartedHandler)
const taskCompletedHandler = (id: string) => {
if (id === taskId) {
taskCompleted = true
console.log("Task completed:", id)
}
}
api.on(RooCodeEventName.TaskCompleted, taskCompletedHandler)
let taskId: string
try {
// Start the task
taskId = await api.startNewTask({
configuration: {
mode: "code",
autoApprovalEnabled: true,
alwaysAllowWrite: true,
alwaysAllowReadOnly: true,
alwaysAllowReadOnlyOutsideWorkspace: true,
},
text: `Use insert_content to add "${insertContent}" at line 0 (end of file) of the file ${testFile.name}. The file already exists with this content:
${testFile.content}
Assume the file exists and you can modify it directly.`,
})
console.log("Task ID:", taskId)
console.log("Test filename:", testFile.name)
// Wait for task to start
await waitFor(() => taskStarted, { timeout: 45_000 })
// Check for early errors
if (errorOccurred) {
console.error("Early error detected:", errorOccurred)
}
// Wait for task completion
await waitFor(() => taskCompleted, { timeout: 45_000 })
// Give extra time for file system operations
await sleep(2000)
// Check if the file was modified correctly
const actualContent = await fs.readFile(testFile.path, "utf-8")
console.log("File content after insertion:", actualContent)
// Verify tool was executed
test("Should insert multiline content into a JavaScript file", async function () {
const api = globalThis.api
const messages: ClineMessage[] = []
const testFile = testFiles.jsFile
const insertContent = `// New import statements
import { utils } from './utils'
import { helpers } from './helpers'`
const expectedContent = `${insertContent}
${testFile.content}`
let taskStarted = false
let taskCompleted = false
let errorOccurred: string | null = null
let insertContentExecuted = false
// Listen for messages
const messageHandler = ({ message }: { message: ClineMessage }) => {
messages.push(message)
// Log important messages for debugging
if (message.type === "say" && message.say === "error") {
errorOccurred = message.text || "Unknown error"
console.error("Error:", message.text)
}
if (message.type === "ask" && message.ask === "tool") {
console.log("Tool request:", message.text?.substring(0, 200))
}
if (message.type === "say" && (message.say === "completion_result" || message.say === "text")) {
console.log("AI response:", message.text?.substring(0, 200))
}
// Check for tool execution
if (message.type === "say" && message.say === "api_req_started" && message.text) {
console.log("API request started:", message.text.substring(0, 200))
try {
const requestData = JSON.parse(message.text)
if (requestData.request && requestData.request.includes("insert_content")) {
insertContentExecuted = true
console.log("insert_content tool executed!")
}
} catch (e) {
console.log("Failed to parse api_req_started message:", e)
}
}
}
api.on(RooCodeEventName.Message, messageHandler)
// Listen for task events
const taskStartedHandler = (id: string) => {
if (id === taskId) {
taskStarted = true
console.log("Task started:", id)
}
}
api.on(RooCodeEventName.TaskStarted, taskStartedHandler)
const taskCompletedHandler = (id: string) => {
if (id === taskId) {
taskCompleted = true
console.log("Task completed:", id)
}
}
api.on(RooCodeEventName.TaskCompleted, taskCompletedHandler)
let taskId: string
try {
// Start the task
taskId = await api.startNewTask({
configuration: {
mode: "code",
autoApprovalEnabled: true,
alwaysAllowWrite: true,
alwaysAllowReadOnly: true,
alwaysAllowReadOnlyOutsideWorkspace: true,
},
text: `Use insert_content to add import statements at the beginning (line 1) of the JavaScript file ${testFile.name}. Add these lines:
${insertContent}
The file already exists with this content:
${testFile.content}
Assume the file exists and you can modify it directly.`,
})
console.log("Task ID:", taskId)
console.log("Test filename:", testFile.name)
// Wait for task to start
await waitFor(() => taskStarted, { timeout: 45_000 })
// Check for early errors
if (errorOccurred) {
console.error("Early error detected:", errorOccurred)
}
// Wait for task completion
await waitFor(() => taskCompleted, { timeout: 45_000 })
// Give extra time for file system operations
await sleep(2000)
test("Should insert content into an empty file", async function () {
const api = globalThis.api
const messages: ClineMessage[] = []
const testFile = testFiles.emptyFile
const insertContent = `# My New File
This is the first line of content
And this is the second line`
const expectedContent = insertContent
let taskStarted = false
let taskCompleted = false
let errorOccurred: string | null = null
let insertContentExecuted = false
// Listen for messages
const messageHandler = ({ message }: { message: ClineMessage }) => {
messages.push(message)
// Log important messages for debugging
if (message.type === "say" && message.say === "error") {
errorOccurred = message.text || "Unknown error"
console.error("Error:", message.text)
}
if (message.type === "ask" && message.ask === "tool") {
console.log("Tool request:", message.text?.substring(0, 200))
}
if (
message.type === "say" &&
(message.say === "completion_result" || message.say === "text")
) {
console.log("AI response:", message.text?.substring(0, 200))
}
// Check for tool execution
if (message.type === "say" && message.say === "api_req_started" && message.text) {
console.log("API request started:", message.text.substring(0, 200))
try {
const requestData = JSON.parse(message.text)
if (requestData.request && requestData.request.includes("insert_content")) {
insertContentExecuted = true
console.log("insert_content tool executed!")
}
} catch (e) {
console.log("Failed to parse api_req_started message:", e)
}
}
}
api.on(RooCodeEventName.Message, messageHandler)
// Listen for task events
const taskStartedHandler = (id: string) => {
if (id === taskId) {
taskStarted = true
console.log("Task started:", id)
}
}
api.on(RooCodeEventName.TaskStarted, taskStartedHandler)
const taskCompletedHandler = (id: string) => {
if (id === taskId) {
taskCompleted = true
console.log("Task completed:", id)
}
}
api.on(RooCodeEventName.TaskCompleted, taskCompletedHandler)
let taskId: string
try {
// Start the task
taskId = await api.startNewTask({
configuration: {
mode: "code",
autoApprovalEnabled: true,
alwaysAllowWrite: true,
alwaysAllowReadOnly: true,
alwaysAllowReadOnlyOutsideWorkspace: true,
},
text: `Use insert_content to add content to the empty file ${testFile.name}. Add this content at line 0 (end of file):
${insertContent}
The file is currently empty. Assume the file exists and you can modify it directly.`,
})
console.log("Task ID:", taskId)
console.log("Test filename:", testFile.name)
// Wait for task to start
await waitFor(() => taskStarted, { timeout: 45_000 })
// Check for early errors
if (errorOccurred) {
console.error("Early error detected:", errorOccurred)
}
// Wait for task completion
await waitFor(() => taskCompleted, { timeout: 45_000 })
// Give extra time for file system operations
await sleep(2000)
// Check if the file was modified correctly
const actualContent = await fs.readFile(testFile.path, "utf-8")
console.log("File content after insertion:", actualContent)
// Verify tool was executed
assert.strictEqual(
insertContentExecuted,
true,
"insert_content tool should have been executed",
)
// Verify file content
assert.strictEqual(
actualContent.trim(),
expectedContent.trim(),
"Content should be inserted into the empty file",
)
// Verify no errors occurred
assert.strictEqual(
errorOccurred,
null,
`Task should complete without errors, but got: ${errorOccurred}`,
)
console.log(
"Test passed! insert_content tool executed and content inserted into empty file successfully",
)
} finally {
api.off(RooCodeEventName.Message, messageHandler)
api.off(RooCodeEventName.TaskStarted, taskStartedHandler)
api.off(RooCodeEventName.TaskCompleted, taskCompletedHandler)
}
})
// Check if the file was modified correctly
const actualContent = await fs.readFile(testFile.path, "utf-8")
console.log("File content after insertion:", actualContent)
// Verify tool was executed
assert.strictEqual(insertContentExecuted, true, "insert_content tool should have been executed")
// Verify file content
assert.strictEqual(
actualContent.trim(),
expectedContent.trim(),
"Multiline content should be inserted at the beginning of the JavaScript file",
)
// Verify no errors occurred
assert.strictEqual(
errorOccurred,
null,
`Task should complete without errors, but got: ${errorOccurred}`,
)
console.log("Test passed! insert_content tool executed and multiline content inserted successfully")
} finally {
api.off(RooCodeEventName.Message, messageHandler)
api.off(RooCodeEventName.TaskStarted, taskStartedHandler)
api.off(RooCodeEventName.TaskCompleted, taskCompletedHandler)
}
})
assert.strictEqual(insertContentExecuted, true, "insert_content tool should have been executed")
// Verify file content
assert.strictEqual(
actualContent.trim(),
expectedContent.trim(),
"Content should be inserted at the end of the file",
)
// Verify no errors occurred
assert.strictEqual(errorOccurred, null, `Task should complete without errors, but got: ${errorOccurred}`)
console.log("Test passed! insert_content tool executed and content inserted at end successfully")
} finally {
api.off(RooCodeEventName.Message, messageHandler)
api.off(RooCodeEventName.TaskStarted, taskStartedHandler)
api.off(RooCodeEventName.TaskCompleted, taskCompletedHandler)
}
})
// Tests will be added here one by one
})

View file

@ -29,12 +29,13 @@
"@roo-code/evals": "workspace:^",
"@roo-code/types": "workspace:^",
"@tanstack/react-query": "^5.69.0",
"archiver": "^7.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.0",
"fuzzysort": "^3.1.0",
"lucide-react": "^0.518.0",
"next": "^15.2.5",
"next": "~15.2.8",
"next-themes": "^0.4.6",
"p-map": "^7.0.3",
"react": "^18.3.1",
@ -52,6 +53,7 @@
"@roo-code/config-eslint": "workspace:^",
"@roo-code/config-typescript": "workspace:^",
"@tailwindcss/postcss": "^4",
"@types/archiver": "^7.0.0",
"@types/ps-tree": "^1.1.6",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.5",

View file

@ -0,0 +1,207 @@
// npx vitest run src/actions/__tests__/killRun.spec.ts
import { execFileSync } from "child_process"
// Mock child_process
vi.mock("child_process", () => ({
execFileSync: vi.fn(),
spawn: vi.fn(),
}))
// Mock next/cache
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}))
// Mock redis client
vi.mock("@/lib/server/redis", () => ({
redisClient: vi.fn().mockResolvedValue({
del: vi.fn().mockResolvedValue(1),
}),
}))
// Mock @roo-code/evals
vi.mock("@roo-code/evals", () => ({
createRun: vi.fn(),
deleteRun: vi.fn(),
createTask: vi.fn(),
exerciseLanguages: [],
getExercisesForLanguage: vi.fn().mockResolvedValue([]),
}))
// Mock timers to speed up tests
vi.useFakeTimers()
// Import after mocks
import { killRun } from "../runs"
const mockExecFileSync = execFileSync as ReturnType<typeof vi.fn>
describe("killRun", () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllTimers()
})
it("should kill controller first, wait, then kill task containers", async () => {
const runId = 123
// execFileSync is used for all docker commands
mockExecFileSync
.mockReturnValueOnce("") // docker kill controller
.mockReturnValueOnce("evals-task-123-456.0\nevals-task-123-789.1\n") // docker ps
.mockReturnValueOnce("") // docker kill evals-task-123-456.0
.mockReturnValueOnce("") // docker kill evals-task-123-789.1
const resultPromise = killRun(runId)
// Fast-forward past the 10 second sleep
await vi.advanceTimersByTimeAsync(10000)
const result = await resultPromise
expect(result.success).toBe(true)
expect(result.killedContainers).toContain("evals-controller-123")
expect(result.killedContainers).toContain("evals-task-123-456.0")
expect(result.killedContainers).toContain("evals-task-123-789.1")
expect(result.errors).toHaveLength(0)
// Verify execFileSync was called for docker kill
expect(mockExecFileSync).toHaveBeenNthCalledWith(
1,
"docker",
["kill", "evals-controller-123"],
expect.any(Object),
)
// Verify execFileSync was called for docker ps with run-specific filter
expect(mockExecFileSync).toHaveBeenNthCalledWith(
2,
"docker",
["ps", "--format", "{{.Names}}", "--filter", "name=evals-task-123-"],
expect.any(Object),
)
})
it("should continue killing runners even if controller is not running", async () => {
const runId = 456
mockExecFileSync
.mockImplementationOnce(() => {
throw new Error("No such container")
}) // controller kill fails
.mockReturnValueOnce("evals-task-456-100.0\n") // docker ps
.mockReturnValueOnce("") // docker kill task
const resultPromise = killRun(runId)
await vi.advanceTimersByTimeAsync(10000)
const result = await resultPromise
expect(result.success).toBe(true)
expect(result.killedContainers).toContain("evals-task-456-100.0")
// Controller not in list since it failed
expect(result.killedContainers).not.toContain("evals-controller-456")
})
it("should clear Redis state after killing containers", async () => {
const runId = 789
const mockDel = vi.fn().mockResolvedValue(1)
const { redisClient } = await import("@/lib/server/redis")
vi.mocked(redisClient).mockResolvedValue({ del: mockDel } as never)
mockExecFileSync
.mockReturnValueOnce("") // controller kill
.mockReturnValueOnce("") // docker ps (no tasks)
const resultPromise = killRun(runId)
await vi.advanceTimersByTimeAsync(10000)
await resultPromise
expect(mockDel).toHaveBeenCalledWith("heartbeat:789")
expect(mockDel).toHaveBeenCalledWith("runners:789")
})
it("should handle docker ps failure gracefully", async () => {
const runId = 111
mockExecFileSync
.mockReturnValueOnce("") // controller kill succeeds
.mockImplementationOnce(() => {
throw new Error("Docker error")
}) // docker ps fails
const resultPromise = killRun(runId)
await vi.advanceTimersByTimeAsync(10000)
const result = await resultPromise
// Should still be successful because controller was killed
expect(result.success).toBe(true)
expect(result.killedContainers).toContain("evals-controller-111")
expect(result.errors).toContain("Failed to list Docker task containers")
})
it("should handle individual task kill failures", async () => {
const runId = 222
mockExecFileSync
.mockReturnValueOnce("") // controller kill
.mockReturnValueOnce("evals-task-222-300.0\nevals-task-222-400.0\n") // docker ps
.mockImplementationOnce(() => {
throw new Error("Kill failed")
}) // first task kill fails
.mockReturnValueOnce("") // second task kill succeeds
const resultPromise = killRun(runId)
await vi.advanceTimersByTimeAsync(10000)
const result = await resultPromise
expect(result.success).toBe(true)
expect(result.killedContainers).toContain("evals-controller-222")
expect(result.killedContainers).toContain("evals-task-222-400.0")
expect(result.errors.length).toBe(1)
expect(result.errors[0]).toContain("evals-task-222-300.0")
})
it("should return success with no containers when nothing is running", async () => {
const runId = 333
mockExecFileSync
.mockImplementationOnce(() => {
throw new Error("No such container")
}) // controller not running
.mockReturnValueOnce("") // no task containers
const resultPromise = killRun(runId)
await vi.advanceTimersByTimeAsync(10000)
const result = await resultPromise
expect(result.success).toBe(true)
expect(result.killedContainers).toHaveLength(0)
expect(result.errors).toHaveLength(0)
})
it("should only kill containers belonging to the specific run", async () => {
const runId = 555
mockExecFileSync
.mockReturnValueOnce("") // controller kill
.mockReturnValueOnce("evals-task-555-100.0\n") // docker ps
.mockReturnValueOnce("") // docker kill task
const resultPromise = killRun(runId)
await vi.advanceTimersByTimeAsync(10000)
const result = await resultPromise
expect(result.success).toBe(true)
// Verify execFileSync was called for docker ps with run-specific filter
expect(mockExecFileSync).toHaveBeenNthCalledWith(
2,
"docker",
["ps", "--format", "{{.Names}}", "--filter", "name=evals-task-555-"],
expect.any(Object),
)
})
})

View file

@ -3,7 +3,7 @@
import * as path from "path"
import fs from "fs"
import { fileURLToPath } from "url"
import { spawn } from "child_process"
import { spawn, execFileSync } from "child_process"
import { revalidatePath } from "next/cache"
import pMap from "p-map"
@ -13,15 +13,22 @@ import {
exerciseLanguages,
createRun as _createRun,
deleteRun as _deleteRun,
updateRun as _updateRun,
getIncompleteRuns as _getIncompleteRuns,
deleteRunsByIds as _deleteRunsByIds,
createTask,
getExercisesForLanguage,
} from "@roo-code/evals"
import { CreateRun } from "@/lib/schemas"
import { redisClient } from "@/lib/server/redis"
// Storage base path for eval logs
const EVALS_STORAGE_PATH = "/tmp/evals/runs"
const EVALS_REPO_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../../evals")
export async function createRun({ suite, exercises = [], timeout, ...values }: CreateRun) {
export async function createRun({ suite, exercises = [], timeout, iterations = 1, ...values }: CreateRun) {
const run = await _createRun({
...values,
timeout,
@ -36,15 +43,34 @@ export async function createRun({ suite, exercises = [], timeout, ...values }: C
throw new Error("Invalid exercise path: " + path)
}
await createTask({ ...values, runId: run.id, language: language as ExerciseLanguage, exercise })
// Create multiple tasks for each iteration
for (let iteration = 1; iteration <= iterations; iteration++) {
await createTask({
...values,
runId: run.id,
language: language as ExerciseLanguage,
exercise,
iteration,
})
}
}
} else {
for (const language of exerciseLanguages) {
const exercises = await getExercisesForLanguage(EVALS_REPO_PATH, language)
const languageExercises = await getExercisesForLanguage(EVALS_REPO_PATH, language)
await pMap(exercises, (exercise) => createTask({ runId: run.id, language, exercise }), {
concurrency: 10,
})
// Create tasks for all iterations of each exercise
const tasksToCreate: Array<{ language: ExerciseLanguage; exercise: string; iteration: number }> = []
for (const exercise of languageExercises) {
for (let iteration = 1; iteration <= iterations; iteration++) {
tasksToCreate.push({ language, exercise, iteration })
}
}
await pMap(
tasksToCreate,
({ language, exercise, iteration }) => createTask({ runId: run.id, language, exercise, iteration }),
{ concurrency: 10 },
)
}
}
@ -97,3 +123,247 @@ export async function deleteRun(runId: number) {
await _deleteRun(runId)
revalidatePath("/runs")
}
export type KillRunResult = {
success: boolean
killedContainers: string[]
errors: string[]
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
/**
* Kill all Docker containers associated with a run (controller and task runners).
* Kills the controller first, waits 10 seconds, then kills runners.
* Also clears Redis state for heartbeat and runners.
*
* Container naming conventions:
* - Controller: evals-controller-{runId}
* - Task runners: evals-task-{runId}-{taskId}.{attempt}
*/
export async function killRun(runId: number): Promise<KillRunResult> {
const killedContainers: string[] = []
const errors: string[] = []
const controllerPattern = `evals-controller-${runId}`
const taskPattern = `evals-task-${runId}-`
try {
// Step 1: Kill the controller first
console.log(`Killing controller: ${controllerPattern}`)
try {
execFileSync("docker", ["kill", controllerPattern], { encoding: "utf-8", timeout: 10000 })
killedContainers.push(controllerPattern)
console.log(`Killed controller container: ${controllerPattern}`)
} catch (_error) {
// Controller might not be running - that's ok, continue to kill runners
console.log(`Controller ${controllerPattern} not running or already stopped`)
}
// Step 2: Wait 10 seconds before killing runners
console.log("Waiting 10 seconds before killing runners...")
await sleep(10000)
// Step 3: Find and kill all task runner containers for THIS run only
let taskContainerNames: string[] = []
try {
const output = execFileSync("docker", ["ps", "--format", "{{.Names}}", "--filter", `name=${taskPattern}`], {
encoding: "utf-8",
timeout: 10000,
})
taskContainerNames = output
.split("\n")
.map((name) => name.trim())
.filter((name) => name.length > 0 && name.startsWith(taskPattern))
} catch (error) {
console.error("Failed to list task containers:", error)
errors.push("Failed to list Docker task containers")
}
// Kill each task runner container
for (const containerName of taskContainerNames) {
try {
execFileSync("docker", ["kill", containerName], { encoding: "utf-8", timeout: 10000 })
killedContainers.push(containerName)
console.log(`Killed task container: ${containerName}`)
} catch (error) {
// Container might have already stopped
console.error(`Failed to kill container ${containerName}:`, error)
errors.push(`Failed to kill container: ${containerName}`)
}
}
// Step 4: Clear Redis state
try {
const redis = await redisClient()
const heartbeatKey = `heartbeat:${runId}`
const runnersKey = `runners:${runId}`
await redis.del(heartbeatKey)
await redis.del(runnersKey)
console.log(`Cleared Redis keys: ${heartbeatKey}, ${runnersKey}`)
} catch (error) {
console.error("Failed to clear Redis state:", error)
errors.push("Failed to clear Redis state")
}
} catch (error) {
console.error("Error in killRun:", error)
errors.push("Unexpected error while killing containers")
}
revalidatePath(`/runs/${runId}`)
revalidatePath("/runs")
return {
success: killedContainers.length > 0 || errors.length === 0,
killedContainers,
errors,
}
}
export type DeleteIncompleteRunsResult = {
success: boolean
deletedCount: number
deletedRunIds: number[]
storageErrors: string[]
}
/**
* Delete all incomplete runs (runs without a taskMetricsId/final score).
* Removes both database records and storage folders.
*/
export async function deleteIncompleteRuns(): Promise<DeleteIncompleteRunsResult> {
const storageErrors: string[] = []
// Get all incomplete runs
const incompleteRuns = await _getIncompleteRuns()
const runIds = incompleteRuns.map((run) => run.id)
if (runIds.length === 0) {
return {
success: true,
deletedCount: 0,
deletedRunIds: [],
storageErrors: [],
}
}
// Delete storage folders for each run
for (const runId of runIds) {
const storagePath = path.join(EVALS_STORAGE_PATH, String(runId))
try {
if (fs.existsSync(storagePath)) {
fs.rmSync(storagePath, { recursive: true, force: true })
console.log(`Deleted storage folder: ${storagePath}`)
}
} catch (error) {
console.error(`Failed to delete storage folder ${storagePath}:`, error)
storageErrors.push(`Failed to delete storage for run ${runId}`)
}
// Also try to clear Redis state for any potentially running incomplete runs
try {
const redis = await redisClient()
await redis.del(`heartbeat:${runId}`)
await redis.del(`runners:${runId}`)
} catch (error) {
// Non-critical error, just log it
console.error(`Failed to clear Redis state for run ${runId}:`, error)
}
}
// Delete from database
await _deleteRunsByIds(runIds)
revalidatePath("/runs")
return {
success: true,
deletedCount: runIds.length,
deletedRunIds: runIds,
storageErrors,
}
}
/**
* Get count of incomplete runs (for UI display)
*/
export async function getIncompleteRunsCount(): Promise<number> {
const incompleteRuns = await _getIncompleteRuns()
return incompleteRuns.length
}
/**
* Delete all runs older than 30 days.
* Removes both database records and storage folders.
*/
export async function deleteOldRuns(): Promise<DeleteIncompleteRunsResult> {
const storageErrors: string[] = []
// Get all runs older than 30 days
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
const { getRuns } = await import("@roo-code/evals")
const allRuns = await getRuns()
const oldRuns = allRuns.filter((run) => run.createdAt < thirtyDaysAgo)
const runIds = oldRuns.map((run) => run.id)
if (runIds.length === 0) {
return {
success: true,
deletedCount: 0,
deletedRunIds: [],
storageErrors: [],
}
}
// Delete storage folders for each run
for (const runId of runIds) {
const storagePath = path.join(EVALS_STORAGE_PATH, String(runId))
try {
if (fs.existsSync(storagePath)) {
fs.rmSync(storagePath, { recursive: true, force: true })
console.log(`Deleted storage folder: ${storagePath}`)
}
} catch (error) {
console.error(`Failed to delete storage folder ${storagePath}:`, error)
storageErrors.push(`Failed to delete storage for run ${runId}`)
}
// Also try to clear Redis state
try {
const redis = await redisClient()
await redis.del(`heartbeat:${runId}`)
await redis.del(`runners:${runId}`)
} catch (error) {
// Non-critical error, just log it
console.error(`Failed to clear Redis state for run ${runId}:`, error)
}
}
// Delete from database
await _deleteRunsByIds(runIds)
revalidatePath("/runs")
return {
success: true,
deletedCount: runIds.length,
deletedRunIds: runIds,
storageErrors,
}
}
/**
* Update the description of a run.
*/
export async function updateRunDescription(runId: number, description: string | null): Promise<{ success: boolean }> {
try {
await _updateRun(runId, { description })
revalidatePath("/runs")
revalidatePath(`/runs/${runId}`)
return { success: true }
} catch (error) {
console.error("Failed to update run description:", error)
return { success: false }
}
}

View file

@ -0,0 +1,74 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import * as fs from "node:fs/promises"
import * as path from "node:path"
import { findTask, findRun } from "@roo-code/evals"
export const dynamic = "force-dynamic"
const LOG_BASE_PATH = "/tmp/evals/runs"
// Sanitize path components to prevent path traversal attacks
function sanitizePathComponent(component: string): string {
// Remove any path separators, null bytes, and other dangerous characters
return component.replace(/[/\\:\0*?"<>|]/g, "_")
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string; taskId: string }> }) {
const { id, taskId } = await params
try {
const runId = Number(id)
const taskIdNum = Number(taskId)
if (isNaN(runId) || isNaN(taskIdNum)) {
return NextResponse.json({ error: "Invalid run ID or task ID" }, { status: 400 })
}
// Verify the run exists
await findRun(runId)
// Get the task to find its language and exercise
const task = await findTask(taskIdNum)
// Verify the task belongs to this run
if (task.runId !== runId) {
return NextResponse.json({ error: "Task does not belong to this run" }, { status: 404 })
}
// Sanitize language and exercise to prevent path traversal
const safeLanguage = sanitizePathComponent(task.language)
const safeExercise = sanitizePathComponent(task.exercise)
// Construct the log file path
const logFileName = `${safeLanguage}-${safeExercise}.log`
const logFilePath = path.join(LOG_BASE_PATH, String(runId), logFileName)
// Verify the resolved path is within the expected directory (defense in depth)
const resolvedPath = path.resolve(logFilePath)
const expectedBase = path.resolve(LOG_BASE_PATH)
if (!resolvedPath.startsWith(expectedBase)) {
return NextResponse.json({ error: "Invalid log path" }, { status: 400 })
}
// Check if the log file exists and read it (async)
try {
const logContent = await fs.readFile(logFilePath, "utf-8")
return NextResponse.json({ logContent })
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return NextResponse.json({ error: "Log file not found", logContent: null }, { status: 200 })
}
throw err
}
} catch (error) {
console.error("Error reading task log:", error)
if (error instanceof Error && error.name === "RecordNotFoundError") {
return NextResponse.json({ error: "Task or run not found" }, { status: 404 })
}
return NextResponse.json({ error: "Failed to read log file" }, { status: 500 })
}
}

View file

@ -0,0 +1,147 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import * as fs from "node:fs"
import * as path from "node:path"
import archiver from "archiver"
import { findRun, getTasks } from "@roo-code/evals"
export const dynamic = "force-dynamic"
const LOG_BASE_PATH = "/tmp/evals/runs"
// Sanitize path components to prevent path traversal attacks
function sanitizePathComponent(component: string): string {
// Remove any path separators, null bytes, and other dangerous characters
return component.replace(/[/\\:\0*?"<>|]/g, "_")
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
try {
const runId = Number(id)
if (isNaN(runId)) {
return NextResponse.json({ error: "Invalid run ID" }, { status: 400 })
}
// Verify the run exists
await findRun(runId)
// Get all tasks for this run
const tasks = await getTasks(runId)
// Filter for failed tasks only
const failedTasks = tasks.filter((task) => task.passed === false)
if (failedTasks.length === 0) {
return NextResponse.json({ error: "No failed tasks to export" }, { status: 400 })
}
// Create a zip archive
const archive = archiver("zip", { zlib: { level: 9 } })
// Collect chunks to build the response
const chunks: Buffer[] = []
archive.on("data", (chunk: Buffer) => {
chunks.push(chunk)
})
// Track archive errors
let archiveError: Error | null = null
archive.on("error", (err: Error) => {
archiveError = err
})
// Set up the end promise before finalizing (proper event listener ordering)
const archiveEndPromise = new Promise<void>((resolve, reject) => {
archive.on("end", resolve)
archive.on("error", reject)
})
// Add each failed task's log file and history files to the archive
const logDir = path.join(LOG_BASE_PATH, String(runId))
let filesAdded = 0
for (const task of failedTasks) {
// Sanitize language and exercise to prevent path traversal
const safeLanguage = sanitizePathComponent(task.language)
const safeExercise = sanitizePathComponent(task.exercise)
const expectedBase = path.resolve(LOG_BASE_PATH)
// Add the log file
const logFileName = `${safeLanguage}-${safeExercise}.log`
const logFilePath = path.join(logDir, logFileName)
// Verify the resolved path is within the expected directory (defense in depth)
const resolvedLogPath = path.resolve(logFilePath)
if (resolvedLogPath.startsWith(expectedBase) && fs.existsSync(logFilePath)) {
archive.file(logFilePath, { name: logFileName })
filesAdded++
}
// Add the API conversation history file
// Format: {language}-{exercise}.{iteration}_api_conversation_history.json
const apiHistoryFileName = `${safeLanguage}-${safeExercise}.${task.iteration}_api_conversation_history.json`
const apiHistoryFilePath = path.join(logDir, apiHistoryFileName)
const resolvedApiHistoryPath = path.resolve(apiHistoryFilePath)
if (resolvedApiHistoryPath.startsWith(expectedBase) && fs.existsSync(apiHistoryFilePath)) {
archive.file(apiHistoryFilePath, { name: apiHistoryFileName })
filesAdded++
}
// Add the UI messages file
// Format: {language}-{exercise}.{iteration}_ui_messages.json
const uiMessagesFileName = `${safeLanguage}-${safeExercise}.${task.iteration}_ui_messages.json`
const uiMessagesFilePath = path.join(logDir, uiMessagesFileName)
const resolvedUiMessagesPath = path.resolve(uiMessagesFilePath)
if (resolvedUiMessagesPath.startsWith(expectedBase) && fs.existsSync(uiMessagesFilePath)) {
archive.file(uiMessagesFilePath, { name: uiMessagesFileName })
filesAdded++
}
}
// Check if any files were actually added
if (filesAdded === 0) {
archive.abort()
return NextResponse.json(
{ error: "No log files found - they may have been cleared from disk" },
{ status: 404 },
)
}
// Finalize the archive
await archive.finalize()
// Wait for all data to be collected
await archiveEndPromise
// Check for archive errors
if (archiveError) {
throw archiveError
}
// Combine all chunks into a single buffer
const zipBuffer = Buffer.concat(chunks)
// Return the zip file
return new NextResponse(zipBuffer, {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="run-${runId}-failed-logs.zip"`,
"Content-Length": String(zipBuffer.length),
},
})
} catch (error) {
console.error("Error exporting failed logs:", error)
if (error instanceof Error && error.name === "RecordNotFoundError") {
return NextResponse.json({ error: "Run not found" }, { status: 404 })
}
return NextResponse.json({ error: "Failed to export logs" }, { status: 500 })
}
}

View file

@ -7,7 +7,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
const run = await findRun(Number(id))
return (
<div className="max-w-3xl mx-auto px-12 p-12">
<div className="w-full px-6 py-12">
<Run run={run} />
</div>
)

View file

@ -1,55 +1,79 @@
"use client"
import { Link2, Link2Off, CheckCircle2 } from "lucide-react"
import type { RunStatus as _RunStatus } from "@/hooks/use-run-status"
import { cn } from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui"
export const RunStatus = ({ runStatus: { sseStatus, heartbeat, runners = [] } }: { runStatus: _RunStatus }) => (
<div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<div>Task Stream:</div>
<div className="font-mono text-sm text-muted-foreground">{sseStatus}</div>
</div>
<div className="relative">
<div
className={cn("absolute size-2.5 rounded-full opacity-50 animate-ping", {
"bg-green-500": sseStatus === "connected",
"bg-amber-500": sseStatus === "waiting",
"bg-rose-500": sseStatus === "error",
})}
/>
<div
className={cn("size-2.5 rounded-full", {
"bg-green-500": sseStatus === "connected",
"bg-amber-500": sseStatus === "waiting",
"bg-rose-500": sseStatus === "error",
})}
/>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<div>Task Controller:</div>
<div className="font-mono text-sm text-muted-foreground">{heartbeat ?? "dead"}</div>
</div>
<div className="relative">
<div
className={cn("absolute size-2.5 rounded-full opacity-50 animate-ping", {
"bg-green-500": !!heartbeat,
"bg-rose-500": !heartbeat,
})}
/>
<div
className={cn("size-2.5 rounded-full", {
"bg-green-500": !!heartbeat,
"bg-rose-500": !heartbeat,
})}
/>
</div>
</div>
<div className="flex items-center gap-2">
<div>Task Runners:</div>
{runners.length > 0 && <div className="font-mono text-sm text-muted-foreground">{runners?.join(", ")}</div>}
</div>
</div>
)
function StreamIcon({ status }: { status: "connected" | "waiting" | "error" }) {
if (status === "connected") {
return <Link2 className="size-4 text-green-500" />
}
return <Link2Off className={cn("size-4", status === "waiting" ? "text-amber-500" : "text-rose-500")} />
}
export const RunStatus = ({
runStatus: { sseStatus, heartbeat, runners = [] },
isComplete = false,
}: {
runStatus: _RunStatus
isComplete?: boolean
}) => {
// For completed runs, show a simple "Complete" badge
if (isComplete) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 cursor-default text-muted-foreground">
<CheckCircle2 className="size-4" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="font-mono text-xs">
Run complete
</TooltipContent>
</Tooltip>
)
}
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 cursor-default text-xs font-mono">
{/* Task Stream status icon */}
<StreamIcon status={sseStatus} />
{/* Task Controller ID */}
<span className={heartbeat ? "text-green-500" : "text-rose-500"}>{heartbeat ?? "-"}</span>
{/* Task Runners count */}
<span className={runners.length > 0 ? "text-green-500" : "text-rose-500"}>
{runners.length > 0 ? `${runners.length}r` : "0r"}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="font-mono text-xs max-w-md">
<div className="space-y-1">
<div className="flex items-center gap-2">
<StreamIcon status={sseStatus} />
<span>Task Stream: {sseStatus}</span>
</div>
<div className="flex items-center gap-2">
<span className={heartbeat ? "text-green-500" : "text-rose-500"}></span>
<span>Task Controller: {heartbeat ?? "dead"}</span>
</div>
<div className="flex items-center gap-2">
<span className={runners.length > 0 ? "text-green-500" : "text-rose-500"}></span>
<span>Task Runners: {runners.length > 0 ? runners.length : "none"}</span>
</div>
{runners.length > 0 && (
<div className="mt-2 pt-2 border-t border-border text-muted-foreground space-y-0.5">
{runners.map((runner) => (
<div key={runner}>{runner}</div>
))}
</div>
)}
</div>
</TooltipContent>
</Tooltip>
)
}

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@ import { useQuery } from "@tanstack/react-query"
import { useForm, FormProvider } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal } from "lucide-react"
import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal, Info, Plus, Minus } from "lucide-react"
import {
globalSettingsSchema,
@ -30,6 +30,9 @@ import {
TIMEOUT_MIN,
TIMEOUT_MAX,
TIMEOUT_DEFAULT,
ITERATIONS_MIN,
ITERATIONS_MAX,
ITERATIONS_DEFAULT,
} from "@/lib/schemas"
import { cn } from "@/lib/utils"
@ -61,7 +64,9 @@ import {
PopoverTrigger,
Slider,
Label,
FormDescription,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui"
import { SettingsDiff } from "./settings-diff"
@ -72,17 +77,38 @@ type ImportedSettings = {
currentApiConfigName: string
}
// Type for a model selection entry
type ModelSelection = {
id: string
model: string
popoverOpen: boolean
}
// Type for a config selection entry (for import mode)
type ConfigSelection = {
id: string
configName: string
popoverOpen: boolean
}
export function NewRun() {
const router = useRouter()
const [provider, setModelSource] = useState<"roo" | "openrouter" | "other">("roo")
const [modelPopoverOpen, setModelPopoverOpen] = useState(false)
const [provider, setModelSource] = useState<"roo" | "openrouter" | "other">("other")
const [useNativeToolProtocol, setUseNativeToolProtocol] = useState(true)
const [commandExecutionTimeout, setCommandExecutionTimeout] = useState(20)
const [terminalShellIntegrationTimeout, setTerminalShellIntegrationTimeout] = useState(30) // seconds
// State for imported settings with config selection
// State for multiple model selections
const [modelSelections, setModelSelections] = useState<ModelSelection[]>([
{ id: crypto.randomUUID(), model: "", popoverOpen: false },
])
// State for imported settings with multiple config selections
const [importedSettings, setImportedSettings] = useState<ImportedSettings | null>(null)
const [selectedConfigName, setSelectedConfigName] = useState<string>("")
const [configPopoverOpen, setConfigPopoverOpen] = useState(false)
const [configSelections, setConfigSelections] = useState<ConfigSelection[]>([
{ id: crypto.randomUUID(), configName: "", popoverOpen: false },
])
const openRouter = useOpenRouterModels()
const rooCodeCloud = useRooCodeCloudModels()
@ -106,6 +132,7 @@ export function NewRun() {
settings: undefined,
concurrency: CONCURRENCY_DEFAULT,
timeout: TIMEOUT_DEFAULT,
iterations: ITERATIONS_DEFAULT,
jobToken: "",
},
})
@ -117,9 +144,9 @@ export function NewRun() {
formState: { isSubmitting },
} = form
const [model, suite, settings] = watch(["model", "suite", "settings", "concurrency"])
const [suite, settings] = watch(["suite", "settings", "concurrency"])
// Load concurrency and timeout from localStorage on mount
// Load settings from localStorage on mount
useEffect(() => {
const savedConcurrency = localStorage.getItem("evals-concurrency")
if (savedConcurrency) {
@ -135,6 +162,37 @@ export function NewRun() {
setValue("timeout", parsed)
}
}
const savedCommandTimeout = localStorage.getItem("evals-command-execution-timeout")
if (savedCommandTimeout) {
const parsed = parseInt(savedCommandTimeout, 10)
if (!isNaN(parsed) && parsed >= 20 && parsed <= 60) {
setCommandExecutionTimeout(parsed)
}
}
const savedShellTimeout = localStorage.getItem("evals-shell-integration-timeout")
if (savedShellTimeout) {
const parsed = parseInt(savedShellTimeout, 10)
if (!isNaN(parsed) && parsed >= 30 && parsed <= 60) {
setTerminalShellIntegrationTimeout(parsed)
}
}
// Load saved exercises selection
const savedSuite = localStorage.getItem("evals-suite")
if (savedSuite === "partial") {
setValue("suite", "partial")
const savedExercises = localStorage.getItem("evals-exercises")
if (savedExercises) {
try {
const parsed = JSON.parse(savedExercises) as string[]
if (Array.isArray(parsed)) {
setSelectedExercises(parsed)
setValue("exercises", parsed)
}
} catch {
// Invalid JSON, ignore
}
}
}
}, [setValue])
// Extract unique languages from exercises
@ -178,6 +236,7 @@ export function NewRun() {
setSelectedExercises(newSelected)
setValue("exercises", newSelected)
localStorage.setItem("evals-exercises", JSON.stringify(newSelected))
},
[getExercisesForLanguage, selectedExercises, setValue],
)
@ -201,40 +260,165 @@ export function NewRun() {
[getExercisesForLanguage, selectedExercises],
)
// Add a new model selection
const addModelSelection = useCallback(() => {
setModelSelections((prev) => [...prev, { id: crypto.randomUUID(), model: "", popoverOpen: false }])
}, [])
// Remove a model selection
const removeModelSelection = useCallback((id: string) => {
setModelSelections((prev) => prev.filter((s) => s.id !== id))
}, [])
// Update a model selection
const updateModelSelection = useCallback(
(id: string, model: string) => {
setModelSelections((prev) => prev.map((s) => (s.id === id ? { ...s, model, popoverOpen: false } : s)))
// Also set the form model field for validation (use first non-empty model)
setValue("model", model)
},
[setValue],
)
// Toggle popover for a model selection
const toggleModelPopover = useCallback((id: string, open: boolean) => {
setModelSelections((prev) => prev.map((s) => (s.id === id ? { ...s, popoverOpen: open } : s)))
}, [])
// Add a new config selection
const addConfigSelection = useCallback(() => {
setConfigSelections((prev) => [...prev, { id: crypto.randomUUID(), configName: "", popoverOpen: false }])
}, [])
// Remove a config selection
const removeConfigSelection = useCallback((id: string) => {
setConfigSelections((prev) => prev.filter((s) => s.id !== id))
}, [])
// Update a config selection
const updateConfigSelection = useCallback(
(id: string, configName: string) => {
setConfigSelections((prev) => prev.map((s) => (s.id === id ? { ...s, configName, popoverOpen: false } : s)))
// Also update the form settings for the first config (for validation)
if (importedSettings) {
const providerSettings = importedSettings.apiConfigs[configName] ?? {}
setValue("model", getModelId(providerSettings) ?? "")
setValue("settings", { ...EVALS_SETTINGS, ...providerSettings, ...importedSettings.globalSettings })
}
},
[importedSettings, setValue],
)
// Toggle popover for a config selection
const toggleConfigPopover = useCallback((id: string, open: boolean) => {
setConfigSelections((prev) => prev.map((s) => (s.id === id ? { ...s, popoverOpen: open } : s)))
}, [])
const onSubmit = useCallback(
async (values: CreateRun) => {
try {
if (provider === "openrouter") {
values.settings = {
...(values.settings || {}),
apiProvider: "openrouter",
openRouterModelId: model,
toolProtocol: useNativeToolProtocol ? "native" : "xml",
// Validate jobToken for Roo Code Cloud provider
if (provider === "roo" && !values.jobToken?.trim()) {
toast.error("Roo Code Cloud Token is required")
return
}
// Determine which selections to use based on provider
const selectionsToLaunch: { model: string; configName?: string }[] = []
if (provider === "other") {
// For import mode, use config selections
for (const config of configSelections) {
if (config.configName) {
selectionsToLaunch.push({ model: "", configName: config.configName })
}
}
} else if (provider === "roo") {
values.settings = {
...(values.settings || {}),
apiProvider: "roo",
apiModelId: model,
toolProtocol: useNativeToolProtocol ? "native" : "xml",
} else {
// For openrouter/roo, use model selections
for (const selection of modelSelections) {
if (selection.model) {
selectionsToLaunch.push({ model: selection.model })
}
}
}
const { id } = await createRun(values)
router.push(`/runs/${id}`)
if (selectionsToLaunch.length === 0) {
toast.error("Please select at least one model or config")
return
}
// Show launching toast
const totalRuns = selectionsToLaunch.length
toast.info(totalRuns > 1 ? `Launching ${totalRuns} runs (every 20 seconds)...` : "Launching run...")
// Launch runs with 20-second delay between each
for (let i = 0; i < selectionsToLaunch.length; i++) {
const selection = selectionsToLaunch[i]!
// Wait 20 seconds between runs (except for the first one)
if (i > 0) {
await new Promise((resolve) => setTimeout(resolve, 20000))
}
const runValues = { ...values }
if (provider === "openrouter") {
runValues.model = selection.model
runValues.settings = {
...(runValues.settings || {}),
apiProvider: "openrouter",
openRouterModelId: selection.model,
toolProtocol: useNativeToolProtocol ? "native" : "xml",
commandExecutionTimeout,
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout * 1000,
}
} else if (provider === "roo") {
runValues.model = selection.model
runValues.settings = {
...(runValues.settings || {}),
apiProvider: "roo",
apiModelId: selection.model,
toolProtocol: useNativeToolProtocol ? "native" : "xml",
commandExecutionTimeout,
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout * 1000,
}
} else if (provider === "other" && selection.configName && importedSettings) {
const providerSettings = importedSettings.apiConfigs[selection.configName] ?? {}
runValues.model = getModelId(providerSettings) ?? ""
runValues.settings = {
...EVALS_SETTINGS,
...providerSettings,
...importedSettings.globalSettings,
toolProtocol: useNativeToolProtocol ? "native" : "xml",
commandExecutionTimeout,
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout * 1000,
}
}
try {
await createRun(runValues)
toast.success(`Run ${i + 1}/${totalRuns} launched`)
} catch (e) {
toast.error(`Run ${i + 1} failed: ${e instanceof Error ? e.message : "Unknown error"}`)
}
}
// Navigate back to main evals UI
router.push("/")
} catch (e) {
toast.error(e instanceof Error ? e.message : "An unknown error occurred.")
}
},
[provider, model, router, useNativeToolProtocol],
)
const onSelectModel = useCallback(
(model: string) => {
setValue("model", model)
setModelPopoverOpen(false)
},
[setValue, setModelPopoverOpen],
[
provider,
modelSelections,
configSelections,
importedSettings,
router,
useNativeToolProtocol,
commandExecutionTimeout,
terminalShellIntegrationTimeout,
],
)
const onImportSettings = useCallback(
@ -265,9 +449,9 @@ export function NewRun() {
currentApiConfigName: providerProfiles.currentApiConfigName,
})
// Default to the current config
// Default to the current config for the first selection
const defaultConfigName = providerProfiles.currentApiConfigName
setSelectedConfigName(defaultConfigName)
setConfigSelections([{ id: crypto.randomUUID(), configName: defaultConfigName, popoverOpen: false }])
// Apply the default config
const providerSettings = providerProfiles.apiConfigs[defaultConfigName] ?? {}
@ -283,22 +467,6 @@ export function NewRun() {
[clearErrors, setValue],
)
const onSelectConfig = useCallback(
(configName: string) => {
if (!importedSettings) {
return
}
setSelectedConfigName(configName)
setConfigPopoverOpen(false)
const providerSettings = importedSettings.apiConfigs[configName] ?? {}
setValue("model", getModelId(providerSettings) ?? "")
setValue("settings", { ...EVALS_SETTINGS, ...providerSettings, ...importedSettings.globalSettings })
},
[importedSettings, setValue],
)
return (
<>
<FormProvider {...form}>
@ -314,9 +482,9 @@ export function NewRun() {
value={provider}
onValueChange={(value) => setModelSource(value as "roo" | "openrouter" | "other")}>
<TabsList className="mb-2">
<TabsTrigger value="other">Import</TabsTrigger>
<TabsTrigger value="roo">Roo Code Cloud</TabsTrigger>
<TabsTrigger value="openrouter">OpenRouter</TabsTrigger>
<TabsTrigger value="other">Other</TabsTrigger>
</TabsList>
</Tabs>
@ -338,121 +506,217 @@ export function NewRun() {
onChange={onImportSettings}
/>
{importedSettings && Object.keys(importedSettings.apiConfigs).length > 1 && (
<div className="space-y-1">
<Label>API Config</Label>
<Popover open={configPopoverOpen} onOpenChange={setConfigPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="input"
role="combobox"
aria-expanded={configPopoverOpen}
className="flex items-center justify-between w-full">
<div>{selectedConfigName || "Select config"}</div>
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
<Command>
<CommandInput
placeholder="Search configs..."
className="h-9"
/>
<CommandList>
<CommandEmpty>No config found.</CommandEmpty>
<CommandGroup>
{Object.keys(importedSettings.apiConfigs).map(
(configName) => (
<CommandItem
key={configName}
value={configName}
onSelect={onSelectConfig}>
{configName}
{configName ===
importedSettings.currentApiConfigName && (
<span className="ml-2 text-xs text-muted-foreground">
(default)
</span>
)}
<Check
className={cn(
"ml-auto size-4",
configName ===
selectedConfigName
? "opacity-100"
: "opacity-0",
{importedSettings && Object.keys(importedSettings.apiConfigs).length > 0 && (
<div className="space-y-2">
<Label>API Configs</Label>
{configSelections.map((selection, index) => (
<div key={selection.id} className="flex items-center gap-2">
<Popover
open={selection.popoverOpen}
onOpenChange={(open) =>
toggleConfigPopover(selection.id, open)
}>
<PopoverTrigger asChild>
<Button
variant="input"
role="combobox"
aria-expanded={selection.popoverOpen}
className="flex items-center justify-between flex-1">
<div>{selection.configName || "Select config"}</div>
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
<Command>
<CommandInput
placeholder="Search configs..."
className="h-9"
/>
<CommandList>
<CommandEmpty>No config found.</CommandEmpty>
<CommandGroup>
{Object.keys(
importedSettings.apiConfigs,
).map((configName) => (
<CommandItem
key={configName}
value={configName}
onSelect={() =>
updateConfigSelection(
selection.id,
configName,
)
}>
{configName}
{configName ===
importedSettings.currentApiConfigName && (
<span className="ml-2 text-xs text-muted-foreground">
(default)
</span>
)}
/>
</CommandItem>
),
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Check
className={cn(
"ml-auto size-4",
configName ===
selection.configName
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{index === configSelections.length - 1 ? (
<Button
type="button"
variant="outline"
size="icon"
onClick={addConfigSelection}
className="shrink-0">
<Plus className="size-4" />
</Button>
) : (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removeConfigSelection(selection.id)}
className="shrink-0">
<Minus className="size-4" />
</Button>
)}
</div>
))}
</div>
)}
<div className="mt-4 p-4 rounded-md bg-muted/30 border border-border space-y-3">
<Label className="text-sm font-medium text-muted-foreground">
Tool Protocol Options
</Label>
<div className="flex flex-col gap-2.5 pl-1">
<label
htmlFor="native-other"
className="flex items-center gap-2 cursor-pointer">
<Checkbox
id="native-other"
checked={useNativeToolProtocol}
onCheckedChange={(checked: boolean) =>
setUseNativeToolProtocol(checked)
}
/>
<span className="text-sm">Use Native Tool Calls</span>
</label>
</div>
</div>
{settings && (
<SettingsDiff defaultSettings={EVALS_SETTINGS} customSettings={settings} />
)}
</div>
) : (
<>
<Popover open={modelPopoverOpen} onOpenChange={setModelPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="input"
role="combobox"
aria-expanded={modelPopoverOpen}
className="flex items-center justify-between">
<div>
{models?.find(({ id }) => id === model)?.name || `Select`}
</div>
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
<Command filter={onFilter}>
<CommandInput
placeholder="Search"
value={searchValue}
onValueChange={setSearchValue}
className="h-9"
/>
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{models?.map(({ id, name }) => (
<CommandItem
key={id}
value={id}
onSelect={onSelectModel}>
{name}
<Check
className={cn(
"ml-auto text-accent group-data-[selected=true]:text-accent-foreground size-4",
id === model ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="space-y-2">
{modelSelections.map((selection, index) => (
<div key={selection.id} className="flex items-center gap-2">
<Popover
open={selection.popoverOpen}
onOpenChange={(open) => toggleModelPopover(selection.id, open)}>
<PopoverTrigger asChild>
<Button
variant="input"
role="combobox"
aria-expanded={selection.popoverOpen}
className="flex items-center justify-between flex-1">
<div>
{models?.find(({ id }) => id === selection.model)
?.name || `Select`}
</div>
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
<Command filter={onFilter}>
<CommandInput
placeholder="Search"
value={searchValue}
onValueChange={setSearchValue}
className="h-9"
/>
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{models?.map(({ id, name }) => (
<CommandItem
key={id}
value={id}
onSelect={() =>
updateModelSelection(
selection.id,
id,
)
}>
{name}
<Check
className={cn(
"ml-auto text-accent group-data-[selected=true]:text-accent-foreground size-4",
id === selection.model
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{index === modelSelections.length - 1 ? (
<Button
type="button"
variant="outline"
size="icon"
onClick={addModelSelection}
className="shrink-0">
<Plus className="size-4" />
</Button>
) : (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removeModelSelection(selection.id)}
className="shrink-0">
<Minus className="size-4" />
</Button>
)}
</div>
))}
</div>
<div className="flex items-center gap-1.5">
<Checkbox
id="native"
checked={useNativeToolProtocol}
onCheckedChange={(checked) =>
setUseNativeToolProtocol(checked === true)
}
/>
<Label htmlFor="native">Use Native Tool Calls</Label>
<div className="mt-4 p-4 rounded-md bg-muted/30 border border-border space-y-3">
<Label className="text-sm font-medium text-muted-foreground">
Tool Protocol Options
</Label>
<div className="flex flex-col gap-2.5 pl-1">
<label
htmlFor="native"
className="flex items-center gap-2 cursor-pointer">
<Checkbox
id="native"
checked={useNativeToolProtocol}
onCheckedChange={(checked: boolean) =>
setUseNativeToolProtocol(checked)
}
/>
<span className="text-sm">Use Native Tool Calls</span>
</label>
</div>
</div>
</>
)}
@ -468,20 +732,28 @@ export function NewRun() {
name="jobToken"
render={({ field }) => (
<FormItem>
<FormLabel>Roo Code Cloud Token</FormLabel>
<div className="flex items-center gap-1">
<FormLabel>Roo Code Cloud Token</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p>
If you have access to the Roo Code Cloud repository and the
decryption key for the .env.* files, generate a token with:
</p>
<code className="text-xs block mt-1">
pnpm --filter @roo-code-cloud/auth production:create-auth-token
[email] [org] [ttl]
</code>
</TooltipContent>
</Tooltip>
</div>
<FormControl>
<Input type="password" {...field} />
<Input type="password" placeholder="Required" {...field} />
</FormControl>
<FormMessage />
<FormDescription>
If you have access to the Roo Code Cloud repository then you can generate a
token with:
<br />
<code className="text-xs">
pnpm --filter @roo-code-cloud/auth production:create-job-token [org]
[timeout]
</code>
</FormDescription>
</FormItem>
)}
/>
@ -495,12 +767,14 @@ export function NewRun() {
<FormLabel>Exercises</FormLabel>
<div className="flex items-center gap-2 flex-wrap">
<Tabs
defaultValue="full"
value={suite}
onValueChange={(value) => {
setValue("suite", value as "full" | "partial")
localStorage.setItem("evals-suite", value)
if (value === "full") {
setSelectedExercises([])
setValue("exercises", [])
localStorage.removeItem("evals-exercises")
}
}}>
<TabsList>
@ -537,6 +811,7 @@ export function NewRun() {
onValueChange={(value) => {
setSelectedExercises(value)
setValue("exercises", value)
localStorage.setItem("evals-exercises", JSON.stringify(value))
}}
placeholder="Select"
variant="inverted"
@ -548,57 +823,153 @@ export function NewRun() {
)}
/>
<FormField
control={form.control}
name="concurrency"
render={({ field }) => (
<FormItem>
<FormLabel>Concurrency</FormLabel>
<FormControl>
<div className="flex flex-row items-center gap-2">
<Slider
value={[field.value]}
min={CONCURRENCY_MIN}
max={CONCURRENCY_MAX}
step={1}
onValueChange={(value) => {
field.onChange(value[0])
localStorage.setItem("evals-concurrency", String(value[0]))
}}
/>
<div>{field.value}</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Concurrency, Timeout, and Iterations in a 3-column row */}
<div className="grid grid-cols-3 gap-4 py-5">
<FormField
control={form.control}
name="concurrency"
render={({ field }) => (
<FormItem>
<FormLabel>Concurrency</FormLabel>
<FormControl>
<div className="flex flex-row items-center gap-2">
<Slider
value={[field.value]}
min={CONCURRENCY_MIN}
max={CONCURRENCY_MAX}
step={1}
onValueChange={(value) => {
field.onChange(value[0])
localStorage.setItem("evals-concurrency", String(value[0]))
}}
/>
<div className="w-6 text-right">{field.value}</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeout"
render={({ field }) => (
<FormItem>
<FormLabel>Timeout (Minutes)</FormLabel>
<FormControl>
<div className="flex flex-row items-center gap-2">
<Slider
value={[field.value]}
min={TIMEOUT_MIN}
max={TIMEOUT_MAX}
step={1}
onValueChange={(value) => {
field.onChange(value[0])
localStorage.setItem("evals-timeout", String(value[0]))
}}
/>
<div>{field.value}</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeout"
render={({ field }) => (
<FormItem>
<FormLabel>Timeout (Minutes)</FormLabel>
<FormControl>
<div className="flex flex-row items-center gap-2">
<Slider
value={[field.value]}
min={TIMEOUT_MIN}
max={TIMEOUT_MAX}
step={1}
onValueChange={(value) => {
field.onChange(value[0])
localStorage.setItem("evals-timeout", String(value[0]))
}}
/>
<div className="w-6 text-right">{field.value}</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="iterations"
render={({ field }) => (
<FormItem>
<FormLabel>Iterations</FormLabel>
<FormControl>
<div className="flex flex-row items-center gap-2">
<Slider
value={[field.value]}
min={ITERATIONS_MIN}
max={ITERATIONS_MAX}
step={1}
onValueChange={(value) => {
field.onChange(value[0])
}}
/>
<div className="w-6 text-right">{field.value}</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Terminal timeouts in a 2-column row */}
<div className="grid grid-cols-2 gap-4 py-5">
<FormItem>
<div className="flex items-center gap-1">
<Label>Command Timeout (Seconds)</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p>
Maximum time in seconds to wait for terminal command execution to complete
before timing out. This applies to commands run via the execute_command
tool.
</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-row items-center gap-2">
<Slider
value={[commandExecutionTimeout]}
min={20}
max={60}
step={1}
onValueChange={([value]) => {
if (value !== undefined) {
setCommandExecutionTimeout(value)
localStorage.setItem("evals-command-execution-timeout", String(value))
}
}}
/>
<div className="w-8 text-right">{commandExecutionTimeout}</div>
</div>
</FormItem>
<FormItem>
<div className="flex items-center gap-1">
<Label>Shell Integration Timeout (Seconds)</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p>
Maximum time in seconds to wait for shell integration to initialize when
opening a new terminal.
</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-row items-center gap-2">
<Slider
value={[terminalShellIntegrationTimeout]}
min={30}
max={60}
step={1}
onValueChange={([value]) => {
if (value !== undefined) {
setTerminalShellIntegrationTimeout(value)
localStorage.setItem("evals-shell-integration-timeout", String(value))
}
}}
/>
<div className="w-8 text-right">{terminalShellIntegrationTimeout}</div>
</div>
</FormItem>
</div>
<FormField
control={form.control}

View file

@ -2,7 +2,9 @@ import { type Keys, type RooCodeSettings, GLOBAL_SETTINGS_KEYS, PROVIDER_SETTING
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"
export const ROO_CODE_SETTINGS_KEYS = [...GLOBAL_SETTINGS_KEYS, ...PROVIDER_SETTINGS_KEYS] as Keys<RooCodeSettings>[]
export const ROO_CODE_SETTINGS_KEYS = [
...new Set([...GLOBAL_SETTINGS_KEYS, ...PROVIDER_SETTINGS_KEYS]),
] as Keys<RooCodeSettings>[]
type SettingsDiffProps = {
defaultSettings: RooCodeSettings

View file

@ -1,12 +1,13 @@
import { useCallback, useState, useRef } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Ellipsis, ClipboardList, Copy, Check, LoaderCircle, Trash, Settings } from "lucide-react"
import { toast } from "sonner"
import { Ellipsis, ClipboardList, Copy, Check, LoaderCircle, Trash, Settings, FileDown, StickyNote } from "lucide-react"
import type { Run as EvalsRun, TaskMetrics as EvalsTaskMetrics } from "@roo-code/evals"
import type { ToolName } from "@roo-code/types"
import { deleteRun } from "@/actions/runs"
import { deleteRun, updateRunDescription } from "@/actions/runs"
import {
formatCurrency,
formatDateTime,
@ -19,6 +20,10 @@ import {
Button,
TableCell,
TableRow,
Textarea,
Tooltip,
TooltipContent,
TooltipTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@ -33,24 +38,95 @@ import {
AlertDialogTitle,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
ScrollArea,
} from "@/components/ui"
// Tool group type (same as in runs.tsx)
type ToolGroup = {
id: string
name: string
icon: string
tools: string[]
}
type RunProps = {
run: EvalsRun
taskMetrics: EvalsTaskMetrics | null
toolColumns: ToolName[]
toolGroups: ToolGroup[]
}
export function Run({ run, taskMetrics, toolColumns }: RunProps) {
export function Run({ run, taskMetrics, toolColumns, toolGroups }: RunProps) {
const router = useRouter()
const [deleteRunId, setDeleteRunId] = useState<number>()
const [showSettings, setShowSettings] = useState(false)
const [isExportingLogs, setIsExportingLogs] = useState(false)
const [showNotesDialog, setShowNotesDialog] = useState(false)
const [editingDescription, setEditingDescription] = useState(run.description ?? "")
const [isSavingNotes, setIsSavingNotes] = useState(false)
const continueRef = useRef<HTMLButtonElement>(null)
const { isPending, copyRun, copied } = useCopyRun(run.id)
const hasDescription = Boolean(run.description && run.description.trim().length > 0)
const handleSaveDescription = useCallback(async () => {
setIsSavingNotes(true)
try {
const result = await updateRunDescription(run.id, editingDescription.trim() || null)
if (result.success) {
toast.success("Description saved")
setShowNotesDialog(false)
router.refresh()
} else {
toast.error("Failed to save description")
}
} catch (error) {
console.error("Error saving description:", error)
toast.error("Failed to save description")
} finally {
setIsSavingNotes(false)
}
}, [run.id, editingDescription, router])
const onExportFailedLogs = useCallback(async () => {
if (run.failed === 0) {
toast.error("No failed tasks to export")
return
}
setIsExportingLogs(true)
try {
const response = await fetch(`/api/runs/${run.id}/logs/failed`)
if (!response.ok) {
const error = await response.json()
toast.error(error.error || "Failed to export logs")
return
}
// Download the zip file
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `run-${run.id}-failed-logs.zip`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
toast.success("Failed logs exported successfully")
} catch (error) {
console.error("Error exporting logs:", error)
toast.error("Failed to export logs")
} finally {
setIsExportingLogs(false)
}
}, [run.id, run.failed])
const onConfirmDelete = useCallback(async () => {
if (!deleteRunId) {
return
@ -75,6 +151,62 @@ export function Run({ run, taskMetrics, toolColumns }: RunProps) {
[router, run.id],
)
// Helper to render a tool group cell
const renderToolGroupCell = (group: ToolGroup) => {
if (!taskMetrics?.toolUsage) {
return <span className="text-muted-foreground">-</span>
}
let totalAttempts = 0
let totalFailures = 0
const breakdown: Array<{ tool: string; attempts: number; rate: string }> = []
for (const toolName of group.tools) {
const usage = taskMetrics.toolUsage[toolName as ToolName]
if (usage) {
totalAttempts += usage.attempts
totalFailures += usage.failures
const rate =
usage.attempts > 0
? `${Math.round(((usage.attempts - usage.failures) / usage.attempts) * 100)}%`
: "0%"
breakdown.push({ tool: toolName, attempts: usage.attempts, rate })
}
}
if (totalAttempts === 0) {
return <span className="text-muted-foreground">-</span>
}
const successRate = ((totalAttempts - totalFailures) / totalAttempts) * 100
const rateColor =
successRate === 100 ? "text-muted-foreground" : successRate >= 80 ? "text-yellow-500" : "text-red-500"
return (
<Tooltip>
<TooltipTrigger>
<div className="flex flex-col items-center">
<span className="font-medium">{totalAttempts}</span>
<span className={rateColor}>{Math.round(successRate)}%</span>
</div>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs">
<div className="font-semibold mb-1">{group.name}</div>
{breakdown.map(({ tool, attempts, rate }) => (
<div key={tool} className="flex justify-between gap-4">
<span>{tool}:</span>
<span>
{attempts} ({rate})
</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
)
}
return (
<>
<TableRow className="cursor-pointer hover:bg-muted/50" onClick={handleRowClick}>
@ -86,9 +218,13 @@ export function Run({ run, taskMetrics, toolColumns }: RunProps) {
<TableCell>{run.passed}</TableCell>
<TableCell>{run.failed}</TableCell>
<TableCell>
{run.passed + run.failed > 0 && (
<span>{((run.passed / (run.passed + run.failed)) * 100).toFixed(1)}%</span>
)}
{run.passed + run.failed > 0 &&
(() => {
const percent = (run.passed / (run.passed + run.failed)) * 100
const colorClass =
percent === 100 ? "text-green-500" : percent >= 80 ? "text-yellow-500" : "text-red-500"
return <span className={colorClass}>{percent.toFixed(1)}%</span>
})()}
</TableCell>
<TableCell>
{taskMetrics && (
@ -98,14 +234,28 @@ export function Run({ run, taskMetrics, toolColumns }: RunProps) {
</div>
)}
</TableCell>
{/* Tool Group Columns */}
{toolGroups.map((group) => (
<TableCell key={group.id} className="text-xs text-center">
{renderToolGroupCell(group)}
</TableCell>
))}
{toolColumns.map((toolName) => {
const usage = taskMetrics?.toolUsage?.[toolName]
const successRate =
usage && usage.attempts > 0 ? ((usage.attempts - usage.failures) / usage.attempts) * 100 : 100
const rateColor =
successRate === 100
? "text-muted-foreground"
: successRate >= 80
? "text-yellow-500"
: "text-red-500"
return (
<TableCell key={toolName} className="text-xs text-center">
{usage ? (
<div className="flex flex-col items-center">
<span className="font-medium">{usage.attempts}</span>
<span className="text-muted-foreground">{formatToolUsageSuccessRate(usage)}</span>
<span className={rateColor}>{formatToolUsageSuccessRate(usage)}</span>
</div>
) : (
<span className="text-muted-foreground">-</span>
@ -116,63 +266,107 @@ export function Run({ run, taskMetrics, toolColumns }: RunProps) {
<TableCell>{taskMetrics && formatCurrency(taskMetrics.cost)}</TableCell>
<TableCell>{taskMetrics && formatDuration(taskMetrics.duration)}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<Button variant="ghost" size="icon" asChild>
<DropdownMenuTrigger data-dropdown-trigger>
<Ellipsis />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/runs/${run.id}`}>
<div className="flex items-center gap-1">
{/* Note Icon */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={hasDescription ? "" : "opacity-30 hover:opacity-60"}
onClick={(e) => {
e.stopPropagation()
setEditingDescription(run.description ?? "")
setShowNotesDialog(true)
}}>
<StickyNote className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
{hasDescription ? (
<div className="whitespace-pre-wrap">{run.description}</div>
) : (
<div className="text-muted-foreground">No description. Click to add one.</div>
)}
</TooltipContent>
</Tooltip>
{/* More Actions Menu */}
<DropdownMenu>
<Button variant="ghost" size="icon" asChild>
<DropdownMenuTrigger data-dropdown-trigger>
<Ellipsis />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/runs/${run.id}`}>
<div className="flex items-center gap-1">
<ClipboardList />
<div>View Tasks</div>
</div>
</Link>
</DropdownMenuItem>
{run.settings && (
<DropdownMenuItem onClick={() => setShowSettings(true)}>
<div className="flex items-center gap-1">
<Settings />
<div>View Settings</div>
</div>
</DropdownMenuItem>
)}
{run.taskMetricsId && (
<DropdownMenuItem onClick={() => copyRun()} disabled={isPending || copied}>
<div className="flex items-center gap-1">
{isPending ? (
<>
<LoaderCircle className="animate-spin" />
Copying...
</>
) : copied ? (
<>
<Check />
Copied!
</>
) : (
<>
<Copy />
Copy to Production
</>
)}
</div>
</DropdownMenuItem>
)}
{run.failed > 0 && (
<DropdownMenuItem onClick={onExportFailedLogs} disabled={isExportingLogs}>
<div className="flex items-center gap-1">
{isExportingLogs ? (
<>
<LoaderCircle className="animate-spin" />
Exporting...
</>
) : (
<>
<FileDown />
Export Failed Logs
</>
)}
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setDeleteRunId(run.id)
setTimeout(() => continueRef.current?.focus(), 0)
}}>
<div className="flex items-center gap-1">
<ClipboardList />
<div>View Tasks</div>
</div>
</Link>
</DropdownMenuItem>
{run.settings && (
<DropdownMenuItem onClick={() => setShowSettings(true)}>
<div className="flex items-center gap-1">
<Settings />
<div>View Settings</div>
<Trash />
<div>Delete</div>
</div>
</DropdownMenuItem>
)}
{run.taskMetricsId && (
<DropdownMenuItem onClick={() => copyRun()} disabled={isPending || copied}>
<div className="flex items-center gap-1">
{isPending ? (
<>
<LoaderCircle className="animate-spin" />
Copying...
</>
) : copied ? (
<>
<Check />
Copied!
</>
) : (
<>
<Copy />
Copy to Production
</>
)}
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setDeleteRunId(run.id)
setTimeout(() => continueRef.current?.focus(), 0)
}}>
<div className="flex items-center gap-1">
<Trash />
<div>Delete</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
<AlertDialog open={!!deleteRunId} onOpenChange={() => setDeleteRunId(undefined)}>
@ -201,6 +395,39 @@ export function Run({ run, taskMetrics, toolColumns }: RunProps) {
</ScrollArea>
</DialogContent>
</Dialog>
{/* Notes/Description Dialog */}
<Dialog open={showNotesDialog} onOpenChange={setShowNotesDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Run Description</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Textarea
placeholder="Add a description or notes for this run..."
value={editingDescription}
onChange={(e) => setEditingDescription(e.target.value)}
rows={4}
className="resize-none"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNotesDialog(false)}>
Cancel
</Button>
<Button onClick={handleSaveDescription} disabled={isSavingNotes}>
{isSavingNotes ? (
<>
<LoaderCircle className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -1,14 +1,82 @@
"use client"
import { useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState, memo } from "react"
import { useRouter } from "next/navigation"
import { ArrowDown, ArrowUp, ArrowUpDown, Rocket } from "lucide-react"
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Box,
Boxes,
Check,
CheckCircle,
CircleDot,
ClipboardList,
Cog,
Combine,
Ellipsis,
File,
FileText,
Folder,
FolderOpen,
Hammer,
Hexagon,
Layers,
List,
ListChecks,
ListTodo,
LoaderCircle,
Package,
Pencil,
PencilLine,
Plus,
Rocket,
Search,
Settings2,
Shapes,
Square,
Star,
Tag,
Terminal,
Trash2,
Wrench,
X,
Zap,
} from "lucide-react"
import type { LucideIcon } from "lucide-react"
import { toast } from "sonner"
import type { Run, TaskMetrics } from "@roo-code/evals"
import type { ToolName } from "@roo-code/types"
import { deleteIncompleteRuns, deleteOldRuns } from "@/actions/runs"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
MultiSelect,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
@ -21,11 +89,206 @@ import {
} from "@/components/ui"
import { Run as Row } from "@/components/home/run"
// Available icons for tool groups
const TOOL_GROUP_ICONS: { name: string; icon: LucideIcon }[] = [
{ name: "combine", icon: Combine },
{ name: "layers", icon: Layers },
{ name: "box", icon: Box },
{ name: "boxes", icon: Boxes },
{ name: "package", icon: Package },
{ name: "folder", icon: Folder },
{ name: "folder-open", icon: FolderOpen },
{ name: "file", icon: File },
{ name: "file-text", icon: FileText },
{ name: "list", icon: List },
{ name: "list-todo", icon: ListTodo },
{ name: "list-checks", icon: ListChecks },
{ name: "clipboard-list", icon: ClipboardList },
{ name: "check", icon: Check },
{ name: "check-circle", icon: CheckCircle },
{ name: "pencil", icon: PencilLine },
{ name: "trash", icon: Trash2 },
{ name: "x", icon: X },
{ name: "search", icon: Search },
{ name: "terminal", icon: Terminal },
{ name: "shapes", icon: Shapes },
{ name: "hexagon", icon: Hexagon },
{ name: "square", icon: Square },
{ name: "circle-dot", icon: CircleDot },
{ name: "star", icon: Star },
{ name: "zap", icon: Zap },
{ name: "hammer", icon: Hammer },
{ name: "wrench", icon: Wrench },
{ name: "cog", icon: Cog },
{ name: "settings", icon: Settings2 },
{ name: "tag", icon: Tag },
]
// Tool group type
export type ToolGroup = {
id: string
name: string
icon: string
tools: string[]
}
// Helper to get icon component by name
function getIconByName(name: string): LucideIcon {
return TOOL_GROUP_ICONS.find((i) => i.name === name)?.icon ?? Combine
}
// Generate a unique ID for tool groups
function generateGroupId(): string {
return `group-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
// Isolated dialog component to prevent parent re-renders on state changes
const ToolGroupEditorDialog = memo(function ToolGroupEditorDialog({
open,
onOpenChange,
editingGroup,
availableTools,
onSave,
}: {
open: boolean
onOpenChange: (open: boolean) => void
editingGroup: ToolGroup | null
availableTools: { label: string; value: string }[]
onSave: (group: ToolGroup) => void
}) {
const [groupName, setGroupName] = useState(editingGroup?.name ?? "")
const [groupIcon, setGroupIcon] = useState(editingGroup?.icon ?? "combine")
const [groupTools, setGroupTools] = useState<string[]>(editingGroup?.tools ?? [])
// Reset form when dialog opens or editingGroup changes
useEffect(() => {
if (open) {
setGroupName(editingGroup?.name ?? "")
setGroupIcon(editingGroup?.icon ?? "combine")
setGroupTools(editingGroup?.tools ?? [])
}
}, [open, editingGroup])
const canSaveGroup = groupName.trim().length > 0 && groupTools.length > 0
const handleSave = () => {
if (!canSaveGroup) return
const group: ToolGroup = {
id: editingGroup?.id ?? generateGroupId(),
name: groupName.trim(),
icon: groupIcon,
tools: groupTools,
}
onSave(group)
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editingGroup ? "Edit Tool Group" : "Create Tool Group"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Group Name <span className="text-destructive">*</span>
</label>
<Input
placeholder="e.g., File Operations"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
className={!groupName.trim() ? "border-muted-foreground/30" : ""}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Icon</label>
<div className="flex flex-wrap gap-2">
{TOOL_GROUP_ICONS.map(({ name, icon: IconComponent }) => (
<Button
key={name}
variant={groupIcon === name ? "default" : "outline"}
size="icon"
className="h-8 w-8"
onClick={() => setGroupIcon(name)}>
<IconComponent className="h-4 w-4" />
</Button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Tools <span className="text-destructive">*</span>
</label>
<MultiSelect
options={availableTools}
value={groupTools}
onValueChange={setGroupTools}
placeholder="Select tools..."
className="w-full"
maxCount={3}
modalPopover
/>
<div className="text-xs text-muted-foreground">
{groupTools.length > 0
? `${groupTools.length} tool${groupTools.length !== 1 ? "s" : ""} selected`
: "Select at least one tool"}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!canSaveGroup}>
{editingGroup ? "Save Changes" : "Create Group"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
})
type RunWithTaskMetrics = Run & { taskMetrics: TaskMetrics | null }
type SortColumn = "model" | "provider" | "passed" | "failed" | "percent" | "cost" | "duration" | "createdAt"
type SortDirection = "asc" | "desc"
type TimeframeOption = "all" | "24h" | "7d" | "30d" | "90d"
const TIMEFRAME_OPTIONS: { value: TimeframeOption; label: string }[] = [
{ value: "all", label: "All time" },
{ value: "24h", label: "Last 24 hours" },
{ value: "7d", label: "Last 7 days" },
{ value: "30d", label: "Last 30 days" },
{ value: "90d", label: "Last 90 days" },
]
// LocalStorage keys
const STORAGE_KEYS = {
TIMEFRAME: "evals-runs-timeframe",
MODEL_FILTER: "evals-runs-model-filter",
PROVIDER_FILTER: "evals-runs-provider-filter",
TOOL_GROUPS: "evals-runs-tool-groups",
}
function getTimeframeStartDate(timeframe: TimeframeOption): Date | null {
if (timeframe === "all") return null
const now = new Date()
switch (timeframe) {
case "24h":
return new Date(now.getTime() - 24 * 60 * 60 * 1000)
case "7d":
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
case "30d":
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
case "90d":
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000)
default:
return null
}
}
// Generate abbreviation from tool name (e.g., "read_file" -> "RF", "list_code_definition_names" -> "LCDN")
function getToolAbbreviation(toolName: string): string {
return toolName
@ -54,6 +317,120 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
const [sortColumn, setSortColumn] = useState<SortColumn | null>("createdAt")
const [sortDirection, setSortDirection] = useState<SortDirection>("desc")
// Filter state - initialize from localStorage
const [timeframeFilter, setTimeframeFilter] = useState<TimeframeOption>(() => {
if (typeof window === "undefined") return "all"
const stored = localStorage.getItem(STORAGE_KEYS.TIMEFRAME)
return (stored as TimeframeOption) || "all"
})
const [modelFilter, setModelFilter] = useState<string[]>(() => {
if (typeof window === "undefined") return []
const stored = localStorage.getItem(STORAGE_KEYS.MODEL_FILTER)
return stored ? JSON.parse(stored) : []
})
const [providerFilter, setProviderFilter] = useState<string[]>(() => {
if (typeof window === "undefined") return []
const stored = localStorage.getItem(STORAGE_KEYS.PROVIDER_FILTER)
return stored ? JSON.parse(stored) : []
})
// Tool groups state - initialize from localStorage
const [toolGroups, setToolGroups] = useState<ToolGroup[]>(() => {
if (typeof window === "undefined") return []
const stored = localStorage.getItem(STORAGE_KEYS.TOOL_GROUPS)
if (stored) {
try {
return JSON.parse(stored)
} catch {
return []
}
}
return []
})
// Tool group editor dialog state
const [showGroupDialog, setShowGroupDialog] = useState(false)
const [editingGroup, setEditingGroup] = useState<ToolGroup | null>(null)
// Delete runs state
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showDeleteOldConfirm, setShowDeleteOldConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
// Persist filters to localStorage
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.TIMEFRAME, timeframeFilter)
}, [timeframeFilter])
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.MODEL_FILTER, JSON.stringify(modelFilter))
}, [modelFilter])
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.PROVIDER_FILTER, JSON.stringify(providerFilter))
}, [providerFilter])
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.TOOL_GROUPS, JSON.stringify(toolGroups))
}, [toolGroups])
// Count incomplete runs (runs without taskMetricsId)
const incompleteRunsCount = useMemo(() => {
return runs.filter((run) => run.taskMetrics === null).length
}, [runs])
// Count runs older than 30 days
const oldRunsCount = useMemo(() => {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
return runs.filter((run) => run.createdAt < thirtyDaysAgo).length
}, [runs])
const handleDeleteIncompleteRuns = useCallback(async () => {
setIsDeleting(true)
try {
const result = await deleteIncompleteRuns()
if (result.success) {
toast.success(`Deleted ${result.deletedCount} incomplete run${result.deletedCount !== 1 ? "s" : ""}`)
if (result.storageErrors.length > 0) {
toast.warning(`Some storage folders could not be deleted: ${result.storageErrors.length} errors`)
}
router.refresh()
} else {
toast.error("Failed to delete incomplete runs")
}
} catch (error) {
console.error("Error deleting incomplete runs:", error)
toast.error("Failed to delete incomplete runs")
} finally {
setIsDeleting(false)
setShowDeleteConfirm(false)
}
}, [router])
const handleDeleteOldRuns = useCallback(async () => {
setIsDeleting(true)
try {
const result = await deleteOldRuns()
if (result.success) {
toast.success(
`Deleted ${result.deletedCount} run${result.deletedCount !== 1 ? "s" : ""} older than 30 days`,
)
if (result.storageErrors.length > 0) {
toast.warning(`Some storage folders could not be deleted: ${result.storageErrors.length} errors`)
}
router.refresh()
} else {
toast.error("Failed to delete old runs")
}
} catch (error) {
console.error("Error deleting old runs:", error)
toast.error("Failed to delete old runs")
} finally {
setIsDeleting(false)
setShowDeleteOldConfirm(false)
}
}, [router])
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc")
@ -63,11 +440,59 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
}
}
// Collect all unique tool names from all runs and sort by total attempts
const toolColumns = useMemo<ToolName[]>(() => {
// Derive unique models and providers from runs
const modelOptions = useMemo(() => {
const models = new Set<string>()
for (const run of runs) {
if (run.model) models.add(run.model)
}
return Array.from(models)
.sort()
.map((model) => ({ label: model, value: model }))
}, [runs])
const providerOptions = useMemo(() => {
const providers = new Set<string>()
for (const run of runs) {
const provider = run.settings?.apiProvider
if (provider) providers.add(provider)
}
return Array.from(providers)
.sort()
.map((provider) => ({ label: provider, value: provider }))
}, [runs])
// Filter runs based on filter state
const filteredRuns = useMemo(() => {
return runs.filter((run) => {
// Timeframe filter
const timeframeStart = getTimeframeStartDate(timeframeFilter)
if (timeframeStart && run.createdAt < timeframeStart) {
return false
}
// Model filter
if (modelFilter.length > 0 && !modelFilter.includes(run.model)) {
return false
}
// Provider filter
if (providerFilter.length > 0) {
const provider = run.settings?.apiProvider
if (!provider || !providerFilter.includes(provider)) {
return false
}
}
return true
})
}, [runs, timeframeFilter, modelFilter, providerFilter])
// Collect all unique tool names from filtered runs and sort by total attempts
const allToolColumns = useMemo<ToolName[]>(() => {
const toolTotals = new Map<ToolName, number>()
for (const run of runs) {
for (const run of filteredRuns) {
if (run.taskMetrics?.toolUsage) {
for (const [toolName, usage] of Object.entries(run.taskMetrics.toolUsage)) {
const tool = toolName as ToolName
@ -81,13 +506,40 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
return Array.from(toolTotals.entries())
.sort((a, b) => b[1] - a[1])
.map(([name]): ToolName => name)
}, [runs])
}, [filteredRuns])
// Sort runs based on current sort column and direction
// Tool column options for the group editor
const toolColumnOptions = useMemo(() => {
return allToolColumns.map((tool) => ({
label: tool,
value: tool,
}))
}, [allToolColumns])
// Get all tools that are in any group
const groupedTools = useMemo(() => {
const grouped = new Set<string>()
for (const group of toolGroups) {
for (const tool of group.tools) {
grouped.add(tool)
}
}
return grouped
}, [toolGroups])
// Separate grouped and individual tool columns
const individualToolColumns = useMemo(() => {
return allToolColumns.filter((tool) => !groupedTools.has(tool))
}, [allToolColumns, groupedTools])
// Use individualToolColumns for rendering
const toolColumns = individualToolColumns
// Sort filtered runs based on current sort column and direction
const sortedRuns = useMemo(() => {
if (!sortColumn) return runs
if (!sortColumn) return filteredRuns
return [...runs].sort((a, b) => {
return [...filteredRuns].sort((a, b) => {
let aVal: string | number | Date | null = null
let bVal: string | number | Date | null = null
@ -139,14 +591,231 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
return sortDirection === "asc" ? comparison : -comparison
})
}, [runs, sortColumn, sortDirection])
}, [filteredRuns, sortColumn, sortDirection])
// Calculate colSpan for empty state (7 base columns + dynamic tools + 3 end columns)
const totalColumns = 7 + toolColumns.length + 3
// Calculate colSpan for empty state (7 base columns + tool groups + dynamic tools + 3 end columns)
const totalColumns = 7 + toolGroups.length + toolColumns.length + 3
// Check if any filters are active
const hasActiveFilters = timeframeFilter !== "all" || modelFilter.length > 0 || providerFilter.length > 0
const clearAllFilters = () => {
setTimeframeFilter("all")
setModelFilter([])
setProviderFilter([])
}
// Tool group management handlers
const openNewGroupDialog = useCallback(() => {
setEditingGroup(null)
setShowGroupDialog(true)
}, [])
const openEditGroupDialog = useCallback((group: ToolGroup) => {
setEditingGroup(group)
setShowGroupDialog(true)
}, [])
const handleSaveGroup = useCallback(
(group: ToolGroup) => {
setToolGroups((prev) => {
const existingIndex = prev.findIndex((g) => g.id === group.id)
if (existingIndex >= 0) {
// Update existing group
const newGroups = [...prev]
newGroups[existingIndex] = group
return newGroups
} else {
// Add new group
return [...prev, group]
}
})
toast.success(editingGroup ? "Group updated" : "Group created")
},
[editingGroup],
)
const handleDeleteGroup = useCallback((groupId: string) => {
setToolGroups((prev) => prev.filter((g) => g.id !== groupId))
toast.success("Group deleted")
}, [])
// Get available tools for group editor (tools not in other groups)
const availableToolsForEditor = useMemo(() => {
const usedInOtherGroups = new Set<string>()
for (const group of toolGroups) {
if (editingGroup && group.id === editingGroup.id) continue
for (const tool of group.tools) {
usedInOtherGroups.add(tool)
}
}
return toolColumnOptions.filter((opt) => !usedInOtherGroups.has(opt.value))
}, [toolColumnOptions, toolGroups, editingGroup])
return (
<>
<Table className="border border-t-0">
{/* Filter Controls */}
<div className="flex items-center gap-4 p-4 border border-b-0 rounded-t-md bg-muted/30">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Timeframe:</span>
<Select
value={timeframeFilter}
onValueChange={(value) => setTimeframeFilter(value as TimeframeOption)}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIMEFRAME_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Model:</span>
<MultiSelect
options={modelOptions}
value={modelFilter}
onValueChange={setModelFilter}
placeholder="All models"
className="w-[200px]"
maxCount={1}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Provider:</span>
<MultiSelect
options={providerOptions}
value={providerFilter}
onValueChange={setProviderFilter}
placeholder="All providers"
className="w-[180px]"
maxCount={1}
/>
</div>
{/* Tool Groups Dropdown */}
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="flex items-center gap-2">
<Layers className="h-4 w-4" />
<span>Groups</span>
{toolGroups.length > 0 && (
<span className="bg-primary text-primary-foreground text-xs px-1.5 rounded-full">
{toolGroups.length}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
{toolGroups.length > 0 ? (
<>
{toolGroups.map((group) => {
const IconComponent = getIconByName(group.icon)
return (
<DropdownMenuItem
key={group.id}
className="flex items-center justify-between"
onClick={(e) => {
e.preventDefault()
openEditGroupDialog(group)
}}>
<div className="flex items-center gap-2">
<IconComponent className="h-4 w-4" />
<span>{group.name}</span>
<span className="text-xs text-muted-foreground">
({group.tools.length})
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation()
openEditGroupDialog(group)
}}>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
handleDeleteGroup(group.id)
}}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
</>
) : (
<div className="px-2 py-1.5 text-sm text-muted-foreground">No groups yet</div>
)}
<DropdownMenuItem onClick={openNewGroupDialog}>
<Plus className="h-4 w-4 mr-2" />
Add Group
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearAllFilters}>
<X className="h-4 w-4 mr-1" />
Clear filters
</Button>
)}
<div className="flex items-center gap-2 ml-auto">
{/* Bulk Actions Menu */}
{(incompleteRunsCount > 0 || oldRunsCount > 0) && (
<DropdownMenu>
<Button variant="ghost" size="sm" asChild>
<DropdownMenuTrigger disabled={isDeleting}>
<Ellipsis className="h-4 w-4" />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent align="end">
{incompleteRunsCount > 0 && (
<DropdownMenuItem
onClick={() => setShowDeleteConfirm(true)}
disabled={isDeleting}
className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete {incompleteRunsCount} incomplete run
{incompleteRunsCount !== 1 ? "s" : ""}
</DropdownMenuItem>
)}
{oldRunsCount > 0 && (
<DropdownMenuItem
onClick={() => setShowDeleteOldConfirm(true)}
disabled={isDeleting}
className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete {oldRunsCount} run{oldRunsCount !== 1 ? "s" : ""} over 30d
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
<div className="text-sm text-muted-foreground">
{filteredRuns.length} of {runs.length} runs
</div>
</div>
</div>
<Table className="border border-t-0 rounded-t-none">
<TableHeader>
<TableRow>
<TableHead
@ -188,6 +857,30 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
</div>
</TableHead>
<TableHead>Tokens</TableHead>
{/* Tool Group Columns */}
{toolGroups.map((group) => {
const IconComponent = getIconByName(group.icon)
return (
<TableHead key={group.id} className="text-center">
<div className="flex justify-center">
<Tooltip>
<TooltipTrigger>
<IconComponent className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent>
<div className="text-xs">
<div className="font-semibold mb-1">{group.name}</div>
{group.tools.map((tool) => (
<div key={tool}>{tool}</div>
))}
</div>
</TooltipContent>
</Tooltip>
</div>
</TableHead>
)
})}
{/* Individual Tool Columns */}
{toolColumns.map((toolName) => (
<TableHead key={toolName} className="text-xs text-center">
<Tooltip>
@ -214,16 +907,34 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
<TableBody>
{sortedRuns.length ? (
sortedRuns.map(({ taskMetrics, ...run }) => (
<Row key={run.id} run={run} taskMetrics={taskMetrics} toolColumns={toolColumns} />
<Row
key={run.id}
run={run}
taskMetrics={taskMetrics}
toolColumns={toolColumns}
toolGroups={toolGroups}
/>
))
) : (
<TableRow>
<TableCell colSpan={totalColumns} className="text-center">
No eval runs yet.
<Button variant="link" onClick={() => router.push("/runs/new")}>
Launch
</Button>
one now.
<TableCell colSpan={totalColumns} className="text-center py-8">
{runs.length === 0 ? (
<>
No eval runs yet.
<Button variant="link" onClick={() => router.push("/runs/new")}>
Launch
</Button>
one now.
</>
) : (
<>
No runs match the current filters.
<Button variant="link" onClick={clearAllFilters}>
Clear filters
</Button>
to see all runs.
</>
)}
</TableCell>
</TableRow>
)}
@ -235,6 +946,79 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
onClick={() => router.push("/runs/new")}>
<Rocket className="size-6" />
</Button>
{/* Tool Group Editor Dialog */}
<ToolGroupEditorDialog
open={showGroupDialog}
onOpenChange={setShowGroupDialog}
editingGroup={editingGroup}
availableTools={availableToolsForEditor}
onSave={handleSaveGroup}
/>
{/* Delete Incomplete Runs Confirmation Dialog */}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Incomplete Runs</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {incompleteRunsCount} incomplete run
{incompleteRunsCount !== 1 ? "s" : ""}? This will permanently remove all database records
and storage folders for these runs. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteIncompleteRuns}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{isDeleting ? (
<>
<LoaderCircle className="h-4 w-4 mr-2 animate-spin" />
Deleting...
</>
) : (
<>
Delete {incompleteRunsCount} run{incompleteRunsCount !== 1 ? "s" : ""}
</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete Old Runs Confirmation Dialog */}
<AlertDialog open={showDeleteOldConfirm} onOpenChange={setShowDeleteOldConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Old Runs</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete {oldRunsCount} run{oldRunsCount !== 1 ? "s" : ""} older than
30 days? This will permanently remove all database records and storage folders for these
runs. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteOldRuns}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{isDeleting ? (
<>
<LoaderCircle className="h-4 w-4 mr-2 animate-spin" />
Deleting...
</>
) : (
<>
Delete {oldRunsCount} run{oldRunsCount !== 1 ? "s" : ""}
</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View file

@ -84,6 +84,18 @@ interface MultiSelectProps extends React.HTMLAttributes<HTMLDivElement>, Variant
* Optional, can be used to add custom styles.
*/
className?: string
/**
* If true, popover width will auto-size to content instead of matching trigger width.
* Optional, defaults to false.
*/
popoverAutoWidth?: boolean
/**
* Optional footer content to render at the bottom of the popover.
* Useful for adding reset buttons or other actions.
*/
footer?: React.ReactNode
}
export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
@ -97,6 +109,8 @@ export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
placeholder = "Select options",
maxCount = 3,
modalPopover = false,
popoverAutoWidth = false,
footer,
className,
...props
},
@ -243,7 +257,7 @@ export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
</div>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
className={cn("p-0", popoverAutoWidth ? "w-auto" : "w-[var(--radix-popover-trigger-width)]")}
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}>
<Command filter={onFilter}>
@ -276,6 +290,7 @@ export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
</CommandGroup>
</CommandList>
</Command>
{footer && <div className="border-t p-2">{footer}</div>}
</PopoverContent>
</Popover>
)

View file

@ -1,7 +1,7 @@
import { useState, useCallback, useRef } from "react"
import { useQuery, keepPreviousData } from "@tanstack/react-query"
import { type TokenUsage, RooCodeEventName, taskEventSchema } from "@roo-code/types"
import { type TokenUsage, type ToolUsage, RooCodeEventName, taskEventSchema } from "@roo-code/types"
import type { Run, Task, TaskMetrics } from "@roo-code/evals"
import { getHeartbeat } from "@/actions/heartbeat"
@ -15,6 +15,7 @@ export type RunStatus = {
runners: string[] | undefined
tasks: (Task & { taskMetrics: TaskMetrics | null })[] | undefined
tokenUsage: Map<number, TokenUsage & { duration?: number }>
toolUsage: Map<number, ToolUsage>
usageUpdatedAt: number | undefined
}
@ -23,6 +24,7 @@ export const useRunStatus = (run: Run): RunStatus => {
const [usageUpdatedAt, setUsageUpdatedAt] = useState<number>()
const tokenUsage = useRef<Map<number, TokenUsage & { duration?: number }>>(new Map())
const toolUsage = useRef<Map<number, ToolUsage>>(new Map())
const startTimes = useRef<Map<number, number>>(new Map())
const { data: heartbeat } = useQuery({
@ -78,6 +80,12 @@ export const useRunStatus = (run: Run): RunStatus => {
const startTime = startTimes.current.get(taskId)
const duration = startTime ? Date.now() - startTime : undefined
tokenUsage.current.set(taskId, { ...payload[1], duration })
// Track tool usage from streaming updates
if (payload[2]) {
toolUsage.current.set(taskId, payload[2])
}
setUsageUpdatedAt(Date.now())
break
}
@ -96,6 +104,7 @@ export const useRunStatus = (run: Run): RunStatus => {
runners,
tasks,
tokenUsage: tokenUsage.current,
toolUsage: toolUsage.current,
usageUpdatedAt,
}
}

View file

@ -0,0 +1,30 @@
import { formatDuration, formatTokens } from "../formatters"
describe("formatDuration()", () => {
it("formats as H:MM:SS", () => {
expect(formatDuration(0)).toBe("0:00:00")
expect(formatDuration(1_000)).toBe("0:00:01")
expect(formatDuration(61_000)).toBe("0:01:01")
expect(formatDuration(3_661_000)).toBe("1:01:01")
})
})
describe("formatTokens()", () => {
it("formats small numbers without suffix", () => {
expect(formatTokens(0)).toBe("0")
expect(formatTokens(999)).toBe("999")
})
it("formats thousands without decimals and clamps to 1.0M at boundary", () => {
expect(formatTokens(1_000)).toBe("1k")
expect(formatTokens(72_500)).toBe("73k")
expect(formatTokens(999_499)).toBe("999k")
expect(formatTokens(999_500)).toBe("1.0M")
})
it("formats millions with one decimal and clamps to 1.0B at boundary", () => {
expect(formatTokens(1_000_000)).toBe("1.0M")
expect(formatTokens(3_240_000)).toBe("3.2M")
expect(formatTokens(999_950_000)).toBe("1.0B")
})
})

View file

@ -11,21 +11,10 @@ export const formatDuration = (durationMs: number) => {
const minutes = Math.floor((seconds % 3600) / 60)
const remainingSeconds = seconds % 60
const parts = []
if (hours > 0) {
parts.push(`${hours}h`)
}
if (minutes > 0) {
parts.push(`${minutes}m`)
}
if (remainingSeconds > 0 || parts.length === 0) {
parts.push(`${remainingSeconds}s`)
}
return parts.join(" ")
// Format as H:MM:SS
const mm = minutes.toString().padStart(2, "0")
const ss = remainingSeconds.toString().padStart(2, "0")
return `${hours}:${mm}:${ss}`
}
export const formatTokens = (tokens: number) => {
@ -34,18 +23,30 @@ export const formatTokens = (tokens: number) => {
}
if (tokens < 1000000) {
return `${(tokens / 1000).toFixed(1)}k`
// No decimal for thousands (e.g., 72k not 72.5k)
const rounded = Math.round(tokens / 1000)
// If rounding crosses the boundary to 1000k, show as 1.0M instead
if (rounded >= 1000) {
return "1.0M"
}
return `${rounded}k`
}
if (tokens < 1000000000) {
return `${(tokens / 1000000).toFixed(1)}M`
// Keep decimal for millions (e.g., 3.2M)
const rounded = Math.round(tokens / 100000) / 10 // Round to 1 decimal
// If rounding crosses the boundary to 1000M, show as 1.0B instead
if (rounded >= 1000) {
return "1.0B"
}
return `${rounded.toFixed(1)}M`
}
return `${(tokens / 1000000000).toFixed(1)}B`
}
export const formatToolUsageSuccessRate = (usage: { attempts: number; failures: number }) =>
usage.attempts === 0 ? "0%" : `${(((usage.attempts - usage.failures) / usage.attempts) * 100).toFixed(1)}%`
usage.attempts === 0 ? "0%" : `${Math.round(((usage.attempts - usage.failures) / usage.attempts) * 100)}%`
export const formatDateTime = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {

View file

@ -14,6 +14,10 @@ export const TIMEOUT_MIN = 5
export const TIMEOUT_MAX = 10
export const TIMEOUT_DEFAULT = 5
export const ITERATIONS_MIN = 1
export const ITERATIONS_MAX = 10
export const ITERATIONS_DEFAULT = 1
export const createRunSchema = z
.object({
model: z.string().min(1, { message: "Model is required." }),
@ -23,6 +27,7 @@ export const createRunSchema = z
settings: rooCodeSettingsSchema.optional(),
concurrency: z.number().int().min(CONCURRENCY_MIN).max(CONCURRENCY_MAX),
timeout: z.number().int().min(TIMEOUT_MIN).max(TIMEOUT_MAX),
iterations: z.number().int().min(ITERATIONS_MIN).max(ITERATIONS_MAX),
jobToken: z.string().optional(),
})
.refine((data) => data.suite === "full" || (data.exercises || []).length > 0, {

View file

@ -27,6 +27,11 @@ const nextConfig: NextConfig = {
destination: "https://roo-code.notion.site/238fd1401b0a8087b858e1ad431507cf?pvs=105",
permanent: false,
},
{
source: "/provider/pricing",
destination: "/provider",
permanent: true,
},
]
},
}

View file

@ -25,7 +25,7 @@
"embla-carousel-react": "^8.6.0",
"framer-motion": "12.15.0",
"lucide-react": "^0.518.0",
"next": "^15.2.5",
"next": "~15.2.8",
"next-themes": "^0.4.6",
"posthog-js": "^1.248.1",
"react": "^18.3.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z"></path></svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>MoonshotAI</title><path d="M1.052 16.916l9.539 2.552a21.007 21.007 0 00.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865l-.18-.016-.044-.004-.084-.009-.094-.01a11.605 11.605 0 01-.157-.02l-.107-.014-.11-.016a11.962 11.962 0 01-.32-.051l-.042-.008-.075-.013-.107-.02-.07-.015-.093-.019-.075-.016-.095-.02-.097-.023-.094-.022-.068-.017-.088-.022-.09-.024-.095-.025-.082-.023-.109-.03-.062-.02-.084-.025-.093-.028-.105-.034-.058-.019-.08-.026-.09-.031-.066-.024a6.293 6.293 0 01-.044-.015l-.068-.025-.101-.037-.057-.022-.08-.03-.087-.035-.088-.035-.079-.032-.095-.04-.063-.028-.063-.027a5.655 5.655 0 01-.041-.018l-.066-.03-.103-.047-.052-.024-.096-.046-.062-.03-.084-.04-.086-.044-.093-.047-.052-.027-.103-.055-.057-.03-.058-.032a6.49 6.49 0 01-.046-.026l-.094-.053-.06-.034-.051-.03-.072-.041-.082-.05-.093-.056-.052-.032-.084-.053-.061-.039-.079-.05-.07-.047-.053-.035a7.785 7.785 0 01-.054-.036l-.044-.03-.044-.03a6.066 6.066 0 01-.04-.028l-.057-.04-.076-.054-.069-.05-.074-.054-.056-.042-.076-.057-.076-.059-.086-.067-.045-.035-.064-.052-.074-.06-.089-.073-.046-.039-.046-.039a7.516 7.516 0 01-.043-.037l-.045-.04-.061-.053-.07-.062-.068-.06-.062-.058-.067-.062-.053-.05-.088-.084a13.28 13.28 0 01-.099-.097l-.029-.028-.041-.042-.069-.07-.05-.051-.05-.053a6.457 6.457 0 01-.168-.179l-.08-.088-.062-.07-.071-.08-.042-.049-.053-.062-.058-.068-.046-.056a7.175 7.175 0 01-.027-.033l-.045-.055-.066-.082-.041-.052-.05-.064-.02-.025a11.99 11.99 0 01-1.44-2.402zm-1.02-5.794l11.353 3.037a20.468 20.468 0 00-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923l-.016-.046-.035-.104a11.965 11.965 0 01-.05-.153l-.007-.023a11.896 11.896 0 01-.207-.741l-.03-.126-.018-.08-.021-.097-.018-.081-.018-.09-.017-.084-.018-.094c-.026-.141-.05-.283-.071-.426l-.017-.118-.011-.083-.013-.102a12.01 12.01 0 01-.019-.161l-.005-.047a12.12 12.12 0 01-.034-2.145zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1.008-.049.01-.067.015-.087.018-.098c.026-.148.056-.295.088-.442l.028-.124.02-.085.024-.097c.022-.09.045-.18.07-.268l.028-.102.023-.083.03-.1.025-.082.03-.096.026-.082.031-.095a11.896 11.896 0 011.01-2.232zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065.027-.04.04-.055.046-.065.055-.076.054-.072.064-.086.05-.065.057-.073.055-.07.06-.074.055-.069.065-.077.054-.066.066-.077.053-.06.072-.082.053-.06.067-.074.054-.058.073-.078.058-.06.063-.067.168-.17.1-.098.059-.056.076-.071a12.084 12.084 0 012.272-1.677zM12.017 0h.097l.082.001.069.001.054.002.068.002.046.001.076.003.047.002.06.003.054.002.087.005.105.007.144.011.088.007.044.004.077.008.082.008.047.005.102.012.05.006.108.014.081.01.042.006.065.01.207.032.07.012.065.011.14.026.092.018.11.022.046.01.075.016.041.01L14.7.3l.042.01.065.015.049.012.071.017.096.024.112.03.113.03.113.032.05.015.07.02.078.024.073.023.05.016.05.016.076.025.099.033.102.036.048.017.064.023.093.034.11.041.116.045.1.04.047.02.06.024.041.018.063.026.04.018.057.025.11.048.1.046.074.035.075.036.06.028.092.046.091.045.102.052.053.028.049.026.046.024.06.033.041.022.052.029.088.05.106.06.087.051.057.034.053.032.096.059.088.055.098.062.036.024.064.041.084.056.04.027.062.042.062.043.023.017c.054.037.108.075.161.114l.083.06.065.048.056.043.086.065.082.064.04.03.05.041.086.069.079.065.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027.065-.028.081-.034.086-.035c.113-.045.227-.09.341-.131l.096-.035.093-.033.084-.03.096-.031c.087-.03.176-.058.264-.085l.091-.027.086-.025.102-.03.085-.023.1-.026L9.04.37l.09-.023.091-.022.095-.022.09-.02.098-.021.091-.02.095-.018.092-.018.1-.018.091-.016.098-.017.092-.014.097-.015.092-.013.102-.013.091-.012.105-.012.09-.01.105-.01c.093-.01.186-.018.28-.024l.106-.008.09-.005.11-.006.093-.004.1-.004.097-.002.099-.002.197-.002z"></path></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>

After

Width:  |  Height:  |  Size: 906 B

View file

@ -1,29 +1,33 @@
import {
ArrowRight,
Bot,
Brain,
ChartLine,
Cloud,
Lock,
Github,
History,
LucideIcon,
Megaphone,
MessageCircleQuestionMark,
ReplaceAll,
Pencil,
Router,
Share2,
Slack,
Users,
Users2,
} from "lucide-react"
import type { Metadata } from "next"
import Image from "next/image"
import { Button } from "@/components/ui"
import { AnimatedBackground } from "@/components/homepage"
import { AnimatedBackground, UseExamplesSection } from "@/components/homepage"
import { SEO } from "@/lib/seo"
import { ogImageUrl } from "@/lib/og"
import { EXTERNAL_LINKS } from "@/lib/constants"
import Image from "next/image"
// Workaround for next/image choking on these for some reason
import screenshotDark from "/public/heroes/cloud-screen.png"
const TITLE = "Roo Code Cloud"
const DESCRIPTION =
"Roo Code Cloud gives you and your team the tools to take AI-coding to the next level with cloud agents, remote control, and more."
const OG_DESCRIPTION = "Go way beyond the IDE"
"Your AI Software Engineering Team in the Cloud. Delegate tasks to autonomous agents, review PRs, and collaborate with your team."
const OG_DESCRIPTION = "Your AI Team in the Cloud"
const PATH = "/cloud"
export const metadata: Metadata = {
@ -54,161 +58,171 @@ export const metadata: Metadata = {
description: DESCRIPTION,
images: [ogImageUrl(TITLE, OG_DESCRIPTION)],
},
keywords: [...SEO.keywords, "cloud", "subscription", "cloud agents", "AI cloud development"],
keywords: [...SEO.keywords, "cloud", "subscription", "cloud agents", "AI cloud development", "autonomous agents"],
}
const howItWorks = [
{
title: "1. Connect your GitHub account",
description:
"Pick which repos the agents can work with in their isolated containers and choose what model you want to power each of them. You're in control.",
icon: Github,
},
{
title: "2. Set up your agent team",
description:
"Choose the roles you want filled, like Explainer, Planner, Coder, PR Reviewer and PR Fixer. They know how to act in each situation and stay on-task with no deviations.",
icon: Users2,
},
{
title: "3. Start giving them tasks",
description:
"Describe what you want them to do from the web UI, get the Reviewer automatically reviewing PRs, get the Coder building features from Slack threads and much more. They're now part of your team.",
icon: Pencil,
},
]
interface Feature {
icon: LucideIcon
title: string
description: string
logos?: string[]
}
const cloudFeatures: Feature[] = [
const features: Feature[] = [
{
icon: Bot,
title: "Autonomous Cloud Agents",
description:
"Delegate work to specialized agents like the Planner, Coder, Explainer, Reviewer, and Fixer that run 24/7.",
},
{
icon: Brain,
title: "Model Agnostic",
description:
"Bring your own keys or use the Roo Code Cloud Provider with access to all top models with no markup.",
},
{
icon: Github,
title: "GitHub PR Reviews",
description:
"Agents can automatically review Pull Requests, provide feedback, and even push fixes directly to your repository.",
},
{
icon: Slack,
title: "Slack Integration",
description: "Start tasks, get updates, and collaborate with agents directly from your team's Slack channels.",
},
{
icon: Router,
title: "Roomote Control",
description: "Control your IDE from anywhere and keep coding away from your computer.",
},
{
icon: Cloud,
title: "Cloud Agents",
description:
"Specialized agents running in the Cloud to get stuff done while you sleep, with a credit-based system that doesn't lock you in or dumb your models down.",
"Connect to your local VS Code instance and control the extension remotely from the browser or Slack.",
},
{
icon: ReplaceAll,
title: "Still Model-agnostic",
description: "Bring your own provider key — no markup, lock-in, no restrictions.",
logos: ["Anthropic", "OpenAI", "Gemini", "Grok", "Qwen", "Kimi", "Mistral", "Ollama"],
icon: Users,
title: "Team Collaboration",
description:
"Manage your team and their access to tasks and resources, with centralized billing and configuration.",
},
{
icon: ChartLine,
title: "Usage Analytics",
description: "Detailed token analytics to help you optimize your costs and usage.",
description: "Detailed token analytics to help you optimize your costs and usage across your team.",
},
{
icon: Megaphone,
title: "Early Model Access",
description: "Get early, free access to new, stealth coding models as they become available.",
icon: History,
title: "Task History",
description: "Access from anywhere all of your tasks, from the cloud and the extension",
},
{
icon: Share2,
title: "Task Sharing",
description: "Share tasks with friends and co-workers and let them follow your work.",
},
{
icon: Users,
title: "Team Management",
description:
"Manage your team and their access to tasks and resources, with centralized billing, analytics and configuration.",
},
{
icon: Lock,
title: "Secure and Private",
description:
"Your data is never used for training, and we're SOC2 Type 2 and GDPR compliant, following state-of-the-art security practices, with deep respect for your IP.",
},
{
icon: MessageCircleQuestionMark,
title: "Priority support",
description: "Get quick help from the people who know Roo best.",
description: "Share tasks with friends and co-workers and let them follow your work in real-time.",
},
]
// Workaround for next/image choking on these for some reason
import screenshotDark from "/public/heroes/cloud-screen.png"
export default function CloudPage() {
return (
<>
<section className="relative flex md:h-[calc(80vh-theme(spacing.12))] items-center overflow-hidden">
{/* Hero Section */}
<section className="relative flex pt-32 pb-20 items-center overflow-hidden">
<AnimatedBackground />
<div className="container relative flex items-center h-full z-10 mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid h-full relative gap-4 md:gap-0 lg:grid-cols-2">
<div className="flex flex-col px-4 justify-center space-y-6 sm:space-y-8">
<div>
<h1 className="text-4xl font-bold tracking-tight mt-8 text-center md:text-left md:text-4xl lg:text-5xl lg:mt-0">
Go <em>way</em> beyond the IDE
</h1>
<p className="mt-4 max-w-md text-lg text-muted-foreground text-center md:text-left sm:mt-6">
Roo Code Cloud gives you (and your team) the tools to take AI-coding to the next
level
</p>
</div>
<div className="flex flex-col space-y-3 sm:flex-row sm:space-x-4 sm:space-y-0">
<Button
variant="outline"
size="lg"
className="w-full sm:w-auto bg-white/20 dark:bg-white/10 backdrop-blur-sm border border-black/40 dark:border-white/30 hover:border-blue-400 hover:bg-white/30 dark:hover:bg-white/20 hover:shadow-[0_0_20px_rgba(59,130,246,0.5)] transition-all duration-300">
<a
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center justify-center">
Start Free Trial
<ArrowRight className="ml-2" />
</a>
</Button>
</div>
</div>
<div className="flex items-center justify-end mx-auto h-full mt-8 lg:mt-0">
<div className="md:w-[900px] md:h-[700px] relative rounded-md overflow-clip">
<div className="block">
<Image
src={screenshotDark}
alt="Screenshot of Roo Code Cloud"
className="max-w-full h-auto"
width={1390}
height={1012}
/>
</div>
</div>
<div className="container relative flex flex-col items-center h-full z-10 mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center max-w-4xl mx-auto mb-12">
<h1 className="text-4xl font-bold tracking-tight mb-6 md:text-5xl lg:text-6xl">
Your AI Team <span className="text-violet-500">in the Cloud</span>
</h1>
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto">
Create your agent team in the Cloud, give them access to GitHub, and start delegating tasks
from Web and Slack.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
size="xl"
className="bg-violet-600 hover:bg-violet-700 text-white transition-all duration-300 shadow-lg hover:shadow-violet-500/25"
asChild>
<a
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center">
Try Cloud for Free
<ArrowRight className="ml-2 size-5" />
</a>
</Button>
<Button variant="outline" size="xl" className="backdrop-blur-sm" asChild>
<a href="/pricing" className="flex items-center justify-center">
View Pricing
</a>
</Button>
</div>
</div>
{/* Screenshot */}
<div className="relative mx-auto mt-4 md:max-w-[1000px]">
<Image
src={screenshotDark}
alt="Roo Code Cloud Interface"
className="w-full h-auto"
width={1390}
height={1012}
priority
/>
</div>
</div>
</section>
{/* How It Works Section */}
<section className="relative overflow-hidden border-t border-border py-32">
<div className="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8">
<div className="mx-auto mb-12 md:mb-24 max-w-4xl text-center">
<div className="absolute inset-y-0 left-1/2 h-full w-full max-w-[1200px] -translate-x-1/2 z-1">
<div className="absolute left-1/2 top-1/2 h-[400px] w-full -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-500/10 dark:bg-blue-700/20 blur-[140px]" />
</div>
<div className="mx-auto mb-12 md:mb-24 max-w-5xl text-center">
<div>
<h2 className="text-4xl font-bold tracking-tight sm:text-5xl">Power and Flexibility</h2>
<p className="mt-6 text-lg text-muted-foreground">
Code in the cloud, access free models, get usage analytics and more
<h2 className="text-3xl font-bold tracking-tight sm:text-5xl mb-4">How it works</h2>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
It only takes 2 minutes to expand your team by 10x.
</p>
</div>
</div>
<div className="relative mx-auto md:max-w-[1200px]">
<ul className="grid grid-cols-1 place-items-center gap-6 md:grid-cols-2 lg:grid-cols-3 lg:gap-8">
{cloudFeatures.map((feature, index) => {
const Icon = feature.icon
<ul className="grid grid-cols-1 place-items-center gap-6 md:grid-cols-3 lg:gap-8">
{howItWorks.map((step, index) => {
const Icon = step.icon
return (
<li
key={index}
className="relative h-full border border-border rounded-2xl bg-background p-8 transition-all duration-300">
<Icon className="size-6 text-foreground/80" />
className="relative h-full border border-border rounded-2xl bg-background p-8 transition-all duration-300 hover:shadow-lg">
{Icon && <Icon className="size-6 text-foreground/80" />}
<h3 className="mb-3 mt-3 text-xl font-semibold text-foreground">
{feature.title}
{step.title}
</h3>
<p className="leading-relaxed font-light text-muted-foreground">
{feature.description}
</p>
{feature.logos && (
<div className="mt-4 flex flex-wrap items-center gap-4">
{feature.logos.map((logo) => (
<Image
key={logo}
width={20}
height={20}
className="w-5 h-5 overflow-clip opacity-50 dark:invert"
src={`/logos/${logo.toLowerCase()}.svg`}
alt={`${logo} Logo`}
/>
))}
</div>
)}
<div className="leading-relaxed font-light text-muted-foreground">
{step.description}
</div>
</li>
)
})}
@ -217,25 +231,61 @@ export default function CloudPage() {
</div>
</section>
<div id="faq"></div>
{/* Use Cases Section */}
<UseExamplesSection />
{/* Features Grid */}
<section className="py-24 bg-muted/30">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative">
<div className="absolute inset-y-0 left-1/2 h-full w-full max-w-[1200px] -translate-x-1/2 z-1">
<div className="absolute left-1/2 top-1/2 h-[800px] w-full -translate-x-1/2 -translate-y-1/2 rounded-full bg-violet-500/10 dark:bg-violet-700/20 blur-[140px]" />
</div>
<div className="text-center mb-16">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
Powering the next generation of software development
</h2>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Everything you need to scale your development capacity with AI.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-6xl mx-auto relative">
{features.map((feature, index) => {
const Icon = feature.icon
return (
<div
key={index}
className="bg-background p-8 rounded-2xl border border-border hover:shadow-lg transition-all duration-300">
<div className="bg-violet-100 dark:bg-violet-900/20 w-12 h-12 rounded-lg flex items-center justify-center mb-6">
<Icon className="size-6 text-violet-600 dark:text-violet-400" />
</div>
<h3 className="text-xl font-semibold mb-3">{feature.title}</h3>
<p className="text-muted-foreground leading-relaxed">{feature.description}</p>
</div>
)
})}
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-20">
<section className="py-24">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl rounded-3xl border border-border/50 bg-gradient-to-br from-blue-500/5 via-cyan-500/5 to-purple-500/5 p-8 text-center shadow-2xl backdrop-blur-xl dark:border-white/20 dark:bg-gradient-to-br dark:from-gray-800 dark:via-gray-900 dark:to-black sm:p-12">
<h2 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl">Try Roo Code Cloud now</h2>
<p className="mx-auto mb-8 max-w-2xl text-lg text-muted-foreground">Code from anywhere.</p>
<div className="mx-auto max-w-4xl rounded-3xl border border-border/50 bg-gradient-to-br from-violet-500/10 via-purple-500/5 to-blue-500/5 p-8 text-center shadow-2xl backdrop-blur-xl dark:border-white/10 sm:p-16">
<h2 className="mb-6 text-3xl font-bold tracking-tight sm:text-4xl">
Try a completely new way of working.
</h2>
<p className="mx-auto mb-10 max-w-2xl text-lg text-muted-foreground">Start for free today.</p>
<div className="flex flex-col justify-center space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0">
<Button
size="lg"
className="bg-black text-white hover:bg-gray-800 hover:shadow-lg hover:shadow-black/20 dark:bg-white dark:text-black dark:hover:bg-gray-200 dark:hover:shadow-white/20 transition-all duration-300"
className="bg-foreground text-background hover:bg-foreground/90 transition-all duration-300"
asChild>
<a
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center">
Create a free Cloud account
Sign up now
<ArrowRight className="ml-2 h-4 w-4" />
</a>
</Button>

View file

@ -0,0 +1,84 @@
/* eslint-disable react/jsx-no-target-blank */
import { getVSCodeDownloads } from "@/lib/stats"
import { Button } from "@/components/ui"
import {
AnimatedBackground,
CodeExample,
CompanyLogos,
FAQSection,
Features,
InstallSection,
Testimonials,
} from "@/components/homepage"
import { EXTERNAL_LINKS } from "@/lib/constants"
import { ArrowRight } from "lucide-react"
import { StructuredData } from "@/components/structured-data"
// Invalidate cache when a request comes in, at most once every hour.
export const revalidate = 3600
export default async function ExtensionPage() {
const downloads = await getVSCodeDownloads()
return (
<>
<StructuredData />
<section className="relative flex h-[calc(125vh-theme(spacing.12))] items-center overflow-hidden md:h-[calc(80svh-theme(spacing.12))]">
<AnimatedBackground />
<div className="container relative flex items-center h-full z-10 mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid h-full relative gap-8 md:gap-12 lg:grid-cols-2 lg:gap-16">
<div className="flex flex-col justify-center space-y-4 sm:space-y-8">
<div>
<h1 className="text-4xl font-bold tracking-tight mt-8 text-center md:text-left md:text-4xl lg:text-5xl lg:mt-0">
The Open Source AI Coding Assistant for serious work.
</h1>
<p className="mt-4 max-w-lg text-lg text-muted-foreground text-center md:text-left sm:mt-6">
Specialized modes stay on task and ship great code.
<br />
Fully model-agnostic so you can use the best (or most cost-effective) model for
each task.
</p>
<p className="max-w-lg text-lg text-muted-foreground text-center md:text-left sm:mt-6">
Stop chasing this week&apos;s hot new model or CLI tool and go deep with Roo Code.
</p>
</div>
<div className="flex flex-col space-y-3 sm:flex-row sm:space-x-4 sm:space-y-0">
<Button
size="lg"
className="w-full hover:bg-gray-200 dark:bg-white dark:text-black sm:w-auto">
<a
href={EXTERNAL_LINKS.MARKETPLACE}
target="_blank"
className="flex w-full items-center justify-center">
Install VS Code Extension
<ArrowRight className="ml-2" />
</a>
</Button>
</div>
<div className="md:max-h-[70px] md:overflow-clip text-center md:text-left pt-6 md:pt-0">
<CompanyLogos />
</div>
</div>
<div className="relative flex items-center mx-auto h-full mt-8 lg:mt-0">
<div className="flex items-center justify-center">
<CodeExample />
</div>
</div>
</div>
</div>
</section>
<div id="product">
<Features />
</div>
<div id="testimonials">
<Testimonials />
</div>
<div id="faq">
<FAQSection />
</div>
<InstallSection downloads={downloads} />
</>
)
}

View file

@ -14,7 +14,7 @@ import "./globals.css"
const inter = Inter({ subsets: ["latin"] })
const OG_TITLE = "Meet Roo Code"
const OG_DESCRIPTION = "The AI dev team that gets things done."
const OG_DESCRIPTION = "Your AI Software Engineering Team in the IDE and the Cloud."
export const metadata: Metadata = {
metadataBase: new URL(SEO.url),

View file

@ -1,16 +1,12 @@
/* eslint-disable react/jsx-no-target-blank */
import { getVSCodeDownloads } from "@/lib/stats"
import { Button } from "@/components/ui"
import {
AnimatedBackground,
CodeExample,
CompanyLogos,
FAQSection,
Features,
InstallSection,
Testimonials,
CTASection,
OptionOverviewSection,
PillarsSection,
UseExamplesSection,
} from "@/components/homepage"
import { EXTERNAL_LINKS } from "@/lib/constants"
import { ArrowRight } from "lucide-react"
@ -20,70 +16,67 @@ import { StructuredData } from "@/components/structured-data"
export const revalidate = 3600
export default async function Home() {
const downloads = await getVSCodeDownloads()
return (
<>
<StructuredData />
<section className="relative flex h-[calc(125vh-theme(spacing.12))] items-center overflow-hidden md:h-[calc(80svh-theme(spacing.12))]">
<AnimatedBackground />
<div className="container relative flex items-center h-full z-10 mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid h-full relative gap-8 md:gap-12 lg:grid-cols-2 lg:gap-16">
<div className="flex flex-col px-4 justify-center space-y-6 sm:space-y-8">
<div>
<h1 className="text-4xl font-bold tracking-tight mt-8 text-center md:text-left md:text-4xl lg:text-5xl lg:mt-0">
The AI dev team that gets things done.
</h1>
<p className="mt-4 max-w-md text-lg text-muted-foreground text-center md:text-left sm:mt-6">
Roo&apos;s specialized modes stay on task and ship great code. Open source and works
with any model.
</p>
</div>
<div className="flex flex-col space-y-3 sm:flex-row sm:space-x-4 sm:space-y-0">
<Button
size="lg"
className="w-full hover:bg-gray-200 dark:bg-white dark:text-black sm:w-auto">
<a
href="https://marketplace.visualstudio.com/items?itemName=RooVeterinaryInc.roo-cline"
target="_blank"
className="flex w-full items-center justify-center">
Install VS Code Extension
<ArrowRight className="ml-2" />
</a>
</Button>
<Button
variant="outline"
size="lg"
className="w-full sm:w-auto bg-white/20 dark:bg-white/10 backdrop-blur-sm border border-black/40 dark:border-white/30 hover:border-blue-400 hover:bg-white/30 dark:hover:bg-white/20 hover:shadow-[0_0_20px_rgba(59,130,246,0.5)] transition-all duration-300">
<a
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP}
target="_blank"
className="flex w-full items-center justify-center">
Try Cloud
<ArrowRight className="ml-2" />
</a>
</Button>
</div>
<CompanyLogos />
<section className="relative flex flex-col items-center overflow-hidden pt-20 pb-12 md:pt-32 md:pb-16">
<div className="absolute inset-y-0 left-1/2 h-full w-full max-w-[1200px] -translate-x-1/2 z-1">
<div className="absolute left-1/2 top-1/2 h-[400px] w-full -translate-x-1/2 -translate-y-1/2 rounded-full bg-violet-500/10 dark:bg-violet-700/20 blur-[140px]" />
</div>
<div className="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8 flex flex-col items-center text-center">
<h1 className="text-3xl md:text-4xl font-bold tracking-tight text-foreground max-w-4xl mb-6">
Your AI Software Engineering Team is here.
<br />
<span className="text-muted-foreground">Interactive in the IDE, autonomous in the cloud.</span>
</h1>
<div className="mt-2 max-w-3xl text-lg text-muted-foreground mb-10 space-y-3">
<p>
Use the <strong className="text-nowrap">Roo Code Extension</strong> on your computer for
full control, or delegate work to your{" "}
<strong className="text-nowrap">Roo Code Cloud Agents</strong> from the web, Slack, Github
or wherever your team is.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 mb-16">
<div className="flex flex-col items-center gap-2">
<Button size="xl" className="w-full">
<a
href={EXTERNAL_LINKS.MARKETPLACE}
target="_blank"
rel="noreferrer"
className="flex items-center justify-center">
Install VS Code Extension
<ArrowRight className="ml-2 size-5" />
</a>
</Button>
<span className="text-xs text-muted-foreground">Free and Open Source</span>
</div>
<div className="relative flex items-center mx-auto h-full mt-8 lg:mt-0">
<div className="flex items-center justify-center">
<CodeExample />
</div>
<div className="flex flex-col items-center gap-2">
<Button size="xl" className="w-full">
<a
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP_HOME}
className="flex items-center justify-center">
Try Cloud for Free
<ArrowRight className="ml-2 size-5" />
</a>
</Button>
<span className="text-xs text-muted-foreground">No credit card needed</span>
</div>
</div>
<div className="mb-12 px-4">
<CompanyLogos />
</div>
</div>
</section>
<div id="product">
<Features />
</div>
<div id="testimonials">
<Testimonials />
</div>
<div id="faq">
<FAQSection />
</div>
<InstallSection downloads={downloads} />
<PillarsSection />
<OptionOverviewSection />
<UseExamplesSection />
<Testimonials />
<FAQSection />
<CTASection />
</>
)
}

View file

@ -33,8 +33,9 @@ export const content: AgentPageContent = {
],
},
cta: {
buttonText: "Start 14-day Free Trial",
disclaimer: "(cancel anytime)",
buttonText: "Try now for free",
disclaimer: "",
tracking: "&agent=pr-fixer",
},
},
howItWorks: {
@ -89,6 +90,6 @@ export const content: AgentPageContent = {
cta: {
heading: "Ship fixes, not follow-ups.",
description: "Let Roo Code's PR Fixer turn your review feedback into clean, ready-to-merge commits.",
buttonText: "Start 14-day Free Trial",
buttonText: "Try now for free",
},
}

View file

@ -1,17 +1,16 @@
import { Users, Building2, ArrowRight, Star, LucideIcon, Check, Cloud, PlugZap } from "lucide-react"
import { Users, ArrowRight, LucideIcon, Check, SquareTerminal, CornerRightDown, Cloud } from "lucide-react"
import type { Metadata } from "next"
import Link from "next/link"
import { Button } from "@/components/ui"
import { AnimatedBackground } from "@/components/homepage"
import { ContactForm } from "@/components/enterprise/contact-form"
import { SEO } from "@/lib/seo"
import { ogImageUrl } from "@/lib/og"
import { EXTERNAL_LINKS } from "@/lib/constants"
const TITLE = "Roo Code Cloud Pricing"
const TITLE = "Roo Code Pricing"
const DESCRIPTION =
"Simple, transparent pricing for Roo Code Cloud. The VS Code extension is free forever. Choose the cloud plan that fits your needs."
"Simple, transparent pricing for all Roo Code products. The VS Code extension is free forever. Choose the cloud plan that fits your needs."
const OG_DESCRIPTION = ""
const PATH = "/pricing"
@ -61,6 +60,7 @@ interface PricingTier {
name: string
icon: LucideIcon
price: string
priceSuffix: string
period?: string
creditPrice?: string
trial?: string
@ -70,60 +70,57 @@ interface PricingTier {
cta: {
text: string
href?: string
isContactForm?: boolean
}
}
const pricingTiers: PricingTier[] = [
{
name: "Cloud Free",
icon: Cloud,
price: "$0",
description: "For folks just getting started",
features: [
"Token usage analytics",
"Access to the Roo Code Cloud Provider, including early access to free stealth models",
"Follow your tasks from anywhere",
"Share tasks with friends and co-workers",
"Community support",
],
name: "VS Code Extension",
icon: SquareTerminal,
price: "Free",
priceSuffix: "inference",
description: "The best local coding agent",
features: ["Unlimited local use", "Bring your own model", "Powerful, extensible modes", "Community support"],
cta: {
text: "Get started",
href: EXTERNAL_LINKS.CLOUD_APP_SIGNUP,
text: "Install Now",
href: EXTERNAL_LINKS.MARKETPLACE,
},
},
{
name: "Cloud Pro",
icon: Star,
price: "$20",
name: "Cloud Free",
icon: Cloud,
price: "$0",
period: "/mo",
trial: "Free for 14 days, then",
priceSuffix: "credits",
creditPrice: `$${PRICE_CREDITS}`,
description: "For pro Roo coders",
featuresIntro: "Everything in Free +",
description: "For AI-forward engineers",
featuresIntro: "Go beyond the extension with",
features: [
"Cloud Agents: Coder, Explainer, Planner, Reviewer, Fixer and more",
"Start tasks from Slack",
"Roomote Control: Start, stop and control extension tasks from anywhere",
"Paid support",
"Access to Cloud Agents: fully autonomous development you can call from Slack, Github and the web",
"Access to the Roo Code Cloud Provider",
"Follow your tasks from anywhere",
"Share tasks with friends and co-workers",
"Token usage analytics",
"Professional support",
],
cta: {
text: "Get started",
href: EXTERNAL_LINKS.CLOUD_APP_SIGNUP + "?redirect_url=/billing",
text: "Sign up",
href: EXTERNAL_LINKS.CLOUD_APP_SIGNUP,
},
},
{
name: "Cloud Team",
icon: Users,
price: "$99",
priceSuffix: "credits",
period: "/mo",
creditPrice: `$${PRICE_CREDITS}`,
trial: "Free for 14 days, then",
description: "For AI-forward teams",
featuresIntro: "Everything in Pro +",
featuresIntro: "Everything in Free +",
features: ["Unlimited users (no per-seat cost)", "Shared configuration & policies", "Centralized billing"],
cta: {
text: "Get started",
text: "Sign up",
href: EXTERNAL_LINKS.CLOUD_APP_SIGNUP + "?redirect_url=/billing",
},
},
@ -135,70 +132,43 @@ export default function PricingPage() {
<AnimatedBackground />
{/* Hero Section */}
<section className="relative overflow-hidden pt-16 pb-12">
<section className="relative overflow-hidden pt-12 pb-10">
<div className="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h1 className="text-5xl font-bold tracking-tight">Roo Code Cloud Pricing</h1>
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
Simple, transparent pricing that scales with your needs.
<br />
No inference markups. Free 14-day trials to kick the tires.
<h1 className="text-5xl font-bold tracking-tight">Roo Code Pricing</h1>
<p className="mt-4 text-lg text-muted-foreground">
For all of our products: the Roo Code VS Code Extension, Roo Code Cloud and the Roo Code
Cloud inference Provider.
</p>
</div>
</div>
</section>
<div className="mx-6 md:mx-auto max-w-6xl">
<div className="rounded-xl p-4 mb-8 text-center bg-gradient-to-r from-blue-500/10 via-cyan-500/10 to-purple-500/10 border border-blue-500/20 dark:border-white/20 ">
<p className="text-center">
<strong className="font-semibold">The Roo Code extension is totally free! </strong>
But Cloud takes you so much further.
</p>
</div>
</div>
<div className="mx-6 md:mx-auto max-w-6xl p-7 mb-4 relative flex flex-col justify-start bg-background border rounded-2xl transition-all shadow-none hover:shadow-lg">
<h3 className="text-xl font-semibold flex items-center gap-2 justify-between">
Roo Code Provider
<PlugZap className="size-6" />
</h3>
<div className="text-sm text-muted-foreground space-y-1 mt-2">
<p className="">
On any plan, you can bring your own provider key or use the built-in Roo Code Cloud provider.
</p>
<p className="text-sm text-muted-foreground">
We offer a select mix of tested state of the art closed and open weight LLMs for you to choose,
with no markup.
<Link href="/provider/pricing" className="underline hover:no-underline ml-1">
See detailed pricing
</Link>
</p>
</div>
</div>
{/* Pricing Tiers */}
<section className="">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="mx-auto grid max-w-6xl gap-4 lg:grid-cols-3">
<div className="mx-auto grid max-w-6xl gap-4 md:grid-cols-3 md:px-4">
{pricingTiers.map((tier) => {
const Icon = tier.icon
return (
<div
key={tier.name}
className="relative p-6 flex flex-col justify-start bg-background border rounded-2xl transition-all hover:shadow-lg">
className="relative group p-6 flex flex-col justify-start bg-background rounded-2xl outline outline-2 outline-border/50 hover:outline-8 transition-all shadow-xl hover:shadow-2xl hover:outline-6">
<div className="mb-6">
<div className="flex items-center justify-between">
<h3 className="text-2xl font-bold tracking-tight">{tier.name}</h3>
<Icon className="size-6" />
</div>
<p className="text-sm text-muted-foreground">{tier.description}</p>
<p className="text-sm font-medium">{tier.description}</p>
</div>
<div className="absolute -right-2 -top-4 rounded-full bg-card shadow-md p-4 outline outline-2 outline-border/50 group-hover:scale-105 group-hover:outline-8 transition-all">
<Icon className="size-6" strokeWidth={1.5} />
</div>
<div className="grow mb-8">
<p className="text-sm text-muted-foreground font-light mb-2">
{tier.featuresIntro}&nbsp;
</p>
<ul className="space-y-3 my-0 h-[168px]">
<ul className="space-y-3 my-0 md:h-[192px]">
{tier.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="mt-0.5 h-4 w-4 text-muted-foreground shrink-0" />
@ -210,58 +180,65 @@ export default function PricingPage() {
<p className="text-base font-light">{tier.trial}</p>
<p className="text-xl my-1 tracking-tight font-light">
<p className="text-xl mb-1 tracking-tight font-light">
<strong className="font-bold">{tier.price}</strong>
{tier.period} + prepaid credits
{tier.period} + {tier.priceSuffix}
<CornerRightDown className="inline size-4 ml-1 relative top-0.5" />
</p>
<p className="text-sm text-muted-foreground mb-3">
<p className="text-sm text-muted-foreground mb-5">
{tier.creditPrice && (
<>
Cloud Agents: {tier.creditPrice}/hour if used
Cloud Agents: {tier.creditPrice}/hour in credits
<br />
</>
)}
Inference:{" "}
<Link href="/provider/pricing" className="underline hover:no-underline">
<Link href="/provider" className="underline hover:no-underline">
Roo Provider pricing
</Link>{" "}
or{" "}
<abbr title="Bring Your Own Key" className="cursor-help">
BYOK
credits or{" "}
<abbr title="Bring Your Own Model" className="cursor-help">
BYOM
</abbr>
</p>
{tier.cta.isContactForm ? (
<ContactForm
formType="demo"
buttonText={tier.cta.text}
buttonClassName="w-full transition-all duration-300"
/>
) : (
<Button size="lg" className="w-full transition-all duration-300" asChild>
<Link href={tier.cta.href!} className="flex items-center justify-center">
{tier.cta.text}
</Link>
</Button>
)}
<Button size="lg" className="w-full transition-all duration-300" asChild>
<Link href={tier.cta.href!} className="flex items-center justify-center">
{tier.cta.text}
<ArrowRight />
</Link>
</Button>
{/* <div className="bg-foreground/20 h-8 absolute -bottom-8 left-1/2 w-[1px]" /> */}
<div className="h-[28px] absolute bottom-[-31px] left-1/2 w-[4px] transition-colors bg-gradient-to-b from-transparent to-violet-700/20 group-hover:from-violet-500/50 group-hover:to-violet-500/20" />
</div>
)
})}
</div>
</div>
<div className="mx-auto grid max-w-6xl gap-4 mt-4 relative">
<p className="bg-background border rounded-2xl p-6 text-center text-sm text-muted-foreground">
<Building2 className="inline size-4 mr-2 mb-0.5" />
Need SAML, advanced security, custom integrations or terms? Enterprise is for you.
<Link
href="/enterprise#contact"
className="font-medium ml-1 text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">
Talk to Sales
</Link>
.
</p>
<div className="max-w-6xl mx-auto mt-8 p-7 flex flex-col md:flex-row gap-8 md:gap-4 bg-violet-200/20 outline-violet-700/20 outline outline-1 rounded-2xl transition-all shadow-none">
<div className="md:border-r md:pr-4">
<h3 className="text-lg font-medium mb-1">Roo Code Provider</h3>
<div className="text-sm text-muted-foreground">
<p className="">
On any plan, you can use your own LLM provider API key or use the built-in Roo Code
Cloud provider curated models to work with Roo with no markup, including the
latest Gemini, GPT and Claude. Paid with credits.
<Link href="/provider" className="underline hover:no-underline ml-1">
See per model pricing.
</Link>
</p>
</div>
</div>
<div className="">
<h3 className="text-lg font-medium mb-1">Credits</h3>
<p className="text-sm text-muted-foreground">
Credits are pre-paid, in dollars, and are deducted with usage for inference and Cloud
Agent runs. You&apos;re always in control of your spend, no surprises.
</p>
</div>
</div>
</div>
</section>
@ -337,6 +314,19 @@ export default function PricingPage() {
reflected in your next billing cycle.
</p>
</div>
<div className="rounded-xl border border-border bg-card p-6 md:col-span-2">
<h3 className="font-semibold">
What if I have enterprise-level needs like SAML/SCIM, large-scale deployments, specific
integrations and custom terms?
</h3>
<p className="mt-2 text-sm text-muted-foreground">
We have an Enterprise plan which can be a fit. Please{" "}
<Link href="/enterprise#contact" className="underline hover:no-underline">
reach out to our sales team
</Link>{" "}
to discuss it.
</p>
</div>
</div>
<div className="mt-12 text-center">

View file

@ -1,10 +1,10 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { ModelCard } from "./components/model-card"
import { ModelCard } from "./pricing/components/model-card"
import { Model, ModelWithTotalPrice, ModelsResponse, SortOption } from "@/lib/types/models"
import Link from "next/link"
import { ChevronDown, CircleX, Loader, LoaderCircle, Search } from "lucide-react"
import { ChevronDown, CircleX, Cloud, Loader, LoaderCircle, Puzzle, Search } from "lucide-react"
const API_URL = "https://api.roocode.com/proxy/v1/models?include_paid=true"
@ -13,28 +13,29 @@ const faqs = [
question: "What are AI model providers?",
answer: "AI model providers offer various language models with different capabilities and pricing.",
},
{
question: "How is pricing calculated?",
answer: "Pricing is based on token usage for input and output, measured per million tokens, like pretty much any other provider out there.",
},
{
question: "What is the Roo Code Cloud Provider?",
answer: (
<>
<p>This is our very own model provider, optimized to work seamlessly with Roo Code Cloud.</p>
<p>
It offers a selection of state-of-the-art LLMs (both closed and open weight) we know work well with
Roo for you to choose, with no markup.
</p>
<p>
We also often feature 100% free models which labs share with us for the community to use and provide
feedback.
</p>
<p>You don&apos;t have to use it to use Roo Code, but it&apos;s the easiest way to do it.</p>
</>
),
},
{
question: "But how much does the Roo Code Cloud service cost?",
question: "Do I have to use the Roo Code Cloud Provider to use the Roo Code products?",
answer: "Not at all! You can bring your own provider key, no problem. This is just meant to make it easier.",
},
{
question: "How is pricing calculated?",
answer: "Pricing is based on token usage for input and output, measured per million tokens, like pretty much any other provider out there.",
},
{
question: "How is my data treated?",
answer: "The Roo Code Cloud provider doesn't keep any of your data, the service only aims to make it easier to use Roo Code. Each model vendor has their own privacy policy though, and usually free models use your data for training, so keep that in mind.",
},
{
question: "How much does the Roo Code Cloud service cost?",
answer: (
<>
Our{" "}
@ -57,7 +58,7 @@ function enrichModelWithTotalPrice(model: Model): ModelWithTotalPrice {
}
}
export default function ProviderPricingPage() {
export default function ProviderPage() {
const [models, setModels] = useState<ModelWithTotalPrice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -133,13 +134,22 @@ export default function ProviderPricingPage() {
<section className="relative overflow-hidden py-16">
<div className="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">
Roo Code Cloud Provider Pricing
</h1>
<p className="mx-auto mt-4 max-w-2xl md:text-lg text-muted-foreground">
See pricing and features for all models we offer in our selection.
<br />
You can always bring your own key (
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">The Roo Code Cloud Provider</h1>
<p className="mx-auto mt-4 max-w-3xl md:text-lg text-muted-foreground">
The easiest way to use Roo Code (in the{" "}
<Link href="/cloud" className="underline hover:no-underline">
<Cloud className="inline size-5 mx-1 -mt-0.5" />
Cloud
</Link>{" "}
or the{" "}
<Link href="/extension" className="underline hover:no-underline">
<Puzzle className="inline size-5 mx-1 -mt-0.5" />
Extension
</Link>
) with the best free and paid models no separate accounts, no messing with API keys.
</p>
<p className="mx-auto mt-2 max-w-2xl md:text-lg text-muted-foreground">
And you can always bring your own key (
<Link href="#faq" className="underline hover:no-underline">
FAQ
</Link>
@ -240,7 +250,7 @@ export default function ProviderPricingPage() {
</div>
<div className="mx-auto mt-12 grid max-w-5xl gap-8 md:grid-cols-2">
{faqs.map((faq, index) => (
<div key={index} className="rounded-lg border border-border bg-card p-6">
<div key={index} className="rounded-2xl border border-border bg-card p-6">
<h3 className="font-semibold">{faq.question}</h3>
<p className="mt-2 text-sm text-muted-foreground">{faq.answer}</p>
</div>

View file

@ -32,8 +32,9 @@ export const content: AgentPageContent = {
],
},
cta: {
buttonText: "Start 14-day Free Trial",
disclaimer: "(cancel anytime)",
buttonText: "Try now for free",
disclaimer: "",
tracking: "&agent=reviewer",
},
},
howItWorks: {
@ -87,6 +88,6 @@ export const content: AgentPageContent = {
cta: {
heading: "Ready for better code reviews?",
description: "Start finding the issues that matter with AI-powered reviews built for depth, not cost-cutting.",
buttonText: "Start 14-day Free Trial",
buttonText: "Try now for free",
},
}

View file

@ -32,8 +32,9 @@ export const content: AgentPageContent = {
],
},
cta: {
buttonText: "Start 14-day Free Trial",
disclaimer: "(cancel anytime)",
buttonText: "Try now for free",
disclaimer: "",
tracking: "&agent=reviewer",
},
},
howItWorks: {
@ -87,6 +88,6 @@ export const content: AgentPageContent = {
cta: {
heading: "Ready for better code reviews?",
description: "Start finding the issues that matter with AI-powered reviews built for depth, not cost-cutting.",
buttonText: "Start 14-day Free Trial",
buttonText: "Try now for free",
},
}

View file

@ -16,8 +16,7 @@ import Image from "next/image"
import Link from "next/link"
import { Button } from "@/components/ui"
import { AnimatedBackground } from "@/components/homepage"
import { AgentCarousel } from "@/components/reviewer/agent-carousel"
import { AnimatedBackground, UseExamplesSection } from "@/components/homepage"
import { EXTERNAL_LINKS } from "@/lib/constants"
import { type AgentPageContent, type IconName } from "./agent-page-content"
@ -93,7 +92,7 @@ export function AgentLandingContent({ content }: { content: AgentPageContent })
className="w-full sm:w-auto backdrop-blur-sm border hover:shadow-[0_0_20px_rgba(59,130,246,0.5)] transition-all duration-300"
asChild>
<a
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP_PRO}
href={`${EXTERNAL_LINKS.CLOUD_APP_SIGNUP_PRO}${content.hero.cta.tracking}`}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center justify-center">
@ -127,6 +126,9 @@ export function AgentLandingContent({ content }: { content: AgentPageContent })
{/* How It Works Section */}
<section className="relative overflow-hidden border-t border-border py-32">
<div className="absolute inset-y-0 left-1/2 h-full w-full max-w-[1200px] -translate-x-1/2 z-1">
<div className="absolute left-1/2 top-1/2 h-[400px] w-full -translate-x-1/2 -translate-y-1/2 rounded-full bg-violet-500/10 dark:bg-violet-700/20 blur-[140px]" />
</div>
<div className="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8">
<div className="mx-auto mb-12 md:mb-24 max-w-5xl text-center">
<div>
@ -161,6 +163,9 @@ export function AgentLandingContent({ content }: { content: AgentPageContent })
{/* Why Better Section */}
<section className="relative overflow-hidden border-t border-border py-32">
<div className="absolute inset-y-0 left-1/2 h-full w-full max-w-[1200px] -translate-x-1/2 z-1">
<div className="absolute left-1/2 top-1/2 h-[400px] w-full -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-500/10 dark:bg-blue-700/20 blur-[140px]" />
</div>
<div className="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8">
<div className="mx-auto mb-12 md:mb-24 max-w-5xl text-center">
<div>
@ -197,8 +202,7 @@ export function AgentLandingContent({ content }: { content: AgentPageContent })
</div>
</section>
{/* Agent Carousel */}
<AgentCarousel currentAgent={content.agentName} />
<UseExamplesSection agentTitle={true} />
{/* CTA Section */}
<section className="py-20">
@ -214,11 +218,11 @@ export function AgentLandingContent({ content }: { content: AgentPageContent })
className="bg-black text-white hover:bg-gray-800 hover:shadow-lg hover:shadow-black/20 dark:bg-white dark:text-black dark:hover:bg-gray-200 dark:hover:shadow-white/20 transition-all duration-300"
asChild>
<a
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP_PRO}
href={`${EXTERNAL_LINKS.CLOUD_APP_SIGNUP_PRO}${content.hero.cta.tracking}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center">
{content.cta.buttonText}
{content.hero.cta.buttonText}
<ArrowRight className="ml-2 h-4 w-4" />
</a>
</Button>

View file

@ -21,7 +21,6 @@ export type IconName =
* serialization from Server Components to Client Components.
*/
export interface AgentPageContent {
/** The agent name used for the carousel display */
agentName: string
hero: {
/** Optional icon name to display in the hero section */
@ -45,6 +44,7 @@ export interface AgentPageContent {
cta: {
buttonText: string
disclaimer: string
tracking: string
}
}
howItWorks: {

View file

@ -13,7 +13,7 @@ import { EXTERNAL_LINKS } from "@/lib/constants"
import { useLogoSrc } from "@/lib/hooks/use-logo-src"
import { ScrollButton } from "@/components/ui"
import ThemeToggle from "@/components/chromes/theme-toggle"
import { ChevronDown, Cloud, X } from "lucide-react"
import { Brain, ChevronDown, Cloud, Puzzle, X } from "lucide-react"
interface NavBarProps {
stars: string | null
@ -25,7 +25,7 @@ export function NavBar({ stars, downloads }: NavBarProps) {
const logoSrc = useLogoSrc()
return (
<header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-md">
<header className="sticky font-light top-0 z-50 border-b border-border bg-background/80 backdrop-blur-md">
<div className="container flex h-16 items-center justify-between px-4 sm:px-6 lg:px-8">
<div className="flex items-center">
<Link href="/" className="flex items-center">
@ -34,94 +34,117 @@ export function NavBar({ stars, downloads }: NavBarProps) {
</div>
{/* Desktop Navigation */}
<nav className="grow ml-6 hidden text-sm font-medium md:flex md:items-center">
<ScrollButton
targetId="product"
className="text-muted-foreground px-4 py-6 transition-transform duration-200 hover:scale-105 hover:text-foreground max-lg:hidden">
Extension
</ScrollButton>
<Link
href="/cloud"
className="text-muted-foreground px-4 py-6 transition-transform duration-200 hover:scale-105 hover:text-foreground">
Cloud
</Link>
<a
href={EXTERNAL_LINKS.DOCUMENTATION}
target="_blank"
className="text-muted-foreground px-4 py-6 transition-transform duration-200 hover:scale-105 hover:text-foreground">
Docs
</a>
<Link
href="/pricing"
className="text-muted-foreground px-4 py-6 transition-transform duration-200 hover:scale-105 hover:text-foreground">
Pricing
</Link>
<nav className="grow ml-6 hidden text-sm md:flex md:items-center">
{/* Product Dropdown */}
<div className="relative group">
<button className="flex items-center px-4 py-6 gap-1 transition-transform duration-200 hover:scale-105 hover:text-foreground">
Product
<ChevronDown className="size-3 ml-1 mt-0.5" />
</button>
<div className="absolute left-0 top-12 mt-2 w-[260px] rounded-md border border-border bg-background py-1 shadow-lg opacity-0 -translate-y-2 pointer-events-none group-hover:opacity-100 group-hover:translate-y-0 group-hover:pointer-events-auto transition-all duration-200">
<Link
href="/extension"
className="block px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-foreground">
<Puzzle className="size-3 inline mr-2 -mt-0.5" />
Roo Code VS Code Extension
</Link>
<Link
href="/cloud"
className="block px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-foreground">
<Cloud className="size-3 inline mr-2 -mt-0.5" />
Roo Code Cloud
</Link>
<Link
href="/provider"
className="block px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-foreground">
<Brain className="size-3 inline mr-2 -mt-0.5" />
Roo Code Cloud Provider
</Link>
</div>
</div>
{/* Resources Dropdown */}
<div className="relative group">
<button className="flex items-center px-4 py-6 gap-1 text-muted-foreground transition-transform duration-200 hover:scale-105 hover:text-foreground">
<button className="flex items-center px-4 py-6 gap-1 transition-transform duration-200 hover:scale-105 hover:text-foreground">
Resources
<ChevronDown className="size-3" />
<ChevronDown className="size-3 ml-1 mt-0.5" />
</button>
{/* Dropdown Menu */}
<div className="absolute left-0 top-12 mt-2 w-40 rounded-md border border-border bg-background py-1 shadow-lg opacity-0 -translate-y-2 pointer-events-none group-hover:opacity-100 group-hover:translate-y-0 group-hover:pointer-events-auto transition-all duration-200">
<ScrollButton
targetId="faq"
className="block px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">
className="block px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-foreground">
FAQ
</ScrollButton>
<Link
href="/evals"
className="block px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">
className="block px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-foreground">
Evals
</Link>
<a
href={EXTERNAL_LINKS.DISCORD}
target="_blank"
rel="noopener noreferrer"
className="block px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">
className="block px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-foreground">
Discord
</a>
<a
href={EXTERNAL_LINKS.SECURITY}
target="_blank"
rel="noopener noreferrer"
className="block px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="block px-4 py-2 text-sm transition-colors hover:bg-accent hover:text-foreground"
onClick={() => setIsMenuOpen(false)}>
Trust Center
</a>
</div>
</div>
<a
href={EXTERNAL_LINKS.DOCUMENTATION}
target="_blank"
className="px-4 py-6 transition-transform duration-200 hover:scale-105 hover:text-foreground">
Docs
</a>
<Link
href="/pricing"
className="px-4 py-6 transition-transform duration-200 hover:scale-105 hover:text-foreground">
Pricing
</Link>
</nav>
<div className="hidden md:flex md:items-center md:space-x-4">
<div className="flex flex-row space-x-2">
<div className="hidden md:flex md:items-center md:space-x-4 flex-shrink-0 font-medium">
<div className="flex flex-row space-x-2 flex-shrink-0">
<ThemeToggle />
<Link
href={EXTERNAL_LINKS.GITHUB}
target="_blank"
className="hidden items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground md:flex">
className="hidden items-center gap-1.5 text-sm hover:text-foreground md:flex whitespace-nowrap">
<RxGithubLogo className="h-4 w-4" />
{stars !== null && <span>{stars}</span>}
</Link>
</div>
<a
href={EXTERNAL_LINKS.CLOUD_APP_LOGIN}
target="_blank"
rel="noopener noreferrer"
className="hidden items-center gap-1.5 rounded-md py-2 text-sm border border-primary-background px-4 text-primary-background transition-all duration-200 hover:shadow-lg hover:scale-105 lg:flex">
Log in
</a>
<a
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP_HOME}
target="_blank"
rel="noopener noreferrer"
className="hidden items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground transition-all duration-200 hover:shadow-lg hover:scale-105 md:flex">
Sign Up
</a>
<Link
href={EXTERNAL_LINKS.MARKETPLACE}
target="_blank"
className="hidden items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-all duration-200 hover:shadow-lg hover:scale-105 md:flex">
className="hidden items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground transition-all duration-200 hover:shadow-lg hover:scale-105 md:flex whitespace-nowrap">
<VscVscode className="-mr-[2px] mt-[1px] h-4 w-4" />
<span>
Install <span className="font-black max-lg:text-xs">&middot;</span>
</span>
{downloads !== null && <span>{downloads}</span>}
</Link>
<a
href={EXTERNAL_LINKS.CLOUD_APP_LOGIN}
target="_blank"
rel="noopener noreferrer"
className="hidden items-center gap-1.5 rounded-md py-2 text-sm border border-primary-background px-4 font-medium text-primary-background transition-all duration-200 hover:shadow-lg hover:scale-105 md:flex">
<Cloud className="inline h-4 w-4" />
Log in
</a>
</div>
{/* Mobile Menu Button */}
@ -141,18 +164,6 @@ export function NavBar({ stars, downloads }: NavBarProps) {
<nav className="flex flex-col justify-between h-full pb-16 overflow-y-auto bg-background pointer-events-auto">
{/* Main navigation items */}
<div className="grow-1 py-4 font-semibold text-lg">
<ScrollButton
targetId="product"
className="block w-full p-5 py-3 text-left text-foreground active:opacity-50"
onClick={() => setIsMenuOpen(false)}>
Extension
</ScrollButton>
<Link
href="/cloud"
className="block w-full p-5 text-left text-foreground active:opacity-50"
onClick={() => setIsMenuOpen(false)}>
Cloud
</Link>
<a
href={EXTERNAL_LINKS.DOCUMENTATION}
target="_blank"
@ -167,6 +178,31 @@ export function NavBar({ stars, downloads }: NavBarProps) {
Pricing
</Link>
{/* Product Section */}
<div className="mt-4 w-full">
<div className="px-5 pb-2 pt-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Product
</div>
<Link
href="/extension"
className="block w-full p-5 py-3 text-left text-foreground active:opacity-50"
onClick={() => setIsMenuOpen(false)}>
Roo Code VS Code Extension
</Link>
<Link
href="/cloud"
className="block w-full p-5 py-3 text-left text-foreground active:opacity-50"
onClick={() => setIsMenuOpen(false)}>
Roo Code Cloud
</Link>
<Link
href="/provider"
className="block w-full p-5 py-3 text-left text-foreground active:opacity-50"
onClick={() => setIsMenuOpen(false)}>
Roo Code Cloud Provider
</Link>
</div>
{/* Resources Section */}
<div className="mt-4 w-full">
<div className="px-5 pb-2 pt-4 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
@ -209,7 +245,7 @@ export function NavBar({ stars, downloads }: NavBarProps) {
<Link
href={EXTERNAL_LINKS.GITHUB}
target="_blank"
className="inline-flex items-center gap-2 rounded-md p-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="inline-flex items-center gap-2 rounded-md p-3 text-sm transition-colors hover:bg-accent hover:text-foreground"
onClick={() => setIsMenuOpen(false)}>
<RxGithubLogo className="h-6 w-6" />
{stars !== null && <span>{stars}</span>}
@ -220,21 +256,30 @@ export function NavBar({ stars, downloads }: NavBarProps) {
<Link
href={EXTERNAL_LINKS.MARKETPLACE}
target="_blank"
className="inline-flex items-center gap-2 rounded-md p-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="inline-flex items-center gap-2 rounded-md p-3 text-sm transition-colors hover:bg-accent hover:text-foreground"
onClick={() => setIsMenuOpen(false)}>
<VscVscode className="h-6 w-6" />
{downloads !== null && <span>{downloads}</span>}
</Link>
</div>
<a
href={EXTERNAL_LINKS.CLOUD_APP_LOGIN}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 rounded-lg border border-primary bg-background p-4 mx-4 mb-4 text-base font-semibold text-primary"
onClick={() => setIsMenuOpen(false)}>
<Cloud className="h-5 w-5" />
Log in
</a>
<div className="flex gap-2 px-4 pb-4">
<a
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP_HOME}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 rounded-full border border-primary bg-foreground p-4 w-full text-base font-semibold text-background"
onClick={() => setIsMenuOpen(false)}>
Sign up
</a>
<a
href={EXTERNAL_LINKS.CLOUD_APP_LOGIN}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 rounded-full border border-primary bg-background p-4 w-full text-base font-semibold text-primary"
onClick={() => setIsMenuOpen(false)}>
Log in
</a>
</div>
</div>
</nav>
</div>

View file

@ -0,0 +1,109 @@
import { Bot, Settings2, ShieldCheck } from "lucide-react"
export function CloudSection() {
return (
<section className="py-24 bg-muted/30">
<div className="container px-4 mx-auto sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">Asynchronous Engineering.</h2>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Stop watching the cursor. Deploy specialized agents to work while you sleep.
</p>
</div>
{/* Pipeline Diagram Visual */}
<div className="flex flex-wrap items-center justify-center gap-4 mb-20 text-sm font-medium">
<div className="px-4 py-2 bg-background border border-border rounded-lg">Ticket</div>
<div className="w-8 h-[1px] bg-border" />
<div className="px-4 py-2 bg-purple-500/10 border border-purple-500/20 text-purple-500 rounded-lg">
Planner Agent
</div>
<div className="w-8 h-[1px] bg-border" />
<div className="px-4 py-2 bg-blue-500/10 border border-blue-500/20 text-blue-500 rounded-lg">
Coder Agent
</div>
<div className="w-8 h-[1px] bg-border" />
<div className="px-4 py-2 bg-green-500/10 border border-green-500/20 text-green-500 rounded-lg">
GitHub PR
</div>
</div>
<div className="grid md:grid-cols-2 gap-12">
<div className="bg-background p-8 rounded-2xl border border-border shadow-sm">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-blue-500/10 rounded-lg">
<ShieldCheck className="h-6 w-6 text-blue-500" />
</div>
<h3 className="text-xl font-bold">Purpose-Built Agents (Safety)</h3>
</div>
<h4 className="text-lg font-semibold mb-2">Zero Drift via Role Constraints</h4>
<p className="text-muted-foreground mb-6">
Fear of agents going haywire is solved by architecture, not prompt engineering. Cloud Agents
enforce the strict Modes you use locally.
</p>
<ul className="space-y-4">
<li className="flex gap-3 items-start">
<Bot className="h-5 w-5 text-purple-500 mt-0.5" />
<div>
<span className="font-bold">The Planner:</span>
<span className="text-muted-foreground ml-2">
Maps dependencies. Read-Only access.
</span>
</div>
</li>
<li className="flex gap-3 items-start">
<Bot className="h-5 w-5 text-blue-500 mt-0.5" />
<div>
<span className="font-bold">The Builder:</span>
<span className="text-muted-foreground ml-2">
Writes code based on the plan. Scoped file access.
</span>
</div>
</li>
<li className="flex gap-3 items-start">
<Bot className="h-5 w-5 text-green-500 mt-0.5" />
<div>
<span className="font-bold">The Reviewer:</span>
<span className="text-muted-foreground ml-2">
Analyzes diffs. Cannot push to main.
</span>
</div>
</li>
</ul>
</div>
<div className="bg-background p-8 rounded-2xl border border-border shadow-sm">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-purple-500/10 rounded-lg">
<Settings2 className="h-6 w-6 text-purple-500" />
</div>
<h3 className="text-xl font-bold">Orchestrated Configuration</h3>
</div>
<h4 className="text-lg font-semibold mb-2">Optimize Your AI Workforce</h4>
<p className="text-muted-foreground mb-6">
Just as you choose models locally, you configure them for the cloud to balance performance
vs. cost.
</p>
<div className="p-4 bg-muted/50 rounded-lg border border-border">
<div className="text-sm font-mono text-muted-foreground mb-2">Config Example:</div>
<div className="space-y-2">
<div className="flex justify-between items-center p-2 bg-background rounded border border-border">
<span className="font-medium">Planner Agent</span>
<span className="text-xs bg-purple-500/10 text-purple-500 px-2 py-1 rounded">
o1-preview (Reasoning)
</span>
</div>
<div className="flex justify-between items-center p-2 bg-background rounded border border-border">
<span className="font-medium">Unit Test Agent</span>
<span className="text-xs bg-blue-500/10 text-blue-500 px-2 py-1 rounded">
Haiku (Speed/Cost)
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
)
}

View file

@ -7,13 +7,13 @@ const logos = ["Apple", "Netflix", "Microsoft", "Amazon", "ByteDance", "Rakuten"
export function CompanyLogos() {
return (
<div className="mt-14">
<div>
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="text-xs text-muted-foreground text-center mb-2 sm:text-left">
Making devs more productive at
className="text-xs text-muted-foreground mb-2 ">
Helping teams ship more at
</motion.p>
<div className="mt-4 flex flex-wrap items-center gap-6 justify-center sm:justify-start">
{logos.map((logo, index) => (
@ -25,7 +25,7 @@ export function CompanyLogos() {
<Image
width={0}
height={0}
className="h-[18px] w-auto overflow-clip opacity-70 dark:invert"
className="h-[20px] w-auto overflow-clip opacity-70 dark:invert"
src={`/logos/${logo.toLowerCase().replace(/\s+/g, "-")}.svg`}
alt={`${logo} Logo`}
/>

View file

@ -0,0 +1,37 @@
import { Button } from "@/components/ui"
import { ArrowRight, Download } from "lucide-react"
import { EXTERNAL_LINKS } from "@/lib/constants"
export function CTASection() {
return (
<section className="py-24 bg-muted/30 border-t border-border">
<div className="container px-4 mx-auto sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-5xl mb-8">Build faster. Solo or Together.</h2>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Button size="lg" className="w-full sm:w-auto h-12 px-8 text-base">
<a
href={EXTERNAL_LINKS.MARKETPLACE}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2">
<Download className="h-4 w-4" />
Install on VS Code
</a>
</Button>
<Button variant="outline" size="lg" className="w-full sm:w-auto h-12 px-8 text-base">
<a
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2">
Try Cloud for Free
<ArrowRight className="h-4 w-4" />
</a>
</Button>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,82 @@
import { GitMerge, Terminal, MessageSquare } from "lucide-react"
export function EcosystemSection() {
return (
<section className="py-24 bg-background">
<div className="container px-4 mx-auto sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-16">Integrated into your SDLC.</h2>
<div className="relative max-w-4xl mx-auto">
{/* Triangle Connection Lines - Absolute positioned */}
<div className="absolute inset-0 hidden md:block pointer-events-none">
<svg
className="w-full h-full"
viewBox="0 0 800 400"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M400 50 L150 350"
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="2"
strokeDasharray="8 8"
/>
<path
d="M400 50 L650 350"
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="2"
strokeDasharray="8 8"
/>
<path
d="M150 350 L650 350"
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="2"
strokeDasharray="8 8"
/>
</svg>
</div>
<div className="grid md:grid-cols-3 gap-8 relative z-10">
{/* Step 1: Dispatch */}
<div className="flex flex-col items-center">
<div className="w-16 h-16 rounded-2xl bg-blue-500/10 flex items-center justify-center mb-6 border border-blue-500/20">
<MessageSquare className="h-8 w-8 text-blue-500" />
</div>
<div className="text-sm font-bold text-blue-500 mb-2">01. DISPATCH</div>
<h3 className="text-xl font-bold mb-3">Trigger Task</h3>
<p className="text-muted-foreground text-sm max-w-[250px]">
Trigger a task via @Roo in Slack or the VS Code terminal.
</p>
</div>
{/* Step 2: Execute */}
<div className="flex flex-col items-center md:-mt-12">
<div className="w-16 h-16 rounded-2xl bg-purple-500/10 flex items-center justify-center mb-6 border border-purple-500/20">
<Terminal className="h-8 w-8 text-purple-500" />
</div>
<div className="text-sm font-bold text-purple-500 mb-2">02. EXECUTE</div>
<h3 className="text-xl font-bold mb-3">Run Agents</h3>
<p className="text-muted-foreground text-sm max-w-[250px]">
Agents run in isolated, ephemeral docker containers.
</p>
</div>
{/* Step 3: Merge */}
<div className="flex flex-col items-center">
<div className="w-16 h-16 rounded-2xl bg-green-500/10 flex items-center justify-center mb-6 border border-green-500/20">
<GitMerge className="h-8 w-8 text-green-500" />
</div>
<div className="text-sm font-bold text-green-500 mb-2">03. MERGE</div>
<h3 className="text-xl font-bold mb-3">Review PR</h3>
<p className="text-muted-foreground text-sm max-w-[250px]">
The output is always a standard GitHub Pull Request. You review code, not chat logs.
</p>
</div>
</div>
</div>
</div>
</section>
)
}

View file

@ -1,7 +1,17 @@
"use client"
import { motion } from "framer-motion"
import { Shield, Users2, ReplaceAll, Keyboard, LucideIcon, CheckCheck, GitPullRequest } from "lucide-react"
import {
Shield,
Users2,
ReplaceAll,
Keyboard,
LucideIcon,
CheckCheck,
GitPullRequest,
Boxes,
TextSearch,
} from "lucide-react"
import Image from "next/image"
export interface Feature {
@ -21,7 +31,8 @@ export const features: Feature[] = [
{
icon: ReplaceAll,
title: "Model-agnostic",
description: "Bring your own provider key or even run local inference — no markup, lock-in, no restrictions.",
description:
"Use the Roo Code cloud Provider, bring your own provider key or even run local inference — no markup, lock-in, no restrictions.",
logos: ["Anthropic", "OpenAI", "Gemini", "Grok", "Qwen", "Kimi", "Mistral", "Ollama"],
},
{
@ -31,16 +42,27 @@ export const features: Feature[] = [
"Control each action and make Roo as autonomous as you want as you build confidence. Or go YOLO and let it rip.",
},
{
icon: GitPullRequest,
title: "Proudly open source",
icon: Boxes,
title: "Large task coordination",
description:
"Community-driven and fully auditable: no throttling or surprises about what's happening behind the scenes.",
"Orchestrator mode handles large tasks by coordinating tasks for other agents, running for hours and delivering.",
},
{
icon: TextSearch,
title: "Performant with large codebases",
description: "Configurable integrated semantic search for quicker retrieval in large codebases.",
},
{
icon: Keyboard,
title: "Highly customizable",
description:
"Fine-tune settings for Roo to work for you, like inference context, model properties, slash commands and more.",
"Fine-tune settings for Roo to work for you, like inference context, model properties, slash commands and more. Most settings can be global or serialized in your repository.",
},
{
icon: GitPullRequest,
title: "Proudly open source",
description:
"Community-driven and fully auditable: no throttling or surprises about what's happening behind the scenes.",
},
{
icon: Shield,

View file

@ -6,3 +6,9 @@ export * from "./features"
export * from "./install-section"
export * from "./testimonials"
export * from "./whats-new-button"
export * from "./option-overview-section"
export * from "./pillars-section"
export * from "./cloud-section"
export * from "./ecosystem-section"
export * from "./cta-section"
export * from "./use-examples-section"

View file

@ -0,0 +1,99 @@
import { Laptop, Cloud, ArrowRight } from "lucide-react"
import { Button } from "../ui"
import { EXTERNAL_LINKS } from "@/lib/constants"
export function OptionOverviewSection() {
return (
<section className="py-24 bg-background">
<div className="container px-4 mx-auto sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
Different form factors for different ways of working.
</h2>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Roo&apos;s always there to help you get stuff done.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 max-w-5xl mx-auto relative z-1">
<div className="absolute left-1/2 top-[140px] -translate-x-1/2 z-1">
<div className="rounded-full size-[300px] border-[10px] border-dashed animate-[spin_10s_linear_infinite] blur-[5px]" />
</div>
<div className="rounded-3xl bg-card outline outline-border/50 hover:outline-8 shadow-xl p-8 h-full group transition-all hover:shadow-2xl hover:shadow-blue-800/30 relative">
<div className="size-15 p-3 rounded-full flex items-center justify-center shadow-lg absolute -top-4 -right-2 transition-all outline outline-foreground/5 bg-card group group-hover:outline-3 group-hover:scale-105">
<Laptop className="size-8 text-blue-500" strokeWidth={1.5} />
</div>
<h3 className="text-2xl font-bold tracking-tight mb-2">Roo Code VS Code Extension</h3>
<p className="font-semibold text-blue-500 mb-4">For Individual Work</p>
<div className="text-muted-foreground mb-4">
<p>
Run Roo directly in VS Code (or any fork even Cursor!), stay close to the code and
control everything:
</p>
<ul className="list-inside my-4 space-y-1">
<li className="list-disc">Approve every action (or set it to auto-approve)</li>
<li className="list-disc">Manage the context window</li>
<li className="list-disc">Configure every detail</li>
<li className="list-disc">Preview changes live</li>
<li className="list-disc">Stick to your customized editor</li>
<li className="list-disc">Write code by hand (gasp!)</li>
</ul>
<p>
Ideal for real-time debugging or quick iteration where you need full, immediate control.
</p>
</div>
<Button size="lg" variant="default" className="bg-blue-600 hover:bg-blue-600/80">
<a
href={EXTERNAL_LINKS.MARKETPLACE}
target="_blank"
rel="noreferrer"
className="flex items-center justify-center">
Install now
<ArrowRight className="ml-2" />
</a>
</Button>
</div>
<div className="rounded-3xl bg-card outline outline-border/50 hover:outline-8 shadow-xl p-8 h-full group transition-all hover:shadow-2xl hover:shadow-blue-800/30 relative">
<div className="size-15 p-3 rounded-full flex items-center justify-center shadow-lg absolute -top-4 -right-2 transition-all outline outline-foreground/5 bg-card group group-hover:outline-3 group-hover:scale-105">
<Cloud className="size-8 text-violet-500" strokeWidth={1.5} />
</div>
<h3 className="text-2xl font-bold tracking-tight mb-2">Roo Code Cloud</h3>
<div className="font-semibold text-violet-500 mb-4">For Team Work with Agents</div>
<div className="text-muted-foreground mb-4">
<p>
Create your agent team in the Cloud, give them access to Github and start giving them
tasks:
</p>
<ul className="list-inside my-4 space-y-1">
<li className="list-disc">
Use agents like the Planner, Coder, Explainer, Reviewer and Fixer
</li>
<li className="list-disc">Choose your provider and model</li>
<li className="list-disc">
Create tasks from the Web and Slack (more integrations soon)
</li>
<li className="list-disc">Get PR Reviews (and fixes) directly on Github</li>
<li className="list-disc">Collaborate with co-workers</li>
</ul>
<p>
Ideal for kicking projects off, parallelizing execution and looping in the rest of your
team.
</p>
</div>
<Button size="lg" variant="default" className="bg-violet-600 hover:bg-violet-600/80">
<a href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP_HOME} className="flex items-center justify-center">
Try now for free
<ArrowRight className="ml-2" />
</a>
</Button>
</div>
</div>
</div>
</section>
)
}

View file

@ -0,0 +1,202 @@
import { Brain, Keyboard, Shield, Users2, Map, Code, MessageCircleQuestion, Bug, TestTube } from "lucide-react"
import Image from "next/image"
import { Link } from "../ui"
const MODEL_LOGOS = [
"OpenRouter",
"Anthropic",
"OpenAI",
"Gemini",
"Grok",
"Bedrock",
"Moonshot",
"Qwen",
"Kimi",
"Mistral",
"Ollama",
]
const MODE_EXAMPLES = [
{
name: "Architect",
description: "Plans complex changes without making changes.",
icon: Map,
},
{
name: "Code",
description: "Implements, refactors and optimizes code.",
icon: Code,
},
{
name: "Ask",
description: "Explains functionality and program behavior.",
icon: MessageCircleQuestion,
},
{
name: "Debug",
description: "Diagnoses issues, traces failures, and proposes targeted, reliable fixes.",
icon: Bug,
},
{
name: "Test",
description: "Creates and improves performant tests without changing the actual functionality.",
icon: TestTube,
},
]
export function PillarsSection() {
return (
<section className="py-24 bg-muted/30 relative">
<div className="absolute inset-y-0 left-1/2 h-full w-full max-w-[1200px] -translate-x-1/2">
<div className="absolute left-1/2 top-1/2 h-[800px] w-full -translate-x-1/2 -translate-y-1/2 rounded-full bg-blue-500/10 dark:bg-blue-700/20 blur-[140px]" />
</div>
<div className="container px-4 mx-auto sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
To trust an agent, you have to do it on your own terms.
</h2>
<p className="text-xl text-muted-foreground max-w-xl mx-auto">
Roo is designed from the ground up to give you the confidence to do ever more with AI.
</p>
</div>
<div className="flex flex-col md:grid md:grid-cols-8 gap-8">
<div className="relative md:col-span-3 h-full">
<div className="rounded-2xl bg-card outline outline-border/50 hover:outline-8 shadow-lg p-8 h-full group transition-all hover:shadow-2xl">
<div className="absolute -right-3 -top-5 bg-card border shadow-md rounded-full p-3 transition-all group-hover:-top-4 group-hover:-right-2 group-hover:scale-110 group-hover:shadow-xl">
<Brain className="size-8 text-violet-600 shrink-0 mt-1" strokeWidth={1.5} />
</div>
<div>
<h3 className="text-2xl font-bold mb-1">Model-agnostic by design</h3>
<h4 className="font-semibold text-lg">Flexible and future-proof.</h4>
<div className="text-muted-foreground my-4 space-y-1">
<p>
&quot;The best model in the world&quot; changes every other week. Providers
throttle models with no warning. 1st-party coding agents only work with their
own models.
</p>
<p>Roo doesn&apos;t care.</p>
<p>
It works great with 10s of models, from frontier to open weight. Choose from{" "}
<Link href="/provider">the curated selection we offer at-cost</Link> or bring
your own key.
</p>
</div>
<div className="mt-6">
<span className="text-muted-foreground text-sm">
Compatible with dozens of providers
</span>
<div className="mt-4 flex flex-wrap items-center gap-4">
{MODEL_LOGOS.map((logo, index) => (
<Image
key={logo}
width={20}
height={20}
className="size-6 overflow-clip dark:invert"
style={{ opacity: 1.1 - index / MODEL_LOGOS.length }}
src={`/logos/${logo.toLowerCase()}.svg`}
alt={`${logo} Logo`}
/>
))}
</div>
</div>
</div>
</div>
</div>
<div className="relative col-span-5 h-full">
<div className="rounded-2xl bg-card outline outline-border/50 hover:outline-8 shadow-lg p-8 h-full group transition-all hover:shadow-2xl">
<div className="absolute -right-3 -top-5 bg-card border shadow-lg rounded-full p-3 transition-all group-hover:-top-4 group-hover:-right-2 group-hover:scale-110 group-hover:shadow-xl">
<Users2 className="size-8 text-violet-600 shrink-0 mt-1" strokeWidth={1.5} />
</div>
<div>
<h3 className="text-2xl font-bold mb-1">Role-specific Modes</h3>
<h4 className="font-semibold text-lg">On-task and under control.</h4>
<div className="text-muted-foreground my-4 space-y-1">
<p>
As capable as they are, when let loose, LLMs hallucinate, cheat and can cause
real damage.
</p>
<p>
Roo&apos;s Modes keep models focused on a given task and limit their access to
tools which are relevant to their role, keeping the context window clearer and
avoiding surprises.
</p>
<p>
Modes are even smart enough to ask to switch to another when stepping outside
their responsibilities.
</p>
</div>
<div className="mt-6">
<span className="text-muted-foreground text-sm">Some examples</span>
<ul className="flex gap-2 flex-wrap mt-2">
{MODE_EXAMPLES.map((mode) => {
const Icon = mode.icon
return (
<li
key={mode.name}
className="rounded-lg border bg-border/40 w-full md:w-[30%] min-w-[200px] text-sm px-3 py-2 flex gap-1">
<Icon className="text-muted-foreground size-4 shrink-0 mt-0.5" />
<div>
<p className="font-semibold">{mode.name}</p>
<p className="text-muted-foreground text-xs">
{mode.description}
</p>
</div>
</li>
)
})}
</ul>
</div>
</div>
</div>
</div>
<div className="relative col-span-4 h-full">
<div className="rounded-2xl bg-card outline outline-border/50 hover:outline-8 shadow-lg p-8 h-full group transition-all hover:shadow-2xl">
<div className="absolute -right-3 -top-5 bg-card border shadow-lg rounded-full p-3 transition-all group-hover:-top-4 group-hover:-right-2 group-hover:scale-110 group-hover:shadow-xl">
<Keyboard className="size-8 text-violet-600 shrink-0 mt-1" strokeWidth={1.5} />
</div>
<div>
<h3 className="text-2xl font-bold mb-1">Highly configurable</h3>
<h4 className="font-semibold text-lg">Make it fit your workflow.</h4>
<div className="text-muted-foreground my-4 space-y-1">
<p>
Developer tools need to fit like gloves. Highly tweakable,
keyboard-shortcut-heavy gloves.
</p>
<p>We made Roo thoughtfully configurable to fit your workflow as best it can.</p>
</div>
</div>
</div>
</div>
<div className="relative col-span-4 h-full">
<div className="rounded-2xl bg-card outline outline-border/50 hover:outline-8 shadow-lg p-8 h-full group transition-all hover:shadow-2xl">
<div className="absolute -right-3 -top-5 bg-card border shadow-lg rounded-full p-3 transition-all group-hover:-top-4 group-hover:-right-2 group-hover:scale-110 group-hover:shadow-xl">
<Shield className="size-8 text-violet-600 shrink-0 mt-1" strokeWidth={1.5} />
</div>
<div>
<h3 className="text-2xl font-bold mb-1">Secure and transparent</h3>
<h4 className="font-semibold text-lg">Open source from the get go.</h4>
<div className="text-muted-foreground my-4 space-y-1">
<p>
The Roo Code Extension is{" "}
<Link target="_blank" href="https://github.com/Roo-Code-Inc/Roo-Code">
open source
</Link>{" "}
so you can see for yourself exactly what it&apos;s doing and we don&apos;t use
your data for training.
</p>
<p>
Plus we&apos;re fully SOC2 Type 2 compliant and follow industry-standard
security practices.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
)
}

View file

@ -193,11 +193,9 @@ export function Testimonials() {
<div className="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8">
<div className="mx-auto mb-8 md:max-w-2xl text-center">
<h2 className="text-4xl font-bold tracking-tight sm:text-5xl">
Developers <em>really</em> shipping with AI are using Roo Code
More than 1 million people are shipping with Roo.
</h2>
<p className="mt-6 text-lg text-muted-foreground">
Join more than 1M people revolutionizing their workflow worldwide
</p>
<p className="mt-6 text-lg text-muted-foreground">And they have some great things to say.</p>
</div>
<motion.div
@ -247,7 +245,11 @@ export function Testimonials() {
{testimonial.name}
</h3>
<p className="text-sm text-muted-foreground transition-colors duration-300 dark:text-muted-foreground/80">
{testimonial.role} at {testimonial.origin}
{testimonial.role !== "Reviewer" && (
<>
{testimonial.role} at {testimonial.origin}
</>
)}
{testimonial.stars && (
<span className="flex items-center mt-1">
{" "}

View file

@ -0,0 +1,438 @@
"use client"
import { useMemo, useState } from "react"
import { motion, AnimatePresence } from "framer-motion"
import {
LucideIcon,
Pointer,
Slack,
Github,
Code,
GitPullRequest,
Wrench,
Map,
MessageCircleQuestionMark,
CornerDownRight,
ChevronDown,
} from "lucide-react"
import Image from "next/image"
import { Button } from "../ui"
interface UseCase {
role: string
use: string
agent: UseCaseAgent
context: UseCaseSource
}
interface UseCaseSource {
name: string
icon: LucideIcon
}
interface UseCaseAgent {
name: string
icon: LucideIcon
}
interface PositionedUseCase extends UseCase {
layer: 1 | 2 | 3 | 4
position: { x: number; y: number }
scale: number
zIndex: number
avatar: string
}
const SOURCES = {
slack: {
name: "Slack",
icon: Slack,
},
web: {
name: "Web",
icon: Pointer,
},
github: {
name: "Github",
icon: Github,
},
extension: {
name: "Extension",
icon: Code,
},
}
const AGENTS = {
explainer: {
name: "Explainer",
icon: MessageCircleQuestionMark,
},
planner: {
name: "Planner",
icon: Map,
},
coder: {
name: "Coder",
icon: Code,
},
reviewer: {
name: "Reviewer",
icon: GitPullRequest,
},
fixer: {
name: "Fixer",
icon: Wrench,
},
}
const USE_CASES: UseCase[] = [
{
role: "Frontend Developer",
use: "Take Lisa's feedback above and incorporate it into the landing page.",
agent: AGENTS.coder,
context: SOURCES.slack,
},
{
role: "Customer Success",
use: "What could be causing this bug as described by the customer?",
agent: AGENTS.explainer,
context: SOURCES.web,
},
{
role: "Backend Engineer",
use: "Create a migration denormalizing total_cost calculation and backfill the remainder.",
agent: AGENTS.coder,
context: SOURCES.extension,
},
{
role: "Security Engineer",
use: "Do we use any of the libraries mentioned in the thread?",
agent: AGENTS.explainer,
context: SOURCES.slack,
},
{
role: "Designer",
use: "Refactor the button component to use CSS variables",
agent: AGENTS.coder,
context: SOURCES.slack,
},
{
role: "Product Manager",
use: "How big of a change would it be to turn this from a yes/no to have 4 options?",
agent: AGENTS.coder,
context: SOURCES.web,
},
{
role: "QA Engineer",
use: "Write a Playwright test for the login flow failure case, extract existing mocks into shared.",
agent: AGENTS.coder,
context: SOURCES.github,
},
{
role: "DevOps Engineer",
use: "Update the Dockerfile to use Node 20 Alpine.",
agent: AGENTS.fixer,
context: SOURCES.slack,
},
{
role: "Mobile Developer",
use: "Copy what we did in PR #4253 and apply to this component.",
agent: AGENTS.coder,
context: SOURCES.slack,
},
{
role: "Technical Writer",
use: "Generate JSDoc comments for the auth utility functions.",
agent: AGENTS.coder,
context: SOURCES.github,
},
{
role: "Junior Developer",
use: "Review this pull request for potential performance improvements.",
agent: AGENTS.reviewer,
context: SOURCES.github,
},
{
role: "Engineering Manager",
use: "Break down this user profile feature into technical tasks, grouped by skill.",
agent: AGENTS.planner,
context: SOURCES.web,
},
{
role: "Support Engineer",
use: "What's causing this stack trace? The customer is on MacOS 26.1.",
agent: AGENTS.explainer,
context: SOURCES.web,
},
{
role: "Frontend Developer",
use: "Make the navigation menu responsive on mobile devices.",
agent: AGENTS.coder,
context: SOURCES.web,
},
{
role: "Backend Engineer",
use: "Give me two architecture options for the notification system in this PRD.",
agent: AGENTS.planner,
context: SOURCES.web,
},
{
role: "Designer",
use: "Implement the loading spinner animation in CSS.",
agent: AGENTS.coder,
context: SOURCES.web,
},
{
role: "Customer Success",
use: "Write a script to find patterns in these CPU load logs.",
agent: AGENTS.coder,
context: SOURCES.slack,
},
{
role: "Full Stack Dev",
use: "Refactor user_preferences to use named columns instead of a single JSON blob",
agent: AGENTS.coder,
context: SOURCES.extension,
},
{
role: "QA Engineer",
use: "Automate the regression suite for the checkout process.",
agent: AGENTS.coder,
context: SOURCES.extension,
},
{
role: "DevOps Engineer",
use: "Understand why this build error only happens in prod and fix it.",
agent: AGENTS.coder,
context: SOURCES.extension,
},
{
role: "Product Marketer",
use: "What were the 5 most significant PRs merged in the past week?",
agent: AGENTS.explainer,
context: SOURCES.slack,
},
{
role: "Junior Developer",
use: "Explain how useEffect dependency arrays work here.",
agent: AGENTS.explainer,
context: SOURCES.extension,
},
{
role: "Senior Engineer",
use: "Check if this implementation follows the Single Responsibility Principle.",
agent: AGENTS.reviewer,
context: SOURCES.github,
},
]
// Seeded random number generator for consistent layout
function seededRandom(seed: number) {
let value = seed
return () => {
value = (value * 9301 + 49297) % 233280
return value / 233280
}
}
const LAYER_SCALES = {
1: 0.7,
2: 0.85,
3: 1.0,
4: 1.15,
}
function distributeItems(items: UseCase[]): PositionedUseCase[] {
const rng = seededRandom(Math.random() * 12345)
const zones = { rows: 7, cols: 4 }
const zoneWidth = 100 / zones.cols
const zoneHeight = 100 / zones.rows
// Create array of zone indices [0...19] and shuffle them
const zoneIndices = Array.from({ length: items.length }, (_, i) => i)
for (let i = zoneIndices.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1))
const temp = zoneIndices[i]!
zoneIndices[i] = zoneIndices[j]!
zoneIndices[j] = temp
}
return items.map((item, index) => {
// Assign to a random unique zone
const zoneIndex = zoneIndices[index]!
const row = Math.floor(zoneIndex / zones.cols)
const col = zoneIndex % zones.cols
// Distribute layers evenly
const layer = ((index % 4) + 1) as 1 | 2 | 3 | 4
// Calculate base position (center of zone)
const baseX = col * zoneWidth + zoneWidth / 2
const baseY = row * zoneHeight + zoneHeight / 2
// Add jitter (±35% of zone size to keep somewhat contained but messy)
const jitterX = (rng() - 0.5) * zoneWidth * 0.7
const jitterY = (rng() - 0.5) * zoneHeight * 0.7
return {
...item,
avatar: `/illustrations/user-faces/${index + 1}.jpg`,
layer,
position: {
x: baseX + jitterX,
y: baseY + jitterY,
},
scale: LAYER_SCALES[layer],
zIndex: layer,
}
})
}
function UseCaseCardContent({
item,
opacity = 1,
className = "",
}: {
item: UseCase & { avatar: string }
opacity?: number
className?: string
}) {
const ContextIcon: LucideIcon = item.context.icon
return (
<div
className={`rounded-xl outline outline-border/50 bg-card/80 backdrop-blur-sm p-3 md:p-4 shadow-xl transition-all hover:shadow-xl hover:outline-8 ${className}`}>
<div
className="text-sm flex items-center gap-2 font-medium text-violet-600 mb-1"
style={{ opacity: opacity }}>
<Image
src={item.avatar}
className="size-6 rounded-full outline-1 outline-border"
alt=""
width={18}
height={18}
unoptimized
/>
<span className="text-nowrap">{item.role}</span>
</div>
<div
className="text-[0.7em] flex flex-wrap items-center gap-1 text-muted-foreground mb-1"
style={{ opacity: opacity }}>
<CornerDownRight className="size-4 shrink-0 ml-3 -mt-1" />
<span className="text-nowrap font-mono">To {item.agent.name} Agent</span>
</div>
<div className="text-base font-light leading-tight my-1 ml-8" style={{ opacity: opacity }}>
{item.use}
</div>
<div
className="text-[0.7em] font-light text-muted-foreground leading-tight mt-2 ml-8"
style={{ opacity: opacity }}>
via <ContextIcon strokeWidth={1.5} className="size-3.5 inline ml-1" /> {item.context.name}
</div>
</div>
)
}
function DesktopUseCaseCard({ item }: { item: PositionedUseCase }) {
const opacity = Math.min(1, 0.5 + item.layer / 3)
return (
<motion.div
className="absolute w-[200px] cursor-default group"
style={{
left: `${item.position.x}%`,
top: `${item.position.y}%`,
zIndex: item.zIndex,
width: Math.round(300 + Math.random() * 100),
}}
initial={{ opacity: 0, scale: 0 }}
whileInView={{
opacity: 1,
scale: item.scale,
transition: {
duration: 0.1,
delay: 0, // Stagger by layer
},
}}
whileHover={{
scale: 1.3,
zIndex: 30,
}}
viewport={{ once: true }}
// Use standard CSS transform for the positioning to avoid conflicts with Framer Motion's scale
transformTemplate={({ scale }) => `translate(-50%, -50%) scale(${scale})`}>
<UseCaseCardContent
item={item}
opacity={opacity}
className={item.layer === 4 ? "shadow-lg border-border" : ""}
/>
</motion.div>
)
}
export function UseExamplesSection({ agentTitle = false }: { agentTitle?: boolean }) {
const positionedItems = useMemo(() => distributeItems(USE_CASES), [])
const [showAllMobile, setShowAllMobile] = useState(false)
return (
<section className="pt-24 bg-background overflow-hidden relative">
<div className="absolute inset-y-0 left-1/2 h-full w-full max-w-[1200px] -translate-x-1/2">
<div className="absolute left-1/2 top-1/2 h-[700px] w-full -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/10 blur-[140px]" />
</div>
<div className="container px-4 mx-auto sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-4xl font-bold tracking-tight mb-4">
{agentTitle ? (
<>
Part of the AI team to help your <em>entire</em> human team
</>
) : (
<>
The AI team to help your <em>entire</em> human team
</>
)}
</h2>
<p className="text-xl font-light text-muted-foreground max-w-2xl mx-auto">
Developers, PMs, Designers, Customer Success: everyone moves faster and more independently with
Roo.
</p>
</div>
{/* Mobile: Vertical Staggered List */}
<div className="md:hidden flex flex-col gap-2 px-2 pb-12 max-w-md mx-auto">
<AnimatePresence mode="popLayout">
{positionedItems.slice(0, showAllMobile ? undefined : 8).map((item, index) => (
<motion.div
key={item.use} // Use a unique key for proper animation tracking
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: (index % 8) * 0.1, duration: 0.4 }}
viewport={{ once: true, margin: "-50px" }}
className={`w-[90%] ${index % 2 === 0 ? "self-start" : "self-end"}`}>
<UseCaseCardContent item={item} />
</motion.div>
))}
</AnimatePresence>
{!showAllMobile && (
<div className="text-center mt-8 z-10">
<Button variant="outline" onClick={() => setShowAllMobile(true)}>
More
<ChevronDown />
</Button>
</div>
)}
</div>
{/* Desktop: Positioned Items Container */}
<div className="hidden md:block relative h-[800px] md:min-h-[800px] w-full max-w-6xl mx-auto">
{positionedItems.map((item, index) => (
<DesktopUseCaseCard key={index} item={item} />
))}
</div>
</div>
</section>
)
}

View file

@ -1,172 +0,0 @@
"use client"
import { useEffect } from "react"
import { motion } from "framer-motion"
import useEmblaCarousel from "embla-carousel-react"
import AutoPlay from "embla-carousel-autoplay"
import {
Bug,
FileText,
Gauge,
GitPullRequest,
Languages,
Microscope,
PocketKnife,
TestTube,
Wrench,
type LucideIcon,
} from "lucide-react"
// AI Agent types for the carousel
interface AIAgent {
icon: LucideIcon
name: string
page?: string
}
const aiAgents: AIAgent[] = [
{ icon: GitPullRequest, name: "PR Reviewer", page: "/reviewer" },
{ icon: Wrench, name: "PR Fixer", page: "/pr-fixer" },
{ icon: PocketKnife, name: "Generalist" },
{ icon: Bug, name: "Bug Fixer" },
{ icon: TestTube, name: "Test Engineer" },
{ icon: Microscope, name: "Security Auditor" },
{ icon: Gauge, name: "Performance Optimizer" },
{ icon: FileText, name: "Documentation Writer" },
{ icon: Languages, name: "String Translator" },
]
export function AgentCarousel({ currentAgent = "" }: { currentAgent?: string } = {}) {
const [emblaRef, emblaApi] = useEmblaCarousel(
{
loop: true,
align: "start",
watchDrag: true,
dragFree: false,
containScroll: false,
duration: 10000,
},
[
AutoPlay({
playOnInit: true,
delay: 0,
stopOnInteraction: false,
stopOnMouseEnter: false,
stopOnFocusIn: false,
}),
],
)
// Continuous scrolling effect
useEffect(() => {
if (!emblaApi) return
const autoPlay = emblaApi?.plugins()?.autoPlay as
| {
play?: () => void
}
| undefined
if (autoPlay?.play) {
autoPlay.play()
}
// Set up continuous scrolling
const interval = setInterval(() => {
if (emblaApi) {
emblaApi.scrollNext()
}
}, 30) // Smooth continuous scroll
return () => clearInterval(interval)
}, [emblaApi])
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
duration: 0.6,
ease: [0.21, 0.45, 0.27, 0.9],
},
},
}
// Duplicate the agents array for seamless infinite scroll
const displayAgents = [...aiAgents, ...aiAgents]
return (
<section className="relative overflow-hidden border-t border-border py-32">
<div className="container relative z-10 mx-auto px-4 sm:px-6 lg:px-8">
<div className="mx-auto mb-12 max-w-4xl text-center">
<div>
<h2 className="text-4xl font-bold tracking-tight sm:text-5xl">
The first members of a whole new team
</h2>
<p className="mt-6 text-lg text-muted-foreground">
Architecture, coding, reviewing, testing, debugging, documenting, designing almost
everything we do today is mostly through our agents. Now we&apos;re bringing them to you.
</p>
<p className="mt-2 text-lg text-muted-foreground">
Roo&apos;s {currentAgent} isn&apos;t yet another single-purpose tool to add to your already
complicated stack. It&apos;s the first member of your AI-powered development team. More
agents are shipping soon.
</p>
</div>
</div>
<div className="relative mx-auto md:max-w-[1200px]">
<motion.div
className="relative -mx-4 md:mx-auto max-w-[1400px]"
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}>
{/* Gradient Overlays */}
<div className="absolute inset-y-0 left-0 z-10 w-[10%] bg-gradient-to-r from-background to-transparent pointer-events-none md:w-[15%]" />
<div className="absolute inset-y-0 right-0 z-10 w-[10%] bg-gradient-to-l from-background to-transparent pointer-events-none md:w-[15%]" />
{/* Embla Carousel Container */}
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex pb-4">
{displayAgents.map((agent, index) => {
const Icon = agent.icon
return (
<div
key={`${agent.name}-${index}`}
className="relative min-w-0 flex-[0_0_45%] px-2 md:flex-[0_0_30%] md:px-4 lg:flex-[0_0_15%]">
<div className="group relative py-6 cursor-default">
<div
className="relative flex flex-col items-center justify-center rounded-full w-[150px] h-[150px] border border-border bg-background p-6 transition-all duration-500 ease-out shadow-xl
hover:scale-110 hover:-translate-y-2
hover:shadow-[0_20px_50px_rgba(39,110,226,0.25)] dark:hover:shadow-[0_20px_50px_rgba(59,130,246,0.25)]">
<Icon
strokeWidth={1}
className="size-9 mb-2 text-foreground transition-colors duration-300"
/>
<h3 className="text-center leading-tight tracking-tight transition-colors duration-300 dark:text-foreground">
{agent.page ? (
<a
href={agent.page}
className="text-foreground/90 font-semibold">
{agent.name}
</a>
) : (
<span className="text-foreground/60 font-medium">
{agent.name}
</span>
)}
</h3>
</div>
</div>
</div>
)
})}
</div>
</div>
</motion.div>
</div>
</div>
</section>
)
}

View file

@ -5,12 +5,12 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
default: "bg-primary text-primary-foreground shadow hover:bg-primary/80",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/80",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
@ -18,8 +18,9 @@ const buttonVariants = cva(
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
sm: "h-8 px-3 text-xs",
lg: "h-10 px-8",
xl: "h-14 px-8 text-lg",
icon: "h-9 w-9",
},
},

View file

@ -3,3 +3,4 @@ export * from "./chart"
export * from "./modal"
export * from "./scroll-button"
export * from "./table"
export * from "./link"

View file

@ -0,0 +1,38 @@
import * as React from "react"
import NextLink from "next/link"
import { cn } from "@/lib/utils"
type BaseLinkProps = React.ComponentPropsWithoutRef<typeof NextLink>
type LinkProps = BaseLinkProps & {
newWindow?: boolean
}
const Link = React.forwardRef<React.ElementRef<typeof NextLink>, LinkProps>(
({ className, newWindow = false, target, rel, ...props }, ref) => {
const computedTarget = newWindow ? "_blank" : target
const computedRel = newWindow
? rel
? rel.includes("noreferrer")
? rel
: `${rel} noreferrer`
: "noreferrer"
: rel
return (
<NextLink
ref={ref}
className={cn("underline hover:no-underline", className)}
target={computedTarget}
rel={computedRel}
{...props}
/>
)
},
)
Link.displayName = "Link"
export { Link }
export type { LinkProps }

View file

@ -26,6 +26,7 @@ export const EXTERNAL_LINKS = {
TESTIMONIALS: "https://roocode.com/#testimonials",
CLOUD_APP_LOGIN: "https://app.roocode.com/sign-in",
CLOUD_APP_SIGNUP: "https://app.roocode.com/sign-up",
CLOUD_APP_SIGNUP_HOME: "https://app.roocode.com/sign-up?redirect_url=/cloud-agents/setup",
CLOUD_APP_SIGNUP_PRO: "https://app.roocode.com/sign-up?redirect_url=/cloud-agents/setup",
}

View file

@ -104,13 +104,19 @@ export async function getVSCodeDownloads() {
}
function formatNumber(num: number): string {
// divide by 1000 to convert to "thousands" format,
// multiply by 10, floor the result, then divide by 10 to keep one decimal place.
// if number is 1 million or more, format as millions
if (num >= 1000000) {
const truncated = Math.floor((num / 1000000) * 100) / 100
return truncated.toFixed(2) + "M"
}
// otherwise, format as thousands
const truncated = Math.floor((num / 1000) * 10) / 10
// ensure one decimal is always shown and append "k"
return truncated.toFixed(1) + "k"
// examples:
// console.log(formatNumber(1033400)) -> "1.03M"
// console.log(formatNumber(2500000)) -> "2.50M"
// console.log(formatNumber(337231)) -> "337.2k"
// console.log(formatNumber(23233)) -> "23.2k"
// console.log(formatNumber(2322)) -> "2.3k"

View file

@ -57,7 +57,7 @@
"tar-fs": ">=3.1.1",
"esbuild": ">=0.25.0",
"undici": ">=5.29.0",
"brace-expansion": ">=2.0.2",
"brace-expansion": "^2.0.2",
"form-data": ">=4.0.4",
"bluebird": ">=3.7.2",
"glob": ">=11.1.0"

View file

@ -337,6 +337,11 @@ export class CloudService extends EventEmitter<CloudServiceEvents> implements Di
return this.shareService!.canShareTask()
}
public async canSharePublicly(): Promise<boolean> {
this.ensureInitialized()
return this.shareService!.canSharePublicly()
}
// Lifecycle
public dispose(): void {

View file

@ -47,4 +47,15 @@ export class CloudShareService {
return false
}
}
async canSharePublicly(): Promise<boolean> {
try {
const cloudSettings = this.settingsService.getSettings()?.cloudSettings
// Public sharing requires both enableTaskSharing AND allowPublicTaskSharing to be true
return !!cloudSettings?.enableTaskSharing && cloudSettings?.allowPublicTaskSharing !== false
} catch (error) {
this.log("[ShareService] Error checking if task can be shared publicly:", error)
return false
}
}
}

View file

@ -69,6 +69,8 @@ abstract class BaseTelemetryClient implements TelemetryClient {
public abstract capture(event: TelemetryEvent): Promise<void>
public async captureException(_error: Error, _additionalProperties?: Record<string, unknown>): Promise<void> {}
public setProvider(provider: TelemetryPropertiesProvider): void {
this.providerRef = new WeakRef(provider)
}

View file

@ -188,18 +188,46 @@ export class ExtensionChannel extends BaseChannel<
{ from: RooCodeEventName.TaskPaused, to: ExtensionBridgeEventName.TaskPaused },
{ from: RooCodeEventName.TaskUnpaused, to: ExtensionBridgeEventName.TaskUnpaused },
{ from: RooCodeEventName.TaskSpawned, to: ExtensionBridgeEventName.TaskSpawned },
{ from: RooCodeEventName.TaskDelegated, to: ExtensionBridgeEventName.TaskDelegated },
{ from: RooCodeEventName.TaskDelegationCompleted, to: ExtensionBridgeEventName.TaskDelegationCompleted },
{ from: RooCodeEventName.TaskDelegationResumed, to: ExtensionBridgeEventName.TaskDelegationResumed },
{ from: RooCodeEventName.TaskUserMessage, to: ExtensionBridgeEventName.TaskUserMessage },
{ from: RooCodeEventName.TaskTokenUsageUpdated, to: ExtensionBridgeEventName.TaskTokenUsageUpdated },
] as const
eventMapping.forEach(({ from, to }) => {
// Create and store the listener function for cleanup.
const listener = async (..._args: unknown[]) => {
this.publish(ExtensionSocketEvents.EVENT, {
const listener = async (...args: unknown[]) => {
const baseEvent: {
type: ExtensionBridgeEventName
instance: ExtensionInstance
timestamp: number
} = {
type: to,
instance: await this.updateInstance(),
timestamp: Date.now(),
})
}
let eventToPublish: ExtensionBridgeEvent
// Add payload for delegation events while avoiding `any`
if (to === ExtensionBridgeEventName.TaskDelegationCompleted) {
const [parentTaskId, childTaskId, summary] = args as [string, string, string]
eventToPublish = {
...(baseEvent as unknown as ExtensionBridgeEvent),
payload: { parentTaskId, childTaskId, summary },
} as unknown as ExtensionBridgeEvent
} else if (to === ExtensionBridgeEventName.TaskDelegationResumed) {
const [parentTaskId, childTaskId] = args as [string, string]
eventToPublish = {
...(baseEvent as unknown as ExtensionBridgeEvent),
payload: { parentTaskId, childTaskId },
} as unknown as ExtensionBridgeEvent
} else {
eventToPublish = baseEvent as unknown as ExtensionBridgeEvent
}
this.publish(ExtensionSocketEvents.EVENT, eventToPublish)
}
this.eventListeners.set(from, listener)

View file

@ -120,6 +120,10 @@ describe("ExtensionChannel", () => {
RooCodeEventName.TaskPaused,
RooCodeEventName.TaskUnpaused,
RooCodeEventName.TaskSpawned,
RooCodeEventName.TaskDelegated,
RooCodeEventName.TaskDelegationCompleted,
RooCodeEventName.TaskDelegationResumed,
RooCodeEventName.TaskUserMessage,
RooCodeEventName.TaskTokenUsageUpdated,
]
@ -246,6 +250,116 @@ describe("ExtensionChannel", () => {
undefined,
)
})
it("should forward delegation events to socket", async () => {
await extensionChannel.onConnect(mockSocket)
;(mockSocket.emit as any).mockClear()
const delegatedListeners = eventListeners.get(RooCodeEventName.TaskDelegated)
expect(delegatedListeners).toBeDefined()
expect(delegatedListeners!.size).toBe(1)
const listener = Array.from(delegatedListeners!)[0]
if (listener) {
await (listener as any)("parent-id", "child-id")
}
expect(mockSocket.emit).toHaveBeenCalledWith(
ExtensionSocketEvents.EVENT,
expect.objectContaining({
type: ExtensionBridgeEventName.TaskDelegated,
instance: expect.any(Object),
timestamp: expect.any(Number),
}),
undefined,
)
})
it("should forward TaskDelegationCompleted with correct payload", async () => {
await extensionChannel.onConnect(mockSocket)
;(mockSocket.emit as any).mockClear()
const completedListeners = eventListeners.get(RooCodeEventName.TaskDelegationCompleted)
expect(completedListeners).toBeDefined()
const listener = Array.from(completedListeners!)[0]
if (listener) {
await (listener as any)("parent-1", "child-1", "Summary text")
}
expect(mockSocket.emit).toHaveBeenCalledWith(
ExtensionSocketEvents.EVENT,
expect.objectContaining({
type: ExtensionBridgeEventName.TaskDelegationCompleted,
instance: expect.any(Object),
timestamp: expect.any(Number),
payload: expect.objectContaining({
parentTaskId: "parent-1",
childTaskId: "child-1",
summary: "Summary text",
}),
}),
undefined,
)
})
it("should forward TaskDelegationResumed with correct payload", async () => {
await extensionChannel.onConnect(mockSocket)
;(mockSocket.emit as any).mockClear()
const resumedListeners = eventListeners.get(RooCodeEventName.TaskDelegationResumed)
expect(resumedListeners).toBeDefined()
const listener = Array.from(resumedListeners!)[0]
if (listener) {
await (listener as any)("parent-2", "child-2")
}
expect(mockSocket.emit).toHaveBeenCalledWith(
ExtensionSocketEvents.EVENT,
expect.objectContaining({
type: ExtensionBridgeEventName.TaskDelegationResumed,
instance: expect.any(Object),
timestamp: expect.any(Number),
payload: expect.objectContaining({
parentTaskId: "parent-2",
childTaskId: "child-2",
}),
}),
undefined,
)
})
it("should propagate all three delegation events in order", async () => {
await extensionChannel.onConnect(mockSocket)
;(mockSocket.emit as any).mockClear()
// Trigger TaskDelegated
const delegatedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegated)!)[0]
await (delegatedListener as any)("p1", "c1")
// Trigger TaskDelegationCompleted
const completedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegationCompleted)!)[0]
await (completedListener as any)("p1", "c1", "result")
// Trigger TaskDelegationResumed
const resumedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegationResumed)!)[0]
await (resumedListener as any)("p1", "c1")
// Verify all three events were emitted
const emittedEvents = (mockSocket.emit as any).mock.calls.map((call: any[]) => call[1]?.type)
expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegated)
expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegationCompleted)
expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegationResumed)
// Verify correct order: Delegated → Completed → Resumed
const delegatedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegated)
const completedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegationCompleted)
const resumedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegationResumed)
expect(delegatedIdx).toBeLessThan(completedIdx)
expect(completedIdx).toBeLessThan(resumedIdx)
})
})
describe("Memory Leak Prevention", () => {
@ -257,7 +371,7 @@ describe("ExtensionChannel", () => {
}
// Listeners should still be the same count (not accumulated)
expect(eventListeners.size).toBe(15)
expect(eventListeners.size).toBe(18)
// Each event should have exactly 1 listener
eventListeners.forEach((listeners) => {

View file

@ -0,0 +1,45 @@
# Development overrides - automatically loaded by docker compose
# These settings only apply when running locally for development
#
# For production, use: docker compose -f docker-compose.yml up
# (explicitly exclude override file)
services:
web:
environment:
- NODE_ENV=development
volumes:
# Mount log files so web can access task logs
- /tmp/evals:/tmp/evals:ro
# Mount source code for hot reload in development
- ../../apps/web-evals:/roo/repo/apps/web-evals:delegated
- ../../packages/evals:/roo/repo/packages/evals:delegated
- ../../packages/types:/roo/repo/packages/types:delegated
- ../../packages/ipc:/roo/repo/packages/ipc:delegated
- ../../packages/cloud:/roo/repo/packages/cloud:delegated
# Exclude node_modules from being overwritten
- /roo/repo/node_modules
- /roo/repo/apps/web-evals/node_modules
- /roo/repo/packages/evals/node_modules
- /roo/repo/packages/types/node_modules
- /roo/repo/packages/ipc/node_modules
- /roo/repo/packages/cloud/node_modules
entrypoint: []
command:
- sh
- -c
- |
echo '🚀 Starting evals web service in development mode...'
wait_for_db() {
echo '⏳ Waiting for database...'
until pg_isready -h db -p 5432 -U postgres -d evals_development > /dev/null 2>&1; do
echo '⏳ Database not ready yet, waiting 2 seconds...'
sleep 2
done
echo '✅ Database is ready'
}
wait_for_db
echo '🔄 Running database migrations...'
pnpm --filter @roo-code/evals db:migrate
echo '🌐 Starting Next.js dev server...'
cd /roo/repo/apps/web-evals && npx next dev -p 3446

View file

@ -55,8 +55,11 @@ services:
- "${EVALS_WEB_PORT:-3446}:3446"
environment:
- HOST_EXECUTION_METHOD=docker
- PRODUCTION_DATABASE_URL
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Mount log files so web can access task logs
- /tmp/evals:/tmp/evals:ro
depends_on:
db:
condition: service_healthy

View file

@ -37,22 +37,33 @@ export const runEvals = async (runId: number) => {
const heartbeat = await startHeartbeat(run.id)
const queue = new PQueue({ concurrency: run.concurrency })
const STAGGER_DELAY_MS = 5000
const filteredTasks = tasks.filter((task) => task.finishedAt === null)
const createTaskRunner = (task: (typeof filteredTasks)[number]) => async () => {
try {
if (containerized) {
await processTaskInContainer({ taskId: task.id, jobToken: run.jobToken, logger })
} else {
await processTask({ taskId: task.id, jobToken: run.jobToken, logger })
}
} catch (error) {
logger.error("error processing task", error)
}
}
try {
await queue.addAll(
tasks
.filter((task) => task.finishedAt === null)
.map((task) => async () => {
try {
if (containerized) {
await processTaskInContainer({ taskId: task.id, jobToken: run.jobToken, logger })
} else {
await processTask({ taskId: task.id, jobToken: run.jobToken, logger })
}
} catch (error) {
logger.error("error processing task", error)
}
}),
)
// Add tasks with staggered start times when concurrency > 1
for (let i = 0; i < filteredTasks.length; i++) {
const task = filteredTasks[i]
if (!task) continue
if (run.concurrency > 1 && i > 0) {
await new Promise((resolve) => setTimeout(resolve, STAGGER_DELAY_MS))
}
queue.add(createTaskRunner(task))
}
await queue.onIdle()
logger.info("finishRun")
const result = await finishRun(run.id)

View file

@ -1,4 +1,5 @@
import * as fs from "fs"
import * as fsp from "fs/promises"
import * as path from "path"
import * as os from "node:os"
@ -12,6 +13,7 @@ import {
RooCodeEventName,
IpcMessageType,
EVALS_SETTINGS,
type ToolUsage,
} from "@roo-code/types"
import { IpcClient } from "@roo-code/ipc"
@ -38,6 +40,58 @@ class SubprocessTimeoutError extends Error {
}
}
/**
* Copy conversation history files from VS Code extension storage to the log directory.
* This allows us to preserve the api_conversation_history.json and ui_messages.json
* files for post-mortem analysis alongside the log files.
*/
async function copyConversationHistory({
rooTaskId,
logDir,
language,
exercise,
iteration,
logger,
}: {
rooTaskId: string
logDir: string
language: string
exercise: string
iteration: number
logger: Logger
}): Promise<void> {
// VS Code extension global storage path within the container
const extensionStoragePath = "/roo/.vscode/User/globalStorage/rooveterinaryinc.roo-cline"
const taskStoragePath = path.join(extensionStoragePath, "tasks", rooTaskId)
const filesToCopy = ["api_conversation_history.json", "ui_messages.json"]
for (const filename of filesToCopy) {
const sourcePath = path.join(taskStoragePath, filename)
// Use sanitized exercise name (replace slashes with dashes) for the destination filename
// Include iteration number to handle multiple attempts at the same exercise
const sanitizedExercise = exercise.replace(/\//g, "-")
const destFilename = `${language}-${sanitizedExercise}.${iteration}_${filename}`
const destPath = path.join(logDir, destFilename)
try {
// Check if source file exists
await fsp.access(sourcePath)
// Copy the file
await fsp.copyFile(sourcePath, destPath)
logger.info(`copied ${filename} to ${destPath}`)
} catch (error) {
// File may not exist if task didn't complete properly - this is not fatal
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
logger.info(`${filename} not found at ${sourcePath} - skipping`)
} else {
logger.error(`failed to copy ${filename}:`, error)
}
}
}
}
export const processTask = async ({
taskId,
jobToken,
@ -114,7 +168,7 @@ export const processTaskInContainer = async ({
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const containerName = `evals-task-${taskId}.${attempt}`
const args = [`--name ${containerName}`, ...baseArgs]
const args = [`--name ${containerName}`, `-e EVALS_ATTEMPT=${attempt}`, ...baseArgs]
const isRetry = attempt > 0
if (isRetry) {
@ -172,6 +226,7 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO
const controller = new AbortController()
const cancelSignal = controller.signal
const containerized = isDockerContainer()
const logDir = containerized ? `/var/log/evals/runs/${run.id}` : `/tmp/evals/runs/${run.id}`
let codeCommand = containerized
? `xvfb-run --auto-servernum --server-num=1 code --wait --log trace --disable-workspace-trust --disable-gpu --disable-lcd-text --no-sandbox --user-data-dir /roo/.vscode --password-store="basic" -n ${workspacePath}`
@ -223,6 +278,15 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO
let taskMetricsId: number | undefined
let rooTaskId: string | undefined
let isClientDisconnected = false
// Track accumulated tool usage across task instances (handles rehydration after abort)
const accumulatedToolUsage: ToolUsage = {}
// Promise that resolves when taskMetricsId is set, preventing race conditions
// where TaskTokenUsageUpdated arrives before TaskStarted handler completes
let resolveTaskMetricsReady: () => void
const taskMetricsReady = new Promise<void>((resolve) => {
resolveTaskMetricsReady = resolve
})
const ignoreEvents: Record<"broadcast" | "log", RooCodeEventName[]> = {
broadcast: [RooCodeEventName.Message],
@ -266,7 +330,23 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO
(payload[0].message.say && loggableSays.includes(payload[0].message.say)) ||
payload[0].message.partial !== true)
) {
logger.info(`${eventName} ->`, payload)
// Extract tool name for tool-related messages for clearer logging
let logEventName: string = eventName
if (eventName === RooCodeEventName.Message && payload[0]?.message?.ask === "tool") {
try {
const textJson = JSON.parse(payload[0].message.text ?? "{}")
if (textJson.tool) {
logEventName = `${eventName} (tool: ${textJson.tool})`
}
} catch {
// If parsing fails, use the default event name
}
} else if (eventName === RooCodeEventName.Message && payload[0]?.message?.ask === "command") {
logEventName = `${eventName} (command)`
} else if (eventName === RooCodeEventName.Message && payload[0]?.message?.ask === "completion_result") {
logEventName = `${eventName} (completion_result)`
}
logger.info(`${logEventName} ->`, payload)
}
if (eventName === RooCodeEventName.TaskStarted) {
@ -287,6 +367,9 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO
taskStartedAt = Date.now()
taskMetricsId = taskMetrics.id
rooTaskId = payload[0]
// Signal that taskMetricsId is now ready for other handlers
resolveTaskMetricsReady()
}
if (eventName === RooCodeEventName.TaskToolFailed) {
@ -294,15 +377,46 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO
await createToolError({ taskId: task.id, toolName, error })
}
if (
(eventName === RooCodeEventName.TaskTokenUsageUpdated || eventName === RooCodeEventName.TaskCompleted) &&
taskMetricsId
) {
if (eventName === RooCodeEventName.TaskTokenUsageUpdated || eventName === RooCodeEventName.TaskCompleted) {
// Wait for taskMetricsId to be set by the TaskStarted handler.
// This prevents a race condition where these events arrive before
// the TaskStarted handler finishes its async database operations.
// Note: taskMetricsReady is also resolved on disconnect to prevent deadlock.
await taskMetricsReady
// Guard: taskMetricsReady may have been resolved due to disconnect
// without taskMetricsId being set. Skip metrics update in this case.
if (!taskMetricsId) {
logger.info(`skipping metrics update: taskMetricsId not set (event: ${eventName})`)
return
}
const duration = Date.now() - taskStartedAt
const { totalCost, totalTokensIn, totalTokensOut, contextTokens, totalCacheWrites, totalCacheReads } =
payload[1]
// For both TaskTokenUsageUpdated and TaskCompleted: toolUsage is payload[2]
const incomingToolUsage: ToolUsage = payload[2] ?? {}
// Merge incoming tool usage with accumulated data using MAX strategy.
// This handles the case where a task is rehydrated after abort:
// - Empty rehydrated data won't overwrite existing: max(5, 0) = 5
// - Legitimate restart with additional work is captured: max(5, 8) = 8
// Each task instance tracks its own cumulative values, so we take the max
// to preserve the highest values seen across all instances.
for (const [toolName, usage] of Object.entries(incomingToolUsage)) {
const existing = accumulatedToolUsage[toolName as keyof ToolUsage]
if (existing) {
accumulatedToolUsage[toolName as keyof ToolUsage] = {
attempts: Math.max(existing.attempts, usage.attempts),
failures: Math.max(existing.failures, usage.failures),
}
} else {
accumulatedToolUsage[toolName as keyof ToolUsage] = { ...usage }
}
}
await updateTaskMetrics(taskMetricsId, {
cost: totalCost,
tokensIn: totalTokensIn,
@ -311,14 +425,10 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO
duration,
cacheWrites: totalCacheWrites ?? 0,
cacheReads: totalCacheReads ?? 0,
toolUsage: accumulatedToolUsage,
})
}
if (eventName === RooCodeEventName.TaskCompleted && taskMetricsId) {
const toolUsage = payload[2]
await updateTaskMetrics(taskMetricsId, { toolUsage })
}
if (eventName === RooCodeEventName.TaskAborted) {
taskAbortedAt = Date.now()
}
@ -331,6 +441,10 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO
client.on(IpcMessageType.Disconnect, async () => {
logger.info(`disconnected from IPC socket -> ${ipcSocketPath}`)
isClientDisconnected = true
// Resolve taskMetricsReady to unblock any handlers waiting on it.
// This prevents deadlock if TaskStarted never fired or threw before resolving.
// The handlers check for taskMetricsId being set before proceeding.
resolveTaskMetricsReady()
})
client.sendCommand({
@ -418,9 +532,25 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO
}
}
// Copy conversation history files from VS Code extension storage to the log directory
// for post-mortem analysis. Only do this in containerized mode where we have a known path.
if (containerized && rooTaskId) {
await copyConversationHistory({
rooTaskId,
logDir,
language,
exercise,
iteration: task.iteration,
logger,
})
}
logger.close()
if (isApiUnstable) {
// Only throw for API instability if the task didn't complete successfully.
// If taskFinishedAt is set via TaskCompleted event, the task succeeded despite
// API retries, so re-running from scratch would waste resources.
if (isApiUnstable && !taskFinishedAt) {
throw new Error("API is unstable, throwing to trigger a retry.")
}
}

View file

@ -0,0 +1,3 @@
DROP INDEX "tasks_language_exercise_idx";--> statement-breakpoint
ALTER TABLE "tasks" ADD COLUMN "iteration" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "tasks_language_exercise_iteration_idx" ON "tasks" USING btree ("run_id","language","exercise","iteration");

View file

@ -0,0 +1,12 @@
ALTER TABLE "tasks" DROP CONSTRAINT "tasks_run_id_runs_id_fk";
--> statement-breakpoint
ALTER TABLE "tasks" DROP CONSTRAINT "tasks_task_metrics_id_taskMetrics_id_fk";
--> statement-breakpoint
ALTER TABLE "toolErrors" DROP CONSTRAINT "toolErrors_run_id_runs_id_fk";
--> statement-breakpoint
ALTER TABLE "toolErrors" DROP CONSTRAINT "toolErrors_task_id_tasks_id_fk";
--> statement-breakpoint
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_task_metrics_id_taskMetrics_id_fk" FOREIGN KEY ("task_metrics_id") REFERENCES "public"."taskMetrics"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "toolErrors" ADD CONSTRAINT "toolErrors_run_id_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "toolErrors" ADD CONSTRAINT "toolErrors_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;

View file

@ -0,0 +1,472 @@
{
"id": "9caa4487-e146-4084-907d-fbf9cc3e03b9",
"prevId": "853d308a-3946-4ea8-9039-236bfce3c6c0",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.runs": {
"name": "runs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "runs_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"task_metrics_id": {
"name": "task_metrics_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"contextWindow": {
"name": "contextWindow",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"inputPrice": {
"name": "inputPrice",
"type": "real",
"primaryKey": false,
"notNull": false
},
"outputPrice": {
"name": "outputPrice",
"type": "real",
"primaryKey": false,
"notNull": false
},
"cacheWritesPrice": {
"name": "cacheWritesPrice",
"type": "real",
"primaryKey": false,
"notNull": false
},
"cacheReadsPrice": {
"name": "cacheReadsPrice",
"type": "real",
"primaryKey": false,
"notNull": false
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"jobToken": {
"name": "jobToken",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pid": {
"name": "pid",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"socket_path": {
"name": "socket_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"concurrency": {
"name": "concurrency",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 2
},
"timeout": {
"name": "timeout",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 5
},
"passed": {
"name": "passed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"failed": {
"name": "failed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"runs_task_metrics_id_taskMetrics_id_fk": {
"name": "runs_task_metrics_id_taskMetrics_id_fk",
"tableFrom": "runs",
"tableTo": "taskMetrics",
"columnsFrom": ["task_metrics_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.taskMetrics": {
"name": "taskMetrics",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "taskMetrics_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"tokens_in": {
"name": "tokens_in",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tokens_out": {
"name": "tokens_out",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tokens_context": {
"name": "tokens_context",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"cache_writes": {
"name": "cache_writes",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"cache_reads": {
"name": "cache_reads",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"cost": {
"name": "cost",
"type": "real",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tool_usage": {
"name": "tool_usage",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tasks": {
"name": "tasks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tasks_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"run_id": {
"name": "run_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"task_metrics_id": {
"name": "task_metrics_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true
},
"exercise": {
"name": "exercise",
"type": "text",
"primaryKey": false,
"notNull": true
},
"iteration": {
"name": "iteration",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"passed": {
"name": "passed",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"finished_at": {
"name": "finished_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"tasks_language_exercise_iteration_idx": {
"name": "tasks_language_exercise_iteration_idx",
"columns": [
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "language",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "exercise",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "iteration",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tasks_run_id_runs_id_fk": {
"name": "tasks_run_id_runs_id_fk",
"tableFrom": "tasks",
"tableTo": "runs",
"columnsFrom": ["run_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"tasks_task_metrics_id_taskMetrics_id_fk": {
"name": "tasks_task_metrics_id_taskMetrics_id_fk",
"tableFrom": "tasks",
"tableTo": "taskMetrics",
"columnsFrom": ["task_metrics_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.toolErrors": {
"name": "toolErrors",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "toolErrors_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"run_id": {
"name": "run_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"task_id": {
"name": "task_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"tool_name": {
"name": "tool_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"toolErrors_run_id_runs_id_fk": {
"name": "toolErrors_run_id_runs_id_fk",
"tableFrom": "toolErrors",
"tableTo": "runs",
"columnsFrom": ["run_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"toolErrors_task_id_tasks_id_fk": {
"name": "toolErrors_task_id_tasks_id_fk",
"tableFrom": "toolErrors",
"tableTo": "tasks",
"columnsFrom": ["task_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,472 @@
{
"id": "71b54967-86df-42ec-a200-bfd8dad85069",
"prevId": "9caa4487-e146-4084-907d-fbf9cc3e03b9",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.runs": {
"name": "runs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "runs_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"task_metrics_id": {
"name": "task_metrics_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"contextWindow": {
"name": "contextWindow",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"inputPrice": {
"name": "inputPrice",
"type": "real",
"primaryKey": false,
"notNull": false
},
"outputPrice": {
"name": "outputPrice",
"type": "real",
"primaryKey": false,
"notNull": false
},
"cacheWritesPrice": {
"name": "cacheWritesPrice",
"type": "real",
"primaryKey": false,
"notNull": false
},
"cacheReadsPrice": {
"name": "cacheReadsPrice",
"type": "real",
"primaryKey": false,
"notNull": false
},
"settings": {
"name": "settings",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"jobToken": {
"name": "jobToken",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pid": {
"name": "pid",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"socket_path": {
"name": "socket_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"concurrency": {
"name": "concurrency",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 2
},
"timeout": {
"name": "timeout",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 5
},
"passed": {
"name": "passed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"failed": {
"name": "failed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"runs_task_metrics_id_taskMetrics_id_fk": {
"name": "runs_task_metrics_id_taskMetrics_id_fk",
"tableFrom": "runs",
"tableTo": "taskMetrics",
"columnsFrom": ["task_metrics_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.taskMetrics": {
"name": "taskMetrics",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "taskMetrics_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"tokens_in": {
"name": "tokens_in",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tokens_out": {
"name": "tokens_out",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tokens_context": {
"name": "tokens_context",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"cache_writes": {
"name": "cache_writes",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"cache_reads": {
"name": "cache_reads",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"cost": {
"name": "cost",
"type": "real",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tool_usage": {
"name": "tool_usage",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tasks": {
"name": "tasks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tasks_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"run_id": {
"name": "run_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"task_metrics_id": {
"name": "task_metrics_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true
},
"exercise": {
"name": "exercise",
"type": "text",
"primaryKey": false,
"notNull": true
},
"iteration": {
"name": "iteration",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"passed": {
"name": "passed",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"finished_at": {
"name": "finished_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"tasks_language_exercise_iteration_idx": {
"name": "tasks_language_exercise_iteration_idx",
"columns": [
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "language",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "exercise",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "iteration",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tasks_run_id_runs_id_fk": {
"name": "tasks_run_id_runs_id_fk",
"tableFrom": "tasks",
"tableTo": "runs",
"columnsFrom": ["run_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"tasks_task_metrics_id_taskMetrics_id_fk": {
"name": "tasks_task_metrics_id_taskMetrics_id_fk",
"tableFrom": "tasks",
"tableTo": "taskMetrics",
"columnsFrom": ["task_metrics_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.toolErrors": {
"name": "toolErrors",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "toolErrors_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"run_id": {
"name": "run_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"task_id": {
"name": "task_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"tool_name": {
"name": "tool_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"toolErrors_run_id_runs_id_fk": {
"name": "toolErrors_run_id_runs_id_fk",
"tableFrom": "toolErrors",
"tableTo": "runs",
"columnsFrom": ["run_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"toolErrors_task_id_tasks_id_fk": {
"name": "toolErrors_task_id_tasks_id_fk",
"tableFrom": "toolErrors",
"tableTo": "tasks",
"columnsFrom": ["task_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -29,6 +29,20 @@
"when": 1763797232454,
"tag": "0003_simple_retro_girl",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1764201678953,
"tag": "0004_sloppy_black_knight",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1765167049182,
"tag": "0005_strong_skrulls",
"breakpoints": true
}
]
}

View file

@ -134,3 +134,67 @@ export const deleteRun = async (runId: number) => {
await db.delete(schema.taskMetrics).where(inArray(schema.taskMetrics.id, taskMetricsIds))
}
/**
* Get all runs without a taskMetricsId (incomplete runs)
*/
export const getIncompleteRuns = async () => {
return db.query.runs.findMany({
where: sql`${schema.runs.taskMetricsId} IS NULL`,
columns: { id: true },
})
}
/**
* Delete multiple runs by their IDs
*/
export const deleteRunsByIds = async (runIds: number[]) => {
if (runIds.length === 0) return
// Get all tasks for these runs
const tasks = await db.query.tasks.findMany({
where: inArray(schema.tasks.runId, runIds),
columns: { id: true, taskMetricsId: true },
})
const taskIds = tasks.map(({ id }) => id)
// Get run taskMetricsIds
const runs = await db.query.runs.findMany({
where: inArray(schema.runs.id, runIds),
columns: { taskMetricsId: true },
})
// Delete tool errors for tasks
if (taskIds.length > 0) {
await db.delete(schema.toolErrors).where(inArray(schema.toolErrors.taskId, taskIds))
}
// Delete tasks
await db.delete(schema.tasks).where(inArray(schema.tasks.runId, runIds))
// Delete tool errors for runs
await db.delete(schema.toolErrors).where(inArray(schema.toolErrors.runId, runIds))
// Delete from tables that exist in DB but not in drizzle schema
// Using individual deletes since drizzle's sql template doesn't support custom table schemas
for (const runId of runIds) {
await db.execute(sql`DELETE FROM "cpuMetrics" WHERE run_id = ${runId}`)
await db.execute(sql`DELETE FROM "notes" WHERE run_id = ${runId}`)
}
// Delete runs
await db.delete(schema.runs).where(inArray(schema.runs.id, runIds))
// Delete task metrics
const taskMetricsIds = [
...tasks
.map(({ taskMetricsId }) => taskMetricsId)
.filter((id): id is number => id !== null && id !== undefined),
...runs.map(({ taskMetricsId }) => taskMetricsId).filter((id): id is number => id !== null && id !== undefined),
]
if (taskMetricsIds.length > 0) {
await db.delete(schema.taskMetrics).where(inArray(schema.taskMetrics.id, taskMetricsIds))
}
}

Some files were not shown because too many files have changed in this diff Show more