From 90e2451ad1cef0a78c7ef2a41f12c5c18b0795c3 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:28:18 +0800 Subject: [PATCH 001/109] Add Anthropic Claude Sonnet 4.6 support across providers (#11509) * Add Anthropic Claude Sonnet 4.6 support across providers Add model definitions and capability flags for Anthropic, Bedrock, Vertex, OpenRouter, and Vercel AI Gateway. Update Anthropic handler and UI model selection logic to support Claude Sonnet 4.6 1M context behavior and tier pricing. Add focused tests for provider handlers, fetchers, and selected-model hooks. Keep Bedrock UI tier pricing parity as-is because this is a pre-existing issue for Opus 4.6 and will be handled separately. Reference: - https://www.anthropic.com/news/claude-sonnet-4-6 - https://platform.claude.com/docs/en/about-claude/models/overview#latest-models-comparison * Delete .changeset/soft-carpets-hunt.md --------- Co-authored-by: Hannes Rudolph --- packages/types/src/providers/anthropic.ts | 21 +++++++++ packages/types/src/providers/bedrock.ts | 27 +++++++++++ packages/types/src/providers/openrouter.ts | 2 + .../types/src/providers/vercel-ai-gateway.ts | 2 + packages/types/src/providers/vertex.ts | 22 +++++++++ .../__tests__/anthropic-vertex.spec.ts | 15 ++++++ src/api/providers/__tests__/anthropic.spec.ts | 46 +++++++++++++++++++ src/api/providers/__tests__/bedrock.spec.ts | 15 ++++++ src/api/providers/anthropic.ts | 10 +++- .../fetchers/__tests__/openrouter.spec.ts | 24 ++++++++++ src/api/providers/fetchers/openrouter.ts | 5 ++ .../settings/providers/Anthropic.tsx | 1 + .../hooks/__tests__/useSelectedModel.spec.ts | 32 +++++++++++++ .../components/ui/hooks/useSelectedModel.ts | 7 ++- 14 files changed, 225 insertions(+), 4 deletions(-) diff --git a/packages/types/src/providers/anthropic.ts b/packages/types/src/providers/anthropic.ts index 62e377c7e5..40a3d885d8 100644 --- a/packages/types/src/providers/anthropic.ts +++ b/packages/types/src/providers/anthropic.ts @@ -7,6 +7,27 @@ export type AnthropicModelId = keyof typeof anthropicModels export const anthropicDefaultModelId: AnthropicModelId = "claude-sonnet-4-5" export const anthropicModels = { + "claude-sonnet-4-6": { + maxTokens: 64_000, // Overridden to 8k if `enableReasoningEffort` is false. + contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag 'context-1m-2025-08-07' + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3.0, // $3 per million input tokens (≤200K context) + outputPrice: 15.0, // $15 per million output tokens (≤200K context) + cacheWritesPrice: 3.75, // $3.75 per million tokens + cacheReadsPrice: 0.3, // $0.30 per million tokens + supportsReasoningBudget: true, + // Tiered pricing for extended context (requires beta flag 'context-1m-2025-08-07') + tiers: [ + { + contextWindow: 1_000_000, // 1M tokens with beta flag + inputPrice: 6.0, // $6 per million input tokens (>200K context) + outputPrice: 22.5, // $22.50 per million output tokens (>200K context) + cacheWritesPrice: 7.5, // $7.50 per million tokens (>200K context) + cacheReadsPrice: 0.6, // $0.60 per million tokens (>200K context) + }, + ], + }, "claude-sonnet-4-5": { maxTokens: 64_000, // Overridden to 8k if `enableReasoningEffort` is false. contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag 'context-1m-2025-08-07' diff --git a/packages/types/src/providers/bedrock.ts b/packages/types/src/providers/bedrock.ts index 008961b301..575db6984a 100644 --- a/packages/types/src/providers/bedrock.ts +++ b/packages/types/src/providers/bedrock.ts @@ -27,6 +27,30 @@ export const bedrockModels = { maxCachePoints: 4, cachableFields: ["system", "messages", "tools"], }, + "anthropic.claude-sonnet-4-6-20260114-v1:0": { + maxTokens: 8192, + contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag 'context-1m-2025-08-07' + supportsImages: true, + supportsPromptCache: true, + supportsReasoningBudget: true, + inputPrice: 3.0, // $3 per million input tokens (≤200K context) + outputPrice: 15.0, // $15 per million output tokens (≤200K context) + cacheWritesPrice: 3.75, // $3.75 per million tokens + cacheReadsPrice: 0.3, // $0.30 per million tokens + minTokensPerCachePoint: 1024, + maxCachePoints: 4, + cachableFields: ["system", "messages", "tools"], + // Tiered pricing for extended context (requires beta flag 'context-1m-2025-08-07') + tiers: [ + { + contextWindow: 1_000_000, // 1M tokens with beta flag + inputPrice: 6.0, // $6 per million input tokens (>200K context) + outputPrice: 22.5, // $22.50 per million output tokens (>200K context) + cacheWritesPrice: 7.5, // $7.50 per million tokens (>200K context) + cacheReadsPrice: 0.6, // $0.60 per million tokens (>200K context) + }, + ], + }, "amazon.nova-pro-v1:0": { maxTokens: 5000, contextWindow: 300_000, @@ -499,6 +523,7 @@ export const BEDROCK_REGIONS = [ export const BEDROCK_1M_CONTEXT_MODEL_IDS = [ "anthropic.claude-sonnet-4-20250514-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0", + "anthropic.claude-sonnet-4-6-20260114-v1:0", "anthropic.claude-opus-4-6-v1", ] as const @@ -506,12 +531,14 @@ export const BEDROCK_1M_CONTEXT_MODEL_IDS = [ // As of Nov 2025, AWS supports Global Inference for: // - Claude Sonnet 4 // - Claude Sonnet 4.5 +// - Claude Sonnet 4.6 // - Claude Haiku 4.5 // - Claude Opus 4.5 // - Claude Opus 4.6 export const BEDROCK_GLOBAL_INFERENCE_MODEL_IDS = [ "anthropic.claude-sonnet-4-20250514-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0", + "anthropic.claude-sonnet-4-6-20260114-v1:0", "anthropic.claude-haiku-4-5-20251001-v1:0", "anthropic.claude-opus-4-5-20251101-v1:0", "anthropic.claude-opus-4-6-v1", diff --git a/packages/types/src/providers/openrouter.ts b/packages/types/src/providers/openrouter.ts index c8168e6024..834f40528e 100644 --- a/packages/types/src/providers/openrouter.ts +++ b/packages/types/src/providers/openrouter.ts @@ -38,6 +38,7 @@ export const OPEN_ROUTER_PROMPT_CACHING_MODELS = new Set([ "anthropic/claude-3.7-sonnet:thinking", "anthropic/claude-sonnet-4", "anthropic/claude-sonnet-4.5", + "anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4", "anthropic/claude-opus-4.1", "anthropic/claude-opus-4.5", @@ -75,6 +76,7 @@ export const OPEN_ROUTER_REASONING_BUDGET_MODELS = new Set([ "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4", "anthropic/claude-sonnet-4.5", + "anthropic/claude-sonnet-4.6", "anthropic/claude-haiku-4.5", "google/gemini-2.5-pro-preview", "google/gemini-2.5-pro", diff --git a/packages/types/src/providers/vercel-ai-gateway.ts b/packages/types/src/providers/vercel-ai-gateway.ts index 43a94a0697..ac633747ba 100644 --- a/packages/types/src/providers/vercel-ai-gateway.ts +++ b/packages/types/src/providers/vercel-ai-gateway.ts @@ -14,6 +14,7 @@ export const VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS = new Set([ "anthropic/claude-opus-4.5", "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4", + "anthropic/claude-sonnet-4.6", "openai/gpt-4.1", "openai/gpt-4.1-mini", "openai/gpt-4.1-nano", @@ -55,6 +56,7 @@ export const VERCEL_AI_GATEWAY_VISION_AND_TOOLS_MODELS = new Set([ "anthropic/claude-opus-4.5", "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4", + "anthropic/claude-sonnet-4.6", "google/gemini-1.5-flash", "google/gemini-1.5-pro", "google/gemini-2.0-flash", diff --git a/packages/types/src/providers/vertex.ts b/packages/types/src/providers/vertex.ts index 55e5648011..2f8a05602a 100644 --- a/packages/types/src/providers/vertex.ts +++ b/packages/types/src/providers/vertex.ts @@ -263,6 +263,27 @@ export const vertexModels = { }, ], }, + "claude-sonnet-4-6@20260114": { + maxTokens: 8192, + contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag 'context-1m-2025-08-07' + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3.0, // $3 per million input tokens (≤200K context) + outputPrice: 15.0, // $15 per million output tokens (≤200K context) + cacheWritesPrice: 3.75, // $3.75 per million tokens + cacheReadsPrice: 0.3, // $0.30 per million tokens + supportsReasoningBudget: true, + // Tiered pricing for extended context (requires beta flag 'context-1m-2025-08-07') + tiers: [ + { + contextWindow: 1_000_000, // 1M tokens with beta flag + inputPrice: 6.0, // $6 per million input tokens (>200K context) + outputPrice: 22.5, // $22.50 per million output tokens (>200K context) + cacheWritesPrice: 7.5, // $7.50 per million tokens (>200K context) + cacheReadsPrice: 0.6, // $0.60 per million tokens (>200K context) + }, + ], + }, "claude-haiku-4-5@20251001": { maxTokens: 8192, contextWindow: 200_000, @@ -491,6 +512,7 @@ export const vertexModels = { export const VERTEX_1M_CONTEXT_MODEL_IDS = [ "claude-sonnet-4@20250514", "claude-sonnet-4-5@20250929", + "claude-sonnet-4-6@20260114", "claude-opus-4-6", ] as const diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index 3d9798fde9..dc284ab754 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -899,6 +899,21 @@ describe("VertexHandler", () => { expect(model.betas).toContain("context-1m-2025-08-07") }) + it("should enable 1M context for Claude Sonnet 4.6 when beta flag is set", () => { + const handler = new AnthropicVertexHandler({ + apiModelId: "claude-sonnet-4-6@20260114", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + vertex1MContext: true, + }) + + const model = handler.getModel() + expect(model.info.contextWindow).toBe(1_000_000) + expect(model.info.inputPrice).toBe(6.0) + expect(model.info.outputPrice).toBe(22.5) + expect(model.betas).toContain("context-1m-2025-08-07") + }) + it("should not enable 1M context when flag is disabled", () => { const handler = new AnthropicVertexHandler({ apiModelId: VERTEX_1M_CONTEXT_MODEL_IDS[0], diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index 7a107edbc8..3731f3a068 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -187,6 +187,28 @@ describe("AnthropicHandler", () => { // Verify API expect(mockCreate).toHaveBeenCalled() }) + + it("should include 1M context beta header for Claude Sonnet 4.6 when enabled", async () => { + const sonnet46Handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-sonnet-4-6", + anthropicBeta1MContext: true, + }) + + const stream = sonnet46Handler.createMessage(systemPrompt, [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello" }], + }, + ]) + + for await (const _chunk of stream) { + // Consume stream + } + + const requestOptions = mockCreate.mock.calls[mockCreate.mock.calls.length - 1]?.[1] + expect(requestOptions?.headers?.["anthropic-beta"]).toContain("context-1m-2025-08-07") + }) }) describe("completePrompt", () => { @@ -286,6 +308,18 @@ describe("AnthropicHandler", () => { expect(model.info.supportsReasoningBudget).toBe(true) }) + it("should handle Claude 4.6 Sonnet model correctly", () => { + const handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-sonnet-4-6", + }) + const model = handler.getModel() + expect(model.id).toBe("claude-sonnet-4-6") + expect(model.info.maxTokens).toBe(64000) + expect(model.info.contextWindow).toBe(200000) + expect(model.info.supportsReasoningBudget).toBe(true) + }) + it("should enable 1M context for Claude 4.5 Sonnet when beta flag is set", () => { const handler = new AnthropicHandler({ apiKey: "test-api-key", @@ -297,6 +331,18 @@ describe("AnthropicHandler", () => { expect(model.info.inputPrice).toBe(6.0) expect(model.info.outputPrice).toBe(22.5) }) + + it("should enable 1M context for Claude 4.6 Sonnet when beta flag is set", () => { + const handler = new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: "claude-sonnet-4-6", + anthropicBeta1MContext: true, + }) + const model = handler.getModel() + expect(model.info.contextWindow).toBe(1000000) + expect(model.info.inputPrice).toBe(6.0) + expect(model.info.outputPrice).toBe(22.5) + }) }) describe("reasoning block filtering", () => { diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts index 115cb9fb40..4c45a62325 100644 --- a/src/api/providers/__tests__/bedrock.spec.ts +++ b/src/api/providers/__tests__/bedrock.spec.ts @@ -701,6 +701,21 @@ describe("AwsBedrockHandler", () => { expect(model.info.contextWindow).toBe(1_000_000) }) + it("should apply 1M tier pricing when awsBedrock1MContext is true for Claude Sonnet 4.6", () => { + const handler = new AwsBedrockHandler({ + apiModelId: "anthropic.claude-sonnet-4-6-20260114-v1:0", + awsAccessKey: "test", + awsSecretKey: "test", + awsRegion: "us-east-1", + awsBedrock1MContext: true, + }) + + const model = handler.getModel() + expect(model.info.contextWindow).toBe(1_000_000) + expect(model.info.inputPrice).toBe(6.0) + expect(model.info.outputPrice).toBe(22.5) + }) + it("should use default context window when awsBedrock1MContext is false for Claude Sonnet 4", () => { const handler = new AwsBedrockHandler({ apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0], diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index b2b158f095..1786a105a5 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -64,10 +64,11 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa // Filter out non-Anthropic blocks (reasoning, thoughtSignature, etc.) before sending to the API const sanitizedMessages = filterNonAnthropicBlocks(messages) - // Add 1M context beta flag if enabled for supported models (Claude Sonnet 4/4.5, Opus 4.6) + // Add 1M context beta flag if enabled for supported models (Claude Sonnet 4/4.5/4.6, Opus 4.6) if ( (modelId === "claude-sonnet-4-20250514" || modelId === "claude-sonnet-4-5" || + modelId === "claude-sonnet-4-6" || modelId === "claude-opus-4-6") && this.options.anthropicBeta1MContext ) { @@ -80,6 +81,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa } switch (modelId) { + case "claude-sonnet-4-6": case "claude-sonnet-4-5": case "claude-sonnet-4-20250514": case "claude-opus-4-6": @@ -145,6 +147,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa // Then check for models that support prompt caching switch (modelId) { + case "claude-sonnet-4-6": case "claude-sonnet-4-5": case "claude-sonnet-4-20250514": case "claude-opus-4-6": @@ -336,7 +339,10 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa // If 1M context beta is enabled for supported models, update the model info if ( - (id === "claude-sonnet-4-20250514" || id === "claude-sonnet-4-5" || id === "claude-opus-4-6") && + (id === "claude-sonnet-4-20250514" || + id === "claude-sonnet-4-5" || + id === "claude-sonnet-4-6" || + id === "claude-opus-4-6") && this.options.anthropicBeta1MContext ) { // Use the tier pricing for 1M context diff --git a/src/api/providers/fetchers/__tests__/openrouter.spec.ts b/src/api/providers/fetchers/__tests__/openrouter.spec.ts index 3bcd27716f..bca54b3078 100644 --- a/src/api/providers/fetchers/__tests__/openrouter.spec.ts +++ b/src/api/providers/fetchers/__tests__/openrouter.spec.ts @@ -266,6 +266,30 @@ describe("OpenRouter API", () => { }) describe("parseOpenRouterModel", () => { + it("sets claude-sonnet-4.6 model to Anthropic max tokens", () => { + const mockModel = { + name: "Claude Sonnet 4.6", + description: "Test model", + context_length: 200000, + max_completion_tokens: 8192, + pricing: { + prompt: "0.000003", + completion: "0.000015", + }, + } + + const result = parseOpenRouterModel({ + id: "anthropic/claude-sonnet-4.6", + model: mockModel, + inputModality: ["text"], + outputModality: ["text"], + maxTokens: 8192, + }) + + expect(result.maxTokens).toBe(64000) + expect(result.contextWindow).toBe(200000) + }) + it("sets horizon-alpha model to 32k max tokens", () => { const mockModel = { name: "Horizon Alpha", diff --git a/src/api/providers/fetchers/openrouter.ts b/src/api/providers/fetchers/openrouter.ts index 9fcf3d49cb..0cf65fb09c 100644 --- a/src/api/providers/fetchers/openrouter.ts +++ b/src/api/providers/fetchers/openrouter.ts @@ -243,6 +243,11 @@ export const parseOpenRouterModel = ({ modelInfo.maxTokens = anthropicModels["claude-3-7-sonnet-20250219:thinking"].maxTokens } + // Set claude-sonnet-4.6 model to use the correct configuration + if (id === "anthropic/claude-sonnet-4.6") { + modelInfo.maxTokens = anthropicModels["claude-sonnet-4-6"].maxTokens + } + // Set claude-opus-4.1 model to use the correct configuration if (id === "anthropic/claude-opus-4.1") { modelInfo.maxTokens = anthropicModels["claude-opus-4-1-20250805"].maxTokens diff --git a/webview-ui/src/components/settings/providers/Anthropic.tsx b/webview-ui/src/components/settings/providers/Anthropic.tsx index 58fa81d6bc..6417e34b8b 100644 --- a/webview-ui/src/components/settings/providers/Anthropic.tsx +++ b/webview-ui/src/components/settings/providers/Anthropic.tsx @@ -26,6 +26,7 @@ export const Anthropic = ({ apiConfiguration, setApiConfigurationField }: Anthro const supports1MContextBeta = selectedModel?.id === "claude-sonnet-4-20250514" || selectedModel?.id === "claude-sonnet-4-5" || + selectedModel?.id === "claude-sonnet-4-6" || selectedModel?.id === "claude-opus-4-6" const handleInputChange = useCallback( diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 8925adf5fd..a8dead311f 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -402,6 +402,38 @@ describe("useSelectedModel", () => { }) }) + describe("anthropic provider with 1M context", () => { + beforeEach(() => { + mockUseRouterModels.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + } as any) + + mockUseOpenRouterModelProviders.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + } as any) + }) + + it("should apply 1M pricing tier for Claude Sonnet 4.6 when enabled", () => { + const apiConfiguration: ProviderSettings = { + apiProvider: "anthropic", + apiModelId: "claude-sonnet-4-6", + anthropicBeta1MContext: true, + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe("claude-sonnet-4-6") + expect(result.current.info?.contextWindow).toBe(1_000_000) + expect(result.current.info?.inputPrice).toBe(6.0) + expect(result.current.info?.outputPrice).toBe(22.5) + }) + }) + describe("bedrock provider with 1M context", () => { beforeEach(() => { mockUseRouterModels.mockReturnValue({ diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 0ac82b5062..8a6e49e212 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -341,11 +341,14 @@ function getSelectedModel({ // Apply 1M context beta tier pricing for supported Claude 4 models if ( provider === "anthropic" && - (id === "claude-sonnet-4-20250514" || id === "claude-sonnet-4-5" || id === "claude-opus-4-6") && + (id === "claude-sonnet-4-20250514" || + id === "claude-sonnet-4-5" || + id === "claude-sonnet-4-6" || + id === "claude-opus-4-6") && apiConfiguration.anthropicBeta1MContext && baseInfo ) { - // Type assertion since we know claude-sonnet-4-20250514 and claude-sonnet-4-5 have tiers + // Type assertion since supported Claude 4 models include 1M context pricing tiers. const modelWithTiers = baseInfo as typeof baseInfo & { tiers?: Array<{ contextWindow: number From 24958f3a3d8e7a5cec0c0bdac0e11653d777db44 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 17 Feb 2026 13:26:35 -0700 Subject: [PATCH 002/109] Release v3.48.0 (#11511) chore: add changeset for v3.48.0 --- .changeset/v3.48.0.md | 53 +++++++++++++++++++ src/core/webview/ClineProvider.ts | 2 +- .../src/components/chat/Announcement.tsx | 4 +- webview-ui/src/i18n/locales/ca/chat.json | 4 +- webview-ui/src/i18n/locales/de/chat.json | 4 +- webview-ui/src/i18n/locales/en/chat.json | 4 +- webview-ui/src/i18n/locales/es/chat.json | 4 +- webview-ui/src/i18n/locales/fr/chat.json | 4 +- webview-ui/src/i18n/locales/hi/chat.json | 4 +- webview-ui/src/i18n/locales/id/chat.json | 4 +- webview-ui/src/i18n/locales/it/chat.json | 4 +- webview-ui/src/i18n/locales/ja/chat.json | 4 +- webview-ui/src/i18n/locales/ko/chat.json | 4 +- webview-ui/src/i18n/locales/nl/chat.json | 4 +- webview-ui/src/i18n/locales/pl/chat.json | 4 +- webview-ui/src/i18n/locales/pt-BR/chat.json | 4 +- webview-ui/src/i18n/locales/ru/chat.json | 4 +- webview-ui/src/i18n/locales/tr/chat.json | 4 +- webview-ui/src/i18n/locales/vi/chat.json | 4 +- webview-ui/src/i18n/locales/zh-CN/chat.json | 4 +- webview-ui/src/i18n/locales/zh-TW/chat.json | 4 +- 21 files changed, 111 insertions(+), 20 deletions(-) create mode 100644 .changeset/v3.48.0.md diff --git a/.changeset/v3.48.0.md b/.changeset/v3.48.0.md new file mode 100644 index 0000000000..e000dc04b8 --- /dev/null +++ b/.changeset/v3.48.0.md @@ -0,0 +1,53 @@ +--- +"roo-cline": minor +--- + +- Add Anthropic Claude Sonnet 4.6 support across all providers — Anthropic, Bedrock, Vertex, OpenRouter, and Vercel AI Gateway (PR #11509 by @PeterDaveHello) +- Add lock toggle to pin API config across all modes in a workspace (PR #11295 by @hannesrudolph) +- Fix: Prevent parent task state loss during orchestrator delegation (PR #11281 by @hannesrudolph) +- Fix: Resolve race condition in new_task delegation that loses parent task history (PR #11331 by @daniel-lxs) +- Fix: Serialize taskHistory writes and fix delegation status overwrite race (PR #11335 by @hannesrudolph) +- Fix: Prevent chat history loss during cloud/settings navigation (#11371 by @SannidhyaSah, PR #11372 by @SannidhyaSah) +- Fix: Preserve condensation summary during task resume (#11487 by @SannidhyaSah, PR #11488 by @SannidhyaSah) +- Fix: Resolve chat scroll anchoring and task-switch scroll race conditions (PR #11385 by @hannesrudolph) +- Fix: Preserve pasted images in chatbox during chat activity (PR #11375 by @app/roomote) +- Add disabledTools setting to globally disable native tools (PR #11277 by @daniel-lxs) +- Rename search_and_replace tool to edit and unify edit-family UI (PR #11296 by @hannesrudolph) +- Render nested subtasks as recursive tree in history view (PR #11299 by @hannesrudolph) +- Remove 9 low-usage providers and add retired-provider UX (PR #11297 by @hannesrudolph) +- Remove browser use functionality entirely (PR #11392 by @hannesrudolph) +- Remove built-in skills and built-in skills mechanism (PR #11414 by @hannesrudolph) +- Remove footgun prompting (file-based system prompt override) (PR #11387 by @hannesrudolph) +- Batch consecutive tool calls in chat UI with shared utility (PR #11245 by @hannesrudolph) +- Validate Gemini thinkingLevel against model capabilities and handle empty streams (PR #11303 by @hannesrudolph) +- Add GLM-5 model support to Z.ai provider (PR #11440 by @app/roomote) +- Fix: Prevent double notification sound playback (PR #11283 by @hannesrudolph) +- Fix: Prevent false unsaved changes prompt with OpenAI Compatible headers (#8230 by @hannesrudolph, PR #11334 by @daniel-lxs) +- Fix: Cancel backend auto-approval timeout when auto-approve is toggled off mid-countdown (PR #11439 by @SannidhyaSah) +- Fix: Add follow_up param validation in AskFollowupQuestionTool (PR #11484 by @rossdonald) +- Fix: Prevent webview postMessage crashes and make dispose idempotent (PR #11313 by @0xMink) +- Fix: Avoid zsh process-substitution false positives in assignments (PR #11365 by @hannesrudolph) +- Fix: Harden command auto-approval against inline JS false positives (PR #11382 by @hannesrudolph) +- Fix: Make tab close best-effort in DiffViewProvider.open (PR #11363 by @0xMink) +- Fix: Canonicalize core.worktree comparison to prevent Windows path mismatch failures (PR #11346 by @0xMink) +- Fix: Make removeClineFromStack() delegation-aware to prevent orphaned parent tasks (PR #11302 by @app/roomote) +- Fix task resumption in the API module (PR #11369 by @cte) +- Make defaultTemperature required in getModelParams to prevent silent temperature overrides (PR #11218 by @app/roomote) +- Remove noisy console.warn logs from NativeToolCallParser (PR #11264 by @daniel-lxs) +- Consolidate getState calls in resolveWebviewView (PR #11320 by @0xMink) +- Clean up repo-facing mode rules (PR #11410 by @hannesrudolph) +- Implement ModelMessage storage layer with AI SDK response messages (PR #11409 by @daniel-lxs) +- Extract translation and merge resolver modes into reusable skills (PR #11215 by @app/roomote) +- Add blog section with initial posts to roocode.com (PR #11127 by @app/roomote) +- Replace Roomote Control with Linear Integration in cloud features grid (PR #11280 by @app/roomote) +- Add IPC query handlers for commands, modes, and models (PR #11279 by @cte) +- Add stdin stream mode for the CLI (PR #11476 by @cte) +- Make CLI auto-approve by default with require-approval opt-in (PR #11424 by @cte) +- Update CLI default model from Opus 4.5 to Opus 4.6 (PR #11273 by @app/roomote) +- Add linux-arm64 support for the Roo CLI (PR #11314 by @cte) +- Release: v1.110.0 version bump (PR #11278 by @jr) +- Release: v1.111.0 (PR #11421 by @cte) +- CLI release: v0.0.51 (PR #11274 by @cte) +- CLI release: v0.0.52 (PR #11324 by @cte) +- CLI release: v0.0.53 (PR #11425 by @cte) +- CLI release: v0.0.54 (PR #11477 by @cte) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index bb9199a65c..408bbfd219 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -166,7 +166,7 @@ export class ClineProvider public isViewLaunched = false public settingsImportedAt?: number - public readonly latestAnnouncementId = "jan-2026-v3.45.0-smart-code-folding" // v3.45.0 Smart Code Folding + public readonly latestAnnouncementId = "feb-2026-v3.48.0-sonnet-46-stability-locked-config" // v3.48.0 Sonnet 4.6, Stability, Locked API Config public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager diff --git a/webview-ui/src/components/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index 7e13c34de6..77dfd01a91 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -44,7 +44,9 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {

{t("chat:announcement.release.heading")}

    -
  • {t("chat:announcement.release.smartCodeFolding")}
  • +
  • {t("chat:announcement.release.sonnet46")}
  • +
  • {t("chat:announcement.release.stabilityFixes")}
  • +
  • {t("chat:announcement.release.lockedApiConfig")}
diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 566d0139be..16e999efd7 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -347,7 +347,9 @@ }, "release": { "heading": "Què hi ha de nou:", - "smartCodeFolding": "Plegament intel·ligent de codi: La condensació de context ara preserva un mapa lleuger dels teus fitxers—signatures de funcions, declaracions de classe i definicions de tipus. Això proporciona millor continuïtat després de condensar i edicions més intel·ligents quan es fa referència a feina anterior." + "sonnet46": "Claude Sonnet 4.6: Suport complet per al darrer model Claude Sonnet 4.6 d'Anthropic a tots els proveïdors — Anthropic, Bedrock, Vertex, OpenRouter i Vercel AI Gateway.", + "stabilityFixes": "Millores d'estabilitat: Cicle de vida de delegació reforçat contra condicions de carrera, correcció de la preservació de l'historial de xat durant la navegació i la represa de tasques, i resolució de problemes d'ancoratge de desplaçament per a una experiència més fluida.", + "lockedApiConfig": "Configuració d'API bloquejada: Un nou commutador de bloqueig et permet fixar la configuració de l'API a tots els modes d'un espai de treball, de manera que canviar de mode ja no reinicia la configuració del proveïdor." }, "cloudAgents": { "heading": "Novetats al núvol:", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 9568dce40d..339083038a 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -347,7 +347,9 @@ }, "release": { "heading": "Was ist neu:", - "smartCodeFolding": "Intelligentes Code-Folding: Kontextkomprimierung bewahrt jetzt eine leichte Karte deiner Dateien—Funktionssignaturen, Klassendeklarationen und Typdefinitionen. Dies ermöglicht bessere Kontinuität nach der Komprimierung und intelligentere Bearbeitungen beim Verweisen auf vorherige Arbeiten." + "sonnet46": "Claude Sonnet 4.6: Volle Unterstützung für Anthropics neuestes Claude Sonnet 4.6 Modell bei allen Anbietern — Anthropic, Bedrock, Vertex, OpenRouter und Vercel AI Gateway.", + "stabilityFixes": "Stabilitätsverbesserungen: Delegierungs-Lebenszyklus gegen Race Conditions gehärtet, Erhaltung des Chat-Verlaufs bei Navigation und Aufgabenwiederaufnahme behoben und Scroll-Verankerungsprobleme für ein flüssigeres Erlebnis gelöst.", + "lockedApiConfig": "Gesperrte API-Konfiguration: Ein neuer Sperr-Schalter ermöglicht es dir, deine API-Konfiguration über alle Modi in einem Workspace zu fixieren, sodass ein Moduswechsel deine Anbietereinstellungen nicht mehr zurücksetzt." }, "cloudAgents": { "heading": "Neu in der Cloud:", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index dd21792e43..71f08bb504 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -366,7 +366,9 @@ }, "release": { "heading": "What's New:", - "smartCodeFolding": "Smart Code Folding: Context condensation now preserves a lightweight map of your files: function signatures, class declarations, and type definitions. This provides better continuity after condensing and smarter edits when referencing previous work." + "sonnet46": "Claude Sonnet 4.6: Full support for Anthropic's latest Claude Sonnet 4.6 model across all providers — Anthropic, Bedrock, Vertex, OpenRouter, and Vercel AI Gateway.", + "stabilityFixes": "Stability Improvements: Hardened delegation lifecycle against race conditions, fixed chat history preservation during navigation and task resume, and resolved scroll anchoring issues for a smoother experience.", + "lockedApiConfig": "Locked API Config: New lock toggle lets you pin your API configuration across all modes in a workspace, so switching modes no longer resets your provider settings." }, "cloudAgents": { "heading": "New in the Cloud:", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 69837b7ec9..cee47210b7 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -347,7 +347,9 @@ }, "release": { "heading": "Qué hay de nuevo:", - "smartCodeFolding": "Plegado de código inteligente: La condensación de contexto ahora preserva un mapa ligero de tus archivos—firmas de función, declaraciones de clase y definiciones de tipo. Esto proporciona mejor continuidad después de condensar y ediciones más inteligentes al referenciar trabajo anterior." + "sonnet46": "Claude Sonnet 4.6: Soporte completo para el último modelo Claude Sonnet 4.6 de Anthropic en todos los proveedores — Anthropic, Bedrock, Vertex, OpenRouter y Vercel AI Gateway.", + "stabilityFixes": "Mejoras de estabilidad: Ciclo de vida de delegación reforzado contra condiciones de carrera, corrección de la preservación del historial de chat durante la navegación y reanudación de tareas, y resolución de problemas de anclaje de desplazamiento para una experiencia más fluida.", + "lockedApiConfig": "Configuración de API bloqueada: Un nuevo interruptor de bloqueo te permite fijar tu configuración de API en todos los modos de un espacio de trabajo, para que cambiar de modo ya no reinicie la configuración del proveedor." }, "cloudAgents": { "heading": "Novedades en la Nube:", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index e3c7d77273..592a85dbcd 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -347,7 +347,9 @@ }, "release": { "heading": "Quoi de neuf :", - "smartCodeFolding": "Pliage de code intelligent : La condensation du contexte préserve maintenant une carte légère de vos fichiers—signatures de fonctions, déclarations de classes et définitions de types. Cela offre une meilleure continuité après la condensation et des éditions plus intelligentes lors du référencement de travail antérieur." + "sonnet46": "Claude Sonnet 4.6 : Prise en charge complète du dernier modèle Claude Sonnet 4.6 d'Anthropic sur tous les fournisseurs — Anthropic, Bedrock, Vertex, OpenRouter et Vercel AI Gateway.", + "stabilityFixes": "Améliorations de stabilité : Cycle de vie de délégation renforcé contre les conditions de course, correction de la préservation de l'historique de chat lors de la navigation et de la reprise de tâches, et résolution des problèmes d'ancrage du défilement pour une expérience plus fluide.", + "lockedApiConfig": "Configuration API verrouillée : Un nouveau bouton de verrouillage te permet de fixer ta configuration API sur tous les modes d'un espace de travail, pour que changer de mode ne réinitialise plus tes paramètres de fournisseur." }, "cloudAgents": { "heading": "Nouveautés dans le Cloud :", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 6e17b7d314..897d6b2db1 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -347,7 +347,9 @@ }, "release": { "heading": "नया क्या है:", - "smartCodeFolding": "स्मार्ट कोड फोल्डिंग: संदर्भ संघनन अब आपकी फ़ाइलों का एक हल्का मानचित्र संरक्षित करता है—फ़ंक्शन सिग्नेचर, क्लास घोषणाएँ, और टाइप परिभाषाएँ। यह संघनन के बाद बेहतर निरंतरता और पिछले काम को संदर्भित करते समय स्मार्ट संपादन प्रदान करता है।" + "sonnet46": "Claude Sonnet 4.6: सभी प्रदाताओं पर Anthropic के नवीनतम Claude Sonnet 4.6 मॉडल का पूर्ण समर्थन — Anthropic, Bedrock, Vertex, OpenRouter, और Vercel AI Gateway।", + "stabilityFixes": "स्थिरता सुधार: रेस कंडीशन के खिलाफ डेलिगेशन जीवनचक्र को मजबूत किया, नेविगेशन और कार्य पुनरारंभ के दौरान चैट इतिहास संरक्षण को ठीक किया, और एक आसान अनुभव के लिए स्क्रॉल एंकरिंग समस्याओं को हल किया।", + "lockedApiConfig": "लॉक्ड API कॉन्फ़िग: एक नया लॉक टॉगल आपको एक वर्कस्पेस में सभी मोड्स में अपनी API कॉन्फ़िगरेशन को पिन करने देता है, ताकि मोड बदलने पर अब आपकी प्रदाता सेटिंग्स रीसेट न हों।" }, "cloudAgents": { "heading": "क्लाउड में नया:", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 9a4473cb01..f78cbc0d47 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -376,7 +376,9 @@ }, "release": { "heading": "Yang Baru:", - "smartCodeFolding": "Smart Code Folding: Kondensasi konteks sekarang mempertahankan peta ringan dari file Anda—tanda tangan fungsi, deklarasi kelas, dan definisi tipe. Ini memberikan kontinuitas yang lebih baik setelah kondensasi dan pengeditan yang lebih cerdas saat merujuk pekerjaan sebelumnya." + "sonnet46": "Claude Sonnet 4.6: Dukungan penuh untuk model Claude Sonnet 4.6 terbaru dari Anthropic di semua penyedia — Anthropic, Bedrock, Vertex, OpenRouter, dan Vercel AI Gateway.", + "stabilityFixes": "Peningkatan Stabilitas: Siklus hidup delegasi diperkuat terhadap race condition, perbaikan pelestarian riwayat chat selama navigasi dan resume tugas, serta penyelesaian masalah penahan scroll untuk pengalaman yang lebih mulus.", + "lockedApiConfig": "Konfigurasi API Terkunci: Tombol kunci baru memungkinkan kamu mengunci konfigurasi API di semua mode dalam workspace, sehingga berpindah mode tidak lagi mengatur ulang pengaturan penyedia." }, "cloudAgents": { "heading": "Baru di Cloud:", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 4c4190c7c1..a6cafe1bd1 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -347,7 +347,9 @@ }, "release": { "heading": "Novità:", - "smartCodeFolding": "Smart Code Folding: La condensazione del contesto mantiene ora una mappa leggera dei tuoi file—firme di funzione, dichiarazioni di classe e definizioni di tipo. Questo fornisce una migliore continuità dopo la condensazione e modifiche più intelligenti quando si fa riferimento al lavoro precedente." + "sonnet46": "Claude Sonnet 4.6: Supporto completo per l'ultimo modello Claude Sonnet 4.6 di Anthropic su tutti i provider — Anthropic, Bedrock, Vertex, OpenRouter e Vercel AI Gateway.", + "stabilityFixes": "Miglioramenti di stabilità: Ciclo di vita della delega rafforzato contro le race condition, correzione della conservazione della cronologia chat durante la navigazione e la ripresa delle attività, e risoluzione dei problemi di ancoraggio dello scroll per un'esperienza più fluida.", + "lockedApiConfig": "Configurazione API bloccata: Un nuovo pulsante di blocco ti permette di fissare la configurazione API su tutti i modi in un workspace, così cambiare modo non reimposta più le impostazioni del provider." }, "cloudAgents": { "heading": "Novità nel Cloud:", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 3c39a9e95b..78721a6f5c 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -347,7 +347,9 @@ }, "release": { "heading": "新機能:", - "smartCodeFolding": "スマートコードフォールディング: コンテキスト圧縮により、ファイルの軽量マップが保持されるようになりました—関数シグネチャ、クラス宣言、型定義。これにより、圧縮後の継続性が向上し、以前の作業を参照する際にさらにスマートな編集が可能になります。" + "sonnet46": "Claude Sonnet 4.6: Anthropicの最新モデルClaude Sonnet 4.6をすべてのプロバイダーで完全サポート — Anthropic、Bedrock、Vertex、OpenRouter、Vercel AI Gateway。", + "stabilityFixes": "安定性の改善: レースコンディションに対するデリゲーションライフサイクルの強化、ナビゲーションおよびタスク再開時のチャット履歴保持の修正、よりスムーズな体験のためのスクロールアンカリング問題の解決。", + "lockedApiConfig": "ロックされたAPI設定: 新しいロックトグルにより、ワークスペース内のすべてのモードでAPI設定を固定でき、モードを切り替えてもプロバイダー設定がリセットされなくなりました。" }, "cloudAgents": { "heading": "クラウドの新機能:", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 73621f8604..86aa58e522 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -347,7 +347,9 @@ }, "release": { "heading": "새로운 기능:", - "smartCodeFolding": "스마트 코드 폴딩: 컨텍스트 응축이 이제 파일의 경량 맵을 보존합니다—함수 시그니처, 클래스 선언 및 타입 정의를 포함합니다. 이는 응축 후 더 나은 연속성과 이전 작업을 참조할 때 더 스마트한 편집을 제공합니다." + "sonnet46": "Claude Sonnet 4.6: 모든 제공업체에서 Anthropic의 최신 Claude Sonnet 4.6 모델을 완벽 지원 — Anthropic, Bedrock, Vertex, OpenRouter, Vercel AI Gateway.", + "stabilityFixes": "안정성 개선: 레이스 컨디션에 대한 위임 수명주기 강화, 탐색 및 작업 재개 시 채팅 기록 보존 수정, 더 부드러운 경험을 위한 스크롤 앵커링 문제 해결.", + "lockedApiConfig": "잠긴 API 설정: 새로운 잠금 토글로 워크스페이스의 모든 모드에서 API 구성을 고정할 수 있어, 모드를 전환해도 제공업체 설정이 더 이상 초기화되지 않습니다." }, "cloudAgents": { "heading": "클라우드의 새로운 기능:", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 94ea4720c0..57dd6e5499 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -320,7 +320,9 @@ }, "release": { "heading": "Wat is er nieuw:", - "smartCodeFolding": "Smart Code Folding: Contextcondensatie behoudt nu een lichte kaart van je bestanden—functiehandtekeningen, klasdeclaraties en typedefinities. Dit biedt betere continuïteit na condensatie en slimmere bewerkingen bij verwijzing naar vorig werk." + "sonnet46": "Claude Sonnet 4.6: Volledige ondersteuning voor Anthropics nieuwste Claude Sonnet 4.6 model bij alle providers — Anthropic, Bedrock, Vertex, OpenRouter en Vercel AI Gateway.", + "stabilityFixes": "Stabiliteitsverbeteringen: Delegatie-levenscyclus versterkt tegen race conditions, behoud van chatgeschiedenis tijdens navigatie en taakhervatting hersteld, en scroll-verankeringsproblemen opgelost voor een soepelere ervaring.", + "lockedApiConfig": "Vergrendelde API-configuratie: Een nieuwe vergrendelingsknop laat je je API-configuratie vastzetten in alle modi van een workspace, zodat het wisselen van modus je providerinstellingen niet meer reset." }, "cloudAgents": { "heading": "Nieuw in de Cloud:", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 8d27348d49..bcf3317882 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -347,7 +347,9 @@ }, "release": { "heading": "Co nowego:", - "smartCodeFolding": "Smart Code Folding: Kondensacja kontekstu teraz zachowuje lekką mapę twoich plików—sygnatury funkcji, deklaracje klas i definicje typów. Zapewnia to lepszą ciągłość po kondensacji i mądrzejsze edycje przy odwoływaniu się do poprzedniej pracy." + "sonnet46": "Claude Sonnet 4.6: Pełne wsparcie dla najnowszego modelu Claude Sonnet 4.6 od Anthropic u wszystkich dostawców — Anthropic, Bedrock, Vertex, OpenRouter i Vercel AI Gateway.", + "stabilityFixes": "Ulepszenia stabilności: Wzmocnienie cyklu życia delegacji przeciwko warunkom wyścigu, naprawa zachowania historii czatu podczas nawigacji i wznawiania zadań oraz rozwiązanie problemów z zakotwiczaniem przewijania dla płynniejszego działania.", + "lockedApiConfig": "Zablokowana konfiguracja API: Nowy przełącznik blokady pozwala przypiąć konfigurację API we wszystkich trybach w workspace, dzięki czemu przełączanie trybów nie resetuje już ustawień dostawcy." }, "cloudAgents": { "heading": "Nowości w chmurze:", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index d4c7a92f56..4429fa2962 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -347,7 +347,9 @@ }, "release": { "heading": "Novidades:", - "smartCodeFolding": "Smart Code Folding: A condensação de contexto agora preserva um mapa leve de seus arquivos—assinaturas de função, declarações de classe e definições de tipo. Isso oferece melhor continuidade após condensação e edições mais inteligentes ao referenciar trabalho anterior." + "sonnet46": "Claude Sonnet 4.6: Suporte completo para o mais recente modelo Claude Sonnet 4.6 da Anthropic em todos os provedores — Anthropic, Bedrock, Vertex, OpenRouter e Vercel AI Gateway.", + "stabilityFixes": "Melhorias de estabilidade: Ciclo de vida de delegação reforçado contra condições de corrida, correção da preservação do histórico de chat durante navegação e retomada de tarefas, e resolução de problemas de ancoragem de rolagem para uma experiência mais suave.", + "lockedApiConfig": "Configuração de API bloqueada: Um novo botão de bloqueio permite fixar sua configuração de API em todos os modos de um workspace, para que trocar de modo não redefina mais suas configurações de provedor." }, "cloudAgents": { "heading": "Novidades na Nuvem:", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index e8d1760097..091175f0bb 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -321,7 +321,9 @@ }, "release": { "heading": "Что нового:", - "smartCodeFolding": "Smart Code Folding: Конденсация контекста теперь сохраняет легкую карту ваших файлов—сигнатуры функций, объявления классов и определения типов. Это обеспечивает лучшую непрерывность после конденсации и более умные правки при ссылке на предыдущую работу." + "sonnet46": "Claude Sonnet 4.6: Полная поддержка новейшей модели Claude Sonnet 4.6 от Anthropic у всех провайдеров — Anthropic, Bedrock, Vertex, OpenRouter и Vercel AI Gateway.", + "stabilityFixes": "Улучшения стабильности: Укрепление жизненного цикла делегирования от состояний гонки, исправление сохранения истории чата при навигации и возобновлении задач, а также решение проблем привязки прокрутки для более плавной работы.", + "lockedApiConfig": "Заблокированная конфигурация API: Новый переключатель блокировки позволяет закрепить конфигурацию API для всех режимов в рабочем пространстве, чтобы переключение режимов больше не сбрасывало настройки провайдера." }, "cloudAgents": { "heading": "Новое в облаке:", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 2d884eaa92..26a975d797 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -348,7 +348,9 @@ }, "release": { "heading": "Yenilikler:", - "smartCodeFolding": "Smart Code Folding: Bağlam yoğunlaştırması şimdi dosyalarınızın hafif bir haritasını korur—fonksiyon imzaları, sınıf bildirimleri ve tür tanımları. Bu, yoğunlaştırmadan sonra daha iyi devamlılık ve önceki çalışmaya atıfta bulunurken daha akıllı düzenlemeler sağlar." + "sonnet46": "Claude Sonnet 4.6: Anthropic'in en yeni Claude Sonnet 4.6 modeli için tüm sağlayıcılarda tam destek — Anthropic, Bedrock, Vertex, OpenRouter ve Vercel AI Gateway.", + "stabilityFixes": "Kararlılık İyileştirmeleri: Yarış koşullarına karşı delegasyon yaşam döngüsü güçlendirildi, gezinme ve görev devam ettirme sırasında sohbet geçmişi koruması düzeltildi ve daha akıcı bir deneyim için kaydırma sabitleme sorunları çözüldü.", + "lockedApiConfig": "Kilitli API Yapılandırması: Yeni kilit düğmesi, API yapılandırmanı bir çalışma alanındaki tüm modlarda sabitlemenizi sağlar, böylece mod değiştirmek artık sağlayıcı ayarlarını sıfırlamaz." }, "cloudAgents": { "heading": "Cloud'daki yenilikler:", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index ac44807484..70637db9c6 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -348,7 +348,9 @@ }, "release": { "heading": "Tính năng mới:", - "smartCodeFolding": "Smart Code Folding: Nén ngữ cảnh giờ đây bảo tồn một bản đồ nhẹ của các tệp của bạn—chữ ký hàm, khai báo lớp và định nghĩa kiểu. Điều này cung cấp tính liên tục tốt hơn sau nén và chỉnh sửa thông minh hơn khi tham chiếu công việc trước đó." + "sonnet46": "Claude Sonnet 4.6: Hỗ trợ đầy đủ mô hình Claude Sonnet 4.6 mới nhất của Anthropic trên tất cả nhà cung cấp — Anthropic, Bedrock, Vertex, OpenRouter và Vercel AI Gateway.", + "stabilityFixes": "Cải thiện ổn định: Tăng cường vòng đời ủy quyền chống lại điều kiện cạnh tranh, sửa lỗi bảo tồn lịch sử trò chuyện khi điều hướng và tiếp tục tác vụ, đồng thời giải quyết các vấn đề neo cuộn để có trải nghiệm mượt mà hơn.", + "lockedApiConfig": "Cấu hình API đã khóa: Nút khóa mới cho phép bạn ghim cấu hình API trên tất cả các chế độ trong workspace, để việc chuyển chế độ không còn đặt lại cài đặt nhà cung cấp nữa." }, "cloudAgents": { "heading": "Mới trên Cloud:", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 0912c0bcac..d5f945da18 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -348,7 +348,9 @@ }, "release": { "heading": "新增功能:", - "smartCodeFolding": "智能代码折叠:上下文压缩现在保留文件的轻量级映射——函数签名、类声明和类型定义。 这在压缩后提供更好的连续性,引用之前工作时编辑更聪明。" + "sonnet46": "Claude Sonnet 4.6:全面支持 Anthropic 最新的 Claude Sonnet 4.6 模型,覆盖所有提供商 — Anthropic、Bedrock、Vertex、OpenRouter 和 Vercel AI Gateway。", + "stabilityFixes": "稳定性改进:强化委派生命周期以防止竞态条件,修复导航和任务恢复时的聊天历史保留问题,并解决滚动锚定问题以获得更流畅的体验。", + "lockedApiConfig": "锁定 API 配置:新的锁定开关让你可以在工作区的所有模式中固定 API 配置,这样切换模式不再重置你的提供商设置。" }, "cloudAgents": { "heading": "云端新功能:", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 6f7066648a..0693a7d96f 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -369,7 +369,9 @@ }, "release": { "heading": "新增功能:", - "smartCodeFolding": "智慧代碼摺疊:上下文壓縮現保留檔案的輕量級對應圖——函數簽章、類別宣告和型別定義。 這提供壓縮後更佳的連續性,以及引用之前工作時更聰慧的編輯。" + "sonnet46": "Claude Sonnet 4.6:全面支援 Anthropic 最新的 Claude Sonnet 4.6 模型,涵蓋所有提供者 — Anthropic、Bedrock、Vertex、OpenRouter 和 Vercel AI Gateway。", + "stabilityFixes": "穩定性改善:強化委派生命週期以防止競態條件,修復導航和工作恢復時的聊天紀錄保留問題,並解決捲動錨定問題以獲得更流暢的體驗。", + "lockedApiConfig": "鎖定 API 設定:新的鎖定開關讓你可以在工作區的所有模式中固定 API 設定,這樣切換模式不再重設你的提供者設定。" }, "cloudAgents": { "heading": "雲端的新功能:", From 17d534e3ff9473f177b35d23a1baffc3f4c17401 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Tue, 17 Feb 2026 12:56:53 -0800 Subject: [PATCH 003/109] In cli stdin stream mode we should not create new tasks (#11515) --- apps/cli/package.json | 1 + apps/cli/scripts/test-stdin-stream.ts | 67 ++++++++++ apps/cli/src/agent/json-event-emitter.ts | 22 +++- apps/cli/src/commands/cli/run.ts | 148 ++++++++++++++++++++++- 4 files changed, 227 insertions(+), 11 deletions(-) create mode 100644 apps/cli/scripts/test-stdin-stream.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index b00805b058..e5b2c889d6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -17,6 +17,7 @@ "build:extension": "pnpm --filter roo-cline bundle", "dev": "ROO_AUTH_BASE_URL=https://app.roocode.com ROO_SDK_BASE_URL=https://cloud-api.roocode.com ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy tsx src/index.ts", "dev:local": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy tsx src/index.ts", + "dev:test-stdin": "tsx scripts/test-stdin-stream.ts", "clean": "rimraf dist .turbo" }, "dependencies": { diff --git a/apps/cli/scripts/test-stdin-stream.ts b/apps/cli/scripts/test-stdin-stream.ts new file mode 100644 index 0000000000..5212df5b33 --- /dev/null +++ b/apps/cli/scripts/test-stdin-stream.ts @@ -0,0 +1,67 @@ +import path from "path" +import { fileURLToPath } from "url" +import readline from "readline" + +import { execa } from "execa" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const cliRoot = path.resolve(__dirname, "..") + +async function main() { + const child = execa( + "pnpm", + ["dev", "--print", "--stdin-prompt-stream", "--provider", "roo", "--output-format", "stream-json"], + { + cwd: cliRoot, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + reject: false, + forceKillAfterDelay: 2_000, + }, + ) + + child.stdout?.on("data", (chunk) => process.stdout.write(chunk)) + child.stderr?.on("data", (chunk) => process.stderr.write(chunk)) + + console.log("[wrapper] Type a message and press Enter to send it.") + console.log("[wrapper] Type /exit to close stdin and let the CLI finish.") + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + }) + + rl.on("line", (line) => { + if (line.trim() === "/exit") { + console.log("[wrapper] Closing stdin...") + child.stdin?.end() + rl.close() + return + } + + if (!child.stdin?.destroyed) { + child.stdin?.write(`${line}\n`) + } + }) + + const onSignal = (signal: NodeJS.Signals) => { + console.log(`[wrapper] Received ${signal}, forwarding to CLI...`) + rl.close() + child.kill(signal) + } + + process.on("SIGINT", () => onSignal("SIGINT")) + process.on("SIGTERM", () => onSignal("SIGTERM")) + + const result = await child + rl.close() + console.log(`[wrapper] CLI exited with code ${result.exitCode}`) + process.exit(result.exitCode ?? 1) +} + +main().catch((error) => { + console.error("[wrapper] Fatal error:", error) + process.exit(1) +}) diff --git a/apps/cli/src/agent/json-event-emitter.ts b/apps/cli/src/agent/json-event-emitter.ts index 4a6d2629ae..bdf96a763d 100644 --- a/apps/cli/src/agent/json-event-emitter.ts +++ b/apps/cli/src/agent/json-event-emitter.ts @@ -19,7 +19,8 @@ import type { ClineMessage } from "@roo-code/types" import type { JsonEvent, JsonEventCost, JsonFinalOutput } from "@/types/json-events.js" import type { ExtensionClient } from "./extension-client.js" -import type { TaskCompletedEvent } from "./events.js" +import type { AgentStateChangeEvent, TaskCompletedEvent } from "./events.js" +import { AgentLoopState } from "./agent-state.js" /** * Options for JsonEventEmitter. @@ -108,10 +109,11 @@ export class JsonEventEmitter { // Subscribe to message events const unsubMessage = client.on("message", (msg) => this.handleMessage(msg, false)) const unsubMessageUpdated = client.on("messageUpdated", (msg) => this.handleMessage(msg, true)) + const unsubStateChange = client.on("stateChange", (event) => this.handleStateChange(event)) const unsubTaskCompleted = client.on("taskCompleted", (event) => this.handleTaskCompleted(event)) const unsubError = client.on("error", (error) => this.handleError(error)) - this.unsubscribers.push(unsubMessage, unsubMessageUpdated, unsubTaskCompleted, unsubError) + this.unsubscribers.push(unsubMessage, unsubMessageUpdated, unsubStateChange, unsubTaskCompleted, unsubError) // Emit init event this.emitEvent({ @@ -121,6 +123,16 @@ export class JsonEventEmitter { }) } + private handleStateChange(event: AgentStateChangeEvent): void { + // Only treat the next say:text as a prompt echo when a new task starts. + if ( + event.previousState.state === AgentLoopState.NO_TASK && + event.currentState.state !== AgentLoopState.NO_TASK + ) { + this.expectPromptEchoAsUser = true + } + } + /** * Detach from the client and clean up subscriptions. */ @@ -257,6 +269,9 @@ export class JsonEventEmitter { case "user_feedback": case "user_feedback_diff": this.emitEvent(this.buildTextEvent("user", msg.ts, contentToSend, isDone)) + if (isDone) { + this.expectPromptEchoAsUser = false + } break case "api_req_started": { @@ -387,9 +402,6 @@ export class JsonEventEmitter { if (this.mode === "json") { this.outputFinalResult(event.success, resultContent) } - - // Next task in the same process starts with a new echoed prompt. - this.expectPromptEchoAsUser = true } /** diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index c7a01450a4..365febb9f8 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -4,6 +4,7 @@ import { createInterface } from "readline" import { fileURLToPath } from "url" import { createElement } from "react" +import pWaitFor from "p-wait-for" import { setLogger } from "@roo-code/vscode-shim" @@ -306,15 +307,149 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption if (useStdinPromptStream) { let hasReceivedStdinPrompt = false + // stdin stream mode may start at most one task in this process. + let startedTaskFromStdin = false + let activeTaskPromise: Promise | null = null + let fatalStreamError: Error | null = null + // Extension-owned queue depth mirrored from state pushes. + // CLI does not maintain its own prompt queue. + let extensionQueueDepth = 0 - for await (const stdinPrompt of readPromptsFromStdinLines()) { - hasReceivedStdinPrompt = true - await host.runTask(stdinPrompt) - jsonEmitter?.clear() + const waitForInitialState = async () => { + // Give the extension a brief chance to publish initial state so + // we can continue an existing task instead of creating a new one. + await pWaitFor( + () => { + if (fatalStreamError) { + throw fatalStreamError + } + + return host.client.isInitialized() + }, + { interval: 25, timeout: 2_000 }, + ).catch(() => { + // Best-effort wait only; continuing preserves previous behavior. + }) + + if (fatalStreamError) { + throw fatalStreamError + } } - if (!hasReceivedStdinPrompt) { - throw new Error("no prompt provided via stdin") + const waitForActiveTask = async () => { + await pWaitFor( + () => { + if (fatalStreamError) { + throw fatalStreamError + } + + if (!host.client.hasActiveTask()) { + if (!activeTaskPromise && startedTaskFromStdin) { + throw new Error("task is no longer active; cannot continue conversation from stdin") + } + + return false + } + + return true + }, + { interval: 25, timeout: 5_000 }, + ) + } + + const startInitialTask = async (taskPrompt: string) => { + startedTaskFromStdin = true + + activeTaskPromise = host + .runTask(taskPrompt) + .catch((error) => { + fatalStreamError = error instanceof Error ? error : new Error(String(error)) + }) + .finally(() => { + activeTaskPromise = null + }) + + await waitForActiveTask() + } + + const enqueueContinuation = async (text: string) => { + if (!host.client.hasActiveTask()) { + await waitForActiveTask() + } + + // Delegate ordering/drain behavior to the extension message queue. + host.sendToExtension({ type: "queueMessage", text }) + } + + const offClientError = host.client.on("error", (error) => { + fatalStreamError = error + }) + + const onExtensionMessage = (message: { type?: string; state?: { messageQueue?: unknown } }) => { + if (message.type !== "state") { + return + } + + const messageQueue = message.state?.messageQueue + extensionQueueDepth = Array.isArray(messageQueue) ? messageQueue.length : 0 + } + + host.on("extensionWebviewMessage", onExtensionMessage) + + try { + await waitForInitialState() + + for await (const stdinPrompt of readPromptsFromStdinLines()) { + hasReceivedStdinPrompt = true + + // Start once, then always continue via extension queue. + if (!host.client.hasActiveTask() && !startedTaskFromStdin) { + await startInitialTask(stdinPrompt) + } else { + await enqueueContinuation(stdinPrompt) + } + + if (fatalStreamError) { + throw fatalStreamError + } + } + + if (!hasReceivedStdinPrompt) { + throw new Error("no prompt provided via stdin") + } + + await pWaitFor( + () => { + if (fatalStreamError) { + throw fatalStreamError + } + + const isSettled = + !host.client.hasActiveTask() && !activeTaskPromise && extensionQueueDepth === 0 + + if (isSettled) { + return true + } + + if (host.isWaitingForInput() && extensionQueueDepth === 0) { + const currentAsk = host.client.getCurrentAsk() + + if (currentAsk === "completion_result") { + return true + } + + if (currentAsk) { + throw new Error(`stdin ended while task was waiting for input (${currentAsk})`) + } + } + + return false + }, + { interval: 50 }, + ) + } finally { + offClientError() + host.off("extensionWebviewMessage", onExtensionMessage) } } else { await host.runTask(prompt!) @@ -331,6 +466,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption process.stdout.write(JSON.stringify(errorEvent) + "\n") } else { console.error("[CLI] Error:", errorMessage) + if (error instanceof Error) { console.error(error.stack) } From be2b41478502f64fd41fcf3617c2f8cb4d055c9a Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Tue, 17 Feb 2026 12:58:20 -0800 Subject: [PATCH 004/109] chore(cli): prepare release v0.0.55 (#11516) --- apps/cli/CHANGELOG.md | 6 ++++++ apps/cli/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index b59e4c7b95..1c01ec6e1c 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.55] - 2026-02-17 + +### Fixed + +- **Stdin Stream Mode**: Fixed issue where new tasks were incorrectly being created in stdin-prompt-stream mode. The mode now properly reuses the existing task for subsequent prompts instead of creating new tasks. + ## [0.0.54] - 2026-02-15 ### Added diff --git a/apps/cli/package.json b/apps/cli/package.json index e5b2c889d6..7f2e8d296c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@roo-code/cli", - "version": "0.0.54", + "version": "0.0.55", "description": "Roo Code CLI - Run the Roo Code agent from the command line", "private": true, "type": "module", From 1b699d90409e8170d68a15c1b3f908a7ed8a4634 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Wed, 18 Feb 2026 05:01:02 +0800 Subject: [PATCH 005/109] fix: simplify 1M context locale copy for Claude 4 models (#11514) Update 1M context locale copy for Claude 4 models Refresh 1M context beta descriptions so locale text matches current model support in Anthropic, Bedrock, and Vertex. Use a shorter model scope string to keep UI copy readable and easier to maintain while staying accurate. --- webview-ui/src/i18n/locales/ca/settings.json | 6 +++--- webview-ui/src/i18n/locales/de/settings.json | 6 +++--- webview-ui/src/i18n/locales/en/settings.json | 6 +++--- webview-ui/src/i18n/locales/es/settings.json | 6 +++--- webview-ui/src/i18n/locales/fr/settings.json | 6 +++--- webview-ui/src/i18n/locales/hi/settings.json | 6 +++--- webview-ui/src/i18n/locales/id/settings.json | 6 +++--- webview-ui/src/i18n/locales/it/settings.json | 6 +++--- webview-ui/src/i18n/locales/ja/settings.json | 6 +++--- webview-ui/src/i18n/locales/ko/settings.json | 6 +++--- webview-ui/src/i18n/locales/nl/settings.json | 6 +++--- webview-ui/src/i18n/locales/pl/settings.json | 6 +++--- webview-ui/src/i18n/locales/pt-BR/settings.json | 6 +++--- webview-ui/src/i18n/locales/ru/settings.json | 6 +++--- webview-ui/src/i18n/locales/tr/settings.json | 6 +++--- webview-ui/src/i18n/locales/vi/settings.json | 6 +++--- webview-ui/src/i18n/locales/zh-CN/settings.json | 6 +++--- webview-ui/src/i18n/locales/zh-TW/settings.json | 6 +++--- 18 files changed, 54 insertions(+), 54 deletions(-) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index fd4347741e..d1edaf3deb 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Obtenir clau API d'Anthropic", "anthropicUseAuthToken": "Passar la clau API d'Anthropic com a capçalera d'autorització en lloc de X-Api-Key", "anthropic1MContextBetaLabel": "Activa la finestra de context d'1M (Beta)", - "anthropic1MContextBetaDescription": "Amplia la finestra de context a 1 milió de tokens per a Claude Sonnet 4", + "anthropic1MContextBetaDescription": "Amplia la finestra de context a 1 milió de tokens per a Claude Sonnet 4.x / Claude Opus 4.6", "awsBedrock1MContextBetaLabel": "Activa la finestra de context d'1M (Beta)", - "awsBedrock1MContextBetaDescription": "Amplia la finestra de context a 1 milió de tokens per a Claude Sonnet 4", + "awsBedrock1MContextBetaDescription": "Amplia la finestra de context a 1 milió de tokens per a Claude Sonnet 4.x / Claude Opus 4.6", "vertex1MContextBetaLabel": "Activa la finestra de context d'1M (Beta)", - "vertex1MContextBetaDescription": "Amplia la finestra de context a 1 milió de tokens per a Claude Sonnet 4", + "vertex1MContextBetaDescription": "Amplia la finestra de context a 1 milió de tokens per a Claude Sonnet 4.x / Claude Opus 4.6", "basetenApiKey": "Clau API de Baseten", "getBasetenApiKey": "Obtenir clau API de Baseten", "fireworksApiKey": "Clau API de Fireworks", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 0a9850684e..f244a09677 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Anthropic API-Schlüssel erhalten", "anthropicUseAuthToken": "Anthropic API-Schlüssel als Authorization-Header anstelle von X-Api-Key übergeben", "anthropic1MContextBetaLabel": "1M Kontextfenster aktivieren (Beta)", - "anthropic1MContextBetaDescription": "Erweitert das Kontextfenster für Claude Sonnet 4 auf 1 Million Token", + "anthropic1MContextBetaDescription": "Erweitert das Kontextfenster für Claude Sonnet 4.x / Claude Opus 4.6 auf 1 Million Token", "awsBedrock1MContextBetaLabel": "1M Kontextfenster aktivieren (Beta)", - "awsBedrock1MContextBetaDescription": "Erweitert das Kontextfenster für Claude Sonnet 4 auf 1 Million Token", + "awsBedrock1MContextBetaDescription": "Erweitert das Kontextfenster für Claude Sonnet 4.x / Claude Opus 4.6 auf 1 Million Token", "vertex1MContextBetaLabel": "1M Kontextfenster aktivieren (Beta)", - "vertex1MContextBetaDescription": "Erweitert das Kontextfenster für Claude Sonnet 4 auf 1 Million Token", + "vertex1MContextBetaDescription": "Erweitert das Kontextfenster für Claude Sonnet 4.x / Claude Opus 4.6 auf 1 Million Token", "basetenApiKey": "Baseten API-Schlüssel", "getBasetenApiKey": "Baseten API-Schlüssel erhalten", "fireworksApiKey": "Fireworks API-Schlüssel", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 6b225113bf..a54184878c 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -429,11 +429,11 @@ "getAnthropicApiKey": "Get Anthropic API Key", "anthropicUseAuthToken": "Pass Anthropic API Key as Authorization header instead of X-Api-Key", "anthropic1MContextBetaLabel": "Enable 1M context window (Beta)", - "anthropic1MContextBetaDescription": "Extends context window to 1 million tokens for Claude Sonnet 4", + "anthropic1MContextBetaDescription": "Extends context window to 1 million tokens for Claude Sonnet 4.x / Claude Opus 4.6", "awsBedrock1MContextBetaLabel": "Enable 1M context window (Beta)", - "awsBedrock1MContextBetaDescription": "Extends context window to 1 million tokens for Claude Sonnet 4", + "awsBedrock1MContextBetaDescription": "Extends context window to 1 million tokens for Claude Sonnet 4.x / Claude Opus 4.6", "vertex1MContextBetaLabel": "Enable 1M context window (Beta)", - "vertex1MContextBetaDescription": "Extends context window to 1 million tokens for Claude Sonnet 4", + "vertex1MContextBetaDescription": "Extends context window to 1 million tokens for Claude Sonnet 4.x / Claude Opus 4.6", "basetenApiKey": "Baseten API Key", "getBasetenApiKey": "Get Baseten API Key", "fireworksApiKey": "Fireworks API Key", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index ef57c1e1b5..397d2e3e36 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Obtener clave API de Anthropic", "anthropicUseAuthToken": "Pasar la clave API de Anthropic como encabezado de autorización en lugar de X-Api-Key", "anthropic1MContextBetaLabel": "Habilitar ventana de contexto de 1M (Beta)", - "anthropic1MContextBetaDescription": "Amplía la ventana de contexto a 1 millón de tokens para Claude Sonnet 4", + "anthropic1MContextBetaDescription": "Amplía la ventana de contexto a 1 millón de tokens para Claude Sonnet 4.x / Claude Opus 4.6", "awsBedrock1MContextBetaLabel": "Habilitar ventana de contexto de 1M (Beta)", - "awsBedrock1MContextBetaDescription": "Amplía la ventana de contexto a 1 millón de tokens para Claude Sonnet 4", + "awsBedrock1MContextBetaDescription": "Amplía la ventana de contexto a 1 millón de tokens para Claude Sonnet 4.x / Claude Opus 4.6", "vertex1MContextBetaLabel": "Habilitar ventana de contexto de 1M (Beta)", - "vertex1MContextBetaDescription": "Amplía la ventana de contexto a 1 millón de tokens para Claude Sonnet 4", + "vertex1MContextBetaDescription": "Amplía la ventana de contexto a 1 millón de tokens para Claude Sonnet 4.x / Claude Opus 4.6", "basetenApiKey": "Clave API de Baseten", "getBasetenApiKey": "Obtener clave API de Baseten", "fireworksApiKey": "Clave API de Fireworks", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index fd38407712..cac40e04cc 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Obtenir la clé API Anthropic", "anthropicUseAuthToken": "Passer la clé API Anthropic comme en-tête d'autorisation au lieu de X-Api-Key", "anthropic1MContextBetaLabel": "Activer la fenêtre de contexte de 1M (Bêta)", - "anthropic1MContextBetaDescription": "Étend la fenêtre de contexte à 1 million de tokens pour Claude Sonnet 4", + "anthropic1MContextBetaDescription": "Étend la fenêtre de contexte à 1 million de tokens pour Claude Sonnet 4.x / Claude Opus 4.6", "awsBedrock1MContextBetaLabel": "Activer la fenêtre de contexte de 1M (Bêta)", - "awsBedrock1MContextBetaDescription": "Étend la fenêtre de contexte à 1 million de tokens pour Claude Sonnet 4", + "awsBedrock1MContextBetaDescription": "Étend la fenêtre de contexte à 1 million de tokens pour Claude Sonnet 4.x / Claude Opus 4.6", "vertex1MContextBetaLabel": "Activer la fenêtre de contexte de 1M (Bêta)", - "vertex1MContextBetaDescription": "Étend la fenêtre de contexte à 1 million de tokens pour Claude Sonnet 4", + "vertex1MContextBetaDescription": "Étend la fenêtre de contexte à 1 million de tokens pour Claude Sonnet 4.x / Claude Opus 4.6", "basetenApiKey": "Clé API Baseten", "getBasetenApiKey": "Obtenir la clé API Baseten", "fireworksApiKey": "Clé API Fireworks", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 907146146b..2131512a18 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Anthropic API कुंजी प्राप्त करें", "anthropicUseAuthToken": "X-Api-Key के बजाय Anthropic API कुंजी को Authorization हेडर के रूप में पास करें", "anthropic1MContextBetaLabel": "1M संदर्भ विंडो सक्षम करें (बीटा)", - "anthropic1MContextBetaDescription": "Claude Sonnet 4 के लिए संदर्भ विंडो को 1 मिलियन टोकन तक बढ़ाता है", + "anthropic1MContextBetaDescription": "Claude Sonnet 4.x / Claude Opus 4.6 के लिए संदर्भ विंडो को 1 मिलियन टोकन तक बढ़ाता है", "awsBedrock1MContextBetaLabel": "1M संदर्भ विंडो सक्षम करें (बीटा)", - "awsBedrock1MContextBetaDescription": "Claude Sonnet 4 के लिए संदर्भ विंडो को 1 मिलियन टोकन तक बढ़ाता है", + "awsBedrock1MContextBetaDescription": "Claude Sonnet 4.x / Claude Opus 4.6 के लिए संदर्भ विंडो को 1 मिलियन टोकन तक बढ़ाता है", "vertex1MContextBetaLabel": "1M संदर्भ विंडो सक्षम करें (बीटा)", - "vertex1MContextBetaDescription": "Claude Sonnet 4 के लिए संदर्भ विंडो को 1 मिलियन टोकन तक बढ़ाता है", + "vertex1MContextBetaDescription": "Claude Sonnet 4.x / Claude Opus 4.6 के लिए संदर्भ विंडो को 1 मिलियन टोकन तक बढ़ाता है", "basetenApiKey": "Baseten API कुंजी", "getBasetenApiKey": "Baseten API कुंजी प्राप्त करें", "fireworksApiKey": "Fireworks API कुंजी", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index d14293c406..878abad259 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Dapatkan Anthropic API Key", "anthropicUseAuthToken": "Kirim Anthropic API Key sebagai Authorization header alih-alih X-Api-Key", "anthropic1MContextBetaLabel": "Aktifkan jendela konteks 1M (Beta)", - "anthropic1MContextBetaDescription": "Memperluas jendela konteks menjadi 1 juta token untuk Claude Sonnet 4", + "anthropic1MContextBetaDescription": "Memperluas jendela konteks menjadi 1 juta token untuk Claude Sonnet 4.x / Claude Opus 4.6", "awsBedrock1MContextBetaLabel": "Aktifkan jendela konteks 1M (Beta)", - "awsBedrock1MContextBetaDescription": "Memperluas jendela konteks menjadi 1 juta token untuk Claude Sonnet 4", + "awsBedrock1MContextBetaDescription": "Memperluas jendela konteks menjadi 1 juta token untuk Claude Sonnet 4.x / Claude Opus 4.6", "vertex1MContextBetaLabel": "Aktifkan jendela konteks 1M (Beta)", - "vertex1MContextBetaDescription": "Memperluas jendela konteks menjadi 1 juta token untuk Claude Sonnet 4", + "vertex1MContextBetaDescription": "Memperluas jendela konteks menjadi 1 juta token untuk Claude Sonnet 4.x / Claude Opus 4.6", "basetenApiKey": "Baseten API Key", "getBasetenApiKey": "Dapatkan Baseten API Key", "fireworksApiKey": "Fireworks API Key", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 04696a6e9e..062c2119f2 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Ottieni chiave API Anthropic", "anthropicUseAuthToken": "Passa la chiave API Anthropic come header di autorizzazione invece di X-Api-Key", "anthropic1MContextBetaLabel": "Abilita finestra di contesto da 1M (Beta)", - "anthropic1MContextBetaDescription": "Estende la finestra di contesto a 1 milione di token per Claude Sonnet 4", + "anthropic1MContextBetaDescription": "Estende la finestra di contesto a 1 milione di token per Claude Sonnet 4.x / Claude Opus 4.6", "awsBedrock1MContextBetaLabel": "Abilita finestra di contesto da 1M (Beta)", - "awsBedrock1MContextBetaDescription": "Estende la finestra di contesto a 1 milione di token per Claude Sonnet 4", + "awsBedrock1MContextBetaDescription": "Estende la finestra di contesto a 1 milione di token per Claude Sonnet 4.x / Claude Opus 4.6", "vertex1MContextBetaLabel": "Abilita finestra di contesto da 1M (Beta)", - "vertex1MContextBetaDescription": "Estende la finestra di contesto a 1 milione di token per Claude Sonnet 4", + "vertex1MContextBetaDescription": "Estende la finestra di contesto a 1 milione di token per Claude Sonnet 4.x / Claude Opus 4.6", "basetenApiKey": "Chiave API Baseten", "getBasetenApiKey": "Ottieni chiave API Baseten", "fireworksApiKey": "Chiave API Fireworks", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index b899e5a5e8..468aa1b95a 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Anthropic APIキーを取得", "anthropicUseAuthToken": "Anthropic APIキーをX-Api-Keyの代わりにAuthorizationヘッダーとして渡す", "anthropic1MContextBetaLabel": "1Mコンテキストウィンドウを有効にする(ベータ版)", - "anthropic1MContextBetaDescription": "Claude Sonnet 4のコンテキストウィンドウを100万トークンに拡張します", + "anthropic1MContextBetaDescription": "Claude Sonnet 4.x / Claude Opus 4.6のコンテキストウィンドウを100万トークンに拡張します", "awsBedrock1MContextBetaLabel": "1Mコンテキストウィンドウを有効にする(ベータ版)", - "awsBedrock1MContextBetaDescription": "Claude Sonnet 4のコンテキストウィンドウを100万トークンに拡張します", + "awsBedrock1MContextBetaDescription": "Claude Sonnet 4.x / Claude Opus 4.6のコンテキストウィンドウを100万トークンに拡張します", "vertex1MContextBetaLabel": "1Mコンテキストウィンドウを有効にする(ベータ版)", - "vertex1MContextBetaDescription": "Claude Sonnet 4のコンテキストウィンドウを100万トークンに拡張します", + "vertex1MContextBetaDescription": "Claude Sonnet 4.x / Claude Opus 4.6のコンテキストウィンドウを100万トークンに拡張します", "basetenApiKey": "Baseten APIキー", "getBasetenApiKey": "Baseten APIキーを取得", "fireworksApiKey": "Fireworks APIキー", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index bd5bcc748e..ab5b993e77 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Anthropic API 키 받기", "anthropicUseAuthToken": "X-Api-Key 대신 Authorization 헤더로 Anthropic API 키 전달", "anthropic1MContextBetaLabel": "1M 컨텍스트 창 활성화 (베타)", - "anthropic1MContextBetaDescription": "Claude Sonnet 4의 컨텍스트 창을 100만 토큰으로 확장", + "anthropic1MContextBetaDescription": "Claude Sonnet 4.x / Claude Opus 4.6의 컨텍스트 창을 100만 토큰으로 확장", "awsBedrock1MContextBetaLabel": "1M 컨텍스트 창 활성화 (베타)", - "awsBedrock1MContextBetaDescription": "Claude Sonnet 4의 컨텍스트 창을 100만 토큰으로 확장", + "awsBedrock1MContextBetaDescription": "Claude Sonnet 4.x / Claude Opus 4.6의 컨텍스트 창을 100만 토큰으로 확장", "vertex1MContextBetaLabel": "1M 컨텍스트 창 활성화 (베타)", - "vertex1MContextBetaDescription": "Claude Sonnet 4의 컨텍스트 창을 100만 토큰으로 확장", + "vertex1MContextBetaDescription": "Claude Sonnet 4.x / Claude Opus 4.6의 컨텍스트 창을 100만 토큰으로 확장", "basetenApiKey": "Baseten API 키", "getBasetenApiKey": "Baseten API 키 가져오기", "fireworksApiKey": "Fireworks API 키", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 9b34f27821..8e0d7a8b55 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Anthropic API-sleutel ophalen", "anthropicUseAuthToken": "Anthropic API-sleutel als Authorization-header doorgeven in plaats van X-Api-Key", "anthropic1MContextBetaLabel": "1M contextvenster inschakelen (bèta)", - "anthropic1MContextBetaDescription": "Breidt het contextvenster uit tot 1 miljoen tokens voor Claude Sonnet 4", + "anthropic1MContextBetaDescription": "Breidt het contextvenster uit tot 1 miljoen tokens voor Claude Sonnet 4.x / Claude Opus 4.6", "awsBedrock1MContextBetaLabel": "1M contextvenster inschakelen (bèta)", - "awsBedrock1MContextBetaDescription": "Breidt het contextvenster uit tot 1 miljoen tokens voor Claude Sonnet 4", + "awsBedrock1MContextBetaDescription": "Breidt het contextvenster uit tot 1 miljoen tokens voor Claude Sonnet 4.x / Claude Opus 4.6", "vertex1MContextBetaLabel": "1M contextvenster inschakelen (bèta)", - "vertex1MContextBetaDescription": "Breidt het contextvenster uit tot 1 miljoen tokens voor Claude Sonnet 4", + "vertex1MContextBetaDescription": "Breidt het contextvenster uit tot 1 miljoen tokens voor Claude Sonnet 4.x / Claude Opus 4.6", "basetenApiKey": "Baseten API-sleutel", "getBasetenApiKey": "Baseten API-sleutel verkrijgen", "fireworksApiKey": "Fireworks API-sleutel", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 558d4ae218..b064eeabcc 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Uzyskaj klucz API Anthropic", "anthropicUseAuthToken": "Przekaż klucz API Anthropic jako nagłówek Authorization zamiast X-Api-Key", "anthropic1MContextBetaLabel": "Włącz okno kontekstowe 1M (Beta)", - "anthropic1MContextBetaDescription": "Rozszerza okno kontekstowe do 1 miliona tokenów dla Claude Sonnet 4", + "anthropic1MContextBetaDescription": "Rozszerza okno kontekstowe do 1 miliona tokenów dla Claude Sonnet 4.x / Claude Opus 4.6", "awsBedrock1MContextBetaLabel": "Włącz okno kontekstowe 1M (Beta)", - "awsBedrock1MContextBetaDescription": "Rozszerza okno kontekstowe do 1 miliona tokenów dla Claude Sonnet 4", + "awsBedrock1MContextBetaDescription": "Rozszerza okno kontekstowe do 1 miliona tokenów dla Claude Sonnet 4.x / Claude Opus 4.6", "vertex1MContextBetaLabel": "Włącz okno kontekstowe 1M (Beta)", - "vertex1MContextBetaDescription": "Rozszerza okno kontekstowe do 1 miliona tokenów dla Claude Sonnet 4", + "vertex1MContextBetaDescription": "Rozszerza okno kontekstowe do 1 miliona tokenów dla Claude Sonnet 4.x / Claude Opus 4.6", "basetenApiKey": "Klucz API Baseten", "getBasetenApiKey": "Uzyskaj klucz API Baseten", "fireworksApiKey": "Klucz API Fireworks", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index e8c5e8f1e0..01a72ce29c 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Obter chave de API Anthropic", "anthropicUseAuthToken": "Passar a chave de API Anthropic como cabeçalho Authorization em vez de X-Api-Key", "anthropic1MContextBetaLabel": "Ativar janela de contexto de 1M (Beta)", - "anthropic1MContextBetaDescription": "Estende a janela de contexto para 1 milhão de tokens para o Claude Sonnet 4", + "anthropic1MContextBetaDescription": "Estende a janela de contexto para 1 milhão de tokens para o Claude Sonnet 4.x / Claude Opus 4.6", "awsBedrock1MContextBetaLabel": "Ativar janela de contexto de 1M (Beta)", - "awsBedrock1MContextBetaDescription": "Estende a janela de contexto para 1 milhão de tokens para o Claude Sonnet 4", + "awsBedrock1MContextBetaDescription": "Estende a janela de contexto para 1 milhão de tokens para o Claude Sonnet 4.x / Claude Opus 4.6", "vertex1MContextBetaLabel": "Ativar janela de contexto de 1M (Beta)", - "vertex1MContextBetaDescription": "Estende a janela de contexto para 1 milhão de tokens para o Claude Sonnet 4", + "vertex1MContextBetaDescription": "Estende a janela de contexto para 1 milhão de tokens para o Claude Sonnet 4.x / Claude Opus 4.6", "basetenApiKey": "Chave de API Baseten", "getBasetenApiKey": "Obter chave de API Baseten", "fireworksApiKey": "Chave de API Fireworks", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 12b78ad48f..e0abd7dd5e 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Получить Anthropic API-ключ", "anthropicUseAuthToken": "Передавать Anthropic API-ключ как Authorization-заголовок вместо X-Api-Key", "anthropic1MContextBetaLabel": "Включить контекстное окно 1M (бета)", - "anthropic1MContextBetaDescription": "Расширяет контекстное окно до 1 миллиона токенов для Claude Sonnet 4", + "anthropic1MContextBetaDescription": "Расширяет контекстное окно до 1 миллиона токенов для Claude Sonnet 4.x / Claude Opus 4.6", "awsBedrock1MContextBetaLabel": "Включить контекстное окно 1M (бета)", - "awsBedrock1MContextBetaDescription": "Расширяет контекстное окно до 1 миллиона токенов для Claude Sonnet 4", + "awsBedrock1MContextBetaDescription": "Расширяет контекстное окно до 1 миллиона токенов для Claude Sonnet 4.x / Claude Opus 4.6", "vertex1MContextBetaLabel": "Включить контекстное окно 1M (бета)", - "vertex1MContextBetaDescription": "Расширяет контекстное окно до 1 миллиона токенов для Claude Sonnet 4", + "vertex1MContextBetaDescription": "Расширяет контекстное окно до 1 миллиона токенов для Claude Sonnet 4.x / Claude Opus 4.6", "basetenApiKey": "Baseten API-ключ", "getBasetenApiKey": "Получить Baseten API-ключ", "fireworksApiKey": "Fireworks API-ключ", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 1aa209b8ae..21d037c2ce 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Anthropic API Anahtarı Al", "anthropicUseAuthToken": "Anthropic API Anahtarını X-Api-Key yerine Authorization başlığı olarak geçir", "anthropic1MContextBetaLabel": "1M bağlam penceresini etkinleştir (Beta)", - "anthropic1MContextBetaDescription": "Claude Sonnet 4 için bağlam penceresini 1 milyon token'a genişletir", + "anthropic1MContextBetaDescription": "Claude Sonnet 4.x / Claude Opus 4.6 için bağlam penceresini 1 milyon token'a genişletir", "awsBedrock1MContextBetaLabel": "1M bağlam penceresini etkinleştir (Beta)", - "awsBedrock1MContextBetaDescription": "Claude Sonnet 4 için bağlam penceresini 1 milyon token'a genişletir", + "awsBedrock1MContextBetaDescription": "Claude Sonnet 4.x / Claude Opus 4.6 için bağlam penceresini 1 milyon token'a genişletir", "vertex1MContextBetaLabel": "1M bağlam penceresini etkinleştir (Beta)", - "vertex1MContextBetaDescription": "Claude Sonnet 4 için bağlam penceresini 1 milyon token'a genişletir", + "vertex1MContextBetaDescription": "Claude Sonnet 4.x / Claude Opus 4.6 için bağlam penceresini 1 milyon token'a genişletir", "basetenApiKey": "Baseten API Anahtarı", "getBasetenApiKey": "Baseten API Anahtarı Al", "fireworksApiKey": "Fireworks API Anahtarı", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 4ab2a6c138..776d17ffa5 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "Lấy khóa API Anthropic", "anthropicUseAuthToken": "Truyền khóa API Anthropic dưới dạng tiêu đề Authorization thay vì X-Api-Key", "anthropic1MContextBetaLabel": "Bật cửa sổ ngữ cảnh 1M (Beta)", - "anthropic1MContextBetaDescription": "Mở rộng cửa sổ ngữ cảnh lên 1 triệu token cho Claude Sonnet 4", + "anthropic1MContextBetaDescription": "Mở rộng cửa sổ ngữ cảnh lên 1 triệu token cho Claude Sonnet 4.x / Claude Opus 4.6", "awsBedrock1MContextBetaLabel": "Bật cửa sổ ngữ cảnh 1M (Beta)", - "awsBedrock1MContextBetaDescription": "Mở rộng cửa sổ ngữ cảnh lên 1 triệu token cho Claude Sonnet 4", + "awsBedrock1MContextBetaDescription": "Mở rộng cửa sổ ngữ cảnh lên 1 triệu token cho Claude Sonnet 4.x / Claude Opus 4.6", "vertex1MContextBetaLabel": "Bật cửa sổ ngữ cảnh 1M (Beta)", - "vertex1MContextBetaDescription": "Mở rộng cửa sổ ngữ cảnh lên 1 triệu token cho Claude Sonnet 4", + "vertex1MContextBetaDescription": "Mở rộng cửa sổ ngữ cảnh lên 1 triệu token cho Claude Sonnet 4.x / Claude Opus 4.6", "basetenApiKey": "Khóa API Baseten", "getBasetenApiKey": "Lấy khóa API Baseten", "fireworksApiKey": "Khóa API Fireworks", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 173e69076f..baa4c1c1f3 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -366,11 +366,11 @@ "getAnthropicApiKey": "获取 Anthropic API 密钥", "anthropicUseAuthToken": "将 Anthropic API 密钥作为 Authorization 标头传递,而不是 X-Api-Key", "anthropic1MContextBetaLabel": "启用 1M 上下文窗口 (Beta)", - "anthropic1MContextBetaDescription": "为 Claude Sonnet 4 将上下文窗口扩展至 100 万个 token", + "anthropic1MContextBetaDescription": "为 Claude Sonnet 4.x / Claude Opus 4.6 将上下文窗口扩展至 100 万个 token", "awsBedrock1MContextBetaLabel": "启用 1M 上下文窗口 (Beta)", - "awsBedrock1MContextBetaDescription": "为 Claude Sonnet 4 将上下文窗口扩展至 100 万个 token", + "awsBedrock1MContextBetaDescription": "为 Claude Sonnet 4.x / Claude Opus 4.6 将上下文窗口扩展至 100 万个 token", "vertex1MContextBetaLabel": "启用 1M 上下文窗口 (Beta)", - "vertex1MContextBetaDescription": "为 Claude Sonnet 4 将上下文窗口扩展至 100 万个 token", + "vertex1MContextBetaDescription": "为 Claude Sonnet 4.x / Claude Opus 4.6 将上下文窗口扩展至 100 万个 token", "basetenApiKey": "Baseten API 密钥", "getBasetenApiKey": "获取 Baseten API 密钥", "fireworksApiKey": "Fireworks API 密钥", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index cd0e23596e..2c45d327b1 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -376,11 +376,11 @@ "getAnthropicApiKey": "取得 Anthropic API 金鑰", "anthropicUseAuthToken": "將 Anthropic API 金鑰作為 Authorization 標頭傳遞,而非使用 X-Api-Key", "anthropic1MContextBetaLabel": "啟用 1M 上下文視窗 (Beta)", - "anthropic1MContextBetaDescription": "為 Claude Sonnet 4 將上下文視窗擴展至 100 萬個 token", + "anthropic1MContextBetaDescription": "為 Claude Sonnet 4.x / Claude Opus 4.6 將上下文視窗擴展至 100 萬個 token", "awsBedrock1MContextBetaLabel": "啟用 1M 上下文視窗 (Beta)", - "awsBedrock1MContextBetaDescription": "為 Claude Sonnet 4 將上下文視窗擴展至 100 萬個 token", + "awsBedrock1MContextBetaDescription": "為 Claude Sonnet 4.x / Claude Opus 4.6 將上下文視窗擴展至 100 萬個 token", "vertex1MContextBetaLabel": "啟用 1M 上下文視窗 (Beta)", - "vertex1MContextBetaDescription": "為 Claude Sonnet 4 將上下文視窗擴展至 100 萬個 token", + "vertex1MContextBetaDescription": "為 Claude Sonnet 4.x / Claude Opus 4.6 將上下文視窗擴展至 100 萬個 token", "basetenApiKey": "Baseten API 金鑰", "getBasetenApiKey": "取得 Baseten API 金鑰", "fireworksApiKey": "Fireworks API 金鑰", From 44df43063f6059557414372e9a36c223b0e3911d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:25:31 -0700 Subject: [PATCH 006/109] Changeset version bump (#11513) * changeset version bump * fix: update changelog-config to support multi-line entries and restore full v3.48.0 CHANGELOG --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Hannes Rudolph --- .changeset/changelog-config.js | 4 +-- .changeset/v3.48.0.md | 53 ---------------------------------- CHANGELOG.md | 50 ++++++++++++++++++++++++++++++++ src/package.json | 2 +- 4 files changed, 53 insertions(+), 56 deletions(-) delete mode 100644 .changeset/v3.48.0.md diff --git a/.changeset/changelog-config.js b/.changeset/changelog-config.js index 0ab9a9e48e..00f93f281e 100644 --- a/.changeset/changelog-config.js +++ b/.changeset/changelog-config.js @@ -1,9 +1,9 @@ const getReleaseLine = async (changeset) => { - const [firstLine] = changeset.summary + const lines = changeset.summary .split("\n") .map((l) => l.trim()) .filter(Boolean) - return `- ${firstLine}` + return lines.map((line) => (line.startsWith("- ") ? line : `- ${line}`)).join("\n") } const getDependencyReleaseLine = async () => { diff --git a/.changeset/v3.48.0.md b/.changeset/v3.48.0.md deleted file mode 100644 index e000dc04b8..0000000000 --- a/.changeset/v3.48.0.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"roo-cline": minor ---- - -- Add Anthropic Claude Sonnet 4.6 support across all providers — Anthropic, Bedrock, Vertex, OpenRouter, and Vercel AI Gateway (PR #11509 by @PeterDaveHello) -- Add lock toggle to pin API config across all modes in a workspace (PR #11295 by @hannesrudolph) -- Fix: Prevent parent task state loss during orchestrator delegation (PR #11281 by @hannesrudolph) -- Fix: Resolve race condition in new_task delegation that loses parent task history (PR #11331 by @daniel-lxs) -- Fix: Serialize taskHistory writes and fix delegation status overwrite race (PR #11335 by @hannesrudolph) -- Fix: Prevent chat history loss during cloud/settings navigation (#11371 by @SannidhyaSah, PR #11372 by @SannidhyaSah) -- Fix: Preserve condensation summary during task resume (#11487 by @SannidhyaSah, PR #11488 by @SannidhyaSah) -- Fix: Resolve chat scroll anchoring and task-switch scroll race conditions (PR #11385 by @hannesrudolph) -- Fix: Preserve pasted images in chatbox during chat activity (PR #11375 by @app/roomote) -- Add disabledTools setting to globally disable native tools (PR #11277 by @daniel-lxs) -- Rename search_and_replace tool to edit and unify edit-family UI (PR #11296 by @hannesrudolph) -- Render nested subtasks as recursive tree in history view (PR #11299 by @hannesrudolph) -- Remove 9 low-usage providers and add retired-provider UX (PR #11297 by @hannesrudolph) -- Remove browser use functionality entirely (PR #11392 by @hannesrudolph) -- Remove built-in skills and built-in skills mechanism (PR #11414 by @hannesrudolph) -- Remove footgun prompting (file-based system prompt override) (PR #11387 by @hannesrudolph) -- Batch consecutive tool calls in chat UI with shared utility (PR #11245 by @hannesrudolph) -- Validate Gemini thinkingLevel against model capabilities and handle empty streams (PR #11303 by @hannesrudolph) -- Add GLM-5 model support to Z.ai provider (PR #11440 by @app/roomote) -- Fix: Prevent double notification sound playback (PR #11283 by @hannesrudolph) -- Fix: Prevent false unsaved changes prompt with OpenAI Compatible headers (#8230 by @hannesrudolph, PR #11334 by @daniel-lxs) -- Fix: Cancel backend auto-approval timeout when auto-approve is toggled off mid-countdown (PR #11439 by @SannidhyaSah) -- Fix: Add follow_up param validation in AskFollowupQuestionTool (PR #11484 by @rossdonald) -- Fix: Prevent webview postMessage crashes and make dispose idempotent (PR #11313 by @0xMink) -- Fix: Avoid zsh process-substitution false positives in assignments (PR #11365 by @hannesrudolph) -- Fix: Harden command auto-approval against inline JS false positives (PR #11382 by @hannesrudolph) -- Fix: Make tab close best-effort in DiffViewProvider.open (PR #11363 by @0xMink) -- Fix: Canonicalize core.worktree comparison to prevent Windows path mismatch failures (PR #11346 by @0xMink) -- Fix: Make removeClineFromStack() delegation-aware to prevent orphaned parent tasks (PR #11302 by @app/roomote) -- Fix task resumption in the API module (PR #11369 by @cte) -- Make defaultTemperature required in getModelParams to prevent silent temperature overrides (PR #11218 by @app/roomote) -- Remove noisy console.warn logs from NativeToolCallParser (PR #11264 by @daniel-lxs) -- Consolidate getState calls in resolveWebviewView (PR #11320 by @0xMink) -- Clean up repo-facing mode rules (PR #11410 by @hannesrudolph) -- Implement ModelMessage storage layer with AI SDK response messages (PR #11409 by @daniel-lxs) -- Extract translation and merge resolver modes into reusable skills (PR #11215 by @app/roomote) -- Add blog section with initial posts to roocode.com (PR #11127 by @app/roomote) -- Replace Roomote Control with Linear Integration in cloud features grid (PR #11280 by @app/roomote) -- Add IPC query handlers for commands, modes, and models (PR #11279 by @cte) -- Add stdin stream mode for the CLI (PR #11476 by @cte) -- Make CLI auto-approve by default with require-approval opt-in (PR #11424 by @cte) -- Update CLI default model from Opus 4.5 to Opus 4.6 (PR #11273 by @app/roomote) -- Add linux-arm64 support for the Roo CLI (PR #11314 by @cte) -- Release: v1.110.0 version bump (PR #11278 by @jr) -- Release: v1.111.0 (PR #11421 by @cte) -- CLI release: v0.0.51 (PR #11274 by @cte) -- CLI release: v0.0.52 (PR #11324 by @cte) -- CLI release: v0.0.53 (PR #11425 by @cte) -- CLI release: v0.0.54 (PR #11477 by @cte) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d932eae0d..565c8eb051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Roo Code Changelog +## [3.48.0] + +- Add Anthropic Claude Sonnet 4.6 support across all providers — Anthropic, Bedrock, Vertex, OpenRouter, and Vercel AI Gateway (PR #11509 by @PeterDaveHello) +- Add lock toggle to pin API config across all modes in a workspace (PR #11295 by @hannesrudolph) +- Fix: Prevent parent task state loss during orchestrator delegation (PR #11281 by @hannesrudolph) +- Fix: Resolve race condition in new_task delegation that loses parent task history (PR #11331 by @daniel-lxs) +- Fix: Serialize taskHistory writes and fix delegation status overwrite race (PR #11335 by @hannesrudolph) +- Fix: Prevent chat history loss during cloud/settings navigation (#11371 by @SannidhyaSah, PR #11372 by @SannidhyaSah) +- Fix: Preserve condensation summary during task resume (#11487 by @SannidhyaSah, PR #11488 by @SannidhyaSah) +- Fix: Resolve chat scroll anchoring and task-switch scroll race conditions (PR #11385 by @hannesrudolph) +- Fix: Preserve pasted images in chatbox during chat activity (PR #11375 by @app/roomote) +- Add disabledTools setting to globally disable native tools (PR #11277 by @daniel-lxs) +- Rename search_and_replace tool to edit and unify edit-family UI (PR #11296 by @hannesrudolph) +- Render nested subtasks as recursive tree in history view (PR #11299 by @hannesrudolph) +- Remove 9 low-usage providers and add retired-provider UX (PR #11297 by @hannesrudolph) +- Remove browser use functionality entirely (PR #11392 by @hannesrudolph) +- Remove built-in skills and built-in skills mechanism (PR #11414 by @hannesrudolph) +- Remove footgun prompting (file-based system prompt override) (PR #11387 by @hannesrudolph) +- Batch consecutive tool calls in chat UI with shared utility (PR #11245 by @hannesrudolph) +- Validate Gemini thinkingLevel against model capabilities and handle empty streams (PR #11303 by @hannesrudolph) +- Add GLM-5 model support to Z.ai provider (PR #11440 by @app/roomote) +- Fix: Prevent double notification sound playback (PR #11283 by @hannesrudolph) +- Fix: Prevent false unsaved changes prompt with OpenAI Compatible headers (#8230 by @hannesrudolph, PR #11334 by @daniel-lxs) +- Fix: Cancel backend auto-approval timeout when auto-approve is toggled off mid-countdown (PR #11439 by @SannidhyaSah) +- Fix: Add follow_up param validation in AskFollowupQuestionTool (PR #11484 by @rossdonald) +- Fix: Prevent webview postMessage crashes and make dispose idempotent (PR #11313 by @0xMink) +- Fix: Avoid zsh process-substitution false positives in assignments (PR #11365 by @hannesrudolph) +- Fix: Harden command auto-approval against inline JS false positives (PR #11382 by @hannesrudolph) +- Fix: Make tab close best-effort in DiffViewProvider.open (PR #11363 by @0xMink) +- Fix: Canonicalize core.worktree comparison to prevent Windows path mismatch failures (PR #11346 by @0xMink) +- Fix: Make removeClineFromStack() delegation-aware to prevent orphaned parent tasks (PR #11302 by @app/roomote) +- Fix task resumption in the API module (PR #11369 by @cte) +- Make defaultTemperature required in getModelParams to prevent silent temperature overrides (PR #11218 by @app/roomote) +- Remove noisy console.warn logs from NativeToolCallParser (PR #11264 by @daniel-lxs) +- Consolidate getState calls in resolveWebviewView (PR #11320 by @0xMink) +- Clean up repo-facing mode rules (PR #11410 by @hannesrudolph) +- Implement ModelMessage storage layer with AI SDK response messages (PR #11409 by @daniel-lxs) +- Extract translation and merge resolver modes into reusable skills (PR #11215 by @app/roomote) +- Add blog section with initial posts to roocode.com (PR #11127 by @app/roomote) +- Replace Roomote Control with Linear Integration in cloud features grid (PR #11280 by @app/roomote) +- Add IPC query handlers for commands, modes, and models (PR #11279 by @cte) +- Add stdin stream mode for the CLI (PR #11476 by @cte) +- Make CLI auto-approve by default with require-approval opt-in (PR #11424 by @cte) +- Update CLI default model from Opus 4.5 to Opus 4.6 (PR #11273 by @app/roomote) +- Add linux-arm64 support for the Roo CLI (PR #11314 by @cte) +- CLI release: v0.0.51 (PR #11274 by @cte) +- CLI release: v0.0.52 (PR #11324 by @cte) +- CLI release: v0.0.53 (PR #11425 by @cte) +- CLI release: v0.0.54 (PR #11477 by @cte) + ## [3.45.0] - 2026-01-27 ![3.45.0 Release - Smart Code Folding](/releases/3.45.0-release.png) diff --git a/src/package.json b/src/package.json index 73cbddfe37..886f3d37e1 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.47.3", + "version": "3.48.0", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", From bfbfaf6d465188afffce478610317393eb0ff7cb Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:32:15 -0500 Subject: [PATCH 007/109] fix: await MCP server initialization before returning McpHub instance (#11518) * fix: await MCP server initialization before returning McpHub instance MCP tools were unavailable on the first task turn when started via IPC because McpHub's constructor fired initializeGlobalMcpServers() and initializeProjectMcpServers() without awaiting them. getInstance() returned a hub with servers still in "connecting" state. Store the combined initialization promise and expose waitUntilReady(), then await it in McpServerManager.getInstance() so the hub is only returned after all servers have connected or timed out. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: assign McpHub instance only after waitUntilReady() resolves Closes race condition where concurrent callers of getInstance() could receive a hub that has not finished initialization. The hub is now created in a local variable and only assigned to this.instance after waitUntilReady() completes. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Roo Code --- src/services/mcp/McpHub.ts | 15 +++++++++++++-- src/services/mcp/McpServerManager.ts | 5 ++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 3d02ce25f1..ea38ee02d6 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -161,14 +161,25 @@ export class McpHub { private isProgrammaticUpdate: boolean = false private flagResetTimer?: NodeJS.Timeout private sanitizedNameRegistry: Map = new Map() + private initializationPromise: Promise constructor(provider: ClineProvider) { this.providerRef = new WeakRef(provider) this.watchMcpSettingsFile() this.watchProjectMcpFile().catch(console.error) this.setupWorkspaceFoldersWatcher() - this.initializeGlobalMcpServers() - this.initializeProjectMcpServers() + this.initializationPromise = Promise.all([ + this.initializeGlobalMcpServers(), + this.initializeProjectMcpServers(), + ]).then(() => {}) + } + + /** + * Waits until all MCP servers have finished their initial connection attempts. + * Each server individually handles its own timeout, so this will not block indefinitely. + */ + async waitUntilReady(): Promise { + await this.initializationPromise } /** * Registers a client (e.g., ClineProvider) using this hub. diff --git a/src/services/mcp/McpServerManager.ts b/src/services/mcp/McpServerManager.ts index e15f9db0a7..3fd7146d9f 100644 --- a/src/services/mcp/McpServerManager.ts +++ b/src/services/mcp/McpServerManager.ts @@ -36,7 +36,10 @@ export class McpServerManager { try { // Double-check instance in case it was created while we were waiting if (!this.instance) { - this.instance = new McpHub(provider) + const hub = new McpHub(provider) + // Wait for all MCP servers to finish connecting (or timing out) + await hub.waitUntilReady() + this.instance = hub // Store a unique identifier in global state to track the primary instance await context.globalState.update(this.GLOBAL_STATE_KEY, Date.now().toString()) } From d575295883d952c8015616a8fa5a52a587559451 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:42:48 -0700 Subject: [PATCH 008/109] feat: add DeleteQueuedMessage IPC command (#11464) * feat: add DeleteQueuedMessage IPC command for queue removal * Delete .changeset/delete-queued-message-ipc.md * fix: add try/catch to DeleteQueuedMessage IPC handler and early return in deleteQueuedMessage --------- Co-authored-by: Roo Code Co-authored-by: Hannes Rudolph --- packages/ipc/src/ipc-client.ts | 7 ++ packages/types/src/__tests__/ipc.test.ts | 48 ++++++++++++- packages/types/src/ipc.ts | 5 ++ .../api-delete-queued-message.spec.ts | 70 +++++++++++++++++++ src/extension/api.ts | 20 ++++++ 5 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/extension/__tests__/api-delete-queued-message.spec.ts diff --git a/packages/ipc/src/ipc-client.ts b/packages/ipc/src/ipc-client.ts index da96ab90f3..d374cb186a 100644 --- a/packages/ipc/src/ipc-client.ts +++ b/packages/ipc/src/ipc-client.ts @@ -108,6 +108,13 @@ export class IpcClient extends EventEmitter { }) } + public deleteQueuedMessage(messageId: string) { + this.sendCommand({ + commandName: TaskCommandName.DeleteQueuedMessage, + data: messageId, + }) + } + public sendMessage(message: IpcMessage) { ipc.of[this._id]?.emit("message", message) } diff --git a/packages/types/src/__tests__/ipc.test.ts b/packages/types/src/__tests__/ipc.test.ts index 856b3f2cc1..a843354a55 100644 --- a/packages/types/src/__tests__/ipc.test.ts +++ b/packages/types/src/__tests__/ipc.test.ts @@ -6,8 +6,19 @@ describe("IPC Types", () => { expect(TaskCommandName.ResumeTask).toBe("ResumeTask") }) + it("should include DeleteQueuedMessage command", () => { + expect(TaskCommandName.DeleteQueuedMessage).toBe("DeleteQueuedMessage") + }) + it("should have all expected task commands", () => { - const expectedCommands = ["StartNewTask", "CancelTask", "CloseTask", "ResumeTask"] + const expectedCommands = [ + "StartNewTask", + "CancelTask", + "CloseTask", + "ResumeTask", + "SendMessage", + "DeleteQueuedMessage", + ] const actualCommands = Object.values(TaskCommandName) expectedCommands.forEach((command) => { @@ -70,5 +81,40 @@ describe("IPC Types", () => { const result = taskCommandSchema.safeParse(invalidCommand) expect(result.success).toBe(false) }) + + it("should validate DeleteQueuedMessage command with messageId", () => { + const command = { + commandName: TaskCommandName.DeleteQueuedMessage, + data: "msg-abc-123", + } + + const result = taskCommandSchema.safeParse(command) + expect(result.success).toBe(true) + + if (result.success && result.data.commandName === TaskCommandName.DeleteQueuedMessage) { + expect(result.data.commandName).toBe("DeleteQueuedMessage") + expect(result.data.data).toBe("msg-abc-123") + } + }) + + it("should reject DeleteQueuedMessage command with invalid data", () => { + const invalidCommand = { + commandName: TaskCommandName.DeleteQueuedMessage, + data: 123, // Should be string + } + + const result = taskCommandSchema.safeParse(invalidCommand) + expect(result.success).toBe(false) + }) + + it("should reject DeleteQueuedMessage command without data", () => { + const invalidCommand = { + commandName: TaskCommandName.DeleteQueuedMessage, + // Missing data field + } + + const result = taskCommandSchema.safeParse(invalidCommand) + expect(result.success).toBe(false) + }) }) }) diff --git a/packages/types/src/ipc.ts b/packages/types/src/ipc.ts index 90a1478a4d..fea040af0b 100644 --- a/packages/types/src/ipc.ts +++ b/packages/types/src/ipc.ts @@ -49,6 +49,7 @@ export enum TaskCommandName { GetCommands = "GetCommands", GetModes = "GetModes", GetModels = "GetModels", + DeleteQueuedMessage = "DeleteQueuedMessage", } /** @@ -91,6 +92,10 @@ export const taskCommandSchema = z.discriminatedUnion("commandName", [ z.object({ commandName: z.literal(TaskCommandName.GetModels), }), + z.object({ + commandName: z.literal(TaskCommandName.DeleteQueuedMessage), + data: z.string(), // messageId + }), ]) export type TaskCommand = z.infer diff --git a/src/extension/__tests__/api-delete-queued-message.spec.ts b/src/extension/__tests__/api-delete-queued-message.spec.ts new file mode 100644 index 0000000000..6bf6014bf8 --- /dev/null +++ b/src/extension/__tests__/api-delete-queued-message.spec.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as vscode from "vscode" + +import { API } from "../api" +import { ClineProvider } from "../../core/webview/ClineProvider" + +vi.mock("vscode") +vi.mock("../../core/webview/ClineProvider") + +describe("API - DeleteQueuedMessage Command", () => { + let api: API + let mockOutputChannel: vscode.OutputChannel + let mockProvider: ClineProvider + let mockRemoveMessage: ReturnType + let mockLog: ReturnType + + beforeEach(() => { + mockOutputChannel = { + appendLine: vi.fn(), + } as unknown as vscode.OutputChannel + + mockRemoveMessage = vi.fn().mockReturnValue(true) + + mockProvider = { + context: {} as vscode.ExtensionContext, + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + getCurrentTaskStack: vi.fn().mockReturnValue([]), + getCurrentTask: vi.fn().mockReturnValue({ + messageQueueService: { + removeMessage: mockRemoveMessage, + }, + }), + viewLaunched: true, + } as unknown as ClineProvider + + mockLog = vi.fn() + + api = new API(mockOutputChannel, mockProvider, undefined, true) + ;(api as any).log = mockLog + }) + + it("should remove a queued message by id", () => { + const messageId = "msg-abc-123" + + api.deleteQueuedMessage(messageId) + + expect(mockRemoveMessage).toHaveBeenCalledWith(messageId) + expect(mockRemoveMessage).toHaveBeenCalledTimes(1) + }) + + it("should handle missing current task gracefully and log a message", () => { + ;(mockProvider.getCurrentTask as ReturnType).mockReturnValue(undefined) + + // Should not throw + expect(() => api.deleteQueuedMessage("msg-abc-123")).not.toThrow() + expect(mockLog).toHaveBeenCalledWith( + "[API#deleteQueuedMessage] no current task; ignoring delete for messageId msg-abc-123", + ) + expect(mockRemoveMessage).not.toHaveBeenCalled() + }) + + it("should handle non-existent message id gracefully", () => { + mockRemoveMessage.mockReturnValue(false) + + // Should not throw even when removeMessage returns false + expect(() => api.deleteQueuedMessage("non-existent-id")).not.toThrow() + expect(mockRemoveMessage).toHaveBeenCalledWith("non-existent-id") + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index 25c81a6589..4a66b40078 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -150,6 +150,15 @@ export class API extends EventEmitter implements RooCodeAPI { sendResponse(RooCodeEventName.ModelsResponse, [{}]) } + break + case TaskCommandName.DeleteQueuedMessage: + this.log(`[API] DeleteQueuedMessage -> ${command.data}`) + try { + this.deleteQueuedMessage(command.data) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.log(`[API] DeleteQueuedMessage failed for messageId ${command.data}: ${errorMessage}`) + } break } }) @@ -266,6 +275,17 @@ export class API extends EventEmitter implements RooCodeAPI { await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images }) } + public deleteQueuedMessage(messageId: string) { + const currentTask = this.sidebarProvider.getCurrentTask() + + if (!currentTask) { + this.log(`[API#deleteQueuedMessage] no current task; ignoring delete for messageId ${messageId}`) + return + } + + currentTask.messageQueueService.removeMessage(messageId) + } + public async pressPrimaryButton() { await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "primaryButtonClick" }) } From 7bc966ee00077385568067077d6efbef62272f13 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Thu, 19 Feb 2026 03:23:13 +0800 Subject: [PATCH 009/109] Fix Bedrock Claude Sonnet 4.6 model ID, cc #11509 (#11569) Replace the incorrect Sonnet 4.6 Bedrock ID with the AWS-supported model ID in the model registry and Bedrock capability lists. Remove references to the deprecated dated ID and update Bedrock tests to validate the corrected Sonnet 4.6 identifier. --- packages/types/src/providers/bedrock.ts | 6 +++--- src/api/providers/__tests__/bedrock.spec.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/types/src/providers/bedrock.ts b/packages/types/src/providers/bedrock.ts index 575db6984a..9ea52bced8 100644 --- a/packages/types/src/providers/bedrock.ts +++ b/packages/types/src/providers/bedrock.ts @@ -27,7 +27,7 @@ export const bedrockModels = { maxCachePoints: 4, cachableFields: ["system", "messages", "tools"], }, - "anthropic.claude-sonnet-4-6-20260114-v1:0": { + "anthropic.claude-sonnet-4-6": { maxTokens: 8192, contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag 'context-1m-2025-08-07' supportsImages: true, @@ -523,7 +523,7 @@ export const BEDROCK_REGIONS = [ export const BEDROCK_1M_CONTEXT_MODEL_IDS = [ "anthropic.claude-sonnet-4-20250514-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0", - "anthropic.claude-sonnet-4-6-20260114-v1:0", + "anthropic.claude-sonnet-4-6", "anthropic.claude-opus-4-6-v1", ] as const @@ -538,7 +538,7 @@ export const BEDROCK_1M_CONTEXT_MODEL_IDS = [ export const BEDROCK_GLOBAL_INFERENCE_MODEL_IDS = [ "anthropic.claude-sonnet-4-20250514-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0", - "anthropic.claude-sonnet-4-6-20260114-v1:0", + "anthropic.claude-sonnet-4-6", "anthropic.claude-haiku-4-5-20251001-v1:0", "anthropic.claude-opus-4-5-20251101-v1:0", "anthropic.claude-opus-4-6-v1", diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts index 4c45a62325..0ea487eb44 100644 --- a/src/api/providers/__tests__/bedrock.spec.ts +++ b/src/api/providers/__tests__/bedrock.spec.ts @@ -703,7 +703,7 @@ describe("AwsBedrockHandler", () => { it("should apply 1M tier pricing when awsBedrock1MContext is true for Claude Sonnet 4.6", () => { const handler = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-sonnet-4-6-20260114-v1:0", + apiModelId: "anthropic.claude-sonnet-4-6", awsAccessKey: "test", awsSecretKey: "test", awsRegion: "us-east-1", From 2d21e80add320c437d1a6582fc0197f802375fd5 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:30:09 -0700 Subject: [PATCH 010/109] Release v3.48.1 (#11584) chore: add changeset for v3.48.1 Co-authored-by: Roo Code --- .changeset/v3.48.1.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/v3.48.1.md diff --git a/.changeset/v3.48.1.md b/.changeset/v3.48.1.md new file mode 100644 index 0000000000..f52b815cde --- /dev/null +++ b/.changeset/v3.48.1.md @@ -0,0 +1,7 @@ +--- +"roo-cline": patch +--- + +- Fix: Await MCP server initialization before returning McpHub instance, preventing race conditions (PR #11518 by @daniel-lxs) +- Fix: Correct Bedrock Claude Sonnet 4.6 model ID (#11509 by @PeterDaveHello, PR #11569 by @PeterDaveHello) +- Add DeleteQueuedMessage IPC command for managing queued messages (PR #11464 by @roomote) From d9b42f57fb4466fa149ddb621f298be4de325cb7 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:44:02 -0700 Subject: [PATCH 011/109] fix: bump @roo-code/types metadata version to 1.111.0 after revert regression (#11588) Co-authored-by: Roo Code --- packages/types/npm/package.metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index 6596dd184d..aad9fab7b1 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.106.0", + "version": "1.111.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", From b91b20536e5660a8f8a7612e1d14cee064d54181 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:36:38 -0700 Subject: [PATCH 012/109] Changeset version bump (#11586) changeset version bump Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/v3.48.1.md | 7 ------- CHANGELOG.md | 8 ++++++++ src/package.json | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 .changeset/v3.48.1.md diff --git a/.changeset/v3.48.1.md b/.changeset/v3.48.1.md deleted file mode 100644 index f52b815cde..0000000000 --- a/.changeset/v3.48.1.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"roo-cline": patch ---- - -- Fix: Await MCP server initialization before returning McpHub instance, preventing race conditions (PR #11518 by @daniel-lxs) -- Fix: Correct Bedrock Claude Sonnet 4.6 model ID (#11509 by @PeterDaveHello, PR #11569 by @PeterDaveHello) -- Add DeleteQueuedMessage IPC command for managing queued messages (PR #11464 by @roomote) diff --git a/CHANGELOG.md b/CHANGELOG.md index 565c8eb051..2d15944f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Roo Code Changelog +## 3.48.1 + +### Patch Changes + +- Fix: Await MCP server initialization before returning McpHub instance, preventing race conditions (PR #11518 by @daniel-lxs) +- Fix: Correct Bedrock Claude Sonnet 4.6 model ID (#11509 by @PeterDaveHello, PR #11569 by @PeterDaveHello) +- Add DeleteQueuedMessage IPC command for managing queued messages (PR #11464 by @roomote) + ## [3.48.0] - Add Anthropic Claude Sonnet 4.6 support across all providers — Anthropic, Bedrock, Vertex, OpenRouter, and Vercel AI Gateway (PR #11509 by @PeterDaveHello) diff --git a/src/package.json b/src/package.json index 886f3d37e1..b004958863 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.48.0", + "version": "3.48.1", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", From 00075684fd58c9ad4017863f3bcc95011961e630 Mon Sep 17 00:00:00 2001 From: John Richmond <5629+jr@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:01:44 -0800 Subject: [PATCH 013/109] Release: v1.112.0 (#11589) chore: bump version to v1.112.0 --- packages/types/npm/package.metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index aad9fab7b1..1fea313659 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.111.0", + "version": "1.112.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", From d7359ff2288227c4a259f2663ea910ccac8154bd Mon Sep 17 00:00:00 2001 From: Chiranjeevisantosh Madugundi Date: Thu, 19 Feb 2026 00:08:45 -0600 Subject: [PATCH 014/109] Add file changes panel per conversation (#11494) * Add file changes panel per conversation Closes #11493 * Add unit tests for file changes and consolidate specs in src/__tests__ Closes #11493 * fix(chat): only show approved file diffs in conversation panel --- src/core/task/Task.ts | 15 + webview-ui/src/__tests__/App.spec.tsx | 2 +- .../src/__tests__/FileChangesPanel.spec.tsx | 175 +++++++++++ .../__tests__/fileChangesFromMessages.spec.ts | 280 ++++++++++++++++++ .../__tests__/ErrorBoundary.spec.tsx | 97 ------ webview-ui/src/components/chat/ChatView.tsx | 2 + .../src/components/chat/FileChangesPanel.tsx | 118 ++++++++ .../chat/utils/fileChangesFromMessages.ts | 64 ++++ .../common/__tests__/MarkdownBlock.spec.tsx | 2 +- webview-ui/src/i18n/locales/ca/chat.json | 3 + webview-ui/src/i18n/locales/de/chat.json | 3 + webview-ui/src/i18n/locales/en/chat.json | 3 + webview-ui/src/i18n/locales/es/chat.json | 3 + webview-ui/src/i18n/locales/fr/chat.json | 3 + webview-ui/src/i18n/locales/hi/chat.json | 3 + webview-ui/src/i18n/locales/id/chat.json | 3 + webview-ui/src/i18n/locales/it/chat.json | 3 + webview-ui/src/i18n/locales/ja/chat.json | 3 + webview-ui/src/i18n/locales/ko/chat.json | 3 + webview-ui/src/i18n/locales/nl/chat.json | 3 + webview-ui/src/i18n/locales/pl/chat.json | 3 + webview-ui/src/i18n/locales/pt-BR/chat.json | 3 + webview-ui/src/i18n/locales/ru/chat.json | 3 + webview-ui/src/i18n/locales/tr/chat.json | 3 + webview-ui/src/i18n/locales/vi/chat.json | 3 + webview-ui/src/i18n/locales/zh-CN/chat.json | 3 + webview-ui/src/i18n/locales/zh-TW/chat.json | 3 + 27 files changed, 710 insertions(+), 99 deletions(-) create mode 100644 webview-ui/src/__tests__/FileChangesPanel.spec.tsx create mode 100644 webview-ui/src/__tests__/fileChangesFromMessages.spec.ts delete mode 100644 webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx create mode 100644 webview-ui/src/components/chat/FileChangesPanel.tsx create mode 100644 webview-ui/src/components/chat/utils/fileChangesFromMessages.ts diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6ba57e98ac..16c6b36dce 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1532,6 +1532,21 @@ export class Task extends EventEmitter implements TaskLike { }) } } + + // Mark the last tool-approval ask as answered when user approves (or auto-approval) + if (askResponse === "yesButtonClicked") { + const lastToolAskIndex = findLastIndex( + this.clineMessages, + (msg) => msg.type === "ask" && msg.ask === "tool" && !msg.isAnswered, + ) + if (lastToolAskIndex !== -1) { + this.clineMessages[lastToolAskIndex].isAnswered = true + void this.updateClineMessage(this.clineMessages[lastToolAskIndex]) + this.saveClineMessages().catch((error) => { + console.error("Failed to save answered tool-ask state:", error) + }) + } + } } /** diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index e8e08782da..e04bc14200 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -193,7 +193,7 @@ describe("App", () => { const chatView = screen.getByTestId("chat-view") expect(chatView).toBeInTheDocument() expect(chatView.getAttribute("data-hidden")).toBe("false") - }) + }, 10000) it("switches to settings view when receiving settingsButtonClicked action", async () => { render() diff --git a/webview-ui/src/__tests__/FileChangesPanel.spec.tsx b/webview-ui/src/__tests__/FileChangesPanel.spec.tsx new file mode 100644 index 0000000000..b28102b1fe --- /dev/null +++ b/webview-ui/src/__tests__/FileChangesPanel.spec.tsx @@ -0,0 +1,175 @@ +import React from "react" +import { fireEvent, render, screen } from "@/utils/test-utils" +import type { ClineMessage } from "@roo-code/types" +import { TranslationProvider } from "@/i18n/__mocks__/TranslationContext" +import FileChangesPanel from "../components/chat/FileChangesPanel" + +const mockPostMessage = vi.fn() + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: (...args: unknown[]) => mockPostMessage(...args), + }, +})) + +// Mock i18n to return readable header with count +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: { count?: number }) => { + if (key === "chat:fileChangesInConversation.header" && opts?.count != null) { + return `${opts.count} file(s) changed in this conversation` + } + return key + }, + }), +})) + +// Lightweight mock so we don't pull in CodeBlock/DiffView +vi.mock("@src/components/common/CodeAccordian", () => ({ + default: ({ + path, + isExpanded, + onToggleExpand, + }: { + path?: string + isExpanded: boolean + onToggleExpand: () => void + }) => ( +
+ {path} + +
+ ), +})) + +function createFileEditMessage(path: string, diff: string): ClineMessage { + return { + type: "ask", + ask: "tool", + ts: Date.now(), + partial: false, + isAnswered: true, + text: JSON.stringify({ + tool: "appliedDiff", + path, + diff, + }), + } +} + +function renderPanel(messages: ClineMessage[] | undefined) { + return render( + + + , + ) +} + +describe("FileChangesPanel", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders nothing when clineMessages is undefined", () => { + const { container } = renderPanel(undefined) + expect(container.firstChild).toBeNull() + }) + + it("renders nothing when clineMessages is empty", () => { + const { container } = renderPanel([]) + expect(container.firstChild).toBeNull() + }) + + it("renders nothing when there are no file-edit messages", () => { + const messages: ClineMessage[] = [ + { + type: "say", + say: "text", + ts: Date.now(), + partial: false, + text: "hello", + }, + { + type: "ask", + ask: "tool", + ts: Date.now(), + partial: false, + text: JSON.stringify({ tool: "read_file", path: "x.ts" }), + }, + ] + const { container } = renderPanel(messages) + expect(container.firstChild).toBeNull() + }) + + it("renders nothing when file-edit ask tool is not approved (isAnswered false or missing)", () => { + const messages: ClineMessage[] = [ + { + type: "ask", + ask: "tool", + ts: Date.now(), + partial: false, + text: JSON.stringify({ + tool: "appliedDiff", + path: "src/foo.ts", + diff: "+line", + }), + }, + ] + const { container } = renderPanel(messages) + expect(container.firstChild).toBeNull() + }) + + it("renders panel with header when there is one file edit", () => { + const messages = [createFileEditMessage("src/foo.ts", "@@ -1 +1 @@\n+line")] + renderPanel(messages) + + expect(screen.getByText("1 file(s) changed in this conversation")).toBeInTheDocument() + // Expand panel so file row is in DOM (CollapsibleContent may not render when closed in some setups) + fireEvent.click(screen.getByText("1 file(s) changed in this conversation").closest("button")!) + expect(screen.getByTestId("accordian-path")).toHaveTextContent("src/foo.ts") + }) + + it("renders one row per unique path when multiple files edited", () => { + const messages = [createFileEditMessage("src/a.ts", "diff a"), createFileEditMessage("src/b.ts", "diff b")] + renderPanel(messages) + + expect(screen.getByText("2 file(s) changed in this conversation")).toBeInTheDocument() + // Expand panel so file rows are rendered + fireEvent.click(screen.getByText("2 file(s) changed in this conversation").closest("button")!) + const paths = screen.getAllByTestId("accordian-path") + expect(paths).toHaveLength(2) + expect(paths.map((el) => el.textContent)).toEqual(expect.arrayContaining(["src/a.ts", "src/b.ts"])) + }) + + it("collapsed by default: panel trigger shows chevron and expanding reveals file rows", () => { + const messages = [createFileEditMessage("src/foo.ts", "diff")] + renderPanel(messages) + + // Header visible + const headerText = screen.getByText("1 file(s) changed in this conversation") + expect(headerText).toBeInTheDocument() + // Trigger is the button that contains the header text + const trigger = headerText.closest("button") + expect(trigger).toBeInTheDocument() + + // Expand panel + fireEvent.click(trigger!) + expect(screen.getByTestId("accordian-path")).toHaveTextContent("src/foo.ts") + }) + + it("toggling a file row expand calls onToggleExpand", () => { + const messages = [createFileEditMessage("src/foo.ts", "diff")] + renderPanel(messages) + + // Expand panel first so the file row is rendered + const headerText = screen.getByText("1 file(s) changed in this conversation") + fireEvent.click(headerText.closest("button")!) + + const accordianToggle = screen.getByTestId("accordian-toggle") + expect(accordianToggle).toHaveTextContent("collapsed") + fireEvent.click(accordianToggle) + expect(accordianToggle).toHaveTextContent("expanded") + }) +}) diff --git a/webview-ui/src/__tests__/fileChangesFromMessages.spec.ts b/webview-ui/src/__tests__/fileChangesFromMessages.spec.ts new file mode 100644 index 0000000000..8fab8b14d5 --- /dev/null +++ b/webview-ui/src/__tests__/fileChangesFromMessages.spec.ts @@ -0,0 +1,280 @@ +import type { ClineMessage } from "@roo-code/types" +import { fileChangesFromMessages } from "../components/chat/utils/fileChangesFromMessages" + +function msg(overrides: Partial & { text: string }): ClineMessage { + return { + type: "say", + say: "tool", + ts: Date.now(), + partial: false, + ...overrides, + } +} + +describe("fileChangesFromMessages", () => { + it("returns empty array for undefined messages", () => { + expect(fileChangesFromMessages(undefined)).toEqual([]) + }) + + it("returns empty array for empty messages", () => { + expect(fileChangesFromMessages([])).toEqual([]) + }) + + it("ignores non-tool messages", () => { + const messages: ClineMessage[] = [ + msg({ type: "say", say: "text", text: "hello" }), + msg({ type: "ask", ask: "followup", text: "world" }), + ] + expect(fileChangesFromMessages(messages)).toEqual([]) + }) + + it("ignores tool messages with non-file-edit tool type", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "read_file", path: "a.ts" }), + }), + ] + expect(fileChangesFromMessages(messages)).toEqual([]) + }) + + it("skips partial messages", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + partial: true, + text: JSON.stringify({ + tool: "appliedDiff", + path: "src/file.ts", + diff: "+x", + }), + }), + ] + expect(fileChangesFromMessages(messages)).toEqual([]) + }) + + it("excludes ask tool file-edit when isAnswered is false or undefined", () => { + const payload = JSON.stringify({ + tool: "appliedDiff", + path: "src/foo.ts", + diff: "+line", + }) + expect(fileChangesFromMessages([msg({ type: "ask", ask: "tool", text: payload, isAnswered: false })])).toEqual( + [], + ) + expect(fileChangesFromMessages([msg({ type: "ask", ask: "tool", text: payload })])).toEqual([]) + }) + + it("includes ask tool file-edit when isAnswered is true", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool: "appliedDiff", + path: "src/foo.ts", + diff: "+line", + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result).toHaveLength(1) + expect(result[0].path).toBe("src/foo.ts") + }) + + it("extracts single-file edit from ask tool message", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool: "appliedDiff", + path: "src/foo.ts", + diff: "@@ -1 +1 @@\n+line", + diffStats: { added: 1, removed: 0 }, + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + path: "src/foo.ts", + diff: "@@ -1 +1 @@\n+line", + diffStats: { added: 1, removed: 0 }, + }) + }) + + it("extracts single-file edit from say tool message", () => { + const messages: ClineMessage[] = [ + msg({ + type: "say", + say: "tool", + text: JSON.stringify({ + tool: "editedExistingFile", + path: "lib/bar.ts", + diff: "-old\n+new", + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result).toHaveLength(1) + expect(result[0].path).toBe("lib/bar.ts") + expect(result[0].diff).toBe("-old\n+new") + }) + + it("uses content when diff is missing for single-file", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool: "newFileCreated", + path: "new.ts", + content: "full file content", + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result).toHaveLength(1) + expect(result[0].diff).toBe("full file content") + }) + + it("ignores single-file tool when path is missing", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "appliedDiff", + diff: "something", + }), + }), + ] + expect(fileChangesFromMessages(messages)).toEqual([]) + }) + + it("ignores single-file tool when diff and content are empty", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "appliedDiff", + path: "x.ts", + }), + }), + ] + expect(fileChangesFromMessages(messages)).toEqual([]) + }) + + it("extracts from batchDiffs", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool: "appliedDiff", + batchDiffs: [ + { path: "a.ts", content: "content a" }, + { path: "b.ts", diffs: [{ content: "content b" }] }, + { path: "c.ts" }, // no content + ], + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ path: "a.ts", diff: "content a" }) + expect(result[1].path).toBe("b.ts") + expect(result[1].diff).toBe("content b") + }) + + it("includes diffStats from batchDiffs when present", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool: "appliedDiff", + batchDiffs: [ + { + path: "f.ts", + content: "x", + diffStats: { added: 2, removed: 1 }, + }, + ], + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result[0].diffStats).toEqual({ added: 2, removed: 1 }) + }) + + it("recognizes all ClineSayTool file-edit tool names (editedExistingFile, appliedDiff, newFileCreated)", () => { + const tools = ["editedExistingFile", "appliedDiff", "newFileCreated"] + for (const tool of tools) { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool, + path: "f.ts", + diff: "d", + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result).toHaveLength(1) + expect(result[0].path).toBe("f.ts") + } + }) + + it("returns multiple entries for multiple file-edit messages", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool: "appliedDiff", + path: "first.ts", + diff: "a", + }), + }), + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool: "editedExistingFile", + path: "second.ts", + diff: "b", + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result).toHaveLength(2) + expect(result[0].path).toBe("first.ts") + expect(result[1].path).toBe("second.ts") + }) + + it("skips invalid JSON in message text", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + text: "not json", + }), + ] + expect(fileChangesFromMessages(messages)).toEqual([]) + }) +}) diff --git a/webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx b/webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx deleted file mode 100644 index 1fbb6774f2..0000000000 --- a/webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from "react" -import { render, screen } from "@testing-library/react" - -import ErrorBoundary from "../ErrorBoundary" - -// Mock telemetryClient -vi.mock("@src/utils/TelemetryClient", () => ({ - telemetryClient: { - capture: vi.fn(), - }, -})) - -// Mock translation -vi.mock("react-i18next", () => ({ - withTranslation: () => (Component: any) => { - Component.defaultProps = { - ...Component.defaultProps, - t: (key: string) => { - // Mock translations for tests - const translations: Record = { - "errorBoundary.title": "Something went wrong", - "errorBoundary.reportText": "Please help us improve by reporting this error on", - "errorBoundary.githubText": "GitHub", - "errorBoundary.copyInstructions": "Please copy and paste the following error message:", - } - return translations[key] || key - }, - } - return Component - }, -})) - -// Test component that throws an error -const ErrorThrowingComponent = ({ shouldThrow = false }) => { - if (shouldThrow) { - throw new Error("Test error") - } - return
Content rendered normally
-} - -describe("ErrorBoundary", () => { - // Suppress console errors during tests - const originalConsoleError = console.error - beforeAll(() => { - console.error = vi.fn() - }) - afterAll(() => { - console.error = originalConsoleError - }) - - test("renders children when no error occurs", () => { - render( - - - , - ) - - expect(screen.getByTestId("normal-render")).toBeInTheDocument() - }) - - test("renders error UI when an error occurs", () => { - // React will log the error to the console - we're just testing the UI behavior - render( - - - , - ) - - // Verify error message is displayed using a more flexible approach - const errorTitle = screen.getByRole("heading", { level: 2 }) - expect(errorTitle.textContent).toContain("Something went wrong") - expect(screen.getByText(/please copy and paste the following error message/i)).toBeInTheDocument() - }) - - test("error boundary renders error UI when component changes but still in error state", () => { - const { rerender } = render( - - - , - ) - - // Verify error message is displayed using a more flexible approach - const errorTitle = screen.getByRole("heading", { level: 2 }) - expect(errorTitle.textContent).toContain("Something went wrong") - - // Update the component to not throw - rerender( - - - , - ) - - // The error boundary should still show the error since it doesn't automatically reset - const errorTitleAfterRerender = screen.getByRole("heading", { level: 2 }) - expect(errorTitleAfterRerender.textContent).toContain("Something went wrong") - }) -}) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index fbd7db0743..c070f8764e 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -46,6 +46,7 @@ import ProfileViolationWarning from "./ProfileViolationWarning" import { CheckpointWarning } from "./CheckpointWarning" import { QueuedMessages } from "./QueuedMessages" import { WorktreeSelector } from "./WorktreeSelector" +import FileChangesPanel from "./FileChangesPanel" import DismissibleUpsell from "../common/DismissibleUpsell" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" import { Cloud } from "lucide-react" @@ -1700,6 +1701,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction + {areButtonsVisible && (
{ + const { t } = useTranslation() + const [panelExpanded, setPanelExpanded] = useState(false) + const [expandedPaths, setExpandedPaths] = useState>(new Set()) + + // Reset expanded file rows when switching to a different task (clineMessages identity change) + useEffect(() => { + setExpandedPaths(new Set()) + }, [clineMessages]) + + const fileChanges = useMemo(() => fileChangesFromMessages(clineMessages), [clineMessages]) + + // Group by path so we show one row per file (multiple edits to same file combined for display) + const byPath = useMemo(() => { + const map = new Map() + for (const entry of fileChanges) { + const key = entry.path + const list = map.get(key) ?? [] + list.push(entry) + map.set(key, list) + } + return map + }, [fileChanges]) + + const togglePath = useCallback((path: string) => { + setExpandedPaths((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + }, []) + + if (fileChanges.length === 0) return null + + const fileCount = byPath.size + + return ( + + + {panelExpanded ? ( + + ) : ( + + )} + + + {t("chat:fileChangesInConversation.header", { count: fileCount })} + + + +
+ {Array.from(byPath.entries()).map(([path, entries]) => { + // If multiple edits to same file, concatenate diffs with a separator + const combinedDiff = entries.map((e) => e.diff).join("\n\n") + const combinedStats = entries.reduce( + (acc, e) => ({ + added: acc.added + (e.diffStats?.added ?? 0), + removed: acc.removed + (e.diffStats?.removed ?? 0), + }), + { added: 0, removed: 0 }, + ) + const isExpanded = expandedPaths.has(path) + return ( +
+ togglePath(path)} + diffStats={ + combinedStats.added > 0 || combinedStats.removed > 0 ? combinedStats : undefined + } + onJumpToFile={ + path + ? () => + vscode.postMessage({ + type: "openFile", + text: path.startsWith("./") ? path : "./" + path, + }) + : undefined + } + /> +
+ ) + })} +
+
+
+ ) +}) + +FileChangesPanel.displayName = "FileChangesPanel" + +export default FileChangesPanel diff --git a/webview-ui/src/components/chat/utils/fileChangesFromMessages.ts b/webview-ui/src/components/chat/utils/fileChangesFromMessages.ts new file mode 100644 index 0000000000..6b77833e9d --- /dev/null +++ b/webview-ui/src/components/chat/utils/fileChangesFromMessages.ts @@ -0,0 +1,64 @@ +import type { ClineMessage, ClineSayTool } from "@roo-code/types" +import { safeJsonParse } from "@roo/core" + +/** File-edit tool names from ClineSayTool["tool"] (packages/types). */ +const FILE_EDIT_TOOLS = new Set(["editedExistingFile", "appliedDiff", "newFileCreated"]) + +export interface FileChangeEntry { + path: string + diff: string + diffStats?: { added: number; removed: number } +} + +/** + * Derives a list of file changes from clineMessages for the current conversation. + * Includes: + * - type "say" + say "tool" (applied tool results, if any are ever pushed that way) + * - type "ask" + ask "tool" (tool approval messages; after approval the message stays as ask, so this is where file edits appear in the UI) + */ +export function fileChangesFromMessages(messages: ClineMessage[] | undefined): FileChangeEntry[] { + if (!messages?.length) return [] + + const entries: FileChangeEntry[] = [] + + for (const msg of messages) { + // Tool payload can be in say "tool" (rare) or ask "tool" (how file edits are stored after approval) + const isSayTool = msg.type === "say" && msg.say === "tool" + const isAskTool = msg.type === "ask" && msg.ask === "tool" + if ((!isSayTool && !isAskTool) || !msg.text || msg.partial) continue + // Only include ask "tool" file edits that the user (or auto-approval) has approved + if (isAskTool && !msg.isAnswered) continue + + const tool = safeJsonParse(msg.text) + if (!tool || !FILE_EDIT_TOOLS.has(tool.tool as string)) continue + + // Batch diffs + if (tool.batchDiffs && Array.isArray(tool.batchDiffs)) { + for (const file of tool.batchDiffs) { + if (!file.path) continue + const content = file.content ?? file.diffs?.map((d) => d.content).join("\n") ?? "" + if (content) { + entries.push({ + path: file.path, + diff: content, + diffStats: file.diffStats, + }) + } + } + continue + } + + // Single file + if (!tool.path) continue + const diff = tool.diff ?? tool.content ?? "" + if (diff) { + entries.push({ + path: tool.path, + diff, + diffStats: tool.diffStats, + }) + } + } + + return entries +} diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx index a0b6857a37..8e41eefa14 100644 --- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx +++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx @@ -34,7 +34,7 @@ describe("MarkdownBlock", () => { // Check that the period is outside the link const paragraph = container.querySelector("p") expect(paragraph?.textContent).toBe("Check out this link: https://example.com.") - }) + }, 10000) it("should render unordered lists with proper styling", async () => { const markdown = `Here are some items: diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 16e999efd7..230d730b13 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -33,6 +33,9 @@ }, "unpin": "Desfixar", "pin": "Fixar", + "fileChangesInConversation": { + "header": "{{count}} fitxer(s) canviat(s) en aquesta conversa" + }, "tokenProgress": { "availableSpace": "Espai disponible: {{amount}} tokens", "tokensUsed": "Tokens utilitzats: {{used}} de {{total}}", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 339083038a..480d3197cd 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -33,6 +33,9 @@ }, "unpin": "Lösen von oben", "pin": "Anheften", + "fileChangesInConversation": { + "header": "{{count}} Datei(en) in dieser Unterhaltung geändert" + }, "tokenProgress": { "availableSpace": "Verfügbarer Speicher: {{amount}} Tokens", "tokensUsed": "Verwendete Tokens: {{used}} von {{total}}", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 71f08bb504..10ef7c8811 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -33,6 +33,9 @@ }, "unpin": "Unpin", "pin": "Pin", + "fileChangesInConversation": { + "header": "{{count}} file(s) changed in this conversation" + }, "retry": { "title": "Retry", "tooltip": "Try the operation again" diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index cee47210b7..b758c4eae2 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -33,6 +33,9 @@ }, "unpin": "Desfijar", "pin": "Fijar", + "fileChangesInConversation": { + "header": "{{count}} archivo(s) modificado(s) en esta conversación" + }, "retry": { "title": "Reintentar", "tooltip": "Intenta la operación de nuevo" diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 592a85dbcd..9080c3d29a 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -33,6 +33,9 @@ }, "unpin": "Désépingler", "pin": "Épingler", + "fileChangesInConversation": { + "header": "{{count}} fichier(s) modifié(s) dans cette conversation" + }, "tokenProgress": { "availableSpace": "Espace disponible : {{amount}} tokens", "tokensUsed": "Tokens utilisés : {{used}} sur {{total}}", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 897d6b2db1..e4226e5ad5 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -33,6 +33,9 @@ }, "unpin": "पिन करें", "pin": "अवपिन करें", + "fileChangesInConversation": { + "header": "इस वार्तालाप में {{count}} फ़ाइल(ें) बदली गईं" + }, "tokenProgress": { "availableSpace": "उपलब्ध स्थान: {{amount}} tokens", "tokensUsed": "प्रयुक्त tokens: {{used}} / {{total}}", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index f78cbc0d47..10b13bcb5b 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -36,6 +36,9 @@ }, "unpin": "Lepas Pin", "pin": "Pin", + "fileChangesInConversation": { + "header": "{{count}} file diubah dalam percakapan ini" + }, "retry": { "title": "Coba Lagi", "tooltip": "Coba operasi lagi" diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index a6cafe1bd1..9d4b03bd73 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -33,6 +33,9 @@ }, "unpin": "Rilascia", "pin": "Fissa", + "fileChangesInConversation": { + "header": "{{count}} file modificati in questa conversazione" + }, "tokenProgress": { "availableSpace": "Spazio disponibile: {{amount}} tokens", "tokensUsed": "Tokens utilizzati: {{used}} di {{total}}", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 78721a6f5c..a648393df7 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -33,6 +33,9 @@ }, "unpin": "ピン留めを解除", "pin": "ピン留め", + "fileChangesInConversation": { + "header": "この会話で {{count}} 個のファイルが変更されました" + }, "tokenProgress": { "availableSpace": "利用可能な空き容量: {{amount}} トークン", "tokensUsed": "使用トークン: {{used}} / {{total}}", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 86aa58e522..8d686b9496 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -33,6 +33,9 @@ }, "unpin": "고정 해제하기", "pin": "고정하기", + "fileChangesInConversation": { + "header": "이 대화에서 {{count}}개 파일이 변경됨" + }, "tokenProgress": { "availableSpace": "사용 가능한 공간: {{amount}} 토큰", "tokensUsed": "사용된 토큰: {{used}} / {{total}}", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 57dd6e5499..eb134cfca4 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -33,6 +33,9 @@ }, "unpin": "Losmaken", "pin": "Vastmaken", + "fileChangesInConversation": { + "header": "{{count}} bestand(en) gewijzigd in dit gesprek" + }, "retry": { "title": "Opnieuw proberen", "tooltip": "Probeer de bewerking opnieuw" diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index bcf3317882..d2ee83879f 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -33,6 +33,9 @@ }, "unpin": "Odepnij", "pin": "Przypnij", + "fileChangesInConversation": { + "header": "{{count}} plik(ów) zmienionych w tej rozmowie" + }, "tokenProgress": { "availableSpace": "Dostępne miejsce: {{amount}} tokenów", "tokensUsed": "Wykorzystane tokeny: {{used}} z {{total}}", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 4429fa2962..2c339defd4 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -33,6 +33,9 @@ }, "unpin": "Desfixar", "pin": "Fixar", + "fileChangesInConversation": { + "header": "{{count}} arquivo(s) alterado(s) nesta conversa" + }, "tokenProgress": { "availableSpace": "Espaço disponível: {{amount}} tokens", "tokensUsed": "Tokens usados: {{used}} de {{total}}", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 091175f0bb..fd3718f28a 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -33,6 +33,9 @@ }, "unpin": "Открепить", "pin": "Закрепить", + "fileChangesInConversation": { + "header": "{{count}} файл(ов) изменено в этом разговоре" + }, "retry": { "title": "Повторить", "tooltip": "Попробовать выполнить операцию снова" diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 26a975d797..2a6b776f78 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -33,6 +33,9 @@ }, "unpin": "Sabitlemeyi iptal et", "pin": "Sabitle", + "fileChangesInConversation": { + "header": "Bu sohbette {{count}} dosya değiştirildi" + }, "tokenProgress": { "availableSpace": "Kullanılabilir alan: {{amount}} token", "tokensUsed": "Kullanılan tokenlar: {{used}} / {{total}}", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 70637db9c6..8e3841f334 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -33,6 +33,9 @@ }, "unpin": "Bỏ ghim khỏi đầu", "pin": "Ghim lên đầu", + "fileChangesInConversation": { + "header": "{{count}} tệp đã thay đổi trong cuộc hội thoại này" + }, "tokenProgress": { "availableSpace": "Không gian khả dụng: {{amount}} tokens", "tokensUsed": "Tokens đã sử dụng: {{used}} trong {{total}}", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index d5f945da18..012797cc51 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -33,6 +33,9 @@ }, "unpin": "取消置顶", "pin": "置顶", + "fileChangesInConversation": { + "header": "此对话中已更改 {{count}} 个文件" + }, "tokenProgress": { "availableSpace": "可用: {{amount}}", "tokensUsed": "已使用: {{used}} / {{total}}", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 0693a7d96f..4acb77715a 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -33,6 +33,9 @@ }, "unpin": "取消釘選", "pin": "釘選", + "fileChangesInConversation": { + "header": "此對話中已變更 {{count}} 個檔案" + }, "retry": { "title": "重試", "tooltip": "再次嘗試操作" From 67ea856fd0d252d39e517b5c30779bc264c832b4 Mon Sep 17 00:00:00 2001 From: James Mtendamema <59908268+JamesRobert20@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:09:17 -0700 Subject: [PATCH 015/109] feat: add per-workspace indexing opt-in and stop/cancel control (#11456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add per-workspace indexing opt-in and stop/cancel control - Add codeIndexWorkspaceEnabled flag in workspaceState (default: false) - Thread AbortController/AbortSignal through orchestrator → scanner - Add Stop Indexing button and Stopping state to UI - Fix handleSettingsChange() to abort active scan when disabling toggle - Add translations for all 18 locales * fix: correct abort handling in indexing scanner and orchestrator - Re-throw AbortError in scanner's file processing catch block to prevent abort signals from being silently swallowed as file errors - Reorder stopWatcher() before setSystemState() in orchestrator abort catch path to ensure watcher cleanup before state transition - Update scanner test to assert AbortError propagation on mid-scan abort * fix: optimize workspace check ordering, translate new i18n keys, fix abort handling - Move workspace-enabled check before _recreateServices() in initialize() to avoid creating Qdrant/embedder connections for disabled workspaces - Translate new i18n keys (indexingStopped, indexingStoppedPartial, stopping, stopIndexingButton, stoppingButton, workspaceToggleLabel, workspaceDisabledMessage) in all 17 non-English locales - Re-throw AbortError in scanner catch block to prevent silent swallowing - Reorder stopWatcher() before setSystemState() in orchestrator abort path - Update scanner test to assert AbortError propagation on mid-scan abort - Fix recoverFromError test for workspace-enabled check ordering * fix: per-folder enablement key, abort-safe dispose and back-pressure, translate i18n Addresses 0xMink review feedback: - Store workspace enablement keyed by folder path to support multi-root workspaces (codeIndexWorkspaceEnabled: instead of single boolean) - Add test proving folder A enabled does not enable folder B - dispose() now calls stopIndexing() to abort orphaned scans on folder removal - Scanner back-pressure loop checks abort signal to avoid spin-waiting - Move workspace-enabled check before _recreateServices() in initialize() - Translate new i18n keys in all 17 non-English locales - Fix abort handling in orchestrator and scanner catch blocks * fix: flush debounced cache writes on abort to preserve indexing progress * feat: add global auto-enable default for backward-compatible workspace indexing * fix: stop/start indexer when auto-enable default changes effective state * fix: URI-keyed enablement, throw AbortError in back-pressure, stopWatcher on early-return * fix: iterate all managers when auto-enable default changes in multi-root workspaces --------- Co-authored-by: James Mtendamema --- ...exing-workspace-opt-in-and-stop-control.md | 9 + packages/types/src/vscode-extension-host.ts | 7 +- src/core/webview/webviewMessageHandler.ts | 90 +++++++- src/i18n/locales/ca/embeddings.json | 4 +- src/i18n/locales/de/embeddings.json | 4 +- src/i18n/locales/en/embeddings.json | 4 +- src/i18n/locales/es/embeddings.json | 4 +- src/i18n/locales/fr/embeddings.json | 4 +- src/i18n/locales/hi/embeddings.json | 4 +- src/i18n/locales/id/embeddings.json | 4 +- src/i18n/locales/it/embeddings.json | 4 +- src/i18n/locales/ja/embeddings.json | 4 +- src/i18n/locales/ko/embeddings.json | 4 +- src/i18n/locales/nl/embeddings.json | 4 +- src/i18n/locales/pl/embeddings.json | 4 +- src/i18n/locales/pt-BR/embeddings.json | 4 +- src/i18n/locales/ru/embeddings.json | 4 +- src/i18n/locales/tr/embeddings.json | 4 +- src/i18n/locales/vi/embeddings.json | 4 +- src/i18n/locales/zh-CN/embeddings.json | 4 +- src/i18n/locales/zh-TW/embeddings.json | 4 +- .../code-index/__tests__/manager.spec.ts | 208 +++++++++++++++++- .../code-index/__tests__/orchestrator.spec.ts | 176 +++++++++++++++ src/services/code-index/cache-manager.ts | 7 + src/services/code-index/interfaces/cache.ts | 1 + .../code-index/interfaces/file-processor.ts | 1 + src/services/code-index/interfaces/manager.ts | 7 +- src/services/code-index/manager.ts | 106 ++++++--- src/services/code-index/orchestrator.ts | 43 +++- .../processors/__tests__/scanner.spec.ts | 63 ++++++ src/services/code-index/processors/scanner.ts | 39 +++- src/services/code-index/state-manager.ts | 6 +- .../src/components/chat/CodeIndexPopover.tsx | 66 ++++++ .../components/chat/IndexingStatusBadge.tsx | 3 + webview-ui/src/i18n/locales/ca/chat.json | 3 +- webview-ui/src/i18n/locales/ca/settings.json | 7 +- webview-ui/src/i18n/locales/de/chat.json | 3 +- webview-ui/src/i18n/locales/de/settings.json | 7 +- webview-ui/src/i18n/locales/en/chat.json | 3 +- webview-ui/src/i18n/locales/en/settings.json | 7 +- webview-ui/src/i18n/locales/es/chat.json | 3 +- webview-ui/src/i18n/locales/es/settings.json | 7 +- webview-ui/src/i18n/locales/fr/chat.json | 3 +- webview-ui/src/i18n/locales/fr/settings.json | 7 +- webview-ui/src/i18n/locales/hi/chat.json | 3 +- webview-ui/src/i18n/locales/hi/settings.json | 7 +- webview-ui/src/i18n/locales/id/chat.json | 3 +- webview-ui/src/i18n/locales/id/settings.json | 7 +- webview-ui/src/i18n/locales/it/chat.json | 3 +- webview-ui/src/i18n/locales/it/settings.json | 7 +- webview-ui/src/i18n/locales/ja/chat.json | 3 +- webview-ui/src/i18n/locales/ja/settings.json | 7 +- webview-ui/src/i18n/locales/ko/chat.json | 3 +- webview-ui/src/i18n/locales/ko/settings.json | 7 +- webview-ui/src/i18n/locales/nl/chat.json | 3 +- webview-ui/src/i18n/locales/nl/settings.json | 7 +- webview-ui/src/i18n/locales/pl/chat.json | 3 +- webview-ui/src/i18n/locales/pl/settings.json | 7 +- webview-ui/src/i18n/locales/pt-BR/chat.json | 3 +- .../src/i18n/locales/pt-BR/settings.json | 7 +- webview-ui/src/i18n/locales/ru/chat.json | 3 +- webview-ui/src/i18n/locales/ru/settings.json | 7 +- webview-ui/src/i18n/locales/tr/chat.json | 3 +- webview-ui/src/i18n/locales/tr/settings.json | 7 +- webview-ui/src/i18n/locales/vi/chat.json | 3 +- webview-ui/src/i18n/locales/vi/settings.json | 7 +- webview-ui/src/i18n/locales/zh-CN/chat.json | 3 +- .../src/i18n/locales/zh-CN/settings.json | 7 +- webview-ui/src/i18n/locales/zh-TW/chat.json | 3 +- .../src/i18n/locales/zh-TW/settings.json | 7 +- 70 files changed, 983 insertions(+), 101 deletions(-) create mode 100644 .changeset/indexing-workspace-opt-in-and-stop-control.md diff --git a/.changeset/indexing-workspace-opt-in-and-stop-control.md b/.changeset/indexing-workspace-opt-in-and-stop-control.md new file mode 100644 index 0000000000..27a1fbe0b0 --- /dev/null +++ b/.changeset/indexing-workspace-opt-in-and-stop-control.md @@ -0,0 +1,9 @@ +--- +"roo-cline": minor +--- + +Add per-workspace indexing opt-in and stop/cancel indexing controls + +- **Per-workspace indexing opt-in**: Indexing no longer auto-starts on every workspace. A new `codeIndexWorkspaceEnabled` flag (stored in `workspaceState`, default: false) requires users to explicitly enable indexing per workspace via a toggle in the CodeIndex popover. The choice is remembered across sessions. +- **Stop/cancel indexing**: Users can stop an in-progress indexing operation via a "Stop Indexing" button. Uses `AbortController`/`AbortSignal` threaded through the orchestrator → scanner pipeline with graceful abort at file and batch boundaries. +- **Disable toggle bug fix**: Unchecking "Enable Codebase Indexing" during active indexing now properly stops the scan via `stopIndexing()` instead of only calling `stopWatcher()`, which left the scanner running asynchronously. diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 38bccc53b5..47b574e4b7 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -508,9 +508,12 @@ export interface WebviewMessage { | "condenseTaskContextRequest" | "requestIndexingStatus" | "startIndexing" + | "stopIndexing" | "clearIndexData" | "indexingStatusUpdate" | "indexCleared" + | "toggleWorkspaceIndexing" + | "setAutoEnableDefault" | "focusPanelRequest" | "openExternal" | "filterMarketplaceItems" @@ -705,7 +708,7 @@ export const checkoutRestorePayloadSchema = z.object({ export type CheckpointRestorePayload = z.infer export interface IndexingStatusPayload { - state: "Standby" | "Indexing" | "Indexed" | "Error" + state: "Standby" | "Indexing" | "Indexed" | "Error" | "Stopping" message: string } @@ -739,6 +742,8 @@ export interface IndexingStatus { totalItems: number currentItemUnit?: string workspacePath?: string + workspaceEnabled?: boolean + autoEnableDefault?: boolean } export interface IndexingStatusUpdateMessage { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index dc8f073bf1..4f8d431724 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -44,6 +44,7 @@ import { Package } from "../../shared/package" import { type RouterName, toRouterName } from "../../shared/api" import { MessageEnhancer } from "./messageEnhancer" +import { CodeIndexManager } from "../../services/code-index/manager" import { checkExistKey } from "../../shared/checkExistApiConfig" import { experimentDefault } from "../../shared/experiments" import { Terminal } from "../../integrations/terminal/Terminal" @@ -2608,7 +2609,6 @@ export const webviewMessageHandler = async ( try { const manager = provider.getCurrentWorkspaceCodeIndexManager() if (!manager) { - // No workspace open - send error status provider.postMessageToWebview({ type: "indexingStatusUpdate", values: { @@ -2622,23 +2622,19 @@ export const webviewMessageHandler = async ( provider.log("Cannot start indexing: No workspace folder open") return } + + // "Start Indexing" implicitly enables the workspace + await manager.setWorkspaceEnabled(true) + if (manager.isFeatureEnabled && manager.isFeatureConfigured) { - // Mimic extension startup behavior: initialize first, which will - // check if Qdrant container is active and reuse existing collection await manager.initialize(provider.contextProxy) - // Only call startIndexing if we're in a state that requires it - // (e.g., Standby or Error). If already Indexed or Indexing, the - // initialize() call above will have already started the watcher. const currentState = manager.state if (currentState === "Standby" || currentState === "Error") { - // startIndexing now handles error recovery internally manager.startIndexing() - // If startIndexing recovered from error, we need to reinitialize if (!manager.isInitialized) { await manager.initialize(provider.contextProxy) - // Try starting again after initialization if (manager.state === "Standby" || manager.state === "Error") { manager.startIndexing() } @@ -2650,6 +2646,82 @@ export const webviewMessageHandler = async ( } break } + case "stopIndexing": { + try { + const manager = provider.getCurrentWorkspaceCodeIndexManager() + if (!manager) { + provider.log("Cannot stop indexing: No workspace folder open") + return + } + manager.stopIndexing() + provider.postMessageToWebview({ + type: "indexingStatusUpdate", + values: manager.getCurrentStatus(), + }) + } catch (error) { + provider.log(`Error stopping indexing: ${error instanceof Error ? error.message : String(error)}`) + } + break + } + case "toggleWorkspaceIndexing": { + try { + const manager = provider.getCurrentWorkspaceCodeIndexManager() + if (!manager) { + provider.log("Cannot toggle workspace indexing: No workspace folder open") + return + } + const enabled = message.bool ?? false + await manager.setWorkspaceEnabled(enabled) + if (enabled && manager.isFeatureEnabled && manager.isFeatureConfigured) { + await manager.initialize(provider.contextProxy) + manager.startIndexing() + } else if (!enabled) { + manager.stopIndexing() + } + provider.postMessageToWebview({ + type: "indexingStatusUpdate", + values: manager.getCurrentStatus(), + }) + } catch (error) { + provider.log( + `Error toggling workspace indexing: ${error instanceof Error ? error.message : String(error)}`, + ) + } + break + } + case "setAutoEnableDefault": { + try { + const manager = provider.getCurrentWorkspaceCodeIndexManager() + if (!manager) { + provider.log("Cannot set auto-enable default: No workspace folder open") + return + } + // Capture prior state for every manager before persisting the global change + const allManagers = CodeIndexManager.getAllInstances() + const priorStates = new Map(allManagers.map((m) => [m, m.isWorkspaceEnabled])) + await manager.setAutoEnableDefault(message.bool ?? true) + // Apply stop/start to every affected manager + for (const m of allManagers) { + const wasEnabled = priorStates.get(m)! + const isNowEnabled = m.isWorkspaceEnabled + if (wasEnabled && !isNowEnabled) { + m.stopIndexing() + } else if (!wasEnabled && isNowEnabled && m.isFeatureEnabled && m.isFeatureConfigured) { + await m.initialize(provider.contextProxy) + m.startIndexing() + } + } + provider.postMessageToWebview({ + type: "indexingStatusUpdate", + values: manager.getCurrentStatus(), + }) + } catch (error) { + provider.log( + `Error setting auto-enable default: ${error instanceof Error ? error.message : String(error)}`, + ) + } + break + } case "clearIndexData": { try { const manager = provider.getCurrentWorkspaceCodeIndexManager() diff --git a/src/i18n/locales/ca/embeddings.json b/src/i18n/locales/ca/embeddings.json index 21a4a27ab4..9ceec7d05c 100644 --- a/src/i18n/locales/ca/embeddings.json +++ b/src/i18n/locales/ca/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Monitor de fitxers aturat.", "failedDuringInitialScan": "Ha fallat durant l'escaneig inicial: {{errorMessage}}", "unknownError": "Error desconegut", - "indexingRequiresWorkspace": "Indexació requereix una carpeta de workspace oberta" + "indexingRequiresWorkspace": "Indexació requereix una carpeta de workspace oberta", + "indexingStopped": "Indexació aturada per l'usuari.", + "indexingStoppedPartial": "Indexació aturada. Dades d'índex parcials conservades." } } diff --git a/src/i18n/locales/de/embeddings.json b/src/i18n/locales/de/embeddings.json index 0297ec0309..766d31d5ba 100644 --- a/src/i18n/locales/de/embeddings.json +++ b/src/i18n/locales/de/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Datei-Watcher gestoppt.", "failedDuringInitialScan": "Fehler während des ersten Scans: {{errorMessage}}", "unknownError": "Unbekannter Fehler", - "indexingRequiresWorkspace": "Indexierung erfordert einen offenen Workspace-Ordner" + "indexingRequiresWorkspace": "Indexierung erfordert einen offenen Workspace-Ordner", + "indexingStopped": "Indexierung vom Benutzer gestoppt.", + "indexingStoppedPartial": "Indexierung gestoppt. Teilweise Indexdaten beibehalten." } } diff --git a/src/i18n/locales/en/embeddings.json b/src/i18n/locales/en/embeddings.json index 5819e45c1a..7777af9027 100644 --- a/src/i18n/locales/en/embeddings.json +++ b/src/i18n/locales/en/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "File watcher stopped.", "failedDuringInitialScan": "Failed during initial scan: {{errorMessage}}", "unknownError": "Unknown error", - "indexingRequiresWorkspace": "Indexing requires an open workspace folder" + "indexingRequiresWorkspace": "Indexing requires an open workspace folder", + "indexingStopped": "Indexing stopped by user.", + "indexingStoppedPartial": "Indexing stopped. Partial index data preserved." } } diff --git a/src/i18n/locales/es/embeddings.json b/src/i18n/locales/es/embeddings.json index eca9efcc07..930404de1f 100644 --- a/src/i18n/locales/es/embeddings.json +++ b/src/i18n/locales/es/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Monitor de archivos detenido.", "failedDuringInitialScan": "Falló durante el escaneo inicial: {{errorMessage}}", "unknownError": "Error desconocido", - "indexingRequiresWorkspace": "La indexación requiere una carpeta de workspace abierta" + "indexingRequiresWorkspace": "La indexación requiere una carpeta de workspace abierta", + "indexingStopped": "Indexación detenida por el usuario.", + "indexingStoppedPartial": "Indexación detenida. Datos de índice parciales conservados." } } diff --git a/src/i18n/locales/fr/embeddings.json b/src/i18n/locales/fr/embeddings.json index fa92217987..7de086307e 100644 --- a/src/i18n/locales/fr/embeddings.json +++ b/src/i18n/locales/fr/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Surveillant de fichiers arrêté.", "failedDuringInitialScan": "Échec lors du scan initial : {{errorMessage}}", "unknownError": "Erreur inconnue", - "indexingRequiresWorkspace": "L'indexation nécessite l'ouverture d'un dossier workspace" + "indexingRequiresWorkspace": "L'indexation nécessite l'ouverture d'un dossier workspace", + "indexingStopped": "Indexation arrêtée par l'utilisateur.", + "indexingStoppedPartial": "Indexation arrêtée. Données d'index partielles conservées." } } diff --git a/src/i18n/locales/hi/embeddings.json b/src/i18n/locales/hi/embeddings.json index eb7f066c56..9c7f9ca50a 100644 --- a/src/i18n/locales/hi/embeddings.json +++ b/src/i18n/locales/hi/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "फाइल वॉचर रुक गया।", "failedDuringInitialScan": "प्रारंभिक स्कैन के दौरान असफल: {{errorMessage}}", "unknownError": "अज्ञात त्रुटि", - "indexingRequiresWorkspace": "इंडेक्सिंग के लिए एक खुला वर्कस्पेस फ़ोल्डर आवश्यक है" + "indexingRequiresWorkspace": "इंडेक्सिंग के लिए एक खुला वर्कस्पेस फ़ोल्डर आवश्यक है", + "indexingStopped": "उपयोगकर्ता द्वारा इंडेक्सिंग रोकी गई।", + "indexingStoppedPartial": "इंडेक्सिंग रोकी गई। आंशिक इंडेक्स डेटा संरक्षित।" } } diff --git a/src/i18n/locales/id/embeddings.json b/src/i18n/locales/id/embeddings.json index cceb965430..955a039eff 100644 --- a/src/i18n/locales/id/embeddings.json +++ b/src/i18n/locales/id/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Pemantau file dihentikan.", "failedDuringInitialScan": "Gagal selama pemindaian awal: {{errorMessage}}", "unknownError": "Kesalahan tidak diketahui", - "indexingRequiresWorkspace": "Pengindeksan memerlukan folder workspace yang terbuka" + "indexingRequiresWorkspace": "Pengindeksan memerlukan folder workspace yang terbuka", + "indexingStopped": "Pengindeksan dihentikan oleh pengguna.", + "indexingStoppedPartial": "Pengindeksan dihentikan. Data indeks parsial dipertahankan." } } diff --git a/src/i18n/locales/it/embeddings.json b/src/i18n/locales/it/embeddings.json index 2e339ef5d8..b7314c244d 100644 --- a/src/i18n/locales/it/embeddings.json +++ b/src/i18n/locales/it/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Monitoraggio file fermato.", "failedDuringInitialScan": "Fallito durante la scansione iniziale: {{errorMessage}}", "unknownError": "Errore sconosciuto", - "indexingRequiresWorkspace": "L'indicizzazione richiede una cartella di workspace aperta" + "indexingRequiresWorkspace": "L'indicizzazione richiede una cartella di workspace aperta", + "indexingStopped": "Indicizzazione interrotta dall'utente.", + "indexingStoppedPartial": "Indicizzazione interrotta. Dati di indice parziali conservati." } } diff --git a/src/i18n/locales/ja/embeddings.json b/src/i18n/locales/ja/embeddings.json index 5223c204e0..ce7150cf1c 100644 --- a/src/i18n/locales/ja/embeddings.json +++ b/src/i18n/locales/ja/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "ファイルウォッチャーが停止されました。", "failedDuringInitialScan": "初期スキャン中に失敗しました:{{errorMessage}}", "unknownError": "不明なエラー", - "indexingRequiresWorkspace": "インデックス作成には、開かれたワークスペースフォルダーが必要です" + "indexingRequiresWorkspace": "インデックス作成には、開かれたワークスペースフォルダーが必要です", + "indexingStopped": "ユーザーによりインデックス作成が停止されました。", + "indexingStoppedPartial": "インデックス作成が停止されました。部分的なインデックスデータは保持されています。" } } diff --git a/src/i18n/locales/ko/embeddings.json b/src/i18n/locales/ko/embeddings.json index 236662eea2..436fa985c0 100644 --- a/src/i18n/locales/ko/embeddings.json +++ b/src/i18n/locales/ko/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "파일 감시자가 중지되었습니다.", "failedDuringInitialScan": "초기 스캔 중 실패: {{errorMessage}}", "unknownError": "알 수 없는 오류", - "indexingRequiresWorkspace": "인덱싱에는 열린 워크스페이스 폴더가 필요합니다" + "indexingRequiresWorkspace": "인덱싱에는 열린 워크스페이스 폴더가 필요합니다", + "indexingStopped": "사용자에 의해 인덱싱이 중지되었습니다.", + "indexingStoppedPartial": "인덱싱이 중지되었습니다. 부분 인덱스 데이터가 보존되었습니다." } } diff --git a/src/i18n/locales/nl/embeddings.json b/src/i18n/locales/nl/embeddings.json index cce3f05c62..01e68683d3 100644 --- a/src/i18n/locales/nl/embeddings.json +++ b/src/i18n/locales/nl/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Bestandsmonitor gestopt.", "failedDuringInitialScan": "Mislukt tijdens initiële scan: {{errorMessage}}", "unknownError": "Onbekende fout", - "indexingRequiresWorkspace": "Indexering vereist een geopende workspace map" + "indexingRequiresWorkspace": "Indexering vereist een geopende workspace map", + "indexingStopped": "Indexering gestopt door gebruiker.", + "indexingStoppedPartial": "Indexering gestopt. Gedeeltelijke indexgegevens bewaard." } } diff --git a/src/i18n/locales/pl/embeddings.json b/src/i18n/locales/pl/embeddings.json index 133f9f40da..0ef846b2cc 100644 --- a/src/i18n/locales/pl/embeddings.json +++ b/src/i18n/locales/pl/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Monitor plików zatrzymany.", "failedDuringInitialScan": "Niepowodzenie podczas początkowego skanowania: {{errorMessage}}", "unknownError": "Nieznany błąd", - "indexingRequiresWorkspace": "Indeksowanie wymaga otwartego folderu workspace" + "indexingRequiresWorkspace": "Indeksowanie wymaga otwartego folderu workspace", + "indexingStopped": "Indeksowanie zatrzymane przez użytkownika.", + "indexingStoppedPartial": "Indeksowanie zatrzymane. Częściowe dane indeksu zachowane." } } diff --git a/src/i18n/locales/pt-BR/embeddings.json b/src/i18n/locales/pt-BR/embeddings.json index 09f4a55787..9cdf775e76 100644 --- a/src/i18n/locales/pt-BR/embeddings.json +++ b/src/i18n/locales/pt-BR/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Monitor de arquivos parado.", "failedDuringInitialScan": "Falhou durante a varredura inicial: {{errorMessage}}", "unknownError": "Erro desconhecido", - "indexingRequiresWorkspace": "A indexação requer uma pasta de workspace aberta" + "indexingRequiresWorkspace": "A indexação requer uma pasta de workspace aberta", + "indexingStopped": "Indexação interrompida pelo usuário.", + "indexingStoppedPartial": "Indexação interrompida. Dados de índice parciais preservados." } } diff --git a/src/i18n/locales/ru/embeddings.json b/src/i18n/locales/ru/embeddings.json index 9e94082bbf..873b1c0630 100644 --- a/src/i18n/locales/ru/embeddings.json +++ b/src/i18n/locales/ru/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Наблюдатель файлов остановлен.", "failedDuringInitialScan": "Ошибка во время первоначального сканирования: {{errorMessage}}", "unknownError": "Неизвестная ошибка", - "indexingRequiresWorkspace": "Для индексации требуется открытая папка рабочего пространства" + "indexingRequiresWorkspace": "Для индексации требуется открытая папка рабочего пространства", + "indexingStopped": "Индексация остановлена пользователем.", + "indexingStoppedPartial": "Индексация остановлена. Частичные данные индекса сохранены." } } diff --git a/src/i18n/locales/tr/embeddings.json b/src/i18n/locales/tr/embeddings.json index 411ed7ab52..30b703a93f 100644 --- a/src/i18n/locales/tr/embeddings.json +++ b/src/i18n/locales/tr/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Dosya izleyici durduruldu.", "failedDuringInitialScan": "İlk tarama sırasında başarısız: {{errorMessage}}", "unknownError": "Bilinmeyen hata", - "indexingRequiresWorkspace": "İndeksleme açık bir workspace klasörü gerektirir" + "indexingRequiresWorkspace": "İndeksleme açık bir workspace klasörü gerektirir", + "indexingStopped": "İndeksleme kullanıcı tarafından durduruldu.", + "indexingStoppedPartial": "İndeksleme durduruldu. Kısmi indeks verileri korundu." } } diff --git a/src/i18n/locales/vi/embeddings.json b/src/i18n/locales/vi/embeddings.json index c9f9880df0..c92ebba276 100644 --- a/src/i18n/locales/vi/embeddings.json +++ b/src/i18n/locales/vi/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Trình theo dõi tệp đã dừng.", "failedDuringInitialScan": "Thất bại trong quá trình quét ban đầu: {{errorMessage}}", "unknownError": "Lỗi không xác định", - "indexingRequiresWorkspace": "Lập chỉ mục yêu cầu một thư mục workspace đang mở" + "indexingRequiresWorkspace": "Lập chỉ mục yêu cầu một thư mục workspace đang mở", + "indexingStopped": "Lập chỉ mục đã bị dừng bởi người dùng.", + "indexingStoppedPartial": "Lập chỉ mục đã dừng. Dữ liệu chỉ mục một phần được bảo toàn." } } diff --git a/src/i18n/locales/zh-CN/embeddings.json b/src/i18n/locales/zh-CN/embeddings.json index c27bc07801..b4f4eaad1d 100644 --- a/src/i18n/locales/zh-CN/embeddings.json +++ b/src/i18n/locales/zh-CN/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "文件监控已停止。", "failedDuringInitialScan": "初始扫描失败:{{errorMessage}}", "unknownError": "未知错误", - "indexingRequiresWorkspace": "索引需要打开的工作区文件夹" + "indexingRequiresWorkspace": "索引需要打开的工作区文件夹", + "indexingStopped": "用户已停止索引。", + "indexingStoppedPartial": "索引已停止。部分索引数据已保留。" } } diff --git a/src/i18n/locales/zh-TW/embeddings.json b/src/i18n/locales/zh-TW/embeddings.json index 744e7022ea..26845ed948 100644 --- a/src/i18n/locales/zh-TW/embeddings.json +++ b/src/i18n/locales/zh-TW/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "檔案監控已停止。", "failedDuringInitialScan": "初始掃描失敗:{{errorMessage}}", "unknownError": "未知錯誤", - "indexingRequiresWorkspace": "索引需要開啟的工作區資料夾" + "indexingRequiresWorkspace": "索引需要開啟的工作區資料夾", + "indexingStopped": "使用者已停止索引。", + "indexingStoppedPartial": "索引已停止。部分索引資料已保留。" } } diff --git a/src/services/code-index/__tests__/manager.spec.ts b/src/services/code-index/__tests__/manager.spec.ts index 929f6f93c8..49a6d91c76 100644 --- a/src/services/code-index/__tests__/manager.spec.ts +++ b/src/services/code-index/__tests__/manager.spec.ts @@ -3,18 +3,45 @@ import { CodeIndexServiceFactory } from "../service-factory" import type { MockedClass } from "vitest" import * as path from "path" +// Helper: create a mock vscode.Uri from an fsPath +function mockUri(fsPath: string, scheme = "file") { + return { + fsPath, + scheme, + authority: "", + path: fsPath, + toString: (skipEncoding?: boolean) => `${scheme}://${fsPath}`, + } +} + // Mock vscode module vi.mock("vscode", () => { const testPath = require("path") const testWorkspacePath = testPath.join(testPath.sep, "test", "workspace") return { + Uri: { + file: (p: string) => ({ + fsPath: p, + scheme: "file", + authority: "", + path: p, + toString: (_skipEncoding?: boolean) => `file://${p}`, + }), + joinPath: vi.fn((...args: any[]) => ({ fsPath: args.join("/") })), + }, window: { activeTextEditor: null, }, workspace: { workspaceFolders: [ { - uri: { fsPath: testWorkspacePath }, + uri: { + fsPath: testWorkspacePath, + scheme: "file", + authority: "", + path: testWorkspacePath, + toString: (_skipEncoding?: boolean) => `file://${testWorkspacePath}`, + }, name: "test", index: 0, }, @@ -25,8 +52,9 @@ vi.mock("vscode", () => { onDidDelete: vi.fn().mockReturnValue({ dispose: vi.fn() }), dispose: vi.fn(), }), + getWorkspaceFolder: vi.fn(), }, - RelativePattern: vi.fn().mockImplementation((base, pattern) => ({ base, pattern })), + RelativePattern: vi.fn().mockImplementation((base: any, pattern: any) => ({ base, pattern })), } }) @@ -95,10 +123,22 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { // Clear all instances before each test CodeIndexManager.disposeAll() + const workspaceStateStore: Record = {} + const globalStateStore: Record = {} mockContext = { subscriptions: [], - workspaceState: {} as any, - globalState: {} as any, + workspaceState: { + get: vi.fn((key: string, defaultValue?: any) => workspaceStateStore[key] ?? defaultValue), + update: vi.fn(async (key: string, value: any) => { + workspaceStateStore[key] = value + }), + } as any, + globalState: { + get: vi.fn((key: string, defaultValue?: any) => globalStateStore[key] ?? defaultValue), + update: vi.fn(async (key: string, value: any) => { + globalStateStore[key] = value + }), + } as any, extensionUri: {} as any, extensionPath: testExtensionPath, asAbsolutePath: vi.fn(), @@ -222,7 +262,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { ;(manager as any)._cacheManager = mockCacheManager // Simulate an initialized manager by setting the required properties - ;(manager as any)._orchestrator = { stopWatcher: vi.fn() } + ;(manager as any)._orchestrator = { stopWatcher: vi.fn(), stopIndexing: vi.fn() } ;(manager as any)._searchService = {} // Verify manager is considered initialized @@ -456,7 +496,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { }) // Mock orchestrator and search service to simulate initialized state - ;(manager as any)._orchestrator = { stopWatcher: vi.fn(), state: "Error" } + ;(manager as any)._orchestrator = { stopWatcher: vi.fn(), stopIndexing: vi.fn(), state: "Error" } ;(manager as any)._searchService = {} ;(manager as any)._serviceFactory = {} }) @@ -540,6 +580,9 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { }), } + // Enable workspace indexing before re-initialization + await manager.setWorkspaceEnabled(true) + // Re-initialize await manager.initialize(mockContextProxy as any) @@ -583,7 +626,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { // Setup manager with service instances ;(manager as any)._configManager = mockConfigManager ;(manager as any)._serviceFactory = {} - ;(manager as any)._orchestrator = { stopWatcher: vi.fn() } + ;(manager as any)._orchestrator = { stopWatcher: vi.fn(), stopIndexing: vi.fn() } ;(manager as any)._searchService = {} // Spy on console.error @@ -608,4 +651,155 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { consoleErrorSpy.mockRestore() }) }) + + describe("workspace-enabled gating", () => { + it("should not start indexing when workspace is not enabled", async () => { + await manager.setAutoEnableDefault(false) + + const mockStateManager = (manager as any)._stateManager + mockStateManager.setSystemState = vi.fn() + mockStateManager.getCurrentStatus = vi.fn().mockReturnValue({ + systemStatus: "Standby", + message: "", + processedItems: 0, + totalItems: 0, + currentItemUnit: "items", + }) + + expect(manager.isWorkspaceEnabled).toBe(false) + + await manager.startIndexing() + + expect(mockStateManager.setSystemState).not.toHaveBeenCalledWith("Indexing", expect.any(String)) + }) + + it("should include workspaceEnabled in getCurrentStatus", async () => { + await manager.setAutoEnableDefault(false) + + const mockStateManager = (manager as any)._stateManager + mockStateManager.getCurrentStatus = vi.fn().mockReturnValue({ + systemStatus: "Standby", + message: "", + processedItems: 0, + totalItems: 0, + currentItemUnit: "items", + }) + + const status = manager.getCurrentStatus() + expect(status.workspaceEnabled).toBe(false) + }) + + it("should persist workspace enabled state", async () => { + await manager.setAutoEnableDefault(false) + expect(manager.isWorkspaceEnabled).toBe(false) + + await manager.setWorkspaceEnabled(true) + expect(manager.isWorkspaceEnabled).toBe(true) + + await manager.setWorkspaceEnabled(false) + expect(manager.isWorkspaceEnabled).toBe(false) + }) + + it("should store enablement per folder URI, not per window", async () => { + CodeIndexManager.disposeAll() + + const vscode = await import("vscode") + + const folderAPath = path.join(path.sep, "test", "folderA") + const folderBPath = path.join(path.sep, "test", "folderB") + const folderAUri = mockUri(folderAPath) + const folderBUri = mockUri(folderBPath) + + // Both folders share the same workspaceState (same window) + const sharedStore: Record = {} + const sharedContext = { + ...mockContext, + workspaceState: { + get: vi.fn((key: string, defaultValue?: any) => sharedStore[key] ?? defaultValue), + update: vi.fn(async (key: string, value: any) => { + sharedStore[key] = value + }), + } as any, + globalState: { + get: vi.fn((_key: string, _defaultValue?: any) => false), + update: vi.fn(), + } as any, + } + + // Patch workspaceFolders to include both folders + ;(vscode.workspace as any).workspaceFolders = [ + { uri: folderAUri, name: "folderA", index: 0 }, + { uri: folderBUri, name: "folderB", index: 1 }, + ] + + const managerA = CodeIndexManager.getInstance(sharedContext as any, folderAPath)! + const managerB = CodeIndexManager.getInstance(sharedContext as any, folderBPath)! + + // Both start disabled (autoEnableDefault is false via globalState mock) + expect(managerA.isWorkspaceEnabled).toBe(false) + expect(managerB.isWorkspaceEnabled).toBe(false) + + // Enable A only + await managerA.setWorkspaceEnabled(true) + + expect(managerA.isWorkspaceEnabled).toBe(true) + expect(managerB.isWorkspaceEnabled).toBe(false) + + // Enable B, disable A + await managerB.setWorkspaceEnabled(true) + await managerA.setWorkspaceEnabled(false) + + expect(managerA.isWorkspaceEnabled).toBe(false) + expect(managerB.isWorkspaceEnabled).toBe(true) + + CodeIndexManager.disposeAll() + }) + }) + + describe("stopIndexing", () => { + it("should delegate to orchestrator.stopIndexing()", () => { + const mockOrchestrator = { + stopIndexing: vi.fn(), + stopWatcher: vi.fn(), + state: "Indexing", + } + ;(manager as any)._orchestrator = mockOrchestrator + + manager.stopIndexing() + + expect(mockOrchestrator.stopIndexing).toHaveBeenCalled() + }) + + it("should be safe to call when orchestrator is not set", () => { + ;(manager as any)._orchestrator = undefined + + expect(() => manager.stopIndexing()).not.toThrow() + }) + }) + + describe("handleSettingsChange - disable toggle bug fix", () => { + it("should abort active indexing when feature is disabled", async () => { + const mockOrchestrator = { + stopIndexing: vi.fn(), + stopWatcher: vi.fn(), + state: "Indexing", + } + ;(manager as any)._orchestrator = mockOrchestrator + + const mockConfigManager = { + loadConfiguration: vi.fn().mockResolvedValue({ requiresRestart: false }), + isFeatureConfigured: true, + isFeatureEnabled: false, + } + ;(manager as any)._configManager = mockConfigManager + + const mockStateManager = (manager as any)._stateManager + mockStateManager.setSystemState = vi.fn() + + await manager.handleSettingsChange() + + expect(mockOrchestrator.stopIndexing).toHaveBeenCalled() + expect(mockStateManager.setSystemState).toHaveBeenCalledWith("Standby", "Code indexing is disabled") + }) + }) }) diff --git a/src/services/code-index/__tests__/orchestrator.spec.ts b/src/services/code-index/__tests__/orchestrator.spec.ts index aab1ef888d..e940ea04c2 100644 --- a/src/services/code-index/__tests__/orchestrator.spec.ts +++ b/src/services/code-index/__tests__/orchestrator.spec.ts @@ -79,6 +79,7 @@ describe("CodeIndexOrchestrator - error path cleanup gating", () => { cacheManager = { clearCacheFile: vi.fn().mockResolvedValue(undefined), + flush: vi.fn().mockResolvedValue(undefined), } vectorStore = { @@ -158,3 +159,178 @@ describe("CodeIndexOrchestrator - error path cleanup gating", () => { expect(lastCall[0]).toBe("Error") }) }) + +describe("CodeIndexOrchestrator - stopIndexing", () => { + const workspacePath = "/test/workspace" + + let configManager: any + let stateManager: any + let cacheManager: any + let vectorStore: any + let scanner: any + let fileWatcher: any + + beforeEach(() => { + vi.clearAllMocks() + + configManager = { + isFeatureConfigured: true, + } + + let currentState = "Standby" + stateManager = { + get state() { + return currentState + }, + setSystemState: vi.fn().mockImplementation((state: string, _msg: string) => { + currentState = state + }), + reportFileQueueProgress: vi.fn(), + reportBlockIndexingProgress: vi.fn(), + } + + cacheManager = { + clearCacheFile: vi.fn().mockResolvedValue(undefined), + flush: vi.fn().mockResolvedValue(undefined), + } + + vectorStore = { + initialize: vi.fn().mockResolvedValue(false), + hasIndexedData: vi.fn().mockResolvedValue(false), + markIndexingIncomplete: vi.fn().mockResolvedValue(undefined), + markIndexingComplete: vi.fn().mockResolvedValue(undefined), + clearCollection: vi.fn().mockResolvedValue(undefined), + } + + scanner = { + scanDirectory: vi.fn(), + } + + fileWatcher = { + initialize: vi.fn().mockResolvedValue(undefined), + onDidStartBatchProcessing: vi.fn().mockReturnValue({ dispose: vi.fn() }), + onBatchProgressUpdate: vi.fn().mockReturnValue({ dispose: vi.fn() }), + onDidFinishBatchProcessing: vi.fn().mockReturnValue({ dispose: vi.fn() }), + dispose: vi.fn(), + } + }) + + it("should abort indexing when stopIndexing() is called", async () => { + // Make scanner hang until aborted + scanner.scanDirectory.mockImplementation( + async (_dir: string, _onError?: any, _onBlocksIndexed?: any, _onFileParsed?: any, signal?: AbortSignal) => { + // Wait for abort signal + await new Promise((resolve) => { + if (signal?.aborted) { + resolve() + return + } + signal?.addEventListener("abort", () => resolve()) + }) + return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 } + }, + ) + + const orchestrator = new CodeIndexOrchestrator( + configManager, + stateManager, + workspacePath, + cacheManager, + vectorStore, + scanner, + fileWatcher, + ) + + // Start indexing (async, don't await) + const indexingPromise = orchestrator.startIndexing() + + // Give it a tick to begin + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Stop indexing + orchestrator.stopIndexing() + + // Wait for indexing to complete + await indexingPromise + + // State should be Standby (not Error) + const setStateCalls = stateManager.setSystemState.mock.calls + const lastCall = setStateCalls[setStateCalls.length - 1] + expect(lastCall[0]).toBe("Standby") + }) + + it("should set state to Standby after abort, not Error", async () => { + // Make scanner throw AbortError when signal is aborted + scanner.scanDirectory.mockImplementation( + async (_dir: string, _onError?: any, _onBlocksIndexed?: any, _onFileParsed?: any, signal?: AbortSignal) => { + await new Promise((resolve) => { + if (signal?.aborted) { + resolve() + return + } + signal?.addEventListener("abort", () => resolve()) + }) + throw new DOMException("Indexing aborted", "AbortError") + }, + ) + + const orchestrator = new CodeIndexOrchestrator( + configManager, + stateManager, + workspacePath, + cacheManager, + vectorStore, + scanner, + fileWatcher, + ) + + const indexingPromise = orchestrator.startIndexing() + await new Promise((resolve) => setTimeout(resolve, 10)) + + orchestrator.stopIndexing() + await indexingPromise + + // Should NOT have set Error state — abort is handled gracefully + const errorCalls = stateManager.setSystemState.mock.calls.filter((call: any[]) => call[0] === "Error") + expect(errorCalls).toHaveLength(0) + + // Should NOT have cleared collection on abort + expect(vectorStore.clearCollection).not.toHaveBeenCalled() + }) + + it("should preserve partial index data after stop", async () => { + scanner.scanDirectory.mockImplementation( + async (_dir: string, _onError?: any, _onBlocksIndexed?: any, _onFileParsed?: any, signal?: AbortSignal) => { + await new Promise((resolve) => { + if (signal?.aborted) { + resolve() + return + } + signal?.addEventListener("abort", () => resolve()) + }) + return { stats: { processed: 5, skipped: 0 }, totalBlockCount: 5 } + }, + ) + + const orchestrator = new CodeIndexOrchestrator( + configManager, + stateManager, + workspacePath, + cacheManager, + vectorStore, + scanner, + fileWatcher, + ) + + const indexingPromise = orchestrator.startIndexing() + await new Promise((resolve) => setTimeout(resolve, 10)) + + orchestrator.stopIndexing() + await indexingPromise + + // Cache should NOT be cleared on user-initiated stop + expect(cacheManager.clearCacheFile).not.toHaveBeenCalled() + // Collection should NOT be cleared on user-initiated stop + expect(vectorStore.clearCollection).not.toHaveBeenCalled() + }) +}) diff --git a/src/services/code-index/cache-manager.ts b/src/services/code-index/cache-manager.ts index a9a4f0ac47..eadaa9e346 100644 --- a/src/services/code-index/cache-manager.ts +++ b/src/services/code-index/cache-manager.ts @@ -110,6 +110,13 @@ export class CacheManager implements ICacheManager { this._debouncedSaveCache() } + /** + * Flushes any pending debounced cache writes to disk immediately. + */ + async flush(): Promise { + await this._performSave() + } + /** * Gets a copy of all file hashes * @returns A copy of the file hashes record diff --git a/src/services/code-index/interfaces/cache.ts b/src/services/code-index/interfaces/cache.ts index a2e62bcac1..01931a3a8b 100644 --- a/src/services/code-index/interfaces/cache.ts +++ b/src/services/code-index/interfaces/cache.ts @@ -2,5 +2,6 @@ export interface ICacheManager { getHash(filePath: string): string | undefined updateHash(filePath: string, hash: string): void deleteHash(filePath: string): void + flush(): Promise getAllHashes(): Record } diff --git a/src/services/code-index/interfaces/file-processor.ts b/src/services/code-index/interfaces/file-processor.ts index 88b19007c3..8ecdc518c8 100644 --- a/src/services/code-index/interfaces/file-processor.ts +++ b/src/services/code-index/interfaces/file-processor.ts @@ -37,6 +37,7 @@ export interface IDirectoryScanner { onError?: (error: Error) => void, onBlocksIndexed?: (indexedCount: number) => void, onFileParsed?: (fileBlockCount: number) => void, + signal?: AbortSignal, ): Promise<{ stats: { processed: number diff --git a/src/services/code-index/interfaces/manager.ts b/src/services/code-index/interfaces/manager.ts index 28ff552327..d657ad667c 100644 --- a/src/services/code-index/interfaces/manager.ts +++ b/src/services/code-index/interfaces/manager.ts @@ -39,6 +39,11 @@ export interface ICodeIndexManager { */ startIndexing(): Promise + /** + * Stops any in-progress indexing operation and the file watcher + */ + stopIndexing(): void + /** * Stops the file watcher */ @@ -69,7 +74,7 @@ export interface ICodeIndexManager { dispose(): void } -export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" +export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" | "Stopping" export type EmbedderProvider = | "openai" | "ollama" diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index dd79a3f161..91ea515e40 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -32,30 +32,47 @@ export class CodeIndexManager { private _isRecoveringFromError = false public static getInstance(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexManager | undefined { - // If workspacePath is not provided, try to get it from the active editor or first workspace folder - if (!workspacePath) { + // Resolve the workspace folder to get both fsPath and the real URI + let folder: vscode.WorkspaceFolder | undefined + + if (workspacePath) { + folder = vscode.workspace.workspaceFolders?.find((f) => f.uri.fsPath === workspacePath) + } else { const activeEditor = vscode.window.activeTextEditor if (activeEditor) { - const workspaceFolder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri) - workspacePath = workspaceFolder?.uri.fsPath + folder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri) } - - if (!workspacePath) { + if (!folder) { const workspaceFolders = vscode.workspace.workspaceFolders if (!workspaceFolders || workspaceFolders.length === 0) { return undefined } - // Use the first workspace folder as fallback - workspacePath = workspaceFolders[0].uri.fsPath + folder = workspaceFolders[0] } + workspacePath = folder.uri.fsPath } if (!CodeIndexManager.instances.has(workspacePath)) { - CodeIndexManager.instances.set(workspacePath, new CodeIndexManager(workspacePath, context)) + // folder may be undefined when workspacePath was provided but doesn't match + // any workspace folder (e.g. cwd passed from a tool). Fall back to file:// URI. + const folderUri = + folder?.uri ?? + ({ + fsPath: workspacePath, + scheme: "file", + authority: "", + path: workspacePath, + toString: () => `file://${workspacePath}`, + } as unknown as vscode.Uri) + CodeIndexManager.instances.set(workspacePath, new CodeIndexManager(workspacePath, folderUri, context)) } return CodeIndexManager.instances.get(workspacePath)! } + public static getAllInstances(): CodeIndexManager[] { + return Array.from(CodeIndexManager.instances.values()) + } + public static disposeAll(): void { for (const instance of CodeIndexManager.instances.values()) { instance.dispose() @@ -64,17 +81,45 @@ export class CodeIndexManager { } private readonly workspacePath: string + private readonly _folderUri: vscode.Uri private readonly context: vscode.ExtensionContext // Private constructor for singleton pattern - private constructor(workspacePath: string, context: vscode.ExtensionContext) { + private constructor(workspacePath: string, folderUri: vscode.Uri, context: vscode.ExtensionContext) { this.workspacePath = workspacePath + this._folderUri = folderUri this.context = context this._stateManager = new CodeIndexStateManager() } // --- Public API --- + /** + * Returns the workspaceState key for per-folder indexing enablement, + * keyed by the real workspace folder URI so local/remote schemes cannot collide. + */ + private _workspaceEnabledKey(): string { + return "codeIndexWorkspaceEnabled:" + this._folderUri.toString(true) + } + + public get isWorkspaceEnabled(): boolean { + const explicit = this.context.workspaceState.get(this._workspaceEnabledKey(), undefined) + if (explicit !== undefined) return explicit + return this.autoEnableDefault + } + + public async setWorkspaceEnabled(enabled: boolean): Promise { + await this.context.workspaceState.update(this._workspaceEnabledKey(), enabled) + } + + public get autoEnableDefault(): boolean { + return this.context.globalState.get("codeIndexAutoEnableDefault", true) + } + + public async setAutoEnableDefault(enabled: boolean): Promise { + await this.context.globalState.update("codeIndexAutoEnableDefault", enabled) + } + public get onProgressUpdate() { return this._stateManager.onProgressUpdate } @@ -138,28 +183,32 @@ export class CodeIndexManager { return { requiresRestart } } - // 4. CacheManager Initialization + // 4. Check workspace-level enablement (before creating expensive services) + if (!this.isWorkspaceEnabled) { + this._stateManager.setSystemState("Standby", "Indexing not enabled for this workspace") + return { requiresRestart } + } + + // 5. CacheManager Initialization if (!this._cacheManager) { this._cacheManager = new CacheManager(this.context, this.workspacePath) await this._cacheManager.initialize() } - // 4. Determine if Core Services Need Recreation + // 6. Determine if Core Services Need Recreation const needsServiceRecreation = !this._serviceFactory || requiresRestart if (needsServiceRecreation) { await this._recreateServices() } - // 5. Handle Indexing Start/Restart - // The enhanced vectorStore.initialize() in startIndexing() now handles dimension changes automatically - // by detecting incompatible collections and recreating them, so we rely on that for dimension changes + // 7. Handle Indexing Start/Restart const shouldStartOrRestartIndexing = requiresRestart || (needsServiceRecreation && (!this._orchestrator || this._orchestrator.state !== "Indexing")) if (shouldStartOrRestartIndexing) { - this._orchestrator?.startIndexing() // This method is async, but we don't await it here + this._orchestrator?.startIndexing() } return { requiresRestart } @@ -173,7 +222,7 @@ export class CodeIndexManager { * The indexing will continue asynchronously and progress will be reported through events. */ public async startIndexing(): Promise { - if (!this.isFeatureEnabled) { + if (!this.isFeatureEnabled || !this.isWorkspaceEnabled) { return } @@ -191,6 +240,15 @@ export class CodeIndexManager { await this._orchestrator!.startIndexing() } + /** + * Stops any in-progress indexing operation and the file watcher. + */ + public stopIndexing(): void { + if (this._orchestrator) { + this._orchestrator.stopIndexing() + } + } + /** * Stops the file watcher and potentially cleans up resources. */ @@ -247,9 +305,7 @@ export class CodeIndexManager { * Cleans up the manager instance. */ public dispose(): void { - if (this._orchestrator) { - this.stopWatcher() - } + this.stopIndexing() this._stateManager.dispose() } @@ -273,6 +329,8 @@ export class CodeIndexManager { return { ...status, workspacePath: this.workspacePath, + workspaceEnabled: this.isWorkspaceEnabled, + autoEnableDefault: this.autoEnableDefault, } } @@ -384,13 +442,9 @@ export class CodeIndexManager { const isFeatureEnabled = this.isFeatureEnabled const isFeatureConfigured = this.isFeatureConfigured - // If feature is disabled, stop the service + // If feature is disabled, stop the service (including any active scan) if (!isFeatureEnabled) { - // Stop the orchestrator if it exists - if (this._orchestrator) { - this._orchestrator.stopWatcher() - } - // Set state to indicate service is disabled + this.stopIndexing() this._stateManager.setSystemState("Standby", "Code indexing is disabled") return } diff --git a/src/services/code-index/orchestrator.ts b/src/services/code-index/orchestrator.ts index 99f317882b..cd65fceb5e 100644 --- a/src/services/code-index/orchestrator.ts +++ b/src/services/code-index/orchestrator.ts @@ -15,6 +15,7 @@ import { t } from "../../i18n" export class CodeIndexOrchestrator { private _fileWatcherSubscriptions: vscode.Disposable[] = [] private _isProcessing: boolean = false + private _abortController: AbortController | null = null constructor( private readonly configManager: CodeIndexConfigManager, @@ -121,6 +122,8 @@ export class CodeIndexOrchestrator { } this._isProcessing = true + this._abortController = new AbortController() + const signal = this._abortController.signal this.stateManager.setSystemState("Indexing", "Initializing services...") // Track whether we successfully connected to Qdrant and started indexing @@ -178,8 +181,16 @@ export class CodeIndexOrchestrator { }, handleBlocksIndexed, handleFileParsed, + signal, ) + if (signal.aborted) { + await this.cacheManager.flush() + this.stopWatcher() + this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped")) + return + } + if (!result) { throw new Error("Incremental scan failed, is scanner initialized?") } @@ -231,8 +242,16 @@ export class CodeIndexOrchestrator { }, handleBlocksIndexed, handleFileParsed, + signal, ) + if (signal.aborted) { + await this.cacheManager.flush() + this.stopWatcher() + this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped")) + return + } + if (!result) { throw new Error("Scan failed, is scanner initialized?") } @@ -282,6 +301,15 @@ export class CodeIndexOrchestrator { this.stateManager.setSystemState("Indexed", t("embeddings:orchestrator.fileWatcherStarted")) } } catch (error: any) { + // Handle abort gracefully — not an error, just a user-initiated stop + if (error?.name === "AbortError" || signal.aborted) { + console.log("[CodeIndexOrchestrator] Indexing aborted by user.") + await this.cacheManager.flush() + this.stopWatcher() + this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped")) + return + } + console.error("[CodeIndexOrchestrator] Error during indexing:", error) TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { error: error instanceof Error ? error.message : String(error), @@ -325,9 +353,22 @@ export class CodeIndexOrchestrator { this.stopWatcher() } finally { this._isProcessing = false + this._abortController = null } } + /** + * Stops any in-progress indexing by aborting the scan and stopping the file watcher. + */ + public stopIndexing(): void { + if (this._abortController) { + this.stateManager.setSystemState("Stopping", t("embeddings:orchestrator.indexingStoppedPartial")) + this._abortController.abort() + this._abortController = null + } + this.stopWatcher() + } + /** * Stops the file watcher and cleans up resources. */ @@ -336,7 +377,7 @@ export class CodeIndexOrchestrator { this._fileWatcherSubscriptions.forEach((sub) => sub.dispose()) this._fileWatcherSubscriptions = [] - if (this.stateManager.state !== "Error") { + if (this.stateManager.state !== "Error" && this.stateManager.state !== "Stopping") { this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.fileWatcherStopped")) } this._isProcessing = false diff --git a/src/services/code-index/processors/__tests__/scanner.spec.ts b/src/services/code-index/processors/__tests__/scanner.spec.ts index 4d4150b443..a6e68bc96b 100644 --- a/src/services/code-index/processors/__tests__/scanner.spec.ts +++ b/src/services/code-index/processors/__tests__/scanner.spec.ts @@ -394,5 +394,68 @@ describe("DirectoryScanner", () => { expect(points[1].payload.segmentHash).toBe("unique-segment-hash-2") expect(points[2].payload.segmentHash).toBe("unique-segment-hash-3") }) + + it("should stop processing files when signal is aborted", async () => { + const { listFiles } = await import("../../../glob/list-files") + vi.mocked(listFiles).mockResolvedValue([["test/file1.js", "test/file2.js", "test/file3.js"], false]) + + // Create an already-aborted signal + const controller = new AbortController() + controller.abort() + + const result = await scanner.scanDirectory("/test", undefined, undefined, undefined, controller.signal) + + // No files should have been processed since signal was already aborted + expect(mockCodeParser.parseFile).not.toHaveBeenCalled() + expect(result.stats.processed).toBe(0) + }) + + it("should stop processing batches when signal is aborted mid-scan", async () => { + const { listFiles } = await import("../../../glob/list-files") + vi.mocked(listFiles).mockResolvedValue([["test/file1.js", "test/file2.js"], false]) + + const controller = new AbortController() + + const mockBlocks: any[] = [ + { + file_path: "test/file1.js", + content: "function hello() {}", + start_line: 1, + end_line: 3, + identifier: "hello", + type: "function", + fileHash: "hash1", + segmentHash: "seg-hash-1", + }, + ] + + // Abort after first file is parsed + ;(mockCodeParser.parseFile as any).mockImplementation(async () => { + controller.abort() + return mockBlocks + }) + + // AbortError should propagate up (the orchestrator handles it in its catch block) + await expect( + scanner.scanDirectory("/test", undefined, undefined, undefined, controller.signal), + ).rejects.toThrow("Indexing aborted") + }) + + it("should not process deleted files when signal is aborted", async () => { + const { listFiles } = await import("../../../glob/list-files") + vi.mocked(listFiles).mockResolvedValue([[], false]) + + // Set up cached files that would normally be detected as deleted + ;(mockCacheManager.getAllHashes as any).mockReturnValue({ "old/file.js": "old-hash" }) + + // Create an already-aborted signal + const controller = new AbortController() + controller.abort() + + await scanner.scanDirectory("/test", undefined, undefined, undefined, controller.signal) + + // Deleted file cleanup should not have run + expect(mockVectorStore.deletePointsByFilePath).not.toHaveBeenCalled() + }) }) }) diff --git a/src/services/code-index/processors/scanner.ts b/src/services/code-index/processors/scanner.ts index 91689a56d7..5d9ff5e362 100644 --- a/src/services/code-index/processors/scanner.ts +++ b/src/services/code-index/processors/scanner.ts @@ -71,6 +71,7 @@ export class DirectoryScanner implements IDirectoryScanner { onError?: (error: Error) => void, onBlocksIndexed?: (indexedCount: number) => void, onFileParsed?: (fileBlockCount: number) => void, + signal?: AbortSignal, ): Promise<{ stats: { processed: number; skipped: number }; totalBlockCount: number }> { const directoryPath = directory // Capture workspace context at scan start @@ -127,6 +128,9 @@ export class DirectoryScanner implements IDirectoryScanner { // Process all files in parallel with concurrency control const parsePromises = supportedPaths.map((filePath) => parseLimiter(async () => { + // Check abort signal before processing each file + if (signal?.aborted) return + try { // Check file size const stats = await stat(filePath) @@ -173,10 +177,17 @@ export class DirectoryScanner implements IDirectoryScanner { addedBlocksFromFile = true // Check if batch threshold is met + // Check abort signal before dispatching batch + if (signal?.aborted) { + throw new DOMException("Indexing aborted", "AbortError") + } + if (currentBatchBlocks.length >= this.batchSegmentThreshold) { // Wait if we've reached the maximum pending batches while (pendingBatchCount >= MAX_PENDING_BATCHES) { - // Wait for at least one batch to complete + if (signal?.aborted) { + throw new DOMException("Indexing aborted", "AbortError") + } await Promise.race(activeBatchPromises) } @@ -235,6 +246,10 @@ export class DirectoryScanner implements IDirectoryScanner { await this.cacheManager.updateHash(filePath, currentFileHash) } } catch (error) { + // Re-throw AbortError — it's not a file processing error, just a user-initiated stop + if (error instanceof DOMException && error.name === "AbortError") { + throw error + } console.error(`Error processing file ${filePath} in workspace ${scanWorkspace}:`, error) TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), @@ -258,6 +273,17 @@ export class DirectoryScanner implements IDirectoryScanner { // Wait for all parsing to complete await Promise.all(parsePromises) + // Check abort signal before processing remaining batch + if (signal?.aborted) { + return { + stats: { + processed: processedCount, + skipped: skippedCount, + }, + totalBlockCount, + } + } + // Process any remaining items in batch if (currentBatchBlocks.length > 0) { const release = await mutex.acquire() @@ -292,6 +318,17 @@ export class DirectoryScanner implements IDirectoryScanner { // Wait for all batch processing to complete await Promise.all(activeBatchPromises) + // Check abort signal before handling deleted files + if (signal?.aborted) { + return { + stats: { + processed: processedCount, + skipped: skippedCount, + }, + totalBlockCount, + } + } + // Handle deleted files const oldHashes = this.cacheManager.getAllHashes() for (const cachedFilePath of Object.keys(oldHashes)) { diff --git a/src/services/code-index/state-manager.ts b/src/services/code-index/state-manager.ts index 90257fdfb1..b678825147 100644 --- a/src/services/code-index/state-manager.ts +++ b/src/services/code-index/state-manager.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode" -export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" +export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" | "Stopping" export class CodeIndexStateManager { private _systemStatus: IndexingState = "Standby" @@ -58,6 +58,8 @@ export class CodeIndexStateManager { public reportBlockIndexingProgress(processedItems: number, totalItems: number): void { const progressChanged = processedItems !== this._processedItems || totalItems !== this._totalItems + // Don't override Stopping state with progress updates + if (this._systemStatus === "Stopping") return // Update if progress changes OR if the system wasn't already in 'Indexing' state if (progressChanged || this._systemStatus !== "Indexing") { this._processedItems = processedItems @@ -81,6 +83,8 @@ export class CodeIndexStateManager { public reportFileQueueProgress(processedFiles: number, totalFiles: number, currentFileBasename?: string): void { const progressChanged = processedFiles !== this._processedItems || totalFiles !== this._totalItems + // Don't override Stopping state with progress updates + if (this._systemStatus === "Stopping") return if (progressChanged || this._systemStatus !== "Indexing") { this._processedItems = processedFiles this._totalItems = totalFiles diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx index 4fcf6406e3..763c243ec1 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -1590,6 +1590,58 @@ export const CodeIndexPopover: React.FC = ({ )}
+ {/* Auto-enable default */} + {currentSettings.codebaseIndexEnabled && ( +
+ + vscode.postMessage({ + type: "setAutoEnableDefault", + bool: e.target.checked, + }) + } + className="accent-vscode-focusBorder" + /> + +
+ )} + + {/* Workspace Toggle */} + {currentSettings.codebaseIndexEnabled && ( +
+ + vscode.postMessage({ + type: "toggleWorkspaceIndexing", + bool: e.target.checked, + }) + } + className="accent-vscode-focusBorder" + /> + +
+ )} + + {currentSettings.codebaseIndexEnabled && !indexingStatus.workspaceEnabled && ( +

+ {t("settings:codeIndex.workspaceDisabledMessage")} +

+ )} + {/* Action Buttons */}
@@ -1603,6 +1655,20 @@ export const CodeIndexPopover: React.FC = ({ )} + {currentSettings.codebaseIndexEnabled && indexingStatus.systemStatus === "Indexing" && ( + + )} + + {currentSettings.codebaseIndexEnabled && indexingStatus.systemStatus === "Stopping" && ( + + )} + {currentSettings.codebaseIndexEnabled && (indexingStatus.systemStatus === "Indexed" || indexingStatus.systemStatus === "Error") && ( diff --git a/webview-ui/src/components/chat/IndexingStatusBadge.tsx b/webview-ui/src/components/chat/IndexingStatusBadge.tsx index 82f654a82f..227df3e645 100644 --- a/webview-ui/src/components/chat/IndexingStatusBadge.tsx +++ b/webview-ui/src/components/chat/IndexingStatusBadge.tsx @@ -64,6 +64,8 @@ export const IndexingStatusBadge: React.FC = ({ classN return t("chat:indexingStatus.indexing", { percentage: progressPercentage }) case "Indexed": return t("chat:indexingStatus.indexed") + case "Stopping": + return t("chat:indexingStatus.stopping") case "Error": return t("chat:indexingStatus.error") default: @@ -76,6 +78,7 @@ export const IndexingStatusBadge: React.FC = ({ classN Standby: "bg-vscode-descriptionForeground/60", Indexing: "bg-yellow-500 animate-pulse", Indexed: "bg-green-500", + Stopping: "bg-amber-500 animate-pulse", Error: "bg-red-500", } diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 230d730b13..c0fe2e0abf 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -428,7 +428,8 @@ "indexing": "Indexant {{percentage}}%", "indexed": "Indexat", "error": "Error d'índex", - "status": "Estat de l'índex" + "status": "Estat de l'índex", + "stopping": "Aturant la indexació..." }, "versionIndicator": { "ariaLabel": "Versió {{version}} - Feu clic per veure les notes de llançament" diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index d1edaf3deb..a741d9a3d7 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "Restablir al valor per defecte (0.4)", "searchMaxResultsLabel": "Màxim de resultats de cerca", "searchMaxResultsDescription": "Nombre màxim de resultats de cerca a retornar quan es consulta l'índex de la base de codi. Els valors més alts proporcionen més context però poden incloure resultats menys rellevants.", - "resetToDefault": "Restablir al valor per defecte" + "resetToDefault": "Restablir al valor per defecte", + "stopIndexingButton": "Aturar indexació", + "stoppingButton": "Aturant...", + "workspaceToggleLabel": "Activar la indexació per a aquest espai de treball", + "workspaceDisabledMessage": "La indexació està configurada però no habilitada per a aquest espai de treball.", + "autoEnableDefaultLabel": "Habilitar automàticament la indexació per a nous espais de treball" }, "autoApprove": { "toggleShortcut": "Pots configurar una drecera global per a aquesta configuració a les preferències del teu IDE.", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 480d3197cd..c8aaeac4a6 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -428,7 +428,8 @@ "indexing": "Indizierung {{percentage}}%", "indexed": "Indiziert", "error": "Index-Fehler", - "status": "Index-Status" + "status": "Index-Status", + "stopping": "Indexierung wird gestoppt..." }, "versionIndicator": { "ariaLabel": "Version {{version}} - Klicken Sie, um die Versionshinweise anzuzeigen" diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index f244a09677..aed7867d80 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "Auf Standardwert zurücksetzen (0.4)", "searchMaxResultsLabel": "Maximale Suchergebnisse", "searchMaxResultsDescription": "Maximale Anzahl von Suchergebnissen, die bei der Abfrage des Codebase-Index zurückgegeben werden. Höhere Werte bieten mehr Kontext, können aber weniger relevante Ergebnisse enthalten.", - "resetToDefault": "Auf Standard zurücksetzen" + "resetToDefault": "Auf Standard zurücksetzen", + "stopIndexingButton": "Indexierung stoppen", + "stoppingButton": "Wird gestoppt...", + "workspaceToggleLabel": "Indexierung für diesen Arbeitsbereich aktivieren", + "workspaceDisabledMessage": "Indexierung ist konfiguriert, aber nicht für diesen Arbeitsbereich aktiviert.", + "autoEnableDefaultLabel": "Indexierung für neue Arbeitsbereiche automatisch aktivieren" }, "autoApprove": { "toggleShortcut": "Du kannst in deinen IDE-Einstellungen einen globalen Shortcut für diese Einstellung konfigurieren.", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 10ef7c8811..4330895260 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -424,7 +424,8 @@ "indexing": "Indexing {{percentage}}%", "indexed": "Indexed", "error": "Index error", - "status": "Index status" + "status": "Index status", + "stopping": "Stopping indexing..." }, "versionIndicator": { "ariaLabel": "Version {{version}} - Click to view release notes" diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index a54184878c..af825fafe8 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -274,7 +274,12 @@ "baseUrlRequired": "Base URL is required", "modelDimensionMinValue": "Model dimension must be greater than 0" }, - "optional": "optional" + "optional": "optional", + "stopIndexingButton": "Stop Indexing", + "stoppingButton": "Stopping...", + "workspaceToggleLabel": "Enable indexing for this workspace", + "workspaceDisabledMessage": "Indexing is configured but not enabled for this workspace.", + "autoEnableDefaultLabel": "Auto-enable indexing for new workspaces" }, "autoApprove": { "description": "Run these actions without asking for permission. Only enable for actions you fully trust and if you understand the security risks.", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index b758c4eae2..d0651ec356 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -428,7 +428,8 @@ "indexing": "Indexando {{percentage}}%", "indexed": "Indexado", "error": "Error de índice", - "status": "Estado del índice" + "status": "Estado del índice", + "stopping": "Deteniendo la indexación..." }, "versionIndicator": { "ariaLabel": "Versión {{version}} - Haz clic para ver las notas de la versión" diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 397d2e3e36..946a6f87c0 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "Restablecer al valor predeterminado (0.4)", "searchMaxResultsLabel": "Resultados máximos de búsqueda", "searchMaxResultsDescription": "Número máximo de resultados de búsqueda a devolver al consultar el índice de código. Valores más altos proporcionan más contexto pero pueden incluir resultados menos relevantes.", - "resetToDefault": "Restablecer al valor predeterminado" + "resetToDefault": "Restablecer al valor predeterminado", + "stopIndexingButton": "Detener indexación", + "stoppingButton": "Deteniendo...", + "workspaceToggleLabel": "Activar indexación para este espacio de trabajo", + "workspaceDisabledMessage": "La indexación está configurada pero no habilitada para este espacio de trabajo.", + "autoEnableDefaultLabel": "Habilitar automáticamente la indexación para nuevos espacios de trabajo" }, "autoApprove": { "toggleShortcut": "Puedes configurar un atajo global para esta configuración en las preferencias de tu IDE.", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 9080c3d29a..31177ea137 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -428,7 +428,8 @@ "indexing": "Indexation {{percentage}}%", "indexed": "Indexé", "error": "Erreur d'index", - "status": "Statut de l'index" + "status": "Statut de l'index", + "stopping": "Arrêt de l'indexation..." }, "versionIndicator": { "ariaLabel": "Version {{version}} - Cliquez pour voir les notes de version" diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index cac40e04cc..c833ed7950 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "Réinitialiser à la valeur par défaut (0.4)", "searchMaxResultsLabel": "Résultats de recherche maximum", "searchMaxResultsDescription": "Nombre maximum de résultats de recherche à retourner lors de l'interrogation de l'index de code. Des valeurs plus élevées fournissent plus de contexte mais peuvent inclure des résultats moins pertinents.", - "resetToDefault": "Réinitialiser par défaut" + "resetToDefault": "Réinitialiser par défaut", + "stopIndexingButton": "Arrêter l'indexation", + "stoppingButton": "Arrêt en cours...", + "workspaceToggleLabel": "Activer l'indexation pour cet espace de travail", + "workspaceDisabledMessage": "L'indexation est configurée mais non activée pour cet espace de travail.", + "autoEnableDefaultLabel": "Activer automatiquement l'indexation pour les nouveaux espaces de travail" }, "autoApprove": { "toggleShortcut": "Vous pouvez configurer un raccourci global pour ce paramètre dans les préférences de votre IDE.", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index e4226e5ad5..3ce4f0a45b 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -428,7 +428,8 @@ "indexing": "इंडेक्सिंग {{percentage}}%", "indexed": "इंडेक्स किया गया", "error": "इंडेक्स त्रुटि", - "status": "इंडेक्स स्थिति" + "status": "इंडेक्स स्थिति", + "stopping": "इंडेक्सिंग रोक रहा है..." }, "versionIndicator": { "ariaLabel": "संस्करण {{version}} - रिलीज़ नोट्स देखने के लिए क्लिक करें" diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 2131512a18..9c20bd4457 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "डिफ़ॉल्ट मान पर रीसेट करें (0.4)", "searchMaxResultsLabel": "अधिकतम खोज परिणाम", "searchMaxResultsDescription": "कोडबेस इंडेक्स को क्वेरी करते समय वापस करने के लिए खोज परिणामों की अधिकतम संख्या। उच्च मान अधिक संदर्भ प्रदान करते हैं लेकिन कम प्रासंगिक परिणाम शामिल कर सकते हैं।", - "resetToDefault": "डिफ़ॉल्ट पर रीसेट करें" + "resetToDefault": "डिफ़ॉल्ट पर रीसेट करें", + "stopIndexingButton": "इंडेक्सिंग रोकें", + "stoppingButton": "रोक रहा है...", + "workspaceToggleLabel": "इस वर्कस्पेस के लिए इंडेक्सिंग सक्षम करें", + "workspaceDisabledMessage": "इंडेक्सिंग कॉन्फ़िगर की गई है लेकिन इस वर्कस्पेस के लिए सक्षम नहीं है।", + "autoEnableDefaultLabel": "नए वर्कस्पेस के लिए स्वचालित रूप से इंडेक्सिंग सक्षम करें" }, "autoApprove": { "toggleShortcut": "आप अपनी आईडीई वरीयताओं में इस सेटिंग के लिए एक वैश्विक शॉर्टकट कॉन्फ़िगर कर सकते हैं।", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 10b13bcb5b..aa576cf054 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -434,7 +434,8 @@ "indexing": "Mengindeks {{percentage}}%", "indexed": "Terindeks", "error": "Error indeks", - "status": "Status indeks" + "status": "Status indeks", + "stopping": "Menghentikan pengindeksan..." }, "versionIndicator": { "ariaLabel": "Versi {{version}} - Klik untuk melihat catatan rilis" diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 878abad259..6320d2bb34 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "Reset ke nilai default (0.4)", "searchMaxResultsLabel": "Hasil Pencarian Maksimum", "searchMaxResultsDescription": "Jumlah maksimum hasil pencarian yang dikembalikan saat melakukan query indeks basis kode. Nilai yang lebih tinggi memberikan lebih banyak konteks tetapi mungkin menyertakan hasil yang kurang relevan.", - "resetToDefault": "Reset ke default" + "resetToDefault": "Reset ke default", + "stopIndexingButton": "Hentikan pengindeksan", + "stoppingButton": "Menghentikan...", + "workspaceToggleLabel": "Aktifkan pengindeksan untuk ruang kerja ini", + "workspaceDisabledMessage": "Pengindeksan dikonfigurasi tetapi tidak diaktifkan untuk ruang kerja ini.", + "autoEnableDefaultLabel": "Aktifkan pengindeksan secara otomatis untuk ruang kerja baru" }, "autoApprove": { "toggleShortcut": "Anda dapat mengonfigurasi pintasan global untuk pengaturan ini di preferensi IDE Anda.", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 9d4b03bd73..c3da510ac3 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -428,7 +428,8 @@ "indexing": "Indicizzazione {{percentage}}%", "indexed": "Indicizzato", "error": "Errore indice", - "status": "Stato indice" + "status": "Stato indice", + "stopping": "Interruzione dell'indicizzazione..." }, "versionIndicator": { "ariaLabel": "Versione {{version}} - Clicca per visualizzare le note di rilascio" diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 062c2119f2..4b29c33247 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "Ripristina al valore predefinito (0.4)", "searchMaxResultsLabel": "Risultati di ricerca massimi", "searchMaxResultsDescription": "Numero massimo di risultati di ricerca da restituire quando si interroga l'indice del codice. Valori più alti forniscono più contesto ma possono includere risultati meno pertinenti.", - "resetToDefault": "Ripristina al valore predefinito" + "resetToDefault": "Ripristina al valore predefinito", + "stopIndexingButton": "Interrompi indicizzazione", + "stoppingButton": "Interruzione...", + "workspaceToggleLabel": "Abilita l'indicizzazione per questo workspace", + "workspaceDisabledMessage": "L'indicizzazione è configurata ma non abilitata per questo workspace.", + "autoEnableDefaultLabel": "Abilita automaticamente l'indicizzazione per i nuovi workspace" }, "autoApprove": { "toggleShortcut": "Puoi configurare una scorciatoia globale per questa impostazione nelle preferenze del tuo IDE.", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index a648393df7..1746aff184 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -428,7 +428,8 @@ "indexing": "インデックス作成中 {{percentage}}%", "indexed": "インデックス作成済み", "error": "インデックスエラー", - "status": "インデックス状態" + "status": "インデックス状態", + "stopping": "インデックス作成を停止中..." }, "versionIndicator": { "ariaLabel": "バージョン {{version}} - クリックしてリリースノートを表示" diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 468aa1b95a..3aab3c7962 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "デフォルト値(0.4)にリセット", "searchMaxResultsLabel": "最大検索結果数", "searchMaxResultsDescription": "コードベースインデックスをクエリする際に返される検索結果の最大数。値を高くするとより多くのコンテキストが提供されますが、関連性の低い結果が含まれる可能性があります。", - "resetToDefault": "デフォルトにリセット" + "resetToDefault": "デフォルトにリセット", + "stopIndexingButton": "インデックス作成を停止", + "stoppingButton": "停止中...", + "workspaceToggleLabel": "このワークスペースのインデックス作成を有効にする", + "workspaceDisabledMessage": "インデックス作成は設定済みですが、このワークスペースでは有効になっていません。", + "autoEnableDefaultLabel": "新しいワークスペースのインデックス作成を自動的に有効にする" }, "autoApprove": { "toggleShortcut": "IDEの環境設定で、この設定のグローバルショートカットを設定できます。", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 8d686b9496..db322a30e5 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -428,7 +428,8 @@ "indexing": "인덱싱 중 {{percentage}}%", "indexed": "인덱싱 완료", "error": "인덱스 오류", - "status": "인덱스 상태" + "status": "인덱스 상태", + "stopping": "인덱싱 중지 중..." }, "versionIndicator": { "ariaLabel": "버전 {{version}} - 릴리스 노트를 보려면 클릭하세요" diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index ab5b993e77..7a522e5706 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "기본값(0.4)으로 재설정", "searchMaxResultsLabel": "최대 검색 결과", "searchMaxResultsDescription": "코드베이스 인덱스를 쿼리할 때 반환할 최대 검색 결과 수입니다. 값이 높을수록 더 많은 컨텍스트를 제공하지만 관련성이 낮은 결과가 포함될 수 있습니다.", - "resetToDefault": "기본값으로 재설정" + "resetToDefault": "기본값으로 재설정", + "stopIndexingButton": "인덱싱 중지", + "stoppingButton": "중지 중...", + "workspaceToggleLabel": "이 워크스페이스에 대한 인덱싱 활성화", + "workspaceDisabledMessage": "인덱싱이 구성되었지만 이 워크스페이스에서는 활성화되지 않았습니다.", + "autoEnableDefaultLabel": "새 워크스페이스에 대한 인덱싱 자동 활성화" }, "autoApprove": { "toggleShortcut": "IDE 환경 설정에서 이 설정에 대한 전역 바로 가기를 구성할 수 있습니다.", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index eb134cfca4..84cddcdac7 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -428,7 +428,8 @@ "indexing": "Indexeren {{percentage}}%", "indexed": "Geïndexeerd", "error": "Index fout", - "status": "Index status" + "status": "Index status", + "stopping": "Indexering wordt gestopt..." }, "versionIndicator": { "ariaLabel": "Versie {{version}} - Klik om release notes te bekijken" diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 8e0d7a8b55..854376b2fd 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "Reset naar standaardwaarde (0.4)", "searchMaxResultsLabel": "Maximum Zoekresultaten", "searchMaxResultsDescription": "Maximum aantal zoekresultaten dat wordt geretourneerd bij het doorzoeken van de codebase-index. Hogere waarden bieden meer context maar kunnen minder relevante resultaten bevatten.", - "resetToDefault": "Reset naar standaard" + "resetToDefault": "Reset naar standaard", + "stopIndexingButton": "Indexering stoppen", + "stoppingButton": "Stoppen...", + "workspaceToggleLabel": "Indexering inschakelen voor deze werkruimte", + "workspaceDisabledMessage": "Indexering is geconfigureerd maar niet ingeschakeld voor deze werkruimte.", + "autoEnableDefaultLabel": "Indexering automatisch inschakelen voor nieuwe werkruimtes" }, "autoApprove": { "toggleShortcut": "U kunt een globale sneltoets voor deze instelling configureren in de voorkeuren van uw IDE.", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index d2ee83879f..c59a410cf3 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -428,7 +428,8 @@ "indexing": "Indeksowanie {{percentage}}%", "indexed": "Zaindeksowane", "error": "Błąd indeksu", - "status": "Status indeksu" + "status": "Status indeksu", + "stopping": "Zatrzymywanie indeksowania..." }, "versionIndicator": { "ariaLabel": "Wersja {{version}} - Kliknij, aby wyświetlić informacje o wydaniu" diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index b064eeabcc..85094cabfb 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "Zresetuj do wartości domyślnej (0.4)", "searchMaxResultsLabel": "Maksymalna liczba wyników wyszukiwania", "searchMaxResultsDescription": "Maksymalna liczba wyników wyszukiwania zwracanych podczas zapytania do indeksu bazy kodu. Wyższe wartości zapewniają więcej kontekstu, ale mogą zawierać mniej istotne wyniki.", - "resetToDefault": "Przywróć domyślne" + "resetToDefault": "Przywróć domyślne", + "stopIndexingButton": "Zatrzymaj indeksowanie", + "stoppingButton": "Zatrzymywanie...", + "workspaceToggleLabel": "Włącz indeksowanie dla tego workspace'a", + "workspaceDisabledMessage": "Indeksowanie jest skonfigurowane, ale nie włączone dla tego workspace'a.", + "autoEnableDefaultLabel": "Automatycznie włączaj indeksowanie dla nowych workspace'ów" }, "autoApprove": { "toggleShortcut": "Możesz skonfigurować globalny skrót dla tego ustawienia w preferencjach swojego IDE.", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 2c339defd4..72e463311f 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -428,7 +428,8 @@ "indexing": "Indexando {{percentage}}%", "indexed": "Indexado", "error": "Erro do índice", - "status": "Status do índice" + "status": "Status do índice", + "stopping": "Parando a indexação..." }, "versionIndicator": { "ariaLabel": "Versão {{version}} - Clique para ver as notas de lançamento" diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 01a72ce29c..3a59ce226a 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "Redefinir para o valor padrão (0.4)", "searchMaxResultsLabel": "Resultados máximos de busca", "searchMaxResultsDescription": "Número máximo de resultados de busca a retornar ao consultar o índice de código. Valores mais altos fornecem mais contexto, mas podem incluir resultados menos relevantes.", - "resetToDefault": "Redefinir para o padrão" + "resetToDefault": "Redefinir para o padrão", + "stopIndexingButton": "Parar indexação", + "stoppingButton": "Parando...", + "workspaceToggleLabel": "Ativar indexação para este workspace", + "workspaceDisabledMessage": "A indexação está configurada, mas não ativada para este workspace.", + "autoEnableDefaultLabel": "Ativar indexação automaticamente para novos workspaces" }, "autoApprove": { "toggleShortcut": "Você pode configurar um atalho global para esta configuração nas preferências do seu IDE.", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index fd3718f28a..20c658bc9e 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -429,7 +429,8 @@ "indexing": "Индексация {{percentage}}%", "indexed": "Проиндексировано", "error": "Ошибка индекса", - "status": "Статус индекса" + "status": "Статус индекса", + "stopping": "Остановка индексации..." }, "versionIndicator": { "ariaLabel": "Версия {{version}} - Нажмите, чтобы просмотреть примечания к выпуску" diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index e0abd7dd5e..7b7197d956 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "Сбросить к значению по умолчанию (0.4)", "searchMaxResultsLabel": "Максимальное количество результатов поиска", "searchMaxResultsDescription": "Максимальное количество результатов поиска, возвращаемых при запросе индекса кодовой базы. Более высокие значения предоставляют больше контекста, но могут включать менее релевантные результаты.", - "resetToDefault": "Сбросить к значению по умолчанию" + "resetToDefault": "Сбросить к значению по умолчанию", + "stopIndexingButton": "Остановить индексацию", + "stoppingButton": "Остановка...", + "workspaceToggleLabel": "Включить индексацию для этого рабочего пространства", + "workspaceDisabledMessage": "Индексация настроена, но не включена для этого рабочего пространства.", + "autoEnableDefaultLabel": "Автоматически включать индексацию для новых рабочих пространств" }, "autoApprove": { "toggleShortcut": "Вы можете настроить глобальное сочетание клавиш для этого параметра в настройках вашей IDE.", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 2a6b776f78..a0dbd54083 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -429,7 +429,8 @@ "indexing": "İndeksleniyor {{percentage}}%", "indexed": "İndekslendi", "error": "İndeks hatası", - "status": "İndeks durumu" + "status": "İndeks durumu", + "stopping": "İndeksleme durduruluyor..." }, "versionIndicator": { "ariaLabel": "Sürüm {{version}} - Sürüm notlarını görüntülemek için tıklayın" diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 21d037c2ce..766b829964 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "Varsayılan değere sıfırla (0.4)", "searchMaxResultsLabel": "Maksimum Arama Sonuçları", "searchMaxResultsDescription": "Kod tabanı dizinini sorgularken döndürülecek maksimum arama sonucu sayısı. Daha yüksek değerler daha fazla bağlam sağlar ancak daha az alakalı sonuçlar içerebilir.", - "resetToDefault": "Varsayılana sıfırla" + "resetToDefault": "Varsayılana sıfırla", + "stopIndexingButton": "İndekslemeyi durdur", + "stoppingButton": "Durduruluyor...", + "workspaceToggleLabel": "Bu çalışma alanı için indekslemeyi etkinleştir", + "workspaceDisabledMessage": "İndeksleme yapılandırıldı ancak bu çalışma alanı için etkinleştirilmedi.", + "autoEnableDefaultLabel": "Yeni çalışma alanları için indekslemeyi otomatik etkinleştir" }, "autoApprove": { "toggleShortcut": "IDE tercihlerinizde bu ayar için genel bir kısayol yapılandırabilirsiniz.", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 8e3841f334..eafd28c90b 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -429,7 +429,8 @@ "indexing": "Đang lập chỉ mục {{percentage}}%", "indexed": "Đã lập chỉ mục", "error": "Lỗi chỉ mục", - "status": "Trạng thái chỉ mục" + "status": "Trạng thái chỉ mục", + "stopping": "Đang dừng lập chỉ mục..." }, "versionIndicator": { "ariaLabel": "Phiên bản {{version}} - Nhấp để xem ghi chú phát hành" diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 776d17ffa5..fd2fd64885 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "Đặt lại về giá trị mặc định (0.4)", "searchMaxResultsLabel": "Số Kết Quả Tìm Kiếm Tối Đa", "searchMaxResultsDescription": "Số lượng kết quả tìm kiếm tối đa được trả về khi truy vấn chỉ mục cơ sở mã. Giá trị cao hơn cung cấp nhiều ngữ cảnh hơn nhưng có thể bao gồm các kết quả ít liên quan hơn.", - "resetToDefault": "Đặt lại về mặc định" + "resetToDefault": "Đặt lại về mặc định", + "stopIndexingButton": "Dừng lập chỉ mục", + "stoppingButton": "Đang dừng...", + "workspaceToggleLabel": "Bật lập chỉ mục cho không gian làm việc này", + "workspaceDisabledMessage": "Lập chỉ mục đã được cấu hình nhưng chưa được bật cho không gian làm việc này.", + "autoEnableDefaultLabel": "Tự động bật lập chỉ mục cho không gian làm việc mới" }, "autoApprove": { "toggleShortcut": "Bạn có thể định cấu hình một phím tắt chung cho cài đặt này trong tùy chọn IDE của bạn.", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 012797cc51..3b92b7f1c3 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -429,7 +429,8 @@ "indexing": "索引中 {{percentage}}%", "indexed": "已索引", "error": "索引错误", - "status": "索引状态" + "status": "索引状态", + "stopping": "正在停止索引..." }, "versionIndicator": { "ariaLabel": "版本 {{version}} - 点击查看发布说明" diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index baa4c1c1f3..40d0f4eda3 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -211,7 +211,12 @@ "searchMinScoreResetTooltip": "恢复默认值 (0.4)", "searchMaxResultsLabel": "最大搜索结果数", "searchMaxResultsDescription": "查询代码库索引时返回的最大搜索结果数。较高的值提供更多上下文,但可能包含相关性较低的结果。", - "resetToDefault": "恢复默认值" + "resetToDefault": "恢复默认值", + "stopIndexingButton": "停止索引", + "stoppingButton": "正在停止...", + "workspaceToggleLabel": "为此工作区启用索引", + "workspaceDisabledMessage": "索引已配置,但尚未为此工作区启用。", + "autoEnableDefaultLabel": "自动为新工作区启用索引" }, "autoApprove": { "toggleShortcut": "您可以在 IDE 首选项中为此设置配置全局快捷方式。", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 4acb77715a..95c1ea51ca 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -427,7 +427,8 @@ "indexing": "索引中 {{percentage}}%", "indexed": "已索引", "error": "索引錯誤", - "status": "索引狀態" + "status": "索引狀態", + "stopping": "正在停止索引..." }, "versionIndicator": { "ariaLabel": "版本 {{version}} - 點選查看發布說明" diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 2c45d327b1..691873ef20 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -221,7 +221,12 @@ "baseUrlRequired": "需要基礎 URL", "modelDimensionMinValue": "模型維度必須大於 0" }, - "optional": "選用" + "optional": "選用", + "stopIndexingButton": "停止索引", + "stoppingButton": "正在停止...", + "workspaceToggleLabel": "為此工作區啟用索引", + "workspaceDisabledMessage": "索引已設定,但尚未為此工作區啟用。", + "autoEnableDefaultLabel": "自動為新工作區啟用索引" }, "autoApprove": { "description": "無需詢問許可即可執行下列動作。請僅在您完全信任且了解安全風險的情況下啟用此功能。", From f864270547208587a6a99cc0a826da70a6912b48 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 18 Feb 2026 23:51:12 -0700 Subject: [PATCH 016/109] fix(chat): redesign rehydration scroll lifecycle (#11483) * fix(chat): stabilize rehydration scroll-to-bottom convergence * fix(chat): preserve user escape hatch during initial settle * refactor(chat): reduce scroll fix PR scope and remove debug plumbing * fix(chat): redesign rehydration scroll lifecycle * refactor(chat): extract scroll lifecycle into useScrollLifecycle hook - Extract ~400 lines of scroll lifecycle logic from ChatView.tsx into a dedicated useScrollLifecycle hook, reducing ChatView scroll-related refs from ~17 to 0 and making the logic testable in isolation. - Reduce INITIAL_LOAD_SETTLE_HARD_CAP_MS from 10s to 5s. If rehydration takes longer, there is likely a rendering performance issue worth investigating separately. - Document the scrollToIndex reversal: PR #6780 removed scrollToIndex due to jitter from stale numeric indices. The "LAST" constant used here resolves at call time, avoiding that issue. - All 6 existing scroll regression tests pass unchanged. * fix(chat): harden scroll lifecycle pointer intent and settle phase fallback * test(chat): stabilize ChatView scroll debug repro flake * refactor(chat): simplify hydration scroll lifecycle * fix(chat): start existing task at latest message bottom --------- Co-authored-by: Roo Code --- webview-ui/src/components/chat/ChatRow.tsx | 3 +- webview-ui/src/components/chat/ChatView.tsx | 176 ++---- .../ChatView.scroll-debug-repro.spec.tsx | 506 ++++++++++++++++++ webview-ui/src/hooks/useScrollLifecycle.ts | 489 +++++++++++++++++ 4 files changed, 1048 insertions(+), 126 deletions(-) create mode 100644 webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx create mode 100644 webview-ui/src/hooks/useScrollLifecycle.ts diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 5dab93d008..96bfb280a9 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -143,11 +143,12 @@ const ChatRow = memo( ) useEffect(() => { + const isHeightValid = height !== 0 && height !== Infinity // used for partials, command output, etc. // NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that // height starts off at Infinity - if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) { + if (isLast && isHeightValid && height !== prevHeightRef.current) { if (!isInitialRender) { onHeightChange(height > prevHeightRef.current) } diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index c070f8764e..fd0aca66cb 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1,6 +1,5 @@ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react" import { useDeepCompareEffect, useEvent } from "react-use" -import debounce from "debounce" import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" import removeMd from "remove-markdown" import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" @@ -49,6 +48,7 @@ import { WorktreeSelector } from "./WorktreeSelector" import FileChangesPanel from "./FileChangesPanel" import DismissibleUpsell from "../common/DismissibleUpsell" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" +import { useScrollLifecycle } from "@src/hooks/useScrollLifecycle" import { Cloud } from "lucide-react" export interface ChatViewProps { @@ -69,8 +69,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const isMountedRef = useRef(true) - const [audioBaseUri] = useState(() => { return (window as unknown as { AUDIO_BASE_URI?: string }).AUDIO_BASE_URI || "" }) @@ -157,9 +155,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction>({}) const prevExpandedRowsRef = useRef>() const scrollContainerRef = useRef(null) - const stickyFollowRef = useRef(false) - const [showScrollToBottom, setShowScrollToBottom] = useState(false) - const isAtBottomRef = useRef(false) const lastTtsRef = useRef("") const [wasStreaming, setWasStreaming] = useState(false) const [checkpointWarning, setCheckpointWarning] = useState< @@ -220,13 +215,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - isMountedRef.current = true - return () => { - isMountedRef.current = false - } - }, []) - const isProfileDisabled = useMemo( () => !!apiConfiguration && !ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList), [apiConfiguration, organizationAllowList], @@ -492,38 +480,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // Reset UI states only when task changes setExpandedRows({}) - everVisibleMessagesTsRef.current.clear() // Clear for new task - setCurrentFollowUpTs(null) // Clear follow-up answered state for new task - setIsCondensing(false) // Reset condensing state when switching tasks - // Note: sendingDisabled is not reset here as it's managed by message effects + everVisibleMessagesTsRef.current.clear() + setCurrentFollowUpTs(null) + setIsCondensing(false) - // Clear any pending auto-approval timeout from previous task if (autoApproveTimeoutRef.current) { clearTimeout(autoApproveTimeoutRef.current) autoApproveTimeoutRef.current = null } - // Reset user response flag for new task userRespondedRef.current = false - - // Ensure new task starts anchored to the bottom. Virtuoso's - // initialTopMostItemIndex fires at mount but the message data may - // arrive asynchronously, so we also engage sticky follow and - // explicitly scroll after a frame to handle the race. - let rafId: number | undefined - if (task?.ts) { - stickyFollowRef.current = true - rafId = requestAnimationFrame(() => { - virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "auto" }) - }) - } - return () => { - if (rafId !== undefined) { - cancelAnimationFrame(rafId) - } - } }, [task?.ts]) const taskTs = task?.ts @@ -551,28 +520,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const prev = prevExpandedRowsRef.current - let wasAnyRowExpandedByUser = false - if (prev) { - // Check if any row transitioned from false/undefined to true - for (const [tsKey, isExpanded] of Object.entries(expandedRows)) { - const ts = Number(tsKey) - if (isExpanded && !(prev[ts] ?? false)) { - wasAnyRowExpandedByUser = true - break - } - } - } - - // Expanding a row indicates the user is browsing; disable sticky follow - if (wasAnyRowExpandedByUser) { - stickyFollowRef.current = false - } - - prevExpandedRowsRef.current = expandedRows // Store current state for next comparison - }, [expandedRows]) - const isStreaming = useMemo(() => { // Checking clineAsk isn't enough since messages effect may be called // again for a tool for example, set clineAsk to its value, and if the @@ -1314,28 +1261,48 @@ const ChatViewComponent: React.ForwardRefRenderFunction - debounce(() => virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }), 10, { - immediate: true, - }), - [], - ) + // Scroll lifecycle is managed by a dedicated hook to keep ChatView focused + // on message handling and UI orchestration. + const { + showScrollToBottom, + handleRowHeightChange, + handleScrollToBottomClick, + enterUserBrowsingHistory, + followOutputCallback, + atBottomStateChangeCallback, + scrollToBottomAuto, + isAtBottomRef, + scrollPhaseRef, + } = useScrollLifecycle({ + virtuosoRef, + scrollContainerRef, + taskTs: task?.ts, + isStreaming, + isHidden, + hasTask: !!task, + }) + // Expanding a row indicates the user is browsing; disable sticky follow. + // Placed after the hook call so enterUserBrowsingHistory is defined. useEffect(() => { - return () => { - scrollToBottomSmooth.clear() + const prev = prevExpandedRowsRef.current + let wasAnyRowExpandedByUser = false + if (prev) { + for (const [tsKey, isExpanded] of Object.entries(expandedRows)) { + const ts = Number(tsKey) + if (isExpanded && !(prev[ts] ?? false)) { + wasAnyRowExpandedByUser = true + break + } + } } - }, [scrollToBottomSmooth]) - const scrollToBottomAuto = useCallback(() => { - virtuosoRef.current?.scrollTo({ - top: Number.MAX_SAFE_INTEGER, - behavior: "auto", // Instant causes crash. - }) - }, []) + if (wasAnyRowExpandedByUser) { + enterUserBrowsingHistory("row-expansion") + } + + prevExpandedRowsRef.current = expandedRows + }, [enterUserBrowsingHistory, expandedRows]) const handleSetExpandedRow = useCallback( (ts: number, expand?: boolean) => { @@ -1357,28 +1324,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - if (isAtBottomRef.current) { - if (isTaller) { - scrollToBottomSmooth() - } else { - setTimeout(() => scrollToBottomAuto(), 0) - } - } - }, - [scrollToBottomSmooth, scrollToBottomAuto], - ) - - // Disable sticky follow when user scrolls up inside the chat container - const handleWheel = useCallback((event: Event) => { - const wheelEvent = event as WheelEvent - if (wheelEvent.deltaY < 0 && scrollContainerRef.current?.contains(wheelEvent.target as Node)) { - stickyFollowRef.current = false - } - }, []) - useEvent("wheel", handleWheel, window, { passive: true }) - // Effect to clear checkpoint warning when messages appear or task changes useEffect(() => { if (isHidden || !task) { @@ -1523,19 +1468,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // Check for Command/Ctrl + Period (with or without Shift) - // Using event.key to respect keyboard layouts (e.g., Dvorak) if ((event.metaKey || event.ctrlKey) && event.key === ".") { - event.preventDefault() // Prevent default browser behavior - + event.preventDefault() if (event.shiftKey) { - // Shift + Period = Previous mode switchToPreviousMode() } else { - // Just Period = Next mode switchToNextMode() } } @@ -1688,17 +1629,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction isAtBottom || stickyFollowRef.current} - atBottomStateChange={(isAtBottom: boolean) => { - isAtBottomRef.current = isAtBottom - setShowScrollToBottom(!isAtBottom) - // Clear sticky follow when user scrolls away from bottom - if (!isAtBottom) { - stickyFollowRef.current = false - } - }} + followOutput={followOutputCallback} + atBottomStateChange={atBottomStateChangeCallback} atBottomThreshold={10} - initialTopMostItemIndex={groupedMessages.length - 1} />
@@ -1712,14 +1645,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // Engage sticky follow until user scrolls up - stickyFollowRef.current = true - // Pin immediately to avoid lag during fast streaming - scrollToBottomAuto() - // Hide button immediately to prevent flash - setShowScrollToBottom(false) - }}> + onClick={handleScrollToBottomClick}> @@ -1825,7 +1751,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - if (isAtBottomRef.current) { + if (isAtBottomRef.current && scrollPhaseRef.current !== "USER_BROWSING_HISTORY") { scrollToBottomAuto() } }} diff --git a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx new file mode 100644 index 0000000000..c71df99f70 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx @@ -0,0 +1,506 @@ +import React, { useEffect, useImperativeHandle, useRef } from "react" +import { act, fireEvent, render, waitFor } from "@/utils/test-utils" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import type { ClineMessage } from "@roo-code/types" + +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" + +import ChatView, { type ChatViewProps } from "../ChatView" + +type FollowOutput = ((isAtBottom: boolean) => "auto" | false) | "auto" | false + +interface ExtensionStateMessage { + type: "state" + state: { + version: string + clineMessages: ClineMessage[] + taskHistory: unknown[] + shouldShowAnnouncement: boolean + allowedCommands: string[] + alwaysAllowExecute: boolean + cloudIsAuthenticated: boolean + telemetrySetting: "enabled" | "disabled" | "unset" + } +} + +interface MockVirtuosoHandle { + scrollToIndex: (options: { + index: number | "LAST" + align?: "end" | "start" | "center" + behavior?: "auto" | "smooth" + }) => void +} + +interface MockVirtuosoProps { + data: ClineMessage[] + itemContent: (index: number, item: ClineMessage) => React.ReactNode + atBottomStateChange?: (isAtBottom: boolean) => void + followOutput?: FollowOutput + className?: string + initialTopMostItemIndex?: number +} + +interface VirtuosoHarnessState { + scrollCalls: number + atBottomAfterCalls: number + signalDelayMs: number + emitFalseOnDataChange: boolean + delayedGrowthMs: number | null + initialTopMostItemIndex: number | undefined + followOutput: FollowOutput | undefined + emitAtBottom: (isAtBottom: boolean) => void +} + +const harness = vi.hoisted(() => ({ + scrollCalls: 0, + atBottomAfterCalls: Number.POSITIVE_INFINITY, + signalDelayMs: 20, + emitFalseOnDataChange: true, + delayedGrowthMs: null, + initialTopMostItemIndex: undefined, + followOutput: undefined, + emitAtBottom: () => {}, +})) + +function nullDefaultModule() { + return { default: () => null } +} + +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) +vi.mock("use-sound", () => ({ default: vi.fn().mockImplementation(() => [vi.fn()]) })) +vi.mock("@src/components/cloud/CloudUpsellDialog", () => ({ CloudUpsellDialog: () => null })) +vi.mock("@src/hooks/useCloudUpsell", () => ({ + useCloudUpsell: () => ({ + isOpen: false, + openUpsell: vi.fn(), + closeUpsell: vi.fn(), + handleConnect: vi.fn(), + }), +})) + +vi.mock("../common/TelemetryBanner", nullDefaultModule) +vi.mock("../common/VersionIndicator", nullDefaultModule) +vi.mock("../history/HistoryPreview", nullDefaultModule) +vi.mock("@src/components/welcome/RooHero", nullDefaultModule) +vi.mock("@src/components/welcome/RooTips", nullDefaultModule) +vi.mock("../Announcement", nullDefaultModule) +vi.mock("./TaskHeader", () => ({ default: () =>
})) +vi.mock("./ProfileViolationWarning", nullDefaultModule) +vi.mock("../common/DismissibleUpsell", nullDefaultModule) + +vi.mock("./CheckpointWarning", () => ({ CheckpointWarning: () => null })) +vi.mock("./QueuedMessages", () => ({ QueuedMessages: () => null })) +vi.mock("./WorktreeSelector", () => ({ WorktreeSelector: () => null })) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeLink: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock("@/components/ui", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + StandardTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + } +}) + +vi.mock("../ChatTextArea", () => { + const MockTextArea = React.forwardRef(function MockTextArea( + props: { + inputValue?: string + setInputValue?: (value: string) => void + onSend: () => void + sendingDisabled?: boolean + }, + ref: React.ForwardedRef<{ focus: () => void }>, + ) { + useImperativeHandle(ref, () => ({ focus: () => {} })) + + return ( + props.setInputValue?.(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !props.sendingDisabled) { + props.onSend() + } + }} + /> + ) + }) + + return { default: MockTextArea, ChatTextArea: MockTextArea } +}) + +vi.mock("../ChatRow", () => ({ + default: ({ message }: { message: ClineMessage }) =>
{message.ts}
, +})) + +vi.mock("react-virtuoso", () => { + const MockVirtuoso = React.forwardRef(function MockVirtuoso( + { data, itemContent, atBottomStateChange, followOutput, className, initialTopMostItemIndex }, + ref, + ) { + const atBottomRef = useRef(atBottomStateChange) + const timeoutIdsRef = useRef([]) + + harness.followOutput = followOutput + harness.initialTopMostItemIndex = initialTopMostItemIndex + harness.emitAtBottom = (isAtBottom: boolean) => { + atBottomRef.current?.(isAtBottom) + } + + useImperativeHandle(ref, () => ({ + scrollToIndex: () => { + harness.scrollCalls += 1 + const reachedBottom = harness.scrollCalls >= harness.atBottomAfterCalls + const timeoutId = window.setTimeout(() => { + atBottomRef.current?.(reachedBottom) + }, harness.signalDelayMs) + timeoutIdsRef.current.push(timeoutId) + }, + })) + + useEffect(() => { + atBottomRef.current = atBottomStateChange + }, [atBottomStateChange]) + + useEffect(() => { + if (harness.emitFalseOnDataChange) { + atBottomStateChange?.(false) + } + + if (harness.delayedGrowthMs !== null) { + const timeoutId = window.setTimeout(() => { + atBottomRef.current?.(false) + }, harness.delayedGrowthMs) + timeoutIdsRef.current.push(timeoutId) + } + }, [data.length, atBottomStateChange]) + + useEffect( + () => () => { + timeoutIdsRef.current.forEach((id) => window.clearTimeout(id)) + timeoutIdsRef.current = [] + }, + [], + ) + + return ( +
+ {data.map((item, index) => ( +
+ {itemContent(index, item)} +
+ ))} +
+ ) + }) + + return { Virtuoso: MockVirtuoso } +}) + +const props: ChatViewProps = { + isHidden: false, + showAnnouncement: false, + hideAnnouncement: () => {}, +} + +const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)) + +const buildMessages = (baseTs: number): ClineMessage[] => [ + { type: "say", say: "text", ts: baseTs, text: "task" }, + { type: "say", say: "text", ts: baseTs + 1, text: "row-1" }, + { type: "say", say: "text", ts: baseTs + 2, text: "row-2" }, +] + +const resolveFollowOutput = (isAtBottom: boolean): "auto" | false => { + const followOutput = harness.followOutput + if (typeof followOutput === "function") { + return followOutput(isAtBottom) + } + return followOutput === "auto" ? "auto" : false +} + +const postState = (clineMessages: ClineMessage[]) => { + const message: ExtensionStateMessage = { + type: "state", + state: { + version: "1.0.0", + clineMessages, + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + }, + } + + window.dispatchEvent( + new MessageEvent("message", { + data: message, + }), + ) +} + +const renderView = () => + render( + + + + + , + ) + +const hydrate = async (atBottomAfterCalls: number) => { + harness.atBottomAfterCalls = atBottomAfterCalls + renderView() + await act(async () => { + await Promise.resolve() + }) + await act(async () => { + postState(buildMessages(Date.now() - 3_000)) + }) + await waitFor(() => { + const list = document.querySelector("[data-testid='virtuoso-item-list']") + expect(list).toBeTruthy() + expect(list?.getAttribute("data-count")).toBe("2") + }) +} + +const waitForCalls = async (min: number, timeout = 1_500) => { + await waitFor(() => expect(harness.scrollCalls).toBeGreaterThanOrEqual(min), { timeout }) +} + +const waitForCallsSettled = async (idleMs = 80, timeoutMs = 2_000) => { + const deadline = Date.now() + timeoutMs + let lastSeen = harness.scrollCalls + + while (Date.now() < deadline) { + await sleep(idleMs) + const current = harness.scrollCalls + + if (current === lastSeen) { + await sleep(idleMs) + if (harness.scrollCalls === current) { + return + } + } + + lastSeen = current + } + + throw new Error(`Expected scroll calls to settle within ${timeoutMs}ms, last count: ${harness.scrollCalls}`) +} + +const getScrollable = (): HTMLElement => { + const scrollable = document.querySelector(".scrollable") + if (!(scrollable instanceof HTMLElement)) { + throw new Error("Expected ChatView scrollable container") + } + return scrollable +} + +const getScrollToBottomButton = (): HTMLButtonElement => { + const icon = document.querySelector(".codicon-chevron-down") + if (!(icon instanceof HTMLElement)) { + throw new Error("Expected scroll-to-bottom icon") + } + + const button = icon.closest("button") + if (!(button instanceof HTMLButtonElement)) { + throw new Error("Expected scroll-to-bottom button") + } + + return button +} + +describe("ChatView scroll behavior regression coverage", () => { + beforeEach(() => { + harness.scrollCalls = 0 + harness.atBottomAfterCalls = Number.POSITIVE_INFINITY + harness.signalDelayMs = 20 + harness.emitFalseOnDataChange = true + harness.delayedGrowthMs = null + harness.initialTopMostItemIndex = undefined + harness.followOutput = undefined + harness.emitAtBottom = () => {} + }) + + it("existing-task entry does not set a top-most initial anchor", async () => { + await hydrate(2) + expect(harness.initialTopMostItemIndex).toBeUndefined() + }) + + it("rehydration uses bounded bottom pinning", async () => { + await hydrate(2) + await waitForCalls(2, 1_200) + await waitForCallsSettled() + expect(harness.scrollCalls).toBe(2) + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + }) + + it("transient hydration-time not-at-bottom signals do not disable sticky follow", async () => { + await hydrate(2) + await waitForCalls(1, 1_200) + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + + await act(async () => { + harness.emitAtBottom(false) + }) + + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + + await waitForCalls(2, 1_200) + await waitForCallsSettled() + expect(harness.scrollCalls).toBe(2) + expect(resolveFollowOutput(false)).toBe("auto") + }) + + it("delayed last-row growth during hydration keeps anchored follow with one bounded repin", async () => { + harness.delayedGrowthMs = 320 + await hydrate(3) + await waitForCalls(1, 1_200) + + await sleep(950) + + expect(harness.scrollCalls).toBe(2) + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + }) + + it("user escape hatch during hydration prevents repinning", async () => { + await hydrate(Number.POSITIVE_INFINITY) + await waitForCalls(1, 1_200) + + await act(async () => { + fireEvent.keyDown(window, { key: "PageUp" }) + }) + + expect(resolveFollowOutput(false)).toBe(false) + + await act(async () => { + harness.emitAtBottom(true) + }) + + expect(resolveFollowOutput(false)).toBe(false) + + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { + timeout: 1_200, + }) + }) + + it("non-wheel upward intent disengages sticky follow", async () => { + await hydrate(2) + await waitForCalls(2) + await waitForCallsSettled() + expect(resolveFollowOutput(false)).toBe("auto") + + const scrollable = getScrollable() + scrollable.scrollTop = 240 + + await act(async () => { + fireEvent.pointerDown(scrollable) + scrollable.scrollTop = 120 + fireEvent.scroll(scrollable) + fireEvent.pointerUp(window) + }) + + expect(resolveFollowOutput(false)).toBe(false) + }) + + it("nested scroller scroll events do not falsely disengage sticky follow", async () => { + await hydrate(2) + await waitForCalls(2) + await waitForCallsSettled() + expect(resolveFollowOutput(false)).toBe("auto") + + const scrollable = getScrollable() + const nestedScrollable = document.createElement("div") + nestedScrollable.style.overflowY = "auto" + nestedScrollable.scrollTop = 0 + scrollable.appendChild(nestedScrollable) + + scrollable.scrollTop = 240 + + await act(async () => { + fireEvent.pointerDown(nestedScrollable) + nestedScrollable.scrollTop = 120 + fireEvent.scroll(nestedScrollable) + fireEvent.pointerUp(window) + }) + + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + }) + + it("wheel-up intent disengages sticky follow", async () => { + await hydrate(2) + await waitForCalls(2) + await waitForCallsSettled() + expect(resolveFollowOutput(false)).toBe("auto") + + const scrollable = getScrollable() + + await act(async () => { + fireEvent.wheel(scrollable, { deltaY: -120 }) + }) + + expect(resolveFollowOutput(false)).toBe(false) + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { + timeout: 1_200, + }) + }) + + it("hydration completion cannot override user escape hatch", async () => { + await hydrate(Number.POSITIVE_INFINITY) + await waitForCalls(1, 1_200) + + await act(async () => { + fireEvent.keyDown(window, { key: "PageUp" }) + }) + + expect(resolveFollowOutput(false)).toBe(false) + + await sleep(700) + + expect(resolveFollowOutput(false)).toBe(false) + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { + timeout: 1_200, + }) + }) + + it("scroll-to-bottom CTA re-anchors with one interaction", async () => { + await hydrate(2) + await waitForCalls(2) + await waitForCallsSettled() + expect(resolveFollowOutput(false)).toBe("auto") + + await act(async () => { + fireEvent.keyDown(window, { key: "PageUp" }) + }) + + expect(resolveFollowOutput(false)).toBe(false) + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { + timeout: 1_200, + }) + + const callsBeforeClick = harness.scrollCalls + harness.atBottomAfterCalls = callsBeforeClick + 2 + + await act(async () => { + getScrollToBottomButton().click() + }) + + expect(resolveFollowOutput(false)).toBe("auto") + await waitFor(() => expect(harness.scrollCalls).toBe(callsBeforeClick + 2), { + timeout: 1_200, + }) + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeNull(), { timeout: 1_200 }) + }) +}) diff --git a/webview-ui/src/hooks/useScrollLifecycle.ts b/webview-ui/src/hooks/useScrollLifecycle.ts new file mode 100644 index 0000000000..8a560f15f9 --- /dev/null +++ b/webview-ui/src/hooks/useScrollLifecycle.ts @@ -0,0 +1,489 @@ +/** + * useScrollLifecycle + * + * Simplified chat scroll lifecycle with a short, time-boxed hydration window. + * + * - Task switch enters `HYDRATING_PINNED_TO_BOTTOM` + * - We issue one immediate `scrollToIndex("LAST")` and one post-render retry + * - During hydration, transient Virtuoso `atBottomStateChange(false)` signals + * are ignored so follow mode does not flicker off + * - User escape intent (wheel / keyboard / pointer-upward drag / row expansion) + * moves to `USER_BROWSING_HISTORY` and prevents forced re-pinning + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useEvent } from "react-use" +import debounce from "debounce" +import type { VirtuosoHandle } from "react-virtuoso" + +const HYDRATION_WINDOW_MS = 600 +const HYDRATION_RETRY_WINDOW_MS = 160 + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ScrollPhase = "HYDRATING_PINNED_TO_BOTTOM" | "ANCHORED_FOLLOWING" | "USER_BROWSING_HISTORY" + +export type ScrollFollowDisengageSource = "wheel-up" | "row-expansion" | "keyboard-nav-up" | "pointer-scroll-up" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const isEditableKeyboardTarget = (target: EventTarget | null): boolean => { + if (!(target instanceof HTMLElement)) { + return false + } + if (target.isContentEditable) { + return true + } + const tagName = target.tagName + return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT" +} + +// --------------------------------------------------------------------------- +// Hook interface +// --------------------------------------------------------------------------- + +export interface UseScrollLifecycleOptions { + virtuosoRef: React.RefObject + scrollContainerRef: React.RefObject + taskTs: number | undefined + isStreaming: boolean + isHidden: boolean + hasTask: boolean +} + +export interface UseScrollLifecycleReturn { + scrollPhase: ScrollPhase + showScrollToBottom: boolean + handleRowHeightChange: (isTaller: boolean) => void + handleScrollToBottomClick: () => void + enterUserBrowsingHistory: (source: ScrollFollowDisengageSource) => void + followOutputCallback: () => "auto" | false + atBottomStateChangeCallback: (isAtBottom: boolean) => void + scrollToBottomAuto: () => void + isAtBottomRef: React.MutableRefObject + scrollPhaseRef: React.MutableRefObject +} + +// --------------------------------------------------------------------------- +// Hook implementation +// --------------------------------------------------------------------------- + +export function useScrollLifecycle({ + virtuosoRef, + scrollContainerRef, + taskTs, + isStreaming, + isHidden, + hasTask, +}: UseScrollLifecycleOptions): UseScrollLifecycleReturn { + // --- Mounted guard --- + const isMountedRef = useRef(true) + + // --- Phase state --- + const [scrollPhase, setScrollPhase] = useState("USER_BROWSING_HISTORY") + const scrollPhaseRef = useRef("USER_BROWSING_HISTORY") + + // --- Visibility state --- + const [showScrollToBottom, setShowScrollToBottom] = useState(false) + + // --- Bottom detection --- + const isAtBottomRef = useRef(false) + + // --- Hydration window --- + const isHydratingRef = useRef(false) + const hydrationTimeoutRef = useRef(null) + const hydrationRetryUsedRef = useRef(false) + + // --- Pointer scroll tracking --- + const pointerScrollActiveRef = useRef(false) + const pointerScrollElementRef = useRef(null) + const pointerScrollLastTopRef = useRef(null) + + // --- Re-anchor frame --- + const reanchorAnimationFrameRef = useRef(null) + + // ----------------------------------------------------------------------- + // Phase transitions + // ----------------------------------------------------------------------- + + const transitionScrollPhase = useCallback((nextPhase: ScrollPhase) => { + if (scrollPhaseRef.current === nextPhase) { + return + } + scrollPhaseRef.current = nextPhase + setScrollPhase(nextPhase) + }, []) + + const enterAnchoredFollowing = useCallback(() => { + transitionScrollPhase("ANCHORED_FOLLOWING") + setShowScrollToBottom(false) + }, [transitionScrollPhase]) + + const enterUserBrowsingHistory = useCallback( + (_source: ScrollFollowDisengageSource) => { + transitionScrollPhase("USER_BROWSING_HISTORY") + // Always show the scroll-to-bottom CTA when the user explicitly + // disengages. If they happen to still be at the physical bottom, + // the next Virtuoso atBottomStateChange(true) will hide it. + setShowScrollToBottom(true) + }, + [transitionScrollPhase], + ) + + const cancelReanchorFrame = useCallback(() => { + if (reanchorAnimationFrameRef.current !== null) { + cancelAnimationFrame(reanchorAnimationFrameRef.current) + reanchorAnimationFrameRef.current = null + } + }, []) + + // ----------------------------------------------------------------------- + // Scroll commands + // ----------------------------------------------------------------------- + + const scrollToBottomSmooth = useMemo( + () => + debounce( + () => virtuosoRef.current?.scrollToIndex({ index: "LAST", align: "end", behavior: "smooth" }), + 10, + { immediate: true }, + ), + [virtuosoRef], + ) + + const scrollToBottomAuto = useCallback(() => { + virtuosoRef.current?.scrollToIndex({ + index: "LAST", + align: "end", + behavior: "auto", + }) + }, [virtuosoRef]) + + const clearHydrationWindow = useCallback(() => { + isHydratingRef.current = false + hydrationRetryUsedRef.current = false + if (hydrationTimeoutRef.current !== null) { + window.clearTimeout(hydrationTimeoutRef.current) + hydrationTimeoutRef.current = null + } + }, []) + + const finishHydrationWindow = useCallback(() => { + if (!isMountedRef.current || !isHydratingRef.current) { + return + } + + if (scrollPhaseRef.current === "HYDRATING_PINNED_TO_BOTTOM") { + if (isAtBottomRef.current) { + enterAnchoredFollowing() + } else { + if (!hydrationRetryUsedRef.current) { + hydrationRetryUsedRef.current = true + scrollToBottomAuto() + hydrationTimeoutRef.current = window.setTimeout(() => { + finishHydrationWindow() + }, HYDRATION_RETRY_WINDOW_MS) + return + } + + // Retry budget exhausted. Keep anchored follow rather than + // downgrading to browsing mode due to non-user transient drift. + enterAnchoredFollowing() + } + } + + clearHydrationWindow() + }, [clearHydrationWindow, enterAnchoredFollowing, scrollToBottomAuto]) + + const startHydrationWindow = useCallback(() => { + isHydratingRef.current = true + hydrationRetryUsedRef.current = false + if (hydrationTimeoutRef.current !== null) { + window.clearTimeout(hydrationTimeoutRef.current) + } + hydrationTimeoutRef.current = window.setTimeout(() => { + finishHydrationWindow() + }, HYDRATION_WINDOW_MS) + + scrollToBottomAuto() + }, [finishHydrationWindow, scrollToBottomAuto]) + + // ----------------------------------------------------------------------- + // Lifecycle effects + // ----------------------------------------------------------------------- + + // Mounted guard + global cleanup + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + clearHydrationWindow() + cancelReanchorFrame() + scrollToBottomSmooth.clear() + } + }, [cancelReanchorFrame, clearHydrationWindow, scrollToBottomSmooth]) + + // Keep phase ref in sync with state + useEffect(() => { + scrollPhaseRef.current = scrollPhase + }, [scrollPhase]) + + // Task switch: reset and begin a short hydration window + useEffect(() => { + isAtBottomRef.current = false + clearHydrationWindow() + cancelReanchorFrame() + + if (taskTs) { + transitionScrollPhase("HYDRATING_PINNED_TO_BOTTOM") + setShowScrollToBottom(false) + startHydrationWindow() + } else { + transitionScrollPhase("USER_BROWSING_HISTORY") + setShowScrollToBottom(false) + } + + return () => { + clearHydrationWindow() + cancelReanchorFrame() + } + }, [cancelReanchorFrame, clearHydrationWindow, startHydrationWindow, taskTs, transitionScrollPhase]) + + // ----------------------------------------------------------------------- + // Row height change handler + // ----------------------------------------------------------------------- + + const handleRowHeightChange = useCallback( + (isTaller: boolean) => { + if ( + scrollPhaseRef.current === "USER_BROWSING_HISTORY" || + scrollPhaseRef.current === "HYDRATING_PINNED_TO_BOTTOM" + ) { + return + } + + const shouldForcePinForAnchoredStreaming = scrollPhaseRef.current === "ANCHORED_FOLLOWING" && isStreaming + if (isAtBottomRef.current || shouldForcePinForAnchoredStreaming) { + if (isTaller) { + scrollToBottomSmooth() + } else { + scrollToBottomAuto() + } + } + }, + [isStreaming, scrollToBottomSmooth, scrollToBottomAuto], + ) + + // ----------------------------------------------------------------------- + // Scroll-to-bottom click handler + // ----------------------------------------------------------------------- + + const handleScrollToBottomClick = useCallback(() => { + enterAnchoredFollowing() + scrollToBottomAuto() + cancelReanchorFrame() + reanchorAnimationFrameRef.current = requestAnimationFrame(() => { + reanchorAnimationFrameRef.current = null + if (scrollPhaseRef.current === "ANCHORED_FOLLOWING") { + scrollToBottomAuto() + } + }) + }, [cancelReanchorFrame, enterAnchoredFollowing, scrollToBottomAuto]) + + // ----------------------------------------------------------------------- + // Virtuoso callback: followOutput + // ----------------------------------------------------------------------- + + const followOutputCallback = useCallback((): "auto" | false => { + return scrollPhase === "USER_BROWSING_HISTORY" ? false : "auto" + }, [scrollPhase]) + + // ----------------------------------------------------------------------- + // Virtuoso callback: atBottomStateChange + // ----------------------------------------------------------------------- + + const atBottomStateChangeCallback = useCallback( + (isAtBottom: boolean) => { + isAtBottomRef.current = isAtBottom + + const currentPhase = scrollPhaseRef.current + + if (!isAtBottom && isHydratingRef.current && currentPhase !== "USER_BROWSING_HISTORY") { + setShowScrollToBottom(false) + return + } + + if (isAtBottom) { + if (currentPhase === "USER_BROWSING_HISTORY" && isHydratingRef.current) { + setShowScrollToBottom(true) + return + } + + enterAnchoredFollowing() + return + } + + if (currentPhase === "ANCHORED_FOLLOWING" && !isAtBottom && pointerScrollActiveRef.current) { + enterUserBrowsingHistory("pointer-scroll-up") + return + } + + if (currentPhase === "ANCHORED_FOLLOWING" && isStreaming) { + scrollToBottomAuto() + setShowScrollToBottom(false) + return + } + + setShowScrollToBottom(currentPhase === "USER_BROWSING_HISTORY") + }, + [enterAnchoredFollowing, enterUserBrowsingHistory, isStreaming, scrollToBottomAuto], + ) + + // ----------------------------------------------------------------------- + // User intent: wheel + // ----------------------------------------------------------------------- + + const handleWheel = useCallback( + (event: Event) => { + const wheelEvent = event as WheelEvent + if (wheelEvent.deltaY < 0 && scrollContainerRef.current?.contains(wheelEvent.target as Node)) { + enterUserBrowsingHistory("wheel-up") + } + }, + [enterUserBrowsingHistory, scrollContainerRef], + ) + useEvent("wheel", handleWheel, window, { passive: true }) + + // ----------------------------------------------------------------------- + // User intent: pointer drag + // ----------------------------------------------------------------------- + + const handlePointerDown = useCallback( + (event: Event) => { + const pointerEvent = event as PointerEvent + const pointerTarget = pointerEvent.target + if (!(pointerTarget instanceof HTMLElement)) { + pointerScrollActiveRef.current = false + pointerScrollElementRef.current = null + pointerScrollLastTopRef.current = null + return + } + + if (!scrollContainerRef.current?.contains(pointerTarget)) { + pointerScrollActiveRef.current = false + pointerScrollElementRef.current = null + pointerScrollLastTopRef.current = null + return + } + + const scroller = + (pointerTarget.closest(".scrollable") as HTMLElement | null) ?? + (pointerTarget.scrollHeight > pointerTarget.clientHeight ? pointerTarget : null) + + pointerScrollActiveRef.current = scroller !== null + pointerScrollElementRef.current = scroller + pointerScrollLastTopRef.current = scroller?.scrollTop ?? null + }, + [scrollContainerRef], + ) + + const handlePointerEnd = useCallback(() => { + pointerScrollActiveRef.current = false + pointerScrollElementRef.current = null + pointerScrollLastTopRef.current = null + }, []) + + const handlePointerActiveScroll = useCallback( + (event: Event) => { + if (!pointerScrollActiveRef.current) { + return + } + + const scrollTarget = event.target + if (!(scrollTarget instanceof HTMLElement)) { + return + } + + if (!scrollContainerRef.current?.contains(scrollTarget)) { + return + } + + if (pointerScrollElementRef.current !== scrollTarget) { + return + } + + const previousTop = pointerScrollLastTopRef.current + const currentTop = scrollTarget.scrollTop + pointerScrollLastTopRef.current = currentTop + + if (previousTop !== null && currentTop < previousTop) { + enterUserBrowsingHistory("pointer-scroll-up") + } + }, + [enterUserBrowsingHistory, scrollContainerRef], + ) + + useEvent("pointerdown", handlePointerDown, window, { passive: true }) + useEvent("pointerup", handlePointerEnd, window, { passive: true }) + useEvent("pointercancel", handlePointerEnd, window, { passive: true }) + useEvent("scroll", handlePointerActiveScroll, window, { passive: true, capture: true }) + + // ----------------------------------------------------------------------- + // User intent: keyboard navigation + // ----------------------------------------------------------------------- + + const handleScrollKeyDown = useCallback( + (event: Event) => { + const keyEvent = event as KeyboardEvent + + if (!hasTask || isHidden) { + return + } + + if (keyEvent.metaKey || keyEvent.ctrlKey || keyEvent.altKey) { + return + } + + if (keyEvent.key !== "PageUp" && keyEvent.key !== "Home" && keyEvent.key !== "ArrowUp") { + return + } + + if (isEditableKeyboardTarget(keyEvent.target)) { + return + } + + const activeElement = document.activeElement + const focusInsideChat = + activeElement instanceof HTMLElement && !!scrollContainerRef.current?.contains(activeElement) + const eventTargetInsideChat = + keyEvent.target instanceof Node && !!scrollContainerRef.current?.contains(keyEvent.target) + + if (focusInsideChat || eventTargetInsideChat || activeElement === document.body) { + enterUserBrowsingHistory("keyboard-nav-up") + } + }, + [enterUserBrowsingHistory, hasTask, isHidden, scrollContainerRef], + ) + useEvent("keydown", handleScrollKeyDown, window) + + // ----------------------------------------------------------------------- + // Return public API + // ----------------------------------------------------------------------- + + return { + scrollPhase, + showScrollToBottom, + handleRowHeightChange, + handleScrollToBottomClick, + enterUserBrowsingHistory, + followOutputCallback, + atBottomStateChangeCallback, + scrollToBottomAuto, + isAtBottomRef, + scrollPhaseRef, + } +} From b598efb42288a7be26701d19564c2680063e0820 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:01:31 -0700 Subject: [PATCH 017/109] feat: add per-task file-based history store for cross-instance safety (#11490) * feat: add per-task file-based history store for cross-instance safety Implement TaskHistoryStore service that stores each task's HistoryItem as an individual JSON file in its existing task directory. This prevents silent data loss when multiple VS Code windows write to the shared globalState taskHistory array concurrently. Key changes: - New TaskHistoryStore class with per-task file writes via safeWriteJson - Index file (_index.json) for fast startup reads - Reconciliation logic to detect and fix drift between instances - fs.watch for cross-instance reactivity - Debounced index writes (2s window) for streaming performance - Migration from globalState on first startup - Write-through to globalState during transition period - Fallback lookups from globalState for backward compatibility Files created: - src/core/task-persistence/TaskHistoryStore.ts - src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts - src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts Files modified: - src/shared/globalFileNames.ts (added historyItem, historyIndex) - src/core/task-persistence/index.ts (export TaskHistoryStore) - src/core/webview/ClineProvider.ts (integrate store, remove write lock) - Test files updated for new store-based approach * fix: address review feedback - reconcile lock, init promise, write-through serialization - reconcile() now runs through withLock() to prevent interleaving with upsert/delete at async boundaries - Added initialized promise so callers can await store readiness before reading (getStateToPostToWebview now awaits it) - Write-through to globalState now happens inside the store lock via onWrite callback, preventing concurrent call races on the transition period fallback - Removed separate updateGlobalState("taskHistory") calls from ClineProvider since the onWrite callback handles it serialized * fix: add TaskHistoryStore to task-persistence mock in Task.persistence.spec.ts The test mocks task-persistence with an explicit factory that was missing the new TaskHistoryStore export, causing all 9 tests to fail with "No TaskHistoryStore export is defined on the mock". * perf: debounce globalState write-through to avoid full-array writes on every mutation Instead of writing the entire HistoryItem[] array to globalState on every upsert/delete (expensive with 5000+ tasks), the write-through is now debounced with a 5-second window. Per-task file writes remain immediate (~200 bytes each). The globalState is flushed on dispose to ensure no data loss on shutdown. This makes the hot path during streaming (token count updates) write only the per-task file, not the full array. --------- Co-authored-by: Roo Code --- src/core/task-persistence/TaskHistoryStore.ts | 572 ++++++++++++++++++ .../TaskHistoryStore.crossInstance.spec.ts | 165 +++++ .../__tests__/TaskHistoryStore.spec.ts | 442 ++++++++++++++ src/core/task-persistence/index.ts | 1 + .../task/__tests__/Task.persistence.spec.ts | 11 + src/core/webview/ClineProvider.ts | 187 +++--- .../ClineProvider.sticky-mode.spec.ts | 18 +- .../ClineProvider.sticky-profile.spec.ts | 95 +-- .../ClineProvider.taskHistory.spec.ts | 89 +-- src/shared/globalFileNames.ts | 2 + 10 files changed, 1430 insertions(+), 152 deletions(-) create mode 100644 src/core/task-persistence/TaskHistoryStore.ts create mode 100644 src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts create mode 100644 src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts new file mode 100644 index 0000000000..4157d8b9fb --- /dev/null +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -0,0 +1,572 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import * as path from "path" + +import type { HistoryItem } from "@roo-code/types" + +import { GlobalFileNames } from "../../shared/globalFileNames" +import { safeWriteJson } from "../../utils/safeWriteJson" +import { getStorageBasePath } from "../../utils/storage" + +/** + * Index file format for fast startup reads. + */ +interface HistoryIndex { + version: number + updatedAt: number + entries: HistoryItem[] +} + +/** + * TaskHistoryStore encapsulates all task history persistence logic. + * + * Each task's HistoryItem is stored as an individual JSON file in its + * existing task directory (`globalStorage/tasks//history_item.json`). + * A single index file (`globalStorage/tasks/_index.json`) is maintained + * as a cache for fast list reads at startup. + * + * Cross-process safety comes from `safeWriteJson`'s `proper-lockfile` + * on per-task file writes. Within a single extension host process, + * an in-process write lock serializes mutations. + */ +/** + * Options for TaskHistoryStore constructor. + */ +export interface TaskHistoryStoreOptions { + /** + * Optional callback invoked inside the write lock after each mutation + * (upsert, delete, deleteMany). Used for serialized write-through to + * globalState during the transition period. + */ + onWrite?: (items: HistoryItem[]) => Promise +} + +export class TaskHistoryStore { + private readonly globalStoragePath: string + private readonly onWrite?: (items: HistoryItem[]) => Promise + private cache: Map = new Map() + private writeLock: Promise = Promise.resolve() + private indexWriteTimer: ReturnType | null = null + private fsWatcher: fsSync.FSWatcher | null = null + private reconcileTimer: ReturnType | null = null + private disposed = false + + /** + * Promise that resolves when initialization is complete. + * Callers can await this to ensure the store is ready before reading. + */ + public readonly initialized: Promise + private resolveInitialized!: () => void + + /** Debounce window for index writes in milliseconds. */ + private static readonly INDEX_WRITE_DEBOUNCE_MS = 2000 + + /** Periodic reconciliation interval in milliseconds. */ + private static readonly RECONCILE_INTERVAL_MS = 5 * 60 * 1000 + + constructor(globalStoragePath: string, options?: TaskHistoryStoreOptions) { + this.globalStoragePath = globalStoragePath + this.onWrite = options?.onWrite + this.initialized = new Promise((resolve) => { + this.resolveInitialized = resolve + }) + } + + // ────────────────────────────── Lifecycle ────────────────────────────── + + /** + * Load index, reconcile if needed, start watchers. + */ + async initialize(): Promise { + try { + const tasksDir = await this.getTasksDir() + await fs.mkdir(tasksDir, { recursive: true }) + + // 1. Load existing index into the cache + await this.loadIndex() + + // 2. Reconcile cache against actual task directories on disk + await this.reconcile() + + // 3. Start fs.watch for cross-instance reactivity + this.startWatcher() + + // 4. Start periodic reconciliation as a defensive fallback + this.startPeriodicReconciliation() + } finally { + // Mark initialization as complete so callers awaiting `initialized` can proceed + this.resolveInitialized() + } + } + + /** + * Flush pending writes, clear watchers, release resources. + */ + dispose(): void { + this.disposed = true + + if (this.indexWriteTimer) { + clearTimeout(this.indexWriteTimer) + this.indexWriteTimer = null + } + + if (this.reconcileTimer) { + clearTimeout(this.reconcileTimer) + this.reconcileTimer = null + } + + if (this.fsWatcher) { + this.fsWatcher.close() + this.fsWatcher = null + } + + // Synchronously flush the index (best-effort) + this.flushIndex().catch((err) => { + console.error("[TaskHistoryStore] Error flushing index on dispose:", err) + }) + } + + // ────────────────────────────── Reads ────────────────────────────── + + /** + * Get a single history item by task ID. + */ + get(taskId: string): HistoryItem | undefined { + return this.cache.get(taskId) + } + + /** + * Get all history items, sorted by timestamp descending (newest first). + */ + getAll(): HistoryItem[] { + return Array.from(this.cache.values()).sort((a, b) => b.ts - a.ts) + } + + /** + * Get history items filtered by workspace path. + */ + getByWorkspace(workspace: string): HistoryItem[] { + return this.getAll().filter((item) => item.workspace === workspace) + } + + // ────────────────────────────── Mutations ────────────────────────────── + + /** + * Insert or update a history item. + * + * Writes the per-task file immediately (source of truth), + * updates the in-memory Map, and schedules a debounced index write. + */ + async upsert(item: HistoryItem): Promise { + return this.withLock(async () => { + const existing = this.cache.get(item.id) + + // Merge: preserve existing metadata unless explicitly overwritten + const merged = existing ? { ...existing, ...item } : item + + // Write per-task file (source of truth) + await this.writeTaskFile(merged) + + // Update in-memory cache + this.cache.set(merged.id, merged) + + // Schedule debounced index write + this.scheduleIndexWrite() + + const all = this.getAll() + + // Call onWrite callback inside the lock for serialized write-through + if (this.onWrite) { + await this.onWrite(all) + } + + return all + }) + } + + /** + * Delete a single task's history item. + */ + async delete(taskId: string): Promise { + return this.withLock(async () => { + this.cache.delete(taskId) + + // Remove per-task file (best-effort) + try { + const filePath = await this.getTaskFilePath(taskId) + await fs.unlink(filePath) + } catch { + // File may already be deleted + } + + this.scheduleIndexWrite() + + // Call onWrite callback inside the lock for serialized write-through + if (this.onWrite) { + await this.onWrite(this.getAll()) + } + }) + } + + /** + * Delete multiple tasks' history items in a batch. + */ + async deleteMany(taskIds: string[]): Promise { + return this.withLock(async () => { + for (const taskId of taskIds) { + this.cache.delete(taskId) + + try { + const filePath = await this.getTaskFilePath(taskId) + await fs.unlink(filePath) + } catch { + // File may already be deleted + } + } + + this.scheduleIndexWrite() + + // Call onWrite callback inside the lock for serialized write-through + if (this.onWrite) { + await this.onWrite(this.getAll()) + } + }) + } + + // ────────────────────────────── Reconciliation ────────────────────────────── + + /** + * Scan task directories vs index and fix any drift. + * + * - Tasks on disk but missing from cache: read and add + * - Tasks in cache but missing from disk: remove + */ + async reconcile(): Promise { + // Run through the write lock to prevent interleaving with upsert/delete + return this.withLock(async () => { + const tasksDir = await this.getTasksDir() + + let dirEntries: string[] + try { + dirEntries = await fs.readdir(tasksDir) + } catch { + return // tasks dir doesn't exist yet + } + + // Filter out the index file and hidden files + const taskDirNames = dirEntries.filter((name) => !name.startsWith("_") && !name.startsWith(".")) + + const onDiskIds = new Set(taskDirNames) + const cacheIds = new Set(this.cache.keys()) + let changed = false + + // Tasks on disk but not in cache: read their history_item.json + for (const taskId of onDiskIds) { + if (!cacheIds.has(taskId)) { + try { + const item = await this.readTaskFile(taskId) + if (item) { + this.cache.set(taskId, item) + changed = true + } + } catch { + // Corrupted or missing file, skip + } + } + } + + // Tasks in cache but not on disk: remove from cache + for (const taskId of cacheIds) { + if (!onDiskIds.has(taskId)) { + this.cache.delete(taskId) + changed = true + } + } + + if (changed) { + this.scheduleIndexWrite() + } + }) + } + + // ────────────────────────────── Cache invalidation ────────────────────────────── + + /** + * Invalidate a single task's cache entry (re-read from disk on next access). + */ + async invalidate(taskId: string): Promise { + try { + const item = await this.readTaskFile(taskId) + if (item) { + this.cache.set(taskId, item) + } else { + this.cache.delete(taskId) + } + } catch { + this.cache.delete(taskId) + } + } + + /** + * Clear all in-memory cache and reload from index. + */ + invalidateAll(): void { + this.cache.clear() + } + + // ────────────────────────────── Migration ────────────────────────────── + + /** + * Migrate from globalState taskHistory array to per-task files. + * + * For each entry in the globalState array, writes a `history_item.json` + * file if one doesn't already exist. This is idempotent and safe to re-run. + */ + async migrateFromGlobalState(taskHistoryEntries: HistoryItem[]): Promise { + if (!taskHistoryEntries || taskHistoryEntries.length === 0) { + return + } + + for (const item of taskHistoryEntries) { + if (!item.id) { + continue + } + + // Check if task directory exists on disk + const tasksDir = await this.getTasksDir() + const taskDir = path.join(tasksDir, item.id) + + try { + await fs.access(taskDir) + } catch { + // Task directory doesn't exist; skip this entry as it's orphaned in globalState + continue + } + + // Write history_item.json if it doesn't exist yet + const filePath = path.join(taskDir, GlobalFileNames.historyItem) + try { + await fs.access(filePath) + // File already exists, skip (don't overwrite existing per-task files) + } catch { + // File doesn't exist, write it + await safeWriteJson(filePath, item) + this.cache.set(item.id, item) + } + } + + // Write the index + await this.writeIndex() + } + + // ────────────────────────────── Private: Index management ────────────────────────────── + + /** + * Load the `_index.json` file into the in-memory cache. + */ + private async loadIndex(): Promise { + const indexPath = await this.getIndexPath() + + try { + const raw = await fs.readFile(indexPath, "utf8") + const index: HistoryIndex = JSON.parse(raw) + + if (index.version === 1 && Array.isArray(index.entries)) { + for (const entry of index.entries) { + if (entry.id) { + this.cache.set(entry.id, entry) + } + } + } + } catch { + // Index doesn't exist or is corrupted; cache stays empty. + // Reconciliation will rebuild it from per-task files. + } + } + + /** + * Write the full index to disk. + */ + private async writeIndex(): Promise { + const indexPath = await this.getIndexPath() + const index: HistoryIndex = { + version: 1, + updatedAt: Date.now(), + entries: this.getAll(), + } + + await safeWriteJson(indexPath, index) + } + + /** + * Schedule a debounced index write. + */ + private scheduleIndexWrite(): void { + if (this.disposed) { + return + } + + if (this.indexWriteTimer) { + clearTimeout(this.indexWriteTimer) + } + + this.indexWriteTimer = setTimeout(async () => { + this.indexWriteTimer = null + try { + await this.writeIndex() + } catch (err) { + console.error("[TaskHistoryStore] Failed to write index:", err) + } + }, TaskHistoryStore.INDEX_WRITE_DEBOUNCE_MS) + } + + /** + * Force an immediate index write (called on dispose/shutdown). + */ + async flushIndex(): Promise { + if (this.indexWriteTimer) { + clearTimeout(this.indexWriteTimer) + this.indexWriteTimer = null + } + + await this.writeIndex() + } + + // ────────────────────────────── Private: Per-task file I/O ────────────────────────────── + + /** + * Write a HistoryItem to its per-task `history_item.json` file. + */ + private async writeTaskFile(item: HistoryItem): Promise { + const filePath = await this.getTaskFilePath(item.id) + await safeWriteJson(filePath, item) + } + + /** + * Read a HistoryItem from its per-task `history_item.json` file. + */ + private async readTaskFile(taskId: string): Promise { + const filePath = await this.getTaskFilePath(taskId) + + try { + const raw = await fs.readFile(filePath, "utf8") + const item: HistoryItem = JSON.parse(raw) + return item.id ? item : null + } catch { + return null + } + } + + // ────────────────────────────── Private: fs.watch ────────────────────────────── + + /** + * Watch the tasks directory for changes from other instances. + */ + private startWatcher(): void { + if (this.disposed) { + return + } + + // Use a debounced handler to avoid excessive reconciliation + let watchDebounce: ReturnType | null = null + + this.getTasksDir() + .then((tasksDir) => { + if (this.disposed) { + return + } + + try { + this.fsWatcher = fsSync.watch(tasksDir, { recursive: false }, (_eventType, _filename) => { + if (this.disposed) { + return + } + + // Debounce the reconciliation triggered by fs.watch + if (watchDebounce) { + clearTimeout(watchDebounce) + } + watchDebounce = setTimeout(() => { + this.reconcile().catch((err) => { + console.error("[TaskHistoryStore] Reconciliation after fs.watch failed:", err) + }) + }, 500) + }) + + this.fsWatcher.on("error", (err) => { + console.error("[TaskHistoryStore] fs.watch error:", err) + // fs.watch is unreliable on some platforms; periodic reconciliation + // serves as the fallback. + }) + } catch (err) { + console.error("[TaskHistoryStore] Failed to start fs.watch:", err) + } + }) + .catch((err) => { + console.error("[TaskHistoryStore] Failed to get tasks dir for watcher:", err) + }) + } + + /** + * Start periodic reconciliation as a defensive fallback for platforms + * where fs.watch is unreliable. + */ + private startPeriodicReconciliation(): void { + if (this.disposed) { + return + } + + this.reconcileTimer = setTimeout(async () => { + if (this.disposed) { + return + } + try { + await this.reconcile() + } catch (err) { + console.error("[TaskHistoryStore] Periodic reconciliation failed:", err) + } + this.startPeriodicReconciliation() + }, TaskHistoryStore.RECONCILE_INTERVAL_MS) + } + + // ────────────────────────────── Private: Write lock ────────────────────────────── + + /** + * Serializes all read-modify-write operations within a single extension + * host process to prevent concurrent interleaving. + */ + private withLock(fn: () => Promise): Promise { + const result = this.writeLock.then(fn, fn) + this.writeLock = result.then( + () => {}, + () => {}, + ) + return result + } + + // ────────────────────────────── Private: Path helpers ────────────────────────────── + + /** + * Get the tasks base directory path, resolving custom storage paths. + */ + private async getTasksDir(): Promise { + const basePath = await getStorageBasePath(this.globalStoragePath) + return path.join(basePath, "tasks") + } + + /** + * Get the path to a task's `history_item.json` file. + */ + private async getTaskFilePath(taskId: string): Promise { + const tasksDir = await this.getTasksDir() + return path.join(tasksDir, taskId, GlobalFileNames.historyItem) + } + + /** + * Get the path to the `_index.json` file. + */ + private async getIndexPath(): Promise { + const tasksDir = await this.getTasksDir() + return path.join(tasksDir, GlobalFileNames.historyIndex) + } +} diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts new file mode 100644 index 0000000000..f344e58dfd --- /dev/null +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts @@ -0,0 +1,165 @@ +// pnpm --filter roo-cline test core/task-persistence/__tests__/TaskHistoryStore.crossInstance.spec.ts + +import * as fs from "fs/promises" +import * as path from "path" +import * as os from "os" + +import type { HistoryItem } from "@roo-code/types" + +import { TaskHistoryStore } from "../TaskHistoryStore" +import { GlobalFileNames } from "../../../shared/globalFileNames" + +vi.mock("../../../utils/storage", () => ({ + getStorageBasePath: vi.fn().mockImplementation((defaultPath: string) => defaultPath), +})) + +// Mock safeWriteJson to use plain fs writes in tests (avoids proper-lockfile issues) +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn().mockImplementation(async (filePath: string, data: any) => { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf8") + }), +})) + +function makeHistoryItem(overrides: Partial = {}): HistoryItem { + return { + id: `task-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`, + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: "/test/workspace", + ...overrides, + } +} + +describe("TaskHistoryStore cross-instance safety", () => { + let tmpDir: string + let storeA: TaskHistoryStore + let storeB: TaskHistoryStore + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-cross-")) + // Two stores pointing at the same globalStoragePath (simulating two VS Code windows) + storeA = new TaskHistoryStore(tmpDir) + storeB = new TaskHistoryStore(tmpDir) + }) + + afterEach(async () => { + storeA.dispose() + storeB.dispose() + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + }) + + it("two instances can write different tasks without conflict", async () => { + await storeA.initialize() + await storeB.initialize() + + // Instance A writes task-a + await storeA.upsert(makeHistoryItem({ id: "task-a", task: "Task from instance A" })) + + // Instance B writes task-b + await storeB.upsert(makeHistoryItem({ id: "task-b", task: "Task from instance B" })) + + // Each instance sees its own task + expect(storeA.get("task-a")).toBeDefined() + expect(storeB.get("task-b")).toBeDefined() + + // After reconciliation, instance A should see task-b and vice versa + await storeA.reconcile() + await storeB.reconcile() + + expect(storeA.get("task-b")).toBeDefined() + expect(storeB.get("task-a")).toBeDefined() + + expect(storeA.getAll()).toHaveLength(2) + expect(storeB.getAll()).toHaveLength(2) + }) + + it("reconciliation in instance B detects a task created by instance A", async () => { + await storeA.initialize() + await storeB.initialize() + + // Instance A creates a task + const item = makeHistoryItem({ id: "cross-task", task: "Created by A" }) + await storeA.upsert(item) + + // Instance B doesn't know about it yet + expect(storeB.get("cross-task")).toBeUndefined() + + // Reconciliation picks it up + await storeB.reconcile() + + expect(storeB.get("cross-task")).toBeDefined() + expect(storeB.get("cross-task")!.task).toBe("Created by A") + }) + + it("delete by instance A is detected by instance B reconciliation", async () => { + await storeA.initialize() + await storeB.initialize() + + // Both instances have a task + const item = makeHistoryItem({ id: "shared-task" }) + await storeA.upsert(item) + await storeB.reconcile() // B picks it up + + expect(storeB.get("shared-task")).toBeDefined() + + // Instance A deletes the task (per-task file + directory would be removed) + await storeA.delete("shared-task") + + // Remove the task directory to simulate full deletion (deleteTaskWithId removes the dir) + const taskDir = path.join(tmpDir, "tasks", "shared-task") + await fs.rm(taskDir, { recursive: true, force: true }) + + // Instance B still has it in cache + expect(storeB.get("shared-task")).toBeDefined() + + // After reconciliation, instance B sees it's gone + await storeB.reconcile() + expect(storeB.get("shared-task")).toBeUndefined() + }) + + it("per-task file updates by one instance are visible to another after invalidation", async () => { + await storeA.initialize() + await storeB.initialize() + + // Instance A creates a task + const item = makeHistoryItem({ id: "update-task", tokensIn: 100 }) + await storeA.upsert(item) + + // Instance B picks it up via reconciliation + await storeB.reconcile() + expect(storeB.get("update-task")!.tokensIn).toBe(100) + + // Instance A updates the task + await storeA.upsert({ ...item, tokensIn: 500 }) + + // Instance B invalidates and re-reads + await storeB.invalidate("update-task") + expect(storeB.get("update-task")!.tokensIn).toBe(500) + }) + + it("concurrent writes to different tasks from two instances produce correct final state", async () => { + await storeA.initialize() + await storeB.initialize() + + // Write alternating tasks from each instance + const promises = [] + for (let i = 0; i < 5; i++) { + promises.push(storeA.upsert(makeHistoryItem({ id: `a-task-${i}`, ts: 1000 + i }))) + promises.push(storeB.upsert(makeHistoryItem({ id: `b-task-${i}`, ts: 2000 + i }))) + } + + await Promise.all(promises) + + // After reconciliation, both should see all 10 tasks + await storeA.reconcile() + await storeB.reconcile() + + expect(storeA.getAll().length).toBe(10) + expect(storeB.getAll().length).toBe(10) + }) +}) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts new file mode 100644 index 0000000000..8adc486160 --- /dev/null +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -0,0 +1,442 @@ +// pnpm --filter roo-cline test core/task-persistence/__tests__/TaskHistoryStore.spec.ts + +import * as fs from "fs/promises" +import * as path from "path" +import * as os from "os" + +import type { HistoryItem } from "@roo-code/types" + +import { TaskHistoryStore } from "../TaskHistoryStore" +import { GlobalFileNames } from "../../../shared/globalFileNames" + +vi.mock("../../../utils/storage", () => ({ + getStorageBasePath: vi.fn().mockImplementation((defaultPath: string) => defaultPath), +})) + +// Mock safeWriteJson to use plain fs writes in tests (avoids proper-lockfile issues) +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn().mockImplementation(async (filePath: string, data: any) => { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf8") + }), +})) + +function makeHistoryItem(overrides: Partial = {}): HistoryItem { + return { + id: `task-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`, + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: "/test/workspace", + ...overrides, + } +} + +describe("TaskHistoryStore", () => { + let tmpDir: string + let store: TaskHistoryStore + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-test-")) + store = new TaskHistoryStore(tmpDir) + }) + + afterEach(async () => { + store.dispose() + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + }) + + describe("initialize()", () => { + it("initializes from empty state (no index, no task dirs)", async () => { + await store.initialize() + expect(store.getAll()).toEqual([]) + }) + + it("initializes from existing index file", async () => { + const tasksDir = path.join(tmpDir, "tasks") + await fs.mkdir(tasksDir, { recursive: true }) + + const item1 = makeHistoryItem({ id: "task-1", ts: 1000 }) + const item2 = makeHistoryItem({ id: "task-2", ts: 2000 }) + + // Create task directories so reconciliation doesn't remove them + await fs.mkdir(path.join(tasksDir, "task-1"), { recursive: true }) + await fs.mkdir(path.join(tasksDir, "task-2"), { recursive: true }) + + // Write per-task files + await fs.writeFile(path.join(tasksDir, "task-1", GlobalFileNames.historyItem), JSON.stringify(item1)) + await fs.writeFile(path.join(tasksDir, "task-2", GlobalFileNames.historyItem), JSON.stringify(item2)) + + // Write index + const index = { + version: 1, + updatedAt: Date.now(), + entries: [item1, item2], + } + await fs.writeFile(path.join(tasksDir, GlobalFileNames.historyIndex), JSON.stringify(index)) + + await store.initialize() + + expect(store.getAll()).toHaveLength(2) + expect(store.get("task-1")).toBeDefined() + expect(store.get("task-2")).toBeDefined() + }) + }) + + describe("get()", () => { + it("returns undefined for non-existent task", async () => { + await store.initialize() + expect(store.get("non-existent")).toBeUndefined() + }) + + it("returns the item after upsert", async () => { + await store.initialize() + const item = makeHistoryItem({ id: "task-get" }) + await store.upsert(item) + expect(store.get("task-get")).toMatchObject({ id: "task-get" }) + }) + }) + + describe("getAll()", () => { + it("returns items sorted by ts descending", async () => { + await store.initialize() + + await store.upsert(makeHistoryItem({ id: "old", ts: 1000 })) + await store.upsert(makeHistoryItem({ id: "mid", ts: 2000 })) + await store.upsert(makeHistoryItem({ id: "new", ts: 3000 })) + + const all = store.getAll() + expect(all).toHaveLength(3) + expect(all[0].id).toBe("new") + expect(all[1].id).toBe("mid") + expect(all[2].id).toBe("old") + }) + }) + + describe("getByWorkspace()", () => { + it("filters by workspace path", async () => { + await store.initialize() + + await store.upsert(makeHistoryItem({ id: "ws-a-1", workspace: "/workspace-a" })) + await store.upsert(makeHistoryItem({ id: "ws-a-2", workspace: "/workspace-a" })) + await store.upsert(makeHistoryItem({ id: "ws-b-1", workspace: "/workspace-b" })) + + const wsA = store.getByWorkspace("/workspace-a") + expect(wsA).toHaveLength(2) + expect(wsA.every((item) => item.workspace === "/workspace-a")).toBe(true) + + const wsB = store.getByWorkspace("/workspace-b") + expect(wsB).toHaveLength(1) + expect(wsB[0].id).toBe("ws-b-1") + }) + }) + + describe("upsert()", () => { + it("writes per-task file and updates cache", async () => { + await store.initialize() + + const item = makeHistoryItem({ id: "upsert-task" }) + const result = await store.upsert(item) + + // Cache should be updated + expect(store.get("upsert-task")).toBeDefined() + expect(result.length).toBe(1) + + // Per-task file should exist + const filePath = path.join(tmpDir, "tasks", "upsert-task", GlobalFileNames.historyItem) + const raw = await fs.readFile(filePath, "utf8") + const written = JSON.parse(raw) + expect(written.id).toBe("upsert-task") + }) + + it("preserves existing metadata on partial updates (delegation fields)", async () => { + await store.initialize() + + const original = makeHistoryItem({ + id: "delegate-task", + status: "delegated", + delegatedToId: "child-1", + awaitingChildId: "child-1", + childIds: ["child-1"], + }) + await store.upsert(original) + + // Partial update that doesn't include delegation fields + const partialUpdate: HistoryItem = makeHistoryItem({ + id: "delegate-task", + tokensIn: 500, + tokensOut: 200, + }) + await store.upsert(partialUpdate) + + const result = store.get("delegate-task")! + expect(result.status).toBe("delegated") + expect(result.delegatedToId).toBe("child-1") + expect(result.awaitingChildId).toBe("child-1") + expect(result.childIds).toEqual(["child-1"]) + expect(result.tokensIn).toBe(500) + expect(result.tokensOut).toBe(200) + }) + + it("returns updated task history array", async () => { + await store.initialize() + + const item1 = makeHistoryItem({ id: "item-1", ts: 1000 }) + const item2 = makeHistoryItem({ id: "item-2", ts: 2000 }) + + await store.upsert(item1) + const result = await store.upsert(item2) + + expect(result).toHaveLength(2) + // Should be sorted by ts descending + expect(result[0].id).toBe("item-2") + expect(result[1].id).toBe("item-1") + }) + }) + + describe("delete()", () => { + it("removes per-task file and updates cache", async () => { + await store.initialize() + + const item = makeHistoryItem({ id: "del-task" }) + await store.upsert(item) + expect(store.get("del-task")).toBeDefined() + + await store.delete("del-task") + expect(store.get("del-task")).toBeUndefined() + expect(store.getAll()).toHaveLength(0) + }) + + it("handles deleting non-existent task gracefully", async () => { + await store.initialize() + await expect(store.delete("non-existent")).resolves.not.toThrow() + }) + }) + + describe("deleteMany()", () => { + it("removes multiple tasks in batch", async () => { + await store.initialize() + + await store.upsert(makeHistoryItem({ id: "batch-1" })) + await store.upsert(makeHistoryItem({ id: "batch-2" })) + await store.upsert(makeHistoryItem({ id: "batch-3" })) + expect(store.getAll()).toHaveLength(3) + + await store.deleteMany(["batch-1", "batch-3"]) + expect(store.getAll()).toHaveLength(1) + expect(store.get("batch-2")).toBeDefined() + }) + }) + + describe("reconcile()", () => { + it("detects tasks on disk missing from index", async () => { + await store.initialize() + + // Manually create a task directory with history_item.json + const tasksDir = path.join(tmpDir, "tasks") + const taskDir = path.join(tasksDir, "orphan-task") + await fs.mkdir(taskDir, { recursive: true }) + + const item = makeHistoryItem({ id: "orphan-task" }) + await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(item)) + + // Reconcile should pick it up + await store.reconcile() + + expect(store.get("orphan-task")).toBeDefined() + expect(store.get("orphan-task")!.id).toBe("orphan-task") + }) + + it("removes tasks from cache that no longer exist on disk", async () => { + await store.initialize() + + const item = makeHistoryItem({ id: "removed-task" }) + await store.upsert(item) + expect(store.get("removed-task")).toBeDefined() + + // Remove the task directory from disk + const taskDir = path.join(tmpDir, "tasks", "removed-task") + await fs.rm(taskDir, { recursive: true, force: true }) + + // Reconcile should remove it from cache + await store.reconcile() + + expect(store.get("removed-task")).toBeUndefined() + }) + }) + + describe("concurrent upsert() calls are serialized", () => { + it("serializes concurrent writes so no entries are lost", async () => { + await store.initialize() + + // Fire 5 concurrent upserts + const promises = Array.from({ length: 5 }, (_, i) => + store.upsert(makeHistoryItem({ id: `concurrent-${i}`, ts: 1000 + i })), + ) + + await Promise.all(promises) + + const all = store.getAll() + expect(all).toHaveLength(5) + const ids = all.map((h) => h.id) + for (let i = 0; i < 5; i++) { + expect(ids).toContain(`concurrent-${i}`) + } + }) + + it("serializes interleaved upsert and delete", async () => { + await store.initialize() + + const item = makeHistoryItem({ id: "interleave-test", ts: 1000 }) + await store.upsert(item) + + // Concurrent update and delete of different items + const promise1 = store.upsert(makeHistoryItem({ id: "survivor", ts: 2000 })) + const promise2 = store.delete("interleave-test") + + await Promise.all([promise1, promise2]) + + expect(store.get("interleave-test")).toBeUndefined() + expect(store.get("survivor")).toBeDefined() + }) + }) + + describe("migrateFromGlobalState()", () => { + it("writes history_item.json for tasks with existing directories", async () => { + await store.initialize() + + const tasksDir = path.join(tmpDir, "tasks") + + // Create task directories (simulating existing tasks) + await fs.mkdir(path.join(tasksDir, "legacy-1"), { recursive: true }) + await fs.mkdir(path.join(tasksDir, "legacy-2"), { recursive: true }) + + const items = [ + makeHistoryItem({ id: "legacy-1", task: "Legacy task 1" }), + makeHistoryItem({ id: "legacy-2", task: "Legacy task 2" }), + makeHistoryItem({ id: "legacy-orphan", task: "Orphaned task" }), // No directory + ] + + await store.migrateFromGlobalState(items) + + // Should have migrated 2 items (skipping orphan) + expect(store.get("legacy-1")).toBeDefined() + expect(store.get("legacy-2")).toBeDefined() + expect(store.get("legacy-orphan")).toBeUndefined() + }) + + it("does not overwrite existing per-task files", async () => { + await store.initialize() + + const tasksDir = path.join(tmpDir, "tasks") + const taskDir = path.join(tasksDir, "existing-task") + await fs.mkdir(taskDir, { recursive: true }) + + // Write an existing history_item.json with specific data + const existingItem = makeHistoryItem({ + id: "existing-task", + task: "Original task text", + tokensIn: 999, + }) + await fs.writeFile(path.join(taskDir, GlobalFileNames.historyItem), JSON.stringify(existingItem)) + + // Try to migrate with different data + const migratedItem = makeHistoryItem({ + id: "existing-task", + task: "Different task text", + tokensIn: 1, + }) + await store.migrateFromGlobalState([migratedItem]) + + // Existing file should not be overwritten + const raw = await fs.readFile(path.join(taskDir, GlobalFileNames.historyItem), "utf8") + const persisted = JSON.parse(raw) + expect(persisted.task).toBe("Original task text") + expect(persisted.tokensIn).toBe(999) + }) + + it("is idempotent (can be called multiple times safely)", async () => { + await store.initialize() + + const tasksDir = path.join(tmpDir, "tasks") + await fs.mkdir(path.join(tasksDir, "idem-task"), { recursive: true }) + + const item = makeHistoryItem({ id: "idem-task" }) + + await store.migrateFromGlobalState([item]) + await store.migrateFromGlobalState([item]) // Second call + + expect(store.get("idem-task")).toBeDefined() + }) + }) + + describe("flushIndex()", () => { + it("writes index to disk on flush", async () => { + await store.initialize() + + await store.upsert(makeHistoryItem({ id: "flush-task" })) + await store.flushIndex() + + const indexPath = path.join(tmpDir, "tasks", GlobalFileNames.historyIndex) + const raw = await fs.readFile(indexPath, "utf8") + const index = JSON.parse(raw) + + expect(index.version).toBe(1) + expect(index.entries).toHaveLength(1) + expect(index.entries[0].id).toBe("flush-task") + }) + }) + + describe("dispose()", () => { + it("flushes index on dispose", async () => { + await store.initialize() + + await store.upsert(makeHistoryItem({ id: "dispose-task" })) + store.dispose() + + // Give the flush a moment to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + const indexPath = path.join(tmpDir, "tasks", GlobalFileNames.historyIndex) + const raw = await fs.readFile(indexPath, "utf8") + const index = JSON.parse(raw) + expect(index.entries).toHaveLength(1) + }) + }) + + describe("invalidate()", () => { + it("re-reads a task from disk", async () => { + await store.initialize() + + const item = makeHistoryItem({ id: "invalidate-task", tokensIn: 100 }) + await store.upsert(item) + + // Manually update the file on disk + const filePath = path.join(tmpDir, "tasks", "invalidate-task", GlobalFileNames.historyItem) + const updated = { ...item, tokensIn: 999 } + await fs.writeFile(filePath, JSON.stringify(updated)) + + await store.invalidate("invalidate-task") + + expect(store.get("invalidate-task")!.tokensIn).toBe(999) + }) + + it("removes item from cache if file no longer exists", async () => { + await store.initialize() + + const item = makeHistoryItem({ id: "gone-task" }) + await store.upsert(item) + + // Delete the file + const filePath = path.join(tmpDir, "tasks", "gone-task", GlobalFileNames.historyItem) + await fs.unlink(filePath) + + await store.invalidate("gone-task") + + expect(store.get("gone-task")).toBeUndefined() + }) + }) +}) diff --git a/src/core/task-persistence/index.ts b/src/core/task-persistence/index.ts index c8656002bd..115711e6fd 100644 --- a/src/core/task-persistence/index.ts +++ b/src/core/task-persistence/index.ts @@ -1,3 +1,4 @@ export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages" export { readTaskMessages, saveTaskMessages } from "./taskMessages" export { taskMetadata } from "./taskMetadata" +export { TaskHistoryStore } from "./TaskHistoryStore" diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 1e4acc9713..e73638d8ad 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -79,6 +79,17 @@ vi.mock("../../task-persistence", () => ({ readApiMessages: mockReadApiMessages, readTaskMessages: mockReadTaskMessages, taskMetadata: mockTaskMetadata, + TaskHistoryStore: vi.fn().mockImplementation(() => ({ + initialize: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + get: vi.fn(), + getAll: vi.fn().mockReturnValue([]), + upsert: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + deleteMany: vi.fn().mockResolvedValue(undefined), + reconcile: vi.fn().mockResolvedValue(undefined), + initialized: Promise.resolve(), + })), })) vi.mock("vscode", () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 408bbfd219..3095daae7e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -97,7 +97,7 @@ import { Task } from "../task/Task" import { webviewMessageHandler } from "./webviewMessageHandler" import type { ClineMessage, TodoItem } from "@roo-code/types" -import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence" +import { readApiMessages, saveApiMessages, saveTaskMessages, TaskHistoryStore } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" import { getUri } from "./getUri" @@ -150,7 +150,10 @@ export class ClineProvider private _disposed = false private recentTasksCache?: string[] - private taskHistoryWriteLock: Promise = Promise.resolve() + public readonly taskHistoryStore: TaskHistoryStore + private taskHistoryStoreInitialized = false + private globalStateWriteThroughTimer: ReturnType | null = null + private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds private pendingOperations: Map = new Map() private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds @@ -185,6 +188,18 @@ export class ClineProvider this.mdmService = mdmService this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) + // Initialize the per-task file-based history store. + // The globalState write-through is debounced separately (not on every mutation) + // since per-task files are authoritative and globalState is only for downgrade compat. + this.taskHistoryStore = new TaskHistoryStore(this.contextProxy.globalStorageUri.fsPath, { + onWrite: async () => { + this.scheduleGlobalStateWriteThrough() + }, + }) + this.initializeTaskHistoryStore().catch((error) => { + this.log(`Failed to initialize TaskHistoryStore: ${error}`) + }) + // Start configuration loading (which might trigger indexing) in the background. // Don't await, allowing activation to continue immediately. @@ -314,6 +329,35 @@ export class ClineProvider } } + /** + * Initialize the TaskHistoryStore and migrate from globalState if needed. + */ + private async initializeTaskHistoryStore(): Promise { + try { + await this.taskHistoryStore.initialize() + + // Migration: backfill per-task files from globalState on first run + const migrationKey = "taskHistoryMigratedToFiles" + const alreadyMigrated = this.context.globalState.get(migrationKey) + + if (!alreadyMigrated) { + const legacyHistory = this.context.globalState.get("taskHistory") ?? [] + + if (legacyHistory.length > 0) { + this.log(`[initializeTaskHistoryStore] Migrating ${legacyHistory.length} entries from globalState`) + await this.taskHistoryStore.migrateFromGlobalState(legacyHistory) + } + + await this.context.globalState.update(migrationKey, true) + this.log("[initializeTaskHistoryStore] Migration complete") + } + + this.taskHistoryStoreInitialized = true + } catch (error) { + this.log(`[initializeTaskHistoryStore] Error: ${error instanceof Error ? error.message : String(error)}`) + } + } + /** * Override EventEmitter's on method to match TaskProviderLike interface */ @@ -667,6 +711,8 @@ export class ClineProvider this.skillsManager = undefined this.marketplaceManager?.cleanup() this.customModesManager?.dispose() + this.taskHistoryStore.dispose() + this.flushGlobalStateWriteThrough() this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) @@ -1344,12 +1390,12 @@ export class ClineProvider try { // Update the task history with the new mode first. - const history = this.getGlobalState("taskHistory") ?? [] - const taskHistoryItem = history.find((item) => item.id === task.taskId) + const taskHistoryItem = + this.taskHistoryStore.get(task.taskId) ?? + (this.getGlobalState("taskHistory") ?? []).find((item) => item.id === task.taskId) if (taskHistoryItem) { - taskHistoryItem.mode = newMode - await this.updateTaskHistory(taskHistoryItem) + await this.updateTaskHistory({ ...taskHistoryItem, mode: newMode }) } // Only update the task's mode after successful persistence. @@ -1563,8 +1609,9 @@ export class ClineProvider // been persisted into taskHistory (it will be captured on the next save). task.setTaskApiConfigName(apiConfigName) - const history = this.getGlobalState("taskHistory") ?? [] - const taskHistoryItem = history.find((item) => item.id === task.taskId) + const taskHistoryItem = + this.taskHistoryStore.get(task.taskId) ?? + (this.getGlobalState("taskHistory") ?? []).find((item) => item.id === task.taskId) if (taskHistoryItem) { await this.updateTaskHistory({ ...taskHistoryItem, apiConfigName }) @@ -1723,8 +1770,8 @@ export class ClineProvider uiMessagesFilePath: string apiConversationHistory: Anthropic.MessageParam[] }> { - const history = this.getGlobalState("taskHistory") ?? [] - const historyItem = history.find((item) => item.id === id) + const historyItem = + this.taskHistoryStore.get(id) ?? (this.getGlobalState("taskHistory") ?? []).find((item) => item.id === id) if (!historyItem) { throw new Error("Task not found") @@ -1856,12 +1903,8 @@ export class ClineProvider } // Delete all tasks from state in one batch - await this.withTaskHistoryLock(async () => { - const taskHistory = this.getGlobalState("taskHistory") ?? [] - const updatedTaskHistory = taskHistory.filter((task) => !allIdsToDelete.includes(task.id)) - await this.updateGlobalState("taskHistory", updatedTaskHistory) - this.recentTasksCache = undefined - }) + await this.taskHistoryStore.deleteMany(allIdsToDelete) + this.recentTasksCache = undefined // Delete associated shadow repositories or branches and task directories const globalStorageDir = this.contextProxy.globalStorageUri.fsPath @@ -1902,12 +1945,9 @@ export class ClineProvider } async deleteTaskFromState(id: string) { - await this.withTaskHistoryLock(async () => { - const taskHistory = this.getGlobalState("taskHistory") ?? [] - const updatedTaskHistory = taskHistory.filter((task) => task.id !== id) - await this.updateGlobalState("taskHistory", updatedTaskHistory) - this.recentTasksCache = undefined - }) + await this.taskHistoryStore.delete(id) + this.recentTasksCache = undefined + await this.postStateToWebview() } @@ -2074,6 +2114,9 @@ export class ClineProvider } async getStateToPostToWebview(): Promise { + // Ensure the store is initialized before reading task history + await this.taskHistoryStore.initialized + const { apiConfiguration, lastShownAnnouncementId, @@ -2206,14 +2249,12 @@ export class ClineProvider autoCondenseContextPercent: autoCondenseContextPercent ?? 100, uriScheme: vscode.env.uriScheme, currentTaskItem: this.getCurrentTask()?.taskId - ? (taskHistory || []).find((item: HistoryItem) => item.id === this.getCurrentTask()?.taskId) + ? this.taskHistoryStore.get(this.getCurrentTask()!.taskId) : undefined, clineMessages: this.getCurrentTask()?.clineMessages || [], currentTaskTodos: this.getCurrentTask()?.todoList || [], messageQueue: this.getCurrentTask()?.messageQueueService?.messages, - taskHistory: (taskHistory || []) - .filter((item: HistoryItem) => item.ts && item.task) - .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts), + taskHistory: this.taskHistoryStore.getAll().filter((item: HistoryItem) => item.ts && item.task), soundEnabled: soundEnabled ?? false, ttsEnabled: ttsEnabled ?? false, ttsSpeed: ttsSpeed ?? 1.0, @@ -2443,7 +2484,7 @@ export class ClineProvider allowedMaxCost: stateValues.allowedMaxCost, autoCondenseContext: stateValues.autoCondenseContext ?? true, autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, - taskHistory: stateValues.taskHistory ?? [], + taskHistory: this.taskHistoryStore.getAll(), allowedCommands: stateValues.allowedCommands, deniedCommands: stateValues.deniedCommands, soundEnabled: stateValues.soundEnabled ?? false, @@ -2552,69 +2593,79 @@ export class ClineProvider } } - /** - * Serializes all read-modify-write operations on taskHistory to prevent - * concurrent interleaving that can cause entries to vanish. - */ - private withTaskHistoryLock(fn: () => Promise): Promise { - const result = this.taskHistoryWriteLock.then(fn, fn) // run even if previous write errored - this.taskHistoryWriteLock = result.then( - () => {}, - () => {}, - ) // swallow for chain continuity - return result - } - /** * Updates a task in the task history and optionally broadcasts the updated history to the webview. + * Now delegates to TaskHistoryStore for per-task file persistence. + * * @param item The history item to update or add * @param options.broadcast Whether to broadcast the updated history to the webview (default: true) * @returns The updated task history array */ async updateTaskHistory(item: HistoryItem, options: { broadcast?: boolean } = {}): Promise { - return this.withTaskHistoryLock(async () => { - const { broadcast = true } = options - const history = (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) || [] - const existingItemIndex = history.findIndex((h) => h.id === item.id) - const wasExisting = existingItemIndex !== -1 + const { broadcast = true } = options - if (wasExisting) { - // Preserve existing metadata (e.g., delegation fields) unless explicitly overwritten. - // This prevents loss of status/awaitingChildId/delegatedToId when tasks are reopened, - // terminated, or when routine message persistence occurs. - history[existingItemIndex] = { - ...history[existingItemIndex], - ...item, - } - } else { - history.push(item) + const history = await this.taskHistoryStore.upsert(item) + this.recentTasksCache = undefined + + // Broadcast the updated history to the webview if requested. + // Prefer per-item updates to avoid repeatedly cloning/sending the full history. + if (broadcast && this.isViewLaunched) { + const updatedItem = this.taskHistoryStore.get(item.id) ?? item + await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) + } + + return history + } + + /** + * Schedule a debounced write-through of task history to globalState. + * Only used for backward compatibility during the transition period. + * Per-task files are authoritative; globalState is the downgrade fallback. + */ + private scheduleGlobalStateWriteThrough(): void { + if (this.globalStateWriteThroughTimer) { + clearTimeout(this.globalStateWriteThroughTimer) + } + + this.globalStateWriteThroughTimer = setTimeout(async () => { + this.globalStateWriteThroughTimer = null + try { + const items = this.taskHistoryStore.getAll() + await this.updateGlobalState("taskHistory", items) + } catch (err) { + this.log( + `[scheduleGlobalStateWriteThrough] Failed: ${err instanceof Error ? err.message : String(err)}`, + ) } + }, ClineProvider.GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS) + } - await this.updateGlobalState("taskHistory", history) - this.recentTasksCache = undefined + /** + * Flush any pending debounced globalState write-through immediately. + */ + private flushGlobalStateWriteThrough(): void { + if (this.globalStateWriteThroughTimer) { + clearTimeout(this.globalStateWriteThroughTimer) + this.globalStateWriteThroughTimer = null + } - // Broadcast the updated history to the webview if requested. - // Prefer per-item updates to avoid repeatedly cloning/sending the full history. - if (broadcast && this.isViewLaunched) { - const updatedItem = wasExisting ? history[existingItemIndex] : item - await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) - } - - return history + const items = this.taskHistoryStore.getAll() + this.updateGlobalState("taskHistory", items).catch((err) => { + this.log(`[flushGlobalStateWriteThrough] Failed: ${err instanceof Error ? err.message : String(err)}`) }) } /** * Broadcasts a task history update to the webview. * This sends a lightweight message with just the task history, rather than the full state. - * @param history The task history to broadcast (if not provided, reads from global state) + * @param history The task history to broadcast (if not provided, reads from the store) */ public async broadcastTaskHistoryUpdate(history?: HistoryItem[]): Promise { if (!this.isViewLaunched) { return } - const taskHistory = history ?? (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) ?? [] + const taskHistory = history ?? this.taskHistoryStore.getAll() // Sort and filter the history the same way as getStateToPostToWebview const sortedHistory = taskHistory @@ -2865,7 +2916,7 @@ export class ClineProvider return this.recentTasksCache } - const history = this.getGlobalState("taskHistory") ?? [] + const history = this.taskHistoryStore.getAll() const workspaceTasks: HistoryItem[] = [] for (const item of history) { diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 9e4f2fab3a..f24cee0786 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -165,10 +165,23 @@ vi.mock("fs/promises", () => ({ mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), readFile: vi.fn().mockResolvedValue(""), + readdir: vi.fn().mockResolvedValue([]), unlink: vi.fn().mockResolvedValue(undefined), rmdir: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), + rm: vi.fn().mockResolvedValue(undefined), })) +vi.mock("../../../utils/storage", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getStorageBasePath: vi.fn().mockImplementation((defaultPath: string) => defaultPath), + getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"), + getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"), + } +}) + vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { hasInstance: vi.fn().mockReturnValue(true), @@ -191,7 +204,7 @@ describe("ClineProvider - Sticky Mode", () => { let mockWebviewView: vscode.WebviewView let mockPostMessage: any - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() if (!TelemetryService.hasInstance()) { @@ -268,6 +281,9 @@ describe("ClineProvider - Sticky Mode", () => { provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // Wait for the async TaskHistoryStore initialization to complete + await new Promise((resolve) => setTimeout(resolve, 10)) + // Mock getMcpHub method provider.getMcpHub = vi.fn().mockReturnValue({ listTools: vi.fn().mockResolvedValue([]), diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index 2f29d79d0e..0bea9b1c36 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -166,10 +166,23 @@ vi.mock("fs/promises", () => ({ mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), readFile: vi.fn().mockResolvedValue(""), + readdir: vi.fn().mockResolvedValue([]), unlink: vi.fn().mockResolvedValue(undefined), rmdir: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), + rm: vi.fn().mockResolvedValue(undefined), })) +vi.mock("../../../utils/storage", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getStorageBasePath: vi.fn().mockImplementation((defaultPath: string) => defaultPath), + getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"), + getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"), + } +}) + vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { hasInstance: vi.fn().mockReturnValue(true), @@ -192,7 +205,7 @@ describe("ClineProvider - Sticky Provider Profile", () => { let mockWebviewView: vscode.WebviewView let mockPostMessage: any - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() taskIdCounter = 0 @@ -270,6 +283,9 @@ describe("ClineProvider - Sticky Provider Profile", () => { provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // Wait for the async TaskHistoryStore initialization to complete + await new Promise((resolve) => setTimeout(resolve, 10)) + // Mock getMcpHub method provider.getMcpHub = vi.fn().mockReturnValue({ listTools: vi.fn().mockResolvedValue([]), @@ -301,20 +317,16 @@ describe("ClineProvider - Sticky Provider Profile", () => { // Add task to provider stack await provider.addClineToStack(mockTask as any) - // Mock getGlobalState to return task history - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ - { - id: mockTask.taskId, - ts: Date.now(), - task: "Test task", - number: 1, - tokensIn: 0, - tokensOut: 0, - cacheWrites: 0, - cacheReads: 0, - totalCost: 0, - }, - ]) + // Populate the store so persistStickyProviderProfileToCurrentTask finds the task + await provider.taskHistoryStore.upsert({ + id: mockTask.taskId, + ts: Date.now(), + task: "Test task", + number: 1, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }) // Mock updateTaskHistory to track calls const updateTaskHistorySpy = vi @@ -608,20 +620,16 @@ describe("ClineProvider - Sticky Provider Profile", () => { updateApiConfiguration: vi.fn(), } - // Mock getGlobalState to return task history with our task - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ - { - id: mockTask.taskId, - ts: Date.now(), - task: "Test task", - number: 1, - tokensIn: 0, - tokensOut: 0, - cacheWrites: 0, - cacheReads: 0, - totalCost: 0, - }, - ]) + // Populate the store so persistStickyProviderProfileToCurrentTask finds the task + await provider.taskHistoryStore.upsert({ + id: mockTask.taskId, + ts: Date.now(), + task: "Test task", + number: 1, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }) // Mock updateTaskHistory to capture the updated history item let updatedHistoryItem: any @@ -720,7 +728,10 @@ describe("ClineProvider - Sticky Provider Profile", () => { }, ] - vi.spyOn(provider as any, "getGlobalState").mockReturnValue(taskHistory) + // Populate the store + for (const item of taskHistory) { + await provider.taskHistoryStore.upsert(item as any) + } // Mock updateTaskHistory vi.spyOn(provider, "updateTaskHistory").mockImplementation((item) => { @@ -776,20 +787,16 @@ describe("ClineProvider - Sticky Provider Profile", () => { // Add task to provider stack await provider.addClineToStack(mockTask as any) - // Mock getGlobalState - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ - { - id: mockTask.taskId, - ts: Date.now(), - task: "Test task", - number: 1, - tokensIn: 0, - tokensOut: 0, - cacheWrites: 0, - cacheReads: 0, - totalCost: 0, - }, - ]) + // Populate the store + await provider.taskHistoryStore.upsert({ + id: mockTask.taskId, + ts: Date.now(), + task: "Test task", + number: 1, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }) // Mock updateTaskHistory to throw error vi.spyOn(provider, "updateTaskHistory").mockRejectedValue(new Error("Save failed")) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 72a6f83960..b1f29008c4 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -17,8 +17,11 @@ vi.mock("fs/promises", () => ({ mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), readFile: vi.fn().mockResolvedValue(""), + readdir: vi.fn().mockResolvedValue([]), unlink: vi.fn().mockResolvedValue(undefined), rmdir: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), + rm: vi.fn().mockResolvedValue(undefined), })) vi.mock("axios", () => ({ @@ -44,6 +47,11 @@ vi.mock("../../../utils/storage", () => ({ getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"), getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"), getGlobalStoragePath: vi.fn().mockResolvedValue("/test/storage/path"), + getStorageBasePath: vi.fn().mockImplementation((defaultPath: string) => defaultPath), +})) + +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn().mockResolvedValue(undefined), })) vi.mock("@modelcontextprotocol/sdk/types.js", () => ({ @@ -239,7 +247,7 @@ describe("ClineProvider Task History Synchronization", () => { let mockPostMessage: ReturnType let taskHistoryState: HistoryItem[] - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() if (!TelemetryService.hasInstance()) { @@ -316,6 +324,10 @@ describe("ClineProvider Task History Synchronization", () => { provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // Wait for the async TaskHistoryStore initialization to complete + // (fire-and-forget from the constructor; microtasks need to flush) + await new Promise((resolve) => setTimeout(resolve, 10)) + // Mock the custom modes manager ;(provider as any).customModesManager = { updateCustomMode: vi.fn().mockResolvedValue(undefined), @@ -496,18 +508,15 @@ describe("ClineProvider Task History Synchronization", () => { await provider.updateTaskHistory(updatedItem) - // Verify the update was persisted - expect(mockContext.globalState.update).toHaveBeenCalledWith( - "taskHistory", + // Verify the update was persisted in the store + const storeHistory = provider.taskHistoryStore.getAll() + expect(storeHistory).toEqual( expect.arrayContaining([expect.objectContaining({ id: "task-update", task: "Updated task" })]), ) // Should not have duplicates - const allCalls = (mockContext.globalState.update as ReturnType).mock.calls - const lastUpdateCall = allCalls.find((call: any[]) => call[0] === "taskHistory") - const historyArray = lastUpdateCall?.[1] as HistoryItem[] - const matchingItems = historyArray?.filter((item: HistoryItem) => item.id === "task-update") - expect(matchingItems?.length).toBe(1) + const matchingItems = storeHistory.filter((item: HistoryItem) => item.id === "task-update") + expect(matchingItems.length).toBe(1) }) it("returns the updated task history array", async () => { @@ -582,18 +591,14 @@ describe("ClineProvider Task History Synchronization", () => { expect(sentHistory[0].id).toBe("valid") }) - it("reads from global state when no history is provided", async () => { + it("reads from store when no history is provided", async () => { await provider.resolveWebviewView(mockWebviewView) provider.isViewLaunched = true - // Set up task history in global state + // Populate the store with an item const now = Date.now() - const stateHistory: HistoryItem[] = [createHistoryItem({ id: "from-state", ts: now, task: "State task" })] - - // Update the mock to return our history - ;(mockContext.globalState.get as ReturnType).mockImplementation((key: string) => { - if (key === "taskHistory") return stateHistory - return undefined + await provider.updateTaskHistory(createHistoryItem({ id: "from-store", ts: now, task: "Store task" }), { + broadcast: false, }) // Clear previous calls @@ -605,8 +610,8 @@ describe("ClineProvider Task History Synchronization", () => { const call = calls.find((c) => c[0]?.type === "taskHistoryUpdated") const sentHistory = call?.[0]?.taskHistory as HistoryItem[] - expect(sentHistory.length).toBe(1) - expect(sentHistory[0].id).toBe("from-state") + expect(sentHistory.length).toBeGreaterThanOrEqual(1) + expect(sentHistory.some((item) => item.id === "from-store")).toBe(true) }) }) @@ -615,13 +620,18 @@ describe("ClineProvider Task History Synchronization", () => { await provider.resolveWebviewView(mockWebviewView) const now = Date.now() - const multiWorkspaceHistory: HistoryItem[] = [ + + // Populate the store with multi-workspace items + await provider.updateTaskHistory( createHistoryItem({ id: "ws1-task", ts: now, task: "Workspace 1 task", workspace: "/path/to/workspace1", }), + { broadcast: false }, + ) + await provider.updateTaskHistory( createHistoryItem({ id: "ws2-task", ts: now - 1000, @@ -629,6 +639,9 @@ describe("ClineProvider Task History Synchronization", () => { workspace: "/path/to/workspace2", number: 2, }), + { broadcast: false }, + ) + await provider.updateTaskHistory( createHistoryItem({ id: "ws3-task", ts: now - 2000, @@ -636,13 +649,8 @@ describe("ClineProvider Task History Synchronization", () => { workspace: "/different/workspace", number: 3, }), - ] - - // Update the mock to return multi-workspace history - ;(mockContext.globalState.get as ReturnType).mockImplementation((key: string) => { - if (key === "taskHistory") return multiWorkspaceHistory - return undefined - }) + { broadcast: false }, + ) const state = await provider.getStateToPostToWebview() @@ -665,8 +673,8 @@ describe("ClineProvider Task History Synchronization", () => { await Promise.all(items.map((item) => provider.updateTaskHistory(item, { broadcast: false }))) - // All 5 entries must survive - const history = (provider as any).contextProxy.getGlobalState("taskHistory") as HistoryItem[] + // All 5 entries must survive (read from store, not debounced globalState) + const history = provider.taskHistoryStore.getAll() const ids = history.map((h: HistoryItem) => h.id) for (const item of items) { expect(ids).toContain(item.id) @@ -690,34 +698,37 @@ describe("ClineProvider Task History Synchronization", () => { provider.deleteTaskFromState("remove-me"), ]) - const history = (provider as any).contextProxy.getGlobalState("taskHistory") as HistoryItem[] + const history = provider.taskHistoryStore.getAll() const ids = history.map((h: HistoryItem) => h.id) expect(ids).toContain("keep-me") expect(ids).toContain("new-item") expect(ids).not.toContain("remove-me") }) - it("does not block subsequent writes when a previous write errors", async () => { + it("does not block subsequent writes when a previous store write errors", async () => { await provider.resolveWebviewView(mockWebviewView) - // Temporarily make updateGlobalState throw - const origUpdateGlobalState = (provider as any).updateGlobalState.bind(provider) + // Temporarily make the store's safeWriteJson throw + const { safeWriteJson } = await import("../../../utils/safeWriteJson") + const mockSafeWriteJson = vi.mocked(safeWriteJson) let callCount = 0 - ;(provider as any).updateGlobalState = vi.fn().mockImplementation((...args: unknown[]) => { + mockSafeWriteJson.mockImplementation(async () => { callCount++ if (callCount === 1) { - return Promise.reject(new Error("simulated write failure")) + throw new Error("simulated write failure") } - return origUpdateGlobalState(...args) }) - // First call should fail + // First call should fail (store write failure) const item1 = createHistoryItem({ id: "fail-item", task: "Fail" }) await expect(provider.updateTaskHistory(item1, { broadcast: false })).rejects.toThrow( "simulated write failure", ) - // Second call should still succeed (lock not stuck) + // Restore mock + mockSafeWriteJson.mockResolvedValue(undefined) + + // Second call should still succeed (store lock not stuck) const item2 = createHistoryItem({ id: "ok-item", task: "OK" }) const result = await provider.updateTaskHistory(item2, { broadcast: false }) expect(result.some((h) => h.id === "ok-item")).toBe(true) @@ -739,7 +750,7 @@ describe("ClineProvider Task History Synchronization", () => { }), ]) - const history = (provider as any).contextProxy.getGlobalState("taskHistory") as HistoryItem[] + const history = provider.taskHistoryStore.getAll() const item = history.find((h: HistoryItem) => h.id === "race-item") expect(item).toBeDefined() // The second write (tokensIn: 222) should be the last one since writes are serialized diff --git a/src/shared/globalFileNames.ts b/src/shared/globalFileNames.ts index 98b48485f0..0b54ff6809 100644 --- a/src/shared/globalFileNames.ts +++ b/src/shared/globalFileNames.ts @@ -4,4 +4,6 @@ export const GlobalFileNames = { mcpSettings: "mcp_settings.json", customModes: "custom_modes.yaml", taskMetadata: "task_metadata.json", + historyItem: "history_item.json", + historyIndex: "_index.json", } From 5f87f83d974385d106bfbd6ab753660d9f862704 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:30:08 -0700 Subject: [PATCH 018/109] Release v3.49.0 (#11595) * chore: add changeset for v3.49.0 * i18n: translate v3.49.0 announcement strings to all supported languages --------- Co-authored-by: Roo Code --- .changeset/indexing-workspace-opt-in-and-stop-control.md | 9 --------- .changeset/v3.49.0.md | 9 +++++++++ src/core/webview/ClineProvider.ts | 2 +- webview-ui/src/components/chat/Announcement.tsx | 6 +++--- webview-ui/src/i18n/locales/ca/chat.json | 6 +++--- webview-ui/src/i18n/locales/de/chat.json | 6 +++--- webview-ui/src/i18n/locales/en/chat.json | 6 +++--- webview-ui/src/i18n/locales/es/chat.json | 6 +++--- webview-ui/src/i18n/locales/fr/chat.json | 6 +++--- webview-ui/src/i18n/locales/hi/chat.json | 6 +++--- webview-ui/src/i18n/locales/id/chat.json | 6 +++--- webview-ui/src/i18n/locales/it/chat.json | 6 +++--- webview-ui/src/i18n/locales/ja/chat.json | 6 +++--- webview-ui/src/i18n/locales/ko/chat.json | 6 +++--- webview-ui/src/i18n/locales/nl/chat.json | 6 +++--- webview-ui/src/i18n/locales/pl/chat.json | 6 +++--- webview-ui/src/i18n/locales/pt-BR/chat.json | 6 +++--- webview-ui/src/i18n/locales/ru/chat.json | 6 +++--- webview-ui/src/i18n/locales/tr/chat.json | 6 +++--- webview-ui/src/i18n/locales/vi/chat.json | 6 +++--- webview-ui/src/i18n/locales/zh-CN/chat.json | 6 +++--- webview-ui/src/i18n/locales/zh-TW/chat.json | 6 +++--- 22 files changed, 67 insertions(+), 67 deletions(-) delete mode 100644 .changeset/indexing-workspace-opt-in-and-stop-control.md create mode 100644 .changeset/v3.49.0.md diff --git a/.changeset/indexing-workspace-opt-in-and-stop-control.md b/.changeset/indexing-workspace-opt-in-and-stop-control.md deleted file mode 100644 index 27a1fbe0b0..0000000000 --- a/.changeset/indexing-workspace-opt-in-and-stop-control.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"roo-cline": minor ---- - -Add per-workspace indexing opt-in and stop/cancel indexing controls - -- **Per-workspace indexing opt-in**: Indexing no longer auto-starts on every workspace. A new `codeIndexWorkspaceEnabled` flag (stored in `workspaceState`, default: false) requires users to explicitly enable indexing per workspace via a toggle in the CodeIndex popover. The choice is remembered across sessions. -- **Stop/cancel indexing**: Users can stop an in-progress indexing operation via a "Stop Indexing" button. Uses `AbortController`/`AbortSignal` threaded through the orchestrator → scanner pipeline with graceful abort at file and batch boundaries. -- **Disable toggle bug fix**: Unchecking "Enable Codebase Indexing" during active indexing now properly stops the scan via `stopIndexing()` instead of only calling `stopWatcher()`, which left the scanner running asynchronously. diff --git a/.changeset/v3.49.0.md b/.changeset/v3.49.0.md new file mode 100644 index 0000000000..b145977e71 --- /dev/null +++ b/.changeset/v3.49.0.md @@ -0,0 +1,9 @@ +--- +"roo-cline": minor +--- + +- Add file changes panel to track all file modifications per conversation (#11493 by @saneroen, PR #11494 by @saneroen) +- Add per-workspace indexing opt-in and stop/cancel indexing controls (#11455 by @JamesRobert20, PR #11456 by @JamesRobert20) +- Add per-task file-based history store for cross-instance safety (PR #11490 by @roomote) +- Fix: Redesign rehydration scroll lifecycle for smoother chat experience (PR #11483 by @hannesrudolph) +- Fix: Bump @roo-code/types metadata version to 1.111.0 after revert regression (PR #11588 by @roomote) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3095daae7e..0c9112a81a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -169,7 +169,7 @@ export class ClineProvider public isViewLaunched = false public settingsImportedAt?: number - public readonly latestAnnouncementId = "feb-2026-v3.48.0-sonnet-46-stability-locked-config" // v3.48.0 Sonnet 4.6, Stability, Locked API Config + public readonly latestAnnouncementId = "feb-2026-v3.49.0-file-changes-panel-indexing-controls-history-scroll" // v3.49.0 File Changes Panel, Workspace Indexing Controls, History & Scroll Stability public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager diff --git a/webview-ui/src/components/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index 77dfd01a91..7ce523c182 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -44,9 +44,9 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {

{t("chat:announcement.release.heading")}

    -
  • {t("chat:announcement.release.sonnet46")}
  • -
  • {t("chat:announcement.release.stabilityFixes")}
  • -
  • {t("chat:announcement.release.lockedApiConfig")}
  • +
  • {t("chat:announcement.release.fileChangesPanel")}
  • +
  • {t("chat:announcement.release.workspaceIndexing")}
  • +
  • {t("chat:announcement.release.historyAndScroll")}
diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index c0fe2e0abf..0b910a34f0 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "Què hi ha de nou:", - "sonnet46": "Claude Sonnet 4.6: Suport complet per al darrer model Claude Sonnet 4.6 d'Anthropic a tots els proveïdors — Anthropic, Bedrock, Vertex, OpenRouter i Vercel AI Gateway.", - "stabilityFixes": "Millores d'estabilitat: Cicle de vida de delegació reforçat contra condicions de carrera, correcció de la preservació de l'historial de xat durant la navegació i la represa de tasques, i resolució de problemes d'ancoratge de desplaçament per a una experiència més fluida.", - "lockedApiConfig": "Configuració d'API bloquejada: Un nou commutador de bloqueig et permet fixar la configuració de l'API a tots els modes d'un espai de treball, de manera que canviar de mode ja no reinicia la configuració del proveïdor." + "fileChangesPanel": "Panell de canvis de fitxers: Fes un seguiment de totes les modificacions de fitxers fetes durant una conversa en un panell dedicat, facilitant la revisió dels canvis.", + "workspaceIndexing": "Controls d'indexació de l'espai de treball: La indexació ja no s'inicia automàticament — activa-la per espai de treball amb un simple commutador, i atura o cancel·la la indexació en qualsevol moment.", + "historyAndScroll": "Estabilitat de l'historial i el desplaçament: Emmagatzematge d'historial basat en fitxers per tasca per a seguretat entre instàncies, més un cicle de vida de desplaçament redissenyat per a una rehidratació del xat més fluida." }, "cloudAgents": { "heading": "Novetats al núvol:", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index c8aaeac4a6..fd6abb0413 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "Was ist neu:", - "sonnet46": "Claude Sonnet 4.6: Volle Unterstützung für Anthropics neuestes Claude Sonnet 4.6 Modell bei allen Anbietern — Anthropic, Bedrock, Vertex, OpenRouter und Vercel AI Gateway.", - "stabilityFixes": "Stabilitätsverbesserungen: Delegierungs-Lebenszyklus gegen Race Conditions gehärtet, Erhaltung des Chat-Verlaufs bei Navigation und Aufgabenwiederaufnahme behoben und Scroll-Verankerungsprobleme für ein flüssigeres Erlebnis gelöst.", - "lockedApiConfig": "Gesperrte API-Konfiguration: Ein neuer Sperr-Schalter ermöglicht es dir, deine API-Konfiguration über alle Modi in einem Workspace zu fixieren, sodass ein Moduswechsel deine Anbietereinstellungen nicht mehr zurücksetzt." + "fileChangesPanel": "Dateiänderungen-Panel: Verfolge alle Dateiänderungen einer Konversation in einem eigenen Panel – so siehst du auf einen Blick, was sich geändert hat.", + "workspaceIndexing": "Workspace-Indexierung: Die Indexierung startet nicht mehr automatisch – aktiviere sie pro Workspace mit einem einfachen Schalter, und stoppe oder brich die Indexierung jederzeit ab.", + "historyAndScroll": "Verlauf & Scroll-Stabilität: Dateibasierter Verlaufsspeicher pro Aufgabe für Sicherheit über Instanzen hinweg, plus ein überarbeiteter Scroll-Lebenszyklus für flüssigere Chat-Rehydrierung." }, "cloudAgents": { "heading": "Neu in der Cloud:", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 4330895260..cd0864d063 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -369,9 +369,9 @@ }, "release": { "heading": "What's New:", - "sonnet46": "Claude Sonnet 4.6: Full support for Anthropic's latest Claude Sonnet 4.6 model across all providers — Anthropic, Bedrock, Vertex, OpenRouter, and Vercel AI Gateway.", - "stabilityFixes": "Stability Improvements: Hardened delegation lifecycle against race conditions, fixed chat history preservation during navigation and task resume, and resolved scroll anchoring issues for a smoother experience.", - "lockedApiConfig": "Locked API Config: New lock toggle lets you pin your API configuration across all modes in a workspace, so switching modes no longer resets your provider settings." + "fileChangesPanel": "File Changes Panel: Track all file modifications made during a conversation in a dedicated panel, making it easy to review what changed.", + "workspaceIndexing": "Workspace Indexing Controls: Indexing no longer auto-starts — opt in per workspace with a simple toggle, and stop or cancel indexing at any time.", + "historyAndScroll": "History & Scroll Stability: Per-task file-based history store for cross-instance safety, plus a redesigned scroll lifecycle for smoother chat rehydration." }, "cloudAgents": { "heading": "New in the Cloud:", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index d0651ec356..f415294d72 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "Qué hay de nuevo:", - "sonnet46": "Claude Sonnet 4.6: Soporte completo para el último modelo Claude Sonnet 4.6 de Anthropic en todos los proveedores — Anthropic, Bedrock, Vertex, OpenRouter y Vercel AI Gateway.", - "stabilityFixes": "Mejoras de estabilidad: Ciclo de vida de delegación reforzado contra condiciones de carrera, corrección de la preservación del historial de chat durante la navegación y reanudación de tareas, y resolución de problemas de anclaje de desplazamiento para una experiencia más fluida.", - "lockedApiConfig": "Configuración de API bloqueada: Un nuevo interruptor de bloqueo te permite fijar tu configuración de API en todos los modos de un espacio de trabajo, para que cambiar de modo ya no reinicie la configuración del proveedor." + "fileChangesPanel": "Panel de cambios de archivos: Rastrea todas las modificaciones de archivos realizadas durante una conversación en un panel dedicado, facilitando la revisión de los cambios.", + "workspaceIndexing": "Controles de indexación del espacio de trabajo: La indexación ya no se inicia automáticamente — actívala por espacio de trabajo con un simple interruptor, y detén o cancela la indexación en cualquier momento.", + "historyAndScroll": "Estabilidad del historial y desplazamiento: Almacenamiento de historial basado en archivos por tarea para seguridad entre instancias, además de un ciclo de vida de desplazamiento rediseñado para una rehidratación del chat más fluida." }, "cloudAgents": { "heading": "Novedades en la Nube:", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 31177ea137..50e81f1fb1 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "Quoi de neuf :", - "sonnet46": "Claude Sonnet 4.6 : Prise en charge complète du dernier modèle Claude Sonnet 4.6 d'Anthropic sur tous les fournisseurs — Anthropic, Bedrock, Vertex, OpenRouter et Vercel AI Gateway.", - "stabilityFixes": "Améliorations de stabilité : Cycle de vie de délégation renforcé contre les conditions de course, correction de la préservation de l'historique de chat lors de la navigation et de la reprise de tâches, et résolution des problèmes d'ancrage du défilement pour une expérience plus fluide.", - "lockedApiConfig": "Configuration API verrouillée : Un nouveau bouton de verrouillage te permet de fixer ta configuration API sur tous les modes d'un espace de travail, pour que changer de mode ne réinitialise plus tes paramètres de fournisseur." + "fileChangesPanel": "Panneau des modifications de fichiers : Suis toutes les modifications de fichiers effectuées pendant une conversation dans un panneau dédié, pour voir facilement ce qui a changé.", + "workspaceIndexing": "Contrôles d'indexation de l'espace de travail : L'indexation ne démarre plus automatiquement — active-la par espace de travail avec un simple bouton, et arrête ou annule l'indexation à tout moment.", + "historyAndScroll": "Stabilité de l'historique et du défilement : Stockage d'historique basé sur des fichiers par tâche pour la sécurité multi-instances, plus un cycle de vie de défilement repensé pour une réhydratation du chat plus fluide." }, "cloudAgents": { "heading": "Nouveautés dans le Cloud :", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 3ce4f0a45b..22d5c2be6f 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "नया क्या है:", - "sonnet46": "Claude Sonnet 4.6: सभी प्रदाताओं पर Anthropic के नवीनतम Claude Sonnet 4.6 मॉडल का पूर्ण समर्थन — Anthropic, Bedrock, Vertex, OpenRouter, और Vercel AI Gateway।", - "stabilityFixes": "स्थिरता सुधार: रेस कंडीशन के खिलाफ डेलिगेशन जीवनचक्र को मजबूत किया, नेविगेशन और कार्य पुनरारंभ के दौरान चैट इतिहास संरक्षण को ठीक किया, और एक आसान अनुभव के लिए स्क्रॉल एंकरिंग समस्याओं को हल किया।", - "lockedApiConfig": "लॉक्ड API कॉन्फ़िग: एक नया लॉक टॉगल आपको एक वर्कस्पेस में सभी मोड्स में अपनी API कॉन्फ़िगरेशन को पिन करने देता है, ताकि मोड बदलने पर अब आपकी प्रदाता सेटिंग्स रीसेट न हों।" + "fileChangesPanel": "फ़ाइल परिवर्तन पैनल: एक समर्पित पैनल में बातचीत के दौरान की गई सभी फ़ाइल संशोधनों को ट्रैक करो, ताकि क्या बदला यह देखना आसान हो।", + "workspaceIndexing": "वर्कस्पेस इंडेक्सिंग नियंत्रण: इंडेक्सिंग अब स्वचालित रूप से शुरू नहीं होती — एक साधारण टॉगल से प्रति वर्कस्पेस सक्रिय करो, और किसी भी समय इंडेक्सिंग रोको या रद्द करो।", + "historyAndScroll": "इतिहास और स्क्रॉल स्थिरता: क्रॉस-इंस्टेंस सुरक्षा के लिए प्रति-कार्य फ़ाइल-आधारित इतिहास स्टोर, साथ ही चैट रीहाइड्रेशन को आसान बनाने के लिए एक पुनर्डिज़ाइन किया गया स्क्रॉल जीवनचक्र।" }, "cloudAgents": { "heading": "क्लाउड में नया:", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index aa576cf054..437b046425 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -379,9 +379,9 @@ }, "release": { "heading": "Yang Baru:", - "sonnet46": "Claude Sonnet 4.6: Dukungan penuh untuk model Claude Sonnet 4.6 terbaru dari Anthropic di semua penyedia — Anthropic, Bedrock, Vertex, OpenRouter, dan Vercel AI Gateway.", - "stabilityFixes": "Peningkatan Stabilitas: Siklus hidup delegasi diperkuat terhadap race condition, perbaikan pelestarian riwayat chat selama navigasi dan resume tugas, serta penyelesaian masalah penahan scroll untuk pengalaman yang lebih mulus.", - "lockedApiConfig": "Konfigurasi API Terkunci: Tombol kunci baru memungkinkan kamu mengunci konfigurasi API di semua mode dalam workspace, sehingga berpindah mode tidak lagi mengatur ulang pengaturan penyedia." + "fileChangesPanel": "Panel Perubahan File: Lacak semua perubahan file yang dilakukan selama percakapan di panel khusus, sehingga mudah untuk meninjau apa yang berubah.", + "workspaceIndexing": "Kontrol Pengindeksan Workspace: Pengindeksan tidak lagi dimulai otomatis — aktifkan per workspace dengan tombol sederhana, dan hentikan atau batalkan pengindeksan kapan saja.", + "historyAndScroll": "Stabilitas Riwayat & Scroll: Penyimpanan riwayat berbasis file per tugas untuk keamanan lintas instansi, ditambah siklus hidup scroll yang didesain ulang untuk rehidrasi chat yang lebih mulus." }, "cloudAgents": { "heading": "Baru di Cloud:", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index c3da510ac3..5cfde15de9 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "Novità:", - "sonnet46": "Claude Sonnet 4.6: Supporto completo per l'ultimo modello Claude Sonnet 4.6 di Anthropic su tutti i provider — Anthropic, Bedrock, Vertex, OpenRouter e Vercel AI Gateway.", - "stabilityFixes": "Miglioramenti di stabilità: Ciclo di vita della delega rafforzato contro le race condition, correzione della conservazione della cronologia chat durante la navigazione e la ripresa delle attività, e risoluzione dei problemi di ancoraggio dello scroll per un'esperienza più fluida.", - "lockedApiConfig": "Configurazione API bloccata: Un nuovo pulsante di blocco ti permette di fissare la configurazione API su tutti i modi in un workspace, così cambiare modo non reimposta più le impostazioni del provider." + "fileChangesPanel": "Pannello modifiche file: Tieni traccia di tutte le modifiche ai file effettuate durante una conversazione in un pannello dedicato, rendendo facile vedere cosa è cambiato.", + "workspaceIndexing": "Controlli indicizzazione workspace: L'indicizzazione non parte più automaticamente — attivala per workspace con un semplice interruttore, e ferma o annulla l'indicizzazione in qualsiasi momento.", + "historyAndScroll": "Stabilità cronologia e scorrimento: Archivio cronologia basato su file per ogni attività per la sicurezza tra istanze, più un ciclo di vita dello scorrimento ridisegnato per una reidratazione della chat più fluida." }, "cloudAgents": { "heading": "Novità nel Cloud:", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 1746aff184..232b74afed 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "新機能:", - "sonnet46": "Claude Sonnet 4.6: Anthropicの最新モデルClaude Sonnet 4.6をすべてのプロバイダーで完全サポート — Anthropic、Bedrock、Vertex、OpenRouter、Vercel AI Gateway。", - "stabilityFixes": "安定性の改善: レースコンディションに対するデリゲーションライフサイクルの強化、ナビゲーションおよびタスク再開時のチャット履歴保持の修正、よりスムーズな体験のためのスクロールアンカリング問題の解決。", - "lockedApiConfig": "ロックされたAPI設定: 新しいロックトグルにより、ワークスペース内のすべてのモードでAPI設定を固定でき、モードを切り替えてもプロバイダー設定がリセットされなくなりました。" + "fileChangesPanel": "ファイル変更パネル: 会話中に行われたすべてのファイル変更を専用パネルで追跡し、何が変わったかを簡単に確認できます。", + "workspaceIndexing": "ワークスペースインデックス制御: インデックスは自動的に開始されなくなりました — シンプルなトグルでワークスペースごとに有効化し、いつでもインデックスを停止またはキャンセルできます。", + "historyAndScroll": "履歴とスクロールの安定性: インスタンス間の安全性のためのタスクごとのファイルベース履歴ストア、さらにスムーズなチャット再ハイドレーションのための再設計されたスクロールライフサイクル。" }, "cloudAgents": { "heading": "クラウドの新機能:", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index db322a30e5..f2c1ae1599 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "새로운 기능:", - "sonnet46": "Claude Sonnet 4.6: 모든 제공업체에서 Anthropic의 최신 Claude Sonnet 4.6 모델을 완벽 지원 — Anthropic, Bedrock, Vertex, OpenRouter, Vercel AI Gateway.", - "stabilityFixes": "안정성 개선: 레이스 컨디션에 대한 위임 수명주기 강화, 탐색 및 작업 재개 시 채팅 기록 보존 수정, 더 부드러운 경험을 위한 스크롤 앵커링 문제 해결.", - "lockedApiConfig": "잠긴 API 설정: 새로운 잠금 토글로 워크스페이스의 모든 모드에서 API 구성을 고정할 수 있어, 모드를 전환해도 제공업체 설정이 더 이상 초기화되지 않습니다." + "fileChangesPanel": "파일 변경 패널: 대화 중에 이루어진 모든 파일 수정 사항을 전용 패널에서 추적하여, 무엇이 변경되었는지 쉽게 확인할 수 있습니다.", + "workspaceIndexing": "워크스페이스 인덱싱 제어: 인덱싱이 더 이상 자동으로 시작되지 않습니다 — 간단한 토글로 워크스페이스별로 활성화하고, 언제든지 인덱싱을 중지하거나 취소할 수 있습니다.", + "historyAndScroll": "기록 및 스크롤 안정성: 인스턴스 간 안전을 위한 작업별 파일 기반 기록 저장소와 더 부드러운 채팅 리하이드레이션을 위한 재설계된 스크롤 라이프사이클." }, "cloudAgents": { "heading": "클라우드의 새로운 기능:", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 84cddcdac7..2d61daf0eb 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -323,9 +323,9 @@ }, "release": { "heading": "Wat is er nieuw:", - "sonnet46": "Claude Sonnet 4.6: Volledige ondersteuning voor Anthropics nieuwste Claude Sonnet 4.6 model bij alle providers — Anthropic, Bedrock, Vertex, OpenRouter en Vercel AI Gateway.", - "stabilityFixes": "Stabiliteitsverbeteringen: Delegatie-levenscyclus versterkt tegen race conditions, behoud van chatgeschiedenis tijdens navigatie en taakhervatting hersteld, en scroll-verankeringsproblemen opgelost voor een soepelere ervaring.", - "lockedApiConfig": "Vergrendelde API-configuratie: Een nieuwe vergrendelingsknop laat je je API-configuratie vastzetten in alle modi van een workspace, zodat het wisselen van modus je providerinstellingen niet meer reset." + "fileChangesPanel": "Bestandswijzigingen-paneel: Volg alle bestandswijzigingen die tijdens een gesprek zijn gemaakt in een speciaal paneel, zodat je eenvoudig kunt zien wat er is gewijzigd.", + "workspaceIndexing": "Workspace-indexering: Indexering start niet meer automatisch — schakel het per workspace in met een simpele schakelaar, en stop of annuleer de indexering op elk moment.", + "historyAndScroll": "Geschiedenis & scroll-stabiliteit: Bestandsgebaseerde geschiedenisopslag per taak voor veiligheid tussen instanties, plus een herontworpen scroll-levenscyclus voor soepelere chat-rehydratie." }, "cloudAgents": { "heading": "Nieuw in de Cloud:", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index c59a410cf3..1300ffa70b 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "Co nowego:", - "sonnet46": "Claude Sonnet 4.6: Pełne wsparcie dla najnowszego modelu Claude Sonnet 4.6 od Anthropic u wszystkich dostawców — Anthropic, Bedrock, Vertex, OpenRouter i Vercel AI Gateway.", - "stabilityFixes": "Ulepszenia stabilności: Wzmocnienie cyklu życia delegacji przeciwko warunkom wyścigu, naprawa zachowania historii czatu podczas nawigacji i wznawiania zadań oraz rozwiązanie problemów z zakotwiczaniem przewijania dla płynniejszego działania.", - "lockedApiConfig": "Zablokowana konfiguracja API: Nowy przełącznik blokady pozwala przypiąć konfigurację API we wszystkich trybach w workspace, dzięki czemu przełączanie trybów nie resetuje już ustawień dostawcy." + "fileChangesPanel": "Panel zmian plików: Śledź wszystkie modyfikacje plików dokonane podczas rozmowy w dedykowanym panelu, ułatwiając przegląd tego, co się zmieniło.", + "workspaceIndexing": "Kontrola indeksowania workspace: Indeksowanie nie uruchamia się już automatycznie — włącz je per workspace prostym przełącznikiem i zatrzymaj lub anuluj indeksowanie w dowolnym momencie.", + "historyAndScroll": "Stabilność historii i przewijania: Plikowe przechowywanie historii per zadanie dla bezpieczeństwa między instancjami, plus przeprojektowany cykl życia przewijania dla płynniejszej rehydratacji czatu." }, "cloudAgents": { "heading": "Nowości w chmurze:", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 72e463311f..f5282b1600 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "Novidades:", - "sonnet46": "Claude Sonnet 4.6: Suporte completo para o mais recente modelo Claude Sonnet 4.6 da Anthropic em todos os provedores — Anthropic, Bedrock, Vertex, OpenRouter e Vercel AI Gateway.", - "stabilityFixes": "Melhorias de estabilidade: Ciclo de vida de delegação reforçado contra condições de corrida, correção da preservação do histórico de chat durante navegação e retomada de tarefas, e resolução de problemas de ancoragem de rolagem para uma experiência mais suave.", - "lockedApiConfig": "Configuração de API bloqueada: Um novo botão de bloqueio permite fixar sua configuração de API em todos os modos de um workspace, para que trocar de modo não redefina mais suas configurações de provedor." + "fileChangesPanel": "Painel de alterações de arquivos: Acompanhe todas as modificações de arquivos feitas durante uma conversa em um painel dedicado, facilitando a revisão do que mudou.", + "workspaceIndexing": "Controles de indexação do workspace: A indexação não inicia mais automaticamente — ative por workspace com um simples botão, e pare ou cancele a indexação a qualquer momento.", + "historyAndScroll": "Estabilidade do histórico e rolagem: Armazenamento de histórico baseado em arquivos por tarefa para segurança entre instâncias, além de um ciclo de vida de rolagem redesenhado para uma reidratação do chat mais suave." }, "cloudAgents": { "heading": "Novidades na Nuvem:", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 20c658bc9e..09c8e0ecaa 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -324,9 +324,9 @@ }, "release": { "heading": "Что нового:", - "sonnet46": "Claude Sonnet 4.6: Полная поддержка новейшей модели Claude Sonnet 4.6 от Anthropic у всех провайдеров — Anthropic, Bedrock, Vertex, OpenRouter и Vercel AI Gateway.", - "stabilityFixes": "Улучшения стабильности: Укрепление жизненного цикла делегирования от состояний гонки, исправление сохранения истории чата при навигации и возобновлении задач, а также решение проблем привязки прокрутки для более плавной работы.", - "lockedApiConfig": "Заблокированная конфигурация API: Новый переключатель блокировки позволяет закрепить конфигурацию API для всех режимов в рабочем пространстве, чтобы переключение режимов больше не сбрасывало настройки провайдера." + "fileChangesPanel": "Панель изменений файлов: Отслеживай все изменения файлов, сделанные во время разговора, в специальной панели — легко увидеть, что изменилось.", + "workspaceIndexing": "Управление индексацией рабочего пространства: Индексация больше не запускается автоматически — включи её для каждого рабочего пространства простым переключателем и останови или отмени индексацию в любой момент.", + "historyAndScroll": "Стабильность истории и прокрутки: Файловое хранилище истории для каждой задачи для безопасности между экземплярами, плюс переработанный жизненный цикл прокрутки для более плавной регидратации чата." }, "cloudAgents": { "heading": "Новое в облаке:", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index a0dbd54083..03da0dce05 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -351,9 +351,9 @@ }, "release": { "heading": "Yenilikler:", - "sonnet46": "Claude Sonnet 4.6: Anthropic'in en yeni Claude Sonnet 4.6 modeli için tüm sağlayıcılarda tam destek — Anthropic, Bedrock, Vertex, OpenRouter ve Vercel AI Gateway.", - "stabilityFixes": "Kararlılık İyileştirmeleri: Yarış koşullarına karşı delegasyon yaşam döngüsü güçlendirildi, gezinme ve görev devam ettirme sırasında sohbet geçmişi koruması düzeltildi ve daha akıcı bir deneyim için kaydırma sabitleme sorunları çözüldü.", - "lockedApiConfig": "Kilitli API Yapılandırması: Yeni kilit düğmesi, API yapılandırmanı bir çalışma alanındaki tüm modlarda sabitlemenizi sağlar, böylece mod değiştirmek artık sağlayıcı ayarlarını sıfırlamaz." + "fileChangesPanel": "Dosya Değişiklikleri Paneli: Bir konuşma sırasında yapılan tüm dosya değişikliklerini özel bir panelde takip et, neyin değiştiğini kolayca gözden geçir.", + "workspaceIndexing": "Çalışma Alanı İndeksleme Kontrolleri: İndeksleme artık otomatik başlamıyor — basit bir düğmeyle çalışma alanı başına etkinleştir ve istediğin zaman indekslemeyi durdur veya iptal et.", + "historyAndScroll": "Geçmiş ve Kaydırma Kararlılığı: Örnekler arası güvenlik için görev bazlı dosya tabanlı geçmiş deposu, ayrıca daha akıcı sohbet yeniden yüklemesi için yeniden tasarlanmış kaydırma yaşam döngüsü." }, "cloudAgents": { "heading": "Cloud'daki yenilikler:", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index eafd28c90b..cc621b9b1e 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -351,9 +351,9 @@ }, "release": { "heading": "Tính năng mới:", - "sonnet46": "Claude Sonnet 4.6: Hỗ trợ đầy đủ mô hình Claude Sonnet 4.6 mới nhất của Anthropic trên tất cả nhà cung cấp — Anthropic, Bedrock, Vertex, OpenRouter và Vercel AI Gateway.", - "stabilityFixes": "Cải thiện ổn định: Tăng cường vòng đời ủy quyền chống lại điều kiện cạnh tranh, sửa lỗi bảo tồn lịch sử trò chuyện khi điều hướng và tiếp tục tác vụ, đồng thời giải quyết các vấn đề neo cuộn để có trải nghiệm mượt mà hơn.", - "lockedApiConfig": "Cấu hình API đã khóa: Nút khóa mới cho phép bạn ghim cấu hình API trên tất cả các chế độ trong workspace, để việc chuyển chế độ không còn đặt lại cài đặt nhà cung cấp nữa." + "fileChangesPanel": "Bảng thay đổi tệp: Theo dõi tất cả các sửa đổi tệp được thực hiện trong cuộc trò chuyện trong một bảng chuyên dụng, giúp dễ dàng xem lại những gì đã thay đổi.", + "workspaceIndexing": "Điều khiển lập chỉ mục Workspace: Lập chỉ mục không còn tự động bắt đầu — bật cho từng workspace bằng một nút đơn giản, và dừng hoặc hủy lập chỉ mục bất cứ lúc nào.", + "historyAndScroll": "Ổn định lịch sử & cuộn: Lưu trữ lịch sử dựa trên tệp cho từng tác vụ để đảm bảo an toàn giữa các phiên, cộng với vòng đời cuộn được thiết kế lại để tải lại cuộc trò chuyện mượt mà hơn." }, "cloudAgents": { "heading": "Mới trên Cloud:", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 3b92b7f1c3..b0205e89c8 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -351,9 +351,9 @@ }, "release": { "heading": "新增功能:", - "sonnet46": "Claude Sonnet 4.6:全面支持 Anthropic 最新的 Claude Sonnet 4.6 模型,覆盖所有提供商 — Anthropic、Bedrock、Vertex、OpenRouter 和 Vercel AI Gateway。", - "stabilityFixes": "稳定性改进:强化委派生命周期以防止竞态条件,修复导航和任务恢复时的聊天历史保留问题,并解决滚动锚定问题以获得更流畅的体验。", - "lockedApiConfig": "锁定 API 配置:新的锁定开关让你可以在工作区的所有模式中固定 API 配置,这样切换模式不再重置你的提供商设置。" + "fileChangesPanel": "档案变更面板:在专用面板中追踪对话期间所有档案修改,方便查看哪些内容发生了变化。", + "workspaceIndexing": "工作区索引控制:索引不再自动启动——通过简单开关按工作区启用,随时停止或取消索引。", + "historyAndScroll": "历史记录与滚动稳定性:基于文件的按任务历史存储确保跨实例安全,加上重新设计的滚动生命周期让聊天重载更流畅。" }, "cloudAgents": { "heading": "云端新功能:", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 95c1ea51ca..68054a3722 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -372,9 +372,9 @@ }, "release": { "heading": "新增功能:", - "sonnet46": "Claude Sonnet 4.6:全面支援 Anthropic 最新的 Claude Sonnet 4.6 模型,涵蓋所有提供者 — Anthropic、Bedrock、Vertex、OpenRouter 和 Vercel AI Gateway。", - "stabilityFixes": "穩定性改善:強化委派生命週期以防止競態條件,修復導航和工作恢復時的聊天紀錄保留問題,並解決捲動錨定問題以獲得更流暢的體驗。", - "lockedApiConfig": "鎖定 API 設定:新的鎖定開關讓你可以在工作區的所有模式中固定 API 設定,這樣切換模式不再重設你的提供者設定。" + "fileChangesPanel": "檔案變更面板:在專用面板中追蹤對話期間所有檔案修改,方便查看哪些內容發生了變化。", + "workspaceIndexing": "工作區索引控制:索引不再自動啟動——透過簡單開關按工作區啟用,隨時停止或取消索引。", + "historyAndScroll": "歷史紀錄與捲動穩定性:基於檔案的按工作歷史儲存確保跨實例安全,加上重新設計的捲動生命週期讓聊天重載更流暢。" }, "cloudAgents": { "heading": "雲端的新功能:", From c9744433791b868f65a0c150210327ddec0f6ac1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:48:19 -0700 Subject: [PATCH 019/109] Changeset version bump (#11596) changeset version bump Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/v3.49.0.md | 9 --------- CHANGELOG.md | 10 ++++++++++ src/package.json | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) delete mode 100644 .changeset/v3.49.0.md diff --git a/.changeset/v3.49.0.md b/.changeset/v3.49.0.md deleted file mode 100644 index b145977e71..0000000000 --- a/.changeset/v3.49.0.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"roo-cline": minor ---- - -- Add file changes panel to track all file modifications per conversation (#11493 by @saneroen, PR #11494 by @saneroen) -- Add per-workspace indexing opt-in and stop/cancel indexing controls (#11455 by @JamesRobert20, PR #11456 by @JamesRobert20) -- Add per-task file-based history store for cross-instance safety (PR #11490 by @roomote) -- Fix: Redesign rehydration scroll lifecycle for smoother chat experience (PR #11483 by @hannesrudolph) -- Fix: Bump @roo-code/types metadata version to 1.111.0 after revert regression (PR #11588 by @roomote) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d15944f46..4671571a67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Roo Code Changelog +## 3.49.0 + +### Minor Changes + +- Add file changes panel to track all file modifications per conversation (#11493 by @saneroen, PR #11494 by @saneroen) +- Add per-workspace indexing opt-in and stop/cancel indexing controls (#11455 by @JamesRobert20, PR #11456 by @JamesRobert20) +- Add per-task file-based history store for cross-instance safety (PR #11490 by @roomote) +- Fix: Redesign rehydration scroll lifecycle for smoother chat experience (PR #11483 by @hannesrudolph) +- Fix: Bump @roo-code/types metadata version to 1.111.0 after revert regression (PR #11588 by @roomote) + ## 3.48.1 ### Patch Changes diff --git a/src/package.json b/src/package.json index b004958863..236bfe04ac 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.48.1", + "version": "3.49.0", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", From ffec9ac1ec366087b53dcc1a8669616be7267394 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Thu, 19 Feb 2026 00:04:34 -0800 Subject: [PATCH 020/109] feat(cli): NDJSON stdin protocol, list subcommands, modularize run.ts (#11597) * feat(cli): add NDJSON stdin protocol, list subcommands, and modularize run.ts Overhaul the stdin prompt stream from raw text lines to a structured NDJSON command protocol (start/message/cancel/ping/shutdown) with requestId correlation, ack/done/error lifecycle events, and queue telemetry. Add list subcommands (commands, modes, models) for programmatic discovery. Extract stdin stream logic from run.ts into stdin-stream.ts and add shared isRecord guard utility. Includes unit tests for all new modules. Co-Authored-By: Claude Opus 4.6 * fix(core): fix Task.ts bug affecting CLI operation Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- apps/cli/scripts/test-stdin-stream.ts | 24 +- .../json-event-emitter-control.test.ts | 170 +++++ apps/cli/src/agent/json-event-emitter.ts | 81 ++- .../src/commands/cli/__tests__/list.test.ts | 29 + .../cli/__tests__/parse-stdin-command.test.ts | 104 +++ apps/cli/src/commands/cli/index.ts | 1 + apps/cli/src/commands/cli/list.ts | 287 ++++++++ apps/cli/src/commands/cli/run.ts | 195 +----- apps/cli/src/commands/cli/stdin-stream.ts | 610 ++++++++++++++++++ apps/cli/src/index.ts | 47 +- .../src/lib/utils/__tests__/guards.test.ts | 27 + apps/cli/src/lib/utils/guards.ts | 3 + apps/cli/src/types/json-events.ts | 31 + src/core/task/Task.ts | 408 ++++++------ 14 files changed, 1643 insertions(+), 374 deletions(-) create mode 100644 apps/cli/src/agent/__tests__/json-event-emitter-control.test.ts create mode 100644 apps/cli/src/commands/cli/__tests__/list.test.ts create mode 100644 apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts create mode 100644 apps/cli/src/commands/cli/list.ts create mode 100644 apps/cli/src/commands/cli/stdin-stream.ts create mode 100644 apps/cli/src/lib/utils/__tests__/guards.test.ts create mode 100644 apps/cli/src/lib/utils/guards.ts diff --git a/apps/cli/scripts/test-stdin-stream.ts b/apps/cli/scripts/test-stdin-stream.ts index 5212df5b33..569c30adbb 100644 --- a/apps/cli/scripts/test-stdin-stream.ts +++ b/apps/cli/scripts/test-stdin-stream.ts @@ -27,6 +27,16 @@ async function main() { console.log("[wrapper] Type a message and press Enter to send it.") console.log("[wrapper] Type /exit to close stdin and let the CLI finish.") + let requestCounter = 0 + let hasStartedTask = false + + const sendCommand = (payload: Record) => { + if (child.stdin?.destroyed) { + return + } + child.stdin?.write(JSON.stringify(payload) + "\n") + } + const rl = readline.createInterface({ input: process.stdin, output: process.stdout, @@ -36,14 +46,22 @@ async function main() { rl.on("line", (line) => { if (line.trim() === "/exit") { console.log("[wrapper] Closing stdin...") + sendCommand({ + command: "shutdown", + requestId: `shutdown-${Date.now()}-${++requestCounter}`, + }) child.stdin?.end() rl.close() return } - if (!child.stdin?.destroyed) { - child.stdin?.write(`${line}\n`) - } + const command = hasStartedTask ? "message" : "start" + sendCommand({ + command, + requestId: `${command}-${Date.now()}-${++requestCounter}`, + prompt: line, + }) + hasStartedTask = true }) const onSignal = (signal: NodeJS.Signals) => { diff --git a/apps/cli/src/agent/__tests__/json-event-emitter-control.test.ts b/apps/cli/src/agent/__tests__/json-event-emitter-control.test.ts new file mode 100644 index 0000000000..8d45538ce3 --- /dev/null +++ b/apps/cli/src/agent/__tests__/json-event-emitter-control.test.ts @@ -0,0 +1,170 @@ +import { Writable } from "stream" + +import { JsonEventEmitter } from "../json-event-emitter.js" + +function createMockStdout(): { stdout: NodeJS.WriteStream; lines: () => Record[] } { + const chunks: string[] = [] + + const writable = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()) + callback() + }, + }) as unknown as NodeJS.WriteStream + + // Each write is a JSON line terminated by \n + const lines = () => + chunks + .join("") + .split("\n") + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as Record) + + return { stdout: writable, lines } +} + +describe("JsonEventEmitter control events", () => { + describe("emitControl", () => { + it("emits an ack event with type control", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + emitter.emitControl({ + subtype: "ack", + requestId: "req-1", + command: "start", + content: "starting task", + code: "accepted", + success: true, + }) + + const output = lines() + expect(output).toHaveLength(1) + expect(output[0]!).toMatchObject({ + type: "control", + subtype: "ack", + requestId: "req-1", + command: "start", + content: "starting task", + code: "accepted", + success: true, + }) + expect(output[0]!.done).toBeUndefined() + }) + + it("sets done: true for done events", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + emitter.emitControl({ + subtype: "done", + requestId: "req-2", + command: "start", + content: "task completed", + code: "task_completed", + success: true, + }) + + const output = lines() + expect(output[0]!).toMatchObject({ type: "control", subtype: "done", done: true }) + }) + + it("does not set done for error events", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + emitter.emitControl({ + subtype: "error", + requestId: "req-3", + command: "start", + content: "something went wrong", + code: "task_error", + success: false, + }) + + const output = lines() + expect(output[0]!.done).toBeUndefined() + expect(output[0]!.success).toBe(false) + }) + }) + + describe("requestIdProvider", () => { + it("injects requestId from provider when event has none", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ + mode: "stream-json", + stdout, + requestIdProvider: () => "injected-id", + }) + + emitter.emitControl({ subtype: "ack", content: "test" }) + + const output = lines() + expect(output[0]!.requestId).toBe("injected-id") + }) + + it("keeps explicit requestId when provider also returns one", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ + mode: "stream-json", + stdout, + requestIdProvider: () => "provider-id", + }) + + emitter.emitControl({ subtype: "ack", requestId: "explicit-id", content: "test" }) + + const output = lines() + expect(output[0]!.requestId).toBe("explicit-id") + }) + + it("omits requestId when provider returns undefined and event has none", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ + mode: "stream-json", + stdout, + requestIdProvider: () => undefined, + }) + + emitter.emitControl({ subtype: "ack", content: "test" }) + + const output = lines() + expect(output[0]!).not.toHaveProperty("requestId") + }) + }) + + describe("emitInit", () => { + it("emits system init with default schema values", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + // emitInit requires a client — we call emitControl to test init-like fields instead. + // emitInit is called internally by attach(), so we test the init fields via options. + // Instead, directly verify the constructor defaults by emitting a control event + // and checking that the emitter was created with correct defaults. + + // We can't call emitInit without a client, but we can verify the options + // were stored correctly by checking what emitControl produces. + emitter.emitControl({ subtype: "ack", content: "test" }) + + // The control event itself doesn't include schema fields, but at least + // we verify the emitter was constructed successfully with defaults. + const output = lines() + expect(output).toHaveLength(1) + }) + + it("accepts custom schemaVersion, protocol, and capabilities", () => { + const { stdout } = createMockStdout() + + // Should not throw when constructed with custom values + const emitter = new JsonEventEmitter({ + mode: "stream-json", + stdout, + schemaVersion: 2, + protocol: "custom-protocol", + capabilities: ["stdin:start", "stdin:message"], + }) + + expect(emitter).toBeDefined() + }) + }) +}) diff --git a/apps/cli/src/agent/json-event-emitter.ts b/apps/cli/src/agent/json-event-emitter.ts index bdf96a763d..b772b13553 100644 --- a/apps/cli/src/agent/json-event-emitter.ts +++ b/apps/cli/src/agent/json-event-emitter.ts @@ -16,7 +16,7 @@ import type { ClineMessage } from "@roo-code/types" -import type { JsonEvent, JsonEventCost, JsonFinalOutput } from "@/types/json-events.js" +import type { JsonEvent, JsonEventCost, JsonEventQueueItem, JsonFinalOutput } from "@/types/json-events.js" import type { ExtensionClient } from "./extension-client.js" import type { AgentStateChangeEvent, TaskCompletedEvent } from "./events.js" @@ -30,6 +30,14 @@ export interface JsonEventEmitterOptions { mode: "json" | "stream-json" /** Output stream (defaults to process.stdout) */ stdout?: NodeJS.WriteStream + /** Optional request id provider for correlating stream events */ + requestIdProvider?: () => string | undefined + /** Transport schema version emitted in system:init */ + schemaVersion?: number + /** Transport protocol identifier emitted in system:init */ + protocol?: string + /** Supported stdin protocol capabilities emitted in system:init */ + capabilities?: string[] } /** @@ -89,17 +97,33 @@ export class JsonEventEmitter { private events: JsonEvent[] = [] private unsubscribers: (() => void)[] = [] private lastCost: JsonEventCost | undefined + private requestIdProvider: () => string | undefined + private schemaVersion: number + private protocol: string + private capabilities: string[] private seenMessageIds = new Set() // Track previous content for delta computation private previousContent = new Map() // Track the completion result content private completionResultContent: string | undefined + // Track the latest assistant text as a fallback for result.content. + private lastAssistantText: string | undefined // The first non-partial "say:text" per task is the echoed user prompt. private expectPromptEchoAsUser = true constructor(options: JsonEventEmitterOptions) { this.mode = options.mode this.stdout = options.stdout ?? process.stdout + this.requestIdProvider = options.requestIdProvider ?? (() => undefined) + this.schemaVersion = options.schemaVersion ?? 1 + this.protocol = options.protocol ?? "roo-cli-stream" + this.capabilities = options.capabilities ?? [ + "stdin:start", + "stdin:message", + "stdin:cancel", + "stdin:ping", + "stdin:shutdown", + ] } /** @@ -120,6 +144,48 @@ export class JsonEventEmitter { type: "system", subtype: "init", content: "Task started", + schemaVersion: this.schemaVersion, + protocol: this.protocol, + capabilities: this.capabilities, + }) + } + + emitControl(event: { + subtype: "ack" | "done" | "error" + requestId?: string + command?: string + taskId?: string + content?: string + success?: boolean + code?: string + }): void { + this.emitEvent({ + type: "control", + subtype: event.subtype, + requestId: event.requestId, + command: event.command, + taskId: event.taskId, + content: event.content, + success: event.success, + code: event.code, + done: event.subtype === "done" ? true : undefined, + }) + } + + emitQueue(event: { + subtype: "snapshot" | "enqueued" | "dequeued" | "drained" | "updated" + taskId?: string + content?: string + queueDepth: number + queue: JsonEventQueueItem[] + }): void { + this.emitEvent({ + type: "queue", + subtype: event.subtype, + taskId: event.taskId, + content: event.content, + queueDepth: event.queueDepth, + queue: event.queue, }) } @@ -248,6 +314,9 @@ export class JsonEventEmitter { } } else { this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone)) + if (msg.text) { + this.lastAssistantText = msg.text + } } break @@ -387,7 +456,7 @@ export class JsonEventEmitter { */ private handleTaskCompleted(event: TaskCompletedEvent): void { // Use tracked completion result content, falling back to event message - const resultContent = this.completionResultContent || event.message?.text + const resultContent = this.completionResultContent || event.message?.text || this.lastAssistantText this.emitEvent({ type: "result", @@ -421,10 +490,13 @@ export class JsonEventEmitter { * For json mode: accumulate for final output */ private emitEvent(event: JsonEvent): void { - this.events.push(event) + const requestId = event.requestId ?? this.requestIdProvider() + const payload = requestId ? { ...event, requestId } : event + + this.events.push(payload) if (this.mode === "stream-json") { - this.outputLine(event) + this.outputLine(payload) } } @@ -466,6 +538,7 @@ export class JsonEventEmitter { this.seenMessageIds.clear() this.previousContent.clear() this.completionResultContent = undefined + this.lastAssistantText = undefined this.expectPromptEchoAsUser = true } } diff --git a/apps/cli/src/commands/cli/__tests__/list.test.ts b/apps/cli/src/commands/cli/__tests__/list.test.ts new file mode 100644 index 0000000000..09e0502244 --- /dev/null +++ b/apps/cli/src/commands/cli/__tests__/list.test.ts @@ -0,0 +1,29 @@ +import { parseFormat } from "../list.js" + +describe("parseFormat", () => { + it("defaults to json when undefined", () => { + expect(parseFormat(undefined)).toBe("json") + }) + + it("returns json for 'json'", () => { + expect(parseFormat("json")).toBe("json") + }) + + it("returns text for 'text'", () => { + expect(parseFormat("text")).toBe("text") + }) + + it("is case-insensitive", () => { + expect(parseFormat("JSON")).toBe("json") + expect(parseFormat("Text")).toBe("text") + expect(parseFormat("TEXT")).toBe("text") + }) + + it("throws on invalid format", () => { + expect(() => parseFormat("xml")).toThrow('Invalid format: xml. Must be "json" or "text".') + }) + + it("throws on empty string", () => { + expect(() => parseFormat("")).toThrow("Invalid format") + }) +}) diff --git a/apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts b/apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts new file mode 100644 index 0000000000..81b9d06b8b --- /dev/null +++ b/apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts @@ -0,0 +1,104 @@ +import { parseStdinStreamCommand } from "../stdin-stream.js" + +describe("parseStdinStreamCommand", () => { + describe("valid commands", () => { + it("parses a start command", () => { + const result = parseStdinStreamCommand( + JSON.stringify({ command: "start", requestId: "req-1", prompt: "hello" }), + 1, + ) + expect(result).toEqual({ command: "start", requestId: "req-1", prompt: "hello" }) + }) + + it("parses a message command", () => { + const result = parseStdinStreamCommand( + JSON.stringify({ command: "message", requestId: "req-2", prompt: "follow up" }), + 1, + ) + expect(result).toEqual({ command: "message", requestId: "req-2", prompt: "follow up" }) + }) + + it.each(["cancel", "ping", "shutdown"] as const)("parses a %s command (no prompt required)", (command) => { + const result = parseStdinStreamCommand(JSON.stringify({ command, requestId: "req-3" }), 1) + expect(result).toEqual({ command, requestId: "req-3" }) + }) + + it("trims whitespace from requestId", () => { + const result = parseStdinStreamCommand(JSON.stringify({ command: "ping", requestId: " req-4 " }), 1) + expect(result.requestId).toBe("req-4") + }) + + it("ignores extra fields", () => { + const result = parseStdinStreamCommand( + JSON.stringify({ command: "ping", requestId: "req-5", extra: "ignored", nested: { a: 1 } }), + 1, + ) + expect(result).toEqual({ command: "ping", requestId: "req-5" }) + }) + }) + + describe("invalid input", () => { + it("throws on invalid JSON", () => { + expect(() => parseStdinStreamCommand("not json", 3)).toThrow("stdin command line 3: invalid JSON") + }) + + it("throws on non-object JSON (string)", () => { + expect(() => parseStdinStreamCommand('"hello"', 1)).toThrow("expected JSON object") + }) + + it("throws on non-object JSON (array)", () => { + // Arrays pass isRecord (typeof [] === "object") but lack a command field + expect(() => parseStdinStreamCommand("[]", 1)).toThrow('missing string "command"') + }) + + it("throws on non-object JSON (number)", () => { + expect(() => parseStdinStreamCommand("42", 1)).toThrow("expected JSON object") + }) + + it("throws on null", () => { + expect(() => parseStdinStreamCommand("null", 1)).toThrow("expected JSON object") + }) + + it("throws when command field is missing", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ requestId: "req" }), 5)).toThrow( + 'stdin command line 5: missing string "command"', + ) + }) + + it("throws when command is not a string", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: 123, requestId: "req" }), 1)).toThrow( + 'missing string "command"', + ) + }) + + it("throws on unsupported command name", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: "unknown", requestId: "req" }), 2)).toThrow( + 'stdin command line 2: unsupported command "unknown"', + ) + }) + + it("throws when requestId is missing", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: "ping" }), 1)).toThrow( + 'missing non-empty string "requestId"', + ) + }) + + it("throws when requestId is empty", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: "ping", requestId: " " }), 1)).toThrow( + 'missing non-empty string "requestId"', + ) + }) + + it("throws when start command has no prompt", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: "start", requestId: "req" }), 1)).toThrow( + '"start" requires non-empty string "prompt"', + ) + }) + + it("throws when message command has empty prompt", () => { + expect(() => + parseStdinStreamCommand(JSON.stringify({ command: "message", requestId: "req", prompt: " " }), 1), + ).toThrow('"message" requires non-empty string "prompt"') + }) + }) +}) diff --git a/apps/cli/src/commands/cli/index.ts b/apps/cli/src/commands/cli/index.ts index 89e8e9f1ba..629c665a75 100644 --- a/apps/cli/src/commands/cli/index.ts +++ b/apps/cli/src/commands/cli/index.ts @@ -1 +1,2 @@ export * from "./run.js" +export * from "./list.js" diff --git a/apps/cli/src/commands/cli/list.ts b/apps/cli/src/commands/cli/list.ts new file mode 100644 index 0000000000..8d8e779c3a --- /dev/null +++ b/apps/cli/src/commands/cli/list.ts @@ -0,0 +1,287 @@ +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" + +import pWaitFor from "p-wait-for" + +import type { Command, ModelRecord, WebviewMessage } from "@roo-code/types" +import { getProviderDefaultModelId } from "@roo-code/types" + +import { ExtensionHost, type ExtensionHostOptions } from "@/agent/index.js" +import { loadToken } from "@/lib/storage/index.js" +import { getDefaultExtensionPath } from "@/lib/utils/extension.js" +import { getApiKeyFromEnv } from "@/lib/utils/provider.js" +import { isRecord } from "@/lib/utils/guards.js" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const REQUEST_TIMEOUT_MS = 10_000 + +type ListFormat = "json" | "text" + +type BaseListOptions = { + workspace?: string + extension?: string + apiKey?: string + format?: string + debug?: boolean +} + +type CommandLike = Pick +type ModeLike = { slug: string; name: string } + +export function parseFormat(rawFormat: string | undefined): ListFormat { + const format = (rawFormat ?? "json").toLowerCase() + if (format === "json" || format === "text") { + return format + } + + throw new Error(`Invalid format: ${rawFormat}. Must be "json" or "text".`) +} + +function resolveWorkspacePath(workspace: string | undefined): string { + const resolved = workspace ? path.resolve(workspace) : process.cwd() + + if (!fs.existsSync(resolved)) { + throw new Error(`Workspace path does not exist: ${resolved}`) + } + + return resolved +} + +function resolveExtensionPath(extension: string | undefined): string { + const resolved = path.resolve(extension || getDefaultExtensionPath(__dirname)) + + if (!fs.existsSync(path.join(resolved, "extension.js"))) { + throw new Error(`Extension bundle not found at: ${resolved}`) + } + + return resolved +} + +function outputJson(data: unknown): void { + process.stdout.write(JSON.stringify(data, null, 2) + "\n") +} + +function outputCommandsText(commands: CommandLike[]): void { + for (const command of commands) { + const description = command.description ? ` - ${command.description}` : "" + process.stdout.write(`/${command.name} (${command.source})${description}\n`) + } +} + +function outputModesText(modes: ModeLike[]): void { + for (const mode of modes) { + process.stdout.write(`${mode.slug}\t${mode.name}\n`) + } +} + +function outputModelsText(models: ModelRecord): void { + for (const modelId of Object.keys(models).sort()) { + process.stdout.write(`${modelId}\n`) + } +} + +async function createListHost(options: BaseListOptions): Promise { + const workspacePath = resolveWorkspacePath(options.workspace) + const extensionPath = resolveExtensionPath(options.extension) + const apiKey = options.apiKey || (await loadToken()) || getApiKeyFromEnv("roo") + + const extensionHostOptions: ExtensionHostOptions = { + mode: "code", + reasoningEffort: undefined, + user: null, + provider: "roo", + model: getProviderDefaultModelId("roo"), + apiKey, + workspacePath, + extensionPath, + nonInteractive: true, + ephemeral: true, + debug: options.debug ?? false, + exitOnComplete: true, + exitOnError: false, + disableOutput: true, + } + + const host = new ExtensionHost(extensionHostOptions) + await host.activate() + + // Best effort wait; mode/commands requests can still succeed without this. + await pWaitFor(() => host.client.isInitialized(), { + interval: 25, + timeout: 2_000, + }).catch(() => undefined) + + return host +} + +/** + * Send a request to the extension and wait for a matching response message. + * Returns `undefined` from `extract` to skip non-matching messages, or the + * parsed value to resolve the promise. + */ +function requestFromExtension( + host: ExtensionHost, + requestType: WebviewMessage["type"], + extract: (message: Record) => T | undefined, +): Promise { + return new Promise((resolve, reject) => { + let settled = false + + const cleanup = () => { + clearTimeout(timeoutId) + host.off("extensionWebviewMessage", onMessage) + offError() + } + + const finish = (fn: () => void) => { + if (settled) return + settled = true + cleanup() + fn() + } + + const onMessage = (message: unknown) => { + if (!isRecord(message)) { + return + } + + let result: T | undefined + try { + result = extract(message) + } catch (error) { + finish(() => reject(error instanceof Error ? error : new Error(String(error)))) + return + } + + if (result !== undefined) { + finish(() => resolve(result)) + } + } + + const offError = host.client.on("error", (error) => { + finish(() => reject(error)) + }) + + const timeoutId = setTimeout(() => { + finish(() => + reject(new Error(`Timed out waiting for ${requestType} response after ${REQUEST_TIMEOUT_MS}ms`)), + ) + }, REQUEST_TIMEOUT_MS) + + host.on("extensionWebviewMessage", onMessage) + host.sendToExtension({ type: requestType }) + }) +} + +function requestCommands(host: ExtensionHost): Promise { + return requestFromExtension(host, "requestCommands", (message) => { + if (message.type !== "commands") { + return undefined + } + return Array.isArray(message.commands) ? (message.commands as CommandLike[]) : [] + }) +} + +function requestModes(host: ExtensionHost): Promise { + return requestFromExtension(host, "requestModes", (message) => { + if (message.type !== "modes") { + return undefined + } + return Array.isArray(message.modes) ? (message.modes as ModeLike[]) : [] + }) +} + +function requestRooModels(host: ExtensionHost): Promise { + return requestFromExtension(host, "requestRooModels", (message) => { + if (message.type !== "singleRouterModelFetchResponse") { + return undefined + } + + const values = isRecord(message.values) ? message.values : undefined + if (values?.provider !== "roo") { + return undefined + } + + if (message.success === false) { + const errorMessage = + typeof message.error === "string" && message.error.length > 0 + ? message.error + : "Failed to fetch Roo models" + throw new Error(errorMessage) + } + + return isRecord(values.models) ? (values.models as ModelRecord) : {} + }) +} + +async function withHostAndSignalHandlers( + options: BaseListOptions, + fn: (host: ExtensionHost) => Promise, +): Promise { + const host = await createListHost(options) + + const shutdown = async (exitCode: number) => { + await host.dispose() + process.exit(exitCode) + } + + const onSigint = () => void shutdown(130) + const onSigterm = () => void shutdown(143) + + process.on("SIGINT", onSigint) + process.on("SIGTERM", onSigterm) + + try { + return await fn(host) + } finally { + process.off("SIGINT", onSigint) + process.off("SIGTERM", onSigterm) + await host.dispose() + } +} + +export async function listCommands(options: BaseListOptions): Promise { + const format = parseFormat(options.format) + + await withHostAndSignalHandlers(options, async (host) => { + const commands = await requestCommands(host) + + if (format === "json") { + outputJson({ commands }) + return + } + + outputCommandsText(commands) + }) +} + +export async function listModes(options: BaseListOptions): Promise { + const format = parseFormat(options.format) + + await withHostAndSignalHandlers(options, async (host) => { + const modes = await requestModes(host) + + if (format === "json") { + outputJson({ modes }) + return + } + + outputModesText(modes) + }) +} + +export async function listModels(options: BaseListOptions): Promise { + const format = parseFormat(options.format) + + await withHostAndSignalHandlers(options, async (host) => { + const models = await requestRooModels(host) + + if (format === "json") { + outputJson({ models }) + return + } + + outputModelsText(models) + }) +} diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 365febb9f8..b72e4e7283 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -1,10 +1,8 @@ import fs from "fs" import path from "path" -import { createInterface } from "readline" import { fileURLToPath } from "url" import { createElement } from "react" -import pWaitFor from "p-wait-for" import { setLogger } from "@roo-code/vscode-shim" @@ -29,27 +27,10 @@ import { getDefaultExtensionPath } from "@/lib/utils/extension.js" import { VERSION } from "@/lib/utils/version.js" import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js" +import { runStdinStreamMode } from "./stdin-stream.js" const __dirname = path.dirname(fileURLToPath(import.meta.url)) -async function* readPromptsFromStdinLines(): AsyncGenerator { - const lineReader = createInterface({ - input: process.stdin, - crlfDelay: Infinity, - terminal: false, - }) - - try { - for await (const line of lineReader) { - if (line.trim()) { - yield line - } - } - } finally { - lineReader.close() - } -} - export async function run(promptArg: string | undefined, flagOptions: FlagOptions) { setLogger({ info: () => {}, @@ -211,19 +192,27 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption if (flagOptions.stdinPromptStream && !flagOptions.print) { console.error("[CLI] Error: --stdin-prompt-stream requires --print mode") - console.error("[CLI] Usage: roo --print --stdin-prompt-stream [options]") + console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]") + process.exit(1) + } + + if (flagOptions.stdinPromptStream && outputFormat !== "stream-json") { + console.error("[CLI] Error: --stdin-prompt-stream requires --output-format=stream-json") + console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]") process.exit(1) } if (flagOptions.stdinPromptStream && process.stdin.isTTY) { console.error("[CLI] Error: --stdin-prompt-stream requires piped stdin") - console.error("[CLI] Example: printf '1+1=?\\n10!=?\\n' | roo --print --stdin-prompt-stream [options]") + console.error( + '[CLI] Example: printf \'{"command":"start","requestId":"1","prompt":"1+1=?"}\\n\' | roo --print --output-format stream-json --stdin-prompt-stream [options]', + ) process.exit(1) } if (flagOptions.stdinPromptStream && prompt) { console.error("[CLI] Error: cannot use positional prompt or --prompt-file with --stdin-prompt-stream") - console.error("[CLI] Usage: roo --print --stdin-prompt-stream [options]") + console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]") process.exit(1) } @@ -234,7 +223,9 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption if (flagOptions.print) { console.error("[CLI] Error: no prompt provided") console.error("[CLI] Usage: roo --print [options] ") - console.error("[CLI] For stdin control mode: roo --print --stdin-prompt-stream [options]") + console.error( + "[CLI] For stdin control mode: roo --print --output-format stream-json --stdin-prompt-stream [options]", + ) } else { console.error("[CLI] Error: prompt is required in non-interactive mode") console.error("[CLI] Usage: roo [options]") @@ -281,9 +272,13 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption extensionHostOptions.disableOutput = useJsonOutput const host = new ExtensionHost(extensionHostOptions) + let streamRequestId: string | undefined const jsonEmitter = useJsonOutput - ? new JsonEventEmitter({ mode: outputFormat as "json" | "stream-json" }) + ? new JsonEventEmitter({ + mode: outputFormat as "json" | "stream-json", + requestIdProvider: () => streamRequestId, + }) : null async function shutdown(signal: string, exitCode: number): Promise { @@ -306,151 +301,17 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption } if (useStdinPromptStream) { - let hasReceivedStdinPrompt = false - // stdin stream mode may start at most one task in this process. - let startedTaskFromStdin = false - let activeTaskPromise: Promise | null = null - let fatalStreamError: Error | null = null - // Extension-owned queue depth mirrored from state pushes. - // CLI does not maintain its own prompt queue. - let extensionQueueDepth = 0 - - const waitForInitialState = async () => { - // Give the extension a brief chance to publish initial state so - // we can continue an existing task instead of creating a new one. - await pWaitFor( - () => { - if (fatalStreamError) { - throw fatalStreamError - } - - return host.client.isInitialized() - }, - { interval: 25, timeout: 2_000 }, - ).catch(() => { - // Best-effort wait only; continuing preserves previous behavior. - }) - - if (fatalStreamError) { - throw fatalStreamError - } + if (!jsonEmitter || outputFormat !== "stream-json") { + throw new Error("--stdin-prompt-stream requires --output-format=stream-json to emit control events") } - const waitForActiveTask = async () => { - await pWaitFor( - () => { - if (fatalStreamError) { - throw fatalStreamError - } - - if (!host.client.hasActiveTask()) { - if (!activeTaskPromise && startedTaskFromStdin) { - throw new Error("task is no longer active; cannot continue conversation from stdin") - } - - return false - } - - return true - }, - { interval: 25, timeout: 5_000 }, - ) - } - - const startInitialTask = async (taskPrompt: string) => { - startedTaskFromStdin = true - - activeTaskPromise = host - .runTask(taskPrompt) - .catch((error) => { - fatalStreamError = error instanceof Error ? error : new Error(String(error)) - }) - .finally(() => { - activeTaskPromise = null - }) - - await waitForActiveTask() - } - - const enqueueContinuation = async (text: string) => { - if (!host.client.hasActiveTask()) { - await waitForActiveTask() - } - - // Delegate ordering/drain behavior to the extension message queue. - host.sendToExtension({ type: "queueMessage", text }) - } - - const offClientError = host.client.on("error", (error) => { - fatalStreamError = error + await runStdinStreamMode({ + host, + jsonEmitter, + setStreamRequestId: (id) => { + streamRequestId = id + }, }) - - const onExtensionMessage = (message: { type?: string; state?: { messageQueue?: unknown } }) => { - if (message.type !== "state") { - return - } - - const messageQueue = message.state?.messageQueue - extensionQueueDepth = Array.isArray(messageQueue) ? messageQueue.length : 0 - } - - host.on("extensionWebviewMessage", onExtensionMessage) - - try { - await waitForInitialState() - - for await (const stdinPrompt of readPromptsFromStdinLines()) { - hasReceivedStdinPrompt = true - - // Start once, then always continue via extension queue. - if (!host.client.hasActiveTask() && !startedTaskFromStdin) { - await startInitialTask(stdinPrompt) - } else { - await enqueueContinuation(stdinPrompt) - } - - if (fatalStreamError) { - throw fatalStreamError - } - } - - if (!hasReceivedStdinPrompt) { - throw new Error("no prompt provided via stdin") - } - - await pWaitFor( - () => { - if (fatalStreamError) { - throw fatalStreamError - } - - const isSettled = - !host.client.hasActiveTask() && !activeTaskPromise && extensionQueueDepth === 0 - - if (isSettled) { - return true - } - - if (host.isWaitingForInput() && extensionQueueDepth === 0) { - const currentAsk = host.client.getCurrentAsk() - - if (currentAsk === "completion_result") { - return true - } - - if (currentAsk) { - throw new Error(`stdin ended while task was waiting for input (${currentAsk})`) - } - } - - return false - }, - { interval: 50 }, - ) - } finally { - offClientError() - host.off("extensionWebviewMessage", onExtensionMessage) - } } else { await host.runTask(prompt!) } diff --git a/apps/cli/src/commands/cli/stdin-stream.ts b/apps/cli/src/commands/cli/stdin-stream.ts new file mode 100644 index 0000000000..dceca2e84d --- /dev/null +++ b/apps/cli/src/commands/cli/stdin-stream.ts @@ -0,0 +1,610 @@ +import { createInterface } from "readline" + +import { isRecord } from "@/lib/utils/guards.js" + +import type { ExtensionHost } from "@/agent/index.js" +import type { JsonEventEmitter } from "@/agent/json-event-emitter.js" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type StdinStreamCommandName = "start" | "message" | "cancel" | "ping" | "shutdown" + +export type StdinStreamCommand = + | { command: "start"; requestId: string; prompt: string } + | { command: "message"; requestId: string; prompt: string } + | { command: "cancel"; requestId: string } + | { command: "ping"; requestId: string } + | { command: "shutdown"; requestId: string } + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +export const VALID_STDIN_COMMANDS = new Set(["start", "message", "cancel", "ping", "shutdown"]) + +export function parseStdinStreamCommand(line: string, lineNumber: number): StdinStreamCommand { + let parsed: unknown + + try { + parsed = JSON.parse(line) + } catch { + throw new Error(`stdin command line ${lineNumber}: invalid JSON`) + } + + if (!isRecord(parsed)) { + throw new Error(`stdin command line ${lineNumber}: expected JSON object`) + } + + const commandRaw = parsed.command + const requestIdRaw = parsed.requestId + + if (typeof commandRaw !== "string") { + throw new Error(`stdin command line ${lineNumber}: missing string "command"`) + } + + if (!VALID_STDIN_COMMANDS.has(commandRaw as StdinStreamCommandName)) { + throw new Error( + `stdin command line ${lineNumber}: unsupported command "${commandRaw}" (expected start|message|cancel|ping|shutdown)`, + ) + } + + if (typeof requestIdRaw !== "string" || requestIdRaw.trim().length === 0) { + throw new Error(`stdin command line ${lineNumber}: missing non-empty string "requestId"`) + } + + const command = commandRaw as StdinStreamCommandName + const requestId = requestIdRaw.trim() + + if (command === "start" || command === "message") { + const promptRaw = parsed.prompt + if (typeof promptRaw !== "string" || promptRaw.trim().length === 0) { + throw new Error(`stdin command line ${lineNumber}: "${command}" requires non-empty string "prompt"`) + } + + return { command, requestId, prompt: promptRaw } + } + + return { command, requestId } +} + +// --------------------------------------------------------------------------- +// NDJSON stdin reader +// --------------------------------------------------------------------------- + +async function* readCommandsFromStdinNdjson(): AsyncGenerator { + const lineReader = createInterface({ + input: process.stdin, + crlfDelay: Infinity, + terminal: false, + }) + + let lineNumber = 0 + + try { + for await (const line of lineReader) { + lineNumber += 1 + const trimmed = line.trim() + if (!trimmed) { + continue + } + yield parseStdinStreamCommand(trimmed, lineNumber) + } + } finally { + lineReader.close() + } +} + +// --------------------------------------------------------------------------- +// Queue snapshot helpers +// --------------------------------------------------------------------------- + +interface StreamQueueItem { + id: string + text?: string + imageCount: number + timestamp?: number +} + +function normalizeQueueText(text: string | undefined): string | undefined { + if (!text) { + return undefined + } + + const compact = text.replace(/\s+/g, " ").trim() + if (!compact) { + return undefined + } + + return compact.length <= 180 ? compact : `${compact.slice(0, 177)}...` +} + +function parseQueueSnapshot(rawQueue: unknown): StreamQueueItem[] | undefined { + if (!Array.isArray(rawQueue)) { + return undefined + } + + const snapshot: StreamQueueItem[] = [] + + for (const entry of rawQueue) { + if (!isRecord(entry)) { + continue + } + + const idRaw = entry.id + if (typeof idRaw !== "string" || idRaw.trim().length === 0) { + continue + } + + const imagesRaw = entry.images + const timestampRaw = entry.timestamp + const imageCount = Array.isArray(imagesRaw) ? imagesRaw.length : 0 + + snapshot.push({ + id: idRaw, + text: normalizeQueueText(typeof entry.text === "string" ? entry.text : undefined), + imageCount, + timestamp: typeof timestampRaw === "number" ? timestampRaw : undefined, + }) + } + + return snapshot +} + +function areStringArraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) { + return false + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false + } + } + + return true +} + +// --------------------------------------------------------------------------- +// Orchestrator +// --------------------------------------------------------------------------- + +export interface StdinStreamModeOptions { + host: ExtensionHost + jsonEmitter: JsonEventEmitter + setStreamRequestId: (id: string | undefined) => void +} + +function isCancellationLikeError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + const normalized = message.toLowerCase() + return normalized.includes("aborted") || normalized.includes("cancelled") || normalized.includes("canceled") +} + +export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId }: StdinStreamModeOptions) { + let hasReceivedStdinCommand = false + let shouldShutdown = false + let activeTaskPromise: Promise | null = null + let fatalStreamError: Error | null = null + let activeRequestId: string | undefined + let activeTaskCommand: "start" | undefined + let latestTaskId: string | undefined + let cancelRequestedForActiveTask = false + let hasSeenQueueState = false + let lastQueueDepth = 0 + let lastQueueMessageIds: string[] = [] + + const waitForPreviousTaskToSettle = async () => { + if (!activeTaskPromise) { + return + } + + try { + await activeTaskPromise + } catch { + // Errors are emitted through control/error events. + } + } + + const offClientError = host.client.on("error", (error) => { + if (cancelRequestedForActiveTask && isCancellationLikeError(error)) { + if (activeTaskCommand === "start") { + jsonEmitter.emitControl({ + subtype: "done", + requestId: activeRequestId, + command: "start", + taskId: latestTaskId, + content: "task cancelled", + code: "task_aborted", + success: false, + }) + } + activeTaskCommand = undefined + activeRequestId = undefined + setStreamRequestId(undefined) + cancelRequestedForActiveTask = false + return + } + + fatalStreamError = error + jsonEmitter.emitControl({ + subtype: "error", + requestId: activeRequestId, + command: activeTaskCommand, + taskId: latestTaskId, + content: error.message, + code: "client_error", + success: false, + }) + }) + + const onExtensionMessage = (message: { + type?: string + state?: { + currentTaskItem?: { id?: unknown } + messageQueue?: unknown + } + }) => { + if (message.type !== "state") { + return + } + + const currentTaskId = message.state?.currentTaskItem?.id + if (typeof currentTaskId === "string" && currentTaskId.trim().length > 0) { + latestTaskId = currentTaskId + } + + const queueSnapshot = parseQueueSnapshot(message.state?.messageQueue) + if (!queueSnapshot) { + return + } + + const queueDepth = queueSnapshot.length + const queueMessageIds = queueSnapshot.map((item) => item.id) + + if (!hasSeenQueueState) { + hasSeenQueueState = true + lastQueueDepth = queueDepth + lastQueueMessageIds = queueMessageIds + + if (queueDepth === 0) { + return + } + + jsonEmitter.emitQueue({ + subtype: "snapshot", + taskId: latestTaskId, + content: `queue snapshot (${queueDepth} item${queueDepth === 1 ? "" : "s"})`, + queueDepth, + queue: queueSnapshot, + }) + return + } + + const depthChanged = queueDepth !== lastQueueDepth + const idsChanged = !areStringArraysEqual(queueMessageIds, lastQueueMessageIds) + + if (!depthChanged && !idsChanged) { + return + } + + const subtype: "enqueued" | "dequeued" | "drained" | "updated" = depthChanged + ? queueDepth > lastQueueDepth + ? "enqueued" + : queueDepth === 0 + ? "drained" + : "dequeued" + : "updated" + + const content = + subtype === "drained" + ? "queue drained" + : `queue ${subtype} (${queueDepth} item${queueDepth === 1 ? "" : "s"})` + + jsonEmitter.emitQueue({ + subtype, + taskId: latestTaskId, + content, + queueDepth, + queue: queueSnapshot, + }) + + lastQueueDepth = queueDepth + lastQueueMessageIds = queueMessageIds + } + + host.on("extensionWebviewMessage", onExtensionMessage) + + const offTaskCompleted = host.client.on("taskCompleted", (event) => { + if (activeTaskCommand === "start") { + const completionCode = event.success + ? "task_completed" + : cancelRequestedForActiveTask + ? "task_aborted" + : "task_failed" + + jsonEmitter.emitControl({ + subtype: "done", + requestId: activeRequestId, + command: "start", + taskId: latestTaskId, + content: event.success + ? "task completed" + : cancelRequestedForActiveTask + ? "task cancelled" + : "task failed", + code: completionCode, + success: event.success, + }) + activeTaskCommand = undefined + activeRequestId = undefined + setStreamRequestId(undefined) + cancelRequestedForActiveTask = false + } + }) + + try { + for await (const stdinCommand of readCommandsFromStdinNdjson()) { + hasReceivedStdinCommand = true + + if (fatalStreamError) { + throw fatalStreamError + } + + switch (stdinCommand.command) { + case "start": + // A task can emit completion events before runTask() finalizers run. + // Wait for full settlement to avoid false "task_busy" on immediate next start. + // Safe from races: `for await` processes stdin commands serially, so no + // concurrent command can mutate state between the check and the await. + if (activeTaskPromise && !host.client.hasActiveTask()) { + await waitForPreviousTaskToSettle() + } + + if (activeTaskPromise || host.client.hasActiveTask()) { + jsonEmitter.emitControl({ + subtype: "error", + requestId: stdinCommand.requestId, + command: "start", + taskId: latestTaskId, + content: "cannot start a new task while another task is active", + code: "task_busy", + success: false, + }) + break + } + + activeRequestId = stdinCommand.requestId + activeTaskCommand = "start" + setStreamRequestId(stdinCommand.requestId) + latestTaskId = undefined + cancelRequestedForActiveTask = false + + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "start", + taskId: latestTaskId, + content: "starting task", + code: "accepted", + success: true, + }) + + activeTaskPromise = host + .runTask(stdinCommand.prompt) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + + if (cancelRequestedForActiveTask || isCancellationLikeError(error)) { + if (activeTaskCommand === "start") { + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "start", + taskId: latestTaskId, + content: "task cancelled", + code: "task_aborted", + success: false, + }) + } + activeTaskCommand = undefined + activeRequestId = undefined + setStreamRequestId(undefined) + cancelRequestedForActiveTask = false + return + } + + fatalStreamError = error instanceof Error ? error : new Error(message) + activeTaskCommand = undefined + activeRequestId = undefined + setStreamRequestId(undefined) + jsonEmitter.emitControl({ + subtype: "error", + requestId: stdinCommand.requestId, + command: "start", + taskId: latestTaskId, + content: message, + code: "task_error", + success: false, + }) + }) + .finally(() => { + activeTaskPromise = null + }) + break + + case "message": + if (!host.client.hasActiveTask()) { + jsonEmitter.emitControl({ + subtype: "error", + requestId: stdinCommand.requestId, + command: "message", + taskId: latestTaskId, + content: "no active task; send a start command first", + code: "no_active_task", + success: false, + }) + break + } + + setStreamRequestId(stdinCommand.requestId) + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "message", + taskId: latestTaskId, + content: "message accepted", + code: "accepted", + success: true, + }) + host.sendToExtension({ type: "queueMessage", text: stdinCommand.prompt }) + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "message", + taskId: latestTaskId, + content: "message queued", + code: "queued", + success: true, + }) + break + + case "cancel": { + setStreamRequestId(stdinCommand.requestId) + + const hasTaskInFlight = Boolean( + activeTaskPromise || activeTaskCommand === "start" || host.client.hasActiveTask(), + ) + + if (!hasTaskInFlight) { + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: "no active task to cancel", + code: "accepted", + success: true, + }) + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: "cancel ignored (no active task)", + code: "no_active_task", + success: true, + }) + break + } + + cancelRequestedForActiveTask = true + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: host.client.hasActiveTask() ? "cancel requested" : "cancel requested (task starting)", + code: "accepted", + success: true, + }) + try { + host.client.cancelTask() + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: "cancel signal sent", + code: "cancel_requested", + success: true, + }) + } catch (error) { + if (!isCancellationLikeError(error)) { + const message = error instanceof Error ? error.message : String(error) + jsonEmitter.emitControl({ + subtype: "error", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: message, + code: "cancel_error", + success: false, + }) + } + } + break + } + + case "ping": + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "ping", + taskId: latestTaskId, + content: "pong", + code: "accepted", + success: true, + }) + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "ping", + taskId: latestTaskId, + content: "pong", + code: "pong", + success: true, + }) + break + + case "shutdown": + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "shutdown", + taskId: latestTaskId, + content: "shutdown requested", + code: "accepted", + success: true, + }) + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "shutdown", + taskId: latestTaskId, + content: "shutting down process", + code: "shutdown_requested", + success: true, + }) + shouldShutdown = true + break + } + + if (shouldShutdown) { + break + } + } + + if (!hasReceivedStdinCommand) { + throw new Error("no stdin command provided") + } + + if (shouldShutdown && host.client.hasActiveTask()) { + host.client.cancelTask() + } + + if (!shouldShutdown && host.client.hasActiveTask() && host.isWaitingForInput()) { + const currentAsk = host.client.getCurrentAsk() + throw new Error(`stdin ended while task was waiting for input (${currentAsk ?? "unknown"})`) + } + + if (!shouldShutdown && activeTaskPromise) { + await activeTaskPromise + } + } finally { + offClientError() + host.off("extensionWebviewMessage", onExtensionMessage) + offTaskCompleted() + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 6eaab05987..8b817db77f 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -2,7 +2,7 @@ import { Command } from "commander" import { DEFAULT_FLAGS } from "@/types/constants.js" import { VERSION } from "@/lib/utils/version.js" -import { run, login, logout, status } from "@/commands/index.js" +import { run, login, logout, status, listCommands, listModes, listModels } from "@/commands/index.js" const program = new Command() @@ -16,7 +16,11 @@ program .option("--prompt-file ", "Read prompt from a file instead of command line argument") .option("-w, --workspace ", "Workspace directory path (defaults to current working directory)") .option("-p, --print", "Print response and exit (non-interactive mode)", false) - .option("--stdin-prompt-stream", "Read prompts from stdin (one prompt per line, requires --print)", false) + .option( + "--stdin-prompt-stream", + "Read NDJSON commands from stdin (requires --print and --output-format stream-json)", + false, + ) .option("-e, --extension ", "Path to the extension bundle directory") .option("-d, --debug", "Enable debug output (includes detailed debug information)", false) .option("-a, --require-approval", "Require manual approval for actions", false) @@ -39,6 +43,45 @@ program ) .action(run) +const listCommand = program.command("list").description("List commands, modes, or models") + +const applyListOptions = (command: Command) => + command + .option("-w, --workspace ", "Workspace directory path (defaults to current working directory)") + .option("-e, --extension ", "Path to the extension bundle directory") + .option("-k, --api-key ", "Roo API key (falls back to saved login/session token)") + .option("--format ", 'Output format: "json" (default) or "text"', "json") + .option("-d, --debug", "Enable debug output", false) + +const runListAction = async (action: () => Promise) => { + try { + await action() + process.exit(0) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[CLI] Error: ${message}`) + process.exit(1) + } +} + +applyListOptions(listCommand.command("commands").description("List available slash commands")).action( + async (options: Parameters[0]) => { + await runListAction(() => listCommands(options)) + }, +) + +applyListOptions(listCommand.command("modes").description("List available modes")).action( + async (options: Parameters[0]) => { + await runListAction(() => listModes(options)) + }, +) + +applyListOptions(listCommand.command("models").description("List available Roo models")).action( + async (options: Parameters[0]) => { + await runListAction(() => listModels(options)) + }, +) + const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud") authCommand diff --git a/apps/cli/src/lib/utils/__tests__/guards.test.ts b/apps/cli/src/lib/utils/__tests__/guards.test.ts new file mode 100644 index 0000000000..f59eeb506d --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/guards.test.ts @@ -0,0 +1,27 @@ +import { isRecord } from "../guards.js" + +describe("isRecord", () => { + it("returns true for plain objects", () => { + expect(isRecord({})).toBe(true) + expect(isRecord({ a: 1 })).toBe(true) + }) + + it("returns true for arrays (arrays are objects)", () => { + expect(isRecord([])).toBe(true) + }) + + it("returns false for null", () => { + expect(isRecord(null)).toBe(false) + }) + + it("returns false for undefined", () => { + expect(isRecord(undefined)).toBe(false) + }) + + it("returns false for primitives", () => { + expect(isRecord("string")).toBe(false) + expect(isRecord(42)).toBe(false) + expect(isRecord(true)).toBe(false) + expect(isRecord(Symbol("s"))).toBe(false) + }) +}) diff --git a/apps/cli/src/lib/utils/guards.ts b/apps/cli/src/lib/utils/guards.ts new file mode 100644 index 0000000000..a901f1a658 --- /dev/null +++ b/apps/cli/src/lib/utils/guards.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} diff --git a/apps/cli/src/types/json-events.ts b/apps/cli/src/types/json-events.ts index f18f3b2768..048a303a0f 100644 --- a/apps/cli/src/types/json-events.ts +++ b/apps/cli/src/types/json-events.ts @@ -27,6 +27,8 @@ export function isValidOutputFormat(format: string): format is OutputFormat { */ export type JsonEventType = | "system" // System messages (init, ready, shutdown) + | "control" // Transport/control protocol events + | "queue" // Message queue telemetry from extension state | "assistant" // Assistant text messages | "user" // User messages (echoed input) | "tool_use" // Tool invocations (file ops, commands, browser, MCP) @@ -35,6 +37,17 @@ export type JsonEventType = | "error" // Errors | "result" // Final task result +export interface JsonEventQueueItem { + /** Queue item id generated by MessageQueueService */ + id: string + /** Queued text prompt preview */ + text?: string + /** Number of attached images in the queued message */ + imageCount?: number + /** Queue insertion/update timestamp (ms epoch) */ + timestamp?: number +} + /** * Tool use information for tool_use events. */ @@ -84,14 +97,32 @@ export interface JsonEventCost { export interface JsonEvent { /** Event type discriminator */ type: JsonEventType + /** Protocol schema version (included on system.init) */ + schemaVersion?: number + /** Transport protocol identifier (included on system.init) */ + protocol?: string + /** Capability names supported by the current process */ + capabilities?: string[] /** Message ID - included on first delta and final message */ id?: number + /** Active task ID when available */ + taskId?: string + /** Request ID for correlating streamed output to stdin commands */ + requestId?: string + /** Command name for control events */ + command?: string /** Content text (for text-based events) */ content?: string /** True when this is the final message (stream complete) */ done?: boolean /** Optional subtype for more specific categorization */ subtype?: string + /** Optional machine-readable status/error code */ + code?: string + /** Current queue depth (for queue events) */ + queueDepth?: number + /** Queue item snapshots (for queue events) */ + queue?: JsonEventQueueItem[] /** Tool use information (for tool_use events) */ tool_use?: JsonEventToolUse /** Tool result information (for tool_result events) */ diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 16c6b36dce..e934342546 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2011,234 +2011,246 @@ export class Task extends EventEmitter implements TaskLike { } private async resumeTaskFromHistory() { - if (this.enableBridge) { - try { - await BridgeOrchestrator.subscribeToTask(this) - } catch (error) { - console.error( - `[Task#resumeTaskFromHistory] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`, - ) + try { + if (this.enableBridge) { + try { + await BridgeOrchestrator.subscribeToTask(this) + } catch (error) { + console.error( + `[Task#resumeTaskFromHistory] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } } - } - const modifiedClineMessages = await this.getSavedClineMessages() + const modifiedClineMessages = await this.getSavedClineMessages() - // Remove any resume messages that may have been added before. - const lastRelevantMessageIndex = findLastIndex( - modifiedClineMessages, - (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), - ) + // Remove any resume messages that may have been added before. + const lastRelevantMessageIndex = findLastIndex( + modifiedClineMessages, + (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), + ) - if (lastRelevantMessageIndex !== -1) { - modifiedClineMessages.splice(lastRelevantMessageIndex + 1) - } - - // Remove any trailing reasoning-only UI messages that were not part of the persisted API conversation - while (modifiedClineMessages.length > 0) { - const last = modifiedClineMessages[modifiedClineMessages.length - 1] - if (last.type === "say" && last.say === "reasoning") { - modifiedClineMessages.pop() - } else { - break + if (lastRelevantMessageIndex !== -1) { + modifiedClineMessages.splice(lastRelevantMessageIndex + 1) } - } - // Since we don't use `api_req_finished` anymore, we need to check if the - // last `api_req_started` has a cost value, if it doesn't and no - // cancellation reason to present, then we remove it since it indicates - // an api request without any partial content streamed. - const lastApiReqStartedIndex = findLastIndex( - modifiedClineMessages, - (m) => m.type === "say" && m.say === "api_req_started", - ) - - if (lastApiReqStartedIndex !== -1) { - const lastApiReqStarted = modifiedClineMessages[lastApiReqStartedIndex] - const { cost, cancelReason }: ClineApiReqInfo = JSON.parse(lastApiReqStarted.text || "{}") - - if (cost === undefined && cancelReason === undefined) { - modifiedClineMessages.splice(lastApiReqStartedIndex, 1) - } - } - - await this.overwriteClineMessages(modifiedClineMessages) - this.clineMessages = await this.getSavedClineMessages() - - // Now present the cline messages to the user and ask if they want to - // resume (NOTE: we ran into a bug before where the - // apiConversationHistory wouldn't be initialized when opening a old - // task, and it was because we were waiting for resume). - // This is important in case the user deletes messages without resuming - // the task first. - this.apiConversationHistory = await this.getSavedApiConversationHistory() - - const lastClineMessage = this.clineMessages - .slice() - .reverse() - .find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) // Could be multiple resume tasks. - - let askType: ClineAsk - if (lastClineMessage?.ask === "completion_result") { - askType = "resume_completed_task" - } else { - askType = "resume_task" - } - - this.isInitialized = true - - const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`. - - let responseText: string | undefined - let responseImages: string[] | undefined - - if (response === "messageResponse") { - await this.say("user_feedback", text, images) - responseText = text - responseImages = images - } - - // Make sure that the api conversation history can be resumed by the API, - // even if it goes out of sync with cline messages. - let existingApiConversationHistory: ApiMessage[] = await this.getSavedApiConversationHistory() - - // Tool blocks are always preserved; native tool calling only. - - // if the last message is an assistant message, we need to check if there's tool use since every tool use has to have a tool response - // if there's no tool use and only a text block, then we can just add a user message - // (note this isn't relevant anymore since we use custom tool prompts instead of tool use blocks, but this is here for legacy purposes in case users resume old tasks) - - // if the last message is a user message, we can need to get the assistant message before it to see if it made tool calls, and if so, fill in the remaining tool responses with 'interrupted' - - let modifiedOldUserContent: Anthropic.Messages.ContentBlockParam[] // either the last message if its user message, or the user message before the last (assistant) message - let modifiedApiConversationHistory: ApiMessage[] // need to remove the last user message to replace with new modified user message - if (existingApiConversationHistory.length > 0) { - const lastMessage = existingApiConversationHistory[existingApiConversationHistory.length - 1] - - if (lastMessage.isSummary) { - // IMPORTANT: If the last message is a condensation summary, we must preserve it - // intact. The summary message carries critical metadata (isSummary, condenseId) - // that getEffectiveApiHistory() uses to filter out condensed messages. - // Removing or merging it would destroy this metadata, causing all condensed - // messages to become "orphaned" and restored to active status — effectively - // undoing the condensation and sending the full history to the API. - // See: https://github.com/RooCodeInc/Roo-Code/issues/11487 - modifiedApiConversationHistory = [...existingApiConversationHistory] - modifiedOldUserContent = [] - } else if (lastMessage.role === "assistant") { - const content = Array.isArray(lastMessage.content) - ? lastMessage.content - : [{ type: "text", text: lastMessage.content }] - const hasToolUse = content.some((block) => block.type === "tool_use") - - if (hasToolUse) { - const toolUseBlocks = content.filter( - (block) => block.type === "tool_use", - ) as Anthropic.Messages.ToolUseBlock[] - const toolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks.map((block) => ({ - type: "tool_result", - tool_use_id: block.id, - content: "Task was interrupted before this tool call could be completed.", - })) - modifiedApiConversationHistory = [...existingApiConversationHistory] // no changes - modifiedOldUserContent = [...toolResponses] + // Remove any trailing reasoning-only UI messages that were not part of the persisted API conversation + while (modifiedClineMessages.length > 0) { + const last = modifiedClineMessages[modifiedClineMessages.length - 1] + if (last.type === "say" && last.say === "reasoning") { + modifiedClineMessages.pop() } else { + break + } + } + + // Since we don't use `api_req_finished` anymore, we need to check if the + // last `api_req_started` has a cost value, if it doesn't and no + // cancellation reason to present, then we remove it since it indicates + // an api request without any partial content streamed. + const lastApiReqStartedIndex = findLastIndex( + modifiedClineMessages, + (m) => m.type === "say" && m.say === "api_req_started", + ) + + if (lastApiReqStartedIndex !== -1) { + const lastApiReqStarted = modifiedClineMessages[lastApiReqStartedIndex] + const { cost, cancelReason }: ClineApiReqInfo = JSON.parse(lastApiReqStarted.text || "{}") + + if (cost === undefined && cancelReason === undefined) { + modifiedClineMessages.splice(lastApiReqStartedIndex, 1) + } + } + + await this.overwriteClineMessages(modifiedClineMessages) + this.clineMessages = await this.getSavedClineMessages() + + // Now present the cline messages to the user and ask if they want to + // resume (NOTE: we ran into a bug before where the + // apiConversationHistory wouldn't be initialized when opening a old + // task, and it was because we were waiting for resume). + // This is important in case the user deletes messages without resuming + // the task first. + this.apiConversationHistory = await this.getSavedApiConversationHistory() + + const lastClineMessage = this.clineMessages + .slice() + .reverse() + .find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) // Could be multiple resume tasks. + + let askType: ClineAsk + if (lastClineMessage?.ask === "completion_result") { + askType = "resume_completed_task" + } else { + askType = "resume_task" + } + + this.isInitialized = true + + const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`. + + let responseText: string | undefined + let responseImages: string[] | undefined + + if (response === "messageResponse") { + await this.say("user_feedback", text, images) + responseText = text + responseImages = images + } + + // Make sure that the api conversation history can be resumed by the API, + // even if it goes out of sync with cline messages. + let existingApiConversationHistory: ApiMessage[] = await this.getSavedApiConversationHistory() + + // Tool blocks are always preserved; native tool calling only. + + // if the last message is an assistant message, we need to check if there's tool use since every tool use has to have a tool response + // if there's no tool use and only a text block, then we can just add a user message + // (note this isn't relevant anymore since we use custom tool prompts instead of tool use blocks, but this is here for legacy purposes in case users resume old tasks) + + // if the last message is a user message, we can need to get the assistant message before it to see if it made tool calls, and if so, fill in the remaining tool responses with 'interrupted' + + let modifiedOldUserContent: Anthropic.Messages.ContentBlockParam[] // either the last message if its user message, or the user message before the last (assistant) message + let modifiedApiConversationHistory: ApiMessage[] // need to remove the last user message to replace with new modified user message + if (existingApiConversationHistory.length > 0) { + const lastMessage = existingApiConversationHistory[existingApiConversationHistory.length - 1] + + if (lastMessage.isSummary) { + // IMPORTANT: If the last message is a condensation summary, we must preserve it + // intact. The summary message carries critical metadata (isSummary, condenseId) + // that getEffectiveApiHistory() uses to filter out condensed messages. + // Removing or merging it would destroy this metadata, causing all condensed + // messages to become "orphaned" and restored to active status — effectively + // undoing the condensation and sending the full history to the API. + // See: https://github.com/RooCodeInc/Roo-Code/issues/11487 modifiedApiConversationHistory = [...existingApiConversationHistory] modifiedOldUserContent = [] - } - } else if (lastMessage.role === "user") { - const previousAssistantMessage: ApiMessage | undefined = - existingApiConversationHistory[existingApiConversationHistory.length - 2] + } else if (lastMessage.role === "assistant") { + const content = Array.isArray(lastMessage.content) + ? lastMessage.content + : [{ type: "text", text: lastMessage.content }] + const hasToolUse = content.some((block) => block.type === "tool_use") - const existingUserContent: Anthropic.Messages.ContentBlockParam[] = Array.isArray(lastMessage.content) - ? lastMessage.content - : [{ type: "text", text: lastMessage.content }] - if (previousAssistantMessage && previousAssistantMessage.role === "assistant") { - const assistantContent = Array.isArray(previousAssistantMessage.content) - ? previousAssistantMessage.content - : [{ type: "text", text: previousAssistantMessage.content }] + if (hasToolUse) { + const toolUseBlocks = content.filter( + (block) => block.type === "tool_use", + ) as Anthropic.Messages.ToolUseBlock[] + const toolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks.map((block) => ({ + type: "tool_result", + tool_use_id: block.id, + content: "Task was interrupted before this tool call could be completed.", + })) + modifiedApiConversationHistory = [...existingApiConversationHistory] // no changes + modifiedOldUserContent = [...toolResponses] + } else { + modifiedApiConversationHistory = [...existingApiConversationHistory] + modifiedOldUserContent = [] + } + } else if (lastMessage.role === "user") { + const previousAssistantMessage: ApiMessage | undefined = + existingApiConversationHistory[existingApiConversationHistory.length - 2] - const toolUseBlocks = assistantContent.filter( - (block) => block.type === "tool_use", - ) as Anthropic.Messages.ToolUseBlock[] + const existingUserContent: Anthropic.Messages.ContentBlockParam[] = Array.isArray( + lastMessage.content, + ) + ? lastMessage.content + : [{ type: "text", text: lastMessage.content }] + if (previousAssistantMessage && previousAssistantMessage.role === "assistant") { + const assistantContent = Array.isArray(previousAssistantMessage.content) + ? previousAssistantMessage.content + : [{ type: "text", text: previousAssistantMessage.content }] - if (toolUseBlocks.length > 0) { - const existingToolResults = existingUserContent.filter( - (block) => block.type === "tool_result", - ) as Anthropic.ToolResultBlockParam[] + const toolUseBlocks = assistantContent.filter( + (block) => block.type === "tool_use", + ) as Anthropic.Messages.ToolUseBlock[] - const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks - .filter( - (toolUse) => !existingToolResults.some((result) => result.tool_use_id === toolUse.id), - ) - .map((toolUse) => ({ - type: "tool_result", - tool_use_id: toolUse.id, - content: "Task was interrupted before this tool call could be completed.", - })) + if (toolUseBlocks.length > 0) { + const existingToolResults = existingUserContent.filter( + (block) => block.type === "tool_result", + ) as Anthropic.ToolResultBlockParam[] - modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) // removes the last user message - modifiedOldUserContent = [...existingUserContent, ...missingToolResponses] + const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks + .filter( + (toolUse) => + !existingToolResults.some((result) => result.tool_use_id === toolUse.id), + ) + .map((toolUse) => ({ + type: "tool_result", + tool_use_id: toolUse.id, + content: "Task was interrupted before this tool call could be completed.", + })) + + modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) // removes the last user message + modifiedOldUserContent = [...existingUserContent, ...missingToolResponses] + } else { + modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) + modifiedOldUserContent = [...existingUserContent] + } } else { modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) modifiedOldUserContent = [...existingUserContent] } } else { - modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) - modifiedOldUserContent = [...existingUserContent] + throw new Error("Unexpected: Last message is not a user or assistant message") } } else { - throw new Error("Unexpected: Last message is not a user or assistant message") + throw new Error("Unexpected: No existing API conversation history") } - } else { - throw new Error("Unexpected: No existing API conversation history") - } - let newUserContent: Anthropic.Messages.ContentBlockParam[] = [...modifiedOldUserContent] + let newUserContent: Anthropic.Messages.ContentBlockParam[] = [...modifiedOldUserContent] - const agoText = ((): string => { - const timestamp = lastClineMessage?.ts ?? Date.now() - const now = Date.now() - const diff = now - timestamp - const minutes = Math.floor(diff / 60000) - const hours = Math.floor(minutes / 60) - const days = Math.floor(hours / 24) + const agoText = ((): string => { + const timestamp = lastClineMessage?.ts ?? Date.now() + const now = Date.now() + const diff = now - timestamp + const minutes = Math.floor(diff / 60000) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) - if (days > 0) { - return `${days} day${days > 1 ? "s" : ""} ago` + if (days > 0) { + return `${days} day${days > 1 ? "s" : ""} ago` + } + if (hours > 0) { + return `${hours} hour${hours > 1 ? "s" : ""} ago` + } + if (minutes > 0) { + return `${minutes} minute${minutes > 1 ? "s" : ""} ago` + } + return "just now" + })() + + if (responseText) { + newUserContent.push({ + type: "text", + text: `\n${responseText}\n`, + }) } - if (hours > 0) { - return `${hours} hour${hours > 1 ? "s" : ""} ago` + + if (responseImages && responseImages.length > 0) { + newUserContent.push(...formatResponse.imageBlocks(responseImages)) } - if (minutes > 0) { - return `${minutes} minute${minutes > 1 ? "s" : ""} ago` + + // Ensure we have at least some content to send to the API. + // If newUserContent is empty, add a minimal resumption message. + if (newUserContent.length === 0) { + newUserContent.push({ + type: "text", + text: "[TASK RESUMPTION] Resuming task...", + }) } - return "just now" - })() - if (responseText) { - newUserContent.push({ - type: "text", - text: `\n${responseText}\n`, - }) + await this.overwriteApiConversationHistory(modifiedApiConversationHistory) + + // Task resuming from history item. + await this.initiateTaskLoop(newUserContent) + } catch (error) { + // Resume and cancellation can race when users issue repeated cancels. + // Treat intentional abort/abandon flows as expected and avoid process-level crashes. + if (this.abandoned === true || this.abort === true || this.abortReason === "user_cancelled") { + return + } + throw error } - - if (responseImages && responseImages.length > 0) { - newUserContent.push(...formatResponse.imageBlocks(responseImages)) - } - - // Ensure we have at least some content to send to the API. - // If newUserContent is empty, add a minimal resumption message. - if (newUserContent.length === 0) { - newUserContent.push({ - type: "text", - text: "[TASK RESUMPTION] Resuming task...", - }) - } - - await this.overwriteApiConversationHistory(modifiedApiConversationHistory) - - // Task resuming from history item. - await this.initiateTaskLoop(newUserContent) } /** From 00c35e691fba62fb342e6d45a2391b17b9c705fe Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Thu, 19 Feb 2026 00:06:22 -0800 Subject: [PATCH 021/109] chore(cli): prepare release v0.1.0 (#11599) --- apps/cli/CHANGELOG.md | 16 ++++++++++++++++ apps/cli/package.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 1c01ec6e1c..b2c0446a03 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.0] - 2026-02-19 + +### Added + +- **NDJSON Stdin Protocol**: Overhauled the stdin prompt stream from raw text lines to a structured NDJSON command protocol (`start`/`message`/`cancel`/`ping`/`shutdown`) with requestId correlation, ack/done/error lifecycle events, and queue telemetry. See [`stdin-stream.ts`](src/ui/stdin-stream.ts) for implementation. +- **List Subcommands**: New `list` subcommands (`commands`, `modes`, `models`) for programmatic discovery of available CLI capabilities. +- **Shared Utilities**: Added `isRecord` guard utility for improved type safety. + +### Changed + +- **Modularized Architecture**: Extracted stdin stream logic from `run.ts` into dedicated [`stdin-stream.ts`](src/ui/stdin-stream.ts) module for better code organization and maintainability. + +### Fixed + +- Fixed a bug in `Task.ts` affecting CLI operation. + ## [0.0.55] - 2026-02-17 ### Fixed diff --git a/apps/cli/package.json b/apps/cli/package.json index 7f2e8d296c..d0659d4984 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@roo-code/cli", - "version": "0.0.55", + "version": "0.1.0", "description": "Roo Code CLI - Run the Roo Code agent from the command line", "private": true, "type": "module", From aff46b14e2f862bc23e127f5ea8b7c8c0ea12c28 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 01:16:40 -0700 Subject: [PATCH 022/109] chore: remove integration tests (#11598) * chore: remove integration test files * chore: remove integration test job from CI workflow --------- Co-authored-by: Roo Code Co-authored-by: Hannes Rudolph --- .github/workflows/code-qa.yml | 63 --- .../CloudService.integration.test.ts | 147 ------- .../executeCommandTimeout.integration.spec.ts | 406 ------------------ ...eHandler.imageMentions.integration.spec.ts | 130 ------ 4 files changed, 746 deletions(-) delete mode 100644 packages/cloud/src/__tests__/CloudService.integration.test.ts delete mode 100644 src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts delete mode 100644 src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index f8ac0c8642..1592b15669 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -58,66 +58,3 @@ jobs: uses: ./.github/actions/setup-node-pnpm - name: Run unit tests run: pnpm test - - check-openrouter-api-key: - runs-on: ubuntu-latest - outputs: - exists: ${{ steps.openrouter-api-key-check.outputs.defined }} - steps: - - name: Check if OpenRouter API key exists - id: openrouter-api-key-check - shell: bash - run: | - if [ "${{ secrets.OPENROUTER_API_KEY }}" != '' ]; then - echo "defined=true" >> $GITHUB_OUTPUT; - else - echo "defined=false" >> $GITHUB_OUTPUT; - fi - - integration-test: - runs-on: ubuntu-latest - needs: [check-openrouter-api-key] - if: needs.check-openrouter-api-key.outputs.exists == 'true' - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm - - name: Create .env.local file - working-directory: apps/vscode-e2e - run: echo "OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}" > .env.local - - name: Set VS Code test version - run: echo "VSCODE_VERSION=1.101.2" >> $GITHUB_ENV - - name: Cache VS Code test runtime - uses: actions/cache@v4 - with: - path: apps/vscode-e2e/.vscode-test - key: ${{ runner.os }}-vscode-test-${{ env.VSCODE_VERSION }} - - name: Pre-download VS Code test runtime with retry - working-directory: apps/vscode-e2e - run: | - for attempt in 1 2 3; do - echo "Download attempt $attempt of 3..." - node -e " - const { downloadAndUnzipVSCode } = require('@vscode/test-electron'); - downloadAndUnzipVSCode({ version: process.env.VSCODE_VERSION || '1.101.2' }) - .then(() => { - console.log('✅ VS Code test runtime downloaded successfully'); - process.exit(0); - }) - .catch(err => { - console.error('❌ Failed to download VS Code (attempt $attempt):', err); - process.exit(1); - }); - " && break || { - if [ $attempt -eq 3 ]; then - echo "All download attempts failed" - exit 1 - fi - echo "Retrying in 5 seconds..." - sleep 5 - } - done - - name: Run integration tests - working-directory: apps/vscode-e2e - run: xvfb-run -a pnpm test:ci diff --git a/packages/cloud/src/__tests__/CloudService.integration.test.ts b/packages/cloud/src/__tests__/CloudService.integration.test.ts deleted file mode 100644 index 2896b43554..0000000000 --- a/packages/cloud/src/__tests__/CloudService.integration.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -// npx vitest run src/__tests__/CloudService.integration.test.ts - -import type { ExtensionContext } from "vscode" - -import { CloudService } from "../CloudService.js" -import { StaticSettingsService } from "../StaticSettingsService.js" -import { CloudSettingsService } from "../CloudSettingsService.js" - -vi.mock("vscode", () => ({ - ExtensionContext: vi.fn(), - window: { - showInformationMessage: vi.fn(), - showErrorMessage: vi.fn(), - }, - env: { - openExternal: vi.fn(), - }, - Uri: { - parse: vi.fn(), - }, -})) - -describe("CloudService Integration - Settings Service Selection", () => { - let mockContext: ExtensionContext - - beforeEach(() => { - CloudService.resetInstance() - - mockContext = { - subscriptions: [], - workspaceState: { - get: vi.fn(), - update: vi.fn(), - keys: vi.fn().mockReturnValue([]), - }, - secrets: { - get: vi.fn(), - store: vi.fn(), - delete: vi.fn(), - onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }), - }, - globalState: { - get: vi.fn(), - update: vi.fn(), - setKeysForSync: vi.fn(), - keys: vi.fn().mockReturnValue([]), - }, - extensionUri: { scheme: "file", path: "/mock/path" }, - extensionPath: "/mock/path", - extensionMode: 1, - asAbsolutePath: vi.fn((relativePath: string) => `/mock/path/${relativePath}`), - storageUri: { scheme: "file", path: "/mock/storage" }, - extension: { - packageJSON: { - version: "1.0.0", - }, - }, - } as unknown as ExtensionContext - }) - - afterEach(() => { - CloudService.resetInstance() - delete process.env.ROO_CODE_CLOUD_ORG_SETTINGS - delete process.env.ROO_CODE_CLOUD_TOKEN - }) - - it("should use CloudSettingsService when no environment variable is set", async () => { - // Ensure no environment variables are set - delete process.env.ROO_CODE_CLOUD_ORG_SETTINGS - delete process.env.ROO_CODE_CLOUD_TOKEN - - const cloudService = await CloudService.createInstance(mockContext) - - // Access the private settingsService to check its type - const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService - expect(settingsService).toBeInstanceOf(CloudSettingsService) - }) - - it("should use StaticSettingsService when ROO_CODE_CLOUD_ORG_SETTINGS is set", async () => { - const validSettings = { - version: 1, - cloudSettings: { - recordTaskMessages: true, - enableTaskSharing: true, - taskShareExpirationDays: 30, - }, - defaultSettings: { - enableCheckpoints: true, - }, - allowList: { - allowAll: true, - providers: {}, - }, - } - - // Set the environment variable - process.env.ROO_CODE_CLOUD_ORG_SETTINGS = Buffer.from(JSON.stringify(validSettings)).toString("base64") - - const cloudService = await CloudService.createInstance(mockContext) - - // Access the private settingsService to check its type - const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService - expect(settingsService).toBeInstanceOf(StaticSettingsService) - - // Verify the settings are correctly loaded - expect(cloudService.getAllowList()).toEqual(validSettings.allowList) - }) - - it("should throw error when ROO_CODE_CLOUD_ORG_SETTINGS contains invalid data", async () => { - // Set invalid environment variable - process.env.ROO_CODE_CLOUD_ORG_SETTINGS = "invalid-base64-data" - - await expect(CloudService.createInstance(mockContext)).rejects.toThrow("Failed to initialize CloudService") - }) - - it("should prioritize static token auth when both environment variables are set", async () => { - const validSettings = { - version: 1, - cloudSettings: { - recordTaskMessages: true, - enableTaskSharing: true, - taskShareExpirationDays: 30, - }, - defaultSettings: { - enableCheckpoints: true, - }, - allowList: { - allowAll: true, - providers: {}, - }, - } - - // Set both environment variables - process.env.ROO_CODE_CLOUD_TOKEN = "test-token" - process.env.ROO_CODE_CLOUD_ORG_SETTINGS = Buffer.from(JSON.stringify(validSettings)).toString("base64") - - const cloudService = await CloudService.createInstance(mockContext) - - // Should use StaticSettingsService for settings - const settingsService = (cloudService as unknown as { settingsService: unknown }).settingsService - expect(settingsService).toBeInstanceOf(StaticSettingsService) - - // Should use StaticTokenAuthService for auth (from the existing logic) - expect(cloudService.isAuthenticated()).toBe(true) - expect(cloudService.hasActiveSession()).toBe(true) - }) -}) diff --git a/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts b/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts deleted file mode 100644 index bd13439ea7..0000000000 --- a/src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts +++ /dev/null @@ -1,406 +0,0 @@ -// Integration tests for command execution timeout functionality -// npx vitest run src/core/tools/__tests__/executeCommandTimeout.integration.spec.ts - -import * as vscode from "vscode" -import * as fs from "fs/promises" -import { executeCommandInTerminal, executeCommandTool, ExecuteCommandOptions } from "../ExecuteCommandTool" -import { Task } from "../../task/Task" -import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" - -// Mock dependencies -vitest.mock("vscode", () => ({ - workspace: { - getConfiguration: vitest.fn(), - }, -})) - -vitest.mock("fs/promises") -vitest.mock("../../../integrations/terminal/TerminalRegistry") -vitest.mock("../../task/Task") -vitest.mock("../../prompts/responses", () => ({ - formatResponse: { - toolError: vitest.fn((msg) => `Tool Error: ${msg}`), - rooIgnoreError: vitest.fn((msg) => `RooIgnore Error: ${msg}`), - }, -})) -vitest.mock("../../../utils/text-normalization", () => ({ - unescapeHtmlEntities: vitest.fn((text) => text), -})) -vitest.mock("../../../shared/package", () => ({ - Package: { - name: "roo-cline", - }, -})) - -describe("Command Execution Timeout Integration", () => { - let mockTask: any - let mockTerminal: any - let mockProcess: any - - beforeEach(() => { - vitest.clearAllMocks() - - // Mock fs.access to resolve successfully for working directory - ;(fs.access as any).mockResolvedValue(undefined) - - // Mock task - mockTask = { - cwd: "/test/directory", - terminalProcess: undefined, - providerRef: { - deref: vitest.fn().mockResolvedValue({ - postMessageToWebview: vitest.fn(), - }), - }, - say: vitest.fn().mockResolvedValue(undefined), - } - - // Mock terminal process - mockProcess = { - abort: vitest.fn(), - then: vitest.fn(), - catch: vitest.fn(), - } - - // Mock terminal - mockTerminal = { - runCommand: vitest.fn().mockReturnValue(mockProcess), - getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/directory"), - } - - // Mock TerminalRegistry - ;(TerminalRegistry.getOrCreateTerminal as any).mockResolvedValue(mockTerminal) - - // Mock VSCode configuration - const mockGetConfiguration = vitest.fn().mockReturnValue({ - get: vitest.fn().mockReturnValue(0), // Default 0 (no timeout) - }) - ;(vscode.workspace.getConfiguration as any).mockReturnValue(mockGetConfiguration()) - }) - - it("should pass timeout configuration to executeCommand", async () => { - const customTimeoutMs = 15000 // 15 seconds in milliseconds - const options: ExecuteCommandOptions = { - executionId: "test-execution", - command: "echo test", - commandExecutionTimeout: customTimeoutMs, - } - - // Mock a quick-completing process - const quickProcess = Promise.resolve() - mockTerminal.runCommand.mockReturnValue(quickProcess) - - await executeCommandInTerminal(mockTask as Task, options) - - // Verify that the terminal was called with the command - expect(mockTerminal.runCommand).toHaveBeenCalledWith("echo test", expect.any(Object)) - }) - - it("should handle timeout scenario", async () => { - const shortTimeoutMs = 100 // Very short timeout in milliseconds - const options: ExecuteCommandOptions = { - executionId: "test-execution", - command: "sleep 10", - commandExecutionTimeout: shortTimeoutMs, - } - - // Create a process that never resolves but has an abort method - const longRunningProcess = new Promise(() => { - // Never resolves to simulate a hanging command - }) - - // Add abort method to the promise - ;(longRunningProcess as any).abort = vitest.fn() - - mockTerminal.runCommand.mockReturnValue(longRunningProcess) - - // Execute with timeout - const result = await executeCommandInTerminal(mockTask as Task, options) - - // Should return timeout error - expect(result[0]).toBe(false) // Not rejected by user - expect(result[1]).toContain("terminated after exceeding") - expect(result[1]).toContain("0.1s") // Should show seconds in error message - }, 10000) // Increase test timeout to 10 seconds - - it("should abort process on timeout", async () => { - const shortTimeoutMs = 50 // Short timeout in milliseconds - const options: ExecuteCommandOptions = { - executionId: "test-execution", - command: "sleep 10", - commandExecutionTimeout: shortTimeoutMs, - } - - // Create a process that can be aborted - const abortSpy = vitest.fn() - - // Mock the process to never resolve but be abortable - const neverResolvingPromise = new Promise(() => {}) - ;(neverResolvingPromise as any).abort = abortSpy - - mockTerminal.runCommand.mockReturnValue(neverResolvingPromise) - - await executeCommandInTerminal(mockTask as Task, options) - - // Verify abort was called - expect(abortSpy).toHaveBeenCalled() - }, 5000) // Increase test timeout to 5 seconds - - it("should clean up timeout on successful completion", async () => { - const options: ExecuteCommandOptions = { - executionId: "test-execution", - command: "echo test", - commandExecutionTimeout: 5000, - } - - // Mock a process that completes quickly - const quickProcess = Promise.resolve() - mockTerminal.runCommand.mockReturnValue(quickProcess) - - const result = await executeCommandInTerminal(mockTask as Task, options) - - // Should complete successfully without timeout - expect(result[0]).toBe(false) // Not rejected - expect(result[1]).not.toContain("terminated after exceeding") - }) - - it("should use default timeout when not specified (0 = no timeout)", async () => { - const options: ExecuteCommandOptions = { - executionId: "test-execution", - command: "echo test", - // commandExecutionTimeout not specified, should use default (0) - } - - const quickProcess = Promise.resolve() - mockTerminal.runCommand.mockReturnValue(quickProcess) - - await executeCommandInTerminal(mockTask as Task, options) - - // Should complete without issues using default (no timeout) - expect(mockTerminal.runCommand).toHaveBeenCalled() - }) - - it("should not timeout when commandExecutionTimeout is 0", async () => { - const options: ExecuteCommandOptions = { - executionId: "test-execution", - command: "sleep 10", - commandExecutionTimeout: 0, // No timeout - } - - // Create a process that resolves after a delay to simulate a long-running command - const longRunningProcess = new Promise((resolve) => { - setTimeout(resolve, 200) // 200ms delay - }) - - mockTerminal.runCommand.mockReturnValue(longRunningProcess) - - const result = await executeCommandInTerminal(mockTask as Task, options) - - // Should complete successfully without timeout - expect(result[0]).toBe(false) // Not rejected - expect(result[1]).not.toContain("terminated after exceeding") - }) - - describe("Command Timeout Allowlist", () => { - let mockBlock: any - let mockAskApproval: any - let mockHandleError: any - let mockPushToolResult: any - - beforeEach(() => { - // Reset mocks for allowlist tests - vitest.clearAllMocks() - ;(fs.access as any).mockResolvedValue(undefined) - ;(TerminalRegistry.getOrCreateTerminal as any).mockResolvedValue(mockTerminal) - - // Mock the executeCommandTool parameters - mockBlock = { - type: "tool_use", - name: "execute_command", - params: { - command: "", - cwd: undefined, - }, - nativeArgs: { - command: "", - cwd: undefined, - }, - partial: false, - } - - mockAskApproval = vitest.fn().mockResolvedValue(true) // Always approve - mockHandleError = vitest.fn() - mockPushToolResult = vitest.fn() - - // Mock task with additional properties needed by executeCommandTool - mockTask = { - cwd: "/test/directory", - terminalProcess: undefined, - providerRef: { - deref: vitest.fn().mockResolvedValue({ - postMessageToWebview: vitest.fn(), - getState: vitest.fn().mockResolvedValue({ - terminalOutputLineLimit: 500, - terminalShellIntegrationDisabled: false, - }), - }), - }, - say: vitest.fn().mockResolvedValue(undefined), - consecutiveMistakeCount: 0, - recordToolError: vitest.fn(), - sayAndCreateMissingParamError: vitest.fn(), - rooIgnoreController: { - validateCommand: vitest.fn().mockReturnValue(null), - }, - lastMessageTs: Date.now(), - ask: vitest.fn(), - didRejectTool: false, - } - }) - - it("should skip timeout for commands in allowlist", async () => { - // Mock VSCode configuration with timeout and allowlist - const mockGetConfiguration = vitest.fn().mockReturnValue({ - get: vitest.fn().mockImplementation((key: string) => { - if (key === "commandExecutionTimeout") return 1 // 1 second timeout - if (key === "commandTimeoutAllowlist") return ["npm", "git"] - return undefined - }), - }) - ;(vscode.workspace.getConfiguration as any).mockReturnValue(mockGetConfiguration()) - - mockBlock.params.command = "npm install" - mockBlock.nativeArgs.command = "npm install" - - // Create a process that would timeout if not allowlisted - const longRunningProcess = new Promise((resolve) => { - setTimeout(resolve, 2000) // 2 seconds, longer than 1 second timeout - }) - mockTerminal.runCommand.mockReturnValue(longRunningProcess) - - await executeCommandTool.handle(mockTask as Task, mockBlock, { - askApproval: mockAskApproval, - handleError: mockHandleError, - pushToolResult: mockPushToolResult, - }) - - // Should complete successfully without timeout because "npm" is in allowlist - expect(mockPushToolResult).toHaveBeenCalled() - const result = mockPushToolResult.mock.calls[0][0] - expect(result).not.toContain("terminated after exceeding") - }, 3000) - - it("should apply timeout for commands not in allowlist", async () => { - // Mock VSCode configuration with timeout and allowlist - const mockGetConfiguration = vitest.fn().mockReturnValue({ - get: vitest.fn().mockImplementation((key: string) => { - if (key === "commandExecutionTimeout") return 1 // 1 second timeout - if (key === "commandTimeoutAllowlist") return ["npm", "git"] - return undefined - }), - }) - ;(vscode.workspace.getConfiguration as any).mockReturnValue(mockGetConfiguration()) - - mockBlock.params.command = "sleep 10" // Not in allowlist - mockBlock.nativeArgs.command = "sleep 10" - - // Create a process that never resolves - const neverResolvingProcess = new Promise(() => {}) - ;(neverResolvingProcess as any).abort = vitest.fn() - mockTerminal.runCommand.mockReturnValue(neverResolvingProcess) - - await executeCommandTool.handle(mockTask as Task, mockBlock, { - askApproval: mockAskApproval, - handleError: mockHandleError, - pushToolResult: mockPushToolResult, - }) - - // Should timeout because "sleep" is not in allowlist - expect(mockPushToolResult).toHaveBeenCalled() - const result = mockPushToolResult.mock.calls[0][0] - expect(result).toContain("terminated after exceeding") - }, 3000) - - it("should handle empty allowlist", async () => { - // Mock VSCode configuration with timeout and empty allowlist - const mockGetConfiguration = vitest.fn().mockReturnValue({ - get: vitest.fn().mockImplementation((key: string) => { - if (key === "commandExecutionTimeout") return 1 // 1 second timeout - if (key === "commandTimeoutAllowlist") return [] - return undefined - }), - }) - ;(vscode.workspace.getConfiguration as any).mockReturnValue(mockGetConfiguration()) - - mockBlock.params.command = "npm install" - mockBlock.nativeArgs.command = "npm install" - - // Create a process that never resolves - const neverResolvingProcess = new Promise(() => {}) - ;(neverResolvingProcess as any).abort = vitest.fn() - mockTerminal.runCommand.mockReturnValue(neverResolvingProcess) - - await executeCommandTool.handle(mockTask as Task, mockBlock, { - askApproval: mockAskApproval, - handleError: mockHandleError, - pushToolResult: mockPushToolResult, - }) - - // Should timeout because allowlist is empty - expect(mockPushToolResult).toHaveBeenCalled() - const result = mockPushToolResult.mock.calls[0][0] - expect(result).toContain("terminated after exceeding") - }, 3000) - - it("should match command prefixes correctly", async () => { - // Mock VSCode configuration with timeout and allowlist - const mockGetConfiguration = vitest.fn().mockReturnValue({ - get: vitest.fn().mockImplementation((key: string) => { - if (key === "commandExecutionTimeout") return 1 // 1 second timeout - if (key === "commandTimeoutAllowlist") return ["git log", "npm run"] - return undefined - }), - }) - ;(vscode.workspace.getConfiguration as any).mockReturnValue(mockGetConfiguration()) - - const longRunningProcess = new Promise((resolve) => { - setTimeout(resolve, 2000) // 2 seconds - }) - const neverResolvingProcess = new Promise(() => {}) - ;(neverResolvingProcess as any).abort = vitest.fn() - - // Test exact prefix match - should not timeout - mockBlock.params.command = "git log --oneline" - mockBlock.nativeArgs.command = "git log --oneline" - mockTerminal.runCommand.mockReturnValueOnce(longRunningProcess) - - await executeCommandTool.handle(mockTask as Task, mockBlock, { - askApproval: mockAskApproval, - handleError: mockHandleError, - pushToolResult: mockPushToolResult, - }) - - expect(mockPushToolResult).toHaveBeenCalled() - const result1 = mockPushToolResult.mock.calls[0][0] - expect(result1).not.toContain("terminated after exceeding") - - // Reset mocks for second test - mockPushToolResult.mockClear() - - // Test partial prefix match (should not match) - should timeout - mockBlock.params.command = "git status" // "git" alone is not in allowlist, only "git log" - mockBlock.nativeArgs.command = "git status" - mockTerminal.runCommand.mockReturnValueOnce(neverResolvingProcess) - - await executeCommandTool.handle(mockTask as Task, mockBlock, { - askApproval: mockAskApproval, - handleError: mockHandleError, - pushToolResult: mockPushToolResult, - }) - - expect(mockPushToolResult).toHaveBeenCalled() - const result2 = mockPushToolResult.mock.calls[0][0] - expect(result2).toContain("terminated after exceeding") - }, 5000) - }) -}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts deleted file mode 100644 index 277e56626a..0000000000 --- a/src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -import * as fs from "fs/promises" -import * as path from "path" -import * as os from "os" - -// Must mock dependencies before importing the handler module. -vi.mock("../../../api/providers/fetchers/modelCache") - -import { webviewMessageHandler } from "../webviewMessageHandler" -import type { ClineProvider } from "../ClineProvider" - -vi.mock("vscode", () => ({ - window: { - showInformationMessage: vi.fn(), - showErrorMessage: vi.fn(), - }, - workspace: { - workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], - }, -})) - -// Mock imageHelpers - use actual implementations for functions that need real file access -vi.mock("../../tools/helpers/imageHelpers", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - validateImageForProcessing: vi.fn().mockResolvedValue({ isValid: true, sizeInMB: 0.001 }), - ImageMemoryTracker: vi.fn().mockImplementation(() => ({ - getTotalMemoryUsed: vi.fn().mockReturnValue(0), - addMemoryUsage: vi.fn(), - })), - } -}) - -describe("webviewMessageHandler - image mentions (integration)", () => { - it("resolves image mentions for newTask and passes images to createTask", async () => { - const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-")) - try { - const imgBytes = Buffer.from("png-bytes") - await fs.writeFile(path.join(tmpRoot, "cat.png"), imgBytes) - - const mockProvider = { - cwd: tmpRoot, - getCurrentTask: vi.fn().mockReturnValue(undefined), - createTask: vi.fn().mockResolvedValue(undefined), - postMessageToWebview: vi.fn().mockResolvedValue(undefined), - getState: vi.fn().mockResolvedValue({ - maxImageFileSize: 5, - maxTotalImageSize: 20, - }), - } as unknown as ClineProvider - - await webviewMessageHandler(mockProvider, { - type: "newTask", - text: "Please look at @/cat.png", - images: [], - } as any) - - expect(mockProvider.createTask).toHaveBeenCalledWith("Please look at @/cat.png", [ - `data:image/png;base64,${imgBytes.toString("base64")}`, - ]) - } finally { - await fs.rm(tmpRoot, { recursive: true, force: true }) - } - }) - - it("resolves image mentions for askResponse and passes images to handleWebviewAskResponse", async () => { - const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-")) - try { - const imgBytes = Buffer.from("jpg-bytes") - await fs.writeFile(path.join(tmpRoot, "cat.jpg"), imgBytes) - - const handleWebviewAskResponse = vi.fn() - const mockProvider = { - cwd: tmpRoot, - getCurrentTask: vi.fn().mockReturnValue({ - cwd: tmpRoot, - handleWebviewAskResponse, - }), - getState: vi.fn().mockResolvedValue({ - maxImageFileSize: 5, - maxTotalImageSize: 20, - }), - } as unknown as ClineProvider - - await webviewMessageHandler(mockProvider, { - type: "askResponse", - askResponse: "messageResponse", - text: "Please look at @/cat.jpg", - images: [], - } as any) - - expect(handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Please look at @/cat.jpg", [ - `data:image/jpeg;base64,${imgBytes.toString("base64")}`, - ]) - } finally { - await fs.rm(tmpRoot, { recursive: true, force: true }) - } - }) - - it("resolves gif image mentions (matching read_file behavior)", async () => { - const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-")) - try { - const imgBytes = Buffer.from("gif-bytes") - await fs.writeFile(path.join(tmpRoot, "animation.gif"), imgBytes) - - const mockProvider = { - cwd: tmpRoot, - getCurrentTask: vi.fn().mockReturnValue(undefined), - createTask: vi.fn().mockResolvedValue(undefined), - postMessageToWebview: vi.fn().mockResolvedValue(undefined), - getState: vi.fn().mockResolvedValue({ - maxImageFileSize: 5, - maxTotalImageSize: 20, - }), - } as unknown as ClineProvider - - await webviewMessageHandler(mockProvider, { - type: "newTask", - text: "See @/animation.gif", - images: [], - } as any) - - expect(mockProvider.createTask).toHaveBeenCalledWith("See @/animation.gif", [ - `data:image/gif;base64,${imgBytes.toString("base64")}`, - ]) - } finally { - await fs.rm(tmpRoot, { recursive: true, force: true }) - } - }) -}) From b64334b2bd728b093b75c8c97afd6aa3ecaaaace Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Fri, 20 Feb 2026 03:16:22 +0800 Subject: [PATCH 023/109] Add Gemini 3.1 Pro support and set Gemini default model (#11608) Add Gemini 3.1 model entries for Gemini and Vertex providers. Include the Gemini custom-tools endpoint model id in Gemini provider. Update geminiDefaultModelId to gemini-3.1-pro-preview. References: - https://ai.google.dev/gemini-api/docs/models/gemini-3.1-pro-preview - https://ai.google.dev/gemini-api/docs/pricing - https://ai.google.dev/gemini-api/docs/thinking - https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-1-pro - https://cloud.google.com/vertex-ai/generative-ai/pricing - https://cloud.google.com/blog/products/ai-machine-learning/gemini-3-1-pro-on-gemini-cli-gemini-enterprise-and-vertex-ai - https://deepmind.google/models/model-cards/gemini-3-1-pro/ - https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-3-1-pro/ --- packages/types/src/providers/gemini.ts | 60 +++++++++++++++++++++++++- packages/types/src/providers/vertex.ts | 29 +++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/types/src/providers/gemini.ts b/packages/types/src/providers/gemini.ts index 18aa2b7751..4734606d5d 100644 --- a/packages/types/src/providers/gemini.ts +++ b/packages/types/src/providers/gemini.ts @@ -3,9 +3,67 @@ import type { ModelInfo } from "../model.js" // https://ai.google.dev/gemini-api/docs/models/gemini export type GeminiModelId = keyof typeof geminiModels -export const geminiDefaultModelId: GeminiModelId = "gemini-3-pro-preview" +export const geminiDefaultModelId: GeminiModelId = "gemini-3.1-pro-preview" export const geminiModels = { + "gemini-3.1-pro-preview": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "low", + + supportsTemperature: true, + defaultTemperature: 1, + inputPrice: 4.0, + outputPrice: 18.0, + cacheReadsPrice: 0.4, + cacheWritesPrice: 4.5, + tiers: [ + { + contextWindow: 200_000, + inputPrice: 2.0, + outputPrice: 12.0, + cacheReadsPrice: 0.2, + }, + { + contextWindow: Infinity, + inputPrice: 4.0, + outputPrice: 18.0, + cacheReadsPrice: 0.4, + }, + ], + }, + "gemini-3.1-pro-preview-customtools": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "low", + + supportsTemperature: true, + defaultTemperature: 1, + inputPrice: 4.0, + outputPrice: 18.0, + cacheReadsPrice: 0.4, + cacheWritesPrice: 4.5, + tiers: [ + { + contextWindow: 200_000, + inputPrice: 2.0, + outputPrice: 12.0, + cacheReadsPrice: 0.2, + }, + { + contextWindow: Infinity, + inputPrice: 4.0, + outputPrice: 18.0, + cacheReadsPrice: 0.4, + }, + ], + }, "gemini-3-pro-preview": { maxTokens: 65_536, contextWindow: 1_048_576, diff --git a/packages/types/src/providers/vertex.ts b/packages/types/src/providers/vertex.ts index 2f8a05602a..b1291d2f59 100644 --- a/packages/types/src/providers/vertex.ts +++ b/packages/types/src/providers/vertex.ts @@ -6,6 +6,35 @@ export type VertexModelId = keyof typeof vertexModels export const vertexDefaultModelId: VertexModelId = "claude-sonnet-4-5@20250929" export const vertexModels = { + "gemini-3.1-pro-preview": { + maxTokens: 65_536, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "low", + + supportsTemperature: true, + defaultTemperature: 1, + inputPrice: 4.0, + outputPrice: 18.0, + cacheReadsPrice: 0.4, + cacheWritesPrice: 4.5, + tiers: [ + { + contextWindow: 200_000, + inputPrice: 2.0, + outputPrice: 12.0, + cacheReadsPrice: 0.2, + }, + { + contextWindow: Infinity, + inputPrice: 4.0, + outputPrice: 18.0, + cacheReadsPrice: 0.4, + }, + ], + }, "gemini-3-pro-preview": { maxTokens: 65_536, contextWindow: 1_048_576, From 8743020f4ec47fd75739b2e00e24ba3c00ef6926 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:04:18 -0700 Subject: [PATCH 024/109] Release v3.50.0 (#11609) * chore: add changeset for v3.50.0 * chore: add v3.50.0 announcement translations for all locales --------- Co-authored-by: Roo Code --- .changeset/v3.50.0.md | 9 +++++++++ src/core/webview/ClineProvider.ts | 2 +- webview-ui/src/components/chat/Announcement.tsx | 6 +++--- webview-ui/src/i18n/locales/ca/chat.json | 6 +++--- webview-ui/src/i18n/locales/de/chat.json | 6 +++--- webview-ui/src/i18n/locales/en/chat.json | 6 +++--- webview-ui/src/i18n/locales/es/chat.json | 6 +++--- webview-ui/src/i18n/locales/fr/chat.json | 6 +++--- webview-ui/src/i18n/locales/hi/chat.json | 6 +++--- webview-ui/src/i18n/locales/id/chat.json | 6 +++--- webview-ui/src/i18n/locales/it/chat.json | 6 +++--- webview-ui/src/i18n/locales/ja/chat.json | 6 +++--- webview-ui/src/i18n/locales/ko/chat.json | 6 +++--- webview-ui/src/i18n/locales/nl/chat.json | 6 +++--- webview-ui/src/i18n/locales/pl/chat.json | 6 +++--- webview-ui/src/i18n/locales/pt-BR/chat.json | 6 +++--- webview-ui/src/i18n/locales/ru/chat.json | 6 +++--- webview-ui/src/i18n/locales/tr/chat.json | 6 +++--- webview-ui/src/i18n/locales/vi/chat.json | 6 +++--- webview-ui/src/i18n/locales/zh-CN/chat.json | 6 +++--- webview-ui/src/i18n/locales/zh-TW/chat.json | 6 +++--- 21 files changed, 67 insertions(+), 58 deletions(-) create mode 100644 .changeset/v3.50.0.md diff --git a/.changeset/v3.50.0.md b/.changeset/v3.50.0.md new file mode 100644 index 0000000000..0ae4ec4aee --- /dev/null +++ b/.changeset/v3.50.0.md @@ -0,0 +1,9 @@ +--- +"roo-cline": minor +--- + +- Add Gemini 3.1 Pro support and set as default Gemini model (PR #11608 by @PeterDaveHello) +- Add NDJSON stdin protocol, list subcommands, and modularize CLI run command (PR #11597 by @cte) +- Prepare CLI v0.1.0 release (PR #11599 by @cte) +- Remove integration tests (PR #11598 by @roomote) +- Changeset version bump (PR #11596 by @github-actions) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0c9112a81a..f3fddf22ae 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -169,7 +169,7 @@ export class ClineProvider public isViewLaunched = false public settingsImportedAt?: number - public readonly latestAnnouncementId = "feb-2026-v3.49.0-file-changes-panel-indexing-controls-history-scroll" // v3.49.0 File Changes Panel, Workspace Indexing Controls, History & Scroll Stability + public readonly latestAnnouncementId = "feb-2026-v3.50.0-gemini-31-pro-cli-ndjson-cli-v010" // v3.50.0 Gemini 3.1 Pro Support, CLI NDJSON Protocol, CLI v0.1.0 public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager diff --git a/webview-ui/src/components/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index 7ce523c182..4f49f8230f 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -44,9 +44,9 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {

{t("chat:announcement.release.heading")}

    -
  • {t("chat:announcement.release.fileChangesPanel")}
  • -
  • {t("chat:announcement.release.workspaceIndexing")}
  • -
  • {t("chat:announcement.release.historyAndScroll")}
  • +
  • {t("chat:announcement.release.geminiPro")}
  • +
  • {t("chat:announcement.release.cliNdjson")}
  • +
  • {t("chat:announcement.release.cliRelease")}
diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 0b910a34f0..c5e42cf3d8 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "Què hi ha de nou:", - "fileChangesPanel": "Panell de canvis de fitxers: Fes un seguiment de totes les modificacions de fitxers fetes durant una conversa en un panell dedicat, facilitant la revisió dels canvis.", - "workspaceIndexing": "Controls d'indexació de l'espai de treball: La indexació ja no s'inicia automàticament — activa-la per espai de treball amb un simple commutador, i atura o cancel·la la indexació en qualsevol moment.", - "historyAndScroll": "Estabilitat de l'historial i el desplaçament: Emmagatzematge d'historial basat en fitxers per tasca per a seguretat entre instàncies, més un cicle de vida de desplaçament redissenyat per a una rehidratació del xat més fluida." + "geminiPro": "Suport per Gemini 3.1 Pro: S'ha afegit el suport per al model Gemini 3.1 Pro i s'ha establert com a model Gemini per defecte per a un millor rendiment.", + "cliNdjson": "Protocol NDJSON del CLI: Nou protocol NDJSON via stdin, subcomandes de llistat i comanda d'execució modularitzada per a fluxos de treball CLI més flexibles.", + "cliRelease": "CLI v0.1.0: El CLI de Roo Code arriba a la seva primera versió oficial amb una interfície de comandes estable." }, "cloudAgents": { "heading": "Novetats al núvol:", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index fd6abb0413..8d5de267f5 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "Was ist neu:", - "fileChangesPanel": "Dateiänderungen-Panel: Verfolge alle Dateiänderungen einer Konversation in einem eigenen Panel – so siehst du auf einen Blick, was sich geändert hat.", - "workspaceIndexing": "Workspace-Indexierung: Die Indexierung startet nicht mehr automatisch – aktiviere sie pro Workspace mit einem einfachen Schalter, und stoppe oder brich die Indexierung jederzeit ab.", - "historyAndScroll": "Verlauf & Scroll-Stabilität: Dateibasierter Verlaufsspeicher pro Aufgabe für Sicherheit über Instanzen hinweg, plus ein überarbeiteter Scroll-Lebenszyklus für flüssigere Chat-Rehydrierung." + "geminiPro": "Gemini 3.1 Pro Unterstützung: Gemini 3.1 Pro wurde als Modell hinzugefügt und als Standard-Gemini-Modell für bessere Leistung festgelegt.", + "cliNdjson": "CLI NDJSON-Protokoll: Neues NDJSON-stdin-Protokoll, List-Unterbefehle und modularisierter Run-Befehl für flexiblere CLI-Workflows.", + "cliRelease": "CLI v0.1.0: Das Roo Code CLI erreicht seine erste offizielle Veröffentlichung mit einer stabilen Befehlsoberfläche." }, "cloudAgents": { "heading": "Neu in der Cloud:", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index cd0864d063..fa23c7e466 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -369,9 +369,9 @@ }, "release": { "heading": "What's New:", - "fileChangesPanel": "File Changes Panel: Track all file modifications made during a conversation in a dedicated panel, making it easy to review what changed.", - "workspaceIndexing": "Workspace Indexing Controls: Indexing no longer auto-starts — opt in per workspace with a simple toggle, and stop or cancel indexing at any time.", - "historyAndScroll": "History & Scroll Stability: Per-task file-based history store for cross-instance safety, plus a redesigned scroll lifecycle for smoother chat rehydration." + "geminiPro": "Gemini 3.1 Pro Support: Added Gemini 3.1 Pro model support and set it as the default Gemini model for improved performance.", + "cliNdjson": "CLI NDJSON Protocol: New NDJSON stdin protocol, list subcommands, and modularized run command for more flexible CLI workflows.", + "cliRelease": "CLI v0.1.0: The Roo Code CLI reaches its first official release with a stable command interface." }, "cloudAgents": { "heading": "New in the Cloud:", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index f415294d72..13b61d9585 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "Qué hay de nuevo:", - "fileChangesPanel": "Panel de cambios de archivos: Rastrea todas las modificaciones de archivos realizadas durante una conversación en un panel dedicado, facilitando la revisión de los cambios.", - "workspaceIndexing": "Controles de indexación del espacio de trabajo: La indexación ya no se inicia automáticamente — actívala por espacio de trabajo con un simple interruptor, y detén o cancela la indexación en cualquier momento.", - "historyAndScroll": "Estabilidad del historial y desplazamiento: Almacenamiento de historial basado en archivos por tarea para seguridad entre instancias, además de un ciclo de vida de desplazamiento rediseñado para una rehidratación del chat más fluida." + "geminiPro": "Soporte para Gemini 3.1 Pro: Se añadió soporte para el modelo Gemini 3.1 Pro y se estableció como modelo Gemini predeterminado para un mejor rendimiento.", + "cliNdjson": "Protocolo NDJSON del CLI: Nuevo protocolo NDJSON por stdin, subcomandos de listado y comando de ejecución modularizado para flujos de trabajo CLI más flexibles.", + "cliRelease": "CLI v0.1.0: El CLI de Roo Code alcanza su primera versión oficial con una interfaz de comandos estable." }, "cloudAgents": { "heading": "Novedades en la Nube:", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 50e81f1fb1..bf9c524cc2 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "Quoi de neuf :", - "fileChangesPanel": "Panneau des modifications de fichiers : Suis toutes les modifications de fichiers effectuées pendant une conversation dans un panneau dédié, pour voir facilement ce qui a changé.", - "workspaceIndexing": "Contrôles d'indexation de l'espace de travail : L'indexation ne démarre plus automatiquement — active-la par espace de travail avec un simple bouton, et arrête ou annule l'indexation à tout moment.", - "historyAndScroll": "Stabilité de l'historique et du défilement : Stockage d'historique basé sur des fichiers par tâche pour la sécurité multi-instances, plus un cycle de vie de défilement repensé pour une réhydratation du chat plus fluide." + "geminiPro": "Support de Gemini 3.1 Pro : Le modèle Gemini 3.1 Pro a été ajouté et défini comme modèle Gemini par défaut pour de meilleures performances.", + "cliNdjson": "Protocole NDJSON du CLI : Nouveau protocole NDJSON via stdin, sous-commandes de liste et commande d'exécution modularisée pour des workflows CLI plus flexibles.", + "cliRelease": "CLI v0.1.0 : Le CLI de Roo Code atteint sa première version officielle avec une interface de commandes stable." }, "cloudAgents": { "heading": "Nouveautés dans le Cloud :", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 22d5c2be6f..71c70c4201 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "नया क्या है:", - "fileChangesPanel": "फ़ाइल परिवर्तन पैनल: एक समर्पित पैनल में बातचीत के दौरान की गई सभी फ़ाइल संशोधनों को ट्रैक करो, ताकि क्या बदला यह देखना आसान हो।", - "workspaceIndexing": "वर्कस्पेस इंडेक्सिंग नियंत्रण: इंडेक्सिंग अब स्वचालित रूप से शुरू नहीं होती — एक साधारण टॉगल से प्रति वर्कस्पेस सक्रिय करो, और किसी भी समय इंडेक्सिंग रोको या रद्द करो।", - "historyAndScroll": "इतिहास और स्क्रॉल स्थिरता: क्रॉस-इंस्टेंस सुरक्षा के लिए प्रति-कार्य फ़ाइल-आधारित इतिहास स्टोर, साथ ही चैट रीहाइड्रेशन को आसान बनाने के लिए एक पुनर्डिज़ाइन किया गया स्क्रॉल जीवनचक्र।" + "geminiPro": "Gemini 3.1 Pro सपोर्ट: Gemini 3.1 Pro मॉडल का सपोर्ट जोड़ा गया और बेहतर प्रदर्शन के लिए इसे डिफ़ॉल्ट Gemini मॉडल के रूप में सेट किया गया।", + "cliNdjson": "CLI NDJSON प्रोटोकॉल: नया NDJSON stdin प्रोटोकॉल, list सबकमांड, और अधिक लचीले CLI वर्कफ़्लो के लिए मॉड्यूलर run कमांड।", + "cliRelease": "CLI v0.1.0: Roo Code CLI एक स्थिर कमांड इंटरफ़ेस के साथ अपनी पहली आधिकारिक रिलीज़ तक पहुँचा।" }, "cloudAgents": { "heading": "क्लाउड में नया:", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 437b046425..f442d6fe10 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -379,9 +379,9 @@ }, "release": { "heading": "Yang Baru:", - "fileChangesPanel": "Panel Perubahan File: Lacak semua perubahan file yang dilakukan selama percakapan di panel khusus, sehingga mudah untuk meninjau apa yang berubah.", - "workspaceIndexing": "Kontrol Pengindeksan Workspace: Pengindeksan tidak lagi dimulai otomatis — aktifkan per workspace dengan tombol sederhana, dan hentikan atau batalkan pengindeksan kapan saja.", - "historyAndScroll": "Stabilitas Riwayat & Scroll: Penyimpanan riwayat berbasis file per tugas untuk keamanan lintas instansi, ditambah siklus hidup scroll yang didesain ulang untuk rehidrasi chat yang lebih mulus." + "geminiPro": "Dukungan Gemini 3.1 Pro: Dukungan model Gemini 3.1 Pro telah ditambahkan dan ditetapkan sebagai model Gemini default untuk performa yang lebih baik.", + "cliNdjson": "Protokol NDJSON CLI: Protokol NDJSON stdin baru, subperintah list, dan perintah run yang dimodularisasi untuk alur kerja CLI yang lebih fleksibel.", + "cliRelease": "CLI v0.1.0: CLI Roo Code mencapai rilis resmi pertamanya dengan antarmuka perintah yang stabil." }, "cloudAgents": { "heading": "Baru di Cloud:", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 5cfde15de9..28f8eba54d 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "Novità:", - "fileChangesPanel": "Pannello modifiche file: Tieni traccia di tutte le modifiche ai file effettuate durante una conversazione in un pannello dedicato, rendendo facile vedere cosa è cambiato.", - "workspaceIndexing": "Controlli indicizzazione workspace: L'indicizzazione non parte più automaticamente — attivala per workspace con un semplice interruttore, e ferma o annulla l'indicizzazione in qualsiasi momento.", - "historyAndScroll": "Stabilità cronologia e scorrimento: Archivio cronologia basato su file per ogni attività per la sicurezza tra istanze, più un ciclo di vita dello scorrimento ridisegnato per una reidratazione della chat più fluida." + "geminiPro": "Supporto Gemini 3.1 Pro: Aggiunto il supporto per il modello Gemini 3.1 Pro e impostato come modello Gemini predefinito per prestazioni migliori.", + "cliNdjson": "Protocollo NDJSON del CLI: Nuovo protocollo NDJSON via stdin, sottocomandi di elenco e comando di esecuzione modularizzato per flussi di lavoro CLI più flessibili.", + "cliRelease": "CLI v0.1.0: Il CLI di Roo Code raggiunge la sua prima release ufficiale con un'interfaccia di comandi stabile." }, "cloudAgents": { "heading": "Novità nel Cloud:", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 232b74afed..6d581dbea4 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "新機能:", - "fileChangesPanel": "ファイル変更パネル: 会話中に行われたすべてのファイル変更を専用パネルで追跡し、何が変わったかを簡単に確認できます。", - "workspaceIndexing": "ワークスペースインデックス制御: インデックスは自動的に開始されなくなりました — シンプルなトグルでワークスペースごとに有効化し、いつでもインデックスを停止またはキャンセルできます。", - "historyAndScroll": "履歴とスクロールの安定性: インスタンス間の安全性のためのタスクごとのファイルベース履歴ストア、さらにスムーズなチャット再ハイドレーションのための再設計されたスクロールライフサイクル。" + "geminiPro": "Gemini 3.1 Pro サポート: Gemini 3.1 Pro モデルのサポートを追加し、パフォーマンス向上のためデフォルトの Gemini モデルに設定しました。", + "cliNdjson": "CLI NDJSON プロトコル: 新しい NDJSON stdin プロトコル、list サブコマンド、より柔軟な CLI ワークフローのためのモジュール化された run コマンド。", + "cliRelease": "CLI v0.1.0: Roo Code CLI が安定したコマンドインターフェースで初の正式リリースに到達しました。" }, "cloudAgents": { "heading": "クラウドの新機能:", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index f2c1ae1599..415481f804 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "새로운 기능:", - "fileChangesPanel": "파일 변경 패널: 대화 중에 이루어진 모든 파일 수정 사항을 전용 패널에서 추적하여, 무엇이 변경되었는지 쉽게 확인할 수 있습니다.", - "workspaceIndexing": "워크스페이스 인덱싱 제어: 인덱싱이 더 이상 자동으로 시작되지 않습니다 — 간단한 토글로 워크스페이스별로 활성화하고, 언제든지 인덱싱을 중지하거나 취소할 수 있습니다.", - "historyAndScroll": "기록 및 스크롤 안정성: 인스턴스 간 안전을 위한 작업별 파일 기반 기록 저장소와 더 부드러운 채팅 리하이드레이션을 위한 재설계된 스크롤 라이프사이클." + "geminiPro": "Gemini 3.1 Pro 지원: Gemini 3.1 Pro 모델 지원이 추가되었으며, 향상된 성능을 위해 기본 Gemini 모델로 설정되었습니다.", + "cliNdjson": "CLI NDJSON 프로토콜: 새로운 NDJSON stdin 프로토콜, list 하위 명령어, 더 유연한 CLI 워크플로를 위한 모듈화된 run 명령어.", + "cliRelease": "CLI v0.1.0: Roo Code CLI가 안정적인 명령어 인터페이스로 첫 공식 릴리스에 도달했습니다." }, "cloudAgents": { "heading": "클라우드의 새로운 기능:", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 2d61daf0eb..1095670a35 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -323,9 +323,9 @@ }, "release": { "heading": "Wat is er nieuw:", - "fileChangesPanel": "Bestandswijzigingen-paneel: Volg alle bestandswijzigingen die tijdens een gesprek zijn gemaakt in een speciaal paneel, zodat je eenvoudig kunt zien wat er is gewijzigd.", - "workspaceIndexing": "Workspace-indexering: Indexering start niet meer automatisch — schakel het per workspace in met een simpele schakelaar, en stop of annuleer de indexering op elk moment.", - "historyAndScroll": "Geschiedenis & scroll-stabiliteit: Bestandsgebaseerde geschiedenisopslag per taak voor veiligheid tussen instanties, plus een herontworpen scroll-levenscyclus voor soepelere chat-rehydratie." + "geminiPro": "Gemini 3.1 Pro ondersteuning: Ondersteuning voor het Gemini 3.1 Pro model is toegevoegd en ingesteld als standaard Gemini-model voor betere prestaties.", + "cliNdjson": "CLI NDJSON-protocol: Nieuw NDJSON stdin-protocol, list-subopdrachten en gemodulariseerd run-commando voor flexibelere CLI-workflows.", + "cliRelease": "CLI v0.1.0: De Roo Code CLI bereikt zijn eerste officiële release met een stabiele opdrachtinterface." }, "cloudAgents": { "heading": "Nieuw in de Cloud:", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 1300ffa70b..37998d0d16 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "Co nowego:", - "fileChangesPanel": "Panel zmian plików: Śledź wszystkie modyfikacje plików dokonane podczas rozmowy w dedykowanym panelu, ułatwiając przegląd tego, co się zmieniło.", - "workspaceIndexing": "Kontrola indeksowania workspace: Indeksowanie nie uruchamia się już automatycznie — włącz je per workspace prostym przełącznikiem i zatrzymaj lub anuluj indeksowanie w dowolnym momencie.", - "historyAndScroll": "Stabilność historii i przewijania: Plikowe przechowywanie historii per zadanie dla bezpieczeństwa między instancjami, plus przeprojektowany cykl życia przewijania dla płynniejszej rehydratacji czatu." + "geminiPro": "Wsparcie dla Gemini 3.1 Pro: Dodano wsparcie dla modelu Gemini 3.1 Pro i ustawiono go jako domyślny model Gemini dla lepszej wydajności.", + "cliNdjson": "Protokół NDJSON w CLI: Nowy protokół NDJSON przez stdin, podkomendy list oraz zmodularyzowana komenda run dla bardziej elastycznych przepływów pracy CLI.", + "cliRelease": "CLI v0.1.0: CLI Roo Code osiąga swoją pierwszą oficjalną wersję ze stabilnym interfejsem poleceń." }, "cloudAgents": { "heading": "Nowości w chmurze:", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index f5282b1600..afda26c2e6 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -350,9 +350,9 @@ }, "release": { "heading": "Novidades:", - "fileChangesPanel": "Painel de alterações de arquivos: Acompanhe todas as modificações de arquivos feitas durante uma conversa em um painel dedicado, facilitando a revisão do que mudou.", - "workspaceIndexing": "Controles de indexação do workspace: A indexação não inicia mais automaticamente — ative por workspace com um simples botão, e pare ou cancele a indexação a qualquer momento.", - "historyAndScroll": "Estabilidade do histórico e rolagem: Armazenamento de histórico baseado em arquivos por tarefa para segurança entre instâncias, além de um ciclo de vida de rolagem redesenhado para uma reidratação do chat mais suave." + "geminiPro": "Suporte ao Gemini 3.1 Pro: Adicionado suporte ao modelo Gemini 3.1 Pro e definido como modelo Gemini padrão para melhor desempenho.", + "cliNdjson": "Protocolo NDJSON do CLI: Novo protocolo NDJSON via stdin, subcomandos de listagem e comando de execução modularizado para fluxos de trabalho CLI mais flexíveis.", + "cliRelease": "CLI v0.1.0: O CLI do Roo Code alcança sua primeira versão oficial com uma interface de comandos estável." }, "cloudAgents": { "heading": "Novidades na Nuvem:", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 09c8e0ecaa..b66e9c40a2 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -324,9 +324,9 @@ }, "release": { "heading": "Что нового:", - "fileChangesPanel": "Панель изменений файлов: Отслеживай все изменения файлов, сделанные во время разговора, в специальной панели — легко увидеть, что изменилось.", - "workspaceIndexing": "Управление индексацией рабочего пространства: Индексация больше не запускается автоматически — включи её для каждого рабочего пространства простым переключателем и останови или отмени индексацию в любой момент.", - "historyAndScroll": "Стабильность истории и прокрутки: Файловое хранилище истории для каждой задачи для безопасности между экземплярами, плюс переработанный жизненный цикл прокрутки для более плавной регидратации чата." + "geminiPro": "Поддержка Gemini 3.1 Pro: Добавлена поддержка модели Gemini 3.1 Pro и установлена как модель Gemini по умолчанию для лучшей производительности.", + "cliNdjson": "Протокол NDJSON в CLI: Новый протокол NDJSON через stdin, подкоманды list и модульная команда run для более гибких CLI-процессов.", + "cliRelease": "CLI v0.1.0: CLI Roo Code достиг своего первого официального релиза со стабильным интерфейсом командной строки." }, "cloudAgents": { "heading": "Новое в облаке:", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 03da0dce05..647f57bf17 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -351,9 +351,9 @@ }, "release": { "heading": "Yenilikler:", - "fileChangesPanel": "Dosya Değişiklikleri Paneli: Bir konuşma sırasında yapılan tüm dosya değişikliklerini özel bir panelde takip et, neyin değiştiğini kolayca gözden geçir.", - "workspaceIndexing": "Çalışma Alanı İndeksleme Kontrolleri: İndeksleme artık otomatik başlamıyor — basit bir düğmeyle çalışma alanı başına etkinleştir ve istediğin zaman indekslemeyi durdur veya iptal et.", - "historyAndScroll": "Geçmiş ve Kaydırma Kararlılığı: Örnekler arası güvenlik için görev bazlı dosya tabanlı geçmiş deposu, ayrıca daha akıcı sohbet yeniden yüklemesi için yeniden tasarlanmış kaydırma yaşam döngüsü." + "geminiPro": "Gemini 3.1 Pro Desteği: Gemini 3.1 Pro model desteği eklendi ve daha iyi performans için varsayılan Gemini modeli olarak ayarlandı.", + "cliNdjson": "CLI NDJSON Protokolü: Yeni NDJSON stdin protokolü, list alt komutları ve daha esnek CLI iş akışları için modüler run komutu.", + "cliRelease": "CLI v0.1.0: Roo Code CLI, kararlı bir komut arayüzüyle ilk resmi sürümüne ulaştı." }, "cloudAgents": { "heading": "Cloud'daki yenilikler:", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index cc621b9b1e..9d56f1885e 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -351,9 +351,9 @@ }, "release": { "heading": "Tính năng mới:", - "fileChangesPanel": "Bảng thay đổi tệp: Theo dõi tất cả các sửa đổi tệp được thực hiện trong cuộc trò chuyện trong một bảng chuyên dụng, giúp dễ dàng xem lại những gì đã thay đổi.", - "workspaceIndexing": "Điều khiển lập chỉ mục Workspace: Lập chỉ mục không còn tự động bắt đầu — bật cho từng workspace bằng một nút đơn giản, và dừng hoặc hủy lập chỉ mục bất cứ lúc nào.", - "historyAndScroll": "Ổn định lịch sử & cuộn: Lưu trữ lịch sử dựa trên tệp cho từng tác vụ để đảm bảo an toàn giữa các phiên, cộng với vòng đời cuộn được thiết kế lại để tải lại cuộc trò chuyện mượt mà hơn." + "geminiPro": "Hỗ trợ Gemini 3.1 Pro: Đã thêm hỗ trợ model Gemini 3.1 Pro và đặt làm model Gemini mặc định để cải thiện hiệu suất.", + "cliNdjson": "Giao thức NDJSON CLI: Giao thức NDJSON stdin mới, lệnh con list, và lệnh run được module hóa cho quy trình CLI linh hoạt hơn.", + "cliRelease": "CLI v0.1.0: CLI Roo Code đạt bản phát hành chính thức đầu tiên với giao diện lệnh ổn định." }, "cloudAgents": { "heading": "Mới trên Cloud:", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index b0205e89c8..22994cbb07 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -351,9 +351,9 @@ }, "release": { "heading": "新增功能:", - "fileChangesPanel": "档案变更面板:在专用面板中追踪对话期间所有档案修改,方便查看哪些内容发生了变化。", - "workspaceIndexing": "工作区索引控制:索引不再自动启动——通过简单开关按工作区启用,随时停止或取消索引。", - "historyAndScroll": "历史记录与滚动稳定性:基于文件的按任务历史存储确保跨实例安全,加上重新设计的滚动生命周期让聊天重载更流畅。" + "geminiPro": "Gemini 3.1 Pro 支持:添加了 Gemini 3.1 Pro 模型支持,并将其设为默认 Gemini 模型以提升性能。", + "cliNdjson": "CLI NDJSON 协议:新增 NDJSON stdin 协议、list 子命令,以及模块化的 run 命令,提供更灵活的 CLI 工作流。", + "cliRelease": "CLI v0.1.0:Roo Code CLI 以稳定的命令接口达成首个正式版本。" }, "cloudAgents": { "heading": "云端新功能:", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 68054a3722..00f57d369d 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -372,9 +372,9 @@ }, "release": { "heading": "新增功能:", - "fileChangesPanel": "檔案變更面板:在專用面板中追蹤對話期間所有檔案修改,方便查看哪些內容發生了變化。", - "workspaceIndexing": "工作區索引控制:索引不再自動啟動——透過簡單開關按工作區啟用,隨時停止或取消索引。", - "historyAndScroll": "歷史紀錄與捲動穩定性:基於檔案的按工作歷史儲存確保跨實例安全,加上重新設計的捲動生命週期讓聊天重載更流暢。" + "geminiPro": "Gemini 3.1 Pro 支援:新增 Gemini 3.1 Pro 模型支援,並設為預設 Gemini 模型以提升效能。", + "cliNdjson": "CLI NDJSON 協定:新增 NDJSON stdin 協定、list 子指令,以及模組化的 run 指令,提供更彈性的 CLI 工作流程。", + "cliRelease": "CLI v0.1.0:Roo Code CLI 以穩定的指令介面達成首個正式版本。" }, "cloudAgents": { "heading": "雲端的新功能:", From d8cfbfdb054a87f6b2677add196809566ccdf66a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:18:17 -0700 Subject: [PATCH 025/109] Changeset version bump (#11610) changeset version bump Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/v3.50.0.md | 9 --------- CHANGELOG.md | 10 ++++++++++ src/package.json | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) delete mode 100644 .changeset/v3.50.0.md diff --git a/.changeset/v3.50.0.md b/.changeset/v3.50.0.md deleted file mode 100644 index 0ae4ec4aee..0000000000 --- a/.changeset/v3.50.0.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"roo-cline": minor ---- - -- Add Gemini 3.1 Pro support and set as default Gemini model (PR #11608 by @PeterDaveHello) -- Add NDJSON stdin protocol, list subcommands, and modularize CLI run command (PR #11597 by @cte) -- Prepare CLI v0.1.0 release (PR #11599 by @cte) -- Remove integration tests (PR #11598 by @roomote) -- Changeset version bump (PR #11596 by @github-actions) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4671571a67..494d88331b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Roo Code Changelog +## 3.50.0 + +### Minor Changes + +- Add Gemini 3.1 Pro support and set as default Gemini model (PR #11608 by @PeterDaveHello) +- Add NDJSON stdin protocol, list subcommands, and modularize CLI run command (PR #11597 by @cte) +- Prepare CLI v0.1.0 release (PR #11599 by @cte) +- Remove integration tests (PR #11598 by @roomote) +- Changeset version bump (PR #11596 by @github-actions) + ## 3.49.0 ### Minor Changes diff --git a/src/package.json b/src/package.json index 236bfe04ac..a41b6682e5 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.49.0", + "version": "3.50.0", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", From 159bf2e9f12b099682715bb2f135ffccb9f90bf4 Mon Sep 17 00:00:00 2001 From: rossdonald <49425722+rossdonald@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:19:20 +1100 Subject: [PATCH 026/109] fix: make settings search results same width as search input (#11617) Changed the settings search results to be the same width as the search input. This ensures the results dropdown does not overflow outside of the parent panel. --- webview-ui/src/components/settings/SettingsSearch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/components/settings/SettingsSearch.tsx b/webview-ui/src/components/settings/SettingsSearch.tsx index a0cce1fefa..bd4f8171ce 100644 --- a/webview-ui/src/components/settings/SettingsSearch.tsx +++ b/webview-ui/src/components/settings/SettingsSearch.tsx @@ -108,7 +108,7 @@ export function SettingsSearch({ index, onNavigate, sections }: SettingsSearchPr inputRef={inputRef} /> {searchQuery && isOpen && ( -
+
Date: Thu, 19 Feb 2026 16:33:39 -0800 Subject: [PATCH 027/109] feat: remove Roomote Control from extension (#11271) * feat: remove Roomote Control from extension Remove all Roomote Control (remote control) functionality: - Remove BridgeOrchestrator and entire bridge directory from @roo-code/cloud - Remove remoteControlEnabled, featureRoomoteControlEnabled from extension state - Remove extensionBridgeEnabled from CloudUserInfo and user settings - Remove roomoteControlEnabled from organization/user feature schemas - Remove enableBridge from Task and ClineProvider - Remove remote control toggle from CloudView UI - Remove remoteControlEnabled message handler - Remove extension bridge disconnect on logout/deactivate - Update CloudTaskButton to show for all logged-in users - Remove remote control translation strings from all locales - Update all related tests CLO-765 * fix: remove dead getOrganizationMetadata and unused socket.io-client dep * Readmes * Readmes * Types * fix: remove leftover Roomote Control references from locale READMEs and stale BridgeOrchestrator mock * Removes cloudtaskbutton * fix: remove orphaned qrcode packages and dead openInCloud translation keys * pnpmlock * Revert these * Revert these * Revert these * Remove socket.io --------- Co-authored-by: Roo Code Co-authored-by: Bruno Bergher Co-authored-by: cte --- README.md | 13 +- locales/ca/README.md | 7 +- locales/de/README.md | 7 +- locales/es/README.md | 7 +- locales/fr/README.md | 7 +- locales/hi/README.md | 7 +- locales/id/README.md | 7 +- locales/it/README.md | 7 +- locales/ja/README.md | 7 +- locales/ko/README.md | 7 +- locales/nl/README.md | 5 +- locales/pl/README.md | 7 +- locales/pt-BR/README.md | 7 +- locales/ru/README.md | 7 +- locales/tr/README.md | 13 +- locales/vi/README.md | 13 +- locales/zh-CN/README.md | 11 +- locales/zh-TW/README.md | 5 +- packages/cloud/package.json | 1 - packages/cloud/src/StaticSettingsService.ts | 12 +- packages/cloud/src/StaticTokenAuthService.ts | 1 - packages/cloud/src/WebAuthService.ts | 41 -- .../CloudSettingsService.parsing.test.ts | 8 +- .../__tests__/StaticTokenAuthService.spec.ts | 19 +- .../src/__tests__/WebAuthService.spec.ts | 5 - packages/cloud/src/bridge/BaseChannel.ts | 142 ------ .../cloud/src/bridge/BridgeOrchestrator.ts | 355 --------------- packages/cloud/src/bridge/ExtensionChannel.ts | 282 ------------ packages/cloud/src/bridge/SocketTransport.ts | 281 ------------ packages/cloud/src/bridge/TaskChannel.ts | 241 ----------- .../bridge/__tests__/ExtensionChannel.test.ts | 402 ----------------- .../src/bridge/__tests__/TaskChannel.test.ts | 407 ------------------ packages/cloud/src/bridge/index.ts | 6 - packages/cloud/src/index.ts | 2 - packages/types/src/__tests__/cloud.test.ts | 90 +--- packages/types/src/cloud.ts | 210 +-------- packages/types/src/vscode-extension-host.ts | 3 - pnpm-lock.yaml | 204 --------- src/__tests__/extension.spec.ts | 81 +--- src/__tests__/single-open-invariant.spec.ts | 2 - src/core/task/Task.ts | 38 +- .../task/__tests__/grounding-sources.test.ts | 4 - .../__tests__/reasoning-preservation.test.ts | 4 - .../tools/__tests__/useMcpToolTool.spec.ts | 42 +- src/core/webview/ClineProvider.ts | 100 +---- .../ClineProvider.apiHandlerRebuild.spec.ts | 3 - .../ClineProvider.flicker-free-cancel.spec.ts | 3 - .../ClineProvider.lockApiConfig.spec.ts | 3 - .../webview/__tests__/ClineProvider.spec.ts | 5 - .../ClineProvider.sticky-mode.spec.ts | 3 - .../ClineProvider.sticky-profile.spec.ts | 3 - .../ClineProvider.taskHistory.spec.ts | 3 - src/core/webview/webviewMessageHandler.ts | 15 - src/extension.ts | 43 +- src/package.json | 1 - .../__tests__/ShadowCheckpointService.spec.ts | 8 +- webview-ui/package.json | 2 - .../src/components/chat/CloudTaskButton.tsx | 126 ------ .../src/components/chat/TaskActions.tsx | 2 - .../chat/__tests__/CloudTaskButton.spec.tsx | 234 ---------- webview-ui/src/components/cloud/CloudView.tsx | 44 +- .../cloud/__tests__/CloudView.spec.tsx | 84 ---- .../marketplace/MarketplaceView.tsx | 2 +- .../src/context/ExtensionStateContext.tsx | 11 - .../__tests__/ExtensionStateContext.spec.tsx | 4 - webview-ui/src/i18n/locales/ca/chat.json | 2 - webview-ui/src/i18n/locales/ca/cloud.json | 3 - webview-ui/src/i18n/locales/de/chat.json | 2 - webview-ui/src/i18n/locales/de/cloud.json | 3 - webview-ui/src/i18n/locales/en/chat.json | 2 - webview-ui/src/i18n/locales/en/cloud.json | 3 - webview-ui/src/i18n/locales/es/chat.json | 2 - webview-ui/src/i18n/locales/es/cloud.json | 3 - webview-ui/src/i18n/locales/fr/chat.json | 2 - webview-ui/src/i18n/locales/fr/cloud.json | 3 - webview-ui/src/i18n/locales/hi/chat.json | 2 - webview-ui/src/i18n/locales/hi/cloud.json | 3 - webview-ui/src/i18n/locales/id/chat.json | 2 - webview-ui/src/i18n/locales/id/cloud.json | 3 - webview-ui/src/i18n/locales/it/chat.json | 2 - webview-ui/src/i18n/locales/it/cloud.json | 3 - webview-ui/src/i18n/locales/ja/chat.json | 2 - webview-ui/src/i18n/locales/ja/cloud.json | 3 - webview-ui/src/i18n/locales/ko/chat.json | 2 - webview-ui/src/i18n/locales/ko/cloud.json | 3 - webview-ui/src/i18n/locales/nl/chat.json | 2 - webview-ui/src/i18n/locales/nl/cloud.json | 3 - webview-ui/src/i18n/locales/pl/chat.json | 2 - webview-ui/src/i18n/locales/pl/cloud.json | 3 - webview-ui/src/i18n/locales/pt-BR/chat.json | 2 - webview-ui/src/i18n/locales/pt-BR/cloud.json | 3 - webview-ui/src/i18n/locales/ru/chat.json | 2 - webview-ui/src/i18n/locales/ru/cloud.json | 3 - webview-ui/src/i18n/locales/tr/chat.json | 2 - webview-ui/src/i18n/locales/tr/cloud.json | 3 - webview-ui/src/i18n/locales/vi/chat.json | 2 - webview-ui/src/i18n/locales/vi/cloud.json | 3 - webview-ui/src/i18n/locales/zh-CN/chat.json | 2 - webview-ui/src/i18n/locales/zh-CN/cloud.json | 3 - webview-ui/src/i18n/locales/zh-TW/chat.json | 2 - webview-ui/src/i18n/locales/zh-TW/cloud.json | 3 - 101 files changed, 108 insertions(+), 3708 deletions(-) delete mode 100644 packages/cloud/src/bridge/BaseChannel.ts delete mode 100644 packages/cloud/src/bridge/BridgeOrchestrator.ts delete mode 100644 packages/cloud/src/bridge/ExtensionChannel.ts delete mode 100644 packages/cloud/src/bridge/SocketTransport.ts delete mode 100644 packages/cloud/src/bridge/TaskChannel.ts delete mode 100644 packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts delete mode 100644 packages/cloud/src/bridge/__tests__/TaskChannel.test.ts delete mode 100644 packages/cloud/src/bridge/index.ts delete mode 100644 webview-ui/src/components/chat/CloudTaskButton.tsx delete mode 100644 webview-ui/src/components/chat/__tests__/CloudTaskButton.spec.tsx diff --git a/README.md b/README.md index 75f37762f9..6f024db235 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ - [简体中文](locales/zh-CN/README.md) - [繁體中文](locales/zh-TW/README.md) - ... - + --- @@ -58,18 +58,17 @@ Roo Code adapts to how you work: - Ask Mode: fast answers, explanations, and docs - Debug Mode: trace issues, add logs, isolate root causes - Custom Modes: build specialized modes for your team or workflow -- Roomote Control: Roomote Control lets you remotely control tasks running in your local VS Code instance. -Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) • [Custom Modes](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) • [Custom Modes](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutorial & Feature Videos
-| | | | -| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
Installing Roo Code |
Configuring Profiles |
Codebase Indexing | -|
Custom Modes |
Checkpoints |
Context Management | +| | | | +| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +|
Installing Roo Code |
Configuring Profiles |
Codebase Indexing | +|
Custom Modes |
Checkpoints |
Context Management |

diff --git a/locales/ca/README.md b/locales/ca/README.md index 0c09d8c661..be4aae4cc0 100644 --- a/locales/ca/README.md +++ b/locales/ca/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,9 +58,8 @@ Roo Code s'adapta a la teva manera de treballar, no a l'inrevés: - Mode Pregunta: respostes ràpides, explicacions i documents - Mode Depuració: rastrejar problemes, afegir registres, aïllar les causes arrel - Modes personalitzats: crea modes especialitzats per al teu equip o flux de treball -- Roomote Control: Roomote Control et permet controlar a distància tasques que s'executen a la teva instància local de VS Code. -Més informació: [Ús de Modes](https://docs.roocode.com/basic-usage/using-modes) • [Modes personalitzats](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Més informació: [Ús de Modes](https://docs.roocode.com/basic-usage/using-modes) • [Modes personalitzats](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutorials i vídeos de funcionalitats @@ -69,7 +68,7 @@ Més informació: [Ús de Modes](https://docs.roocode.com/basic-usage/using-mode | | | | | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Instal·lant Roo Code |
Configurant perfils |
Indexació de la base de codi | -|
Modes personalitzats |
Punts de control |
Gestió de Context | +|
Modes personalitzats |
Punts de control |
Gestió de Context |

diff --git a/locales/de/README.md b/locales/de/README.md index 526d601e70..634996646d 100644 --- a/locales/de/README.md +++ b/locales/de/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,9 +58,8 @@ Roo Code passt sich an deine Arbeitsweise an, nicht umgekehrt: - Fragen-Modus: schnelle Antworten, Erklärungen und Dokumentationen - Debug-Modus: Probleme aufspüren, Protokolle hinzufügen, Ursachen isolieren - Benutzerdefinierte Modi: erstelle spezialisierte Modi für dein Team oder deinen Workflow -- Roomote Control: Mit Roomote Control kannst du Aufgaben in deiner lokalen VS Code-Instanz aus der Ferne steuern. -Mehr erfahren: [Modi verwenden](https://docs.roocode.com/basic-usage/using-modes) • [Benutzerdefinierte Modi](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Mehr erfahren: [Modi verwenden](https://docs.roocode.com/basic-usage/using-modes) • [Benutzerdefinierte Modi](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutorial- & Feature-Videos @@ -69,7 +68,7 @@ Mehr erfahren: [Modi verwenden](https://docs.roocode.com/basic-usage/using-modes | | | | | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Roo Code installieren |
Profile konfigurieren |
Codebasis-Indizierung | -|
Benutzerdefinierte Modi |
Checkpoints |
Kontextverwaltung | +|
Benutzerdefinierte Modi |
Checkpoints |
Kontextverwaltung |

diff --git a/locales/es/README.md b/locales/es/README.md index 9e378b7fd7..6764151149 100644 --- a/locales/es/README.md +++ b/locales/es/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,9 +58,8 @@ Roo Code se adapta a tu forma de trabajar, no al revés: - Modo Pregunta: respuestas rápidas, explicaciones y documentos - Modo Depuración: rastrear problemas, agregar registros, aislar causas raíz - Modos Personalizados: crea modos especializados para tu equipo o flujo de trabajo -- Roomote Control: Roomote Control te permite controlar de forma remota tareas que se ejecutan en tu instancia local de VS Code. -Más info: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [Modos Personalizados](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Más info: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [Modos Personalizados](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutoriales y vídeos de funcionalidades @@ -69,7 +68,7 @@ Más info: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [M | | | | | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Instalando Roo Code |
Configurando perfiles |
Indexación de la base de código | -|
Modos personalizados |
Checkpoints |
Gestión de Contexto | +|
Modos personalizados |
Checkpoints |
Gestión de Contexto |

diff --git a/locales/fr/README.md b/locales/fr/README.md index 5197e76f0e..36e693f041 100644 --- a/locales/fr/README.md +++ b/locales/fr/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,9 +58,8 @@ Roo Code s'adapte à votre façon de travailler, pas l'inverse : - Mode Demande : réponses rapides, explications et documents - Mode Débogage : tracer les problèmes, ajouter des journaux, isoler les causes profondes - Modes Personnalisés : créez des modes spécialisés pour votre équipe ou votre flux de travail -- Roomote Control : Roomote Control te permet de piloter à distance les tâches exécutées dans ton instance VS Code locale. -En savoir plus : [Utiliser les Modes](https://docs.roocode.com/basic-usage/using-modes) • [Modes personnalisés](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +En savoir plus : [Utiliser les Modes](https://docs.roocode.com/basic-usage/using-modes) • [Modes personnalisés](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutoriels & Vidéos de fonctionnalités @@ -69,7 +68,7 @@ En savoir plus : [Utiliser les Modes](https://docs.roocode.com/basic-usage/using | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Installer Roo Code |
Configurer les profils |
Indexation de la base de code | -|
Modes personnalisés |
Checkpoints |
Gestion du Contexte | +|
Modes personnalisés |
Checkpoints |
Gestion du Contexte |

diff --git a/locales/hi/README.md b/locales/hi/README.md index 8d12b68994..27cb624213 100644 --- a/locales/hi/README.md +++ b/locales/hi/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,9 +58,8 @@ - पूछें मोड: त्वरित उत्तर, स्पष्टीकरण और डॉक्स - डीबग मोड: समस्याओं का पता लगाएं, लॉग जोड़ें, मूल कारणों को अलग करें - कस्टम मोड: अपनी टीम या वर्कफ़्लो के लिए विशेष मोड बनाएं -- Roomote Control: Roomote Control से तुम अपनी लोकल VS Code इंस्टेंस में चल रही टास्क को रिमोट से कंट्रोल कर सकते हो। -और जानो: [मोड्स का इस्तेमाल](https://docs.roocode.com/basic-usage/using-modes) • [कस्टम मोड्स](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +और जानो: [मोड्स का इस्तेमाल](https://docs.roocode.com/basic-usage/using-modes) • [कस्टम मोड्स](https://docs.roocode.com/advanced-usage/custom-modes) ## ट्यूटोरियल और फ़ीचर वीडियो @@ -69,7 +68,7 @@ | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
रू कोड इंस्टॉल करना |
प्रोफाइल कॉन्फ़िगर करना |
कोडबेस इंडेक्सिंग | -|
कस्टम मोड |
चेकपॉइंट्स |
संदर्भ प्रबंधन | +|
कस्टम मोड |
चेकपॉइंट्स |
संदर्भ प्रबंधन |

diff --git a/locales/id/README.md b/locales/id/README.md index 657b1ab750..0d208c18a3 100644 --- a/locales/id/README.md +++ b/locales/id/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,9 +58,8 @@ Roo Code beradaptasi dengan cara Anda bekerja, bukan sebaliknya: - Mode Tanya: jawaban cepat, penjelasan, dan dokumen - Mode Debug: melacak masalah, menambahkan log, mengisolasi akar penyebab - Mode Kustom: buat mode khusus untuk tim atau alur kerja Anda -- Roomote Control: Roomote Control memungkinkan kamu mengontrol dari jarak jauh tugas yang berjalan di VS Code lokalmu. -Pelajari lebih lanjut: [Menggunakan Mode](https://docs.roocode.com/basic-usage/using-modes) • [Mode Kustom](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Pelajari lebih lanjut: [Menggunakan Mode](https://docs.roocode.com/basic-usage/using-modes) • [Mode Kustom](https://docs.roocode.com/advanced-usage/custom-modes) ## Video Tutorial & Fitur @@ -69,7 +68,7 @@ Pelajari lebih lanjut: [Menggunakan Mode](https://docs.roocode.com/basic-usage/u | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Menginstal Roo Code |
Mengonfigurasi Profil |
Pengindeksan Basis Kode | -|
Mode Kustom |
Pos Pemeriksaan |
Manajemen Konteks | +|
Mode Kustom |
Pos Pemeriksaan |
Manajemen Konteks |

diff --git a/locales/it/README.md b/locales/it/README.md index 9bd5ce9e81..69a7393967 100644 --- a/locales/it/README.md +++ b/locales/it/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,9 +58,8 @@ Roo Code si adatta al tuo modo di lavorare, non il contrario: - Modalità Chiedi: risposte rapide, spiegazioni e documenti - Modalità Debug: traccia problemi, aggiungi log, isola le cause principali - Modalità Personalizzate: crea modalità specializzate per il tuo team o flusso di lavoro -- Roomote Control: Roomote Control ti permette di controllare da remoto le attività in esecuzione sulla tua istanza locale di VS Code. -Scopri di più: [Usare le Modalità](https://docs.roocode.com/basic-usage/using-modes) • [Modalità personalizzate](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Scopri di più: [Usare le Modalità](https://docs.roocode.com/basic-usage/using-modes) • [Modalità personalizzate](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutorial e video sulle funzionalità @@ -69,7 +68,7 @@ Scopri di più: [Usare le Modalità](https://docs.roocode.com/basic-usage/using- | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Installazione di Roo Code |
Configurazione dei profili |
Indicizzazione della codebase | -|
Modalità personalizzate |
Checkpoint |
Gestione del Contesto | +|
Modalità personalizzate |
Checkpoint |
Gestione del Contesto |

diff --git a/locales/ja/README.md b/locales/ja/README.md index 3b7a7a6e6e..dd5332b961 100644 --- a/locales/ja/README.md +++ b/locales/ja/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,9 +58,8 @@ Roo Codeは、あなたの働き方に合わせるように適応します。 - 質問モード:迅速な回答、説明、ドキュメント - デバッグモード:問題の追跡、ログの追加、根本原因の特定 - カスタムモード:チームやワークフローに特化したモードの構築 -- Roomote Control: Roomote Control はローカルの VS Code で実行中のタスクをリモート操作できます。 -詳しくは: [モードの使い方](https://docs.roocode.com/basic-usage/using-modes) • [カスタムモード](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +詳しくは: [モードの使い方](https://docs.roocode.com/basic-usage/using-modes) • [カスタムモード](https://docs.roocode.com/advanced-usage/custom-modes) ## チュートリアルと機能のビデオ @@ -69,7 +68,7 @@ Roo Codeは、あなたの働き方に合わせるように適応します。 | | | | | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Roo Codeのインストール |
プロファイルの設定 |
コードベースのインデックス作成 | -|
カスタムモード |
チェックポイント |
コンテキスト管理 | +|
カスタムモード |
チェックポイント |
コンテキスト管理 |

diff --git a/locales/ko/README.md b/locales/ko/README.md index a53a1b965f..1070244b78 100644 --- a/locales/ko/README.md +++ b/locales/ko/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,9 +58,8 @@ Roo Code는 당신의 작업 방식에 맞춰 적응합니다. - 질문 모드: 빠른 답변, 설명 및 문서 - 디버그 모드: 문제 추적, 로그 추가, 근본 원인 격리 - 사용자 지정 모드: 팀이나 워크플로우를 위한 특수 모드 구축 -- Roomote Control: Roomote Control은 로컬 VS Code 인스턴스에서 실행 중인 작업을 원격으로 제어할 수 있어. -자세히: [모드 사용](https://docs.roocode.com/basic-usage/using-modes) • [사용자 지정 모드](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +자세히: [모드 사용](https://docs.roocode.com/basic-usage/using-modes) • [사용자 지정 모드](https://docs.roocode.com/advanced-usage/custom-modes) ## 튜토리얼 및 기능 비디오 @@ -69,7 +68,7 @@ Roo Code는 당신의 작업 방식에 맞춰 적응합니다. | | | | | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Roo Code 설치하기 |
프로필 구성하기 |
코드베이스 인덱싱 | -|
사용자 지정 모드 |
체크포인트 |
컨텍스트 관리 | +|
사용자 지정 모드 |
체크포인트 |
컨텍스트 관리 |

diff --git a/locales/nl/README.md b/locales/nl/README.md index fa5454b9c5..52e7138f8c 100644 --- a/locales/nl/README.md +++ b/locales/nl/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,9 +58,8 @@ Roo Code past zich aan jouw werkwijze aan, niet andersom: - Vraag Modus: snelle antwoorden, uitleg en documenten - Debug Modus: spoor problemen op, voeg logs toe, isoleer de oorzaak - Aangepaste Modi: bouw gespecialiseerde modi voor je team of workflow -- Roomote Control: Roomote Control laat je taken op je lokale VS Code-instantie op afstand besturen. -Meer info: [Modi gebruiken](https://docs.roocode.com/basic-usage/using-modes) • [Aangepaste modi](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Meer info: [Modi gebruiken](https://docs.roocode.com/basic-usage/using-modes) • [Aangepaste modi](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutorial & Feature Videos diff --git a/locales/pl/README.md b/locales/pl/README.md index b8553a08c7..8f79597598 100644 --- a/locales/pl/README.md +++ b/locales/pl/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,9 +58,8 @@ Roo Code dostosowuje się do Twojego sposobu pracy, a nie odwrotnie: - Tryb Zapytaj: szybkie odpowiedzi, wyjaśnienia i dokumenty - Tryb Debugowanie: śledzenie problemów, dodawanie logów, izolowanie przyczyn źródłowych - Tryby niestandardowe: buduj specjalistyczne tryby dla swojego zespołu lub przepływu pracy -- Roomote Control: Roomote Control pozwala zdalnie sterować zadaniami uruchomionymi na twojej lokalnej instancji VS Code. -Więcej: [Korzystanie z trybów](https://docs.roocode.com/basic-usage/using-modes) • [Tryby niestandardowe](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Więcej: [Korzystanie z trybów](https://docs.roocode.com/basic-usage/using-modes) • [Tryby niestandardowe](https://docs.roocode.com/advanced-usage/custom-modes) ## Filmy instruktażowe i prezentujące funkcje @@ -69,7 +68,7 @@ Więcej: [Korzystanie z trybów](https://docs.roocode.com/basic-usage/using-mode | | | | | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Instalacja Roo Code |
Konfiguracja profili |
Indeksowanie bazy kodu | -|
Tryby niestandardowe |
Punkty kontrolne |
Zarządzanie Kontekstem | +|
Tryby niestandardowe |
Punkty kontrolne |
Zarządzanie Kontekstem |

diff --git a/locales/pt-BR/README.md b/locales/pt-BR/README.md index 3b128b0fd3..50269e00ed 100644 --- a/locales/pt-BR/README.md +++ b/locales/pt-BR/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,9 +58,8 @@ O Roo Code se adapta à sua maneira de trabalhar, e não o contrário: - Modo Pergunta: respostas rápidas, explicações e documentos - Modo Depuração: rastreie problemas, adicione logs, isole as causas raiz - Modos Personalizados: crie modos especializados para sua equipe ou fluxo de trabalho -- Roomote Control: O Roomote Control permite controlar remotamente tarefas em execução na sua instância local do VS Code. -Saiba mais: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [Modos personalizados](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Saiba mais: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [Modos personalizados](https://docs.roocode.com/advanced-usage/custom-modes) ## Vídeos de tutorial e recursos @@ -69,7 +68,7 @@ Saiba mais: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [ | | | | | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Instalando o Roo Code |
Configurando perfis |
Indexação da base de código | -|
Modos personalizados |
Checkpoints |
Gerenciamento de Contexto | +|
Modos personalizados |
Checkpoints |
Gerenciamento de Contexto |

diff --git a/locales/ru/README.md b/locales/ru/README.md index 9abf4ae511..a323ff417c 100644 --- a/locales/ru/README.md +++ b/locales/ru/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,9 +58,8 @@ Roo Code адаптируется к вашему стилю работы, а н - Режим Вопрос: быстрые ответы, объяснения и документация - Режим Отладка: отслеживание проблем, добавление логов, изоляция первопричин - Пользовательские режимы: создавайте специализированные режимы для вашей команды или рабочего процесса -- Roomote Control: Roomote Control позволяет удаленно управлять задачами, запущенными в вашей локальной инстансе VS Code. -Подробнее: [Использование режимов](https://docs.roocode.com/basic-usage/using-modes) • [Пользовательские режимы](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Подробнее: [Использование режимов](https://docs.roocode.com/basic-usage/using-modes) • [Пользовательские режимы](https://docs.roocode.com/advanced-usage/custom-modes) ## Обучающие видео и видео о функциях @@ -69,7 +68,7 @@ Roo Code адаптируется к вашему стилю работы, а н | | | | | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Установка Roo Code |
Настройка профилей |
Индексация кодовой базы | -|
Пользовательские режимы |
Контрольные точки |
Управление Контекстом | +|
Пользовательские режимы |
Контрольные точки |
Управление Контекстом |

diff --git a/locales/tr/README.md b/locales/tr/README.md index ac5a788407..2847a23c06 100644 --- a/locales/tr/README.md +++ b/locales/tr/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,18 +58,17 @@ Roo Code, sizin çalışma şeklinize uyum sağlar, tam tersi değil: - Sor Modu: hızlı cevaplar, açıklamalar ve belgeler - Hata Ayıklama Modu: sorunları izleyin, günlükler ekleyin, kök nedenleri izole edin - Özel Modlar: ekibiniz veya iş akışınız için özel modlar oluşturun -- Roomote Control: Roomote Control, yerel VS Code örneğinde çalışan işleri uzaktan kontrol etmeni sağlar. -Daha fazla: [Modları kullanma](https://docs.roocode.com/basic-usage/using-modes) • [Özel modlar](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Daha fazla: [Modları kullanma](https://docs.roocode.com/basic-usage/using-modes) • [Özel modlar](https://docs.roocode.com/advanced-usage/custom-modes) ## Eğitim ve Özellik Videoları

-| | | | -| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
Roo Code Kurulumu |
Profilleri Yapılandırma |
Kod Tabanı İndeksleme | -|
Özel Modlar |
Kontrol Noktaları |
Bağlam Yönetimi | +| | | | +| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +|
Roo Code Kurulumu |
Profilleri Yapılandırma |
Kod Tabanı İndeksleme | +|
Özel Modlar |
Kontrol Noktaları |
Bağlam Yönetimi |

diff --git a/locales/vi/README.md b/locales/vi/README.md index bedaa3c26a..aa9b4ff674 100644 --- a/locales/vi/README.md +++ b/locales/vi/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,18 +58,17 @@ Roo Code thích ứng với cách bạn làm việc, chứ không phải ngượ - Chế độ Hỏi: câu trả lời nhanh, giải thích và tài liệu - Chế độ Gỡ lỗi: theo dõi sự cố, thêm nhật ký, cô lập nguyên nhân gốc rễ - Chế độ Tùy chỉnh: xây dựng các chế độ chuyên biệt cho nhóm hoặc quy trình làm việc của bạn -- Roomote Control: Roomote Control cho phép bạn điều khiển từ xa các tác vụ đang chạy trên VS Code cục bộ của bạn. -Xem thêm: [Sử dụng Chế độ](https://docs.roocode.com/basic-usage/using-modes) • [Chế độ tùy chỉnh](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Xem thêm: [Sử dụng Chế độ](https://docs.roocode.com/basic-usage/using-modes) • [Chế độ tùy chỉnh](https://docs.roocode.com/advanced-usage/custom-modes) ## Video hướng dẫn & tính năng

-| | | | -| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
Cài đặt Roo Code |
Định cấu hình Hồ sơ |
Lập chỉ mục cơ sở mã | -|
Chế độ tùy chỉnh |
Điểm kiểm tra |
Quản lý Ngữ cảnh | +| | | | +| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +|
Cài đặt Roo Code |
Định cấu hình Hồ sơ |
Lập chỉ mục cơ sở mã | +|
Chế độ tùy chỉnh |
Điểm kiểm tra |
Quản lý Ngữ cảnh |

diff --git a/locales/zh-CN/README.md b/locales/zh-CN/README.md index a21e147f96..ef26871f31 100644 --- a/locales/zh-CN/README.md +++ b/locales/zh-CN/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,17 +58,16 @@ Roo Code 适应您的工作方式,而不是相反: - 提问模式:快速回答、解释和文档 - 调试模式:跟踪问题、添加日志、隔离根本原因 - 自定义模式:为您的团队或工作流程构建专门的模式 -- Roomote Control:Roomote Control 允许你远程控制在本地 VS Code 实例中运行的任务。 -了解更多:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自定义模式](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +了解更多:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自定义模式](https://docs.roocode.com/advanced-usage/custom-modes) ## 教程和功能视频

-| | | | -| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
安装 Roo Code |
配置个人资料 |
代码库索引 | +| | | | +| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------: | +|
安装 Roo Code |
配置个人资料 |
代码库索引 | |
自定义模式 |
检查点 |
上下文管理 |
diff --git a/locales/zh-TW/README.md b/locales/zh-TW/README.md index fe985d7ec8..db94c425c9 100644 --- a/locales/zh-TW/README.md +++ b/locales/zh-TW/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,9 +58,8 @@ Roo Code 會配合您的工作方式,而非要您配合它: - 詢問模式:快速回答、解釋和文件 - 偵錯模式:追蹤問題、新增日誌、鎖定根本原因 - 自訂模式:為您的團隊或工作流程建置專門的模式 -- Roomote Control:Roomote Control 讓您能遠端控制在本機 VS Code 執行個體中運行的工作。 -更多資訊:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自訂模式](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +更多資訊:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自訂模式](https://docs.roocode.com/advanced-usage/custom-modes) ## 教學和功能影片 diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 2d4456d273..92ecaa1764 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -15,7 +15,6 @@ "ioredis": "^5.6.1", "jwt-decode": "^4.0.0", "p-wait-for": "^5.0.2", - "socket.io-client": "^4.8.1", "zod": "^3.25.76" }, "devDependencies": { diff --git a/packages/cloud/src/StaticSettingsService.ts b/packages/cloud/src/StaticSettingsService.ts index 492a0a8d4b..2365208f9d 100644 --- a/packages/cloud/src/StaticSettingsService.ts +++ b/packages/cloud/src/StaticSettingsService.ts @@ -42,15 +42,12 @@ export class StaticSettingsService implements SettingsService { } /** - * Returns static user settings with roomoteControlEnabled and extensionBridgeEnabled as true + * Returns static user settings with task sync enabled */ public getUserSettings(): UserSettingsData | undefined { return { - features: { - roomoteControlEnabled: true, - }, + features: {}, settings: { - extensionBridgeEnabled: true, taskSyncEnabled: true, }, version: 1, @@ -58,14 +55,11 @@ export class StaticSettingsService implements SettingsService { } public getUserFeatures(): UserFeatures { - return { - roomoteControlEnabled: true, - } + return {} } public getUserSettingsConfig(): UserSettingsConfig { return { - extensionBridgeEnabled: true, taskSyncEnabled: true, } } diff --git a/packages/cloud/src/StaticTokenAuthService.ts b/packages/cloud/src/StaticTokenAuthService.ts index 2ff7b75f0e..c2450b0c22 100644 --- a/packages/cloud/src/StaticTokenAuthService.ts +++ b/packages/cloud/src/StaticTokenAuthService.ts @@ -30,7 +30,6 @@ export class StaticTokenAuthService extends EventEmitter impl this.userInfo = { id: payload?.r?.u || payload?.sub || undefined, organizationId: payload?.r?.o || undefined, - extensionBridgeEnabled: true, } } diff --git a/packages/cloud/src/WebAuthService.ts b/packages/cloud/src/WebAuthService.ts index 69ad28e8ec..501bf95bb5 100644 --- a/packages/cloud/src/WebAuthService.ts +++ b/packages/cloud/src/WebAuthService.ts @@ -625,8 +625,6 @@ export class WebAuthService extends EventEmitter implements A )?.email_address } - let extensionBridgeEnabled = true - // Fetch organization info if user is in organization context try { const storedOrgId = this.getStoredOrganizationId() @@ -641,8 +639,6 @@ export class WebAuthService extends EventEmitter implements A if (userMembership) { this.setUserOrganizationInfo(userInfo, userMembership) - extensionBridgeEnabled = await this.isExtensionBridgeEnabledForOrganization(storedOrgId) - this.log("[auth] User in organization context:", { id: userMembership.organization.id, name: userMembership.organization.name, @@ -662,10 +658,6 @@ export class WebAuthService extends EventEmitter implements A if (primaryOrgMembership) { this.setUserOrganizationInfo(userInfo, primaryOrgMembership) - extensionBridgeEnabled = await this.isExtensionBridgeEnabledForOrganization( - primaryOrgMembership.organization.id, - ) - this.log("[auth] Legacy credentials: Found organization membership:", { id: primaryOrgMembership.organization.id, name: primaryOrgMembership.organization.name, @@ -680,9 +672,6 @@ export class WebAuthService extends EventEmitter implements A // Don't throw - organization info is optional } - // Set the extension bridge enabled flag - userInfo.extensionBridgeEnabled = extensionBridgeEnabled - return userInfo } @@ -729,36 +718,6 @@ export class WebAuthService extends EventEmitter implements A throw new Error(errorMessage) } - private async getOrganizationMetadata( - organizationId: string, - ): Promise<{ public_metadata?: Record } | null> { - try { - const response = await fetch(`${getClerkBaseUrl()}/v1/organizations/${organizationId}`, { - headers: { - Authorization: `Bearer ${this.credentials!.clientToken}`, - "User-Agent": this.userAgent(), - }, - signal: AbortSignal.timeout(10000), - }) - - if (!response.ok) { - this.log(`[auth] Failed to fetch organization metadata: ${response.status} ${response.statusText}`) - return null - } - - const data = await response.json() - return data.response || data - } catch (error) { - this.log("[auth] Error fetching organization metadata:", error) - return null - } - } - - private async isExtensionBridgeEnabledForOrganization(organizationId: string): Promise { - const orgMetadata = await this.getOrganizationMetadata(organizationId) - return orgMetadata?.public_metadata?.extension_bridge_enabled === true - } - private async clerkLogout(credentials: AuthCredentials): Promise { const formData = new URLSearchParams() formData.append("_is_native", "1") diff --git a/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts b/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts index 8d69303c38..b0486b971c 100644 --- a/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts +++ b/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts @@ -105,12 +105,8 @@ describe("CloudSettingsService - Response Parsing", () => { }, }, user: { - features: { - roomoteControlEnabled: true, - }, - settings: { - extensionBridgeEnabled: true, - }, + features: {}, + settings: {}, version: 1, }, } diff --git a/packages/cloud/src/__tests__/StaticTokenAuthService.spec.ts b/packages/cloud/src/__tests__/StaticTokenAuthService.spec.ts index a3756082ea..c37f2a7df9 100644 --- a/packages/cloud/src/__tests__/StaticTokenAuthService.spec.ts +++ b/packages/cloud/src/__tests__/StaticTokenAuthService.spec.ts @@ -89,7 +89,6 @@ describe("StaticTokenAuthService", () => { const userInfo = serviceWithJWT.getUserInfo() expect(userInfo?.id).toBe("user_2xmBhejNeDTwanM8CgIOnMgVxzC") expect(userInfo?.organizationId).toBe("org_123abc") - expect(userInfo?.extensionBridgeEnabled).toBe(true) }) it("should parse job token without orgId (null orgId case)", () => { @@ -98,7 +97,6 @@ describe("StaticTokenAuthService", () => { const userInfo = serviceWithJWT.getUserInfo() expect(userInfo?.id).toBe("user_2xmBhejNeDTwanM8CgIOnMgVxzC") expect(userInfo?.organizationId).toBeUndefined() - expect(userInfo?.extensionBridgeEnabled).toBe(true) }) it("should parse auth token and extract userId from r.u", () => { @@ -107,7 +105,6 @@ describe("StaticTokenAuthService", () => { const userInfo = serviceWithAuthToken.getUserInfo() expect(userInfo?.id).toBe("user_123") expect(userInfo?.organizationId).toBeUndefined() - expect(userInfo?.extensionBridgeEnabled).toBe(true) }) it("should handle legacy JWT format with sub field", () => { @@ -116,7 +113,6 @@ describe("StaticTokenAuthService", () => { const userInfo = serviceWithLegacyJWT.getUserInfo() expect(userInfo?.id).toBe("user_123") expect(userInfo?.organizationId).toBeUndefined() - expect(userInfo?.extensionBridgeEnabled).toBe(true) }) it("should handle invalid JWT gracefully", () => { @@ -125,7 +121,6 @@ describe("StaticTokenAuthService", () => { const userInfo = serviceWithInvalidJWT.getUserInfo() expect(userInfo?.id).toBeUndefined() expect(userInfo?.organizationId).toBeUndefined() - expect(userInfo?.extensionBridgeEnabled).toBe(true) expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse JWT:", expect.any(Error)) }) @@ -183,9 +178,7 @@ describe("StaticTokenAuthService", () => { authService.broadcast() expect(spy).toHaveBeenCalledWith({ - userInfo: expect.objectContaining({ - extensionBridgeEnabled: true, - }), + userInfo: expect.objectContaining({}), }) }) @@ -199,7 +192,6 @@ describe("StaticTokenAuthService", () => { expect(spy).toHaveBeenCalledWith({ userInfo: { - extensionBridgeEnabled: true, id: "user_2xmBhejNeDTwanM8CgIOnMgVxzC", organizationId: "org_123abc", }, @@ -220,10 +212,9 @@ describe("StaticTokenAuthService", () => { }) describe("getUserInfo", () => { - it("should return object with extensionBridgeEnabled flag", () => { + it("should return user info object", () => { const userInfo = authService.getUserInfo() - expect(userInfo).toHaveProperty("extensionBridgeEnabled") - expect(userInfo?.extensionBridgeEnabled).toBe(true) + expect(userInfo).toBeDefined() }) }) @@ -305,9 +296,7 @@ describe("StaticTokenAuthService", () => { }) expect(userInfoSpy).toHaveBeenCalledWith({ - userInfo: expect.objectContaining({ - extensionBridgeEnabled: true, - }), + userInfo: expect.objectContaining({}), }) }) }) diff --git a/packages/cloud/src/__tests__/WebAuthService.spec.ts b/packages/cloud/src/__tests__/WebAuthService.spec.ts index 3398e3f2a3..aa406e400d 100644 --- a/packages/cloud/src/__tests__/WebAuthService.spec.ts +++ b/packages/cloud/src/__tests__/WebAuthService.spec.ts @@ -636,7 +636,6 @@ describe("WebAuthService", () => { name: "John Doe", email: "john@example.com", picture: "https://example.com/avatar.jpg", - extensionBridgeEnabled: true, }, }) }) @@ -801,7 +800,6 @@ describe("WebAuthService", () => { name: "Jane Smith", email: "jane@example.com", picture: "https://example.com/jane.jpg", - extensionBridgeEnabled: true, }) }) @@ -869,7 +867,6 @@ describe("WebAuthService", () => { name: "Jane Smith", email: "jane@example.com", picture: "https://example.com/jane.jpg", - extensionBridgeEnabled: false, organizationId: "org_1", organizationName: "Org 1", organizationRole: "member", @@ -920,7 +917,6 @@ describe("WebAuthService", () => { name: "John Doe", email: undefined, picture: undefined, - extensionBridgeEnabled: true, }) }) }) @@ -1045,7 +1041,6 @@ describe("WebAuthService", () => { name: "Test User", email: undefined, picture: undefined, - extensionBridgeEnabled: true, }, }) }) diff --git a/packages/cloud/src/bridge/BaseChannel.ts b/packages/cloud/src/bridge/BaseChannel.ts deleted file mode 100644 index 1b2615b24c..0000000000 --- a/packages/cloud/src/bridge/BaseChannel.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { Socket } from "socket.io-client" -import * as vscode from "vscode" - -import type { StaticAppProperties, GitProperties } from "@roo-code/types" - -export interface BaseChannelOptions { - instanceId: string - appProperties: StaticAppProperties - gitProperties?: GitProperties - isCloudAgent: boolean -} - -/** - * Abstract base class for communication channels in the bridge system. - * Provides common functionality for bidirectional communication between - * the VSCode extension and web application. - * - * @template TCommand - Type of commands this channel can receive. - * @template TEvent - Type of events this channel can publish. - */ -export abstract class BaseChannel { - protected socket: Socket | null = null - protected readonly instanceId: string - protected readonly appProperties: StaticAppProperties - protected readonly gitProperties?: GitProperties - protected readonly isCloudAgent: boolean - - constructor(options: BaseChannelOptions) { - this.instanceId = options.instanceId - this.appProperties = options.appProperties - this.gitProperties = options.gitProperties - this.isCloudAgent = options.isCloudAgent - } - - /** - * Called when socket connects. - */ - public async onConnect(socket: Socket): Promise { - this.socket = socket - await this.handleConnect(socket) - } - - /** - * Called when socket disconnects. - */ - public onDisconnect(): void { - this.socket = null - this.handleDisconnect() - } - - /** - * Called when socket reconnects. - */ - public async onReconnect(socket: Socket): Promise { - this.socket = socket - await this.handleReconnect(socket) - } - - /** - * Cleanup resources. - */ - public async cleanup(socket: Socket | null): Promise { - if (socket) { - await this.handleCleanup(socket) - } - - this.socket = null - } - - /** - * Emit a socket event with error handling. - */ - protected publish( - eventName: TEventName, - data: TEventData, - callback?: (params: Params) => void, - ): boolean { - if (!this.socket) { - console.error(`[${this.constructor.name}#emit] socket not available for ${eventName}`) - return false - } - - try { - // console.log(`[${this.constructor.name}#emit] emit() -> ${eventName}`, data) - this.socket.emit(eventName, data, callback) - - return true - } catch (error) { - console.error( - `[${this.constructor.name}#emit] emit() failed -> ${eventName}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - - return false - } - } - - /** - * Handle incoming commands - template method that ensures common functionality - * is executed before subclass-specific logic. - * - * This method should be called by subclasses to handle commands. - * It will execute common functionality and then delegate to the abstract - * handleCommandImplementation method. - */ - public async handleCommand(command: TCommand): Promise { - // Common functionality: focus the sidebar. - await vscode.commands.executeCommand(`${this.appProperties.appName}.SidebarProvider.focus`) - - // Delegate to subclass-specific implementation. - await this.handleCommandImplementation(command) - } - - /** - * Handle command-specific logic - must be implemented by subclasses. - * This method is called after common functionality has been executed. - */ - protected abstract handleCommandImplementation(command: TCommand): Promise - - /** - * Handle connection-specific logic. - */ - protected abstract handleConnect(socket: Socket): Promise - - /** - * Handle disconnection-specific logic. - */ - protected handleDisconnect(): void { - // Default implementation - can be overridden. - } - - /** - * Handle reconnection-specific logic. - */ - protected abstract handleReconnect(socket: Socket): Promise - - /** - * Handle cleanup-specific logic. - */ - protected abstract handleCleanup(socket: Socket): Promise -} diff --git a/packages/cloud/src/bridge/BridgeOrchestrator.ts b/packages/cloud/src/bridge/BridgeOrchestrator.ts deleted file mode 100644 index 16ad0244f0..0000000000 --- a/packages/cloud/src/bridge/BridgeOrchestrator.ts +++ /dev/null @@ -1,355 +0,0 @@ -import crypto from "crypto" -import os from "os" - -import { - type TaskProviderLike, - type TaskLike, - type CloudUserInfo, - type ExtensionBridgeCommand, - type TaskBridgeCommand, - type StaticAppProperties, - type GitProperties, - ConnectionState, - ExtensionSocketEvents, - TaskSocketEvents, -} from "@roo-code/types" - -import { SocketTransport } from "./SocketTransport.js" -import { ExtensionChannel } from "./ExtensionChannel.js" -import { TaskChannel } from "./TaskChannel.js" - -export interface BridgeOrchestratorOptions { - userId: string - socketBridgeUrl: string - token: string - provider: TaskProviderLike - sessionId: string - isCloudAgent: boolean -} - -/** - * Central orchestrator for the extension bridge system. - * Coordinates communication between the VSCode extension and web application - * through WebSocket connections and manages extension/task channels. - */ -export class BridgeOrchestrator { - private static instance: BridgeOrchestrator | null = null - - private static pendingTask: TaskLike | null = null - - // Core - private readonly userId: string - private readonly socketBridgeUrl: string - private readonly token: string - private readonly provider: TaskProviderLike - private readonly instanceId: string - private readonly appProperties: StaticAppProperties - private readonly gitProperties?: GitProperties - private readonly isCloudAgent?: boolean - - // Components - private socketTransport: SocketTransport - private extensionChannel: ExtensionChannel - private taskChannel: TaskChannel - - // Reconnection - private readonly MAX_RECONNECT_ATTEMPTS = Infinity - private readonly RECONNECT_DELAY = 1_000 - private readonly RECONNECT_DELAY_MAX = 30_000 - - public static getInstance(): BridgeOrchestrator | null { - return BridgeOrchestrator.instance - } - - public static isEnabled(user: CloudUserInfo | null, remoteControlEnabled: boolean): boolean { - // Always disabled if signed out. - if (!user) { - return false - } - - // Disabled by the user's organization? - if (!user.extensionBridgeEnabled) { - return false - } - - // Disabled by the user? - if (!remoteControlEnabled) { - return false - } - - return true - } - - public static async connectOrDisconnect( - userInfo: CloudUserInfo, - remoteControlEnabled: boolean, - options: BridgeOrchestratorOptions, - ): Promise { - if (BridgeOrchestrator.isEnabled(userInfo, remoteControlEnabled)) { - await BridgeOrchestrator.connect(options) - } else { - await BridgeOrchestrator.disconnect() - } - } - - public static async connect(options: BridgeOrchestratorOptions) { - const instance = BridgeOrchestrator.instance - - if (!instance) { - try { - console.log(`[BridgeOrchestrator#connectOrDisconnect] Connecting...`) - - // Populate telemetry properties before registering the instance. - await options.provider.getTelemetryProperties() - - BridgeOrchestrator.instance = new BridgeOrchestrator(options) - await BridgeOrchestrator.instance.connect() - } catch (error) { - console.error( - `[BridgeOrchestrator#connectOrDisconnect] connect() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } else { - if ( - instance.connectionState === ConnectionState.FAILED || - instance.connectionState === ConnectionState.DISCONNECTED - ) { - console.log( - `[BridgeOrchestrator#connectOrDisconnect] Re-connecting... (state: ${instance.connectionState})`, - ) - - instance.reconnect().catch((error) => { - console.error( - `[BridgeOrchestrator#connectOrDisconnect] reconnect() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - }) - } else { - console.log( - `[BridgeOrchestrator#connectOrDisconnect] Already connected or connecting (state: ${instance.connectionState})`, - ) - } - } - } - - public static async disconnect() { - const instance = BridgeOrchestrator.instance - - if (instance) { - try { - console.log( - `[BridgeOrchestrator#connectOrDisconnect] Disconnecting... (state: ${instance.connectionState})`, - ) - - await instance.disconnect() - } catch (error) { - console.error( - `[BridgeOrchestrator#connectOrDisconnect] disconnect() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } finally { - BridgeOrchestrator.instance = null - } - } else { - console.log(`[BridgeOrchestrator#connectOrDisconnect] Already disconnected`) - } - } - - /** - * @TODO: What if subtasks also get spawned? We'd probably want deferred - * subscriptions for those too. - */ - public static async subscribeToTask(task: TaskLike): Promise { - const instance = BridgeOrchestrator.instance - - if (instance && instance.socketTransport.isConnected()) { - console.log(`[BridgeOrchestrator#subscribeToTask] Subscribing to task ${task.taskId}`) - await instance.subscribeToTask(task) - } else { - console.log(`[BridgeOrchestrator#subscribeToTask] Deferring subscription for task ${task.taskId}`) - BridgeOrchestrator.pendingTask = task - } - } - - private constructor(options: BridgeOrchestratorOptions) { - this.userId = options.userId - this.socketBridgeUrl = options.socketBridgeUrl - this.token = options.token - this.provider = options.provider - this.instanceId = options.sessionId || crypto.randomUUID() - this.appProperties = { ...options.provider.appProperties, hostname: os.hostname() } - this.gitProperties = options.provider.gitProperties - this.isCloudAgent = options.isCloudAgent - - this.socketTransport = new SocketTransport({ - url: this.socketBridgeUrl, - socketOptions: { - query: { - token: this.token, - clientType: "extension", - instanceId: this.instanceId, - }, - transports: ["websocket", "polling"], - reconnection: true, - reconnectionAttempts: this.MAX_RECONNECT_ATTEMPTS, - reconnectionDelay: this.RECONNECT_DELAY, - reconnectionDelayMax: this.RECONNECT_DELAY_MAX, - }, - onConnect: () => this.handleConnect(), - onDisconnect: () => this.handleDisconnect(), - onReconnect: () => this.handleReconnect(), - }) - - this.extensionChannel = new ExtensionChannel({ - instanceId: this.instanceId, - appProperties: this.appProperties, - gitProperties: this.gitProperties, - userId: this.userId, - provider: this.provider, - isCloudAgent: this.isCloudAgent, - }) - - this.taskChannel = new TaskChannel({ - instanceId: this.instanceId, - appProperties: this.appProperties, - gitProperties: this.gitProperties, - isCloudAgent: this.isCloudAgent, - }) - } - - private setupSocketListeners() { - const socket = this.socketTransport.getSocket() - - if (!socket) { - console.error("[BridgeOrchestrator] Socket not available") - return - } - - // Remove any existing listeners first to prevent duplicates. - socket.off(ExtensionSocketEvents.RELAYED_COMMAND) - socket.off(TaskSocketEvents.RELAYED_COMMAND) - socket.off("connected") - - socket.on(ExtensionSocketEvents.RELAYED_COMMAND, (message: ExtensionBridgeCommand) => { - console.log( - `[BridgeOrchestrator] on(${ExtensionSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.instanceId}`, - ) - - this.extensionChannel?.handleCommand(message) - }) - - socket.on(TaskSocketEvents.RELAYED_COMMAND, (message: TaskBridgeCommand) => { - console.log( - `[BridgeOrchestrator] on(${TaskSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.taskId}`, - ) - - this.taskChannel.handleCommand(message) - }) - } - - private async handleConnect() { - const socket = this.socketTransport.getSocket() - - if (!socket) { - console.error("[BridgeOrchestrator#handleConnect] Socket not available") - return - } - - await this.extensionChannel.onConnect(socket) - await this.taskChannel.onConnect(socket) - - if (BridgeOrchestrator.pendingTask) { - console.log( - `[BridgeOrchestrator#handleConnect] Subscribing to task ${BridgeOrchestrator.pendingTask.taskId}`, - ) - - try { - await this.subscribeToTask(BridgeOrchestrator.pendingTask) - BridgeOrchestrator.pendingTask = null - } catch (error) { - console.error( - `[BridgeOrchestrator#handleConnect] subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - } - - private handleDisconnect() { - this.extensionChannel.onDisconnect() - this.taskChannel.onDisconnect() - } - - private async handleReconnect() { - const socket = this.socketTransport.getSocket() - - if (!socket) { - console.error("[BridgeOrchestrator] Socket not available after reconnect") - return - } - - // Re-setup socket listeners to ensure they're properly configured - // after automatic reconnection (Socket.IO's built-in reconnection) - // The socket.off() calls in setupSocketListeners prevent duplicates - this.setupSocketListeners() - - await this.extensionChannel.onReconnect(socket) - await this.taskChannel.onReconnect(socket) - } - - // Task API - - public async subscribeToTask(task: TaskLike): Promise { - const socket = this.socketTransport.getSocket() - - if (!socket || !this.socketTransport.isConnected()) { - console.warn("[BridgeOrchestrator] Cannot subscribe to task: not connected. Will retry when connected.") - this.taskChannel.addPendingTask(task) - - if ( - this.connectionState === ConnectionState.DISCONNECTED || - this.connectionState === ConnectionState.FAILED - ) { - await this.connect() - } - - return - } - - await this.taskChannel.subscribeToTask(task, socket) - } - - public async unsubscribeFromTask(taskId: string): Promise { - const socket = this.socketTransport.getSocket() - - if (!socket) { - return - } - - await this.taskChannel.unsubscribeFromTask(taskId, socket) - } - - // Shared API - - public get connectionState(): ConnectionState { - return this.socketTransport.getConnectionState() - } - - private async connect(): Promise { - await this.socketTransport.connect() - this.setupSocketListeners() - } - - public async disconnect(): Promise { - await this.extensionChannel.cleanup(this.socketTransport.getSocket()) - await this.taskChannel.cleanup(this.socketTransport.getSocket()) - await this.socketTransport.disconnect() - BridgeOrchestrator.instance = null - BridgeOrchestrator.pendingTask = null - } - - public async reconnect(): Promise { - await this.socketTransport.reconnect() - - // After a manual reconnect, we have a new socket instance - // so we need to set up listeners again. - this.setupSocketListeners() - } -} diff --git a/packages/cloud/src/bridge/ExtensionChannel.ts b/packages/cloud/src/bridge/ExtensionChannel.ts deleted file mode 100644 index 26fce96228..0000000000 --- a/packages/cloud/src/bridge/ExtensionChannel.ts +++ /dev/null @@ -1,282 +0,0 @@ -import type { Socket } from "socket.io-client" - -import { - type TaskProviderLike, - type TaskProviderEvents, - type ExtensionInstance, - type ExtensionBridgeCommand, - type ExtensionBridgeEvent, - RooCodeEventName, - TaskStatus, - ExtensionBridgeCommandName, - ExtensionBridgeEventName, - ExtensionSocketEvents, - HEARTBEAT_INTERVAL_MS, -} from "@roo-code/types" - -import { type BaseChannelOptions, BaseChannel } from "./BaseChannel.js" - -interface ExtensionChannelOptions extends BaseChannelOptions { - userId: string - provider: TaskProviderLike -} - -/** - * Manages the extension-level communication channel. - * Handles extension registration, heartbeat, and extension-specific commands. - */ -export class ExtensionChannel extends BaseChannel< - ExtensionBridgeCommand, - ExtensionSocketEvents, - ExtensionBridgeEvent | ExtensionInstance -> { - private userId: string - private provider: TaskProviderLike - private extensionInstance: ExtensionInstance - private heartbeatInterval: NodeJS.Timeout | null = null - private eventListeners: Map void> = new Map() - - constructor(options: ExtensionChannelOptions) { - super({ - instanceId: options.instanceId, - appProperties: options.appProperties, - gitProperties: options.gitProperties, - isCloudAgent: options.isCloudAgent, - }) - - this.userId = options.userId - this.provider = options.provider - - this.extensionInstance = { - instanceId: this.instanceId, - userId: this.userId, - workspacePath: this.provider.cwd, - appProperties: this.appProperties, - gitProperties: this.gitProperties, - lastHeartbeat: Date.now(), - task: { taskId: "", taskStatus: TaskStatus.None }, - taskHistory: [], - isCloudAgent: this.isCloudAgent, - } - - this.setupListeners() - } - - protected async handleCommandImplementation(command: ExtensionBridgeCommand): Promise { - if (command.instanceId !== this.instanceId) { - console.log(`[ExtensionChannel] command -> instance id mismatch | ${this.instanceId}`, { - messageInstanceId: command.instanceId, - }) - - return - } - - switch (command.type) { - case ExtensionBridgeCommandName.StartTask: { - console.log(`[ExtensionChannel] command -> createTask() | ${command.instanceId}`, { - text: command.payload.text?.substring(0, 100) + "...", - hasImages: !!command.payload.images, - mode: command.payload.mode, - providerProfile: command.payload.providerProfile, - }) - - this.provider.createTask( - command.payload.text, - command.payload.images, - undefined, // parentTask - undefined, // options - { mode: command.payload.mode, currentApiConfigName: command.payload.providerProfile }, - ) - - break - } - case ExtensionBridgeCommandName.StopTask: { - const instance = await this.updateInstance() - - if (instance.task.taskStatus === TaskStatus.Running) { - console.log(`[ExtensionChannel] command -> cancelTask() | ${command.instanceId}`) - this.provider.cancelTask() - this.provider.postStateToWebview() - } else if (instance.task.taskId) { - console.log(`[ExtensionChannel] command -> clearTask() | ${command.instanceId}`) - this.provider.clearTask() - this.provider.postStateToWebview() - } - - break - } - case ExtensionBridgeCommandName.ResumeTask: { - console.log(`[ExtensionChannel] command -> resumeTask() | ${command.instanceId}`, { - taskId: command.payload.taskId, - }) - - this.provider.resumeTask(command.payload.taskId) - this.provider.postStateToWebview() - break - } - } - } - - protected async handleConnect(socket: Socket): Promise { - await this.registerInstance(socket) - this.startHeartbeat(socket) - } - - protected async handleReconnect(socket: Socket): Promise { - await this.registerInstance(socket) - this.startHeartbeat(socket) - } - - protected override handleDisconnect(): void { - this.stopHeartbeat() - } - - protected async handleCleanup(socket: Socket): Promise { - this.stopHeartbeat() - this.cleanupListeners() - await this.unregisterInstance(socket) - } - - private async registerInstance(_socket: Socket): Promise { - const instance = await this.updateInstance() - await this.publish(ExtensionSocketEvents.REGISTER, instance) - } - - private async unregisterInstance(_socket: Socket): Promise { - const instance = await this.updateInstance() - await this.publish(ExtensionSocketEvents.UNREGISTER, instance) - } - - private startHeartbeat(socket: Socket): void { - this.stopHeartbeat() - - this.heartbeatInterval = setInterval(async () => { - const instance = await this.updateInstance() - - try { - socket.emit(ExtensionSocketEvents.HEARTBEAT, instance) - // Heartbeat is too frequent to log - } catch (error) { - console.error( - `[ExtensionChannel] emit() failed -> ${ExtensionSocketEvents.HEARTBEAT}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - } - }, HEARTBEAT_INTERVAL_MS) - } - - private stopHeartbeat(): void { - if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval) - this.heartbeatInterval = null - } - } - - private setupListeners(): void { - const eventMapping = [ - { from: RooCodeEventName.TaskCreated, to: ExtensionBridgeEventName.TaskCreated }, - { from: RooCodeEventName.TaskStarted, to: ExtensionBridgeEventName.TaskStarted }, - { from: RooCodeEventName.TaskCompleted, to: ExtensionBridgeEventName.TaskCompleted }, - { from: RooCodeEventName.TaskAborted, to: ExtensionBridgeEventName.TaskAborted }, - { from: RooCodeEventName.TaskFocused, to: ExtensionBridgeEventName.TaskFocused }, - { from: RooCodeEventName.TaskUnfocused, to: ExtensionBridgeEventName.TaskUnfocused }, - { from: RooCodeEventName.TaskActive, to: ExtensionBridgeEventName.TaskActive }, - { from: RooCodeEventName.TaskInteractive, to: ExtensionBridgeEventName.TaskInteractive }, - { from: RooCodeEventName.TaskResumable, to: ExtensionBridgeEventName.TaskResumable }, - { from: RooCodeEventName.TaskIdle, to: ExtensionBridgeEventName.TaskIdle }, - { 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[]) => { - 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) - this.provider.on(from, listener) - }) - } - - private cleanupListeners(): void { - this.eventListeners.forEach((listener, eventName) => { - // Cast is safe because we only store valid event names from eventMapping. - this.provider.off(eventName as keyof TaskProviderEvents, listener) - }) - - this.eventListeners.clear() - } - - private async updateInstance(): Promise { - const task = this.provider?.getCurrentTask() - const taskHistory = this.provider?.getRecentTasks() ?? [] - - const mode = await this.provider?.getMode() - const modes = (await this.provider?.getModes()) ?? [] - - const providerProfile = await this.provider?.getProviderProfile() - const providerProfiles = (await this.provider?.getProviderProfiles()) ?? [] - - this.extensionInstance = { - ...this.extensionInstance, - lastHeartbeat: Date.now(), - task: task - ? { - taskId: task.taskId, - parentTaskId: task.parentTaskId, - childTaskId: task.childTaskId, - taskStatus: task.taskStatus, - taskAsk: task?.taskAsk, - queuedMessages: task.queuedMessages, - tokenUsage: task.tokenUsage, - ...task.metadata, - } - : { taskId: "", taskStatus: TaskStatus.None }, - taskAsk: task?.taskAsk, - taskHistory, - mode, - providerProfile, - modes, - providerProfiles, - } - - return this.extensionInstance - } -} diff --git a/packages/cloud/src/bridge/SocketTransport.ts b/packages/cloud/src/bridge/SocketTransport.ts deleted file mode 100644 index 2df3cf95eb..0000000000 --- a/packages/cloud/src/bridge/SocketTransport.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { io, type Socket, type SocketOptions, type ManagerOptions } from "socket.io-client" - -import { ConnectionState, type RetryConfig } from "@roo-code/types" - -export interface SocketTransportOptions { - url: string - socketOptions: Partial - onConnect?: () => void | Promise - onDisconnect?: (reason: string) => void - onReconnect?: () => void | Promise - logger?: { - log: (message: string, ...args: unknown[]) => void - error: (message: string, ...args: unknown[]) => void - warn: (message: string, ...args: unknown[]) => void - } -} - -/** - * Manages the WebSocket transport layer for the bridge system. - * Handles connection lifecycle, retries, and reconnection logic. - */ -export class SocketTransport { - private socket: Socket | null = null - private connectionState: ConnectionState = ConnectionState.DISCONNECTED - private retryTimeout: NodeJS.Timeout | null = null - private isPreviouslyConnected: boolean = false - - private readonly retryConfig: RetryConfig = { - maxInitialAttempts: Infinity, - initialDelay: 1_000, - maxDelay: 15_000, - backoffMultiplier: 2, - } - - private readonly CONNECTION_TIMEOUT = 2_000 - private readonly options: SocketTransportOptions - - constructor(options: SocketTransportOptions, retryConfig?: Partial) { - this.options = options - - if (retryConfig) { - this.retryConfig = { ...this.retryConfig, ...retryConfig } - } - } - - // This is the initial connnect attempt. We need to implement our own - // infinite retry mechanism since Socket.io's automatic reconnection only - // kicks in after a successful initial connection. - public async connect(): Promise { - if (this.connectionState === ConnectionState.CONNECTED) { - console.log(`[SocketTransport#connect] Already connected`) - return - } - - if (this.connectionState === ConnectionState.CONNECTING || this.connectionState === ConnectionState.RETRYING) { - console.log(`[SocketTransport#connect] Already in progress`) - return - } - - let attempt = 0 - let delay = this.retryConfig.initialDelay - - while (attempt < this.retryConfig.maxInitialAttempts) { - console.log(`[SocketTransport#connect] attempt = ${attempt + 1}, delay = ${delay}ms`) - this.connectionState = attempt === 0 ? ConnectionState.CONNECTING : ConnectionState.RETRYING - - try { - await this._connect() - break - } catch (_error) { - attempt++ - - if (this.socket) { - this.socket.disconnect() - this.socket = null - } - - const promise = new Promise((resolve) => { - this.retryTimeout = setTimeout(resolve, delay) - }) - - await promise - - delay = Math.min(delay * this.retryConfig.backoffMultiplier, this.retryConfig.maxDelay) - } - } - - if (this.retryTimeout) { - clearTimeout(this.retryTimeout) - this.retryTimeout = null - } - - if (this.socket?.connected) { - console.log(`[SocketTransport#connect] connected - ${this.options.url}`) - } else { - // Since we have infinite retries this should never happen. - this.connectionState = ConnectionState.FAILED - console.error(`[SocketTransport#connect] Giving up`) - } - } - - private async _connect(): Promise { - return new Promise((resolve, reject) => { - this.socket = io(this.options.url, this.options.socketOptions) - - let connectionTimeout: NodeJS.Timeout | null = setTimeout(() => { - console.error(`[SocketTransport#_connect] failed to connect after ${this.CONNECTION_TIMEOUT}ms`) - - if (this.connectionState !== ConnectionState.CONNECTED) { - this.socket?.disconnect() - reject(new Error("Connection timeout")) - } - }, this.CONNECTION_TIMEOUT) - - // https://socket.io/docs/v4/client-api/#event-connect - this.socket.on("connect", async () => { - console.log( - `[SocketTransport#_connect] on(connect): isPreviouslyConnected = ${this.isPreviouslyConnected}`, - ) - - if (connectionTimeout) { - clearTimeout(connectionTimeout) - connectionTimeout = null - } - - this.connectionState = ConnectionState.CONNECTED - - if (this.isPreviouslyConnected) { - if (this.options.onReconnect) { - await this.options.onReconnect() - } - } else { - if (this.options.onConnect) { - await this.options.onConnect() - } - } - - this.isPreviouslyConnected = true - resolve() - }) - - // https://socket.io/docs/v4/client-api/#event-connect_error - this.socket.on("connect_error", (error) => { - if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) { - console.error(`[SocketTransport] on(connect_error): ${error.message}`) - clearTimeout(connectionTimeout) - connectionTimeout = null - reject(error) - } - }) - - // https://socket.io/docs/v4/client-api/#event-disconnect - this.socket.on("disconnect", (reason, details) => { - console.log( - `[SocketTransport#_connect] on(disconnect) (reason: ${reason}, details: ${JSON.stringify(details)})`, - ) - this.connectionState = ConnectionState.DISCONNECTED - - if (this.options.onDisconnect) { - this.options.onDisconnect(reason) - } - - // Don't attempt to reconnect if we're manually disconnecting. - const isManualDisconnect = reason === "io client disconnect" - - if (!isManualDisconnect && this.isPreviouslyConnected) { - // After successful initial connection, rely entirely on - // Socket.IO's reconnection logic. - console.log("[SocketTransport#_connect] will attempt to reconnect") - } else { - console.log("[SocketTransport#_connect] will *NOT* attempt to reconnect") - } - }) - - // https://socket.io/docs/v4/client-api/#event-error - // Fired upon a connection error. - this.socket.io.on("error", (error) => { - // Connection error. - if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) { - console.error(`[SocketTransport#_connect] on(error): ${error.message}`) - clearTimeout(connectionTimeout) - connectionTimeout = null - reject(error) - } - - // Post-connection error. - if (this.connectionState === ConnectionState.CONNECTED) { - console.error(`[SocketTransport#_connect] on(error): ${error.message}`) - } - }) - - // https://socket.io/docs/v4/client-api/#event-reconnect - // Fired upon a successful reconnection. - this.socket.io.on("reconnect", (attempt) => { - console.log(`[SocketTransport#_connect] on(reconnect) - ${attempt}`) - this.connectionState = ConnectionState.CONNECTED - - if (this.options.onReconnect) { - this.options.onReconnect() - } - }) - - // https://socket.io/docs/v4/client-api/#event-reconnect_attempt - // Fired upon an attempt to reconnect. - this.socket.io.on("reconnect_attempt", (attempt) => { - console.log(`[SocketTransport#_connect] on(reconnect_attempt) - ${attempt}`) - }) - - // https://socket.io/docs/v4/client-api/#event-reconnect_error - // Fired upon a reconnection attempt error. - this.socket.io.on("reconnect_error", (error) => { - console.error(`[SocketTransport#_connect] on(reconnect_error): ${error.message}`) - }) - - // https://socket.io/docs/v4/client-api/#event-reconnect_failed - // Fired when couldn't reconnect within `reconnectionAttempts`. - // Since we use infinite retries, this should never fire. - this.socket.io.on("reconnect_failed", () => { - console.error(`[SocketTransport#_connect] on(reconnect_failed) - giving up`) - this.connectionState = ConnectionState.FAILED - }) - - // This is a custom event fired by the server. - this.socket.on("auth_error", (error) => { - console.error( - `[SocketTransport#_connect] on(auth_error): ${error instanceof Error ? error.message : String(error)}`, - ) - - if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) { - clearTimeout(connectionTimeout) - connectionTimeout = null - reject(new Error(error.message || "Authentication failed")) - } - }) - }) - } - - public async disconnect(): Promise { - console.log(`[SocketTransport#disconnect] Disconnecting...`) - - if (this.retryTimeout) { - clearTimeout(this.retryTimeout) - this.retryTimeout = null - } - - if (this.socket) { - this.socket.removeAllListeners() - this.socket.io.removeAllListeners() - this.socket.disconnect() - this.socket = null - } - - this.connectionState = ConnectionState.DISCONNECTED - console.log(`[SocketTransport#disconnect] Disconnected`) - } - - public getSocket(): Socket | null { - return this.socket - } - - public getConnectionState(): ConnectionState { - return this.connectionState - } - - public isConnected(): boolean { - return this.connectionState === ConnectionState.CONNECTED && this.socket?.connected === true - } - - public async reconnect(): Promise { - console.log(`[SocketTransport#reconnect] Manually reconnecting...`) - - if (this.connectionState === ConnectionState.CONNECTED) { - console.log(`[SocketTransport#reconnect] Already connected`) - return - } - - this.isPreviouslyConnected = false - await this.disconnect() - await this.connect() - } -} diff --git a/packages/cloud/src/bridge/TaskChannel.ts b/packages/cloud/src/bridge/TaskChannel.ts deleted file mode 100644 index 433e740d4e..0000000000 --- a/packages/cloud/src/bridge/TaskChannel.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { Socket } from "socket.io-client" - -import { - type ClineMessage, - type TaskEvents, - type TaskLike, - type TaskBridgeCommand, - type TaskBridgeEvent, - type JoinResponse, - type LeaveResponse, - RooCodeEventName, - TaskBridgeEventName, - TaskBridgeCommandName, - TaskSocketEvents, -} from "@roo-code/types" - -import { type BaseChannelOptions, BaseChannel } from "./BaseChannel.js" - -type TaskEventListener = { - [K in keyof TaskEvents]: (...args: TaskEvents[K]) => void | Promise -}[keyof TaskEvents] - -type TaskEventMapping = { - from: keyof TaskEvents - to: TaskBridgeEventName - createPayload: (task: TaskLike, ...args: any[]) => any // eslint-disable-line @typescript-eslint/no-explicit-any -} - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -interface TaskChannelOptions extends BaseChannelOptions {} - -/** - * Manages task-level communication channels. - * Handles task subscriptions, messaging, and task-specific commands. - */ -export class TaskChannel extends BaseChannel< - TaskBridgeCommand, - TaskSocketEvents, - TaskBridgeEvent | { taskId: string } -> { - private subscribedTasks: Map = new Map() - private pendingTasks: Map = new Map() - private taskListeners: Map> = new Map() - - private readonly eventMapping: readonly TaskEventMapping[] = [ - { - from: RooCodeEventName.Message, - to: TaskBridgeEventName.Message, - createPayload: (task: TaskLike, data: { action: string; message: ClineMessage }) => ({ - type: TaskBridgeEventName.Message, - taskId: task.taskId, - action: data.action, - message: data.message, - }), - }, - { - from: RooCodeEventName.TaskModeSwitched, - to: TaskBridgeEventName.TaskModeSwitched, - createPayload: (task: TaskLike, mode: string) => ({ - type: TaskBridgeEventName.TaskModeSwitched, - taskId: task.taskId, - mode, - }), - }, - { - from: RooCodeEventName.TaskInteractive, - to: TaskBridgeEventName.TaskInteractive, - createPayload: (task: TaskLike, _taskId: string) => ({ - type: TaskBridgeEventName.TaskInteractive, - taskId: task.taskId, - }), - }, - ] as const - - constructor(options: TaskChannelOptions) { - super(options) - } - - protected async handleCommandImplementation(command: TaskBridgeCommand): Promise { - const task = this.subscribedTasks.get(command.taskId) - - if (!task) { - console.error(`[TaskChannel] Unable to find task ${command.taskId}`) - return - } - - switch (command.type) { - case TaskBridgeCommandName.Message: - console.log( - `[TaskChannel] ${TaskBridgeCommandName.Message} ${command.taskId} -> submitUserMessage()`, - command, - ) - - await task.submitUserMessage( - command.payload.text, - command.payload.images, - command.payload.mode, - command.payload.providerProfile, - ) - - break - - case TaskBridgeCommandName.ApproveAsk: - console.log( - `[TaskChannel] ${TaskBridgeCommandName.ApproveAsk} ${command.taskId} -> approveAsk()`, - command, - ) - - task.approveAsk(command.payload) - break - - case TaskBridgeCommandName.DenyAsk: - console.log(`[TaskChannel] ${TaskBridgeCommandName.DenyAsk} ${command.taskId} -> denyAsk()`, command) - task.denyAsk(command.payload) - break - } - } - - protected async handleConnect(socket: Socket): Promise { - // Rejoin all subscribed tasks. - for (const taskId of this.subscribedTasks.keys()) { - await this.publish(TaskSocketEvents.JOIN, { taskId }) - } - - // Subscribe to any pending tasks. - for (const task of this.pendingTasks.values()) { - await this.subscribeToTask(task, socket) - } - - this.pendingTasks.clear() - } - - protected async handleReconnect(_socket: Socket): Promise { - // Rejoin all subscribed tasks. - for (const taskId of this.subscribedTasks.keys()) { - await this.publish(TaskSocketEvents.JOIN, { taskId }) - } - } - - protected async handleCleanup(socket: Socket): Promise { - const unsubscribePromises = [] - - for (const taskId of this.subscribedTasks.keys()) { - unsubscribePromises.push(this.unsubscribeFromTask(taskId, socket)) - } - - await Promise.allSettled(unsubscribePromises) - this.subscribedTasks.clear() - this.taskListeners.clear() - this.pendingTasks.clear() - } - - /** - * Add a task to the pending queue (will be subscribed when connected). - */ - public addPendingTask(task: TaskLike): void { - this.pendingTasks.set(task.taskId, task) - } - - public async subscribeToTask(task: TaskLike, _socket: Socket): Promise { - const taskId = task.taskId - - await this.publish(TaskSocketEvents.JOIN, { taskId }, (response: JoinResponse) => { - if (response.success) { - console.log(`[TaskChannel#subscribeToTask] subscribed to ${taskId}`) - this.subscribedTasks.set(taskId, task) - this.setupTaskListeners(task) - } else { - console.error(`[TaskChannel#subscribeToTask] failed to subscribe to ${taskId}: ${response.error}`) - } - }) - } - - public async unsubscribeFromTask(taskId: string, _socket: Socket): Promise { - const task = this.subscribedTasks.get(taskId) - - if (!task) { - return - } - - await this.publish(TaskSocketEvents.LEAVE, { taskId }, (response: LeaveResponse) => { - if (response.success) { - console.log(`[TaskChannel#unsubscribeFromTask] unsubscribed from ${taskId}`) - } else { - console.error(`[TaskChannel#unsubscribeFromTask] failed to unsubscribe from ${taskId}`) - } - - // If we failed to unsubscribe then something is probably wrong and - // we should still discard this task from `subscribedTasks`. - this.removeTaskListeners(task) - this.subscribedTasks.delete(taskId) - }) - } - - private setupTaskListeners(task: TaskLike): void { - if (this.taskListeners.has(task.taskId)) { - console.warn(`[TaskChannel] Listeners already exist for task, removing old listeners for ${task.taskId}`) - this.removeTaskListeners(task) - } - - const listeners = new Map() - - this.eventMapping.forEach(({ from, to, createPayload }) => { - const listener = (...args: unknown[]) => { - const payload = createPayload(task, ...args) - this.publish(TaskSocketEvents.EVENT, payload) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - task.on(from, listener as any) - listeners.set(to, listener) - }) - - this.taskListeners.set(task.taskId, listeners) - } - - private removeTaskListeners(task: TaskLike): void { - const listeners = this.taskListeners.get(task.taskId) - - if (!listeners) { - return - } - - this.eventMapping.forEach(({ from, to }) => { - const listener = listeners.get(to) - if (listener) { - try { - task.off(from, listener as any) // eslint-disable-line @typescript-eslint/no-explicit-any - } catch (error) { - console.error( - `[TaskChannel] task.off(${from}) failed for task ${task.taskId}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - } - } - }) - - this.taskListeners.delete(task.taskId) - } -} diff --git a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts deleted file mode 100644 index 188e2cc029..0000000000 --- a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import type { Socket } from "socket.io-client" - -import { - type TaskProviderLike, - type TaskProviderEvents, - type StaticAppProperties, - RooCodeEventName, - ExtensionBridgeEventName, - ExtensionSocketEvents, -} from "@roo-code/types" - -import { ExtensionChannel } from "../ExtensionChannel.js" - -describe("ExtensionChannel", () => { - let mockSocket: Socket - let mockProvider: TaskProviderLike - let extensionChannel: ExtensionChannel - const instanceId = "test-instance-123" - const userId = "test-user-456" - - const appProperties: StaticAppProperties = { - appName: "roo-code", - appVersion: "1.0.0", - vscodeVersion: "1.0.0", - platform: "darwin", - editorName: "Roo Code", - hostname: "test-host", - } - - // Track registered event listeners - const eventListeners = new Map unknown>>() - - beforeEach(() => { - // Reset the event listeners tracker - eventListeners.clear() - - // Create mock socket - mockSocket = { - emit: vi.fn(), - on: vi.fn(), - off: vi.fn(), - disconnect: vi.fn(), - } as unknown as Socket - - // Create mock provider with event listener tracking - mockProvider = { - cwd: "/test/workspace", - appProperties: { - version: "1.0.0", - extensionVersion: "1.0.0", - }, - gitProperties: undefined, - getCurrentTask: vi.fn().mockReturnValue(undefined), - getCurrentTaskStack: vi.fn().mockReturnValue([]), - getRecentTasks: vi.fn().mockReturnValue([]), - createTask: vi.fn(), - cancelTask: vi.fn(), - clearTask: vi.fn(), - resumeTask: vi.fn(), - getState: vi.fn(), - postStateToWebview: vi.fn(), - postMessageToWebview: vi.fn(), - getTelemetryProperties: vi.fn(), - getMode: vi.fn().mockResolvedValue("code"), - getModes: vi.fn().mockResolvedValue([ - { slug: "code", name: "Code", description: "Code mode" }, - { slug: "architect", name: "Architect", description: "Architect mode" }, - ]), - getProviderProfile: vi.fn().mockResolvedValue("default"), - getProviderProfiles: vi.fn().mockResolvedValue([{ name: "default", description: "Default profile" }]), - on: vi.fn((event: keyof TaskProviderEvents, listener: (...args: unknown[]) => unknown) => { - if (!eventListeners.has(event)) { - eventListeners.set(event, new Set()) - } - eventListeners.get(event)!.add(listener) - return mockProvider - }), - off: vi.fn((event: keyof TaskProviderEvents, listener: (...args: unknown[]) => unknown) => { - const listeners = eventListeners.get(event) - if (listeners) { - listeners.delete(listener) - if (listeners.size === 0) { - eventListeners.delete(event) - } - } - return mockProvider - }), - } as unknown as TaskProviderLike - - // Create extension channel instance - extensionChannel = new ExtensionChannel({ - instanceId, - appProperties, - userId, - provider: mockProvider, - isCloudAgent: false, - }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe("Event Listener Management", () => { - it("should register event listeners on initialization", () => { - // Verify that listeners were registered for all expected events - const expectedEvents: RooCodeEventName[] = [ - RooCodeEventName.TaskCreated, - RooCodeEventName.TaskStarted, - RooCodeEventName.TaskCompleted, - RooCodeEventName.TaskAborted, - RooCodeEventName.TaskFocused, - RooCodeEventName.TaskUnfocused, - RooCodeEventName.TaskActive, - RooCodeEventName.TaskInteractive, - RooCodeEventName.TaskResumable, - RooCodeEventName.TaskIdle, - RooCodeEventName.TaskPaused, - RooCodeEventName.TaskUnpaused, - RooCodeEventName.TaskSpawned, - RooCodeEventName.TaskDelegated, - RooCodeEventName.TaskDelegationCompleted, - RooCodeEventName.TaskDelegationResumed, - - RooCodeEventName.TaskUserMessage, - RooCodeEventName.TaskTokenUsageUpdated, - ] - - // Check that on() was called for each event - expect(mockProvider.on).toHaveBeenCalledTimes(expectedEvents.length) - - // Verify each event was registered - expectedEvents.forEach((eventName) => { - expect(mockProvider.on).toHaveBeenCalledWith(eventName, expect.any(Function)) - }) - - // Verify listeners are tracked in our Map - expect(eventListeners.size).toBe(expectedEvents.length) - }) - - it("should remove all event listeners during cleanup", async () => { - // Verify initial state - listeners are registered - const initialListenerCount = eventListeners.size - expect(initialListenerCount).toBeGreaterThan(0) - - // Get the count of listeners for each event before cleanup - const listenerCountsBefore = new Map() - eventListeners.forEach((listeners, event) => { - listenerCountsBefore.set(event, listeners.size) - }) - - // Perform cleanup - await extensionChannel.cleanup(mockSocket) - - // Verify that off() was called for each registered event - expect(mockProvider.off).toHaveBeenCalledTimes(initialListenerCount) - - // Verify all listeners were removed from our tracking Map - expect(eventListeners.size).toBe(0) - - // Verify that the same listener functions that were added were removed - const onCalls = (mockProvider.on as any).mock.calls - const offCalls = (mockProvider.off as any).mock.calls - - // Each on() call should have a corresponding off() call with the same listener - onCalls.forEach(([eventName, listener]: [keyof TaskProviderEvents, any]) => { - const hasMatchingOff = offCalls.some( - ([offEvent, offListener]: [keyof TaskProviderEvents, any]) => - offEvent === eventName && offListener === listener, - ) - expect(hasMatchingOff).toBe(true) - }) - }) - - it("should not have duplicate listeners after multiple channel creations", () => { - // Create a second channel with the same provider - const secondChannel = new ExtensionChannel({ - instanceId: "instance-2", - appProperties, - userId, - provider: mockProvider, - isCloudAgent: false, - }) - - // Each event should have exactly 2 listeners (one from each channel) - eventListeners.forEach((listeners) => { - expect(listeners.size).toBe(2) - }) - - // Clean up the first channel - extensionChannel.cleanup(mockSocket) - - // Each event should now have exactly 1 listener (from the second channel) - eventListeners.forEach((listeners) => { - expect(listeners.size).toBe(1) - }) - - // Clean up the second channel - secondChannel.cleanup(mockSocket) - - // All listeners should be removed - expect(eventListeners.size).toBe(0) - }) - - it("should handle cleanup even if called multiple times", async () => { - // First cleanup - await extensionChannel.cleanup(mockSocket) - const firstOffCallCount = (mockProvider.off as any).mock.calls.length - - // Second cleanup (should be safe to call again) - await extensionChannel.cleanup(mockSocket) - const secondOffCallCount = (mockProvider.off as any).mock.calls.length - - // The second cleanup shouldn't try to remove listeners again - // since the internal Map was cleared - expect(secondOffCallCount).toBe(firstOffCallCount) - }) - - it("should properly forward events to socket when listeners are triggered", async () => { - // Connect the socket to enable publishing - await extensionChannel.onConnect(mockSocket) - - // Clear the mock calls from the connection (which emits a register event) - ;(mockSocket.emit as any).mockClear() - - // Get a listener that was registered for TaskStarted - const taskStartedListeners = eventListeners.get(RooCodeEventName.TaskStarted) - expect(taskStartedListeners).toBeDefined() - expect(taskStartedListeners!.size).toBe(1) - - // Trigger the listener - const listener = Array.from(taskStartedListeners!)[0] - if (listener) { - await listener("test-task-id") - } - - // Verify the event was published to the socket - expect(mockSocket.emit).toHaveBeenCalledWith( - ExtensionSocketEvents.EVENT, - expect.objectContaining({ - type: ExtensionBridgeEventName.TaskStarted, - instance: expect.objectContaining({ - instanceId, - userId, - }), - timestamp: expect.any(Number), - }), - 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", () => { - it("should not accumulate listeners over multiple connect/disconnect cycles", async () => { - // Simulate multiple connect/disconnect cycles - for (let i = 0; i < 5; i++) { - await extensionChannel.onConnect(mockSocket) - extensionChannel.onDisconnect() - } - - // Listeners should still be the same count (not accumulated) - expect(eventListeners.size).toBe(18) - - // Each event should have exactly 1 listener - eventListeners.forEach((listeners) => { - expect(listeners.size).toBe(1) - }) - }) - - it("should properly clean up heartbeat interval", async () => { - // Spy on setInterval and clearInterval - const setIntervalSpy = vi.spyOn(global, "setInterval") - const clearIntervalSpy = vi.spyOn(global, "clearInterval") - - // Connect to start heartbeat - await extensionChannel.onConnect(mockSocket) - expect(setIntervalSpy).toHaveBeenCalled() - - // Get the interval ID - const intervalId = setIntervalSpy.mock.results[0]?.value - - // Cleanup should stop the heartbeat - await extensionChannel.cleanup(mockSocket) - expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId) - - setIntervalSpy.mockRestore() - clearIntervalSpy.mockRestore() - }) - }) -}) diff --git a/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts b/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts deleted file mode 100644 index f4f1526607..0000000000 --- a/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import type { Socket } from "socket.io-client" - -import { - type TaskLike, - type ClineMessage, - type StaticAppProperties, - RooCodeEventName, - TaskBridgeEventName, - TaskBridgeCommandName, - TaskSocketEvents, - TaskStatus, -} from "@roo-code/types" - -import { TaskChannel } from "../TaskChannel.js" - -describe("TaskChannel", () => { - let mockSocket: Socket - let taskChannel: TaskChannel - let mockTask: TaskLike - const instanceId = "test-instance-123" - const taskId = "test-task-456" - - const appProperties: StaticAppProperties = { - appName: "roo-code", - appVersion: "1.0.0", - vscodeVersion: "1.0.0", - platform: "darwin", - editorName: "Roo Code", - hostname: "test-host", - } - - beforeEach(() => { - // Create mock socket - mockSocket = { - emit: vi.fn(), - on: vi.fn(), - off: vi.fn(), - disconnect: vi.fn(), - } as unknown as Socket - - // Create mock task with event emitter functionality - const listeners = new Map unknown>>() - mockTask = { - taskId, - taskStatus: TaskStatus.Running, - taskAsk: undefined, - metadata: {}, - on: vi.fn((event: string, listener: (...args: unknown[]) => unknown) => { - if (!listeners.has(event)) { - listeners.set(event, new Set()) - } - listeners.get(event)!.add(listener) - return mockTask - }), - off: vi.fn((event: string, listener: (...args: unknown[]) => unknown) => { - const eventListeners = listeners.get(event) - if (eventListeners) { - eventListeners.delete(listener) - if (eventListeners.size === 0) { - listeners.delete(event) - } - } - return mockTask - }), - approveAsk: vi.fn(), - denyAsk: vi.fn(), - submitUserMessage: vi.fn(), - abortTask: vi.fn(), - // Helper to trigger events in tests - _triggerEvent: (event: string, ...args: any[]) => { - const eventListeners = listeners.get(event) - if (eventListeners) { - eventListeners.forEach((listener) => listener(...args)) - } - }, - _getListenerCount: (event: string) => { - return listeners.get(event)?.size || 0 - }, - } as unknown as TaskLike & { - _triggerEvent: (event: string, ...args: any[]) => void - _getListenerCount: (event: string) => number - } - - // Create task channel instance - taskChannel = new TaskChannel({ - instanceId, - appProperties, - isCloudAgent: false, - }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe("Event Mapping Refactoring", () => { - it("should use the unified event mapping approach", () => { - // Access the private eventMapping through type assertion - const channel = taskChannel as any - - // Verify eventMapping exists and has the correct structure - expect(channel.eventMapping).toBeDefined() - expect(Array.isArray(channel.eventMapping)).toBe(true) - expect(channel.eventMapping.length).toBe(3) - - // Verify each mapping has the required properties - channel.eventMapping.forEach((mapping: any) => { - expect(mapping).toHaveProperty("from") - expect(mapping).toHaveProperty("to") - expect(mapping).toHaveProperty("createPayload") - expect(typeof mapping.createPayload).toBe("function") - }) - - // Verify specific mappings - expect(channel.eventMapping[0].from).toBe(RooCodeEventName.Message) - expect(channel.eventMapping[0].to).toBe(TaskBridgeEventName.Message) - - expect(channel.eventMapping[1].from).toBe(RooCodeEventName.TaskModeSwitched) - expect(channel.eventMapping[1].to).toBe(TaskBridgeEventName.TaskModeSwitched) - - expect(channel.eventMapping[2].from).toBe(RooCodeEventName.TaskInteractive) - expect(channel.eventMapping[2].to).toBe(TaskBridgeEventName.TaskInteractive) - }) - - it("should setup listeners using the event mapping", async () => { - // Mock the publish method to simulate successful subscription - const channel = taskChannel as any - channel.publish = vi.fn((event: string, data: any, callback?: Function) => { - if (event === TaskSocketEvents.JOIN && callback) { - // Simulate successful join response - callback({ success: true }) - } - return true - }) - - // Connect and subscribe to task - await taskChannel.onConnect(mockSocket) - await channel.subscribeToTask(mockTask, mockSocket) - - // Wait for async operations - await new Promise((resolve) => setTimeout(resolve, 0)) - - // Verify listeners were registered for all mapped events - const task = mockTask as any - expect(task._getListenerCount(RooCodeEventName.Message)).toBe(1) - expect(task._getListenerCount(RooCodeEventName.TaskModeSwitched)).toBe(1) - expect(task._getListenerCount(RooCodeEventName.TaskInteractive)).toBe(1) - }) - - it("should correctly transform Message event payloads", async () => { - // Setup channel with task - const channel = taskChannel as any - let publishCalls: any[] = [] - - channel.publish = vi.fn((event: string, data: any, callback?: Function) => { - publishCalls.push({ event, data }) - - if (event === TaskSocketEvents.JOIN && callback) { - callback({ success: true }) - } - - return true - }) - - await taskChannel.onConnect(mockSocket) - await channel.subscribeToTask(mockTask, mockSocket) - await new Promise((resolve) => setTimeout(resolve, 0)) - - // Clear previous calls - publishCalls = [] - - // Trigger Message event - const messageData = { - action: "test-action", - message: { type: "say", text: "Hello" } as ClineMessage, - } - - ;(mockTask as any)._triggerEvent(RooCodeEventName.Message, messageData) - - // Verify the event was published with correct payload - expect(publishCalls.length).toBe(1) - expect(publishCalls[0]).toEqual({ - event: TaskSocketEvents.EVENT, - data: { - type: TaskBridgeEventName.Message, - taskId: taskId, - action: messageData.action, - message: messageData.message, - }, - }) - }) - - it("should correctly transform TaskModeSwitched event payloads", async () => { - // Setup channel with task - const channel = taskChannel as any - let publishCalls: any[] = [] - - channel.publish = vi.fn((event: string, data: any, callback?: Function) => { - publishCalls.push({ event, data }) - - if (event === TaskSocketEvents.JOIN && callback) { - callback({ success: true }) - } - - return true - }) - - await taskChannel.onConnect(mockSocket) - await channel.subscribeToTask(mockTask, mockSocket) - await new Promise((resolve) => setTimeout(resolve, 0)) - - // Clear previous calls - publishCalls = [] - - // Trigger TaskModeSwitched event - const mode = "architect" - ;(mockTask as any)._triggerEvent(RooCodeEventName.TaskModeSwitched, mode) - - // Verify the event was published with correct payload - expect(publishCalls.length).toBe(1) - expect(publishCalls[0]).toEqual({ - event: TaskSocketEvents.EVENT, - data: { - type: TaskBridgeEventName.TaskModeSwitched, - taskId: taskId, - mode: mode, - }, - }) - }) - - it("should correctly transform TaskInteractive event payloads", async () => { - // Setup channel with task - const channel = taskChannel as any - let publishCalls: any[] = [] - - channel.publish = vi.fn((event: string, data: any, callback?: Function) => { - publishCalls.push({ event, data }) - if (event === TaskSocketEvents.JOIN && callback) { - callback({ success: true }) - } - return true - }) - - await taskChannel.onConnect(mockSocket) - await channel.subscribeToTask(mockTask, mockSocket) - await new Promise((resolve) => setTimeout(resolve, 0)) - - // Clear previous calls - publishCalls = [] - - // Trigger TaskInteractive event - ;(mockTask as any)._triggerEvent(RooCodeEventName.TaskInteractive, taskId) - - // Verify the event was published with correct payload - expect(publishCalls.length).toBe(1) - expect(publishCalls[0]).toEqual({ - event: TaskSocketEvents.EVENT, - data: { - type: TaskBridgeEventName.TaskInteractive, - taskId: taskId, - }, - }) - }) - - it("should properly clean up listeners using event mapping", async () => { - // Setup channel with task - const channel = taskChannel as any - - channel.publish = vi.fn((event: string, data: any, callback?: Function) => { - if (event === TaskSocketEvents.JOIN && callback) { - callback({ success: true }) - } - if (event === TaskSocketEvents.LEAVE && callback) { - callback({ success: true }) - } - return true - }) - - await taskChannel.onConnect(mockSocket) - await channel.subscribeToTask(mockTask, mockSocket) - await new Promise((resolve) => setTimeout(resolve, 0)) - - // Verify listeners are registered - const task = mockTask as any - expect(task._getListenerCount(RooCodeEventName.Message)).toBe(1) - expect(task._getListenerCount(RooCodeEventName.TaskModeSwitched)).toBe(1) - expect(task._getListenerCount(RooCodeEventName.TaskInteractive)).toBe(1) - - // Clean up - await taskChannel.cleanup(mockSocket) - - // Verify all listeners were removed - expect(task._getListenerCount(RooCodeEventName.Message)).toBe(0) - expect(task._getListenerCount(RooCodeEventName.TaskModeSwitched)).toBe(0) - expect(task._getListenerCount(RooCodeEventName.TaskInteractive)).toBe(0) - }) - - it("should handle duplicate listener prevention", async () => { - // Setup channel with task - await taskChannel.onConnect(mockSocket) - - // Subscribe to the same task twice - const channel = taskChannel as any - channel.subscribedTasks.set(taskId, mockTask) - channel.setupTaskListeners(mockTask) - - // Try to setup listeners again (should remove old ones first) - const warnSpy = vi.spyOn(console, "warn") - channel.setupTaskListeners(mockTask) - - // Verify warning was logged - expect(warnSpy).toHaveBeenCalledWith( - `[TaskChannel] Listeners already exist for task, removing old listeners for ${taskId}`, - ) - - // Verify only one set of listeners exists - const task = mockTask as any - expect(task._getListenerCount(RooCodeEventName.Message)).toBe(1) - expect(task._getListenerCount(RooCodeEventName.TaskModeSwitched)).toBe(1) - expect(task._getListenerCount(RooCodeEventName.TaskInteractive)).toBe(1) - - warnSpy.mockRestore() - }) - }) - - describe("Command Handling", () => { - beforeEach(async () => { - // Setup channel with a subscribed task - await taskChannel.onConnect(mockSocket) - const channel = taskChannel as any - channel.subscribedTasks.set(taskId, mockTask) - }) - - it("should handle Message command", async () => { - const command = { - type: TaskBridgeCommandName.Message, - taskId, - timestamp: Date.now(), - payload: { - text: "Hello, world!", - images: ["image1.png"], - }, - } - - await taskChannel.handleCommand(command) - - expect(mockTask.submitUserMessage).toHaveBeenCalledWith( - command.payload.text, - command.payload.images, - undefined, - undefined, - ) - }) - - it("should handle ApproveAsk command", async () => { - const command = { - type: TaskBridgeCommandName.ApproveAsk, - taskId, - timestamp: Date.now(), - payload: { - text: "Approved", - }, - } - - await taskChannel.handleCommand(command) - - expect(mockTask.approveAsk).toHaveBeenCalledWith(command.payload) - }) - - it("should handle DenyAsk command", async () => { - const command = { - type: TaskBridgeCommandName.DenyAsk, - taskId, - timestamp: Date.now(), - payload: { - text: "Denied", - }, - } - - await taskChannel.handleCommand(command) - - expect(mockTask.denyAsk).toHaveBeenCalledWith(command.payload) - }) - - it("should log error for unknown task", async () => { - const errorSpy = vi.spyOn(console, "error") - - const command = { - type: TaskBridgeCommandName.Message, - taskId: "unknown-task", - timestamp: Date.now(), - payload: { - text: "Hello", - }, - } - - await taskChannel.handleCommand(command) - - expect(errorSpy).toHaveBeenCalledWith(`[TaskChannel] Unable to find task unknown-task`) - - errorSpy.mockRestore() - }) - }) -}) diff --git a/packages/cloud/src/bridge/index.ts b/packages/cloud/src/bridge/index.ts deleted file mode 100644 index 94873c09fd..0000000000 --- a/packages/cloud/src/bridge/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { type BridgeOrchestratorOptions, BridgeOrchestrator } from "./BridgeOrchestrator.js" -export { type SocketTransportOptions, SocketTransport } from "./SocketTransport.js" - -export { BaseChannel } from "./BaseChannel.js" -export { ExtensionChannel } from "./ExtensionChannel.js" -export { TaskChannel } from "./TaskChannel.js" diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index 65b92ebc13..8792176fee 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -2,7 +2,5 @@ export * from "./config.js" export { CloudService } from "./CloudService.js" -export { BridgeOrchestrator } from "./bridge/index.js" - export { RetryQueue } from "./retry-queue/index.js" export type { QueuedRequest, QueueStats, RetryQueueConfig, RetryQueueEvents } from "./retry-queue/index.js" diff --git a/packages/types/src/__tests__/cloud.test.ts b/packages/types/src/__tests__/cloud.test.ts index 4e9e792a29..9c2a32a99c 100644 --- a/packages/types/src/__tests__/cloud.test.ts +++ b/packages/types/src/__tests__/cloud.test.ts @@ -21,43 +21,9 @@ describe("organizationFeaturesSchema", () => { expect(result.data).toEqual({}) }) - it("should validate with roomoteControlEnabled as true", () => { - const input = { roomoteControlEnabled: true } - const result = organizationFeaturesSchema.safeParse(input) - expect(result.success).toBe(true) - expect(result.data).toEqual(input) - }) - - it("should validate with roomoteControlEnabled as false", () => { - const input = { roomoteControlEnabled: false } - const result = organizationFeaturesSchema.safeParse(input) - expect(result.success).toBe(true) - expect(result.data).toEqual(input) - }) - - it("should reject non-boolean roomoteControlEnabled", () => { - const input = { roomoteControlEnabled: "true" } - const result = organizationFeaturesSchema.safeParse(input) - expect(result.success).toBe(false) - }) - - it("should allow additional properties (for future extensibility)", () => { - const input = { roomoteControlEnabled: true, futureProperty: "test" } - const result = organizationFeaturesSchema.safeParse(input) - expect(result.success).toBe(true) - expect(result.data?.roomoteControlEnabled).toBe(true) - // Note: Additional properties are stripped by Zod, which is expected behavior - }) - it("should have correct TypeScript type", () => { - // Type-only test to ensure TypeScript compilation - const features: OrganizationFeatures = { - roomoteControlEnabled: true, - } - expect(features.roomoteControlEnabled).toBe(true) - const emptyFeatures: OrganizationFeatures = {} - expect(emptyFeatures.roomoteControlEnabled).toBeUndefined() + expect(emptyFeatures).toEqual({}) }) }) @@ -87,43 +53,7 @@ describe("organizationSettingsSchema with features", () => { expect(result.data?.features).toEqual({}) }) - it("should validate with features.roomoteControlEnabled as true", () => { - const input = { - ...validBaseSettings, - features: { - roomoteControlEnabled: true, - }, - } - const result = organizationSettingsSchema.safeParse(input) - expect(result.success).toBe(true) - expect(result.data?.features?.roomoteControlEnabled).toBe(true) - }) - - it("should validate with features.roomoteControlEnabled as false", () => { - const input = { - ...validBaseSettings, - features: { - roomoteControlEnabled: false, - }, - } - const result = organizationSettingsSchema.safeParse(input) - expect(result.success).toBe(true) - expect(result.data?.features?.roomoteControlEnabled).toBe(false) - }) - - it("should reject invalid features object", () => { - const input = { - ...validBaseSettings, - features: { - roomoteControlEnabled: "invalid", - }, - } - const result = organizationSettingsSchema.safeParse(input) - expect(result.success).toBe(false) - }) - it("should have correct TypeScript type for features", () => { - // Type-only test to ensure TypeScript compilation const settings: OrganizationSettings = { version: 1, defaultSettings: {}, @@ -131,11 +61,9 @@ describe("organizationSettingsSchema with features", () => { allowAll: true, providers: {}, }, - features: { - roomoteControlEnabled: true, - }, + features: {}, } - expect(settings.features?.roomoteControlEnabled).toBe(true) + expect(settings.features).toEqual({}) const settingsWithoutFeatures: OrganizationSettings = { version: 1, @@ -165,9 +93,7 @@ describe("organizationSettingsSchema with features", () => { }, }, }, - features: { - roomoteControlEnabled: true, - }, + features: {}, hiddenMcps: ["test-mcp"], hideMarketplaceMcps: true, mcps: [], @@ -415,7 +341,6 @@ describe("organizationCloudSettingsSchema with llmEnhancedFeaturesEnabled", () = describe("userSettingsConfigSchema with llmEnhancedFeaturesEnabled", () => { it("should validate without llmEnhancedFeaturesEnabled property", () => { const input = { - extensionBridgeEnabled: true, taskSyncEnabled: true, } const result = userSettingsConfigSchema.safeParse(input) @@ -425,7 +350,6 @@ describe("userSettingsConfigSchema with llmEnhancedFeaturesEnabled", () => { it("should validate with llmEnhancedFeaturesEnabled as true", () => { const input = { - extensionBridgeEnabled: true, taskSyncEnabled: true, llmEnhancedFeaturesEnabled: true, } @@ -436,7 +360,6 @@ describe("userSettingsConfigSchema with llmEnhancedFeaturesEnabled", () => { it("should validate with llmEnhancedFeaturesEnabled as false", () => { const input = { - extensionBridgeEnabled: true, taskSyncEnabled: true, llmEnhancedFeaturesEnabled: false, } @@ -456,15 +379,12 @@ describe("userSettingsConfigSchema with llmEnhancedFeaturesEnabled", () => { it("should have correct TypeScript type", () => { // Type-only test to ensure TypeScript compilation const settings: UserSettingsConfig = { - extensionBridgeEnabled: true, taskSyncEnabled: true, llmEnhancedFeaturesEnabled: true, } expect(settings.llmEnhancedFeaturesEnabled).toBe(true) - const settingsWithoutLlmFeatures: UserSettingsConfig = { - extensionBridgeEnabled: false, - } + const settingsWithoutLlmFeatures: UserSettingsConfig = {} expect(settingsWithoutLlmFeatures.llmEnhancedFeaturesEnabled).toBeUndefined() }) diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index 2de8ce9168..c991cdb1e6 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -41,7 +41,6 @@ export interface CloudUserInfo { organizationName?: string organizationRole?: string organizationImageUrl?: string - extensionBridgeEnabled?: boolean } /** @@ -143,9 +142,7 @@ export type OrganizationCloudSettings = z.infer @@ -171,14 +168,11 @@ export type OrganizationSettings = z.infer * User Settings Schemas */ -export const userFeaturesSchema = z.object({ - roomoteControlEnabled: z.boolean().optional(), -}) +export const userFeaturesSchema = z.object({}) export type UserFeatures = z.infer export const userSettingsConfigSchema = z.object({ - extensionBridgeEnabled: z.boolean().optional(), taskSyncEnabled: z.boolean().optional(), llmEnhancedFeaturesEnabled: z.boolean().optional(), }) @@ -430,206 +424,6 @@ export const extensionInstanceSchema = z.object({ export type ExtensionInstance = z.infer -/** - * ExtensionBridgeEvent - */ - -export enum ExtensionBridgeEventName { - TaskCreated = RooCodeEventName.TaskCreated, - TaskStarted = RooCodeEventName.TaskStarted, - TaskCompleted = RooCodeEventName.TaskCompleted, - TaskAborted = RooCodeEventName.TaskAborted, - TaskFocused = RooCodeEventName.TaskFocused, - TaskUnfocused = RooCodeEventName.TaskUnfocused, - TaskActive = RooCodeEventName.TaskActive, - TaskInteractive = RooCodeEventName.TaskInteractive, - TaskResumable = RooCodeEventName.TaskResumable, - TaskIdle = RooCodeEventName.TaskIdle, - - TaskPaused = RooCodeEventName.TaskPaused, - TaskUnpaused = RooCodeEventName.TaskUnpaused, - TaskSpawned = RooCodeEventName.TaskSpawned, - TaskDelegated = RooCodeEventName.TaskDelegated, - TaskDelegationCompleted = RooCodeEventName.TaskDelegationCompleted, - TaskDelegationResumed = RooCodeEventName.TaskDelegationResumed, - - TaskUserMessage = RooCodeEventName.TaskUserMessage, - - TaskTokenUsageUpdated = RooCodeEventName.TaskTokenUsageUpdated, - - ModeChanged = RooCodeEventName.ModeChanged, - ProviderProfileChanged = RooCodeEventName.ProviderProfileChanged, - - InstanceRegistered = "instance_registered", - InstanceUnregistered = "instance_unregistered", - HeartbeatUpdated = "heartbeat_updated", -} - -export const extensionBridgeEventSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskCreated), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskStarted), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskCompleted), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskAborted), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskFocused), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskUnfocused), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskActive), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskInteractive), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskResumable), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskIdle), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskPaused), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskUnpaused), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskSpawned), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskDelegated), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskDelegationCompleted), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskDelegationResumed), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskUserMessage), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskTokenUsageUpdated), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - - z.object({ - type: z.literal(ExtensionBridgeEventName.ModeChanged), - instance: extensionInstanceSchema, - mode: z.string(), - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.ProviderProfileChanged), - instance: extensionInstanceSchema, - providerProfile: z.object({ name: z.string(), provider: z.string().optional() }), - timestamp: z.number(), - }), - - z.object({ - type: z.literal(ExtensionBridgeEventName.InstanceRegistered), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.InstanceUnregistered), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.HeartbeatUpdated), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), -]) - -export type ExtensionBridgeEvent = z.infer - -/** - * ExtensionBridgeCommand - */ - -export enum ExtensionBridgeCommandName { - StartTask = "start_task", - StopTask = "stop_task", - ResumeTask = "resume_task", -} - -export const extensionBridgeCommandSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal(ExtensionBridgeCommandName.StartTask), - instanceId: z.string(), - payload: z.object({ - text: z.string(), - images: z.array(z.string()).optional(), - mode: z.string().optional(), - providerProfile: z.string().optional(), - }), - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeCommandName.StopTask), - instanceId: z.string(), - payload: z.object({ taskId: z.string() }), - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeCommandName.ResumeTask), - instanceId: z.string(), - payload: z.object({ taskId: z.string() }), - timestamp: z.number(), - }), -]) - -export type ExtensionBridgeCommand = z.infer - /** * TaskBridgeEvent */ diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 47b574e4b7..2d8c52cb04 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -366,9 +366,7 @@ export type ExtensionState = Pick< apiModelId?: string mcpServers?: McpServer[] mdmCompliant?: boolean - remoteControlEnabled: boolean taskSyncEnabled: boolean - featureRoomoteControlEnabled: boolean openAiCodexIsAuthenticated?: boolean debug?: boolean @@ -472,7 +470,6 @@ export interface WebviewMessage { | "deleteMessageConfirm" | "submitEditedMessage" | "editMessageConfirm" - | "remoteControlEnabled" | "taskSyncEnabled" | "searchCommits" | "setApiConfigPassword" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b461926f5..42a502c8c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -495,9 +495,6 @@ importers: p-wait-for: specifier: ^5.0.2 version: 5.0.2 - socket.io-client: - specifier: ^4.8.1 - version: 4.8.1 zod: specifier: 3.25.76 version: 3.25.76 @@ -971,9 +968,6 @@ importers: simple-git: specifier: ^3.27.0 version: 3.27.0 - socket.io-client: - specifier: ^4.8.1 - version: 4.8.1 sound-play: specifier: ^1.1.0 version: 1.1.0 @@ -1200,9 +1194,6 @@ importers: '@tanstack/react-query': specifier: ^5.68.0 version: 5.76.1(react@18.3.1) - '@types/qrcode': - specifier: ^1.5.5 - version: 1.5.5 '@vscode/codicons': specifier: ^0.0.36 version: 0.0.36 @@ -1266,9 +1257,6 @@ importers: pretty-bytes: specifier: ^7.0.0 version: 7.0.0 - qrcode: - specifier: ^1.5.4 - version: 1.5.4 react: specifier: ^18.3.1 version: 18.3.1 @@ -4093,9 +4081,6 @@ packages: resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} - '@socket.io/component-emitter@3.1.2': - resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -4580,9 +4565,6 @@ packages: '@types/ps-tree@1.1.6': resolution: {integrity: sha512-PtrlVaOaI44/3pl3cvnlK+GxOM3re2526TJvPvh7W+keHIXdV4TE0ylpPBAcvFQCbGitaTXwL9u+RF7qtVeazQ==} - '@types/qrcode@1.5.5': - resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==} - '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -5275,10 +5257,6 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -5429,9 +5407,6 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - cliui@6.0.0: - resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} - cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -5865,15 +5840,6 @@ packages: supports-color: optional: true - debug@4.3.7: - resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -5892,10 +5858,6 @@ packages: supports-color: optional: true - decamelize@1.2.0: - resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} - engines: {node: '>=0.10.0'} - decamelize@4.0.0: resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} engines: {node: '>=10'} @@ -6024,9 +5986,6 @@ packages: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} - dijkstrajs@1.0.3: - resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} - dingbat-to-unicode@1.0.1: resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} @@ -6262,13 +6221,6 @@ packages: end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} - engine.io-client@6.6.3: - resolution: {integrity: sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==} - - engine.io-parser@5.2.3: - resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} - engines: {node: '>=10.0.0'} - enhanced-resolve@5.18.1: resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} engines: {node: '>=10.13.0'} @@ -8896,10 +8848,6 @@ packages: pkg-types@2.2.0: resolution: {integrity: sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==} - pngjs@5.0.0: - resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} - engines: {node: '>=10.13.0'} - points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -9123,11 +9071,6 @@ packages: resolution: {integrity: sha512-CnzhOgrZj8DvkDqI+Yx+9or33i3Y9uUYbKyYpP4C13jWwXx/keQ38RMTMmxuLCWQlxjZrOH0Foq7P2fGP7adDQ==} engines: {node: '>=18'} - qrcode@1.5.4: - resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} - engines: {node: '>=10.13.0'} - hasBin: true - qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -9431,9 +9374,6 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - require-main-filename@2.0.0: - resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -9632,9 +9572,6 @@ packages: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} - set-blocking@2.0.0: - resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -9750,14 +9687,6 @@ packages: resolution: {integrity: sha512-UOPtVuYkzYGee0Bd2Szz8d2G3RfMfJ2t3qVdZUAozZyAk+a0Sxa+QKix0YCwjL/A1RR0ar44nCxaoN9FxdJGwA==} engines: {node: '>= 18'} - socket.io-client@4.8.1: - resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} - engines: {node: '>=10.0.0'} - - socket.io-parser@4.2.4: - resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} - engines: {node: '>=10.0.0'} - socks-proxy-agent@8.0.5: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} @@ -10869,9 +10798,6 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} - which-module@2.0.1: - resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} - which-pm-runs@1.1.0: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} @@ -10921,10 +10847,6 @@ packages: workerpool@9.2.0: resolution: {integrity: sha512-PKZqBOCo6CYkVOwAxWxQaSF2Fvb5Iv2fCeTP7buyWI2GiynWr46NcXSgK/idoV6e60dgCBfgYc+Un3HMvmqP8w==} - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -10940,18 +10862,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.17.1: - resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.18.2: resolution: {integrity: sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==} engines: {node: '>=10.0.0'} @@ -10995,17 +10905,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - xmlhttprequest-ssl@2.1.2: - resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} - engines: {node: '>=0.4.0'} - xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} - y18n@4.0.3: - resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} - y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -11025,10 +10928,6 @@ packages: engines: {node: '>= 14.6'} hasBin: true - yargs-parser@18.1.3: - resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} - engines: {node: '>=6'} - yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -11041,10 +10940,6 @@ packages: resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} engines: {node: '>=10'} - yargs@15.4.1: - resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} - engines: {node: '>=8'} - yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -14310,8 +14205,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@socket.io/component-emitter@3.1.2': {} - '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -14798,10 +14691,6 @@ snapshots: '@types/ps-tree@1.1.6': {} - '@types/qrcode@1.5.5': - dependencies: - '@types/node': 24.2.1 - '@types/react-dom@18.3.7(@types/react@18.3.23)': dependencies: '@types/react': 18.3.23 @@ -15655,8 +15544,6 @@ snapshots: camelcase-css@2.0.1: {} - camelcase@5.3.1: {} - camelcase@6.3.0: {} camelize@1.0.1: {} @@ -15818,12 +15705,6 @@ snapshots: client-only@0.0.1: {} - cliui@6.0.0: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -16274,10 +16155,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.7: - dependencies: - ms: 2.1.3 - debug@4.4.1(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -16288,8 +16165,6 @@ snapshots: dependencies: ms: 2.1.3 - decamelize@1.2.0: {} - decamelize@4.0.0: {} decimal.js-light@2.5.1: {} @@ -16387,8 +16262,6 @@ snapshots: diff@5.2.0: {} - dijkstrajs@1.0.3: {} - dingbat-to-unicode@1.0.1: {} dir-glob@3.0.1: @@ -16537,20 +16410,6 @@ snapshots: dependencies: once: 1.4.0 - engine.io-client@6.6.3: - dependencies: - '@socket.io/component-emitter': 3.1.2 - debug: 4.3.7 - engine.io-parser: 5.2.3 - ws: 8.17.1 - xmlhttprequest-ssl: 2.1.2 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - engine.io-parser@5.2.3: {} - enhanced-resolve@5.18.1: dependencies: graceful-fs: 4.2.11 @@ -19773,8 +19632,6 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 - pngjs@5.0.0: {} - points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -20045,12 +19902,6 @@ snapshots: - supports-color - utf-8-validate - qrcode@1.5.4: - dependencies: - dijkstrajs: 1.0.3 - pngjs: 5.0.0 - yargs: 15.4.1 - qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -20459,8 +20310,6 @@ snapshots: require-directory@2.1.1: {} - require-main-filename@2.0.0: {} - resize-observer-polyfill@1.5.1: {} resolve-from@4.0.0: {} @@ -20708,8 +20557,6 @@ snapshots: transitivePeerDependencies: - supports-color - set-blocking@2.0.0: {} - set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -20873,24 +20720,6 @@ snapshots: smol-toml@1.3.4: {} - socket.io-client@4.8.1: - dependencies: - '@socket.io/component-emitter': 3.1.2 - debug: 4.3.7 - engine.io-client: 6.6.3 - socket.io-parser: 4.2.4 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - socket.io-parser@4.2.4: - dependencies: - '@socket.io/component-emitter': 3.1.2 - debug: 4.3.7 - transitivePeerDependencies: - - supports-color - socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.3 @@ -22247,8 +22076,6 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 - which-module@2.0.1: {} - which-pm-runs@1.1.0: {} which-typed-array@1.1.19: @@ -22296,12 +22123,6 @@ snapshots: workerpool@9.2.0: {} - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -22322,8 +22143,6 @@ snapshots: wrappy@1.0.2: {} - ws@8.17.1: {} - ws@8.18.2: {} ws@8.18.3: {} @@ -22341,12 +22160,8 @@ snapshots: xmlchars@2.2.0: {} - xmlhttprequest-ssl@2.1.2: {} - xtend@4.0.2: {} - y18n@4.0.3: {} - y18n@5.0.8: {} yallist@3.1.1: {} @@ -22357,11 +22172,6 @@ snapshots: yaml@2.8.0: {} - yargs-parser@18.1.3: - dependencies: - camelcase: 5.3.1 - decamelize: 1.2.0 - yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} @@ -22373,20 +22183,6 @@ snapshots: flat: 5.0.2 is-plain-obj: 2.1.0 - yargs@15.4.1: - dependencies: - cliui: 6.0.0 - decamelize: 1.2.0 - find-up: 4.1.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - require-main-filename: 2.0.0 - set-blocking: 2.0.0 - string-width: 4.2.3 - which-module: 2.0.1 - y18n: 4.0.3 - yargs-parser: 18.1.3 - yargs@16.2.0: dependencies: cliui: 7.0.4 diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 446a91f77c..a13e78c4bd 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -51,8 +51,6 @@ vi.mock("fs", () => ({ existsSync: vi.fn().mockReturnValue(false), })) -const mockBridgeOrchestratorDisconnect = vi.fn().mockResolvedValue(undefined) - const mockCloudServiceInstance = { off: vi.fn(), on: vi.fn(), @@ -71,9 +69,6 @@ vi.mock("@roo-code/cloud", () => ({ return mockCloudServiceInstance }, }, - BridgeOrchestrator: { - disconnect: mockBridgeOrchestratorDisconnect, - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) @@ -181,20 +176,14 @@ vi.mock("../i18n", () => ({ t: vi.fn((key) => key), })) -// Mock ClineProvider - remoteControlEnabled must call BridgeOrchestrator.disconnect for the test +// Mock ClineProvider vi.mock("../core/webview/ClineProvider", async () => { - const { BridgeOrchestrator } = await import("@roo-code/cloud") const mockInstance = { resolveWebviewView: vi.fn(), postMessageToWebview: vi.fn(), postStateToWebview: vi.fn(), postStateToWebviewWithoutClineMessages: vi.fn(), getState: vi.fn().mockResolvedValue({}), - remoteControlEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { - if (!enabled) { - await BridgeOrchestrator.disconnect() - } - }), initializeCloudProfileSyncWhenReady: vi.fn().mockResolvedValue(undefined), providerSettingsManager: {}, contextProxy: { getGlobalState: vi.fn() }, @@ -230,7 +219,6 @@ describe("extension.ts", () => { beforeEach(() => { vi.clearAllMocks() - mockBridgeOrchestratorDisconnect.mockClear() mockContext = { extensionPath: "/test/path", @@ -274,73 +262,6 @@ describe("extension.ts", () => { expect(dotenvx.config).toHaveBeenCalledTimes(1) }) - test("authStateChangedHandler calls BridgeOrchestrator.disconnect when logged-out event fires", async () => { - const { CloudService, BridgeOrchestrator } = await import("@roo-code/cloud") - - // Capture the auth state changed handler. - vi.mocked(CloudService.createInstance).mockImplementation(async (_context, _logger, handlers) => { - if (handlers?.["auth-state-changed"]) { - authStateChangedHandler = handlers["auth-state-changed"] - } - - return { - off: vi.fn(), - on: vi.fn(), - telemetryClient: null, - hasActiveSession: vi.fn().mockReturnValue(false), - authService: null, - } as any - }) - - // Activate the extension. - const { activate } = await import("../extension") - await activate(mockContext) - - // Verify handler was registered. - expect(authStateChangedHandler).toBeDefined() - - // Trigger logout. - await authStateChangedHandler!({ - state: "logged-out" as AuthState, - previousState: "logged-in" as AuthState, - }) - - // Verify BridgeOrchestrator.disconnect was called - expect(mockBridgeOrchestratorDisconnect).toHaveBeenCalled() - }) - - test("authStateChangedHandler does not call BridgeOrchestrator.disconnect for other states", async () => { - const { CloudService } = await import("@roo-code/cloud") - - // Capture the auth state changed handler. - vi.mocked(CloudService.createInstance).mockImplementation(async (_context, _logger, handlers) => { - if (handlers?.["auth-state-changed"]) { - authStateChangedHandler = handlers["auth-state-changed"] - } - - return { - off: vi.fn(), - on: vi.fn(), - telemetryClient: null, - hasActiveSession: vi.fn().mockReturnValue(false), - authService: null, - } as any - }) - - // Activate the extension. - const { activate } = await import("../extension") - await activate(mockContext) - - // Trigger login. - await authStateChangedHandler!({ - state: "logged-in" as AuthState, - previousState: "logged-out" as AuthState, - }) - - // Verify BridgeOrchestrator.disconnect was NOT called. - expect(mockBridgeOrchestratorDisconnect).not.toHaveBeenCalled() - }) - describe("Roo model cache refresh on auth state change (ROO-202)", () => { beforeEach(() => { vi.resetModules() diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts index 7fac886030..b7e1b99d7c 100644 --- a/src/__tests__/single-open-invariant.spec.ts +++ b/src/__tests__/single-open-invariant.spec.ts @@ -13,7 +13,6 @@ vi.mock("../core/task/Task", () => { public parentTask?: any public apiConfiguration: any public rootTask?: any - public enableBridge?: boolean constructor(opts: any) { this.taskId = opts.historyItem?.id ?? `task-${Math.random().toString(36).slice(2, 8)}` this.parentTask = opts.parentTask @@ -49,7 +48,6 @@ describe("Single-open-task invariant", () => { enableCheckpoints: true, checkpointTimeout: 60, cloudUserInfo: null, - remoteControlEnabled: false, }), removeClineFromStack, addClineToStack, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index e934342546..9d19248057 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -55,7 +55,7 @@ import { countEnabledMcpTools, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" +import { CloudService } from "@roo-code/cloud" // api import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" @@ -143,7 +143,6 @@ export interface TaskOptions extends CreateTaskOptions { apiConfiguration: ProviderSettings enableCheckpoints?: boolean checkpointTimeout?: number - enableBridge?: boolean consecutiveMistakeLimit?: number task?: string images?: string[] @@ -332,9 +331,6 @@ export class Task extends EventEmitter implements TaskLike { checkpointService?: RepoPerTaskCheckpointService checkpointServiceInitializing = false - // Task Bridge - enableBridge: boolean - // Message Queue Service public readonly messageQueueService: MessageQueueService private messageQueueStateChangedHandler: (() => void) | undefined @@ -426,7 +422,6 @@ export class Task extends EventEmitter implements TaskLike { apiConfiguration, enableCheckpoints = true, checkpointTimeout = DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, - enableBridge = false, consecutiveMistakeLimit = DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, task, images, @@ -497,7 +492,6 @@ export class Task extends EventEmitter implements TaskLike { this.diffViewProvider = new DiffViewProvider(this.cwd, this) this.enableCheckpoints = enableCheckpoints this.checkpointTimeout = checkpointTimeout - this.enableBridge = enableBridge this.parentTask = parentTask this.taskNumber = taskNumber @@ -1938,16 +1932,6 @@ export class Task extends EventEmitter implements TaskLike { private async startTask(task?: string, images?: string[]): Promise { try { - if (this.enableBridge) { - try { - await BridgeOrchestrator.subscribeToTask(this) - } catch (error) { - console.error( - `[Task#startTask] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - // `conversationHistory` (for API) and `clineMessages` (for webview) // need to be in sync. // If the extension process were killed, then on restart the @@ -2012,16 +1996,6 @@ export class Task extends EventEmitter implements TaskLike { private async resumeTaskFromHistory() { try { - if (this.enableBridge) { - try { - await BridgeOrchestrator.subscribeToTask(this) - } catch (error) { - console.error( - `[Task#resumeTaskFromHistory] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - const modifiedClineMessages = await this.getSavedClineMessages() // Remove any resume messages that may have been added before. @@ -2352,16 +2326,6 @@ export class Task extends EventEmitter implements TaskLike { console.error("Error removing event listeners:", error) } - if (this.enableBridge) { - BridgeOrchestrator.getInstance() - ?.unsubscribeFromTask(this.taskId) - .catch((error) => - console.error( - `[Task#dispose] BridgeOrchestrator#unsubscribeFromTask() failed: ${error instanceof Error ? error.message : String(error)}`, - ), - ) - } - // Release any terminals associated with this task. try { // Release any terminals associated with this task. diff --git a/src/core/task/__tests__/grounding-sources.test.ts b/src/core/task/__tests__/grounding-sources.test.ts index f6874a581e..fd82ef8baf 100644 --- a/src/core/task/__tests__/grounding-sources.test.ts +++ b/src/core/task/__tests__/grounding-sources.test.ts @@ -75,14 +75,10 @@ vi.mock("@roo-code/telemetry", () => ({ }, })) -// Mock @roo-code/cloud to prevent socket.io-client initialization issues vi.mock("@roo-code/cloud", () => ({ CloudService: { isEnabled: () => false, }, - BridgeOrchestrator: { - subscribeToTask: vi.fn(), - }, })) // Mock delay to prevent actual delays diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts index 3bf2dec298..ea70a26bd3 100644 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ b/src/core/task/__tests__/reasoning-preservation.test.ts @@ -75,14 +75,10 @@ vi.mock("@roo-code/telemetry", () => ({ }, })) -// Mock @roo-code/cloud to prevent socket.io-client initialization issues vi.mock("@roo-code/cloud", () => ({ CloudService: { isEnabled: () => false, }, - BridgeOrchestrator: { - subscribeToTask: vi.fn(), - }, })) // Mock delay to prevent actual delays diff --git a/src/core/tools/__tests__/useMcpToolTool.spec.ts b/src/core/tools/__tests__/useMcpToolTool.spec.ts index 27a991456a..5ee826774f 100644 --- a/src/core/tools/__tests__/useMcpToolTool.spec.ts +++ b/src/core/tools/__tests__/useMcpToolTool.spec.ts @@ -676,14 +676,12 @@ describe("useMcpToolTool", () => { mockProviderRef.deref.mockReturnValue({ getMcpHub: () => ({ callTool: vi.fn().mockResolvedValue(mockToolResult), - getAllServers: vi - .fn() - .mockReturnValue([ - { - name: "figma-server", - tools: [{ name: "get_screenshot", description: "Get screenshot" }], - }, - ]), + getAllServers: vi.fn().mockReturnValue([ + { + name: "figma-server", + tools: [{ name: "get_screenshot", description: "Get screenshot" }], + }, + ]), }), postMessageToWebview: vi.fn(), }) @@ -790,14 +788,12 @@ describe("useMcpToolTool", () => { mockProviderRef.deref.mockReturnValue({ getMcpHub: () => ({ callTool: vi.fn().mockResolvedValue(mockToolResult), - getAllServers: vi - .fn() - .mockReturnValue([ - { - name: "figma-server", - tools: [{ name: "get_screenshot", description: "Get screenshot" }], - }, - ]), + getAllServers: vi.fn().mockReturnValue([ + { + name: "figma-server", + tools: [{ name: "get_screenshot", description: "Get screenshot" }], + }, + ]), }), postMessageToWebview: vi.fn(), }) @@ -852,14 +848,12 @@ describe("useMcpToolTool", () => { mockProviderRef.deref.mockReturnValue({ getMcpHub: () => ({ callTool: vi.fn().mockResolvedValue(mockToolResult), - getAllServers: vi - .fn() - .mockReturnValue([ - { - name: "figma-server", - tools: [{ name: "get_screenshots", description: "Get screenshots" }], - }, - ]), + getAllServers: vi.fn().mockReturnValue([ + { + name: "figma-server", + tools: [{ name: "get_screenshots", description: "Get screenshots" }], + }, + ]), }), postMessageToWebview: vi.fn(), }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f3fddf22ae..b9da4b4c60 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -49,7 +49,7 @@ import { } from "@roo-code/types" import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts" import { TelemetryService } from "@roo-code/telemetry" -import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud" +import { CloudService, getRooCodeApiUrl } from "@roo-code/cloud" import { Package } from "../../shared/package" import { findLast } from "../../shared/array" @@ -1078,7 +1078,6 @@ export class ClineProvider workspacePath: historyItem.workspace, onCreated: this.taskCreationCallback, startTask: options?.startTask ?? true, - enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, taskSyncEnabled), // Preserve the status from the history item to avoid overwriting it when the task saves messages initialStatus: historyItem.status, }) @@ -2194,11 +2193,9 @@ export class ClineProvider includeCurrentCost, maxGitStatusFiles, taskSyncEnabled, - remoteControlEnabled, imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, - featureRoomoteControlEnabled, lockApiConfigAcrossModes, } = await this.getState() @@ -2343,11 +2340,9 @@ export class ClineProvider includeCurrentCost: includeCurrentCost ?? true, maxGitStatusFiles: maxGitStatusFiles ?? 0, taskSyncEnabled, - remoteControlEnabled, imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, - featureRoomoteControlEnabled, openAiCodexIsAuthenticated: await (async () => { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") @@ -2564,32 +2559,9 @@ export class ClineProvider includeCurrentCost: stateValues.includeCurrentCost ?? true, maxGitStatusFiles: stateValues.maxGitStatusFiles ?? 0, taskSyncEnabled, - remoteControlEnabled: (() => { - try { - const cloudSettings = CloudService.instance.getUserSettings() - return cloudSettings?.settings?.extensionBridgeEnabled ?? false - } catch (error) { - console.error( - `[getState] failed to get remote control setting from cloud: ${error instanceof Error ? error.message : String(error)}`, - ) - return false - } - })(), imageGenerationProvider: stateValues.imageGenerationProvider, openRouterImageApiKey: stateValues.openRouterImageApiKey, openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, - featureRoomoteControlEnabled: (() => { - try { - const userSettings = CloudService.instance.getUserSettings() - const hasOrganization = cloudUserInfo?.organizationId != null - return hasOrganization || (userSettings?.features?.roomoteControlEnabled ?? false) - } catch (error) { - console.error( - `[getState] failed to get featureRoomoteControlEnabled: ${error instanceof Error ? error.message : String(error)}`, - ) - return false - } - })(), } } @@ -2786,64 +2758,6 @@ export class ClineProvider return true } - public async remoteControlEnabled(enabled: boolean) { - if (!enabled) { - await BridgeOrchestrator.disconnect() - return - } - - const userInfo = CloudService.instance.getUserInfo() - - if (!userInfo) { - this.log("[ClineProvider#remoteControlEnabled] Failed to get user info, disconnecting") - await BridgeOrchestrator.disconnect() - return - } - - const config = await CloudService.instance.cloudAPI?.bridgeConfig().catch(() => undefined) - - if (!config) { - this.log("[ClineProvider#remoteControlEnabled] Failed to get bridge config") - return - } - - await BridgeOrchestrator.connectOrDisconnect(userInfo, enabled, { - ...config, - provider: this, - sessionId: vscode.env.sessionId, - isCloudAgent: CloudService.instance.isCloudAgent, - }) - - const bridge = BridgeOrchestrator.getInstance() - - if (bridge) { - const currentTask = this.getCurrentTask() - - if (currentTask && !currentTask.enableBridge) { - try { - currentTask.enableBridge = true - await BridgeOrchestrator.subscribeToTask(currentTask) - } catch (error) { - const message = `[ClineProvider#remoteControlEnabled] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}` - this.log(message) - console.error(message) - } - } - } else { - for (const task of this.clineStack) { - if (task.enableBridge) { - try { - await BridgeOrchestrator.getInstance()?.unsubscribeFromTask(task.taskId) - } catch (error) { - const message = `[ClineProvider#remoteControlEnabled] BridgeOrchestrator#unsubscribeFromTask() failed: ${error instanceof Error ? error.message : String(error)}` - this.log(message) - console.error(message) - } - } - } - } - } - /** * Gets the CodeIndexManager for the current active workspace * @returns CodeIndexManager instance for the current workspace or the default one @@ -2999,15 +2913,8 @@ export class ClineProvider } } - const { - apiConfiguration, - organizationAllowList, - enableCheckpoints, - checkpointTimeout, - experiments, - cloudUserInfo, - remoteControlEnabled, - } = await this.getState() + const { apiConfiguration, organizationAllowList, enableCheckpoints, checkpointTimeout, experiments } = + await this.getState() // Single-open-task invariant: always enforce for user-initiated top-level tasks if (!parentTask) { @@ -3035,7 +2942,6 @@ export class ClineProvider parentTask, taskNumber: this.clineStack.length + 1, onCreated: this.taskCreationCallback, - enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, remoteControlEnabled), initialTodos: options.initialTodos, ...options, }) diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 9e57ae94b8..87c6ea968c 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -129,9 +129,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) diff --git a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts index 8533865031..4bb01347a3 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -78,9 +78,6 @@ vi.mock("@roo-code/cloud", () => ({ isAuthenticated: vi.fn().mockReturnValue(false), }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://api.roo-code.com"), })) diff --git a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts index 1a4993b186..2cf9d4cae8 100644 --- a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts @@ -110,9 +110,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 9400ee34aa..1e26cd45be 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -301,9 +301,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) @@ -554,9 +551,7 @@ describe("ClineProvider", () => { diagnosticsEnabled: true, openRouterImageApiKey: undefined, openRouterImageGenerationSelectedModel: undefined, - remoteControlEnabled: false, taskSyncEnabled: false, - featureRoomoteControlEnabled: false, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, } diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index f24cee0786..abef31af89 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -112,9 +112,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index 0bea9b1c36..da2734de87 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -114,9 +114,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index b1f29008c4..d1bbd9bca6 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -229,9 +229,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4f8d431724..6d17517164 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1352,25 +1352,10 @@ export const webviewMessageHandler = async ( } break } - case "remoteControlEnabled": - try { - await CloudService.instance.updateUserSettings({ extensionBridgeEnabled: message.bool ?? false }) - } catch (error) { - provider.log( - `CloudService#updateUserSettings failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - break - case "taskSyncEnabled": const enabled = message.bool ?? false const updatedSettings: Partial = { taskSyncEnabled: enabled } - // If disabling task sync, also disable remote control. - if (!enabled) { - updatedSettings.extensionBridgeEnabled = false - } - try { await CloudService.instance.updateUserSettings(updatedSettings) } catch (error) { diff --git a/src/extension.ts b/src/extension.ts index 75fff6328f..19c0d70585 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,7 +18,7 @@ if (fs.existsSync(envPath)) { } import type { CloudUserInfo, AuthState } from "@roo-code/types" -import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" +import { CloudService } from "@roo-code/cloud" import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry" import { customToolRegistry } from "@roo-code/core" @@ -200,16 +200,6 @@ export async function activate(context: vscode.ExtensionContext) { authStateChangedHandler = async (data: { state: AuthState; previousState: AuthState }) => { postStateListener() - if (data.state === "logged-out") { - try { - await provider.remoteControlEnabled(false) - } catch (error) { - cloudLogger( - `[authStateChangedHandler] remoteControlEnabled(false) failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - // Handle Roo models cache based on auth state (ROO-202) const handleRooModelsCache = async () => { try { @@ -265,36 +255,11 @@ export async function activate(context: vscode.ExtensionContext) { } settingsUpdatedHandler = async () => { - const userInfo = CloudService.instance.getUserInfo() - - if (userInfo && CloudService.instance.cloudAPI) { - try { - provider.remoteControlEnabled(CloudService.instance.isTaskSyncEnabled()) - } catch (error) { - cloudLogger( - `[settingsUpdatedHandler] remoteControlEnabled failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - postStateListener() } userInfoHandler = async ({ userInfo }: { userInfo: CloudUserInfo }) => { postStateListener() - - if (!CloudService.instance.cloudAPI) { - cloudLogger("[userInfoHandler] CloudAPI is not initialized") - return - } - - try { - provider.remoteControlEnabled(CloudService.instance.isTaskSyncEnabled()) - } catch (error) { - cloudLogger( - `[userInfoHandler] remoteControlEnabled failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } } cloudService = await CloudService.createInstance(context, cloudLogger, { @@ -481,12 +446,6 @@ export async function deactivate() { } } - const bridge = BridgeOrchestrator.getInstance() - - if (bridge) { - await bridge.disconnect() - } - await McpServerManager.cleanup(extensionContext) TelemetryService.instance.shutdown() TerminalRegistry.cleanup() diff --git a/src/package.json b/src/package.json index a41b6682e5..3b7b28ebf0 100644 --- a/src/package.json +++ b/src/package.json @@ -521,7 +521,6 @@ "serialize-error": "^12.0.0", "shell-quote": "^1.8.2", "simple-git": "^3.27.0", - "socket.io-client": "^4.8.1", "sound-play": "^1.1.0", "stream-json": "^1.8.0", "string-similarity": "^4.0.4", diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index f2d9d12dd0..92bf1f8e7d 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -942,9 +942,7 @@ describe("worktree path comparison", () => { // Second init with stubbed worktree returning a trailing newline const service2 = new RepoPerTaskCheckpointService("trim-test-2", shadowDir, workspaceDir, () => {}) - vitest - .spyOn(service2 as any, "getShadowGitConfigWorktree") - .mockResolvedValue(workspaceDir + "\n") + vitest.spyOn(service2 as any, "getShadowGitConfigWorktree").mockResolvedValue(workspaceDir + "\n") await service2.initShadowGit() } finally { @@ -983,9 +981,7 @@ describe("worktree path comparison", () => { // Second init should throw because core.worktree is missing const service2 = new RepoPerTaskCheckpointService("missing-test-2", shadowDir, workspaceDir, () => {}) - await expect(service2.initShadowGit()).rejects.toThrowError( - /core\.worktree to be set/, - ) + await expect(service2.initShadowGit()).rejects.toThrowError(/core\.worktree to be set/) } finally { vitest.restoreAllMocks() await fs.rm(shadowDir, { recursive: true, force: true }) diff --git a/webview-ui/package.json b/webview-ui/package.json index d72c6a1a2c..7722f4119f 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -33,7 +33,6 @@ "@roo-code/types": "workspace:^", "@tailwindcss/vite": "^4.0.0", "@tanstack/react-query": "^5.68.0", - "@types/qrcode": "^1.5.5", "@vscode/codicons": "^0.0.36", "@vscode/webview-ui-toolkit": "^1.4.0", "axios": "^1.12.0", @@ -55,7 +54,6 @@ "mermaid": "^11.4.1", "posthog-js": "^1.227.2", "pretty-bytes": "^7.0.0", - "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-compiler-runtime": "^1.0.0", diff --git a/webview-ui/src/components/chat/CloudTaskButton.tsx b/webview-ui/src/components/chat/CloudTaskButton.tsx deleted file mode 100644 index 672bf020bb..0000000000 --- a/webview-ui/src/components/chat/CloudTaskButton.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useState, useEffect, useCallback } from "react" -import { useTranslation } from "react-i18next" -import { Copy, Check, CloudUploadIcon } from "lucide-react" -import QRCode from "qrcode" - -import type { HistoryItem } from "@roo-code/types" - -import { useExtensionState } from "@/context/ExtensionStateContext" -import { useCopyToClipboard } from "@/utils/clipboard" -import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input } from "@/components/ui" -import { vscode } from "@/utils/vscode" -import { LucideIconButton } from "./LucideIconButton" - -interface CloudTaskButtonProps { - item?: HistoryItem - disabled?: boolean -} - -export const CloudTaskButton = ({ item, disabled = false }: CloudTaskButtonProps) => { - const [dialogOpen, setDialogOpen] = useState(false) - const { t } = useTranslation() - const { cloudUserInfo, cloudApiUrl } = useExtensionState() - const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard() - const [canvasElement, setCanvasElement] = useState(null) - - // Generate the cloud URL for the task - const cloudTaskUrl = item?.id ? `${cloudApiUrl}/task/${item.id}` : "" - - const generateQRCode = useCallback( - (canvas: HTMLCanvasElement, context: string) => { - if (!cloudTaskUrl) { - // This will run again later when ready - return - } - - QRCode.toCanvas( - canvas, - cloudTaskUrl, - { - width: 140, - margin: 0, - color: { - dark: "#000000", - light: "#FFFFFF", - }, - }, - (error: Error | null | undefined) => { - if (error) { - console.error(`Error generating QR code (${context}):`, error) - } - }, - ) - }, - [cloudTaskUrl], - ) - - // Callback ref to capture canvas element when it mounts - const canvasRef = useCallback( - (node: HTMLCanvasElement | null) => { - if (node) { - setCanvasElement(node) - - // Try to generate QR code immediately when canvas is available - if (dialogOpen) { - generateQRCode(node, "on mount") - } - } else { - setCanvasElement(null) - } - }, - [dialogOpen, generateQRCode], - ) - - // Also generate QR code when dialog opens after canvas is available - useEffect(() => { - if (dialogOpen && canvasElement) { - generateQRCode(canvasElement, "in useEffect") - } - }, [dialogOpen, canvasElement, generateQRCode]) - - if (!cloudUserInfo?.extensionBridgeEnabled || !item?.id) { - return null - } - - return ( - <> - setDialogOpen(true)}> - - - - - {t("chat:task.openInCloud")} - - -
-

{t("chat:task.openInCloudIntro")}

-
-
vscode.postMessage({ type: "openExternal", url: cloudTaskUrl })} - title={t("chat:task.openInCloud")}> - -
-
- -
- - -
-
-
-
- - ) -} diff --git a/webview-ui/src/components/chat/TaskActions.tsx b/webview-ui/src/components/chat/TaskActions.tsx index 74575ddc28..c7401425f6 100644 --- a/webview-ui/src/components/chat/TaskActions.tsx +++ b/webview-ui/src/components/chat/TaskActions.tsx @@ -9,7 +9,6 @@ import { useExtensionState } from "@/context/ExtensionStateContext" import { DeleteTaskDialog } from "../history/DeleteTaskDialog" import { ShareButton } from "./ShareButton" -import { CloudTaskButton } from "./CloudTaskButton" import { CopyIcon, DownloadIcon, Trash2Icon, FileJsonIcon, MessageSquareCodeIcon } from "lucide-react" import { LucideIconButton } from "./LucideIconButton" @@ -64,7 +63,6 @@ export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { )} - {debug && item?.id && ( <> ({ - default: { - toCanvas: vi.fn((_canvas, _text, _options, callback) => { - // Simulate successful QR code generation - if (callback) { - callback(null) - } - }), - }, -})) - -// Mock react-i18next -vi.mock("react-i18next") - -// Mock the cloud config -vi.mock("@roo-code/cloud/src/config", () => ({ - getRooCodeApiUrl: vi.fn(() => "https://app.roocode.com"), -})) - -// Mock the extension state context -vi.mock("@/context/ExtensionStateContext", () => ({ - ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => children, - useExtensionState: vi.fn(), -})) - -// Mock clipboard utility -vi.mock("@/utils/clipboard", () => ({ - useCopyToClipboard: () => ({ - copyWithFeedback: vi.fn(), - showCopyFeedback: false, - }), -})) - -const mockUseTranslation = vi.mocked(useTranslation) -const { useExtensionState } = await import("@/context/ExtensionStateContext") -const mockUseExtensionState = vi.mocked(useExtensionState) - -describe("CloudTaskButton", () => { - const mockT = vi.fn((key: string) => key) - const mockItem = { - id: "test-task-id", - number: 1, - ts: Date.now(), - task: "Test Task", - tokensIn: 100, - tokensOut: 50, - totalCost: 0.01, - } - - beforeEach(() => { - vi.clearAllMocks() - - mockUseTranslation.mockReturnValue({ - t: mockT, - i18n: {} as any, - ready: true, - } as any) - - // Default extension state with bridge enabled - mockUseExtensionState.mockReturnValue({ - cloudUserInfo: { - id: "test-user", - email: "test@example.com", - extensionBridgeEnabled: true, - }, - cloudApiUrl: "https://app.roocode.com", - } as any) - }) - - test("renders cloud task button when extension bridge is enabled", () => { - render() - - const button = screen.getByTestId("cloud-task-button") - expect(button).toBeInTheDocument() - expect(button).toHaveAttribute("aria-label", "chat:task.openInCloud") - }) - - test("does not render when extension bridge is disabled", () => { - mockUseExtensionState.mockReturnValue({ - cloudUserInfo: { - id: "test-user", - email: "test@example.com", - extensionBridgeEnabled: false, - }, - cloudApiUrl: "https://app.roocode.com", - } as any) - - render() - - expect(screen.queryByTestId("cloud-task-button")).not.toBeInTheDocument() - }) - - test("does not render when cloudUserInfo is null", () => { - mockUseExtensionState.mockReturnValue({ - cloudUserInfo: null, - cloudApiUrl: "https://app.roocode.com", - } as any) - - render() - - expect(screen.queryByTestId("cloud-task-button")).not.toBeInTheDocument() - }) - - test("does not render when item has no id", () => { - const itemWithoutId = { ...mockItem, id: undefined } - render() - - expect(screen.queryByTestId("cloud-task-button")).not.toBeInTheDocument() - }) - - test("opens dialog when button is clicked", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByText("chat:task.openInCloud")).toBeInTheDocument() - }) - }) - - test("displays correct cloud URL in dialog", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - const input = screen.getByDisplayValue("https://app.roocode.com/task/test-task-id") - expect(input).toBeInTheDocument() - expect(input).toBeDisabled() - }) - }) - - test("displays intro text in dialog", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByText("chat:task.openInCloudIntro")).toBeInTheDocument() - }) - }) - - // Note: QR code generation is tested implicitly through the canvas rendering test below - - test("QR code canvas is rendered", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - // Canvas element doesn't have a specific aria label, find it directly - const canvas = document.querySelector("canvas") - expect(canvas).toBeInTheDocument() - expect(canvas?.tagName).toBe("CANVAS") - }) - }) - - // Note: Error handling for QR code generation is non-critical as per PR feedback - - test("button is disabled when disabled prop is true", () => { - render() - - const button = screen.getByTestId("cloud-task-button") - expect(button).toBeDisabled() - }) - - test("button is enabled when disabled prop is false", () => { - render() - - const button = screen.getByTestId("cloud-task-button") - expect(button).not.toBeDisabled() - }) - - test("dialog can be closed", async () => { - render() - - // Open dialog - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByText("chat:task.openInCloud")).toBeInTheDocument() - }) - - // Close dialog by clicking the X button (assuming it exists in Dialog component) - const closeButton = screen.getByRole("button", { name: /close/i }) - fireEvent.click(closeButton) - - await waitFor(() => { - expect(screen.queryByText("chat:task.openInCloud")).not.toBeInTheDocument() - }) - }) - - test("copy button exists in dialog", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - // Look for the copy button (it should have a Copy icon) - const copyButtons = screen.getAllByRole("button") - const copyButton = copyButtons.find( - (btn) => btn.querySelector('[class*="lucide"]') || btn.textContent?.includes("Copy"), - ) - expect(copyButton).toBeInTheDocument() - }) - }) - - test("uses correct URL from getRooCodeApiUrl", async () => { - // Mock getRooCodeApiUrl to return a custom URL - vi.doMock("@roo-code/cloud/src/config", () => ({ - getRooCodeApiUrl: vi.fn(() => "https://custom.roocode.com"), - })) - - // Clear module cache and re-import to get the mocked version - vi.resetModules() - - // Since we can't easily test the dynamic import, let's skip this specific test - // The functionality is already covered by the main component using getRooCodeApiUrl - expect(true).toBe(true) - }) -}) diff --git a/webview-ui/src/components/cloud/CloudView.tsx b/webview-ui/src/components/cloud/CloudView.tsx index e8ed9e163c..997997ccd0 100644 --- a/webview-ui/src/components/cloud/CloudView.tsx +++ b/webview-ui/src/components/cloud/CloudView.tsx @@ -9,7 +9,7 @@ import { vscode } from "@src/utils/vscode" import { telemetryClient } from "@src/utils/TelemetryClient" import { ToggleSwitch } from "@/components/ui/toggle-switch" import { renderCloudBenefitsContent } from "./CloudUpsellDialog" -import { ArrowRight, CircleAlert, Info, Lock, TriangleAlert } from "lucide-react" +import { ArrowRight, Info, Lock, TriangleAlert } from "lucide-react" import { cn } from "@/lib/utils" import { Tab, TabContent } from "../common/Tab" import { Button } from "@/components/ui/button" @@ -28,13 +28,7 @@ type CloudViewProps = { export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, organizations = [] }: CloudViewProps) => { const { t } = useAppTranslation() - const { - remoteControlEnabled, - setRemoteControlEnabled, - taskSyncEnabled, - setTaskSyncEnabled, - featureRoomoteControlEnabled, - } = useExtensionState() + const { taskSyncEnabled, setTaskSyncEnabled } = useExtensionState() const wasAuthenticatedRef = useRef(false) const timeoutRef = useRef(null) const manualUrlInputRef = useRef(null) @@ -144,12 +138,6 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, organization } } - const handleRemoteControlToggle = () => { - const newValue = !remoteControlEnabled - setRemoteControlEnabled(newValue) - vscode.postMessage({ type: "remoteControlEnabled", bool: newValue }) - } - const handleTaskSyncToggle = () => { const newValue = !taskSyncEnabled setTaskSyncEnabled(newValue) @@ -219,34 +207,6 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, organization
{t("cloud:taskSyncDescription")}
- - {/* Remote Control Toggle - Only shown when both extensionBridgeEnabled and featureRoomoteControlEnabled are true */} - {userInfo?.extensionBridgeEnabled && featureRoomoteControlEnabled && ( - <> -
- - - {t("cloud:remoteControl")} - -
-
- {t("cloud:remoteControlDescription")} - {!taskSyncEnabled && ( -
- - {t("cloud:remoteControlRequiresTaskSync")} -
- )} -
- - )}
diff --git a/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx b/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx index 87f5da9c65..120579e732 100644 --- a/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx +++ b/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx @@ -23,10 +23,6 @@ vi.mock("@src/i18n/TranslationContext", () => ({ "cloud:taskSync": "Task sync", "cloud:taskSyncDescription": "Sync your tasks for viewing and sharing on Roo Code Cloud", "cloud:taskSyncManagedByOrganization": "Task sync is managed by your organization", - "cloud:remoteControl": "Roomote Control", - "cloud:remoteControlDescription": - "Enable following and interacting with tasks in this workspace with Roo Code Cloud", - "cloud:remoteControlRequiresTaskSync": "Task sync must be enabled to use Roomote Control", "cloud:usageMetricsAlwaysReported": "Model usage info is always reported when logged in", "cloud:profilePicture": "Profile picture", "cloud:cloudUrlPillLabel": "Roo Code Cloud URL: ", @@ -52,12 +48,8 @@ vi.mock("@src/utils/TelemetryClient", () => ({ // Mock the extension state context const mockExtensionState = { - remoteControlEnabled: false, - setRemoteControlEnabled: vi.fn(), taskSyncEnabled: true, setTaskSyncEnabled: vi.fn(), - featureRoomoteControlEnabled: true, // Default to true for tests - setFeatureRoomoteControlEnabled: vi.fn(), } vi.mock("@src/context/ExtensionStateContext", () => ({ @@ -116,82 +108,6 @@ describe("CloudView", () => { expect(screen.getByText("test@example.com")).toBeInTheDocument() }) - it("should display remote control toggle when user has extension bridge enabled and roomote control enabled", () => { - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - extensionBridgeEnabled: true, - } - - render() - - // Check that the remote control toggle is displayed - expect(screen.getByTestId("remote-control-toggle")).toBeInTheDocument() - expect(screen.getByText("Roomote Control")).toBeInTheDocument() - expect( - screen.getByText("Enable following and interacting with tasks in this workspace with Roo Code Cloud"), - ).toBeInTheDocument() - }) - - it("should not display remote control toggle when user does not have extension bridge enabled", () => { - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - extensionBridgeEnabled: false, - } - - render() - - // Check that the remote control toggle is NOT displayed - expect(screen.queryByTestId("remote-control-toggle")).not.toBeInTheDocument() - expect(screen.queryByText("Roomote Control")).not.toBeInTheDocument() - }) - - it("should not display remote control toggle when roomote control is disabled", () => { - // Temporarily override the mock for this specific test - const originalFeatureRoomoteControlEnabled = mockExtensionState.featureRoomoteControlEnabled - mockExtensionState.featureRoomoteControlEnabled = false - - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - extensionBridgeEnabled: true, // Bridge enabled but roomote control disabled - } - - render() - - // Check that the remote control toggle is NOT displayed - expect(screen.queryByTestId("remote-control-toggle")).not.toBeInTheDocument() - expect(screen.queryByText("Roomote Control")).not.toBeInTheDocument() - - // Restore the original value - mockExtensionState.featureRoomoteControlEnabled = originalFeatureRoomoteControlEnabled - }) - - it("should display remote control toggle for organization users (simulating backend logic)", () => { - // This test simulates what the ClineProvider would do: - // Organization users are treated as having featureRoomoteControlEnabled true - const originalFeatureRoomoteControlEnabled = mockExtensionState.featureRoomoteControlEnabled - mockExtensionState.featureRoomoteControlEnabled = true // Simulating ClineProvider logic for org users - - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - organizationId: "org-123", // User is in an organization - extensionBridgeEnabled: true, - } - - render() - - // Check that the remote control toggle IS displayed for organization users - // (The ClineProvider would set featureRoomoteControlEnabled to true for org users) - expect(screen.getByTestId("remote-control-toggle")).toBeInTheDocument() - expect(screen.getByText("Roomote Control")).toBeInTheDocument() - - // Restore the original value - mockExtensionState.featureRoomoteControlEnabled = originalFeatureRoomoteControlEnabled - }) - it("should not display cloud URL pill when pointing to production", () => { const mockUserInfo = { name: "Test User", diff --git a/webview-ui/src/components/marketplace/MarketplaceView.tsx b/webview-ui/src/components/marketplace/MarketplaceView.tsx index 94c50b80ab..0ab5430eec 100644 --- a/webview-ui/src/components/marketplace/MarketplaceView.tsx +++ b/webview-ui/src/components/marketplace/MarketplaceView.tsx @@ -108,7 +108,7 @@ export function MarketplaceView({ stateManager, onDone, targetTab }: Marketplace onClick={() => onDone?.()} aria-label={t("settings:back")}> - {t("settings:back")} + {t("settings:back")}

{t("marketplace:title")}

diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ac0b25d8c3..ce7a607d9a 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -94,12 +94,8 @@ export interface ExtensionStateContextType extends ExtensionState { setTerminalOutputPreviewSize: (value: "small" | "medium" | "large") => void mcpEnabled: boolean setMcpEnabled: (value: boolean) => void - remoteControlEnabled: boolean - setRemoteControlEnabled: (value: boolean) => void taskSyncEnabled: boolean setTaskSyncEnabled: (value: boolean) => void - featureRoomoteControlEnabled: boolean - setFeatureRoomoteControlEnabled: (value: boolean) => void setCurrentApiConfigName: (value: string) => void setListApiConfigMeta: (value: ProviderSettingsEntry[]) => void mode: Mode @@ -212,9 +208,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode writeDelayMs: 1000, terminalShellIntegrationTimeout: 4000, mcpEnabled: true, - remoteControlEnabled: false, taskSyncEnabled: false, - featureRoomoteControlEnabled: false, currentApiConfigName: "default", listApiConfigMeta: [], mode: defaultModeSlug, @@ -514,9 +508,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode profileThresholds: state.profileThresholds ?? {}, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, - remoteControlEnabled: state.remoteControlEnabled ?? false, taskSyncEnabled: state.taskSyncEnabled, - featureRoomoteControlEnabled: state.featureRoomoteControlEnabled ?? false, setExperimentEnabled: (id, enabled) => setState((prevState) => ({ ...prevState, experiments: { ...prevState.experiments, [id]: enabled } })), setApiConfiguration, @@ -554,10 +546,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setState((prevState) => ({ ...prevState, terminalShellIntegrationDisabled: value })), setTerminalZdotdir: (value) => setState((prevState) => ({ ...prevState, terminalZdotdir: value })), setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), - setRemoteControlEnabled: (value) => setState((prevState) => ({ ...prevState, remoteControlEnabled: value })), setTaskSyncEnabled: (value) => setState((prevState) => ({ ...prevState, taskSyncEnabled: value }) as any), - setFeatureRoomoteControlEnabled: (value) => - setState((prevState) => ({ ...prevState, featureRoomoteControlEnabled: value })), setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), setListApiConfigMeta, setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })), diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index f55d07e070..2a5e74c40d 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -214,9 +214,7 @@ describe("mergeExtensionState", () => { hasOpenedModeSelector: false, // Add the new required property maxImageFileSize: 5, maxTotalImageSize: 20, - remoteControlEnabled: false, taskSyncEnabled: false, - featureRoomoteControlEnabled: false, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Add the checkpoint timeout property maxReadFileLine: -1, } @@ -285,9 +283,7 @@ describe("mergeExtensionState", () => { hasOpenedModeSelector: false, maxImageFileSize: 5, maxTotalImageSize: 20, - remoteControlEnabled: false, taskSyncEnabled: false, - featureRoomoteControlEnabled: false, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, maxReadFileLine: -1, } diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index c5e42cf3d8..f029391a37 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Compartició deshabilitada per l'organització", "shareSuccessOrganization": "Enllaç d'organització copiat al porta-retalls", "shareSuccessPublic": "Enllaç públic copiat al porta-retalls", - "openInCloud": "Obrir tasca a Roo Code Cloud", - "openInCloudIntro": "Continua monitoritzant o interactuant amb Roo des de qualsevol lloc. Escaneja, fes clic o copia per obrir.", "openApiHistory": "Obrir historial d'API", "openUiHistory": "Obrir historial d'UI", "backToParentTask": "Tasca principal" diff --git a/webview-ui/src/i18n/locales/ca/cloud.json b/webview-ui/src/i18n/locales/ca/cloud.json index 077cdf42fa..7c0eeff82b 100644 --- a/webview-ui/src/i18n/locales/ca/cloud.json +++ b/webview-ui/src/i18n/locales/ca/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Visita Roo Code Cloud", "taskSync": "Sincronització de tasques", "taskSyncDescription": "Sincronitza les teves tasques per veure-les i compartir-les a Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Permet controlar tasques des de Roo Code Cloud", - "remoteControlRequiresTaskSync": "La sincronització de tasques ha d'estar habilitada per utilitzar Roomote Control", "taskSyncManagedByOrganization": "La sincronització de tasques la gestiona la teva organització", "usageMetricsAlwaysReported": "La informació d'ús del model sempre es reporta quan s'ha iniciat sessió", "cloudUrlPillLabel": "URL de Roo Code Cloud", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 8d5de267f5..2301f966b1 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Freigabe von der Organisation deaktiviert", "shareSuccessOrganization": "Organisationslink in die Zwischenablage kopiert", "shareSuccessPublic": "Öffentlicher Link in die Zwischenablage kopiert", - "openInCloud": "Aufgabe in Roo Code Cloud öffnen", - "openInCloudIntro": "Überwache oder interagiere mit Roo von überall aus. Scanne, klicke oder kopiere zum Öffnen.", "openApiHistory": "API-Verlauf öffnen", "openUiHistory": "UI-Verlauf öffnen", "backToParentTask": "Übergeordnete Aufgabe" diff --git a/webview-ui/src/i18n/locales/de/cloud.json b/webview-ui/src/i18n/locales/de/cloud.json index 70e480cbc4..7ac165b497 100644 --- a/webview-ui/src/i18n/locales/de/cloud.json +++ b/webview-ui/src/i18n/locales/de/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Roo Code Cloud besuchen", "taskSync": "Aufgabensynchronisierung", "taskSyncDescription": "Synchronisiere deine Aufgaben zum Anzeigen und Teilen in Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Ermöglicht die Steuerung von Aufgaben über Roo Code Cloud", - "remoteControlRequiresTaskSync": "Die Aufgabensynchronisierung muss aktiviert sein, um Roomote Control zu verwenden", "taskSyncManagedByOrganization": "Die Aufgabensynchronisierung wird von deiner Organisation verwaltet", "usageMetricsAlwaysReported": "Modellnutzungsinformationen werden bei Anmeldung immer gemeldet", "authWaiting": "Warte auf Abschluss der Authentifizierung...", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index fa23c7e466..4899859e3a 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Sharing disabled by organization", "shareSuccessOrganization": "Organization link copied to clipboard", "shareSuccessPublic": "Public link copied to clipboard", - "openInCloud": "Open task in Roo Code Cloud", - "openInCloudIntro": "Keep monitoring or interacting with Roo from anywhere. Scan, click or copy to open.", "openApiHistory": "Open API History", "openUiHistory": "Open UI History", "backToParentTask": "Parent task" diff --git a/webview-ui/src/i18n/locales/en/cloud.json b/webview-ui/src/i18n/locales/en/cloud.json index 4ba0d5f62d..5f52afdc6a 100644 --- a/webview-ui/src/i18n/locales/en/cloud.json +++ b/webview-ui/src/i18n/locales/en/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Visit Roo Code Cloud", "taskSync": "Task sync", "taskSyncDescription": "Sync your tasks for viewing and sharing on Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Allow controlling tasks from Roo Code Cloud", - "remoteControlRequiresTaskSync": "Task sync must be enabled to use Roomote Control", "taskSyncManagedByOrganization": "Task sync is managed by your organization", "usageMetricsAlwaysReported": "Model usage info is always reported when logged in", "cloudUrlPillLabel": "Roo Code Cloud URL", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 13b61d9585..1b2bd24a55 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Compartir deshabilitado por la organización", "shareSuccessOrganization": "Enlace de organización copiado al portapapeles", "shareSuccessPublic": "Enlace público copiado al portapapeles", - "openInCloud": "Abrir tarea en Roo Code Cloud", - "openInCloudIntro": "Continúa monitoreando o interactuando con Roo desde cualquier lugar. Escanea, haz clic o copia para abrir.", "openApiHistory": "Abrir historial de API", "openUiHistory": "Abrir historial de UI", "backToParentTask": "Tarea principal" diff --git a/webview-ui/src/i18n/locales/es/cloud.json b/webview-ui/src/i18n/locales/es/cloud.json index 1281cccb41..eb0cddc8b9 100644 --- a/webview-ui/src/i18n/locales/es/cloud.json +++ b/webview-ui/src/i18n/locales/es/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Visitar Roo Code Cloud", "taskSync": "Sincronización de tareas", "taskSyncDescription": "Sincroniza tus tareas para verlas y compartirlas en Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Permite controlar tareas desde Roo Code Cloud", - "remoteControlRequiresTaskSync": "La sincronización de tareas debe estar habilitada para usar Roomote Control", "taskSyncManagedByOrganization": "La sincronización de tareas es gestionada por tu organización", "usageMetricsAlwaysReported": "La información de uso del modelo siempre se reporta cuando se ha iniciado sesión", "authWaiting": "Esperando que se complete la autenticación...", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index bf9c524cc2..adb77f40b9 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Partage désactivé par l'organisation", "shareSuccessOrganization": "Lien d'organisation copié dans le presse-papiers", "shareSuccessPublic": "Lien public copié dans le presse-papiers", - "openInCloud": "Ouvrir la tâche dans Roo Code Cloud", - "openInCloudIntro": "Continue à surveiller ou interagir avec Roo depuis n'importe où. Scanne, clique ou copie pour ouvrir.", "openApiHistory": "Ouvrir l'historique de l'API", "openUiHistory": "Ouvrir l'historique de l'UI", "backToParentTask": "Tâche parente" diff --git a/webview-ui/src/i18n/locales/fr/cloud.json b/webview-ui/src/i18n/locales/fr/cloud.json index 8e031aaad8..ac8a8d1537 100644 --- a/webview-ui/src/i18n/locales/fr/cloud.json +++ b/webview-ui/src/i18n/locales/fr/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Visiter Roo Code Cloud", "taskSync": "Synchronisation des tâches", "taskSyncDescription": "Synchronisez vos tâches pour les visualiser et les partager sur Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Permet de contrôler les tâches depuis Roo Code Cloud", - "remoteControlRequiresTaskSync": "La synchronisation des tâches doit être activée pour utiliser Roomote Control", "taskSyncManagedByOrganization": "La synchronisation des tâches est gérée par votre organisation", "usageMetricsAlwaysReported": "Les informations d'utilisation du modèle sont toujours signalées lors de la connexion", "authWaiting": "En attente de la fin de l'authentification...", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 71c70c4201..b58481f910 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "संगठन द्वारा साझाकरण अक्षम किया गया", "shareSuccessOrganization": "संगठन लिंक क्लिपबोर्ड में कॉपी किया गया", "shareSuccessPublic": "सार्वजनिक लिंक क्लिपबोर्ड में कॉपी किया गया", - "openInCloud": "Roo Code Cloud में कार्य खोलें", - "openInCloudIntro": "कहीं से भी Roo की निगरानी या इंटरैक्ट करना जारी रखें। खोलने के लिए स्कैन करें, क्लिक करें या कॉपी करें।", "openApiHistory": "API इतिहास खोलें", "openUiHistory": "UI इतिहास खोलें", "backToParentTask": "मूल कार्य" diff --git a/webview-ui/src/i18n/locales/hi/cloud.json b/webview-ui/src/i18n/locales/hi/cloud.json index ecf6e1f101..c573284e1d 100644 --- a/webview-ui/src/i18n/locales/hi/cloud.json +++ b/webview-ui/src/i18n/locales/hi/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Roo Code Cloud पर जाएं", "taskSync": "कार्य सिंक", "taskSyncDescription": "Roo Code Cloud पर देखने और साझा करने के लिए अपने कार्यों को सिंक करें", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Roo Code Cloud से कार्यों को नियंत्रित करने की अनुमति दें", - "remoteControlRequiresTaskSync": "Roomote Control का उपयोग करने के लिए कार्य सिंक सक्षम होना चाहिए", "taskSyncManagedByOrganization": "कार्य सिंक आपके संगठन द्वारा प्रबंधित किया जाता है", "usageMetricsAlwaysReported": "लॉग इन होने पर मॉडल उपयोग जानकारी हमेशा रिपोर्ट की जाती है", "authWaiting": "प्रमाणीकरण पूरा होने की प्रतीक्षा कर रहे हैं...", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index f442d6fe10..98b08d4877 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Berbagi dinonaktifkan oleh organisasi", "shareSuccessOrganization": "Tautan organisasi disalin ke clipboard", "shareSuccessPublic": "Tautan publik disalin ke clipboard", - "openInCloud": "Buka tugas di Roo Code Cloud", - "openInCloudIntro": "Terus pantau atau berinteraksi dengan Roo dari mana saja. Pindai, klik atau salin untuk membuka.", "openApiHistory": "Buka Riwayat API", "openUiHistory": "Buka Riwayat UI", "backToParentTask": "Tugas Induk" diff --git a/webview-ui/src/i18n/locales/id/cloud.json b/webview-ui/src/i18n/locales/id/cloud.json index 3e3e293f97..5e8000c7b8 100644 --- a/webview-ui/src/i18n/locales/id/cloud.json +++ b/webview-ui/src/i18n/locales/id/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Kunjungi Roo Code Cloud", "taskSync": "Sinkronisasi tugas", "taskSyncDescription": "Sinkronkan tugas Anda untuk melihat dan berbagi di Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Izinkan mengontrol tugas dari Roo Code Cloud", - "remoteControlRequiresTaskSync": "Sinkronisasi tugas harus diaktifkan untuk menggunakan Roomote Control", "taskSyncManagedByOrganization": "Sinkronisasi tugas dikelola oleh organisasi Anda", "usageMetricsAlwaysReported": "Informasi penggunaan model selalu dilaporkan saat masuk", "authWaiting": "Menunggu autentikasi selesai...", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 28f8eba54d..b2b9adf685 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Condivisione disabilitata dall'organizzazione", "shareSuccessOrganization": "Link organizzazione copiato negli appunti", "shareSuccessPublic": "Link pubblico copiato negli appunti", - "openInCloud": "Apri attività in Roo Code Cloud", - "openInCloudIntro": "Continua a monitorare o interagire con Roo da qualsiasi luogo. Scansiona, clicca o copia per aprire.", "openApiHistory": "Apri cronologia API", "openUiHistory": "Apri cronologia UI", "backToParentTask": "Attività principale" diff --git a/webview-ui/src/i18n/locales/it/cloud.json b/webview-ui/src/i18n/locales/it/cloud.json index cd575786d5..b0c0dceec2 100644 --- a/webview-ui/src/i18n/locales/it/cloud.json +++ b/webview-ui/src/i18n/locales/it/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Visita Roo Code Cloud", "taskSync": "Sincronizzazione attività", "taskSyncDescription": "Sincronizza le tue attività per visualizzarle e condividerle su Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Consenti il controllo delle attività da Roo Code Cloud", - "remoteControlRequiresTaskSync": "La sincronizzazione delle attività deve essere abilitata per utilizzare Roomote Control", "taskSyncManagedByOrganization": "La sincronizzazione delle attività è gestita dalla tua organizzazione", "usageMetricsAlwaysReported": "Le informazioni sull'utilizzo del modello vengono sempre segnalate quando si è connessi", "authWaiting": "In attesa del completamento dell'autenticazione...", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 6d581dbea4..e0e8ff2460 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "組織により共有が無効化されています", "shareSuccessOrganization": "組織リンクをクリップボードにコピーしました", "shareSuccessPublic": "公開リンクをクリップボードにコピーしました", - "openInCloud": "Roo Code Cloudでタスクを開く", - "openInCloudIntro": "どこからでもRooの監視や操作を続けられます。スキャン、クリック、またはコピーして開いてください。", "openApiHistory": "API履歴を開く", "openUiHistory": "UI履歴を開く", "backToParentTask": "親タスク" diff --git a/webview-ui/src/i18n/locales/ja/cloud.json b/webview-ui/src/i18n/locales/ja/cloud.json index d25f61b78c..6474bd8fe0 100644 --- a/webview-ui/src/i18n/locales/ja/cloud.json +++ b/webview-ui/src/i18n/locales/ja/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Roo Code Cloudを訪問", "taskSync": "タスク同期", "taskSyncDescription": "Roo Code Cloudでタスクを表示・共有するために同期", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Roo Code Cloudからタスクを制御できるようにする", - "remoteControlRequiresTaskSync": "Roomote Controlを使用するにはタスク同期を有効にする必要があります", "taskSyncManagedByOrganization": "タスク同期は組織によって管理されます", "usageMetricsAlwaysReported": "ログイン時にはモデル使用情報が常に報告されます", "authWaiting": "認証完了をお待ちください...", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 415481f804..ff3e419975 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "조직에서 공유가 비활성화됨", "shareSuccessOrganization": "조직 링크가 클립보드에 복사되었습니다", "shareSuccessPublic": "공개 링크가 클립보드에 복사되었습니다", - "openInCloud": "Roo Code Cloud에서 작업 열기", - "openInCloudIntro": "어디서나 Roo를 계속 모니터링하거나 상호작용할 수 있습니다. 스캔, 클릭 또는 복사하여 열기.", "openApiHistory": "API 기록 열기", "openUiHistory": "UI 기록 열기", "backToParentTask": "상위 작업" diff --git a/webview-ui/src/i18n/locales/ko/cloud.json b/webview-ui/src/i18n/locales/ko/cloud.json index 6aba5f58ba..c0731e3bf4 100644 --- a/webview-ui/src/i18n/locales/ko/cloud.json +++ b/webview-ui/src/i18n/locales/ko/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Roo Code Cloud 방문", "taskSync": "작업 동기화", "taskSyncDescription": "Roo Code Cloud에서 보고 공유할 수 있도록 작업을 동기화", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Roo Code Cloud에서 작업을 제어할 수 있도록 허용", - "remoteControlRequiresTaskSync": "Roomote Control을 사용하려면 작업 동기화가 활성화되어야 합니다", "taskSyncManagedByOrganization": "작업 동기화는 조직에서 관리합니다", "usageMetricsAlwaysReported": "로그인 시 모델 사용 정보가 항상 보고됩니다", "authWaiting": "인증 완료를 기다리는 중...", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 1095670a35..d79c95d9e9 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Delen uitgeschakeld door organisatie", "shareSuccessOrganization": "Organisatielink gekopieerd naar klembord", "shareSuccessPublic": "Openbare link gekopieerd naar klembord", - "openInCloud": "Taak openen in Roo Code Cloud", - "openInCloudIntro": "Blijf Roo vanaf elke locatie monitoren of ermee interacteren. Scan, klik of kopieer om te openen.", "openApiHistory": "API-geschiedenis openen", "openUiHistory": "UI-geschiedenis openen", "backToParentTask": "Bovenliggende taak" diff --git a/webview-ui/src/i18n/locales/nl/cloud.json b/webview-ui/src/i18n/locales/nl/cloud.json index dad073fdc2..5a4a5c4db2 100644 --- a/webview-ui/src/i18n/locales/nl/cloud.json +++ b/webview-ui/src/i18n/locales/nl/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Bezoek Roo Code Cloud", "taskSync": "Taaksynchronisatie", "taskSyncDescription": "Synchroniseer je taken om ze te bekijken en delen op Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Sta toe taken te besturen vanuit Roo Code Cloud", - "remoteControlRequiresTaskSync": "Taaksynchronisatie moet ingeschakeld zijn om Roomote Control te gebruiken", "taskSyncManagedByOrganization": "Taaksynchronisatie wordt beheerd door uw organisatie", "usageMetricsAlwaysReported": "Modelgebruiksinformatie wordt altijd gerapporteerd wanneer ingelogd", "authWaiting": "Wachten tot authenticatie voltooid is...", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 37998d0d16..ddf225bae0 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Udostępnianie wyłączone przez organizację", "shareSuccessOrganization": "Link organizacji skopiowany do schowka", "shareSuccessPublic": "Link publiczny skopiowany do schowka", - "openInCloud": "Otwórz zadanie w Roo Code Cloud", - "openInCloudIntro": "Kontynuuj monitorowanie lub interakcję z Roo z dowolnego miejsca. Zeskanuj, kliknij lub skopiuj, aby otworzyć.", "openApiHistory": "Otwórz historię API", "openUiHistory": "Otwórz historię UI", "backToParentTask": "Zadanie nadrzędne" diff --git a/webview-ui/src/i18n/locales/pl/cloud.json b/webview-ui/src/i18n/locales/pl/cloud.json index 827eaa70ef..3a7eac8cdb 100644 --- a/webview-ui/src/i18n/locales/pl/cloud.json +++ b/webview-ui/src/i18n/locales/pl/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Odwiedź Roo Code Cloud", "taskSync": "Synchronizacja zadań", "taskSyncDescription": "Synchronizuj swoje zadania, aby przeglądać i udostępniać je w Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Pozwól kontrolować zadania z Roo Code Cloud", - "remoteControlRequiresTaskSync": "Synchronizacja zadań musi być włączona, aby używać Roomote Control", "taskSyncManagedByOrganization": "Synchronizacja zadań jest zarządzana przez Twoją organizację", "usageMetricsAlwaysReported": "Informacje o użyciu modelu są zawsze raportowane po zalogowaniu", "authWaiting": "Oczekiwanie na zakończenie uwierzytelniania...", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index afda26c2e6..b0ad26bfbd 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Compartilhamento desabilitado pela organização", "shareSuccessOrganization": "Link da organização copiado para a área de transferência", "shareSuccessPublic": "Link público copiado para a área de transferência", - "openInCloud": "Abrir tarefa no Roo Code Cloud", - "openInCloudIntro": "Continue monitorando ou interagindo com Roo de qualquer lugar. Escaneie, clique ou copie para abrir.", "openApiHistory": "Abrir histórico da API", "openUiHistory": "Abrir histórico da UI", "backToParentTask": "Tarefa pai" diff --git a/webview-ui/src/i18n/locales/pt-BR/cloud.json b/webview-ui/src/i18n/locales/pt-BR/cloud.json index 1876f1a666..08ee2f205f 100644 --- a/webview-ui/src/i18n/locales/pt-BR/cloud.json +++ b/webview-ui/src/i18n/locales/pt-BR/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Visitar Roo Code Cloud", "taskSync": "Sincronização de tarefas", "taskSyncDescription": "Sincronize suas tarefas para visualizar e compartilhar no Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Permite controlar tarefas a partir do Roo Code Cloud", - "remoteControlRequiresTaskSync": "A sincronização de tarefas deve estar habilitada para usar o Roomote Control", "taskSyncManagedByOrganization": "A sincronização de tarefas é gerenciada pela sua organização", "usageMetricsAlwaysReported": "As informações de uso do modelo são sempre reportadas quando conectado", "authWaiting": "Aguardando conclusão da autenticação...", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index b66e9c40a2..3681d3d648 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Обмен отключен организацией", "shareSuccessOrganization": "Ссылка организации скопирована в буфер обмена", "shareSuccessPublic": "Публичная ссылка скопирована в буфер обмена", - "openInCloud": "Открыть задачу в Roo Code Cloud", - "openInCloudIntro": "Продолжай отслеживать или взаимодействовать с Roo откуда угодно. Отсканируй, нажми или скопируй для открытия.", "openApiHistory": "Открыть историю API", "openUiHistory": "Открыть историю UI", "backToParentTask": "Родительская задача" diff --git a/webview-ui/src/i18n/locales/ru/cloud.json b/webview-ui/src/i18n/locales/ru/cloud.json index 438a1a90fd..20aa6d4506 100644 --- a/webview-ui/src/i18n/locales/ru/cloud.json +++ b/webview-ui/src/i18n/locales/ru/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Посетить Roo Code Cloud", "taskSync": "Синхронизация задач", "taskSyncDescription": "Синхронизируйте свои задачи для просмотра и обмена в Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Разрешить управление задачами из Roo Code Cloud", - "remoteControlRequiresTaskSync": "Для использования Roomote Control должна быть включена синхронизация задач", "taskSyncManagedByOrganization": "Синхронизация задач управляется вашей организацией", "usageMetricsAlwaysReported": "Информация об использовании модели всегда сообщается при входе в систему", "authWaiting": "Ожидание завершения аутентификации...", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 647f57bf17..216d8e0797 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Paylaşım kuruluş tarafından devre dışı bırakıldı", "shareSuccessOrganization": "Organizasyon bağlantısı panoya kopyalandı", "shareSuccessPublic": "Genel bağlantı panoya kopyalandı", - "openInCloud": "Görevi Roo Code Cloud'da aç", - "openInCloudIntro": "Roo'yu her yerden izlemeye veya etkileşime devam et. Açmak için tara, tıkla veya kopyala.", "openApiHistory": "API Geçmişini Aç", "openUiHistory": "UI Geçmişini Aç", "backToParentTask": "Üst görev" diff --git a/webview-ui/src/i18n/locales/tr/cloud.json b/webview-ui/src/i18n/locales/tr/cloud.json index 24e767a6c3..8c14c744a7 100644 --- a/webview-ui/src/i18n/locales/tr/cloud.json +++ b/webview-ui/src/i18n/locales/tr/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Roo Code Cloud'u ziyaret et", "taskSync": "Görev senkronizasyonu", "taskSyncDescription": "Görevlerinizi Roo Code Cloud'da görüntülemek ve paylaşmak için senkronize edin", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Roo Code Cloud'dan görevleri kontrol etmeye izin ver", - "remoteControlRequiresTaskSync": "Roomote Control'ü kullanmak için görev senkronizasyonu etkinleştirilmelidir", "taskSyncManagedByOrganization": "Görev senkronizasyonu kuruluşunuz tarafından yönetilir", "usageMetricsAlwaysReported": "Oturum açıldığında model kullanım bilgileri her zaman raporlanır", "authWaiting": "Kimlik doğrulama tamamlanması bekleniyor...", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 9d56f1885e..83cb5a03f7 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Chia sẻ bị tổ chức vô hiệu hóa", "shareSuccessOrganization": "Liên kết tổ chức đã được sao chép vào clipboard", "shareSuccessPublic": "Liên kết công khai đã được sao chép vào clipboard", - "openInCloud": "Mở tác vụ trong Roo Code Cloud", - "openInCloudIntro": "Tiếp tục theo dõi hoặc tương tác với Roo từ bất cứ đâu. Quét, nhấp hoặc sao chép để mở.", "openApiHistory": "Mở lịch sử API", "openUiHistory": "Mở lịch sử UI", "backToParentTask": "Nhiệm vụ cha" diff --git a/webview-ui/src/i18n/locales/vi/cloud.json b/webview-ui/src/i18n/locales/vi/cloud.json index e9c4dee720..45dd092b75 100644 --- a/webview-ui/src/i18n/locales/vi/cloud.json +++ b/webview-ui/src/i18n/locales/vi/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Truy cập Roo Code Cloud", "taskSync": "Đồng bộ tác vụ", "taskSyncDescription": "Đồng bộ tác vụ của bạn để xem và chia sẻ trên Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Cho phép điều khiển tác vụ từ Roo Code Cloud", - "remoteControlRequiresTaskSync": "Đồng bộ tác vụ phải được bật để sử dụng Roomote Control", "taskSyncManagedByOrganization": "Việc đồng bộ hóa công việc được quản lý bởi tổ chức của bạn", "usageMetricsAlwaysReported": "Thông tin sử dụng mô hình luôn được báo cáo khi đăng nhập", "authWaiting": "Đang chờ hoàn tất xác thực...", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 22994cbb07..049a06a7ce 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "组织已禁用分享功能", "shareSuccessOrganization": "组织链接已复制到剪贴板", "shareSuccessPublic": "公开链接已复制到剪贴板", - "openInCloud": "在 Roo Code Cloud 中打开任务", - "openInCloudIntro": "从任何地方继续监控或与 Roo 交互。扫描、点击或复制以打开。", "openApiHistory": "打开 API 历史", "openUiHistory": "打开 UI 历史", "backToParentTask": "父任务" diff --git a/webview-ui/src/i18n/locales/zh-CN/cloud.json b/webview-ui/src/i18n/locales/zh-CN/cloud.json index 29b4f98e4c..560218c56f 100644 --- a/webview-ui/src/i18n/locales/zh-CN/cloud.json +++ b/webview-ui/src/i18n/locales/zh-CN/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "访问 Roo Code Cloud", "taskSync": "任务同步", "taskSyncDescription": "同步您的任务以在 Roo Code Cloud 上查看和共享", - "remoteControl": "Roomote Control", - "remoteControlDescription": "允许从 Roo Code Cloud 控制任务", - "remoteControlRequiresTaskSync": "必须启用任务同步才能使用 Roomote Control", "taskSyncManagedByOrganization": "任务同步由您的组织管理", "usageMetricsAlwaysReported": "登录时始终报告模型使用信息", "authWaiting": "等待身份验证完成...", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 00f57d369d..e01b86f52b 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "組織已停用分享功能", "shareSuccessOrganization": "組織連結已複製到剪貼簿", "shareSuccessPublic": "公開連結已複製到剪貼簿", - "openInCloud": "在 Roo Code Cloud 中開啟工作", - "openInCloudIntro": "從任何地方繼續監控或與 Roo 互動。掃描、點選或複製即可開啟。", "openApiHistory": "開啟 API 歷史紀錄", "openUiHistory": "開啟 UI 歷史紀錄", "backToParentTask": "上層工作" diff --git a/webview-ui/src/i18n/locales/zh-TW/cloud.json b/webview-ui/src/i18n/locales/zh-TW/cloud.json index 1ade83d3e3..032c18ed91 100644 --- a/webview-ui/src/i18n/locales/zh-TW/cloud.json +++ b/webview-ui/src/i18n/locales/zh-TW/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "造訪 Roo Code Cloud", "taskSync": "任務同步", "taskSyncDescription": "同步工作以在 Roo Code Cloud 上檢視和分享", - "remoteControl": "Roomote Control", - "remoteControlDescription": "允許從 Roo Code Cloud 控制工作", - "remoteControlRequiresTaskSync": "必須啟用任務同步才能使用 Roomote Control", "taskSyncManagedByOrganization": "工作同步由您的組織管理", "usageMetricsAlwaysReported": "登入時會一律回報模型使用資訊", "authWaiting": "等待瀏覽器驗證完成...", From 9a8af61936619fea7160469670a78b64a0ebc59c Mon Sep 17 00:00:00 2001 From: Ashank Sundaram Date: Fri, 20 Feb 2026 09:41:48 +0800 Subject: [PATCH 028/109] feat(openai-codex): add gpt-5.3-codex-spark model metadata (#11620) --- packages/types/src/providers/openai-codex.ts | 14 ++++++++++++++ src/api/providers/__tests__/openai-codex.spec.ts | 12 +++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/types/src/providers/openai-codex.ts b/packages/types/src/providers/openai-codex.ts index 72b909591a..f8d9bd25df 100644 --- a/packages/types/src/providers/openai-codex.ts +++ b/packages/types/src/providers/openai-codex.ts @@ -68,6 +68,20 @@ export const openAiCodexModels = { supportsTemperature: false, description: "GPT-5.3 Codex: OpenAI's flagship coding model via ChatGPT subscription", }, + "gpt-5.3-codex-spark": { + maxTokens: 8192, + contextWindow: 128000, + includedTools: ["apply_patch"], + excludedTools: ["apply_diff", "write_to_file"], + supportsImages: false, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high", "xhigh"], + reasoningEffort: "medium", + inputPrice: 0, + outputPrice: 0, + supportsTemperature: false, + description: "GPT-5.3 Codex Spark: Fast, text-only coding model via ChatGPT subscription", + }, "gpt-5.2-codex": { maxTokens: 128000, contextWindow: 400000, diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts index 26a0e83c45..2e164fe469 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -3,7 +3,7 @@ import { OpenAiCodexHandler } from "../openai-codex" describe("OpenAiCodexHandler.getModel", () => { - it.each(["gpt-5.1", "gpt-5", "gpt-5.1-codex", "gpt-5-codex", "gpt-5-codex-mini"])( + it.each(["gpt-5.1", "gpt-5", "gpt-5.1-codex", "gpt-5-codex", "gpt-5-codex-mini", "gpt-5.3-codex-spark"])( "should return specified model when a valid model id is provided: %s", (apiModelId) => { const handler = new OpenAiCodexHandler({ apiModelId }) @@ -23,4 +23,14 @@ describe("OpenAiCodexHandler.getModel", () => { expect(model.id).toBe("gpt-5.3-codex") expect(model.info).toBeDefined() }) + + it("should use Spark-specific limits and capabilities", () => { + const handler = new OpenAiCodexHandler({ apiModelId: "gpt-5.3-codex-spark" }) + const model = handler.getModel() + + expect(model.id).toBe("gpt-5.3-codex-spark") + expect(model.info.contextWindow).toBe(128000) + expect(model.info.maxTokens).toBe(8192) + expect(model.info.supportsImages).toBe(false) + }) }) From ea7da97a40f3fb00db6b53642d17697820585a79 Mon Sep 17 00:00:00 2001 From: Ashank Sundaram Date: Fri, 20 Feb 2026 10:24:26 +0800 Subject: [PATCH 029/109] fix(openai): handle done-only/content-part responses (#11621) * fix(openai-codex): handle done-only/content-part responses * fix(openai): align native done-event stream fallbacks with codex * fix(openai): address stream fallback review feedback --- .changeset/sly-candles-hide.md | 5 + .../openai-codex-native-tool-calls.spec.ts | 308 ++++++++++++++++++ .../__tests__/openai-native-tools.spec.ts | 209 ++++++++++++ src/api/providers/openai-codex.ts | 156 ++++++++- src/api/providers/openai-native.ts | 145 ++++++++- 5 files changed, 803 insertions(+), 20 deletions(-) create mode 100644 .changeset/sly-candles-hide.md diff --git a/.changeset/sly-candles-hide.md b/.changeset/sly-candles-hide.md new file mode 100644 index 0000000000..be720c0250 --- /dev/null +++ b/.changeset/sly-candles-hide.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Fix OpenAI Codex and OpenAI Native stream parsing for done-only and `content_part` events, including duplicate-text guards when deltas are already streamed. diff --git a/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts b/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts index 608f639ed4..0ac1e9b884 100644 --- a/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts +++ b/src/api/providers/__tests__/openai-codex-native-tool-calls.spec.ts @@ -97,4 +97,312 @@ describe("OpenAiCodexHandler native tool calls", () => { name: "attempt_completion", }) }) + + it("yields text when Codex emits assistant message only in response.output_item.done", async () => { + vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + ;(handler as any).client = { + responses: { + create: vi.fn().mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "hello from spark" }], + }, + output_index: 0, + } + yield { + type: "response.completed", + response: { + id: "resp_done_only", + status: "completed", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "hello from spark" }], + }, + ], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + } + }, + }), + }, + } + + const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], { + taskId: "t", + tools: [], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks.length).toBeGreaterThan(0) + expect(textChunks.map((c) => c.text).join("")).toContain("hello from spark") + }) + + it("yields text when Codex emits assistant message only in response.completed output", async () => { + vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + ;(handler as any).client = { + responses: { + create: vi.fn().mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { + type: "response.completed", + response: { + id: "resp_completed_only", + status: "completed", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "final payload only" }], + }, + ], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + } + }, + }), + }, + } + + const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], { + taskId: "t", + tools: [], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks.length).toBeGreaterThan(0) + expect(textChunks.map((c) => c.text).join("")).toContain("final payload only") + }) + + it("yields text when Codex emits response.output_text.done without deltas", async () => { + vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + ;(handler as any).client = { + responses: { + create: vi.fn().mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { + type: "response.output_text.done", + text: "done-event text only", + } + yield { + type: "response.completed", + response: { + id: "resp_done_text_only", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + } + }, + }), + }, + } + + const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], { + taskId: "t", + tools: [], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks.length).toBeGreaterThan(0) + expect(textChunks.map((c) => c.text).join("")).toContain("done-event text only") + }) + + it("yields tool_call when Codex emits function_call only in response.output_item.done", async () => { + vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + ;(handler as any).client = { + responses: { + create: vi.fn().mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_done_only", + name: "attempt_completion", + arguments: '{"result":"ok"}', + }, + output_index: 0, + } + yield { + type: "response.completed", + response: { + id: "resp_done_tool_only", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + } + }, + }), + }, + } + + const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], { + taskId: "t", + tools: [], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCalls = chunks.filter((c) => c.type === "tool_call") + expect(toolCalls.length).toBeGreaterThan(0) + expect(toolCalls[0]).toMatchObject({ + type: "tool_call", + id: "call_done_only", + name: "attempt_completion", + }) + }) + + it("yields text when Codex emits response.content_part.added", async () => { + vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + ;(handler as any).client = { + responses: { + create: vi.fn().mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { + type: "response.content_part.added", + part: { + type: "output_text", + text: "content part text", + }, + output_index: 0, + content_index: 0, + } + yield { + type: "response.completed", + response: { + id: "resp_content_part", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + } + }, + }), + }, + } + + const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], { + taskId: "t", + tools: [], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks.length).toBeGreaterThan(0) + expect(textChunks.map((c) => c.text).join("")).toContain("content part text") + }) + + it("does not duplicate text when Codex emits delta and output_text.done", async () => { + vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + ;(handler as any).client = { + responses: { + create: vi.fn().mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { type: "response.output_text.delta", delta: "hello " } + yield { type: "response.output_text.delta", delta: "world" } + yield { type: "response.output_text.done", text: "hello world" } + yield { + type: "response.completed", + response: { + id: "resp_delta_done", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + } + }, + }), + }, + } + + const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], { + taskId: "t", + tools: [], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks.map((c) => c.text).join("")).toBe("hello world") + }) + + it("does not duplicate text when Codex emits delta and content_part.added", async () => { + vi.spyOn(openAiCodexOAuthManager, "getAccessToken").mockResolvedValue("test-token") + vi.spyOn(openAiCodexOAuthManager, "getAccountId").mockResolvedValue("acct_test") + ;(handler as any).client = { + responses: { + create: vi.fn().mockResolvedValue({ + async *[Symbol.asyncIterator]() { + yield { type: "response.output_text.delta", delta: "hello world" } + yield { + type: "response.content_part.added", + part: { type: "output_text", text: "hello world" }, + output_index: 0, + content_index: 0, + } + yield { + type: "response.completed", + response: { + id: "resp_delta_content_part", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + } + }, + }), + }, + } + + const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], { + taskId: "t", + tools: [], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks.map((c) => c.text).join("")).toBe("hello world") + }) }) diff --git a/src/api/providers/__tests__/openai-native-tools.spec.ts b/src/api/providers/__tests__/openai-native-tools.spec.ts index e0746f792e..1e0e09c9a5 100644 --- a/src/api/providers/__tests__/openai-native-tools.spec.ts +++ b/src/api/providers/__tests__/openai-native-tools.spec.ts @@ -360,3 +360,212 @@ describe("OpenAiNativeHandler MCP tool schema handling", () => { }) }) }) + +describe("OpenAiNativeHandler done-event fallbacks", () => { + const createHandlerWithEvents = (events: any[]) => { + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + } as ApiHandlerOptions) + + ;(handler as any).client = { + responses: { + create: vi.fn().mockResolvedValue({ + async *[Symbol.asyncIterator]() { + for (const event of events) { + yield event + } + }, + }), + }, + } + + return handler + } + + const collectChunksFromEvents = async (events: any[]) => { + const handler = createHandlerWithEvents(events) + const stream = handler.createMessage("system", [{ role: "user", content: "test" } as any], { + taskId: "t", + tools: [], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + return chunks + } + + it.each([ + [ + "response.output_item.done message", + [ + { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "hello from done item" }], + }, + output_index: 0, + }, + { + type: "response.completed", + response: { + id: "resp_done_item_only", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + }, + ], + "hello from done item", + ], + [ + "response.completed output", + [ + { + type: "response.completed", + response: { + id: "resp_completed_only", + status: "completed", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "final payload only" }], + }, + ], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + }, + ], + "final payload only", + ], + [ + "response.output_text.done", + [ + { + type: "response.output_text.done", + text: "done-event text only", + }, + { + type: "response.completed", + response: { + id: "resp_done_text_only", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + }, + ], + "done-event text only", + ], + [ + "response.content_part.added", + [ + { + type: "response.content_part.added", + part: { + type: "output_text", + text: "content part text", + }, + output_index: 0, + content_index: 0, + }, + { + type: "response.completed", + response: { + id: "resp_content_part", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + }, + ], + "content part text", + ], + ])("yields text when native emits %s", async (_caseName, events, expectedText) => { + const chunks = await collectChunksFromEvents(events) + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks.length).toBeGreaterThan(0) + expect(textChunks.map((c) => c.text).join("")).toContain(expectedText) + }) + + it("yields tool_call when native emits function_call only in response.output_item.done", async () => { + const chunks = await collectChunksFromEvents([ + { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_done_only", + name: "attempt_completion", + arguments: '{"result":"ok"}', + }, + output_index: 0, + }, + { + type: "response.completed", + response: { + id: "resp_done_tool_only", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + }, + ]) + + const toolCalls = chunks.filter((c) => c.type === "tool_call") + expect(toolCalls.length).toBeGreaterThan(0) + expect(toolCalls[0]).toMatchObject({ + type: "tool_call", + id: "call_done_only", + name: "attempt_completion", + }) + }) + + it("does not duplicate text when delta and output_text.done are both emitted", async () => { + const chunks = await collectChunksFromEvents([ + { type: "response.output_text.delta", delta: "hello " }, + { type: "response.output_text.delta", delta: "world" }, + { type: "response.output_text.done", text: "hello world" }, + { + type: "response.completed", + response: { + id: "resp_delta_done", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + }, + ]) + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks.map((c) => c.text).join("")).toBe("hello world") + }) + + it("does not duplicate text when delta and content_part.added are both emitted", async () => { + const chunks = await collectChunksFromEvents([ + { type: "response.output_text.delta", delta: "hello world" }, + { + type: "response.content_part.added", + part: { type: "output_text", text: "hello world" }, + output_index: 0, + content_index: 0, + }, + { + type: "response.completed", + response: { + id: "resp_delta_content_part", + status: "completed", + output: [], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + }, + ]) + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks.map((c) => c.text).join("")).toBe("hello world") + }) +}) diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index d64780c555..9dfb37bc72 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -64,11 +64,21 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion */ private pendingToolCallId: string | undefined private pendingToolCallName: string | undefined + // Tracks whether this response already emitted text to avoid duplicate done-event rendering. + private sawTextOutputInCurrentResponse = false + // Tracks whether text arrived through delta events so content_part events can be treated as fallback-only. + private sawTextDeltaInCurrentResponse = false + // Tracks tool call IDs emitted via streaming partial events to prevent done-event duplicates. + private streamedToolCallIds = new Set() // Event types handled by the shared event processor private readonly coreHandledEventTypes = new Set([ "response.text.delta", "response.output_text.delta", + "response.text.done", + "response.output_text.done", + "response.content_part.added", + "response.content_part.done", "response.reasoning.delta", "response.reasoning_text.delta", "response.reasoning_summary.delta", @@ -149,6 +159,9 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion this.lastResponseId = undefined this.pendingToolCallId = undefined this.pendingToolCallName = undefined + this.sawTextOutputInCurrentResponse = false + this.sawTextDeltaInCurrentResponse = false + this.streamedToolCallIds.clear() // Get access token from OAuth manager let accessToken = await openAiCodexOAuthManager.getAccessToken() @@ -378,6 +391,9 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion } for await (const outChunk of this.processEvent(event, model)) { + if (outChunk.type === "text") { + this.sawTextOutputInCurrentResponse = true + } yield outChunk } } @@ -647,6 +663,9 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion for await (const outChunk of this.processEvent(parsed, model)) { if (outChunk.type === "text" || outChunk.type === "reasoning") { hasContent = true + if (outChunk.type === "text") { + this.sawTextOutputInCurrentResponse = true + } } yield outChunk } @@ -660,6 +679,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion for (const content of outputItem.content) { if (content.type === "text" && content.text) { hasContent = true + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: content.text } } } @@ -685,8 +705,26 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion ) { if (parsed.delta) { hasContent = true + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: parsed.delta } } + } else if ( + (parsed.type === "response.text.done" || parsed.type === "response.output_text.done") && + !hasContent + ) { + const doneText = + typeof parsed.text === "string" + ? parsed.text + : typeof parsed.output_text === "string" + ? parsed.output_text + : typeof parsed.delta === "string" + ? parsed.delta + : undefined + if (doneText) { + hasContent = true + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: doneText } + } } else if ( parsed.type === "response.reasoning.delta" || parsed.type === "response.reasoning_text.delta" @@ -706,12 +744,14 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion } else if (parsed.type === "response.refusal.delta") { if (parsed.delta) { hasContent = true + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: `[Refusal] ${parsed.delta}` } } } else if (parsed.type === "response.output_item.added") { if (parsed.item) { if (parsed.item.type === "text" && parsed.item.text) { hasContent = true + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: parsed.item.text } } else if (parsed.item.type === "reasoning" && parsed.item.text) { hasContent = true @@ -720,6 +760,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion for (const content of parsed.item.content) { if (content.type === "text" && content.text) { hasContent = true + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: content.text } } } @@ -760,6 +801,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion for (const content of outputItem.content) { if (content.type === "output_text" && content.text) { hasContent = true + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: content.text } } } @@ -779,6 +821,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion } } else if (parsed.choices?.[0]?.delta?.content) { hasContent = true + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: parsed.choices[0].delta.content } } else if ( parsed.item && @@ -786,6 +829,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion parsed.item.text.length > 0 ) { hasContent = true + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: parsed.item.text } } else if (parsed.usage) { const usageData = this.normalizeUsage(parsed.usage, model) @@ -803,6 +847,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion const parsed = JSON.parse(line) if (parsed.content || parsed.text || parsed.message) { hasContent = true + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: parsed.content || parsed.text || parsed.message } } } catch { @@ -836,11 +881,45 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // Handle text deltas if (event?.type === "response.text.delta" || event?.type === "response.output_text.delta") { if (event?.delta) { + this.sawTextDeltaInCurrentResponse = true + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: event.delta } } return } + if (event?.type === "response.text.done" || event?.type === "response.output_text.done") { + const doneText = + typeof event?.text === "string" + ? event.text + : typeof event?.output_text === "string" + ? event.output_text + : typeof event?.delta === "string" + ? event.delta + : undefined + if (!this.sawTextOutputInCurrentResponse && doneText) { + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: doneText } + } + return + } + + if (event?.type === "response.content_part.added" || event?.type === "response.content_part.done") { + const part = event?.part + if ( + !this.sawTextDeltaInCurrentResponse && + (part?.type === "text" || part?.type === "output_text") && + (typeof part?.text === "string" || typeof part?.text?.value === "string") + ) { + const partText = typeof part.text === "string" ? part.text : part.text.value + if (partText) { + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: partText } + } + } + return + } + // Handle reasoning deltas if ( event?.type === "response.reasoning.delta" || @@ -857,6 +936,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // Handle refusal deltas if (event?.type === "response.refusal.delta") { if (event?.delta) { + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: `[Refusal] ${event.delta}` } } return @@ -875,6 +955,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // to include a stable id/name. Avoid emitting incomplete tool_call_partial chunks because // NativeToolCallParser requires a name to start a call. if (typeof callId === "string" && callId.length > 0 && typeof name === "string" && name.length > 0) { + this.streamedToolCallIds.add(callId) yield { type: "tool_call_partial", index: event.index ?? 0, @@ -908,17 +989,64 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion } } - // For "added" events, yield text/reasoning content (streaming path) - // For "done" events, do NOT yield text/reasoning - it's already been streamed via deltas - // and would cause double-emission (A, B, C, ABC). + // For "added" events, yield text/reasoning content (streaming path). + // For "done" events, normally text was already streamed via deltas, but some models + // only provide assistant text on done events. Emit fallback text only if none was emitted yet. if (event.type === "response.output_item.added") { if (item.type === "text" && item.text) { + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: item.text } + } else if (item.type === "output_text" && item.text) { + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: item.text } } else if (item.type === "reasoning" && item.text) { yield { type: "reasoning", text: item.text } } else if (item.type === "message" && Array.isArray(item.content)) { for (const content of item.content) { if ((content?.type === "text" || content?.type === "output_text") && content?.text) { + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: content.text } + } + } + } + } else if ( + event.type === "response.output_item.done" && + (item.type === "function_call" || item.type === "tool_call") + ) { + const callId = item.call_id || item.tool_call_id || item.id + const name = item.name || item.function?.name || item.function_name + const argsRaw = item.arguments || item.function?.arguments || item.input + const args = + typeof argsRaw === "string" + ? argsRaw + : argsRaw && typeof argsRaw === "object" + ? JSON.stringify(argsRaw) + : "" + + // Fallback for models that only emit a complete function_call in output_item.done. + // If we already streamed partials for this ID, skip to avoid duplicate tool execution. + if ( + typeof callId === "string" && + callId.length > 0 && + typeof name === "string" && + name.length > 0 && + !this.streamedToolCallIds.has(callId) + ) { + yield { + type: "tool_call", + id: callId, + name, + arguments: args, + } + } + } else if (!this.sawTextOutputInCurrentResponse) { + if ((item.type === "text" || item.type === "output_text") && item.text) { + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: item.text } + } else if (item.type === "message" && Array.isArray(item.content)) { + for (const content of item.content) { + if ((content?.type === "text" || content?.type === "output_text") && content?.text) { + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: content.text } } } @@ -937,6 +1065,26 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // Handle completion events if (event?.type === "response.done" || event?.type === "response.completed") { + // Some Codex variants only provide assistant text in the final completed payload. + if (!this.sawTextOutputInCurrentResponse && Array.isArray(event?.response?.output)) { + for (const outputItem of event.response.output) { + if ((outputItem?.type === "text" || outputItem?.type === "output_text") && outputItem?.text) { + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: outputItem.text } + continue + } + + if (outputItem?.type === "message" && Array.isArray(outputItem.content)) { + for (const content of outputItem.content) { + if ((content?.type === "text" || content?.type === "output_text") && content?.text) { + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: content.text } + } + } + } + } + } + const usage = event?.response?.usage || event?.usage || undefined const usageData = this.normalizeUsage(usage, model) if (usageData) { @@ -947,6 +1095,8 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion // Fallbacks if (event?.choices?.[0]?.delta?.content) { + this.sawTextDeltaInCurrentResponse = true + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: event.choices[0].delta.content } return } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index d7c60c5daf..3dfad3ed35 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -45,6 +45,12 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio */ private pendingToolCallId: string | undefined private pendingToolCallName: string | undefined + // Tracks whether this response already emitted text to avoid duplicate done-event rendering. + private sawTextOutputInCurrentResponse = false + // Tracks whether text arrived through delta events so content_part events can be treated as fallback-only. + private sawTextDeltaInCurrentResponse = false + // Tracks tool call IDs emitted via streaming partial events to prevent done-event duplicates. + private streamedToolCallIds = new Set() // Resolved service tier from Responses API (actual tier used by OpenAI) private lastServiceTier: ServiceTier | undefined // Complete response output array (includes reasoning items with encrypted_content) @@ -58,6 +64,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio private readonly coreHandledEventTypes = new Set([ "response.text.delta", "response.output_text.delta", + "response.text.done", + "response.output_text.done", + "response.content_part.added", + "response.content_part.done", "response.reasoning.delta", "response.reasoning_text.delta", "response.reasoning_summary.delta", @@ -184,6 +194,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Reset pending tool identity for this request this.pendingToolCallId = undefined this.pendingToolCallName = undefined + this.sawTextOutputInCurrentResponse = false + this.sawTextDeltaInCurrentResponse = false + this.streamedToolCallIds.clear() // Use Responses API for ALL models const { verbosity, reasoning } = this.getModel() @@ -700,7 +713,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio this.lastResponseId = parsed.response.id as string } - // Delegate standard event types to the shared processor to avoid duplication + // Delegate standard event types to the shared processor to avoid duplication. + // This applies to both SDK and raw SSE fallback paths. if (parsed?.type && this.coreHandledEventTypes.has(parsed.type)) { for await (const outChunk of this.processEvent(parsed, model)) { // Track whether we've emitted any content so fallback handling can decide appropriately @@ -1051,13 +1065,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // For SSE path, usage often arrives separately; avoid double-emitting here. } // These are structural or status events, we can just log them at a lower level or ignore. - else if ( - parsed.type === "response.created" || - parsed.type === "response.in_progress" || - parsed.type === "response.output_item.done" || - parsed.type === "response.content_part.added" || - parsed.type === "response.content_part.done" - ) { + else if (parsed.type === "response.created" || parsed.type === "response.in_progress") { // Status events - no action needed } // Fallback for older formats or unexpected responses @@ -1146,14 +1154,50 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio this.lastResponseId = event.response.id as string } - // Handle known streaming text deltas + // Handle text deltas if (event?.type === "response.text.delta" || event?.type === "response.output_text.delta") { if (event?.delta) { + this.sawTextDeltaInCurrentResponse = true + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: event.delta } } return } + // Handle done-only text for variants that skip delta events. + if (event?.type === "response.text.done" || event?.type === "response.output_text.done") { + const doneText = + typeof event?.text === "string" + ? event.text + : typeof event?.output_text === "string" + ? event.output_text + : typeof event?.delta === "string" + ? event.delta + : undefined + if (!this.sawTextOutputInCurrentResponse && doneText) { + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: doneText } + } + return + } + + // Handle content-part text for structured streaming payloads. + if (event?.type === "response.content_part.added" || event?.type === "response.content_part.done") { + const part = event?.part + if ( + !this.sawTextDeltaInCurrentResponse && + (part?.type === "text" || part?.type === "output_text") && + (typeof part?.text === "string" || typeof part?.text?.value === "string") + ) { + const partText = typeof part.text === "string" ? part.text : part.text.value + if (partText) { + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: partText } + } + } + return + } + // Handle reasoning deltas (including summary variants) if ( event?.type === "response.reasoning.delta" || @@ -1170,6 +1214,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Handle refusal deltas if (event?.type === "response.refusal.delta") { if (event?.delta) { + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: `[Refusal] ${event.delta}` } } return @@ -1189,6 +1234,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Avoid emitting incomplete tool_call_partial chunks; the downstream // NativeToolCallParser needs a name to start a call. if (typeof name === "string" && name.length > 0 && typeof callId === "string" && callId.length > 0) { + this.streamedToolCallIds.add(callId) yield { type: "tool_call_partial", index: event.index ?? 0, @@ -1223,11 +1269,15 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } } - // For "added" events, yield text/reasoning content (streaming path) - // For "done" events, do NOT yield text/reasoning - it's already been streamed via deltas - // and would cause double-emission (A, B, C, ABC). + // For "added" events, yield text/reasoning content (streaming path). + // For "done" events, normally text was already streamed via deltas, but some models + // only provide assistant text on done events. Emit fallback text only if none was emitted yet. if (event.type === "response.output_item.added") { if (item.type === "text" && item.text) { + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: item.text } + } else if (item.type === "output_text" && item.text) { + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: item.text } } else if (item.type === "reasoning" && item.text) { yield { type: "reasoning", text: item.text } @@ -1235,6 +1285,49 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio for (const content of item.content) { // Some implementations send 'text'; others send 'output_text' if ((content?.type === "text" || content?.type === "output_text") && content?.text) { + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: content.text } + } + } + } + } else if ( + event.type === "response.output_item.done" && + (item.type === "function_call" || item.type === "tool_call") + ) { + const callId = item.call_id || item.tool_call_id || item.id + const name = item.name || item.function?.name || item.function_name + const argsRaw = item.arguments || item.function?.arguments || item.input + const args = + typeof argsRaw === "string" + ? argsRaw + : argsRaw && typeof argsRaw === "object" + ? JSON.stringify(argsRaw) + : "" + + // Fallback for models that only emit a complete function_call in output_item.done. + // If we already streamed partials for this ID, skip to avoid duplicate tool execution. + if ( + typeof callId === "string" && + callId.length > 0 && + typeof name === "string" && + name.length > 0 && + !this.streamedToolCallIds.has(callId) + ) { + yield { + type: "tool_call", + id: callId, + name, + arguments: args, + } + } + } else if (!this.sawTextOutputInCurrentResponse) { + if ((item.type === "text" || item.type === "output_text") && item.text) { + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: item.text } + } else if (item.type === "message" && Array.isArray(item.content)) { + for (const content of item.content) { + if ((content?.type === "text" || content?.type === "output_text") && content?.text) { + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: content.text } } } @@ -1242,17 +1335,33 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio } // Note: We intentionally do NOT emit tool_call from response.output_item.done - // for function_call/tool_call items. The streaming path handles tool calls via: - // 1. tool_call_partial events during argument deltas - // 2. NativeToolCallParser.finalizeRawChunks() at stream end emitting tool_call_end - // 3. NativeToolCallParser.finalizeStreamingToolCall() creating the final ToolUse - // Emitting tool_call here would cause duplicate tool rendering. + // for function_call/tool_call items if we already saw streaming partials. } return } // Completion events that may carry usage if (event?.type === "response.done" || event?.type === "response.completed") { + // Some OpenAI variants only provide assistant text in the final completed payload. + if (!this.sawTextOutputInCurrentResponse && Array.isArray(event?.response?.output)) { + for (const outputItem of event.response.output) { + if ((outputItem?.type === "text" || outputItem?.type === "output_text") && outputItem?.text) { + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: outputItem.text } + continue + } + + if (outputItem?.type === "message" && Array.isArray(outputItem.content)) { + for (const content of outputItem.content) { + if ((content?.type === "text" || content?.type === "output_text") && content?.text) { + this.sawTextOutputInCurrentResponse = true + yield { type: "text", text: content.text } + } + } + } + } + } + const usage = event?.response?.usage || event?.usage || undefined const usageData = this.normalizeUsage(usage, model) if (usageData) { @@ -1263,6 +1372,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Fallbacks for older formats or unexpected objects if (event?.choices?.[0]?.delta?.content) { + this.sawTextDeltaInCurrentResponse = true + this.sawTextOutputInCurrentResponse = true yield { type: "text", text: event.choices[0].delta.content } return } From 318bb928e0bab89f728f42d24d3977b38a1035fd Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:43:09 -0500 Subject: [PATCH 030/109] feat: add timeout parameter to execute_command tool (#11622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: add `timeout` parameter to `execute_command` tool Allow the agent to specify a per-command timeout in seconds. When exceeded, the command continues running in the background (like clicking "Run in Background") and the output collected so far is returned, rather than aborting. This is useful for long-running processes like dev servers or file watchers that may never exit on their own. The agent timeout runs independently of the user-configured abort timeout — the user timeout remains active as a safety net even after the agent moves on. Co-authored-by: Claude Opus 4.6 (1M context) --- .../assistant-message/NativeToolCallParser.ts | 2 + .../tools/native-tools/execute_command.ts | 18 +++- src/core/tools/ExecuteCommandTool.ts | 102 +++++++++++------- src/shared/tools.ts | 5 +- 4 files changed, 81 insertions(+), 46 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index e0ea1383f1..bda7c71eb8 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -459,6 +459,7 @@ export class NativeToolCallParser { nativeArgs = { command: partialArgs.command, cwd: partialArgs.cwd, + timeout: partialArgs.timeout, } } break @@ -787,6 +788,7 @@ export class NativeToolCallParser { nativeArgs = { command: args.command, cwd: args.cwd, + timeout: args.timeout, } as NativeArgsFor } break diff --git a/src/core/prompts/tools/native-tools/execute_command.ts b/src/core/prompts/tools/native-tools/execute_command.ts index 4b97b99eb5..68c68dc5fd 100644 --- a/src/core/prompts/tools/native-tools/execute_command.ts +++ b/src/core/prompts/tools/native-tools/execute_command.ts @@ -5,20 +5,26 @@ const EXECUTE_COMMAND_DESCRIPTION = `Request to execute a CLI command on the sys Parameters: - command: (required) The CLI command to execute. This should be valid for the current operating system. Ensure the command is properly formatted and does not contain any harmful instructions. - cwd: (optional) The working directory to execute the command in +- timeout: (optional) Timeout in seconds. When exceeded, the command keeps running in the background and you receive the output so far. Set this for commands that may run indefinitely, such as dev servers or file watchers, so you can proceed without waiting for them to exit. Example: Executing npm run dev -{ "command": "npm run dev", "cwd": null } +{ "command": "npm run dev", "cwd": null, "timeout": null } Example: Executing ls in a specific directory if directed -{ "command": "ls -la", "cwd": "/home/user/projects" } +{ "command": "ls -la", "cwd": "/home/user/projects", "timeout": null } Example: Using relative paths -{ "command": "touch ./testdata/example.file", "cwd": null }` +{ "command": "touch ./testdata/example.file", "cwd": null, "timeout": null } + +Example: Running a build with a timeout +{ "command": "npm run build", "cwd": null, "timeout": 30 }` const COMMAND_PARAMETER_DESCRIPTION = `Shell command to execute` const CWD_PARAMETER_DESCRIPTION = `Optional working directory for the command, relative or absolute` +const TIMEOUT_PARAMETER_DESCRIPTION = `Timeout in seconds. When exceeded, the command continues running in the background and output collected so far is returned. Use this for long-running processes like dev servers, file watchers, or any command that may not exit on its own` + export default { type: "function", function: { @@ -36,8 +42,12 @@ export default { type: ["string", "null"], description: CWD_PARAMETER_DESCRIPTION, }, + timeout: { + type: ["number", "null"], + description: TIMEOUT_PARAMETER_DESCRIPTION, + }, }, - required: ["command", "cwd"], + required: ["command", "cwd", "timeout"], additionalProperties: false, }, }, diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index ef1370202e..cb6fc6ff02 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -26,13 +26,14 @@ class ShellIntegrationError extends Error {} interface ExecuteCommandParams { command: string cwd?: string + timeout?: number | null } export class ExecuteCommandTool extends BaseTool<"execute_command"> { readonly name = "execute_command" as const async execute(params: ExecuteCommandParams, task: Task, callbacks: ToolCallbacks): Promise { - const { command, cwd: customCwd } = params + const { command, cwd: customCwd, timeout: timeoutSeconds } = params const { handleError, pushToolResult, askApproval } = callbacks try { @@ -85,12 +86,16 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { // Convert seconds to milliseconds for internal use, but skip timeout if command is allowlisted const commandExecutionTimeout = isCommandAllowlisted ? 0 : commandExecutionTimeoutSeconds * 1000 + // Convert agent-specified timeout from seconds to milliseconds + const agentTimeout = typeof timeoutSeconds === "number" && timeoutSeconds > 0 ? timeoutSeconds * 1000 : 0 + const options: ExecuteCommandOptions = { executionId, command: canonicalCommand, customCwd, terminalShellIntegrationDisabled, commandExecutionTimeout, + agentTimeout, } try { @@ -144,6 +149,7 @@ export type ExecuteCommandOptions = { customCwd?: string terminalShellIntegrationDisabled?: boolean commandExecutionTimeout?: number + agentTimeout?: number } export async function executeCommandInTerminal( @@ -154,6 +160,7 @@ export async function executeCommandInTerminal( customCwd, terminalShellIntegrationDisabled = true, commandExecutionTimeout = 0, + agentTimeout = 0, }: ExecuteCommandOptions, ): Promise<[boolean, ToolResponse]> { // Convert milliseconds back to seconds for display purposes. @@ -308,49 +315,64 @@ export async function executeCommandInTerminal( const process = terminal.runCommand(command, callbacks) task.terminalProcess = process - // Implement command execution timeout (skip if timeout is 0). - if (commandExecutionTimeout > 0) { - let timeoutId: NodeJS.Timeout | undefined - let isTimedOut = false + // Dual-timeout logic: + // - Agent timeout: transitions the command to background (continues running) + // - User timeout: aborts the command (kills it) + // Both timers run independently — the user timeout remains active as a safety net + // even after the agent timeout moves the command to the background. + let agentTimeoutId: NodeJS.Timeout | undefined + let userTimeoutId: NodeJS.Timeout | undefined + let isUserTimedOut = false - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - isTimedOut = true - task.terminalProcess?.abort() - reject(new Error(`Command execution timed out after ${commandExecutionTimeout}ms`)) - }, commandExecutionTimeout) - }) + try { + const racers: Promise[] = [process] - try { - await Promise.race([process, timeoutPromise]) - } catch (error) { - if (isTimedOut) { - const status: CommandExecutionStatus = { executionId, status: "timeout" } - provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) - await task.say("error", t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds })) - task.didToolFailInCurrentTurn = true - task.terminalProcess = undefined - - return [ - false, - `The command was terminated after exceeding a user-configured ${commandExecutionTimeoutSeconds}s timeout. Do not try to re-run the command.`, - ] - } - throw error - } finally { - if (timeoutId) { - clearTimeout(timeoutId) - } - - task.terminalProcess = undefined + // Agent timeout: transition to background (command keeps running) + if (agentTimeout > 0) { + racers.push( + new Promise((resolve) => { + agentTimeoutId = setTimeout(() => { + runInBackground = true + process.continue() + task.supersedePendingAsk() + resolve() + }, agentTimeout) + }), + ) } - } else { - // No timeout - just wait for the process to complete. - try { - await process - } finally { - task.terminalProcess = undefined + + // User timeout: abort the command (existing behavior) + if (commandExecutionTimeout > 0) { + racers.push( + new Promise((_, reject) => { + userTimeoutId = setTimeout(() => { + isUserTimedOut = true + task.terminalProcess?.abort() + reject(new Error(`Command execution timed out after ${commandExecutionTimeout}ms`)) + }, commandExecutionTimeout) + }), + ) } + + await Promise.race(racers) + } catch (error) { + if (isUserTimedOut) { + const status: CommandExecutionStatus = { executionId, status: "timeout" } + provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) }) + await task.say("error", t("common:errors:command_timeout", { seconds: commandExecutionTimeoutSeconds })) + task.didToolFailInCurrentTurn = true + task.terminalProcess = undefined + + return [ + false, + `The command was terminated after exceeding a user-configured ${commandExecutionTimeoutSeconds}s timeout. Do not try to re-run the command.`, + ] + } + throw error + } finally { + clearTimeout(agentTimeoutId) + clearTimeout(userTimeoutId) + task.terminalProcess = undefined } if (shellIntegrationError) { diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 491ba69361..d2dd9907b1 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -66,6 +66,7 @@ export const toolParamNames = [ "new_string", // search_replace and edit_file parameter "replace_all", // edit tool parameter for replacing all occurrences "expected_replacements", // edit_file parameter for multiple occurrences + "timeout", // execute_command parameter "artifact_id", // read_command_output parameter "search", // read_command_output parameter for grep-like search "offset", // read_command_output and read_file parameter @@ -93,7 +94,7 @@ export type NativeToolArgs = { read_file: import("@roo-code/types").ReadFileToolParams read_command_output: { artifact_id: string; search?: string; offset?: number; limit?: number } attempt_completion: { result: string } - execute_command: { command: string; cwd?: string } + execute_command: { command: string; cwd?: string; timeout?: number | null } apply_diff: { path: string; diff: string } edit: { file_path: string; old_string: string; new_string: string; replace_all?: boolean } search_and_replace: { file_path: string; old_string: string; new_string: string; replace_all?: boolean } @@ -168,7 +169,7 @@ export interface McpToolUse { export interface ExecuteCommandToolUse extends ToolUse<"execute_command"> { name: "execute_command" // Pick, "command"> makes "command" required, but Partial<> makes it optional - params: Partial, "command" | "cwd">> + params: Partial, "command" | "cwd" | "timeout">> } export interface ReadFileToolUse extends ToolUse<"read_file"> { From 0d5b932d2e74afe1b8b321e1d3f08099a23ca944 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:02:44 -0700 Subject: [PATCH 031/109] feat: disable apply_diff and enable edit tool for Vertex and Gemini providers (#11619) * feat: disable apply_diff and enable edit tool for Vertex and Gemini providers * feat: disable apply_diff and enable edit tool for Anthropic provider * feat: disable apply_diff and enable edit tool for Anthropic Vertex provider * fix: remove out-of-scope Anthropic/Anthropic-Vertex changes The PR scope is Gemini and Vertex providers only. Reverting the Anthropic and Anthropic-Vertex tool preference changes that were not part of the stated scope. --------- Co-authored-by: Roo Code --- src/api/providers/__tests__/gemini.spec.ts | 14 ++++++++++++ src/api/providers/__tests__/vertex.spec.ts | 26 ++++++++++++++++++++++ src/api/providers/gemini.ts | 7 ++++++ src/api/providers/vertex.ts | 9 +++++++- 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index 8c2ee87a78..47ee79dd0d 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -173,6 +173,20 @@ describe("GeminiHandler", () => { const modelInfo = invalidHandler.getModel() expect(modelInfo.id).toBe(geminiDefaultModelId) // Default model }) + + it("should exclude apply_diff and include edit in tool preferences", () => { + const modelInfo = handler.getModel() + expect(modelInfo.info.excludedTools).toContain("apply_diff") + expect(modelInfo.info.includedTools).toContain("edit") + }) + + it("should not duplicate tool entries if already present", () => { + const modelInfo = handler.getModel() + const excludedCount = modelInfo.info.excludedTools!.filter((t: string) => t === "apply_diff").length + const includedCount = modelInfo.info.includedTools!.filter((t: string) => t === "edit").length + expect(excludedCount).toBe(1) + expect(includedCount).toBe(1) + }) }) describe("calculateCost", () => { diff --git a/src/api/providers/__tests__/vertex.spec.ts b/src/api/providers/__tests__/vertex.spec.ts index 1420b05c7a..3361176f1f 100644 --- a/src/api/providers/__tests__/vertex.spec.ts +++ b/src/api/providers/__tests__/vertex.spec.ts @@ -137,5 +137,31 @@ describe("VertexHandler", () => { expect(modelInfo.info.maxTokens).toBe(8192) expect(modelInfo.info.contextWindow).toBe(1048576) }) + + it("should exclude apply_diff and include edit in tool preferences", () => { + const testHandler = new VertexHandler({ + apiModelId: "gemini-2.0-flash-001", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const modelInfo = testHandler.getModel() + expect(modelInfo.info.excludedTools).toContain("apply_diff") + expect(modelInfo.info.includedTools).toContain("edit") + }) + + it("should not duplicate tool entries if already present", () => { + const testHandler = new VertexHandler({ + apiModelId: "gemini-2.0-flash-001", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const modelInfo = testHandler.getModel() + const excludedCount = modelInfo.info.excludedTools!.filter((t: string) => t === "apply_diff").length + const includedCount = modelInfo.info.includedTools!.filter((t: string) => t === "edit").length + expect(excludedCount).toBe(1) + expect(includedCount).toBe(1) + }) }) }) diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index db8041b980..a49073ea33 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -359,6 +359,13 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl defaultTemperature: info.defaultTemperature ?? 1, }) + // Gemini models perform better with the edit tool instead of apply_diff. + info = { + ...info, + excludedTools: [...new Set([...(info.excludedTools || []), "apply_diff"])], + includedTools: [...new Set([...(info.includedTools || []), "edit"])], + } + // The `:thinking` suffix indicates that the model is a "Hybrid" // reasoning model and that reasoning is required to be enabled. // The actual model ID honored by Gemini's API does not have this diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index f470b88e9b..fd318d9b19 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -15,7 +15,7 @@ export class VertexHandler extends GeminiHandler implements SingleCompletionHand override getModel() { const modelId = this.options.apiModelId let id = modelId && modelId in vertexModels ? (modelId as VertexModelId) : vertexDefaultModelId - const info: ModelInfo = vertexModels[id] + let info: ModelInfo = vertexModels[id] const params = getModelParams({ format: "gemini", modelId: id, @@ -24,6 +24,13 @@ export class VertexHandler extends GeminiHandler implements SingleCompletionHand defaultTemperature: info.defaultTemperature ?? 1, }) + // Vertex Gemini models perform better with the edit tool instead of apply_diff. + info = { + ...info, + excludedTools: [...new Set([...(info.excludedTools || []), "apply_diff"])], + includedTools: [...new Set([...(info.includedTools || []), "edit"])], + } + // The `:thinking` suffix indicates that the model is a "Hybrid" // reasoning model and that reasoning is required to be enabled. // The actual model ID honored by Gemini's API does not have this From 5db2062d0c3bf3c2b0a001501be8f5912cf28f2a Mon Sep 17 00:00:00 2001 From: Chiranjeevisantosh Madugundi Date: Fri, 20 Feb 2026 00:22:20 -0600 Subject: [PATCH 032/109] =?UTF-8?q?feat:=20show=20aggregated=20+/=E2=88=92?= =?UTF-8?q?=20line=20counts=20in=20FileChangesPanel=20header=20(#11618)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: show aggregated +/− line counts in FileChangesPanel header * feat(FileChangesPanel): show merged diff relative to final file state * fix(webview): restrict readFileContent to paths inside the workspace * fix: add workspace-boundary validation to readFileContent to prevent path traversal * fix(tests): mock isPathOutsideWorkspace in readFileContent spec * fix(tests): mock isPathOutsideWorkspace in readFileContent spec * fix: use path.resolve/path.sep in readFileContent test mock for cross-platform compatibility The isPathOutsideWorkspace mock used hardcoded Unix-style path comparisons (/mock/workspace with forward slashes), which fails on Windows where path.resolve() produces paths with drive letters (C:\mock\workspace\...). Replace manual string normalization with path.resolve() and path.sep so the mock behaves correctly on both Windows and Unix. --------- Co-authored-by: Roo Code --- packages/types/src/vscode-extension-host.ts | 6 + src/core/tools/ApplyDiffTool.ts | 2 + src/core/tools/ApplyPatchTool.ts | 1 + ...viewMessageHandler.readFileContent.spec.ts | 210 ++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 39 ++++ .../src/__tests__/FileChangesPanel.spec.tsx | 26 ++- .../src/components/chat/FileChangesPanel.tsx | 78 ++++++- .../chat/utils/fileChangesFromMessages.ts | 3 + 8 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 src/core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 2d8c52cb04..15edd13db4 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -103,7 +103,10 @@ export interface ExtensionMessage { | "branchWorktreeIncludeResult" | "folderSelected" | "skills" + | "fileContent" text?: string + /** For fileContent: { path, content, error? } */ + fileContent?: { path: string; content: string | null; error?: string } payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any checkpointWarning?: { type: "WAIT_TIMEOUT" | "INIT_TIMEOUT" @@ -442,6 +445,7 @@ export interface WebviewMessage { | "openImage" | "saveImage" | "openFile" + | "readFileContent" | "openMention" | "cancelTask" | "cancelAutoApproval" @@ -783,6 +787,8 @@ export interface ClineSayTool { matchCount?: number diff?: string content?: string + // Original file content before first edit (for merged diff display in FileChangesPanel) + originalContent?: string // Unified diff statistics computed by the extension diffStats?: { added: number; removed: number } regex?: string diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 5ca7002ff2..3b664b3bd2 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -150,6 +150,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { ...sharedMessageProps, diff: diffContent, content: unifiedPatch, + originalContent, diffStats, isProtected: isWriteProtected, } satisfies ClineSayTool) @@ -194,6 +195,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { ...sharedMessageProps, diff: diffContent, content: unifiedPatch, + originalContent, diffStats, isProtected: isWriteProtected, } satisfies ClineSayTool) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index a9ad591e4a..3f3295404b 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -341,6 +341,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { tool: "appliedDiff", path: getReadablePath(task.cwd, relPath), diff: sanitizedDiff, + originalContent, isOutsideWorkspace, } diff --git a/src/core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts new file mode 100644 index 0000000000..00230c077a --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts @@ -0,0 +1,210 @@ +// npx vitest core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts + +import { describe, it, expect, vi, beforeEach } from "vitest" + +vi.mock("../../../api/providers/fetchers/modelCache") + +vi.mock("vscode", () => ({ + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + showTextDocument: vi.fn(), + }, + workspace: { + workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], + openTextDocument: vi.fn().mockResolvedValue({}), + }, +})) + +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string) => key), +})) + +vi.mock("fs/promises", () => { + const readFile = vi.fn().mockResolvedValue("file content here") + return { + default: { + rm: vi.fn(), + mkdir: vi.fn(), + readFile, + writeFile: vi.fn(), + }, + rm: vi.fn(), + mkdir: vi.fn(), + readFile, + writeFile: vi.fn(), + } +}) + +vi.mock("../../../utils/fs") +vi.mock("../../../utils/path") +vi.mock("../../../utils/globalContext") + +vi.mock("../../../utils/pathUtils", () => ({ + isPathOutsideWorkspace: vi.fn((filePath: string) => { + const nodePath = require("path") + const normalized = nodePath.resolve(filePath) + const workspaceRoot = nodePath.resolve("/mock/workspace") + // Path is inside workspace if it equals or is under workspace root + if (normalized === workspaceRoot) return false + if (normalized.startsWith(workspaceRoot + nodePath.sep)) return false + return true + }), +})) + +vi.mock("../../mentions/resolveImageMentions", () => ({ + resolveImageMentions: vi.fn(async ({ text, images }: { text: string; images?: string[] }) => ({ + text, + images: [...(images ?? [])], + })), +})) + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" +import * as fs from "fs/promises" + +const MOCK_CWD = "/mock/workspace/project" + +const mockProvider = { + getState: vi.fn(), + postMessageToWebview: vi.fn(), + customModesManager: { + getCustomModes: vi.fn(), + deleteCustomMode: vi.fn(), + }, + context: { + extensionPath: "/mock/extension/path", + globalStorageUri: { fsPath: "/mock/global/storage" }, + }, + contextProxy: { + context: { + extensionPath: "/mock/extension/path", + globalStorageUri: { fsPath: "/mock/global/storage" }, + }, + setValue: vi.fn(), + getValue: vi.fn(), + }, + log: vi.fn(), + postStateToWebview: vi.fn(), + getCurrentTask: vi.fn().mockReturnValue({ cwd: MOCK_CWD }), + getTaskWithId: vi.fn(), + createTaskWithHistoryItem: vi.fn(), + cwd: MOCK_CWD, +} as unknown as ClineProvider + +describe("webviewMessageHandler - readFileContent path traversal prevention", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(fs.readFile).mockResolvedValue("file content here") + vi.mocked(mockProvider.getCurrentTask).mockReturnValue({ cwd: MOCK_CWD } as any) + }) + + it("allows reading a file within the workspace using a relative path", async () => { + await webviewMessageHandler(mockProvider, { + type: "readFileContent", + text: "src/index.ts", + }) + + expect(fs.readFile).toHaveBeenCalled() + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "fileContent", + fileContent: expect.objectContaining({ + path: "src/index.ts", + content: "file content here", + }), + }), + ) + }) + + it("blocks path traversal with ../", async () => { + await webviewMessageHandler(mockProvider, { + type: "readFileContent", + text: "../../../etc/passwd", + }) + + expect(fs.readFile).not.toHaveBeenCalled() + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "fileContent", + fileContent: expect.objectContaining({ + path: "../../../etc/passwd", + content: null, + error: "Path is outside workspace", + }), + }), + ) + }) + + it("blocks absolute paths outside the workspace", async () => { + await webviewMessageHandler(mockProvider, { + type: "readFileContent", + text: "/etc/shadow", + }) + + expect(fs.readFile).not.toHaveBeenCalled() + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "fileContent", + fileContent: expect.objectContaining({ + path: "/etc/shadow", + content: null, + error: "Path is outside workspace", + }), + }), + ) + }) + + it("blocks traversal disguised in the middle of a path", async () => { + await webviewMessageHandler(mockProvider, { + type: "readFileContent", + text: "src/../../../../etc/passwd", + }) + + expect(fs.readFile).not.toHaveBeenCalled() + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "fileContent", + fileContent: expect.objectContaining({ + content: null, + error: "Path is outside workspace", + }), + }), + ) + }) + + it("returns error when no path is provided", async () => { + await webviewMessageHandler(mockProvider, { + type: "readFileContent", + text: "", + }) + + expect(fs.readFile).not.toHaveBeenCalled() + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "fileContent", + fileContent: expect.objectContaining({ + content: null, + error: "No path provided", + }), + }), + ) + }) + + it("allows reading a file using an absolute path within the workspace", async () => { + await webviewMessageHandler(mockProvider, { + type: "readFileContent", + text: `${MOCK_CWD}/src/index.ts`, + }) + + expect(fs.readFile).toHaveBeenCalled() + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "fileContent", + fileContent: expect.objectContaining({ + content: "file content here", + }), + }), + ) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 6d17517164..19d7e5adb3 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -63,6 +63,7 @@ import { openMention } from "../mentions" import { resolveImageMentions } from "../mentions/resolveImageMentions" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { getWorkspacePath } from "../../utils/path" +import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { Mode, defaultModeSlug } from "../../shared/modes" import { getModels, flushModels } from "../../api/providers/fetchers/modelCache" import { GetModelsOptions } from "../../shared/api" @@ -1142,6 +1143,44 @@ export const webviewMessageHandler = async ( } openFile(filePath, message.values as { create?: boolean; content?: string; line?: number }) break + case "readFileContent": { + const relPath = message.text || "" + if (!relPath) { + provider.postMessageToWebview({ + type: "fileContent", + fileContent: { path: relPath, content: null, error: "No path provided" }, + }) + break + } + try { + const cwd = getCurrentCwd() + if (!cwd) { + provider.postMessageToWebview({ + type: "fileContent", + fileContent: { path: relPath, content: null, error: "No workspace path available" }, + }) + break + } + const absPath = path.resolve(cwd, relPath) + // Workspace-boundary validation: prevent path traversal attacks + if (isPathOutsideWorkspace(absPath)) { + provider.postMessageToWebview({ + type: "fileContent", + fileContent: { path: relPath, content: null, error: "Path is outside workspace" }, + }) + break + } + const content = await fs.readFile(absPath, "utf-8") + provider.postMessageToWebview({ type: "fileContent", fileContent: { path: relPath, content } }) + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err) + provider.postMessageToWebview({ + type: "fileContent", + fileContent: { path: relPath, content: null, error: errorMsg }, + }) + } + break + } case "openMention": openMention(getCurrentCwd(), message.text) break diff --git a/webview-ui/src/__tests__/FileChangesPanel.spec.tsx b/webview-ui/src/__tests__/FileChangesPanel.spec.tsx index b28102b1fe..9c5535fb7b 100644 --- a/webview-ui/src/__tests__/FileChangesPanel.spec.tsx +++ b/webview-ui/src/__tests__/FileChangesPanel.spec.tsx @@ -44,7 +44,11 @@ vi.mock("@src/components/common/CodeAccordian", () => ({ ), })) -function createFileEditMessage(path: string, diff: string): ClineMessage { +function createFileEditMessage( + path: string, + diff: string, + diffStats?: { added: number; removed: number }, +): ClineMessage { return { type: "ask", ask: "tool", @@ -55,6 +59,7 @@ function createFileEditMessage(path: string, diff: string): ClineMessage { tool: "appliedDiff", path, diff, + ...(diffStats && { diffStats }), }), } } @@ -172,4 +177,23 @@ describe("FileChangesPanel", () => { fireEvent.click(accordianToggle) expect(accordianToggle).toHaveTextContent("expanded") }) + + it("hides aggregate stats when no diffStats are present", () => { + const messages = [createFileEditMessage("src/a.ts", "diff a"), createFileEditMessage("src/b.ts", "diff b")] + renderPanel(messages) + + expect(screen.queryByTestId("total-added")).not.toBeInTheDocument() + expect(screen.queryByTestId("total-removed")).not.toBeInTheDocument() + }) + + it("shows aggregated + and - totals in the header when diffStats are present", () => { + const messages = [ + createFileEditMessage("src/a.ts", "diff a", { added: 3, removed: 1 }), + createFileEditMessage("src/b.ts", "diff b", { added: 2, removed: 5 }), + ] + renderPanel(messages) + + expect(screen.getByTestId("total-added")).toHaveTextContent("+5") + expect(screen.getByTestId("total-removed")).toHaveTextContent("-6") + }) }) diff --git a/webview-ui/src/components/chat/FileChangesPanel.tsx b/webview-ui/src/components/chat/FileChangesPanel.tsx index 8a4eb016cc..7dec194e0c 100644 --- a/webview-ui/src/components/chat/FileChangesPanel.tsx +++ b/webview-ui/src/components/chat/FileChangesPanel.tsx @@ -1,8 +1,9 @@ -import { memo, useEffect, useMemo, useState, useCallback } from "react" +import { memo, useEffect, useMemo, useState, useCallback, useRef } from "react" import { useTranslation } from "react-i18next" import { ChevronDown, ChevronRight, FileDiff } from "lucide-react" +import { createTwoFilesPatch } from "diff" -import type { ClineMessage } from "@roo-code/types" +import type { ClineMessage, ExtensionMessage } from "@roo-code/types" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui" import { cn } from "@/lib/utils" @@ -20,10 +21,14 @@ const FileChangesPanel = memo(({ clineMessages, className }: FileChangesPanelPro const { t } = useTranslation() const [panelExpanded, setPanelExpanded] = useState(false) const [expandedPaths, setExpandedPaths] = useState>(new Set()) + const [finalContentByPath, setFinalContentByPath] = useState>({}) + const pendingPathsRef = useRef>(new Set()) - // Reset expanded file rows when switching to a different task (clineMessages identity change) + // Reset expanded file rows and final content cache when switching to a different task useEffect(() => { setExpandedPaths(new Set()) + setFinalContentByPath({}) + pendingPathsRef.current = new Set() }, [clineMessages]) const fileChanges = useMemo(() => fileChangesFromMessages(clineMessages), [clineMessages]) @@ -40,6 +45,17 @@ const FileChangesPanel = memo(({ clineMessages, className }: FileChangesPanelPro return map }, [fileChanges]) + // Aggregate total lines added/removed across all files for the panel header + const totalStats = useMemo(() => { + return fileChanges.reduce( + (acc, e) => ({ + added: acc.added + (e.diffStats?.added ?? 0), + removed: acc.removed + (e.diffStats?.removed ?? 0), + }), + { added: 0, removed: 0 }, + ) + }, [fileChanges]) + const togglePath = useCallback((path: string) => { setExpandedPaths((prev) => { const next = new Set(prev) @@ -49,6 +65,38 @@ const FileChangesPanel = memo(({ clineMessages, className }: FileChangesPanelPro }) }, []) + // Request final file content when a row is expanded and we have originalContent + useEffect(() => { + for (const path of expandedPaths) { + const entries = byPath.get(path) + if (!entries?.length) continue + const originalContent = entries[0].originalContent + const lookupPath = path.startsWith("./") ? path.slice(2) : path + if ( + originalContent !== undefined && + !(lookupPath in finalContentByPath) && + !pendingPathsRef.current.has(lookupPath) + ) { + pendingPathsRef.current.add(lookupPath) + vscode.postMessage({ type: "readFileContent", text: lookupPath }) + } + } + }, [expandedPaths, byPath, finalContentByPath]) + + // Listen for fileContent responses + useEffect(() => { + const handler = (event: MessageEvent) => { + const message: ExtensionMessage = event.data + if (message.type === "fileContent" && message.fileContent?.path != null) { + const fc = message.fileContent + pendingPathsRef.current.delete(fc.path) + setFinalContentByPath((prev) => ({ ...prev, [fc.path]: fc.content ?? null })) + } + } + window.addEventListener("message", handler) + return () => window.removeEventListener("message", handler) + }, []) + if (fileChanges.length === 0) return null const fileCount = byPath.size @@ -69,12 +117,30 @@ const FileChangesPanel = memo(({ clineMessages, className }: FileChangesPanelPro {t("chat:fileChangesInConversation.header", { count: fileCount })} + {totalStats.added > 0 || totalStats.removed > 0 ? ( +
+ + +{totalStats.added} + + + -{totalStats.removed} + +
+ ) : null}
{Array.from(byPath.entries()).map(([path, entries]) => { - // If multiple edits to same file, concatenate diffs with a separator - const combinedDiff = entries.map((e) => e.diff).join("\n\n") + const originalContent = entries[0].originalContent + const lookupPath = path.startsWith("./") ? path.slice(2) : path + const finalContent = finalContentByPath[lookupPath] + const hasMergedDiff = + originalContent !== undefined && finalContent != null && finalContent !== "" + const displayDiff = hasMergedDiff + ? createTwoFilesPatch(path, path, originalContent, finalContent) + : entries.map((e) => e.diff).join("\n\n") const combinedStats = entries.reduce( (acc, e) => ({ added: acc.added + (e.diffStats?.added ?? 0), @@ -87,7 +153,7 @@ const FileChangesPanel = memo(({ clineMessages, className }: FileChangesPanelPro
togglePath(path)} diff --git a/webview-ui/src/components/chat/utils/fileChangesFromMessages.ts b/webview-ui/src/components/chat/utils/fileChangesFromMessages.ts index 6b77833e9d..738305ad15 100644 --- a/webview-ui/src/components/chat/utils/fileChangesFromMessages.ts +++ b/webview-ui/src/components/chat/utils/fileChangesFromMessages.ts @@ -8,6 +8,8 @@ export interface FileChangeEntry { path: string diff: string diffStats?: { added: number; removed: number } + /** Original file content before first edit (for merged diff display) */ + originalContent?: string } /** @@ -56,6 +58,7 @@ export function fileChangesFromMessages(messages: ClineMessage[] | undefined): F path: tool.path, diff, diffStats: tool.diffStats, + originalContent: tool.originalContent, }) } } From 492006d53d45a543930e8a03731afe260ff78064 Mon Sep 17 00:00:00 2001 From: Sazid Al Bayazid Date: Fri, 20 Feb 2026 12:55:22 +0600 Subject: [PATCH 033/109] feat: add visual feedback to copy button in task actions (#11403) * feat: add visual feedback to copy button in task actions The copy button now shows a checkmark for 2 seconds after copying to provide visual feedback. Fixes #11401 * fix: change Check icon import name in TaskActions and update test - Fix Check icon import name from Check to CheckIcon for consistency in TaskActions.tsx - Add test to verify check icon is shown when showCopyFeedback is true - Mock useCopyToClipboard hook in TaskActions.spec.tsx to test copy button functionality This change resolves the import inconsistency and adds a test to ensure the copy button correctly shows a check icon after successful copy. --- .../src/components/chat/TaskActions.tsx | 6 ++-- .../chat/__tests__/TaskActions.spec.tsx | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/webview-ui/src/components/chat/TaskActions.tsx b/webview-ui/src/components/chat/TaskActions.tsx index c7401425f6..7646f4bc0e 100644 --- a/webview-ui/src/components/chat/TaskActions.tsx +++ b/webview-ui/src/components/chat/TaskActions.tsx @@ -9,7 +9,7 @@ import { useExtensionState } from "@/context/ExtensionStateContext" import { DeleteTaskDialog } from "../history/DeleteTaskDialog" import { ShareButton } from "./ShareButton" -import { CopyIcon, DownloadIcon, Trash2Icon, FileJsonIcon, MessageSquareCodeIcon } from "lucide-react" +import { CopyIcon, CheckIcon, DownloadIcon, Trash2Icon, FileJsonIcon, MessageSquareCodeIcon } from "lucide-react" import { LucideIconButton } from "./LucideIconButton" interface TaskActionsProps { @@ -20,7 +20,7 @@ interface TaskActionsProps { export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { const [deleteTaskId, setDeleteTaskId] = useState(null) const { t } = useTranslation() - const { copyWithFeedback } = useCopyToClipboard() + const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard() const { debug } = useExtensionState() return ( @@ -33,7 +33,7 @@ export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { {item?.task && ( copyWithFeedback(item.task, e)} /> diff --git a/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx index d7a53ccacc..4ba0853cd8 100644 --- a/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskActions.spec.tsx @@ -3,6 +3,7 @@ import type { HistoryItem } from "@roo-code/types" import { render, screen, fireEvent } from "@/utils/test-utils" import { vscode } from "@/utils/vscode" import { useExtensionState } from "@/context/ExtensionStateContext" +import { useCopyToClipboard } from "@/utils/clipboard" import { TaskActions } from "../TaskActions" @@ -24,8 +25,14 @@ vi.mock("@/context/ExtensionStateContext", () => ({ useExtensionState: vi.fn(), })) +// Mock the useCopyToClipboard hook +vi.mock("@/utils/clipboard", () => ({ + useCopyToClipboard: vi.fn(), +})) + const mockPostMessage = vi.mocked(vscode.postMessage) const mockUseExtensionState = vi.mocked(useExtensionState) +const mockUseCopyToClipboard = vi.mocked(useCopyToClipboard) // Mock react-i18next vi.mock("react-i18next", () => ({ @@ -87,6 +94,10 @@ describe("TaskActions", () => { organizationName: "Test Organization", }, } as any) + mockUseCopyToClipboard.mockReturnValue({ + copyWithFeedback: vi.fn(), + showCopyFeedback: false, + }) }) describe("Share Button Visibility", () => { @@ -353,6 +364,29 @@ describe("TaskActions", () => { const deleteButton = screen.queryByLabelText("Delete Task (Shift + Click to skip confirmation)") expect(deleteButton).not.toBeInTheDocument() }) + + it("shows check icon when showCopyFeedback is true", () => { + // First render with showCopyFeedback: false (default) + const { rerender } = render() + + // Verify copy icon is shown initially + const copyButton = screen.getByLabelText("Copy") + expect(copyButton).toBeInTheDocument() + expect(copyButton.querySelector("svg.lucide-copy")).toBeInTheDocument() + expect(copyButton.querySelector("svg.lucide-check")).not.toBeInTheDocument() + + // Mock showCopyFeedback: true to simulate successful copy + mockUseCopyToClipboard.mockReturnValue({ + copyWithFeedback: vi.fn(), + showCopyFeedback: true, + }) + + rerender() + + // Verify check icon is shown after successful copy + expect(copyButton.querySelector("svg.lucide-check")).toBeInTheDocument() + expect(copyButton.querySelector("svg.lucide-copy")).not.toBeInTheDocument() + }) }) describe("Button States", () => { From 3a7a01f2f744e1ec49ece53335ead86e0591616e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:56:40 -0700 Subject: [PATCH 034/109] Changeset version bump (#11623) changeset version bump Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/sly-candles-hide.md | 5 ----- CHANGELOG.md | 6 ++++++ src/package.json | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) delete mode 100644 .changeset/sly-candles-hide.md diff --git a/.changeset/sly-candles-hide.md b/.changeset/sly-candles-hide.md deleted file mode 100644 index be720c0250..0000000000 --- a/.changeset/sly-candles-hide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -Fix OpenAI Codex and OpenAI Native stream parsing for done-only and `content_part` events, including duplicate-text guards when deltas are already streamed. diff --git a/CHANGELOG.md b/CHANGELOG.md index 494d88331b..20e350c96d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Roo Code Changelog +## 3.50.1 + +### Patch Changes + +- Fix OpenAI Codex and OpenAI Native stream parsing for done-only and `content_part` events, including duplicate-text guards when deltas are already streamed. + ## 3.50.0 ### Minor Changes diff --git a/src/package.json b/src/package.json index 3b7b28ebf0..0a6b86833c 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.50.0", + "version": "3.50.1", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", From 27095553ca362d10e2d2077d416915d5f806970e Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 00:06:52 -0700 Subject: [PATCH 035/109] fix(bedrock): enable prompt caching for custom ARN and default to ON (#11373) * fix(bedrock): enable prompt caching for custom ARN and default to ON - Set supportsPromptCache to true for custom-arn model info in useSelectedModel.ts - Change awsUsePromptCache default from false to true using nullish coalescing - Add tests for custom-arn prompt caching support Closes #10846 * fix(bedrock): align backend awsUsePromptCache default with UI (?? true) The backend treated undefined awsUsePromptCache as falsy (OFF) while the UI checkbox defaulted to true via nullish coalescing. This caused the UI to show prompt caching as ON but the backend to keep it OFF for new users. Apply the same ?? true default in the backend so both sides agree. --------- Co-authored-by: Roo Code --- src/api/providers/__tests__/bedrock.spec.ts | 52 +++++++++++++++++++ src/api/providers/bedrock.ts | 4 +- .../components/settings/providers/Bedrock.tsx | 2 +- .../hooks/__tests__/useSelectedModel.spec.ts | 46 ++++++++++++++++ .../components/ui/hooks/useSelectedModel.ts | 2 +- 5 files changed, 103 insertions(+), 3 deletions(-) diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts index 0ea487eb44..975e38af12 100644 --- a/src/api/providers/__tests__/bedrock.spec.ts +++ b/src/api/providers/__tests__/bedrock.spec.ts @@ -1275,4 +1275,56 @@ describe("AwsBedrockHandler", () => { expect(mockCaptureException).toHaveBeenCalled() }) }) + + describe("prompt cache default behavior", () => { + beforeEach(() => { + mockConverseStreamCommand.mockReset() + }) + + // System prompt must exceed minTokensPerCachePoint (1024) for cache points to be placed + const longSystemPrompt = "You are a helpful assistant. ".repeat(200) + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + + it("should enable prompt caching by default when awsUsePromptCache is undefined", async () => { + const defaultHandler = new AwsBedrockHandler({ + apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + awsAccessKey: "test-access-key", + awsSecretKey: "test-secret-key", + awsRegion: "us-east-1", + // awsUsePromptCache is intentionally omitted (undefined) + }) + + const generator = defaultHandler.createMessage(longSystemPrompt, messages) + await generator.next() // Start the generator + + expect(mockConverseStreamCommand).toHaveBeenCalled() + const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + + // System content should include a cachePoint entry since prompt caching defaults to ON + const systemBlocks = commandArg.system + const hasCachePoint = systemBlocks?.some((block: any) => block.cachePoint !== undefined) + expect(hasCachePoint).toBe(true) + }) + + it("should disable prompt caching when awsUsePromptCache is explicitly false", async () => { + const disabledHandler = new AwsBedrockHandler({ + apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + awsAccessKey: "test-access-key", + awsSecretKey: "test-secret-key", + awsRegion: "us-east-1", + awsUsePromptCache: false, + }) + + const generator = disabledHandler.createMessage(longSystemPrompt, messages) + await generator.next() // Start the generator + + expect(mockConverseStreamCommand).toHaveBeenCalled() + const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + + // System content should NOT include cachePoint since caching is explicitly disabled + const systemBlocks = commandArg.system + const hasCachePoint = systemBlocks?.some((block: any) => block.cachePoint !== undefined) + expect(hasCachePoint).toBe(false) + }) + }) }) diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 6bcf57d42a..3ceb251003 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -357,7 +357,9 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH }, ): ApiStream { const modelConfig = this.getModel() - const usePromptCache = Boolean(this.options.awsUsePromptCache && this.supportsAwsPromptCache(modelConfig)) + const usePromptCache = Boolean( + (this.options.awsUsePromptCache ?? true) && this.supportsAwsPromptCache(modelConfig), + ) const conversationId = messages.length > 0 diff --git a/webview-ui/src/components/settings/providers/Bedrock.tsx b/webview-ui/src/components/settings/providers/Bedrock.tsx index d9c69f8a8e..ed554f126d 100644 --- a/webview-ui/src/components/settings/providers/Bedrock.tsx +++ b/webview-ui/src/components/settings/providers/Bedrock.tsx @@ -198,7 +198,7 @@ export const Bedrock = ({ apiConfiguration, setApiConfigurationField, selectedMo {selectedModelInfo?.supportsPromptCache && ( <>
{t("settings:providers.enablePromptCaching")} diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index a8dead311f..2c24e4b565 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -496,6 +496,52 @@ describe("useSelectedModel", () => { }) }) + describe("bedrock provider with custom ARN", () => { + beforeEach(() => { + mockUseRouterModels.mockReturnValue({ + data: { + openrouter: {}, + requesty: {}, + litellm: {}, + }, + isLoading: false, + isError: false, + } as any) + + mockUseOpenRouterModelProviders.mockReturnValue({ + data: {}, + isLoading: false, + isError: false, + } as any) + }) + + it("should enable supportsPromptCache for custom-arn model", () => { + const apiConfiguration: ProviderSettings = { + apiProvider: "bedrock", + apiModelId: "custom-arn", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe("custom-arn") + expect(result.current.info?.supportsPromptCache).toBe(true) + }) + + it("should enable supportsImages for custom-arn model", () => { + const apiConfiguration: ProviderSettings = { + apiProvider: "bedrock", + apiModelId: "custom-arn", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe("custom-arn") + expect(result.current.info?.supportsImages).toBe(true) + }) + }) + describe("litellm provider", () => { beforeEach(() => { mockUseOpenRouterModelProviders.mockReturnValue({ diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 8a6e49e212..959deff2b7 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -182,7 +182,7 @@ function getSelectedModel({ if (id === "custom-arn") { return { id, - info: { maxTokens: 5000, contextWindow: 128_000, supportsPromptCache: false, supportsImages: true }, + info: { maxTokens: 5000, contextWindow: 128_000, supportsPromptCache: true, supportsImages: true }, } } From 618aa6652b3248acea3ae83e1bfc884e6e5b97d6 Mon Sep 17 00:00:00 2001 From: RussellZager Date: Thu, 19 Feb 2026 23:19:56 -0800 Subject: [PATCH 036/109] Inline terminal rendering parity with the VSCode Terminal (#11361) * fix: render ANSI escape codes in inline terminal output Fixes #10699 ## Problem The inline terminal output displayed raw ANSI bracket codes ([1m, [32m, etc.) instead of rendering colors and formatting. This was caused by: 1. Backend: strip-ansi removing the ESC byte but leaving bracket remnants 2. Frontend: CodeBlock/Shiki having no ANSI rendering capability ## Solution 1. Backend: Replace strip-ansi with targeted removal of only VSCode shell integration sequences (OSC 633/133), preserving standard ANSI SGR codes 2. Frontend: Add new TerminalOutput component using ansi-to-html library that converts ANSI sequences to styled HTML spans 3. Map ANSI colors to VSCode terminal theme CSS variables for consistent theming across light/dark themes ## Testing - Verified XSS prevention (escapeXML: true) - Verified theme compatibility - Added unit tests for both backend and frontend changes - Updated existing tests to expect ANSI codes in output Bundle size impact: ~3KB gzipped (ansi-to-html library) Co-authored-by: Zman771 <605281+Zman771@users.noreply.github.com> * fix: add eslint-disable for intentional ANSI control regex --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Zman771 <605281+Zman771@users.noreply.github.com> Co-authored-by: Russell Zager --- pnpm-lock.yaml | 20 ++++- src/integrations/terminal/TerminalProcess.ts | 55 +++++++++--- .../__tests__/TerminalProcess.test.ts | 87 +++++++++++++++++++ .../TerminalProcessExec.bash.spec.ts | 9 +- webview-ui/package.json | 5 +- .../src/components/chat/CommandExecution.tsx | 3 +- .../src/components/chat/TerminalOutput.tsx | 77 ++++++++++++++++ .../chat/__tests__/CommandExecution.spec.tsx | 33 ++++--- .../chat/__tests__/TerminalOutput.spec.tsx | 31 +++++++ 9 files changed, 289 insertions(+), 31 deletions(-) create mode 100644 src/integrations/terminal/__tests__/TerminalProcess.test.ts create mode 100644 webview-ui/src/components/chat/TerminalOutput.tsx create mode 100644 webview-ui/src/components/chat/__tests__/TerminalOutput.spec.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42a502c8c5..d95c2f0234 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1200,6 +1200,9 @@ importers: '@vscode/webview-ui-toolkit': specifier: ^1.4.0 version: 1.4.0(react@18.3.1) + ansi-to-html: + specifier: ^0.7.2 + version: 0.7.2 axios: specifier: ^1.12.0 version: 1.12.0 @@ -4936,6 +4939,11 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansi-to-html@0.7.2: + resolution: {integrity: sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==} + engines: {node: '>=8.0.0'} + hasBin: true + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -6229,6 +6237,9 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -6879,6 +6890,7 @@ packages: glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true global-agent@3.0.0: @@ -10030,7 +10042,7 @@ packages: tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -15162,6 +15174,10 @@ snapshots: ansi-styles@6.2.3: {} + ansi-to-html@0.7.2: + dependencies: + entities: 2.2.0 + any-promise@1.3.0: {} anymatch@3.1.3: @@ -16420,6 +16436,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@2.2.0: {} + entities@4.5.0: {} entities@6.0.0: {} diff --git a/src/integrations/terminal/TerminalProcess.ts b/src/integrations/terminal/TerminalProcess.ts index 7aba55173f..d202191b95 100644 --- a/src/integrations/terminal/TerminalProcess.ts +++ b/src/integrations/terminal/TerminalProcess.ts @@ -1,4 +1,3 @@ -import stripAnsi from "strip-ansi" import * as vscode from "vscode" import { inspect } from "util" @@ -245,7 +244,7 @@ export class TerminalProcess extends BaseTerminalProcess { // command is finished, we still want to consider it 'hot' in case // so that api request stalls to let diagnostics catch up"). this.stopHotTimer() - this.emit("completed", this.removeEscapeSequences(this.fullOutput)) + this.emit("completed", this.stripCursorSequences(this.removeVSCodeShellIntegration(this.fullOutput))) this.emit("continue") } @@ -311,7 +310,7 @@ export class TerminalProcess extends BaseTerminalProcess { outputToProcess = outputToProcess.slice(0, endIndex) // Clean and return output - return this.removeEscapeSequences(outputToProcess) + return this.stripCursorSequences(this.removeVSCodeShellIntegration(outputToProcess)) } private emitRemainingBufferIfListening() { @@ -375,17 +374,45 @@ export class TerminalProcess extends BaseTerminalProcess { return data.slice(contentStart, endIndex) } - // Removes ANSI escape sequences and VSCode-specific terminal control codes from output. - // While stripAnsi handles most ANSI codes, VSCode's shell integration adds custom - // escape sequences (OSC 633) that need special handling. These sequences control - // terminal features like marking command start/end and setting prompts. - // - // This method could be extended to handle other escape sequences, but any additions - // should be carefully considered to ensure they only remove control codes and don't - // alter the actual content or behavior of the output stream. - private removeEscapeSequences(str: string): string { - // eslint-disable-next-line no-control-regex - return stripAnsi(str.replace(/\x1b\]633;[^\x07]+\x07/gs, "").replace(/\x1b\]133;[^\x07]+\x07/gs, "")) + /** + * Remove only VSCode shell integration sequences (OSC 633/133) while + * preserving standard ANSI SGR escape codes for color/formatting. + * + * VSCode shell integration uses OSC 633 and OSC 133 sequences to mark + * prompt boundaries, command starts/ends, etc. These are not useful + * for inline display and should be stripped. + * + * Standard ANSI SGR sequences (e.g., \x1B[32m for green) are preserved + * so the frontend can render them as styled HTML. + */ + private removeVSCodeShellIntegration(text: string): string { + // Remove OSC 633 sequences: \x1B]633;....\x07 or \x1B]633;....\x1B\\ + // Remove OSC 133 sequences: \x1B]133;....\x07 or \x1B]133;....\x1B\\ + return ( + text + // eslint-disable-next-line no-control-regex + .replace(/\x1B\]633;[^\x07\x1B]*(?:\x07|\x1B\\)/g, "") + // eslint-disable-next-line no-control-regex + .replace(/\x1B\]133;[^\x07\x1B]*(?:\x07|\x1B\\)/g, "") + // eslint-disable-next-line no-control-regex + .replace(/\x1B\][0-9]+;[^\x07\x1B]*(?:\x07|\x1B\\)/g, "") + ) // Also remove other common OSC sequences that aren't color-related + } + + private stripCursorSequences(text: string): string { + return ( + text + // eslint-disable-next-line no-control-regex + .replace(/\x1B\[\d*[ABCDEFGHJ]/g, "") // Remove cursor movement: up, down, forward, back + // eslint-disable-next-line no-control-regex + .replace(/\x1B\[su/g, "") // Remove cursor position save/restore + // eslint-disable-next-line no-control-regex + .replace(/\x1B\[\d*[KJ]/g, "") // Remove erase in line/display + // eslint-disable-next-line no-control-regex + .replace(/\x1B\[\?25[hl]/g, "") // Remove cursor show/hide + // eslint-disable-next-line no-control-regex + .replace(/\x1B\[\d*;\d*r/g, "") // Remove scroll region + ) } /** diff --git a/src/integrations/terminal/__tests__/TerminalProcess.test.ts b/src/integrations/terminal/__tests__/TerminalProcess.test.ts new file mode 100644 index 0000000000..cf2e8dbb80 --- /dev/null +++ b/src/integrations/terminal/__tests__/TerminalProcess.test.ts @@ -0,0 +1,87 @@ +import * as vscode from "vscode" +import { TerminalProcess } from "../TerminalProcess" +import { Terminal } from "../Terminal" + +// Mock dependencies +vi.mock("vscode", () => ({ + window: { + createTerminal: vi.fn(), + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn(), + }), + }, + ThemeIcon: vi.fn(), +})) + +describe("TerminalProcess ANSI Handling", () => { + let terminalProcess: any // Using any to access private methods + let mockTerminal: any + + beforeEach(() => { + mockTerminal = { + shellIntegration: { + executeCommand: vi.fn(), + }, + name: "Test Terminal", + processId: Promise.resolve(123), + creationOptions: {}, + exitStatus: undefined, + state: { isInteractedWith: true }, + dispose: vi.fn(), + hide: vi.fn(), + show: vi.fn(), + sendText: vi.fn(), + } + + const terminalInfo = new Terminal(1, mockTerminal, "/tmp") + terminalProcess = new TerminalProcess(terminalInfo) + }) + + describe("removeVSCodeShellIntegration", () => { + it("should preserve standard ANSI SGR sequences", () => { + const input = "\x1B[32mgreen text\x1B[0m" + const result = terminalProcess.removeVSCodeShellIntegration(input) + expect(result).toBe("\x1B[32mgreen text\x1B[0m") + }) + + it("should remove OSC 633 sequences", () => { + const input = "\x1B]633;A\x07some text" + const result = terminalProcess.removeVSCodeShellIntegration(input) + expect(result).toBe("some text") + }) + + it("should remove OSC 133 sequences", () => { + const input = "\x1B]133;A\x07some text" + const result = terminalProcess.removeVSCodeShellIntegration(input) + expect(result).toBe("some text") + }) + + it("should handle mixed sequences", () => { + const input = "\x1B]633;C\x07\x1B[1m\x1B[32m✓\x1B[39m\x1B[22m test passed" + const result = terminalProcess.removeVSCodeShellIntegration(input) + expect(result).toBe("\x1B[1m\x1B[32m✓\x1B[39m\x1B[22m test passed") + }) + + it("should remove other OSC sequences", () => { + const input = "\x1B]0;Console Title\x07Content" + const result = terminalProcess.removeVSCodeShellIntegration(input) + expect(result).toBe("Content") + }) + }) + + describe("stripCursorSequences", () => { + it("should remove cursor movement codes", () => { + const input = "text\x1B[1Aup\x1B[2Kclear" + const result = terminalProcess.stripCursorSequences(input) + expect(result).toBe("textupclear") + }) + + it("should preserve colors while removing cursor codes", () => { + const input = "\x1B[31mred\x1B[1B\x1B[32mgreen" + const result = terminalProcess.stripCursorSequences(input) + expect(result).toBe("\x1B[31mred\x1B[32mgreen") + }) + }) +}) diff --git a/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts b/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts index e6b9483d0f..720fb427a5 100644 --- a/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts @@ -354,9 +354,12 @@ describe("TerminalProcess with Bash Command Output", () => { expect(capturedOutput).toBe("Red Text\r\n") } else { // Use printf instead of echo -e for more consistent behavior across platforms - // Note: ANSI escape sequences are stripped in the output processing - const { capturedOutput } = await testTerminalCommand('printf "\\033[31mRed Text\\033[0m\\n"', "Red Text\n") - expect(capturedOutput).toBe("Red Text\n") + // Note: ANSI escape sequences are now preserved in the output processing + const { capturedOutput } = await testTerminalCommand( + 'printf "\\033[31mRed Text\\033[0m\\n"', + "\x1B[31mRed Text\x1B[0m\n", + ) + expect(capturedOutput).toBe("\x1B[31mRed Text\x1B[0m\n") } }) diff --git a/webview-ui/package.json b/webview-ui/package.json index 7722f4119f..6da253ea33 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -35,6 +35,7 @@ "@tanstack/react-query": "^5.68.0", "@vscode/codicons": "^0.0.36", "@vscode/webview-ui-toolkit": "^1.4.0", + "ansi-to-html": "^0.7.2", "axios": "^1.12.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -55,8 +56,8 @@ "posthog-js": "^1.227.2", "pretty-bytes": "^7.0.0", "react": "^18.3.1", - "react-dom": "^18.3.1", "react-compiler-runtime": "^1.0.0", + "react-dom": "^18.3.1", "react-i18next": "^15.4.1", "react-icons": "^5.5.0", "react-markdown": "^9.0.3", @@ -84,7 +85,6 @@ "zod": "^3.25.61" }, "devDependencies": { - "babel-plugin-react-compiler": "^1.0.0", "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", "@testing-library/jest-dom": "^6.6.3", @@ -101,6 +101,7 @@ "@types/vscode-webview": "^1.57.5", "@vitejs/plugin-react": "^4.3.4", "@vitest/ui": "^3.2.3", + "babel-plugin-react-compiler": "^1.0.0", "identity-obj-proxy": "^3.0.0", "jsdom": "^26.0.0", "vite": "6.3.6", diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index e5763213cc..af1d72c6a5 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -18,6 +18,7 @@ import { Button, StandardTooltip } from "@src/components/ui" import CodeBlock from "@src/components/common/CodeBlock" import { CommandPatternSelector } from "./CommandPatternSelector" +import { TerminalOutput } from "./TerminalOutput" interface CommandPattern { pattern: string @@ -225,7 +226,7 @@ const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean; "max-h-0": !isExpanded, "max-h-[100%] mt-1 pt-1 border-t border-border/25": isExpanded, })}> - {output.length > 0 && } + {output.length > 0 && }
) diff --git a/webview-ui/src/components/chat/TerminalOutput.tsx b/webview-ui/src/components/chat/TerminalOutput.tsx new file mode 100644 index 0000000000..78684e3753 --- /dev/null +++ b/webview-ui/src/components/chat/TerminalOutput.tsx @@ -0,0 +1,77 @@ +import React, { useMemo } from "react" +import Convert from "ansi-to-html" + +interface TerminalOutputProps { + content: string + className?: string +} + +// Create a single converter instance with sensible defaults +const converter = new Convert({ + fg: "var(--vscode-terminal-foreground, #cccccc)", + bg: "var(--vscode-terminal-background, transparent)", + // Map ANSI colors to VSCode terminal color CSS variables for theme compatibility + colors: { + 0: "var(--vscode-terminal-ansiBlack, #000000)", + 1: "var(--vscode-terminal-ansiRed, #cd3131)", + 2: "var(--vscode-terminal-ansiGreen, #0dbc79)", + 3: "var(--vscode-terminal-ansiYellow, #e5e510)", + 4: "var(--vscode-terminal-ansiBlue, #2472c8)", + 5: "var(--vscode-terminal-ansiMagenta, #bc3fbc)", + 6: "var(--vscode-terminal-ansiCyan, #11a8cd)", + 7: "var(--vscode-terminal-ansiWhite, #e5e5e5)", + 8: "var(--vscode-terminal-ansiBrightBlack, #666666)", + 9: "var(--vscode-terminal-ansiBrightRed, #f14c4c)", + 10: "var(--vscode-terminal-ansiBrightGreen, #23d18b)", + 11: "var(--vscode-terminal-ansiBrightYellow, #f5f543)", + 12: "var(--vscode-terminal-ansiBrightBlue, #3b8eea)", + 13: "var(--vscode-terminal-ansiBrightMagenta, #d670d6)", + 14: "var(--vscode-terminal-ansiBrightCyan, #29b8db)", + 15: "var(--vscode-terminal-ansiBrightWhite, #e5e5e5)", + }, + escapeXML: true, // Prevent XSS — escape HTML entities in the content + newline: false, // We handle newlines ourselves via
+})
+
+/**
+ * Renders terminal output with ANSI color/formatting support.
+ *
+ * Uses ansi-to-html to convert ANSI escape sequences into styled  elements.
+ * Colors are mapped to VSCode terminal theme CSS variables for consistent theming.
+ *
+ * The component uses a monospace font and preserves whitespace/newlines
+ * to match terminal rendering behavior.
+ */
+export const TerminalOutput: React.FC = ({ content, className }) => {
+	const html = useMemo(() => {
+		try {
+			return converter.toHtml(content)
+		} catch {
+			// Fallback: if conversion fails, show raw text (stripped of ANSI)
+			// eslint-disable-next-line no-control-regex
+			return content.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "")
+		}
+	}, [content])
+
+	return (
+		
+	)
+}
diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx
index c8027edda3..f40987d269 100644
--- a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx
+++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx
@@ -23,6 +23,11 @@ vi.mock("../../common/CodeBlock", () => ({
 	default: ({ source }: { source: string }) => 
{source}
, })) +// Mock TerminalOutput +vi.mock("../TerminalOutput", () => ({ + TerminalOutput: ({ content }: { content: string }) =>
{content}
, +})) + vi.mock("../CommandPatternSelector", () => ({ CommandPatternSelector: ({ patterns, onAllowPatternChange, onDenyPatternChange }: any) => (
@@ -72,6 +77,9 @@ describe("CommandExecution", () => { const codeBlocks = screen.getAllByTestId("code-block") expect(codeBlocks[0]).toHaveTextContent("npm install") + + const terminalOutput = screen.getByTestId("terminal-output") + expect(terminalOutput).toHaveTextContent("Installing packages...") }) it("should render with custom icon and title", () => { @@ -230,7 +238,9 @@ Suggested patterns: npm, npm install, npm run` // First check that the command was parsed correctly const codeBlocks = screen.getAllByTestId("code-block") expect(codeBlocks[0]).toHaveTextContent("npm install") - expect(codeBlocks[1]).toHaveTextContent("Suggested patterns: npm, npm install, npm run") + + const terminalOutput = screen.getByTestId("terminal-output") + expect(terminalOutput).toHaveTextContent("Suggested patterns: npm, npm install, npm run") const selector = screen.getByTestId("command-pattern-selector") expect(selector).toBeInTheDocument() @@ -292,8 +302,10 @@ Output here` // Output should be visible when shell integration is disabled const codeBlocks = screen.getAllByTestId("code-block") - expect(codeBlocks).toHaveLength(2) // Command and output blocks - expect(codeBlocks[1]).toHaveTextContent("Output here") + expect(codeBlocks).toHaveLength(1) // Only command block + + const terminalOutput = screen.getByTestId("terminal-output") + expect(terminalOutput).toHaveTextContent("Output here") }) it("should handle undefined allowedCommands and deniedCommands", () => { @@ -563,9 +575,10 @@ Output: // Should show a command pattern expect(selector.textContent).toMatch(/wc/) - // The output should still be displayed in the code block - expect(codeBlocks.length).toBeGreaterThan(1) - expect(codeBlocks[1].textContent).toContain("45 total") + // The output should still be displayed + const terminalOutput = screen.getByTestId("terminal-output") + expect(terminalOutput).toBeInTheDocument() + expect(terminalOutput.textContent).toContain("45 total") }) it("should handle commands with zero output", () => { @@ -586,10 +599,10 @@ Output: // Should show a command pattern expect(selector.textContent).toMatch(/wc/) - // The output should still be displayed in the code block - const codeBlocks = screen.getAllByTestId("code-block") - expect(codeBlocks.length).toBeGreaterThan(1) - expect(codeBlocks[1]).toHaveTextContent("0 total") + // The output should still be displayed + const terminalOutput = screen.getByTestId("terminal-output") + expect(terminalOutput).toBeInTheDocument() + expect(terminalOutput).toHaveTextContent("0 total") }) }) }) diff --git a/webview-ui/src/components/chat/__tests__/TerminalOutput.spec.tsx b/webview-ui/src/components/chat/__tests__/TerminalOutput.spec.tsx new file mode 100644 index 0000000000..f6bfc0b6a4 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/TerminalOutput.spec.tsx @@ -0,0 +1,31 @@ +import { render } from "@testing-library/react"; +import { TerminalOutput } from "../TerminalOutput"; + +describe("TerminalOutput", () => { + it("renders plain text without ANSI codes", () => { + const { container } = render(); + expect(container.textContent).toBe("hello world"); + }); + + it("converts ANSI color codes to styled spans", () => { + const { container } = render( + + ); + const span = container.querySelector("span"); + expect(span).toBeTruthy(); + expect(span?.textContent).toBe("green"); + }); + + it("escapes HTML in terminal output to prevent XSS", () => { + const { container } = render( + alert("xss")'} /> + ); + expect(container.innerHTML).not.toContain("'); + }); + + it("handles empty content", () => { + const { container } = render(); + expect(container.textContent).toBe(""); + }); +}); From b34678488e9c8b0e35866d6a43e3521e632ea920 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 08:39:10 -0700 Subject: [PATCH 037/109] fix: prevent git templates from leaking into shadow checkpoint repos (#8629) Pass --template="" to git init and strip GIT_TEMPLATE_DIR from the environment so system/user git hooks and other template files never get copied into the shadow repository used for checkpoints. Co-authored-by: Roo Code --- .../checkpoints/ShadowCheckpointService.ts | 5 +- .../__tests__/ShadowCheckpointService.spec.ts | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index bd44afb358..89ae52c435 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -39,7 +39,8 @@ function createSanitizedGit(baseDir: string): SimpleGit { key === "GIT_INDEX_FILE" || key === "GIT_OBJECT_DIRECTORY" || key === "GIT_ALTERNATE_OBJECT_DIRECTORIES" || - key === "GIT_CEILING_DIRECTORIES" + key === "GIT_CEILING_DIRECTORIES" || + key === "GIT_TEMPLATE_DIR" ) { removedVars.push(`${key}=${value}`) continue @@ -172,7 +173,7 @@ export abstract class ShadowCheckpointService extends EventEmitter { this.baseHash = await git.revparse(["HEAD"]) } else { this.log(`[${this.constructor.name}#initShadowGit] creating shadow git repo at ${this.checkpointsDir}`) - await git.init() + await git.init({ "--template": "" }) await git.addConfig("core.worktree", this.workspaceDir) // Sets the working tree to the current workspace. await git.addConfig("commit.gpgSign", "false") // Disable commit signing for shadow repo. await git.addConfig("user.name", "Roo Code") diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 92bf1f8e7d..5bc43d54ce 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -824,6 +824,55 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( expect(await fs.readFile(testFile, "utf-8")).toBe("Hello, world!") }) + it("does not apply git templates when initializing shadow repo", async () => { + // This test verifies that git init uses --template="" and GIT_TEMPLATE_DIR + // is stripped, preventing system/user git hooks from leaking into the shadow repo. + const templateDir = path.join(tmpDir, `git-template-${Date.now()}`) + const hooksDir = path.join(templateDir, "hooks") + await fs.mkdir(hooksDir, { recursive: true }) + await fs.writeFile(path.join(hooksDir, "pre-commit"), "#!/bin/sh\nexit 1", { mode: 0o755 }) + + const testShadowDir = path.join(tmpDir, `shadow-template-test-${Date.now()}`) + const testWorkspaceDir = path.join(tmpDir, `workspace-template-test-${Date.now()}`) + await initWorkspaceRepo({ workspaceDir: testWorkspaceDir }) + + const originalTemplateDir = process.env.GIT_TEMPLATE_DIR + process.env.GIT_TEMPLATE_DIR = templateDir + + try { + const testService = await klass.create({ + taskId: `test-template-${Date.now()}`, + shadowDir: testShadowDir, + workspaceDir: testWorkspaceDir, + log: () => {}, + }) + await testService.initShadowGit() + + // Verify no hooks were copied from the template + const shadowHooksDir = path.join(testShadowDir, ".git", "hooks") + let hookFiles: string[] = [] + + try { + hookFiles = await fs.readdir(shadowHooksDir) + } catch { + // hooks dir may not exist at all, which is fine + } + + // The pre-commit hook from the template should NOT be present + expect(hookFiles).not.toContain("pre-commit") + } finally { + if (originalTemplateDir !== undefined) { + process.env.GIT_TEMPLATE_DIR = originalTemplateDir + } else { + delete process.env.GIT_TEMPLATE_DIR + } + + await fs.rm(testShadowDir, { recursive: true, force: true }) + await fs.rm(testWorkspaceDir, { recursive: true, force: true }) + await fs.rm(templateDir, { recursive: true, force: true }) + } + }) + it("isolates checkpoint operations from GIT_DIR environment variable", async () => { // This test verifies the fix for the issue where GIT_DIR environment variable // causes checkpoint commits to go to the wrong repository. From 9918e837baed0d385026ae8bf2a89ab4abf1cb06 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:05:38 -0700 Subject: [PATCH 038/109] Release v3.50.2 (#11631) chore: add changeset for v3.50.2 Co-authored-by: Roo Code --- .changeset/v3.50.2.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/v3.50.2.md diff --git a/.changeset/v3.50.2.md b/.changeset/v3.50.2.md new file mode 100644 index 0000000000..90271b67e1 --- /dev/null +++ b/.changeset/v3.50.2.md @@ -0,0 +1,7 @@ +--- +"roo-cline": patch +--- + +- Fix: Inline terminal rendering parity with the VSCode Terminal (#10699 by @jerrill-johnson-bitwerx, PR #11361 by @RussellZager) +- Fix: Enable prompt caching for Bedrock custom ARN and default to ON (#10846 by @wisestmumbler, PR #11373 by @roomote) +- Feat: Add visual feedback to copy button in task actions (#11401 by @omagoduck, PR #11403 by @omagoduck) From 4288b0a72fd968553c66cc9071d963d277c69bac Mon Sep 17 00:00:00 2001 From: pugazhendhi-m <132246623+pugazhendhi-m@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:07:13 +0530 Subject: [PATCH 039/109] feat: restore Unbound as a provider (#11624) * feat: restore Unbound as a provider * Adds translations * fix: add unbound to ClineProvider test expectations Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- apps/cli/src/lib/utils/context-window.ts | 2 + packages/types/src/global-settings.ts | 1 + packages/types/src/provider-settings.ts | 13 +- packages/types/src/providers/index.ts | 4 + packages/types/src/providers/unbound.ts | 16 ++ src/api/index.ts | 3 + src/api/providers/fetchers/modelCache.ts | 4 + src/api/providers/fetchers/unbound.ts | 40 ++++ src/api/providers/index.ts | 1 + src/api/providers/unbound.ts | 212 ++++++++++++++++++ .../webview/__tests__/ClineProvider.spec.ts | 5 + .../__tests__/webviewMessageHandler.spec.ts | 17 ++ src/core/webview/webviewMessageHandler.ts | 8 + src/shared/ProfileValidator.ts | 2 + src/shared/api.ts | 1 + .../src/components/settings/ApiOptions.tsx | 15 ++ .../src/components/settings/ModelPicker.tsx | 1 + .../src/components/settings/constants.ts | 1 + .../components/settings/providers/Unbound.tsx | 101 +++++++++ .../components/settings/providers/index.ts | 1 + .../settings/utils/providerModelConfig.ts | 1 + .../components/ui/hooks/useSelectedModel.ts | 5 + webview-ui/src/i18n/locales/ca/settings.json | 2 + webview-ui/src/i18n/locales/de/settings.json | 2 + webview-ui/src/i18n/locales/en/settings.json | 2 + webview-ui/src/i18n/locales/es/settings.json | 2 + webview-ui/src/i18n/locales/fr/settings.json | 2 + webview-ui/src/i18n/locales/hi/settings.json | 2 + webview-ui/src/i18n/locales/id/settings.json | 2 + webview-ui/src/i18n/locales/it/settings.json | 2 + webview-ui/src/i18n/locales/ja/settings.json | 2 + webview-ui/src/i18n/locales/ko/settings.json | 2 + webview-ui/src/i18n/locales/nl/settings.json | 2 + webview-ui/src/i18n/locales/pl/settings.json | 2 + .../src/i18n/locales/pt-BR/settings.json | 2 + webview-ui/src/i18n/locales/ru/settings.json | 2 + webview-ui/src/i18n/locales/tr/settings.json | 2 + webview-ui/src/i18n/locales/vi/settings.json | 2 + .../src/i18n/locales/zh-CN/settings.json | 2 + .../src/i18n/locales/zh-TW/settings.json | 2 + .../src/utils/__tests__/validate.spec.ts | 1 + webview-ui/src/utils/validate.ts | 5 + 42 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 packages/types/src/providers/unbound.ts create mode 100644 src/api/providers/fetchers/unbound.ts create mode 100644 src/api/providers/unbound.ts create mode 100644 webview-ui/src/components/settings/providers/Unbound.tsx diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index df878e16b0..5cd58b55a8 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -46,6 +46,8 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined { return config.openAiModelId case "requesty": return config.requestyModelId + case "unbound": + return config.unboundModelId case "litellm": return config.litellmModelId case "vercel-ai-gateway": diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index de3bd07661..91b37f3d6d 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -264,6 +264,7 @@ export const SECRET_STATE_KEYS = [ "mistralApiKey", "minimaxApiKey", "requestyApiKey", + "unboundApiKey", "xaiApiKey", "litellmApiKey", "codeIndexOpenAiKey", diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index fef422666d..859792d7c3 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -34,7 +34,7 @@ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3 * Dynamic provider requires external API calls in order to get the model list. */ -export const dynamicProviders = ["openrouter", "vercel-ai-gateway", "litellm", "requesty", "roo"] as const +export const dynamicProviders = ["openrouter", "vercel-ai-gateway", "litellm", "requesty", "roo", "unbound"] as const export type DynamicProvider = (typeof dynamicProviders)[number] @@ -142,7 +142,6 @@ export const retiredProviderNames = [ "groq", "huggingface", "io-intelligence", - "unbound", ] as const export const retiredProviderNamesSchema = z.enum(retiredProviderNames) @@ -327,6 +326,11 @@ const requestySchema = baseProviderSettingsSchema.extend({ requestyModelId: z.string().optional(), }) +const unboundSchema = baseProviderSettingsSchema.extend({ + unboundApiKey: z.string().optional(), + unboundModelId: z.string().optional(), +}) + const fakeAiSchema = baseProviderSettingsSchema.extend({ fakeAi: z.unknown().optional(), }) @@ -399,6 +403,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv moonshotSchema.merge(z.object({ apiProvider: z.literal("moonshot") })), minimaxSchema.merge(z.object({ apiProvider: z.literal("minimax") })), requestySchema.merge(z.object({ apiProvider: z.literal("requesty") })), + unboundSchema.merge(z.object({ apiProvider: z.literal("unbound") })), fakeAiSchema.merge(z.object({ apiProvider: z.literal("fake-ai") })), xaiSchema.merge(z.object({ apiProvider: z.literal("xai") })), basetenSchema.merge(z.object({ apiProvider: z.literal("baseten") })), @@ -431,6 +436,7 @@ export const providerSettingsSchema = z.object({ ...moonshotSchema.shape, ...minimaxSchema.shape, ...requestySchema.shape, + ...unboundSchema.shape, ...fakeAiSchema.shape, ...xaiSchema.shape, ...basetenSchema.shape, @@ -468,6 +474,7 @@ export const modelIdKeys = [ "lmStudioModelId", "lmStudioDraftModelId", "requestyModelId", + "unboundModelId", "litellmModelId", "vercelAiGatewayModelId", ] as const satisfies readonly (keyof ProviderSettings)[] @@ -505,6 +512,7 @@ export const modelIdKeysByProvider: Record = { deepseek: "apiModelId", "qwen-code": "apiModelId", requesty: "requestyModelId", + unbound: "unboundModelId", xai: "apiModelId", baseten: "apiModelId", litellm: "litellmModelId", @@ -627,6 +635,7 @@ export const MODELS_BY_PROVIDER: Record< litellm: { id: "litellm", label: "LiteLLM", models: [] }, openrouter: { id: "openrouter", label: "OpenRouter", models: [] }, requesty: { id: "requesty", label: "Requesty", models: [] }, + unbound: { id: "unbound", label: "Unbound", models: [] }, "vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] }, // Local providers; models discovered from localhost endpoints. diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index a9c1e8804c..6bb959c705 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -17,6 +17,7 @@ export * from "./qwen-code.js" export * from "./requesty.js" export * from "./roo.js" export * from "./sambanova.js" +export * from "./unbound.js" export * from "./vertex.js" export * from "./vscode-llm.js" export * from "./xai.js" @@ -39,6 +40,7 @@ import { qwenCodeDefaultModelId } from "./qwen-code.js" import { requestyDefaultModelId } from "./requesty.js" import { rooDefaultModelId } from "./roo.js" import { sambaNovaDefaultModelId } from "./sambanova.js" +import { unboundDefaultModelId } from "./unbound.js" import { vertexDefaultModelId } from "./vertex.js" import { vscodeLlmDefaultModelId } from "./vscode-llm.js" import { xaiDefaultModelId } from "./xai.js" @@ -105,6 +107,8 @@ export function getProviderDefaultModelId( return rooDefaultModelId case "qwen-code": return qwenCodeDefaultModelId + case "unbound": + return unboundDefaultModelId case "vercel-ai-gateway": return vercelAiGatewayDefaultModelId case "anthropic": diff --git a/packages/types/src/providers/unbound.ts b/packages/types/src/providers/unbound.ts new file mode 100644 index 0000000000..f45c986dd0 --- /dev/null +++ b/packages/types/src/providers/unbound.ts @@ -0,0 +1,16 @@ +import type { ModelInfo } from "../model.js" + +// Unbound +// https://gateway.getunbound.ai +export const unboundDefaultModelId = "anthropic/claude-sonnet-4-5" + +export const unboundDefaultModelInfo: ModelInfo = { + maxTokens: 8192, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3.0, + outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, +} diff --git a/src/api/index.ts b/src/api/index.ts index a527b7e133..ebc2682a1a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -21,6 +21,7 @@ import { MistralHandler, VsCodeLmHandler, RequestyHandler, + UnboundHandler, FakeAIHandler, XAIHandler, LiteLLMHandler, @@ -151,6 +152,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new MistralHandler(options) case "requesty": return new RequestyHandler(options) + case "unbound": + return new UnboundHandler(options) case "fake-ai": return new FakeAIHandler(options) case "xai": diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 3ac8c2296c..a574a660bc 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -19,6 +19,7 @@ import { fileExistsAtPath } from "../../../utils/fs" import { getOpenRouterModels } from "./openrouter" import { getVercelAiGatewayModels } from "./vercel-ai-gateway" import { getRequestyModels } from "./requesty" +import { getUnboundModels } from "./unbound" import { getLiteLLMModels } from "./litellm" import { GetModelsOptions } from "../../../shared/api" import { getOllamaModels } from "./ollama" @@ -68,6 +69,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise> { + const models: Record = {} + + try { + const headers: Record = {} + + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}` + } + + const response = await axios.get("https://api.getunbound.ai/models", { headers }) + const rawModels = response.data?.data ?? response.data + + for (const rawModel of rawModels) { + const modelInfo: ModelInfo = { + maxTokens: rawModel.max_output_tokens ?? 8192, + contextWindow: rawModel.context_window ?? 200_000, + supportsPromptCache: rawModel.supports_caching ?? false, + supportsImages: rawModel.supports_vision ?? false, + inputPrice: parseApiPrice(rawModel.input_price), + outputPrice: parseApiPrice(rawModel.output_price), + description: rawModel.description, + cacheWritesPrice: parseApiPrice(rawModel.caching_price), + cacheReadsPrice: parseApiPrice(rawModel.cached_price), + } + + models[rawModel.id] = modelInfo + } + } catch (error) { + console.error(`Error fetching Unbound models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + } + + return models +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 51eafc200d..b6de795210 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -17,6 +17,7 @@ export { OpenRouterHandler } from "./openrouter" export { QwenCodeHandler } from "./qwen-code" export { RequestyHandler } from "./requesty" export { SambaNovaHandler } from "./sambanova" +export { UnboundHandler } from "./unbound" export { VertexHandler } from "./vertex" export { VsCodeLmHandler } from "./vscode-lm" export { XAIHandler } from "./xai" diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts new file mode 100644 index 0000000000..d50bfcc85d --- /dev/null +++ b/src/api/providers/unbound.ts @@ -0,0 +1,212 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { type ModelInfo, type ModelRecord, unboundDefaultModelId, unboundDefaultModelInfo } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" + +import { convertToOpenAiMessages } from "../transform/openai-format" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" +import { OpenAiReasoningParams } from "../transform/reasoning" + +import { DEFAULT_HEADERS } from "./constants" +import { getModels } from "./fetchers/modelCache" +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { handleOpenAIError } from "./utils/openai-error-handler" +import { applyRouterToolPreferences } from "./utils/router-tool-preferences" + +// Unbound usage includes extra fields for Anthropic cache tokens. +interface UnboundUsage extends OpenAI.CompletionUsage { + cache_creation_input_tokens?: number + cache_read_input_tokens?: number +} + +type UnboundChatCompletionParamsStreaming = OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & { + unbound_metadata?: { + originApp?: string + taskId?: string + mode?: string + } + thinking?: OpenAiReasoningParams +} + +type UnboundChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { + unbound_metadata?: { + originApp?: string + taskId?: string + mode?: string + } + thinking?: OpenAiReasoningParams +} + +export class UnboundHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + protected models: ModelRecord = {} + private client: OpenAI + private readonly providerName = "Unbound" + + constructor(options: ApiHandlerOptions) { + super() + + this.options = options + + const apiKey = this.options.unboundApiKey ?? "not-provided" + + this.client = new OpenAI({ + baseURL: "https://api.getunbound.ai/v1", + apiKey: apiKey, + defaultHeaders: { + ...DEFAULT_HEADERS, + "X-Unbound-Metadata": JSON.stringify({ labels: [{ key: "app", value: "roo-code" }] }), + }, + }) + } + + public async fetchModel() { + this.models = await getModels({ provider: "unbound", apiKey: this.options.unboundApiKey }) + return this.getModel() + } + + override getModel() { + const id = this.options.unboundModelId ?? unboundDefaultModelId + const cachedInfo = this.models[id] ?? unboundDefaultModelInfo + let info: ModelInfo = cachedInfo + + // Apply tool preferences for models accessed through routers (OpenAI, Gemini) + info = applyRouterToolPreferences(id, info) + + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) + + return { id, info, ...params } + } + + protected processUsageMetrics(usage: any, modelInfo?: ModelInfo): ApiStreamUsageChunk { + const unboundUsage = usage as UnboundUsage + const inputTokens = unboundUsage?.prompt_tokens || 0 + const outputTokens = unboundUsage?.completion_tokens || 0 + const cacheWriteTokens = unboundUsage?.cache_creation_input_tokens || 0 + const cacheReadTokens = unboundUsage?.cache_read_input_tokens || 0 + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + : { totalCost: 0 } + + return { + type: "usage", + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheWriteTokens: cacheWriteTokens, + cacheReadTokens: cacheReadTokens, + totalCost: totalCost, + } + } + + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { + id: model, + info, + maxTokens: max_tokens, + temperature, + reasoningEffort: reasoning_effort, + reasoning: thinking, + } = await this.fetchModel() + + const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] + + // Map extended efforts to OpenAI Chat Completions-accepted values (omit unsupported) + const allowedEffort = (["low", "medium", "high"] as const).includes(reasoning_effort as any) + ? (reasoning_effort as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming["reasoning_effort"]) + : undefined + + const completionParams: UnboundChatCompletionParamsStreaming = { + messages: openAiMessages, + model, + max_tokens, + temperature, + ...(allowedEffort && { reasoning_effort: allowedEffort }), + ...(thinking && { thinking }), + stream: true, + stream_options: { include_usage: true }, + unbound_metadata: { originApp: "roo-code", taskId: metadata?.taskId, mode: metadata?.mode }, + tools: this.convertToolsForOpenAI(metadata?.tools), + tool_choice: metadata?.tool_choice, + } + + let stream + try { + stream = await this.client.chat.completions.create(completionParams) + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } + let lastUsage: any = undefined + + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta + + if (delta?.content) { + yield { type: "text", text: delta.content } + } + + if (delta && "reasoning_content" in delta && delta.reasoning_content) { + yield { type: "reasoning", text: (delta.reasoning_content as string | undefined) || "" } + } + + // Handle native tool calls + if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) { + for (const toolCall of delta.tool_calls) { + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } + } + } + + if (chunk.usage) { + lastUsage = chunk.usage + } + } + + if (lastUsage) { + yield this.processUsageMetrics(lastUsage, info) + } + } + + async completePrompt(prompt: string): Promise { + const { id: model, maxTokens: max_tokens, temperature } = await this.fetchModel() + + let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [{ role: "system", content: prompt }] + + const completionParams: UnboundChatCompletionParams = { + model, + max_tokens, + messages: openAiMessages, + temperature: temperature, + } + + let response: OpenAI.Chat.ChatCompletion + try { + response = await this.client.chat.completions.create(completionParams) + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } + return response.choices[0]?.message.content || "" + } +} diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1e26cd45be..cfa4b0317f 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2468,6 +2468,7 @@ describe("ClineProvider - Router Models", () => { // Verify getModels was called for each provider with correct options expect(getModels).toHaveBeenCalledWith({ provider: "openrouter" }) expect(getModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" }) + expect(getModels).toHaveBeenCalledWith({ provider: "unbound" }) expect(getModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" }) expect(getModels).toHaveBeenCalledWith( expect.objectContaining({ @@ -2487,6 +2488,7 @@ describe("ClineProvider - Router Models", () => { routerModels: { openrouter: mockModels, requesty: mockModels, + unbound: mockModels, roo: mockModels, litellm: mockModels, ollama: {}, @@ -2519,6 +2521,7 @@ describe("ClineProvider - Router Models", () => { vi.mocked(getModels) .mockResolvedValueOnce(mockModels) // openrouter success .mockRejectedValueOnce(new Error("Requesty API error")) // requesty fail + .mockResolvedValueOnce(mockModels) // unbound success .mockResolvedValueOnce(mockModels) // vercel-ai-gateway success .mockResolvedValueOnce(mockModels) // roo success .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm fail @@ -2531,6 +2534,7 @@ describe("ClineProvider - Router Models", () => { routerModels: { openrouter: mockModels, requesty: {}, + unbound: mockModels, roo: mockModels, ollama: {}, lmstudio: {}, @@ -2624,6 +2628,7 @@ describe("ClineProvider - Router Models", () => { routerModels: { openrouter: mockModels, requesty: mockModels, + unbound: mockModels, roo: mockModels, litellm: {}, ollama: {}, diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 420d309fb7..1cd8285993 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -296,6 +296,11 @@ describe("webviewMessageHandler - requestRouterModels", () => { // Verify getModels was called for each provider expect(mockGetModels).toHaveBeenCalledWith({ provider: "openrouter" }) expect(mockGetModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" }) + expect(mockGetModels).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "unbound", + }), + ) expect(mockGetModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" }) expect(mockGetModels).toHaveBeenCalledWith( expect.objectContaining({ @@ -315,6 +320,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { routerModels: { openrouter: mockModels, requesty: mockModels, + unbound: mockModels, litellm: mockModels, roo: mockModels, ollama: {}, @@ -399,6 +405,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { routerModels: { openrouter: mockModels, requesty: mockModels, + unbound: mockModels, roo: mockModels, litellm: {}, ollama: {}, @@ -423,6 +430,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { mockGetModels .mockResolvedValueOnce(mockModels) // openrouter .mockRejectedValueOnce(new Error("Requesty API error")) // requesty + .mockResolvedValueOnce(mockModels) // unbound .mockResolvedValueOnce(mockModels) // vercel-ai-gateway .mockResolvedValueOnce(mockModels) // roo .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm @@ -452,6 +460,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { routerModels: { openrouter: mockModels, requesty: {}, + unbound: mockModels, roo: mockModels, litellm: {}, ollama: {}, @@ -467,6 +476,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { mockGetModels .mockRejectedValueOnce(new Error("Structured error message")) // openrouter .mockRejectedValueOnce(new Error("Requesty API error")) // requesty + .mockRejectedValueOnce(new Error("Unbound error")) // unbound .mockRejectedValueOnce(new Error("Vercel AI Gateway error")) // vercel-ai-gateway .mockRejectedValueOnce(new Error("Roo API error")) // roo .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm @@ -490,6 +500,13 @@ describe("webviewMessageHandler - requestRouterModels", () => { values: { provider: "requesty" }, }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "singleRouterModelFetchResponse", + success: false, + error: "Unbound error", + values: { provider: "unbound" }, + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 19d7e5adb3..5194b16df9 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -876,6 +876,7 @@ export const webviewMessageHandler = async ( "vercel-ai-gateway": {}, litellm: {}, requesty: {}, + unbound: {}, ollama: {}, lmstudio: {}, roo: {}, @@ -905,6 +906,13 @@ export const webviewMessageHandler = async ( baseUrl: apiConfiguration.requestyBaseUrl, }, }, + { + key: "unbound", + options: { + provider: "unbound", + apiKey: apiConfiguration.unboundApiKey, + }, + }, { key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, { key: "roo", diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index ae58763d6a..7246a90177 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -77,6 +77,8 @@ export class ProfileValidator { return profile.ollamaModelId case "requesty": return profile.requestyModelId + case "unbound": + return profile.unboundModelId case "fake-ai": default: return undefined diff --git a/src/shared/api.ts b/src/shared/api.ts index 7e999e1289..52af6b2072 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -173,6 +173,7 @@ const dynamicProviderExtras = { "vercel-ai-gateway": {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type litellm: {} as { apiKey: string; baseUrl: string }, requesty: {} as { apiKey?: string; baseUrl?: string }, + unbound: {} as { apiKey?: string }, ollama: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type lmstudio: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type roo: {} as { apiKey?: string; baseUrl?: string }, diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 8aa14e2dc9..4d914a4833 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -31,6 +31,7 @@ import { rooDefaultModelId, vercelAiGatewayDefaultModelId, minimaxDefaultModelId, + unboundDefaultModelId, } from "@roo-code/types" import { @@ -83,6 +84,7 @@ import { Requesty, Roo, SambaNova, + Unbound, Vertex, VSCodeLM, XAI, @@ -330,6 +332,7 @@ const ApiOptions = ({ > = { openrouter: { field: "openRouterModelId", default: openRouterDefaultModelId }, requesty: { field: "requestyModelId", default: requestyDefaultModelId }, + unbound: { field: "unboundModelId", default: unboundDefaultModelId }, litellm: { field: "litellmModelId", default: litellmDefaultModelId }, anthropic: { field: "apiModelId", default: anthropicDefaultModelId }, "openai-codex": { field: "apiModelId", default: openAiCodexDefaultModelId }, @@ -518,6 +521,18 @@ const ApiOptions = ({ /> )} + {selectedProvider === "unbound" && ( + + )} + {selectedProvider === "anthropic" && ( a.label.localeCompare(b.label)) diff --git a/webview-ui/src/components/settings/providers/Unbound.tsx b/webview-ui/src/components/settings/providers/Unbound.tsx new file mode 100644 index 0000000000..8c68241415 --- /dev/null +++ b/webview-ui/src/components/settings/providers/Unbound.tsx @@ -0,0 +1,101 @@ +import { useCallback } from "react" +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { + type ProviderSettings, + type OrganizationAllowList, + type RouterModels, + unboundDefaultModelId, +} from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Button } from "@src/components/ui" + +import { inputEventTransform } from "../transforms" +import { ModelPicker } from "../ModelPicker" + +type UnboundProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void + routerModels?: RouterModels + refetchRouterModels: () => void + organizationAllowList: OrganizationAllowList + modelValidationError?: string + simplifySettings?: boolean +} + +export const Unbound = ({ + apiConfiguration, + setApiConfigurationField, + routerModels, + organizationAllowList, + modelValidationError, + simplifySettings, +}: UnboundProps) => { + const { t } = useAppTranslation() + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ProviderSettings[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + +
+ +
+
+
+ {t("settings:providers.apiKeyStorageNotice")} +
+ + {t("settings:providers.getUnboundApiKey")} + + + + + ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index d7684fb945..597caffd1d 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -14,6 +14,7 @@ export { QwenCode } from "./QwenCode" export { Roo } from "./Roo" export { Requesty } from "./Requesty" export { SambaNova } from "./SambaNova" +export { Unbound } from "./Unbound" export { Vertex } from "./Vertex" export { VSCodeLM } from "./VSCodeLM" export { XAI } from "./XAI" diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index 85fb54d6e9..fa71814390 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -118,6 +118,7 @@ export const isStaticModelProvider = (provider: ProviderName): boolean => { export const PROVIDERS_WITH_CUSTOM_MODEL_UI: ProviderName[] = [ "openrouter", "requesty", + "unbound", "openai", // OpenAI Compatible "openai-codex", // OpenAI Codex has custom UI with auth and rate limits "litellm", diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 959deff2b7..c32a08990c 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -159,6 +159,11 @@ function getSelectedModel({ const routerInfo = routerModels.requesty?.[id] return { id, info: routerInfo } } + case "unbound": { + const id = getValidatedModelId(apiConfiguration.unboundModelId, routerModels.unbound, defaultModelId) + const routerInfo = routerModels.unbound?.[id] + return { id, info: routerInfo } + } case "litellm": { const id = getValidatedModelId(apiConfiguration.litellmModelId, routerModels.litellm, defaultModelId) const routerInfo = routerModels.litellm?.[id] diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index a741d9a3d7..2c83cabbbc 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -356,6 +356,8 @@ "headerName": "Nom de la capçalera", "headerValue": "Valor de la capçalera", "noCustomHeaders": "No hi ha capçaleres personalitzades definides. Feu clic al botó + per afegir-ne una.", + "unboundApiKey": "Clau API de Unbound", + "getUnboundApiKey": "Obtenir clau API de Unbound", "requestyApiKey": "Clau API de Requesty", "refreshModels": { "label": "Actualitzar models", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index aed7867d80..c31d29147d 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -356,6 +356,8 @@ "headerName": "Header-Name", "headerValue": "Header-Wert", "noCustomHeaders": "Keine benutzerdefinierten Headers definiert. Klicke auf die + Schaltfläche, um einen hinzuzufügen.", + "unboundApiKey": "Unbound API-Schlüssel", + "getUnboundApiKey": "Unbound API-Schlüssel erhalten", "requestyApiKey": "Requesty API-Schlüssel", "refreshModels": { "label": "Modelle aktualisieren", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index af825fafe8..3b2497aaee 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -419,6 +419,8 @@ "headerName": "Header name", "headerValue": "Header value", "noCustomHeaders": "No custom headers defined. Click the + button to add one.", + "unboundApiKey": "Unbound API Key", + "getUnboundApiKey": "Get Unbound API Key", "requestyApiKey": "Requesty API Key", "refreshModels": { "label": "Refresh Models", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 946a6f87c0..6595c4f907 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -356,6 +356,8 @@ "headerName": "Nombre del encabezado", "headerValue": "Valor del encabezado", "noCustomHeaders": "No hay encabezados personalizados definidos. Haga clic en el botón + para añadir uno.", + "unboundApiKey": "Clave API de Unbound", + "getUnboundApiKey": "Obtener clave API de Unbound", "requestyApiKey": "Clave API de Requesty", "refreshModels": { "label": "Actualizar modelos", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index c833ed7950..56337bda14 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -356,6 +356,8 @@ "headerName": "Nom de l'en-tête", "headerValue": "Valeur de l'en-tête", "noCustomHeaders": "Aucun en-tête personnalisé défini. Cliquez sur le bouton + pour en ajouter un.", + "unboundApiKey": "Clé API Unbound", + "getUnboundApiKey": "Obtenir la clé API Unbound", "requestyApiKey": "Clé API Requesty", "refreshModels": { "label": "Actualiser les modèles", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 9c20bd4457..abd334bec0 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -356,6 +356,8 @@ "headerName": "हेडर नाम", "headerValue": "हेडर मूल्य", "noCustomHeaders": "कोई कस्टम हेडर परिभाषित नहीं है। एक जोड़ने के लिए + बटन पर क्लिक करें।", + "unboundApiKey": "Unbound API कुंजी", + "getUnboundApiKey": "Unbound API कुंजी प्राप्त करें", "requestyApiKey": "Requesty API कुंजी", "refreshModels": { "label": "मॉडल रिफ्रेश करें", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 6320d2bb34..1ebcf2073b 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -356,6 +356,8 @@ "headerName": "Nama header", "headerValue": "Nilai header", "noCustomHeaders": "Tidak ada header kustom yang didefinisikan. Klik tombol + untuk menambahkan satu.", + "unboundApiKey": "Unbound API Key", + "getUnboundApiKey": "Dapatkan Unbound API Key", "requestyApiKey": "Requesty API Key", "refreshModels": { "label": "Refresh Model", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 4b29c33247..4a0c716165 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -356,6 +356,8 @@ "headerName": "Nome intestazione", "headerValue": "Valore intestazione", "noCustomHeaders": "Nessuna intestazione personalizzata definita. Fai clic sul pulsante + per aggiungerne una.", + "unboundApiKey": "Chiave API Unbound", + "getUnboundApiKey": "Ottieni chiave API Unbound", "requestyApiKey": "Chiave API Requesty", "refreshModels": { "label": "Aggiorna modelli", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 3aab3c7962..b0d921571a 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -356,6 +356,8 @@ "headerName": "ヘッダー名", "headerValue": "ヘッダー値", "noCustomHeaders": "カスタムヘッダーが定義されていません。+ ボタンをクリックして追加してください。", + "unboundApiKey": "Unbound API キー", + "getUnboundApiKey": "Unbound APIキーを取得", "requestyApiKey": "Requesty APIキー", "refreshModels": { "label": "モデルを更新", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 7a522e5706..88fc8e6d79 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -356,6 +356,8 @@ "headerName": "헤더 이름", "headerValue": "헤더 값", "noCustomHeaders": "정의된 사용자 정의 헤더가 없습니다. + 버튼을 클릭하여 추가하세요.", + "unboundApiKey": "Unbound API 키", + "getUnboundApiKey": "Unbound API 키 받기", "requestyApiKey": "Requesty API 키", "refreshModels": { "label": "모델 새로고침", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 854376b2fd..fcfad37d37 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -356,6 +356,8 @@ "headerName": "Headernaam", "headerValue": "Headerwaarde", "noCustomHeaders": "Geen aangepaste headers gedefinieerd. Klik op de + knop om er een toe te voegen.", + "unboundApiKey": "Unbound API sleutel", + "getUnboundApiKey": "Unbound API-sleutel ophalen", "requestyApiKey": "Requesty API-sleutel", "refreshModels": { "label": "Modellen verversen", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 85094cabfb..fa48bc6b21 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -356,6 +356,8 @@ "headerName": "Nazwa nagłówka", "headerValue": "Wartość nagłówka", "noCustomHeaders": "Brak zdefiniowanych niestandardowych nagłówków. Kliknij przycisk +, aby dodać.", + "unboundApiKey": "Klucz API Unbound", + "getUnboundApiKey": "Uzyskaj klucz API Unbound", "requestyApiKey": "Klucz API Requesty", "refreshModels": { "label": "Odśwież modele", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 3a59ce226a..a8387e0512 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -356,6 +356,8 @@ "headerName": "Nome do cabeçalho", "headerValue": "Valor do cabeçalho", "noCustomHeaders": "Nenhum cabeçalho personalizado definido. Clique no botão + para adicionar um.", + "unboundApiKey": "Chave de API Unbound", + "getUnboundApiKey": "Obter chave de API Unbound", "requestyApiKey": "Chave de API Requesty", "refreshModels": { "label": "Atualizar modelos", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 7b7197d956..fe24ebee29 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -356,6 +356,8 @@ "headerName": "Имя заголовка", "headerValue": "Значение заголовка", "noCustomHeaders": "Пользовательские заголовки не определены. Нажмите кнопку +, чтобы добавить.", + "unboundApiKey": "Unbound API-ключ", + "getUnboundApiKey": "Получить Unbound API-ключ", "requestyApiKey": "Requesty API-ключ", "refreshModels": { "label": "Обновить модели", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 766b829964..7171718f1c 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -356,6 +356,8 @@ "headerName": "Başlık adı", "headerValue": "Başlık değeri", "noCustomHeaders": "Tanımlanmış özel başlık yok. Eklemek için + düğmesine tıklayın.", + "unboundApiKey": "Unbound API Anahtarı", + "getUnboundApiKey": "Unbound API Anahtarı Al", "requestyApiKey": "Requesty API Anahtarı", "refreshModels": { "label": "Modelleri Yenile", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index fd2fd64885..95b4f2d686 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -356,6 +356,8 @@ "headerName": "Tên tiêu đề", "headerValue": "Giá trị tiêu đề", "noCustomHeaders": "Chưa có tiêu đề tùy chỉnh nào được định nghĩa. Nhấp vào nút + để thêm.", + "unboundApiKey": "Khóa API Unbound", + "getUnboundApiKey": "Lấy khóa API Unbound", "requestyApiKey": "Khóa API Requesty", "refreshModels": { "label": "Làm mới mô hình", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 40d0f4eda3..eeba6bb079 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -356,6 +356,8 @@ "headerName": "标头名称", "headerValue": "标头值", "noCustomHeaders": "暂无自定义标头。点击 + 按钮添加。", + "unboundApiKey": "Unbound API 密钥", + "getUnboundApiKey": "获取 Unbound API 密钥", "requestyApiKey": "Requesty API 密钥", "refreshModels": { "label": "刷新模型", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 691873ef20..9f4241c3dd 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -366,6 +366,8 @@ "headerName": "標頭名稱", "headerValue": "標頭值", "noCustomHeaders": "尚未定義自訂標頭。點選 + 按鈕以新增。", + "unboundApiKey": "Unbound API 金鑰", + "getUnboundApiKey": "取得 Unbound API 金鑰", "requestyApiKey": "Requesty API 金鑰", "refreshModels": { "label": "重新整理模型", diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index 0a046adc54..9b0b7a66e0 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -39,6 +39,7 @@ describe("Model Validation Functions", () => { }, }, requesty: {}, + unbound: {}, litellm: {}, ollama: {}, lmstudio: {}, diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 116013d03f..a4c950f8dd 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -48,6 +48,11 @@ function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): stri return i18next.t("settings:validation.apiKey") } break + case "unbound": + if (!apiConfiguration.unboundApiKey) { + return i18next.t("settings:validation.apiKey") + } + break case "litellm": if (!apiConfiguration.litellmApiKey) { return i18next.t("settings:validation.apiKey") From 93415c7204a674f82bab12a75a7c766f39791fb6 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:46:58 -0700 Subject: [PATCH 040/109] fix: correct Vertex AI claude-sonnet-4-6 model ID (#11626) fix: correct Vertex AI claude-sonnet-4-6 model ID by removing date suffix Co-authored-by: Roo Code --- packages/types/src/providers/vertex.ts | 4 ++-- src/api/providers/__tests__/anthropic-vertex.spec.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/types/src/providers/vertex.ts b/packages/types/src/providers/vertex.ts index b1291d2f59..f82341ace1 100644 --- a/packages/types/src/providers/vertex.ts +++ b/packages/types/src/providers/vertex.ts @@ -292,7 +292,7 @@ export const vertexModels = { }, ], }, - "claude-sonnet-4-6@20260114": { + "claude-sonnet-4-6": { maxTokens: 8192, contextWindow: 200_000, // Default 200K, extendable to 1M with beta flag 'context-1m-2025-08-07' supportsImages: true, @@ -541,7 +541,7 @@ export const vertexModels = { export const VERTEX_1M_CONTEXT_MODEL_IDS = [ "claude-sonnet-4@20250514", "claude-sonnet-4-5@20250929", - "claude-sonnet-4-6@20260114", + "claude-sonnet-4-6", "claude-opus-4-6", ] as const diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index dc284ab754..f1ada38a63 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -901,7 +901,7 @@ describe("VertexHandler", () => { it("should enable 1M context for Claude Sonnet 4.6 when beta flag is set", () => { const handler = new AnthropicVertexHandler({ - apiModelId: "claude-sonnet-4-6@20260114", + apiModelId: "claude-sonnet-4-6", vertexProjectId: "test-project", vertexRegion: "us-central1", vertex1MContext: true, From 2991bc9a395bd2eb2dac43971a9657fb2f135075 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:47:38 -0700 Subject: [PATCH 041/109] Changeset version bump (#11634) changeset version bump Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/v3.50.2.md | 7 ------- CHANGELOG.md | 8 ++++++++ src/package.json | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 .changeset/v3.50.2.md diff --git a/.changeset/v3.50.2.md b/.changeset/v3.50.2.md deleted file mode 100644 index 90271b67e1..0000000000 --- a/.changeset/v3.50.2.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"roo-cline": patch ---- - -- Fix: Inline terminal rendering parity with the VSCode Terminal (#10699 by @jerrill-johnson-bitwerx, PR #11361 by @RussellZager) -- Fix: Enable prompt caching for Bedrock custom ARN and default to ON (#10846 by @wisestmumbler, PR #11373 by @roomote) -- Feat: Add visual feedback to copy button in task actions (#11401 by @omagoduck, PR #11403 by @omagoduck) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20e350c96d..f07c212877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Roo Code Changelog +## 3.50.2 + +### Patch Changes + +- Fix: Inline terminal rendering parity with the VSCode Terminal (#10699 by @jerrill-johnson-bitwerx, PR #11361 by @RussellZager) +- Fix: Enable prompt caching for Bedrock custom ARN and default to ON (#10846 by @wisestmumbler, PR #11373 by @roomote) +- Feat: Add visual feedback to copy button in task actions (#11401 by @omagoduck, PR #11403 by @omagoduck) + ## 3.50.1 ### Patch Changes diff --git a/src/package.json b/src/package.json index 0a6b86833c..3ca2f76171 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.50.1", + "version": "3.50.2", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", From a55c85a8ec71fd984d2769ebfbebf01a8553c4a3 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 20 Feb 2026 11:33:43 -0700 Subject: [PATCH 042/109] ci: trigger code-qa workflow on pull_request_review approval (#11636) --- .github/workflows/code-qa.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index 1592b15669..f0b3c6ba50 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -7,9 +7,15 @@ on: pull_request: types: [opened, reopened, ready_for_review, synchronize] branches: [main] + pull_request_review: + types: [submitted] + branches: [main] jobs: check-translations: + if: | + github.event_name != 'pull_request_review' || + github.event.review.state == 'approved' runs-on: ubuntu-latest steps: - name: Checkout code @@ -20,6 +26,9 @@ jobs: run: node scripts/find-missing-translations.js knip: + if: | + github.event_name != 'pull_request_review' || + github.event.review.state == 'approved' runs-on: ubuntu-latest steps: - name: Checkout code @@ -30,6 +39,9 @@ jobs: run: pnpm knip compile: + if: | + github.event_name != 'pull_request_review' || + github.event.review.state == 'approved' runs-on: ubuntu-latest steps: - name: Checkout code @@ -43,6 +55,9 @@ jobs: unit-test: name: platform-unit-test (${{ matrix.name }}) + if: | + github.event_name != 'pull_request_review' || + github.event.review.state == 'approved' runs-on: ${{ matrix.os }} strategy: matrix: From 6bd6dc61a4dc26b271913ad75637b969029ba895 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:34:24 -0700 Subject: [PATCH 043/109] Release v3.50.3 (#11638) chore: add changeset for v3.50.3 Co-authored-by: Roo Code --- .changeset/v3.50.3.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/v3.50.3.md diff --git a/.changeset/v3.50.3.md b/.changeset/v3.50.3.md new file mode 100644 index 0000000000..4af6c4caef --- /dev/null +++ b/.changeset/v3.50.3.md @@ -0,0 +1,6 @@ +--- +"roo-cline": patch +--- + +- Fix: Correct Vertex AI claude-sonnet-4-6 model ID (#11625 by @yuvarajl, PR #11626 by @roomote) +- Restore Unbound as a provider (PR #11624 by @pugazhendhi-m) From 6b3097cb31ce53f700dfe42d85862991a67a0b07 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 20 Feb 2026 12:00:30 -0700 Subject: [PATCH 044/109] Revert "ci: trigger code-qa workflow on pull_request_review approval" (#11639) Revert "ci: trigger code-qa workflow on pull_request_review approval (#11636)" This reverts commit a55c85a8ec71fd984d2769ebfbebf01a8553c4a3. --- .github/workflows/code-qa.yml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index f0b3c6ba50..1592b15669 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -7,15 +7,9 @@ on: pull_request: types: [opened, reopened, ready_for_review, synchronize] branches: [main] - pull_request_review: - types: [submitted] - branches: [main] jobs: check-translations: - if: | - github.event_name != 'pull_request_review' || - github.event.review.state == 'approved' runs-on: ubuntu-latest steps: - name: Checkout code @@ -26,9 +20,6 @@ jobs: run: node scripts/find-missing-translations.js knip: - if: | - github.event_name != 'pull_request_review' || - github.event.review.state == 'approved' runs-on: ubuntu-latest steps: - name: Checkout code @@ -39,9 +30,6 @@ jobs: run: pnpm knip compile: - if: | - github.event_name != 'pull_request_review' || - github.event.review.state == 'approved' runs-on: ubuntu-latest steps: - name: Checkout code @@ -55,9 +43,6 @@ jobs: unit-test: name: platform-unit-test (${{ matrix.name }}) - if: | - github.event_name != 'pull_request_review' || - github.event.review.state == 'approved' runs-on: ${{ matrix.os }} strategy: matrix: From ae09ee5a646ade354a1cd2f35016d84cc41f50c3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:16:35 -0700 Subject: [PATCH 045/109] Changeset version bump (#11642) changeset version bump Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/v3.50.3.md | 6 ------ CHANGELOG.md | 7 +++++++ src/package.json | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) delete mode 100644 .changeset/v3.50.3.md diff --git a/.changeset/v3.50.3.md b/.changeset/v3.50.3.md deleted file mode 100644 index 4af6c4caef..0000000000 --- a/.changeset/v3.50.3.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"roo-cline": patch ---- - -- Fix: Correct Vertex AI claude-sonnet-4-6 model ID (#11625 by @yuvarajl, PR #11626 by @roomote) -- Restore Unbound as a provider (PR #11624 by @pugazhendhi-m) diff --git a/CHANGELOG.md b/CHANGELOG.md index f07c212877..1b0cc3a4d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Roo Code Changelog +## 3.50.3 + +### Patch Changes + +- Fix: Correct Vertex AI claude-sonnet-4-6 model ID (#11625 by @yuvarajl, PR #11626 by @roomote) +- Restore Unbound as a provider (PR #11624 by @pugazhendhi-m) + ## 3.50.2 ### Patch Changes diff --git a/src/package.json b/src/package.json index 3ca2f76171..93c0b7931f 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.50.2", + "version": "3.50.3", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", From 2df6e3899841ffbd978969fdc0c578a8aced8599 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Fri, 20 Feb 2026 22:15:16 -0500 Subject: [PATCH 046/109] Add pnpm version 10.8.1 to .tool-versions --- .tool-versions | 1 + 1 file changed, 1 insertion(+) diff --git a/.tool-versions b/.tool-versions index 269cea0b28..fc43bbb1c7 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ +pnpm 10.8.1 nodejs 20.19.2 From 62a7bd73547ae6f58d95ba76e2822a0374a21984 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:34:18 -0500 Subject: [PATCH 047/109] feat: add MiniMax M2.5 model (#11458) * feat: add MiniMax M2.5 model and set as default * fix: update MiniMax M2.5 contextWindow to 204_800 * Delete .changeset/add-minimax-m25.md --------- Co-authored-by: Roo Code Co-authored-by: Matt Rubens --- packages/types/src/providers/minimax.ts | 17 ++++++++++++++++- src/api/providers/__tests__/minimax.spec.ts | 20 ++++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/types/src/providers/minimax.ts b/packages/types/src/providers/minimax.ts index 96dd71769d..a47907168d 100644 --- a/packages/types/src/providers/minimax.ts +++ b/packages/types/src/providers/minimax.ts @@ -5,9 +5,24 @@ import type { ModelInfo } from "../model.js" // https://platform.minimax.io/docs/api-reference/text-openai-api // https://platform.minimax.io/docs/api-reference/text-anthropic-api export type MinimaxModelId = keyof typeof minimaxModels -export const minimaxDefaultModelId: MinimaxModelId = "MiniMax-M2" +export const minimaxDefaultModelId: MinimaxModelId = "MiniMax-M2.5" export const minimaxModels = { + "MiniMax-M2.5": { + maxTokens: 16_384, + contextWindow: 204_800, + supportsImages: false, + supportsPromptCache: true, + includedTools: ["search_and_replace"], + excludedTools: ["apply_diff"], + preserveReasoning: true, + inputPrice: 0.3, + outputPrice: 1.2, + cacheWritesPrice: 0.375, + cacheReadsPrice: 0.03, + description: + "MiniMax M2.5, the latest MiniMax model with enhanced coding and agentic capabilities, building on the strengths of the M2 series.", + }, "MiniMax-M2": { maxTokens: 16_384, contextWindow: 192_000, diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 86cb5e0194..e5df368f50 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -87,6 +87,22 @@ describe("MiniMaxHandler", () => { expect(model.info).toEqual(minimaxModels[testModelId]) }) + it("should return MiniMax-M2.5 model with correct configuration", () => { + const testModelId: MinimaxModelId = "MiniMax-M2.5" + const handlerWithModel = new MiniMaxHandler({ + apiModelId: testModelId, + minimaxApiKey: "test-minimax-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(minimaxModels[testModelId]) + expect(model.info.contextWindow).toBe(204_800) + expect(model.info.maxTokens).toBe(16_384) + expect(model.info.supportsPromptCache).toBe(true) + expect(model.info.cacheWritesPrice).toBe(0.375) + expect(model.info.cacheReadsPrice).toBe(0.03) + }) + it("should return MiniMax-M2 model with correct configuration", () => { const testModelId: MinimaxModelId = "MiniMax-M2" const handlerWithModel = new MiniMaxHandler({ @@ -175,10 +191,10 @@ describe("MiniMaxHandler", () => { expect(model.info).toEqual(minimaxModels[minimaxDefaultModelId]) }) - it("should default to MiniMax-M2 model", () => { + it("should default to MiniMax-M2.5 model", () => { const handlerDefault = new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" }) const model = handlerDefault.getModel() - expect(model.id).toBe("MiniMax-M2") + expect(model.id).toBe("MiniMax-M2.5") }) }) From ab61ee2cd6050266fdeee740427906835f081e38 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:19:42 -0500 Subject: [PATCH 048/109] Release v3.50.4 (#11671) Co-authored-by: Roo Code --- .changeset/v3.50.4.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/v3.50.4.md diff --git a/.changeset/v3.50.4.md b/.changeset/v3.50.4.md new file mode 100644 index 0000000000..4a6be9f53e --- /dev/null +++ b/.changeset/v3.50.4.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +- Feat: Add MiniMax M2.5 model support (#11471 by @love8ko, PR #11458 by @roomote) From aca95ccd04fd16c596e15fd863a46ed38325d821 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:25:27 -0500 Subject: [PATCH 049/109] Changeset version bump (#11672) * changeset version bump * Revise CHANGELOG for recent version updates Updated changelog for versions 3.50.4 to 3.48.0, including new features, fixes, and improvements. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Matt Rubens --- .changeset/v3.50.4.md | 5 ----- CHANGELOG.md | 28 ++++++++++------------------ src/package.json | 2 +- 3 files changed, 11 insertions(+), 24 deletions(-) delete mode 100644 .changeset/v3.50.4.md diff --git a/.changeset/v3.50.4.md b/.changeset/v3.50.4.md deleted file mode 100644 index 4a6be9f53e..0000000000 --- a/.changeset/v3.50.4.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"roo-cline": patch ---- - -- Feat: Add MiniMax M2.5 model support (#11471 by @love8ko, PR #11458 by @roomote) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0cc3a4d3..c162d104ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,29 +1,25 @@ # Roo Code Changelog -## 3.50.3 +## [3.50.4] - 2026-02-21 -### Patch Changes +- Feat: Add MiniMax M2.5 model support (#11471 by @love8ko, PR #11458 by @roomote) + +## [3.50.3] - 2026-02-20 - Fix: Correct Vertex AI claude-sonnet-4-6 model ID (#11625 by @yuvarajl, PR #11626 by @roomote) - Restore Unbound as a provider (PR #11624 by @pugazhendhi-m) -## 3.50.2 - -### Patch Changes +## [3.50.2] - 2026-02-20 - Fix: Inline terminal rendering parity with the VSCode Terminal (#10699 by @jerrill-johnson-bitwerx, PR #11361 by @RussellZager) - Fix: Enable prompt caching for Bedrock custom ARN and default to ON (#10846 by @wisestmumbler, PR #11373 by @roomote) - Feat: Add visual feedback to copy button in task actions (#11401 by @omagoduck, PR #11403 by @omagoduck) -## 3.50.1 - -### Patch Changes +## [3.50.1] - 2026-02-20 - Fix OpenAI Codex and OpenAI Native stream parsing for done-only and `content_part` events, including duplicate-text guards when deltas are already streamed. -## 3.50.0 - -### Minor Changes +## [3.50.0] - 2026-02-19 - Add Gemini 3.1 Pro support and set as default Gemini model (PR #11608 by @PeterDaveHello) - Add NDJSON stdin protocol, list subcommands, and modularize CLI run command (PR #11597 by @cte) @@ -31,9 +27,7 @@ - Remove integration tests (PR #11598 by @roomote) - Changeset version bump (PR #11596 by @github-actions) -## 3.49.0 - -### Minor Changes +## [3.49.0] - 2026-02-19 - Add file changes panel to track all file modifications per conversation (#11493 by @saneroen, PR #11494 by @saneroen) - Add per-workspace indexing opt-in and stop/cancel indexing controls (#11455 by @JamesRobert20, PR #11456 by @JamesRobert20) @@ -41,15 +35,13 @@ - Fix: Redesign rehydration scroll lifecycle for smoother chat experience (PR #11483 by @hannesrudolph) - Fix: Bump @roo-code/types metadata version to 1.111.0 after revert regression (PR #11588 by @roomote) -## 3.48.1 - -### Patch Changes +## [3.48.1] - 2026-02-18 - Fix: Await MCP server initialization before returning McpHub instance, preventing race conditions (PR #11518 by @daniel-lxs) - Fix: Correct Bedrock Claude Sonnet 4.6 model ID (#11509 by @PeterDaveHello, PR #11569 by @PeterDaveHello) - Add DeleteQueuedMessage IPC command for managing queued messages (PR #11464 by @roomote) -## [3.48.0] +## [3.48.0] - 2026-02-17 - Add Anthropic Claude Sonnet 4.6 support across all providers — Anthropic, Bedrock, Vertex, OpenRouter, and Vercel AI Gateway (PR #11509 by @PeterDaveHello) - Add lock toggle to pin API config across all modes in a workspace (PR #11295 by @hannesrudolph) diff --git a/src/package.json b/src/package.json index 93c0b7931f..241312ac8c 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.50.3", + "version": "3.50.4", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", From 29caab9d0d5613064bc3f357cd9c20f7bb8033fd Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Mon, 23 Feb 2026 23:13:26 -0800 Subject: [PATCH 050/109] feat: warm Roo models on CLI startup (#11722) When the CLI is configured with the Roo provider, proactively fetch and warm the model list during activation so that model information is available before the first prompt is sent. The warmup has a 10s timeout and failures are logged only in debug mode so they never block normal operation. Co-authored-by: Claude Opus 4.6 --- apps/cli/src/commands/cli/run.ts | 65 ++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index b72e4e7283..8c9d82d903 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -21,6 +21,7 @@ import { JsonEventEmitter } from "@/agent/json-event-emitter.js" import { createClient } from "@/lib/sdk/index.js" import { loadToken, loadSettings } from "@/lib/storage/index.js" +import { isRecord } from "@/lib/utils/guards.js" import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js" import { runOnboarding } from "@/lib/utils/onboarding.js" import { getDefaultExtensionPath } from "@/lib/utils/extension.js" @@ -30,6 +31,60 @@ import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js" import { runStdinStreamMode } from "./stdin-stream.js" const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROO_MODEL_WARMUP_TIMEOUT_MS = 10_000 + +async function warmRooModels(host: ExtensionHost): Promise { + await new Promise((resolve, reject) => { + let settled = false + + const cleanup = () => { + clearTimeout(timeoutId) + host.off("extensionWebviewMessage", onMessage) + } + + const finish = (fn: () => void) => { + if (settled) return + settled = true + cleanup() + fn() + } + + const onMessage = (message: unknown) => { + if (!isRecord(message)) { + return + } + + if (message.type !== "singleRouterModelFetchResponse") { + return + } + + const values = isRecord(message.values) ? message.values : undefined + + if (values?.provider !== "roo") { + return + } + + if (message.success === false) { + const errorMessage = + typeof message.error === "string" && message.error.length > 0 + ? message.error + : "failed to refresh Roo models" + + finish(() => reject(new Error(errorMessage))) + return + } + + finish(() => resolve()) + } + + const timeoutId = setTimeout(() => { + finish(() => reject(new Error(`timed out waiting for Roo models after ${ROO_MODEL_WARMUP_TIMEOUT_MS}ms`))) + }, ROO_MODEL_WARMUP_TIMEOUT_MS) + + host.on("extensionWebviewMessage", onMessage) + host.sendToExtension({ type: "requestRooModels" }) + }) +} export async function run(promptArg: string | undefined, flagOptions: FlagOptions) { setLogger({ @@ -295,6 +350,16 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption try { await host.activate() + if (extensionHostOptions.provider === "roo") { + try { + await warmRooModels(host) + } catch (warmupError) { + if (flagOptions.debug) { + const message = warmupError instanceof Error ? warmupError.message : String(warmupError) + console.error(`[CLI] Warning: Roo model warmup failed: ${message}`) + } + } + } if (jsonEmitter) { jsonEmitter.attachToClient(host.client) From 48d7e29ed815c47b10afb41847327b8dbb56a1f1 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Mon, 23 Feb 2026 23:15:53 -0800 Subject: [PATCH 051/109] chore(cli): prepare release v0.1.1 (#11723) --- apps/cli/CHANGELOG.md | 7 +++++++ apps/cli/package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index b2c0446a03..0703c6c23f 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.1] - 2026-02-24 + +### Added + +- **Roo Model Warmup**: When configured with the Roo provider, the CLI now proactively fetches and warms the model list during activation so that model information is available before the first prompt is sent. The warmup has a 10s timeout and failures are logged only in debug mode. +- **Unbound Provider**: Added Unbound as an available provider option. + ## [0.1.0] - 2026-02-19 ### Added diff --git a/apps/cli/package.json b/apps/cli/package.json index d0659d4984..c3571a3c75 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@roo-code/cli", - "version": "0.1.0", + "version": "0.1.1", "description": "Roo Code CLI - Run the Roo Code agent from the command line", "private": true, "type": "module", From b73dc15fdaa840e81456bf42ade9adb4c943bc7f Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 08:54:49 -0800 Subject: [PATCH 052/109] fix(marketing): restore Linear integration page (#11725) fix(marketing): restore Linear integration page removed in revert Co-authored-by: Roo Code --- apps/web-roo-code/src/app/linear/page.tsx | 413 ++++++++++++++++ .../components/linear/linear-issue-demo.tsx | 442 ++++++++++++++++++ 2 files changed, 855 insertions(+) create mode 100644 apps/web-roo-code/src/app/linear/page.tsx create mode 100644 apps/web-roo-code/src/components/linear/linear-issue-demo.tsx diff --git a/apps/web-roo-code/src/app/linear/page.tsx b/apps/web-roo-code/src/app/linear/page.tsx new file mode 100644 index 0000000000..40334e2698 --- /dev/null +++ b/apps/web-roo-code/src/app/linear/page.tsx @@ -0,0 +1,413 @@ +import { + ArrowRight, + CheckCircle, + CreditCard, + Eye, + GitBranch, + GitPullRequest, + Link2, + MessageSquare, + Settings, + Shield, +} from "lucide-react" +import type { LucideIcon } from "lucide-react" +import type { Metadata } from "next" + +import { AnimatedBackground } from "@/components/homepage" +import { LinearIssueDemo } from "@/components/linear/linear-issue-demo" +import { Button } from "@/components/ui" +import { EXTERNAL_LINKS } from "@/lib/constants" +import { SEO } from "@/lib/seo" +import { ogImageUrl } from "@/lib/og" + +const TITLE = "Roo Code for Linear" +const DESCRIPTION = "Assign development work to @Roo Code directly from Linear. Get PRs back without switching tools." +const OG_DESCRIPTION = "Turn Linear Issues into Pull Requests" +const PATH = "/linear" + +// Featured Workflow section is temporarily commented out until video is ready +// const LINEAR_DEMO_YOUTUBE_ID = "" + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { + canonical: `${SEO.url}${PATH}`, + }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: `${SEO.url}${PATH}`, + siteName: SEO.name, + images: [ + { + url: ogImageUrl(TITLE, OG_DESCRIPTION), + width: 1200, + height: 630, + alt: TITLE, + }, + ], + locale: SEO.locale, + type: "website", + }, + twitter: { + card: SEO.twitterCard, + title: TITLE, + description: DESCRIPTION, + images: [ogImageUrl(TITLE, OG_DESCRIPTION)], + }, + keywords: [ + ...SEO.keywords, + "linear integration", + "issue to PR", + "AI in Linear", + "engineering workflow automation", + "Roo Code Cloud", + ], +} + +// Invalidate cache when a request comes in, at most once every hour. +export const revalidate = 3600 + +type ValueProp = { + icon: LucideIcon + title: string + description: string +} + +const VALUE_PROPS: ValueProp[] = [ + { + icon: GitBranch, + title: "Work where you already work.", + description: + "Assign development work to @Roo Code directly from Linear. No new tools to learn, no context switching required.", + }, + { + icon: Eye, + title: "Progress is visible.", + description: + "Watch progress unfold in real-time. Roo Code posts updates as comments, so your whole team stays in the loop.", + }, + { + icon: MessageSquare, + title: "Mention for refinement.", + description: + 'Need changes? Just comment "@Roo Code also add dark mode support" and the agent picks up where it left off.', + }, + { + icon: Link2, + title: "Full traceability.", + description: + "Every PR links back to the originating issue. Every issue shows its linked PR. Your audit trail stays clean.", + }, + { + icon: Settings, + title: "Organization-level setup.", + description: + "Connect once, use everywhere. Your team members can assign issues to @Roo Code without individual configuration.", + }, + { + icon: Shield, + title: "Safe by design.", + description: + "Agents never touch main/master directly. They produce branches and PRs. You review and approve before merge.", + }, +] + +// type WorkflowStep = { +// step: number +// title: string +// description: string +// } + +// const WORKFLOW_STEPS: WorkflowStep[] = [ +// { +// step: 1, +// title: "Create an issue", +// description: "Write your issue with acceptance criteria. Be as detailed as you like.", +// }, +// { +// step: 2, +// title: "Call @Roo Code", +// description: "Mention @Roo Code in a comment to start. The agent begins working immediately.", +// }, +// { +// step: 3, +// title: "Watch progress", +// description: "Roo Code posts status updates as comments. Refine with @-mentions if needed.", +// }, +// { +// step: 4, +// title: "Review the PR", +// description: "When ready, the PR link appears in the issue. Review, iterate, and ship.", +// }, +// ] + +type OnboardingStep = { + icon: LucideIcon + title: string + description: string + link?: { + href: string + text: string + } +} + +const ONBOARDING_STEPS: OnboardingStep[] = [ + { + icon: CreditCard, + title: "1. Team Plan", + description: "Linear integration requires a Team plan.", + link: { + href: EXTERNAL_LINKS.CLOUD_APP_TEAM_TRIAL, + text: "Start a free trial", + }, + }, + { + icon: GitPullRequest, + title: "2. Connect GitHub", + description: "Link your repositories so Roo Code can open PRs on your behalf.", + }, + { + icon: Settings, + title: "3. Connect Linear", + description: "Authorize via OAuth. No API keys to manage or rotate.", + }, + { + icon: CheckCircle, + title: "4. Link & Start", + description: "Map your Linear project to a repo, then assign or mention @Roo Code.", + }, +] + +function LinearIcon({ className }: { className?: string }) { + return ( + + + + ) +} + +export default function LinearPage(): JSX.Element { + return ( + <> + {/* Hero Section */} +
+ +
+
+
+
+ + Powered by Roo Code Cloud +
+

+ Turn Linear Issues into Pull Requests +

+

+ Assign development work to @Roo Code directly from Linear. Get PRs back without + switching tools. +

+ +
+ +
+ +
+
+
+
+ + {/* Value Props Section */} +
+
+
+
+
+
+

+ Why your team will love using Roo Code in Linear +

+

+ AI agents that understand context, keep your team in the loop, and deliver PRs you can + review. +

+
+
+ {VALUE_PROPS.map((prop, index) => { + const Icon = prop.icon + return ( +
+
+ +
+

{prop.title}

+

{prop.description}

+
+ ) + })} +
+
+
+ + {/* Featured Workflow Section - temporarily commented out until video is ready +
+
+
+
+
+ +
+
+ + Featured Workflow +
+

Issue to Shipped Feature

+

+ Stay in Linear from assignment to review. Roo Code keeps the issue updated and links the PR + when it's ready. +

+
+ +
+
+ {/* YouTube Video Embed or Placeholder */} + {/*
+ {LINEAR_DEMO_YOUTUBE_ID ? ( +