From 647a9493698a3103855c2ff23103c7678dfc8a1d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 20 Aug 2026 18:39:13 +0300 Subject: [PATCH 01/59] feat(providers): support custom API protocols --- .../sections/providers/CustomProviderForm.tsx | 27 +++++++++++++++ .../providers/custom-provider-form.test.ts | 22 ++++++++++++ .../providers/custom-provider-form.ts | 34 ++++++++++++++++--- .../ui/src/lib/i18n/messages/de.settings.ts | 7 +++- .../ui/src/lib/i18n/messages/en.settings.ts | 7 +++- .../ui/src/lib/i18n/messages/es.settings.ts | 7 +++- .../ui/src/lib/i18n/messages/fr.settings.ts | 7 +++- .../ui/src/lib/i18n/messages/ja.settings.ts | 7 +++- .../ui/src/lib/i18n/messages/ko.settings.ts | 7 +++- .../ui/src/lib/i18n/messages/pl.settings.ts | 7 +++- .../src/lib/i18n/messages/pt-BR.settings.ts | 7 +++- .../ui/src/lib/i18n/messages/uk.settings.ts | 7 +++- .../src/lib/i18n/messages/zh-CN.settings.ts | 7 +++- .../src/lib/i18n/messages/zh-TW.settings.ts | 7 +++- packages/web/server/lib/opencode/providers.js | 13 ++++--- .../web/server/lib/opencode/providers.test.js | 27 +++++++++++++++ 16 files changed, 180 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/components/sections/providers/CustomProviderForm.tsx b/packages/ui/src/components/sections/providers/CustomProviderForm.tsx index 6fce317e..557151d2 100644 --- a/packages/ui/src/components/sections/providers/CustomProviderForm.tsx +++ b/packages/ui/src/components/sections/providers/CustomProviderForm.tsx @@ -10,9 +10,11 @@ import { } from '@/components/sections/shared/SettingsSection'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Icon } from '@/components/icon/Icon'; import { useI18n } from '@/lib/i18n'; import { + CUSTOM_PROVIDER_PROTOCOLS, createEmptyCustomProviderForm, createHeaderRow, createModelRow, @@ -161,6 +163,31 @@ export const CustomProviderForm: React.FC = ({ {err.providerID ?

{err.providerID}

: null} + + + + key; const baseForm = (overrides: Partial = {}): CustomProviderFormState => ({ providerID: 'custom-provider', name: 'Custom Provider', + protocol: 'openai-chat', baseURL: 'https://api.example.com/v1', apiKey: 'sk-test', models: [{ row: 'm0', id: 'model-a', name: 'Model A' }], @@ -96,6 +97,16 @@ describe('validateCustomProvider', () => { expect(result.result?.config.env).toEqual(['CUSTOM_PROVIDER_KEY']); }); + test('uses the selected OpenCode provider adapter', () => { + const result = validateCustomProvider({ + form: baseForm({ protocol: 'openai-responses' }), + t, + existingProviderIDs: new Set(), + }); + + expect(result.result?.config.npm).toBe('@ai-sdk/openai'); + }); + test('rejects missing credentials', () => { const result = validateCustomProvider({ form: baseForm({ apiKey: ' ' }), @@ -300,10 +311,21 @@ describe('provider edit helpers', () => { expect(state.name).toBe('Campus LLM'); expect(state.baseURL).toBe('https://llm.example.edu/v1'); expect(state.apiKey).toBe('{env:CAMPUS_KEY}'); + expect(state.protocol).toBe('openai-chat'); expect(state.models[0]).toEqual({ row: state.models[0].row, id: 'fast', name: 'Fast' }); expect(state.headers[0]).toEqual({ row: state.headers[0].row, key: 'X-Campus', value: '1' }); }); + test('prefills the protocol from a custom provider model', () => { + const state = providerToCustomFormState({ + id: 'responses-api', + options: { baseURL: 'https://api.example.com/v1' }, + models: [{ id: 'gpt', name: 'GPT', api: { npm: '@ai-sdk/openai' } }], + }); + + expect(state.protocol).toBe('openai-responses'); + }); + test('requires a config-layer source before treating a provider as editable custom', () => { const catalogLike = { id: 'openai', diff --git a/packages/ui/src/components/sections/providers/custom-provider-form.ts b/packages/ui/src/components/sections/providers/custom-provider-form.ts index 49178ece..355ee6db 100644 --- a/packages/ui/src/components/sections/providers/custom-provider-form.ts +++ b/packages/ui/src/components/sections/providers/custom-provider-form.ts @@ -1,10 +1,16 @@ /** - * Custom / Other OpenAI-compatible provider form helpers. + * Custom provider form helpers. * Mirrors OpenCode web UI validation and request construction so a provider * can be defined from Settings without code changes. */ -export const CUSTOM_PROVIDER_NPM = '@ai-sdk/openai-compatible'; +export const CUSTOM_PROVIDER_PROTOCOLS = { + 'openai-chat': '@ai-sdk/openai-compatible', + 'openai-responses': '@ai-sdk/openai', + 'anthropic-messages': '@ai-sdk/anthropic', +} as const; +export type CustomProviderProtocol = keyof typeof CUSTOM_PROVIDER_PROTOCOLS; +export type CustomProviderNpm = (typeof CUSTOM_PROVIDER_PROTOCOLS)[CustomProviderProtocol]; export const CUSTOM_PROVIDER_ID = '__custom_provider__'; const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/; const BASE_URL_PATTERN = /^https?:\/\//; @@ -30,6 +36,7 @@ export type HeaderRow = { export type CustomProviderFormState = { providerID: string; name: string; + protocol: CustomProviderProtocol; baseURL: string; apiKey: string; models: ModelRow[]; @@ -54,7 +61,7 @@ export type HeaderFieldErrors = { }; export type CustomProviderConfig = { - npm: typeof CUSTOM_PROVIDER_NPM; + npm: CustomProviderNpm; name: string; env?: string[]; options: { @@ -120,12 +127,24 @@ export const createHeaderRow = (): HeaderRow => ({ export const createEmptyCustomProviderForm = (): CustomProviderFormState => ({ providerID: '', name: '', + protocol: 'openai-chat', baseURL: '', apiKey: '', models: [createModelRow()], headers: [createHeaderRow()], }); +function protocolFromNpm(npm: string | undefined): CustomProviderProtocol { + switch (npm) { + case '@ai-sdk/openai': + return 'openai-responses'; + case '@ai-sdk/anthropic': + return 'anthropic-messages'; + default: + return 'openai-chat'; + } +} + function parseEnvApiKey(apiKey: string): { env?: string; key?: string } { const trimmed = apiKey.trim(); if (!trimmed) { @@ -159,7 +178,7 @@ export function isCustomOpenAICompatibleProvider(provider: ProviderLikeForCustom const api = 'api' in model && model.api && typeof model.api === 'object' ? model.api as { npm?: unknown } : null; - return typeof api?.npm === 'string' && api.npm === CUSTOM_PROVIDER_NPM; + return typeof api?.npm === 'string' && new Set(Object.values(CUSTOM_PROVIDER_PROTOCOLS)).has(api.npm); }); } @@ -238,9 +257,14 @@ export function providerToCustomFormState(provider: ProviderLikeForCustomForm): ? provider.env.find((entry) => typeof entry === 'string' && entry.trim().length > 0)?.trim() : undefined; + const modelWithApi = modelEntries.find( + (model): model is { id?: string; name?: string; api?: { npm?: string } } => 'api' in model, + ); + return { providerID: provider.id, name: typeof provider.name === 'string' && provider.name.trim() ? provider.name : provider.id, + protocol: protocolFromNpm(modelWithApi?.api?.npm), baseURL, apiKey: envName ? `{env:${envName}}` : '', models, @@ -360,7 +384,7 @@ export function validateCustomProvider(input: ValidateCustomProviderInput): Vali name, apiKey: key, config: { - npm: CUSTOM_PROVIDER_NPM, + npm: CUSTOM_PROVIDER_PROTOCOLS[input.form.protocol], name, ...(env ? { env: [env] } : {}), options: { diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 02ee5eeb..8029b28b 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1309,13 +1309,18 @@ export const settingsDict = { 'settings.providers.page.custom.optionLabel': 'Andere / Benutzerdefiniert', 'settings.providers.page.custom.title': 'Benutzerdefinierter Anbieter', 'settings.providers.page.custom.editTitle': 'Benutzerdefinierten Anbieter bearbeiten', - 'settings.providers.page.custom.description': 'Fügen Sie einen OpenAI-kompatiblen Anbieter mit Basis-URL, Anmeldedaten und Modellliste hinzu. Wird in der OpenCode-Konfiguration gespeichert und steht im Chat wie jeder andere Anbieter zur Verfügung.', + 'settings.providers.page.custom.description': 'Fügen Sie einen Anbieter mit Basis-URL, Anmeldedaten, Modellliste und unterstütztem API-Protokoll hinzu. Wird zur Nutzung im Chat in der OpenCode-Konfiguration gespeichert.', 'settings.providers.page.custom.field.providerID.label': 'Anbieter-ID', 'settings.providers.page.custom.field.providerID.placeholder': 'mein-anbieter', 'settings.providers.page.custom.field.providerID.info': 'Kleinbuchstaben, Zahlen, Bindestriche und Unterstriche. Wird als OpenCode-Anbieter-ID verwendet.', 'settings.providers.page.custom.field.name.label': 'Anzeigename', 'settings.providers.page.custom.field.name.placeholder': 'Mein Anbieter', 'settings.providers.page.custom.field.name.info': 'Wird in den Anbieter- und Modellauswahlen angezeigt.', + 'settings.providers.page.custom.field.protocol.label': 'API-Protokoll', + 'settings.providers.page.custom.field.protocol.info': 'Wählen Sie das Anfrageformat, das diese API implementiert.', + 'settings.providers.page.custom.field.protocol.openaiChat': 'OpenAI Chat Completions', + 'settings.providers.page.custom.field.protocol.openaiResponses': 'OpenAI Responses', + 'settings.providers.page.custom.field.protocol.anthropicMessages': 'Anthropic Messages', 'settings.providers.page.custom.field.baseURL.label': 'Basis-URL', 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', 'settings.providers.page.custom.field.baseURL.info': 'OpenAI-kompatible API-Basis-URL. Muss mit http:// oder https:// beginnen.', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 5e58a605..00a788ad 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1371,13 +1371,18 @@ export const settingsDict = { 'settings.providers.page.custom.optionLabel': 'Other / Custom', 'settings.providers.page.custom.title': 'Custom provider', 'settings.providers.page.custom.editTitle': 'Edit custom provider', - 'settings.providers.page.custom.description': 'Add an OpenAI-compatible provider with a base URL, credentials, and model list. Saved to OpenCode config so it works in chat like any other provider.', + 'settings.providers.page.custom.description': 'Add a provider with a base URL, credentials, model list, and supported API protocol. Saved to OpenCode config for use in chat.', 'settings.providers.page.custom.field.providerID.label': 'Provider ID', 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', 'settings.providers.page.custom.field.providerID.info': 'Lowercase letters, numbers, hyphens, and underscores. Used as the OpenCode provider id.', 'settings.providers.page.custom.field.name.label': 'Display name', 'settings.providers.page.custom.field.name.placeholder': 'My Provider', 'settings.providers.page.custom.field.name.info': 'Shown in the provider and model pickers.', + 'settings.providers.page.custom.field.protocol.label': 'API protocol', + 'settings.providers.page.custom.field.protocol.info': 'Choose the request format implemented by this API.', + 'settings.providers.page.custom.field.protocol.openaiChat': 'OpenAI Chat Completions', + 'settings.providers.page.custom.field.protocol.openaiResponses': 'OpenAI Responses', + 'settings.providers.page.custom.field.protocol.anthropicMessages': 'Anthropic Messages', 'settings.providers.page.custom.field.baseURL.label': 'Base URL', 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', 'settings.providers.page.custom.field.baseURL.info': 'OpenAI-compatible API base URL. Must start with http:// or https://.', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index bd06384c..13dc9ce9 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1340,13 +1340,18 @@ export const settingsDict = { "settings.providers.page.custom.title": "Proveedor personalizado", "settings.providers.page.custom.editTitle": "Editar proveedor personalizado", - "settings.providers.page.custom.description": "Añade un proveedor compatible con OpenAI con URL base, credenciales y lista de modelos. Se guarda en la configuración de OpenCode para usarlo en el chat como cualquier otro proveedor.", + "settings.providers.page.custom.description": "Añade un proveedor con URL base, credenciales, lista de modelos y un protocolo de API compatible. Se guarda en la configuración de OpenCode para usarlo en el chat.", "settings.providers.page.custom.field.providerID.label": "ID del proveedor", "settings.providers.page.custom.field.providerID.placeholder": "mi-proveedor", "settings.providers.page.custom.field.providerID.info": "Minúsculas, números, guiones y guiones bajos. Se usa como ID de proveedor de OpenCode.", "settings.providers.page.custom.field.name.label": "Nombre visible", "settings.providers.page.custom.field.name.placeholder": "Mi proveedor", "settings.providers.page.custom.field.name.info": "Se muestra en los selectores de proveedor y modelo.", + "settings.providers.page.custom.field.protocol.label": "Protocolo de API", + "settings.providers.page.custom.field.protocol.info": "Elige el formato de solicitud que implementa esta API.", + "settings.providers.page.custom.field.protocol.openaiChat": "OpenAI Chat Completions", + "settings.providers.page.custom.field.protocol.openaiResponses": "OpenAI Responses", + "settings.providers.page.custom.field.protocol.anthropicMessages": "Anthropic Messages", "settings.providers.page.custom.field.baseURL.label": "URL base", "settings.providers.page.custom.field.baseURL.placeholder": "https://api.example.com/v1", "settings.providers.page.custom.field.baseURL.info": "URL base de la API compatible con OpenAI. Debe empezar por http:// o https://.", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index b75e1c35..477cb651 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1258,13 +1258,18 @@ export const settingsDict = { 'settings.providers.page.custom.title': 'Fournisseur personnalisé', 'settings.providers.page.custom.editTitle': 'Modifier le fournisseur personnalisé', - 'settings.providers.page.custom.description': 'Ajoutez un fournisseur compatible OpenAI avec une URL de base, des identifiants et une liste de modèles. Enregistré dans la configuration OpenCode pour l’utiliser dans le chat comme les autres fournisseurs.', + 'settings.providers.page.custom.description': 'Ajoutez un fournisseur avec une URL de base, des identifiants, une liste de modèles et un protocole API pris en charge. Enregistré dans la configuration OpenCode pour le chat.', 'settings.providers.page.custom.field.providerID.label': 'ID du fournisseur', 'settings.providers.page.custom.field.providerID.placeholder': 'mon-fournisseur', 'settings.providers.page.custom.field.providerID.info': 'Minuscules, chiffres, tirets et underscores. Utilisé comme ID de fournisseur OpenCode.', 'settings.providers.page.custom.field.name.label': 'Nom affiché', 'settings.providers.page.custom.field.name.placeholder': 'Mon fournisseur', 'settings.providers.page.custom.field.name.info': 'Affiché dans les sélecteurs de fournisseur et de modèle.', + 'settings.providers.page.custom.field.protocol.label': 'Protocole API', + 'settings.providers.page.custom.field.protocol.info': 'Choisissez le format de requête implémenté par cette API.', + 'settings.providers.page.custom.field.protocol.openaiChat': 'OpenAI Chat Completions', + 'settings.providers.page.custom.field.protocol.openaiResponses': 'OpenAI Responses', + 'settings.providers.page.custom.field.protocol.anthropicMessages': 'Anthropic Messages', 'settings.providers.page.custom.field.baseURL.label': 'URL de base', 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', 'settings.providers.page.custom.field.baseURL.info': 'URL de base de l’API compatible OpenAI. Doit commencer par http:// ou https://.', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 00319089..0f6d90f2 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1373,13 +1373,18 @@ export const settingsDict = { 'settings.providers.page.custom.title': 'カスタムプロバイダー', 'settings.providers.page.custom.editTitle': 'カスタムプロバイダーを編集', - 'settings.providers.page.custom.description': 'ベース URL・認証情報・モデル一覧を指定して、OpenAI 互換プロバイダーを追加します。OpenCode 設定に保存され、他のプロバイダーと同様にチャットで使えます。', + 'settings.providers.page.custom.description': 'ベース URL、認証情報、モデル一覧、対応 API プロトコルを指定してプロバイダーを追加します。チャットで使えるよう OpenCode 設定に保存されます。', 'settings.providers.page.custom.field.providerID.label': 'プロバイダー ID', 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', 'settings.providers.page.custom.field.providerID.info': '小文字・数字・ハイフン・アンダースコア。OpenCode のプロバイダー ID として使われます。', 'settings.providers.page.custom.field.name.label': '表示名', 'settings.providers.page.custom.field.name.placeholder': 'My Provider', 'settings.providers.page.custom.field.name.info': 'プロバイダーおよびモデル選択に表示されます。', + 'settings.providers.page.custom.field.protocol.label': 'API プロトコル', + 'settings.providers.page.custom.field.protocol.info': 'この API が実装しているリクエスト形式を選択します。', + 'settings.providers.page.custom.field.protocol.openaiChat': 'OpenAI Chat Completions', + 'settings.providers.page.custom.field.protocol.openaiResponses': 'OpenAI Responses', + 'settings.providers.page.custom.field.protocol.anthropicMessages': 'Anthropic Messages', 'settings.providers.page.custom.field.baseURL.label': 'ベース URL', 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', 'settings.providers.page.custom.field.baseURL.info': 'OpenAI 互換 API のベース URL。http:// または https:// で始めてください。', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 74c6bc98..0d56f1bf 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1340,13 +1340,18 @@ export const settingsDict = { 'settings.providers.page.custom.title': '사용자 정의 제공자', 'settings.providers.page.custom.editTitle': '사용자 지정 공급자 편집', - 'settings.providers.page.custom.description': '기본 URL, 자격 증명, 모델 목록으로 OpenAI 호환 제공자를 추가합니다. OpenCode 설정에 저장되어 다른 제공자와 같이 채팅에서 사용할 수 있습니다.', + 'settings.providers.page.custom.description': '기본 URL, 자격 증명, 모델 목록 및 지원되는 API 프로토콜로 제공자를 추가합니다. 채팅에 사용할 수 있도록 OpenCode 설정에 저장됩니다.', 'settings.providers.page.custom.field.providerID.label': '제공자 ID', 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', 'settings.providers.page.custom.field.providerID.info': '소문자, 숫자, 하이픈, 밑줄. OpenCode 제공자 ID로 사용됩니다.', 'settings.providers.page.custom.field.name.label': '표시 이름', 'settings.providers.page.custom.field.name.placeholder': '내 제공자', 'settings.providers.page.custom.field.name.info': '제공자 및 모델 선택기에 표시됩니다.', + 'settings.providers.page.custom.field.protocol.label': 'API 프로토콜', + 'settings.providers.page.custom.field.protocol.info': '이 API가 구현하는 요청 형식을 선택하세요.', + 'settings.providers.page.custom.field.protocol.openaiChat': 'OpenAI Chat Completions', + 'settings.providers.page.custom.field.protocol.openaiResponses': 'OpenAI Responses', + 'settings.providers.page.custom.field.protocol.anthropicMessages': 'Anthropic Messages', 'settings.providers.page.custom.field.baseURL.label': '기본 URL', 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', 'settings.providers.page.custom.field.baseURL.info': 'OpenAI 호환 API 기본 URL. http:// 또는 https://로 시작해야 합니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 9092163f..6053bf59 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1419,13 +1419,18 @@ export const settingsDict = { 'settings.providers.page.custom.title': 'Niestandardowy dostawca', 'settings.providers.page.custom.editTitle': 'Edytuj niestandardowego dostawcę', - 'settings.providers.page.custom.description': 'Dodaj dostawcę zgodnego z OpenAI, podając adres bazowy, poświadczenia i listę modeli. Zapisuje się w konfiguracji OpenCode i działa w czacie jak każdy inny dostawca.', + 'settings.providers.page.custom.description': 'Dodaj dostawcę z adresem bazowym, poświadczeniami, listą modeli i obsługiwanym protokołem API. Zapisuje się w konfiguracji OpenCode do użycia w czacie.', 'settings.providers.page.custom.field.providerID.label': 'ID dostawcy', 'settings.providers.page.custom.field.providerID.placeholder': 'moj-dostawca', 'settings.providers.page.custom.field.providerID.info': 'Małe litery, cyfry, myślniki i podkreślenia. Używane jako ID dostawcy OpenCode.', 'settings.providers.page.custom.field.name.label': 'Nazwa wyświetlana', 'settings.providers.page.custom.field.name.placeholder': 'Mój dostawca', 'settings.providers.page.custom.field.name.info': 'Widoczna w selektorach dostawcy i modelu.', + 'settings.providers.page.custom.field.protocol.label': 'Protokół API', + 'settings.providers.page.custom.field.protocol.info': 'Wybierz format żądania obsługiwany przez to API.', + 'settings.providers.page.custom.field.protocol.openaiChat': 'OpenAI Chat Completions', + 'settings.providers.page.custom.field.protocol.openaiResponses': 'OpenAI Responses', + 'settings.providers.page.custom.field.protocol.anthropicMessages': 'Anthropic Messages', 'settings.providers.page.custom.field.baseURL.label': 'Adres bazowy', 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', 'settings.providers.page.custom.field.baseURL.info': 'Bazowy URL API zgodnego z OpenAI. Musi zaczynać się od http:// lub https://.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index d85f354c..bae5d5c7 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1340,13 +1340,18 @@ export const settingsDict = { "settings.providers.page.custom.title": "Provedor personalizado", "settings.providers.page.custom.editTitle": "Editar provedor personalizado", - "settings.providers.page.custom.description": "Adicione um provedor compatível com OpenAI com URL base, credenciais e lista de modelos. Salvo na configuração do OpenCode para uso no chat como qualquer outro provedor.", + "settings.providers.page.custom.description": "Adicione um provedor com URL base, credenciais, lista de modelos e protocolo de API compatível. Ele é salvo na configuração do OpenCode para uso no chat.", "settings.providers.page.custom.field.providerID.label": "ID do provedor", "settings.providers.page.custom.field.providerID.placeholder": "meu-provedor", "settings.providers.page.custom.field.providerID.info": "Letras minúsculas, números, hífens e sublinhados. Usado como ID de provedor do OpenCode.", "settings.providers.page.custom.field.name.label": "Nome de exibição", "settings.providers.page.custom.field.name.placeholder": "Meu provedor", "settings.providers.page.custom.field.name.info": "Mostrado nos seletores de provedor e modelo.", + "settings.providers.page.custom.field.protocol.label": "Protocolo de API", + "settings.providers.page.custom.field.protocol.info": "Escolha o formato de solicitação implementado por esta API.", + "settings.providers.page.custom.field.protocol.openaiChat": "OpenAI Chat Completions", + "settings.providers.page.custom.field.protocol.openaiResponses": "OpenAI Responses", + "settings.providers.page.custom.field.protocol.anthropicMessages": "Anthropic Messages", "settings.providers.page.custom.field.baseURL.label": "URL base", "settings.providers.page.custom.field.baseURL.placeholder": "https://api.example.com/v1", "settings.providers.page.custom.field.baseURL.info": "URL base da API compatível com OpenAI. Deve começar com http:// ou https://.", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index efb4bfbe..c8637731 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1340,13 +1340,18 @@ export const settingsDict = { "settings.providers.page.custom.title": "Власний провайдер", "settings.providers.page.custom.editTitle": "Редагувати власного провайдера", - "settings.providers.page.custom.description": "Додайте OpenAI-сумісного провайдера з базовою URL-адресою, обліковими даними та списком моделей. Зберігається в конфігурації OpenCode й працює в чаті як будь-який інший провайдер.", + "settings.providers.page.custom.description": "Додайте провайдера з базовою URL-адресою, обліковими даними, списком моделей і підтримуваним протоколом API. Зберігається в конфігурації OpenCode для використання в чаті.", "settings.providers.page.custom.field.providerID.label": "ID провайдера", "settings.providers.page.custom.field.providerID.placeholder": "mij-provider", "settings.providers.page.custom.field.providerID.info": "Малі літери, цифри, дефіси та підкреслення. Використовується як ID провайдера OpenCode.", "settings.providers.page.custom.field.name.label": "Відображувана назва", "settings.providers.page.custom.field.name.placeholder": "Мій провайдер", "settings.providers.page.custom.field.name.info": "Показується у виборі провайдера та моделі.", + "settings.providers.page.custom.field.protocol.label": "Протокол API", + "settings.providers.page.custom.field.protocol.info": "Виберіть формат запиту, який реалізує цей API.", + "settings.providers.page.custom.field.protocol.openaiChat": "OpenAI Chat Completions", + "settings.providers.page.custom.field.protocol.openaiResponses": "OpenAI Responses", + "settings.providers.page.custom.field.protocol.anthropicMessages": "Anthropic Messages", "settings.providers.page.custom.field.baseURL.label": "Базова URL-адреса", "settings.providers.page.custom.field.baseURL.placeholder": "https://api.example.com/v1", "settings.providers.page.custom.field.baseURL.info": "Базова URL-адреса OpenAI-сумісного API. Має починатися з http:// або https://.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index ee4a87bc..b37a906e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1340,13 +1340,18 @@ export const settingsDict = { 'settings.providers.page.custom.title': '自定义提供商', 'settings.providers.page.custom.editTitle': '编辑自定义提供商', - 'settings.providers.page.custom.description': '通过指定基础 URL、凭据和模型列表,添加兼容 OpenAI 的提供商。会写入 OpenCode 配置,可像其他提供商一样在聊天中使用。', + 'settings.providers.page.custom.description': '通过指定基础 URL、凭据、模型列表和支持的 API 协议添加提供商。会写入 OpenCode 配置以供聊天使用。', 'settings.providers.page.custom.field.providerID.label': '提供商 ID', 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', 'settings.providers.page.custom.field.providerID.info': '小写字母、数字、连字符和下划线。用作 OpenCode 提供商 ID。', 'settings.providers.page.custom.field.name.label': '显示名称', 'settings.providers.page.custom.field.name.placeholder': '我的提供商', 'settings.providers.page.custom.field.name.info': '显示在提供商和模型选择器中。', + 'settings.providers.page.custom.field.protocol.label': 'API 协议', + 'settings.providers.page.custom.field.protocol.info': '选择此 API 实现的请求格式。', + 'settings.providers.page.custom.field.protocol.openaiChat': 'OpenAI Chat Completions', + 'settings.providers.page.custom.field.protocol.openaiResponses': 'OpenAI Responses', + 'settings.providers.page.custom.field.protocol.anthropicMessages': 'Anthropic Messages', 'settings.providers.page.custom.field.baseURL.label': '基础 URL', 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', 'settings.providers.page.custom.field.baseURL.info': '兼容 OpenAI 的 API 基础 URL。必须以 http:// 或 https:// 开头。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 8a7241ae..cc9db44c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1247,13 +1247,18 @@ export const settingsDict = { 'settings.providers.page.custom.title': '自訂供應商', 'settings.providers.page.custom.editTitle': '編輯自訂提供者', - 'settings.providers.page.custom.description': '透過指定基礎 URL、憑證與模型清單,新增相容 OpenAI 的供應商。會寫入 OpenCode 設定,可像其他供應商一樣在聊天中使用。', + 'settings.providers.page.custom.description': '透過指定基礎 URL、憑證、模型清單與支援的 API 通訊協定新增供應商。會寫入 OpenCode 設定以供聊天使用。', 'settings.providers.page.custom.field.providerID.label': '供應商 ID', 'settings.providers.page.custom.field.providerID.placeholder': 'my-provider', 'settings.providers.page.custom.field.providerID.info': '小寫字母、數字、連字號與底線。用作 OpenCode 供應商 ID。', 'settings.providers.page.custom.field.name.label': '顯示名稱', 'settings.providers.page.custom.field.name.placeholder': '我的供應商', 'settings.providers.page.custom.field.name.info': '顯示於供應商與模型選擇器。', + 'settings.providers.page.custom.field.protocol.label': 'API 通訊協定', + 'settings.providers.page.custom.field.protocol.info': '選擇此 API 實作的請求格式。', + 'settings.providers.page.custom.field.protocol.openaiChat': 'OpenAI Chat Completions', + 'settings.providers.page.custom.field.protocol.openaiResponses': 'OpenAI Responses', + 'settings.providers.page.custom.field.protocol.anthropicMessages': 'Anthropic Messages', 'settings.providers.page.custom.field.baseURL.label': '基礎 URL', 'settings.providers.page.custom.field.baseURL.placeholder': 'https://api.example.com/v1', 'settings.providers.page.custom.field.baseURL.info': '相容 OpenAI 的 API 基礎 URL。必須以 http:// 或 https:// 開頭。', diff --git a/packages/web/server/lib/opencode/providers.js b/packages/web/server/lib/opencode/providers.js index 050c942c..42600165 100644 --- a/packages/web/server/lib/opencode/providers.js +++ b/packages/web/server/lib/opencode/providers.js @@ -9,6 +9,11 @@ import { const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/; const BASE_URL_PATTERN = /^https?:\/\//; const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible'; +const CUSTOM_PROVIDER_NPM_PACKAGES = new Set([ + OPENAI_COMPATIBLE_NPM, + '@ai-sdk/openai', + '@ai-sdk/anthropic', +]); function getProviderSources(providerId, workingDirectory) { const layers = readConfigLayers(workingDirectory); @@ -42,7 +47,7 @@ function getProviderSources(providerId, workingDirectory) { } /** - * Validate a custom OpenAI-compatible provider config payload before persistence. + * Validate a custom provider config payload before persistence. * Returns { ok: true, value } or { ok: false, error }. * * Credentials: either config.env contains a variable name, or hasStoredAuth is true @@ -63,8 +68,8 @@ function validateCustomProviderConfig(providerId, config, options = {}) { } const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM; - if (npm !== OPENAI_COMPATIBLE_NPM) { - return { ok: false, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` }; + if (!CUSTOM_PROVIDER_NPM_PACKAGES.has(npm)) { + return { ok: false, error: 'Custom providers must use @ai-sdk/openai-compatible, @ai-sdk/openai, or @ai-sdk/anthropic' }; } const optionsBlock = isPlainObject(config.options) ? config.options : null; @@ -102,7 +107,7 @@ function validateCustomProviderConfig(providerId, config, options = {}) { } const normalized = { - npm: OPENAI_COMPATIBLE_NPM, + npm, name, options: { baseURL, diff --git a/packages/web/server/lib/opencode/providers.test.js b/packages/web/server/lib/opencode/providers.test.js index b023bf87..9ce3715a 100644 --- a/packages/web/server/lib/opencode/providers.test.js +++ b/packages/web/server/lib/opencode/providers.test.js @@ -71,6 +71,33 @@ describe('custom provider config persistence', () => { }).ok).toBe(true); }); + test('accepts the OpenCode Responses and Anthropic adapter packages', () => { + for (const npm of ['@ai-sdk/openai', '@ai-sdk/anthropic']) { + const result = validateCustomProviderConfig('ok', { + name: 'X', + npm, + env: ['MY_KEY'], + options: { baseURL: 'https://api.example.com/v1' }, + models: { m: { name: 'M' } }, + }); + expect(result.ok).toBe(true); + expect(result.value.config.npm).toBe(npm); + } + }); + + test('rejects unsupported adapter packages', () => { + const result = validateCustomProviderConfig('ok', { + name: 'X', + npm: '@example/unsupported', + env: ['MY_KEY'], + options: { baseURL: 'https://api.example.com/v1' }, + models: { m: { name: 'M' } }, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain('@ai-sdk/openai'); + }); + test('upsertProviderConfig writes and round-trips project config', () => { const result = upsertProviderConfig('campus-llm', { name: 'Campus LLM', From 108c9f529b9fcecb7e9f5d90a32a2851eb6427d4 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 20 Aug 2026 19:07:59 +0300 Subject: [PATCH 02/59] fix(skills): simplify skill source cards --- .../src/components/sections/skills/SkillsSidebar.tsx | 10 ---------- .../sections/skills/catalog/SkillsCatalogPage.tsx | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx index f440b9a5..18042671 100644 --- a/packages/ui/src/components/sections/skills/SkillsSidebar.tsx +++ b/packages/ui/src/components/sections/skills/SkillsSidebar.tsx @@ -439,12 +439,6 @@ const SkillListItem: React.FC = ({ }) => { const { t } = useI18n(); const isMobile = isMobileDeviceViaCSS(); - const sourceLabel = skill.source === 'claude' - ? t('settings.skills.sidebar.badge.claude') - : skill.source === 'agents' - ? t('settings.skills.sidebar.badge.agents') - : t('settings.skills.sidebar.badge.opencode'); - const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50'; const isBuiltIn = isBuiltInSkill(skill); const canRename = isRenamableSkill(skill); const [isContextMenuOpen, setIsContextMenuOpen] = React.useState(false); @@ -479,10 +473,6 @@ const SkillListItem: React.FC = ({ {skill.name} - - {skill.scope} - - {sourceLabel} diff --git a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx index d4fdd3e5..621aa2fa 100644 --- a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx +++ b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx @@ -391,7 +391,7 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo type="button" data-settings-item="skills.catalog.add-catalog" onClick={() => setAddCatalogOpen(true)} - className="min-h-24 text-left rounded-lg border border-dashed border-[var(--surface-subtle)] hover:border-[var(--interactive-border-hover)] hover:bg-[var(--surface-muted)] p-3.5 flex gap-3 items-start transition-colors" + className="min-h-24 text-left rounded-lg border border-dashed border-[var(--interactive-border)] hover:border-[var(--interactive-border-hover)] hover:bg-[var(--surface-muted)] p-3.5 flex gap-3 items-start transition-colors" > From 3613127e2d70aae617c8a665044b3b276adc3f49 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 20 Aug 2026 19:09:13 +0300 Subject: [PATCH 03/59] fix(quota): support z.ai credit limits --- .../components/usage/UsageProviderCards.tsx | 11 +++- .../ui/src/components/usage/usageGroups.ts | 2 + packages/ui/src/lib/i18n/messages/de.ts | 4 +- packages/ui/src/lib/i18n/messages/en.ts | 4 +- packages/ui/src/lib/i18n/messages/es.ts | 4 +- packages/ui/src/lib/i18n/messages/fr.ts | 4 +- packages/ui/src/lib/i18n/messages/ja.ts | 4 +- packages/ui/src/lib/i18n/messages/ko.ts | 4 +- packages/ui/src/lib/i18n/messages/pl.ts | 4 +- packages/ui/src/lib/i18n/messages/pt-BR.ts | 4 +- packages/ui/src/lib/i18n/messages/uk.ts | 4 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 4 +- packages/ui/src/lib/i18n/messages/zh-TW.ts | 4 +- packages/vscode/src/quotaProviders.test.ts | 27 +++++++++ packages/vscode/src/quotaProviders.ts | 55 ++++++++++++++----- .../web/server/lib/quota/DOCUMENTATION.md | 2 +- .../lib/quota/providers/claude/index.js | 4 +- .../web/server/lib/quota/providers/zai.js | 32 +++++++++-- .../server/lib/quota/providers/zai.test.js | 33 +++++++++++ 19 files changed, 162 insertions(+), 48 deletions(-) diff --git a/packages/ui/src/components/usage/UsageProviderCards.tsx b/packages/ui/src/components/usage/UsageProviderCards.tsx index 68e2a518..56f2ada0 100644 --- a/packages/ui/src/components/usage/UsageProviderCards.tsx +++ b/packages/ui/src/components/usage/UsageProviderCards.tsx @@ -35,6 +35,11 @@ export const UsageProviderCards: React.FC<{ {group.providerName} + {group.planLabel ? ( + + {group.planLabel} + + ) : null} {group.status && group.rows.length === 0 ? ( {group.status} ) : null} @@ -54,12 +59,12 @@ export const UsageProviderCards: React.FC<{ ); return (
- - + + {row.subtitle ? `${row.subtitle} · ${row.label}` : row.label} {resetLabel ? ( - + {resetLabel} ) : null} diff --git a/packages/ui/src/components/usage/usageGroups.ts b/packages/ui/src/components/usage/usageGroups.ts index 3b1bdb0e..68a85bce 100644 --- a/packages/ui/src/components/usage/usageGroups.ts +++ b/packages/ui/src/components/usage/usageGroups.ts @@ -15,6 +15,7 @@ export type UsageLimitRow = { export type UsageProviderGroup = { providerId: QuotaProviderId; providerName: string; + planLabel?: string | null; rows: UsageLimitRow[]; /** Provider-level message: a fetch error, or "nothing reported". */ status: string | null; @@ -76,6 +77,7 @@ export const useUsageProviderGroups = (): UsageProviderGroup[] => { return { providerId: providerMeta.id, providerName: providerMeta.name, + planLabel: result.planLabel, rows, status, }; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 35d1cab5..fec60411 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2853,9 +2853,9 @@ export const dict = { 'quota.window.5h': '5-Stunden-Limit', 'quota.window.7d': '7-Tage-Limit', 'quota.window.extraUsage': 'Zusätzliche Nutzung', - 'quota.window.weekly': 'Wöchentliches Limit', + 'quota.window.weekly': 'Wöchentlich', 'quota.window.daily': 'Täglich', - 'quota.window.monthly': 'Monatliches Limit', + 'quota.window.monthly': 'Monatlich', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Kreditguthaben', 'quota.window.monthlyCredits': 'Monatliche Credits', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index a2af1dcc..4abb4e1d 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -3031,9 +3031,9 @@ export const dict = { 'quota.window.5h': '5-Hour', 'quota.window.7d': '7-Day Limit', 'quota.window.extraUsage': 'Extra Usage', - 'quota.window.weekly': 'Weekly Limit', + 'quota.window.weekly': 'Weekly', 'quota.window.daily': 'Daily', - 'quota.window.monthly': 'Monthly Limit', + 'quota.window.monthly': 'Monthly', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', 'quota.window.monthlyCredits': 'Monthly Credits', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 34649bcd..458a6adc 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -3032,9 +3032,9 @@ export const dict: Record = { "quota.window.5h": "5-Hour", "quota.window.7d": "7-Day Limit", "quota.window.extraUsage": "Uso adicional", - "quota.window.weekly": "Weekly Limit", + "quota.window.weekly": "Semanal", "quota.window.daily": "Daily", - "quota.window.monthly": "Monthly Limit", + "quota.window.monthly": "Mensual", "quota.window.credits": "Credits", "quota.window.creditsBalance": "Credits Balance", "quota.window.monthlyCredits": "Créditos mensuales", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index bde799e2..bba07ce2 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2724,9 +2724,9 @@ export const dict = { 'quota.window.5h': '5 heures', 'quota.window.7d': 'Limite sur 7 jours', 'quota.window.extraUsage': 'Utilisation supplémentaire', - 'quota.window.weekly': 'Limite hebdomadaire', + 'quota.window.weekly': 'Hebdomadaire', 'quota.window.daily': 'Quotidien', - 'quota.window.monthly': 'Limite mensuelle', + 'quota.window.monthly': 'Mensuel', 'quota.window.credits': 'Crédits', 'quota.window.creditsBalance': 'Solde de crédits', 'quota.window.monthlyCredits': 'Crédits mensuels', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index a18ca8dc..7d26028d 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -3027,9 +3027,9 @@ export const dict: Record = { 'quota.window.5h': '5時間', 'quota.window.7d': '7日間制限', 'quota.window.extraUsage': '追加利用', - 'quota.window.weekly': '週間制限', + 'quota.window.weekly': '毎週', 'quota.window.daily': '日次', - 'quota.window.monthly': '月間制限', + 'quota.window.monthly': '毎月', 'quota.window.credits': 'クレジット', 'quota.window.creditsBalance': 'クレジット残高', 'quota.window.monthlyCredits': '月間クレジット', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 170ac4b2..b7a0accd 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -3031,9 +3031,9 @@ export const dict: Record = { 'quota.window.5h': '5-Hour', 'quota.window.7d': '7-Day Limit', 'quota.window.extraUsage': '추가 사용량', - 'quota.window.weekly': 'Weekly Limit', + 'quota.window.weekly': '매주', 'quota.window.daily': 'Daily', - 'quota.window.monthly': 'Monthly Limit', + 'quota.window.monthly': '매월', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', 'quota.window.monthlyCredits': '월간 크레딧', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index fd0ebd78..d9247510 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -3048,9 +3048,9 @@ export const dict: Record = { 'quota.window.5h': '5-Hour', 'quota.window.7d': '7-Day Limit', 'quota.window.extraUsage': 'Dodatkowe zużycie', - 'quota.window.weekly': 'Weekly Limit', + 'quota.window.weekly': 'Tygodniowo', 'quota.window.daily': 'Daily', - 'quota.window.monthly': 'Monthly Limit', + 'quota.window.monthly': 'Miesięcznie', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', 'quota.window.monthlyCredits': 'Kredyty miesięczne', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 93a53b2c..b5dd9ae7 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -3032,9 +3032,9 @@ export const dict: Record = { "quota.window.5h": "5-Hour", "quota.window.7d": "7-Day Limit", "quota.window.extraUsage": "Uso adicional", - "quota.window.weekly": "Weekly Limit", + "quota.window.weekly": "Semanal", "quota.window.daily": "Daily", - "quota.window.monthly": "Monthly Limit", + "quota.window.monthly": "Mensal", "quota.window.credits": "Credits", "quota.window.creditsBalance": "Credits Balance", "quota.window.monthlyCredits": "Créditos mensais", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index ce9a0963..e30dea88 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -3032,9 +3032,9 @@ export const dict: Record = { "quota.window.5h": "5-Hour", "quota.window.7d": "7-Day Limit", "quota.window.extraUsage": "Додаткове використання", - "quota.window.weekly": "Weekly Limit", + "quota.window.weekly": "Щотижня", "quota.window.daily": "Daily", - "quota.window.monthly": "Monthly Limit", + "quota.window.monthly": "Щомісяця", "quota.window.credits": "Credits", "quota.window.creditsBalance": "Credits Balance", "quota.window.monthlyCredits": "Місячні кредити", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index eb6b7241..0b1034e3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -3032,9 +3032,9 @@ export const dict: Record = { 'quota.window.5h': '5-Hour', 'quota.window.7d': '7-Day Limit', 'quota.window.extraUsage': '额外用量', - 'quota.window.weekly': 'Weekly Limit', + 'quota.window.weekly': '每周', 'quota.window.daily': 'Daily', - 'quota.window.monthly': 'Monthly Limit', + 'quota.window.monthly': '每月', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', 'quota.window.monthlyCredits': '每月积分', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 89ab710d..4b2caab8 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -3031,9 +3031,9 @@ export const dict: Record = { 'quota.window.5h': '5-Hour', 'quota.window.7d': '7-Day Limit', 'quota.window.extraUsage': '額外用量', - 'quota.window.weekly': 'Weekly Limit', + 'quota.window.weekly': '每週', 'quota.window.daily': 'Daily', - 'quota.window.monthly': 'Monthly Limit', + 'quota.window.monthly': '每月', 'quota.window.credits': 'Credits', 'quota.window.creditsBalance': 'Credits Balance', 'quota.window.monthlyCredits': '每月點數', diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index 2309ea6d..ee88916c 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -327,6 +327,33 @@ describe('Z.ai quota provider (VS Code parity)', () => { assert.equal(windows['MCP Tools']!.windowSeconds, 30 * 24 * 60 * 60); assert.equal(windows['MCP Tools']!.resetAt, 1787128459979); }); + + test('maps CREDIT_LIMIT entries to windows with credit value labels and plan level', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + code: 200, + data: { + limits: [ + { type: 'CREDIT_LIMIT', unit: 3, number: 5, usage: 12000, currentValue: 65, remaining: 11934, percentage: 1, nextResetTime: 1787257978907 }, + { type: 'CREDIT_LIMIT', unit: 6, number: 1, usage: 60000, currentValue: 65, remaining: 59934, percentage: 1, nextResetTime: 1787844668997 }, + ], + level: 'pro', + }, + }))); + + const result = await fetchQuotaForProvider('zai-coding-plan'); + const windows = result.usage!.windows; + + assert.equal(result.ok, true); + assert.equal(result.planLabel, 'pro'); + assert.equal(windows['5h']!.usedPercent, 1); + assert.equal(windows['5h']!.windowSeconds, 5 * 60 * 60); + assert.equal(windows['5h']!.resetAt, 1787257978907); + assert.equal(windows['5h']!.valueLabel, '65 / 12k credits'); + assert.equal(windows.weekly!.usedPercent, 1); + assert.equal(windows.weekly!.windowSeconds, 7 * 24 * 60 * 60); + assert.equal(windows.weekly!.resetAt, 1787844668997); + assert.equal(windows.weekly!.valueLabel, '65 / 60k credits'); + }); }); describe('NeuralWatt quota provider (VS Code parity)', () => { diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index a33bf6b6..042fc5d4 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -72,13 +72,31 @@ type ZaiLimit = { type?: string; number?: number; unit?: number; + usage?: number; + currentValue?: number; + remaining?: number; nextResetTime?: number; percentage?: number; }; +// CREDIT_LIMIT entries carry `usage` (total credits) and `currentValue` (consumed); +// TOKENS_LIMIT entries only carry a percentage. +const formatZaiCreditAmount = (value: number): string => { + if (value < 1000) return value.toLocaleString('en-US'); + return `${Math.round(value / 100) / 10}k`; +}; + +const formatZaiCreditValueLabel = (limit: ZaiLimit): string | null => { + const used = toNumber(limit.currentValue); + const total = toNumber(limit.usage); + if (used === null || total === null) return null; + return `${formatZaiCreditAmount(used)} / ${formatZaiCreditAmount(total)} credits`; +}; + type ZaiPayload = { data?: { limits?: ZaiLimit[]; + level?: string; }; }; @@ -411,15 +429,20 @@ const buildResult = (data: { configured: boolean; usage?: ProviderUsage | null; error?: string; -}): ProviderResult => ({ - providerId: data.providerId, - providerName: data.providerName, - ok: data.ok, - configured: data.configured, - usage: data.usage ?? null, - ...(data.error ? { error: data.error } : {}), - fetchedAt: Date.now(), -}); + planLabel?: string | null; +}): ProviderResult => { + const result: ProviderResult = { + providerId: data.providerId, + providerName: data.providerName, + ok: data.ok, + configured: data.configured, + usage: data.usage ?? null, + ...(data.error ? { error: data.error } : {}), + fetchedAt: Date.now(), + }; + if (data.planLabel) result.planLabel = data.planLabel; + return result; +}; const resolveXaiAuth = (): XaiAuthEntry | null => { const entry = getProviderAuth('xai'); @@ -1291,7 +1314,7 @@ const buildClaudeRateLimitResult = (): ProviderResult => ( providerName: 'Claude', ok: false, configured: true, - error: 'Rate limited by Anthropic. Retrying shortly.', + error: 'Rate limited. Retrying soon.', }) ); @@ -2059,16 +2082,19 @@ const fetchZaiQuota = async (): Promise => { const payload = await response.json() as ZaiPayload; const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : []; const windows: Record = {}; - for (const tokensLimit of limits.filter((limit) => limit?.type === 'TOKENS_LIMIT')) { - const windowSeconds = resolveWindowSeconds(tokensLimit as Record); + // The API renamed TOKENS_LIMIT to CREDIT_LIMIT; field semantics stayed the same, + // so both limit types map to the same windows. + for (const limit of limits.filter((entry) => entry?.type === 'TOKENS_LIMIT' || entry?.type === 'CREDIT_LIMIT')) { + const windowSeconds = resolveWindowSeconds(limit as Record); const windowLabel = resolveWindowLabel(windowSeconds); - const resetAt = tokensLimit.nextResetTime ? normalizeTimestamp(tokensLimit.nextResetTime) : null; - const usedPercent = typeof tokensLimit.percentage === 'number' ? tokensLimit.percentage : null; + const resetAt = limit.nextResetTime ? normalizeTimestamp(limit.nextResetTime) : null; + const usedPercent = typeof limit.percentage === 'number' ? limit.percentage : null; windows[windowLabel] = toUsageWindow({ usedPercent, windowSeconds, resetAt, + valueLabel: formatZaiCreditValueLabel(limit), }); } @@ -2087,6 +2113,7 @@ const fetchZaiQuota = async (): Promise => { ok: true, configured: true, usage: { windows }, + planLabel: payload?.data?.level || null, }); } catch (error) { return buildResult({ diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index 78d86706..83e905a4 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -101,4 +101,4 @@ The provider computes `usedPercent` from whichever of `used`/`remaining` is pres - Keep provider IDs stable; clients use them directly. - Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs. - Keep Google behavior changes isolated and review `providers/google/*` together. -- Z.ai Coding Plan exposes separate 5-hour and weekly `TOKENS_LIMIT` entries plus a monthly `TIME_LIMIT` for MCP tools; web and VS Code must preserve all three windows. +- Z.ai Coding Plan exposes separate 5-hour and weekly token/credit limit entries plus a monthly `TIME_LIMIT` for MCP tools. The API renamed the limit type from `TOKENS_LIMIT` to `CREDIT_LIMIT` (same `unit`/`number` window semantics); `CREDIT_LIMIT` entries additionally carry `usage` (total), `currentValue` (consumed), and `remaining`, surfaced as a credit `valueLabel`, and the payload's `data.level` becomes `planLabel`. Web and VS Code must preserve these windows and stay in sync. diff --git a/packages/web/server/lib/quota/providers/claude/index.js b/packages/web/server/lib/quota/providers/claude/index.js index 8ee0d677..71fa3e43 100644 --- a/packages/web/server/lib/quota/providers/claude/index.js +++ b/packages/web/server/lib/quota/providers/claude/index.js @@ -81,7 +81,7 @@ const fetchQuotaUncoalesced = async () => { if (Date.now() < cooldownUntil) { return cachedResultFor(fingerprint, credential.planLabel) - ?? failure('Rate limited by Anthropic. Retrying shortly.'); + ?? failure('Rate limited. Retrying soon.'); } let response; @@ -100,7 +100,7 @@ const fetchQuotaUncoalesced = async () => { if (response.status === 429) { cooldownUntil = Date.now() + cooldownFromHeader(response); return cachedResultFor(fingerprint, credential.planLabel) - ?? failure('Rate limited by Anthropic. Retrying shortly.'); + ?? failure('Rate limited. Retrying soon.'); } if (response.status === 401 || response.status === 403) { diff --git a/packages/web/server/lib/quota/providers/zai.js b/packages/web/server/lib/quota/providers/zai.js index 0c526f99..b3d9efac 100644 --- a/packages/web/server/lib/quota/providers/zai.js +++ b/packages/web/server/lib/quota/providers/zai.js @@ -4,6 +4,7 @@ import { normalizeAuthEntry, buildResult, toUsageWindow, + toNumber, resolveWindowSeconds, resolveWindowLabel, normalizeTimestamp @@ -13,6 +14,20 @@ export const providerId = 'zai-coding-plan'; export const providerName = 'z.ai'; const aliases = ['zai-coding-plan', 'zai', 'z.ai']; +// CREDIT_LIMIT entries carry `usage` (total credits), `currentValue` (consumed), +// and `remaining`; TOKENS_LIMIT entries only carry a percentage. +const formatCreditAmount = (value) => { + if (value < 1000) return value.toLocaleString('en-US'); + return `${Math.round(value / 100) / 10}k`; +}; + +const formatCreditValueLabel = (limit) => { + const used = toNumber(limit?.currentValue); + const total = toNumber(limit?.usage); + if (used === null || total === null) return null; + return `${formatCreditAmount(used)} / ${formatCreditAmount(total)} credits`; +}; + export const isConfigured = () => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); @@ -56,16 +71,20 @@ export const fetchQuota = async () => { const payload = await response.json(); const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : []; const windows = {}; - for (const tokensLimit of limits.filter((limit) => limit?.type === 'TOKENS_LIMIT')) { - const windowSeconds = resolveWindowSeconds(tokensLimit); + // The API renamed TOKENS_LIMIT to CREDIT_LIMIT; field semantics stayed the same, + // so both limit types map to the same windows. + for (const limit of limits.filter((entry) => entry?.type === 'TOKENS_LIMIT' || entry?.type === 'CREDIT_LIMIT')) { + const windowSeconds = resolveWindowSeconds(limit); const windowLabel = resolveWindowLabel(windowSeconds); - const resetAt = tokensLimit?.nextResetTime ? normalizeTimestamp(tokensLimit.nextResetTime) : null; - const usedPercent = typeof tokensLimit?.percentage === 'number' ? tokensLimit.percentage : null; + const resetAt = limit?.nextResetTime ? normalizeTimestamp(limit.nextResetTime) : null; + const usedPercent = typeof limit?.percentage === 'number' ? limit.percentage : null; + const creditValueLabel = formatCreditValueLabel(limit); windows[windowLabel] = toUsageWindow({ usedPercent, windowSeconds, - resetAt + resetAt, + valueLabel: creditValueLabel }); } @@ -83,7 +102,8 @@ export const fetchQuota = async () => { providerName, ok: true, configured: true, - usage: { windows } + usage: { windows }, + planLabel: typeof payload?.data?.level === 'string' && payload.data.level ? payload.data.level : null }); } catch (error) { return buildResult({ diff --git a/packages/web/server/lib/quota/providers/zai.test.js b/packages/web/server/lib/quota/providers/zai.test.js index 39d5dcf5..d721cd9e 100644 --- a/packages/web/server/lib/quota/providers/zai.test.js +++ b/packages/web/server/lib/quota/providers/zai.test.js @@ -51,4 +51,37 @@ describe('Z.ai quota provider', () => { resetAt: 1787128459979, }); }); + + it('maps CREDIT_LIMIT entries to windows with credit value labels and plan level', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ + code: 200, + data: { + limits: [ + { type: 'CREDIT_LIMIT', unit: 3, number: 5, usage: 12000, currentValue: 65, remaining: 11934, percentage: 1, nextResetTime: 1787257978907 }, + { type: 'CREDIT_LIMIT', unit: 6, number: 1, usage: 60000, currentValue: 65, remaining: 59934, percentage: 1, nextResetTime: 1787844668997 }, + ], + level: 'pro', + }, + }))); + + const result = await fetchQuota(); + const windows = result.usage.windows; + + expect(result.ok).toBe(true); + expect(result.planLabel).toBe('pro'); + expect(windows['5h']).toMatchObject({ + usedPercent: 1, + remainingPercent: 99, + windowSeconds: 5 * 60 * 60, + resetAt: 1787257978907, + valueLabel: '65 / 12k credits', + }); + expect(windows.weekly).toMatchObject({ + usedPercent: 1, + remainingPercent: 99, + windowSeconds: 7 * 24 * 60 * 60, + resetAt: 1787844668997, + valueLabel: '65 / 60k credits', + }); + }); }); From 0d70a631f652b731b09395cc4c9ec18c4e16e463 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 20 Aug 2026 21:25:29 +0300 Subject: [PATCH 04/59] fix(electron): block startup splash history navigation --- packages/electron/main.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index c6fe8edb..46805505 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -2465,6 +2465,9 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken, requestHeaders: desktopRequestHeaders }; browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken, desktopRequestHeaders); browserWindow.__ocTitleBarOverlayEnabled = titleBarOverlayEnabled; + browserWindow.on('app-command', (event, command) => { + if (command === 'browser-backward') event.preventDefault(); + }); if (useSaved && saved.maximized) { browserWindow.maximize(); From 9e87d7fdb9f293389baa012e2a4d28f4804a10c5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 12:12:40 +0300 Subject: [PATCH 05/59] feat(chats): add managed projectless chat sessions Create projectless chat sessions under a managed, date-scoped Chats directory and clean abandoned or deleted session folders. Add Chats to sidebar state, startup cache, shared context, and Electron Mini Chat while keeping VS Code project-only. Resolve managed chat directories to one server-side memory owner and document the runtime contracts. --- packages/electron/README.md | 1 + packages/ui/src/App.tsx | 14 +- packages/ui/src/apps/ElectronMiniChatApp.tsx | 18 ++- .../ui/src/components/chat/ChatContainer.tsx | 4 +- packages/ui/src/components/chat/ChatInput.tsx | 8 +- .../chat/composer/state/useDraftTarget.ts | 30 ++++- .../chat/composer/ui/DraftTargetSelectors.tsx | 16 ++- packages/ui/src/components/layout/Header.tsx | 21 ++- .../components/layout/RightSidebarTabs.tsx | 22 +++- .../components/mini-chat/MiniChatLayout.tsx | 22 +++- .../src/components/session/SessionSidebar.tsx | 75 ++++++----- .../session/sidebar/DOCUMENTATION.md | 2 +- .../sidebar/SidebarActivitySections.tsx | 44 ++++++- .../session/sidebar/hooks/useSwitcherItems.ts | 6 +- .../sidebar/sidebarSessionSources.test.ts | 28 ++++ .../session/sidebar/sidebarSessionSources.ts | 19 +++ .../src/hooks/useMiniChatKeyboardShortcuts.ts | 16 +-- packages/ui/src/lib/chatDirectories.test.ts | 55 ++++++++ packages/ui/src/lib/chatDirectories.ts | 88 +++++++++++++ packages/ui/src/lib/i18n/messages/de.ts | 2 + packages/ui/src/lib/i18n/messages/en.ts | 2 + packages/ui/src/lib/i18n/messages/es.ts | 2 + packages/ui/src/lib/i18n/messages/fr.ts | 2 + packages/ui/src/lib/i18n/messages/ja.ts | 2 + packages/ui/src/lib/i18n/messages/ko.ts | 2 + packages/ui/src/lib/i18n/messages/pl.ts | 2 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 + packages/ui/src/lib/i18n/messages/uk.ts | 2 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 + packages/ui/src/stores/globalSessions.test.ts | 26 +++- packages/ui/src/stores/globalSessions.ts | 7 + .../ui/src/stores/useGlobalSessionsStore.ts | 36 ++++- packages/ui/src/sync/DOCUMENTATION.md | 10 ++ .../ui/src/sync/__tests__/issue-2039.test.ts | 4 + packages/ui/src/sync/persist-cache.test.ts | 13 +- packages/ui/src/sync/persist-cache.ts | 18 +++ packages/ui/src/sync/session-actions.test.ts | 1 + packages/ui/src/sync/session-actions.ts | 18 +++ packages/ui/src/sync/session-ui-store.test.js | 20 +-- packages/ui/src/sync/session-ui-store.ts | 124 +++++++++++++++--- packages/web/server/index.js | 1 + .../lib/agent-memory/project-resolution.js | 11 +- .../agent-memory/project-resolution.test.js | 9 ++ .../lib/project-context/DOCUMENTATION.md | 2 + .../lib/session-knowledge/DOCUMENTATION.md | 2 + 46 files changed, 677 insertions(+), 136 deletions(-) create mode 100644 packages/ui/src/components/session/sidebar/sidebarSessionSources.test.ts create mode 100644 packages/ui/src/components/session/sidebar/sidebarSessionSources.ts create mode 100644 packages/ui/src/lib/chatDirectories.test.ts create mode 100644 packages/ui/src/lib/chatDirectories.ts diff --git a/packages/electron/README.md b/packages/electron/README.md index a7763d48..7a306894 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -143,6 +143,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u ## Native Features Owned Here - Floating Mini Chat windows. +- New Mini Chat windows default to the managed Chats target. Explicit project/worktree drafts retain their target, existing managed chat sessions reopen in their own directory, and the compact header omits project/branch metadata for Chats. Opening a managed draft back in the main window preserves that target. - Multiple native windows. - Native notifications. - User-confirmed local folder selection. The shared UI supplies the requested directory as the picker `defaultPath`; confirmation is required before filesystem access is retried. diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index ff0ffe7c..80085329 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -33,7 +33,6 @@ import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionR import { useSessionUIStore } from '@/sync/session-ui-store'; import { markSessionViewed } from '@/sync/notification-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; @@ -639,12 +638,9 @@ function App({ apis }: AppProps) { React.useEffect(() => { if (typeof window === 'undefined') return; const onOpenMiniChat = () => { - const currentDir = useDirectoryStore.getState().currentDirectory; - const { activeProjectId, projects } = useProjectsStore.getState(); - const activeProject = projects.find((p) => p.id === activeProjectId) ?? null; void invokeDesktop('desktop_open_draft_mini_chat_window', { - directory: currentDir || activeProject?.path || '', - projectId: activeProject?.id ?? null, + directory: '', + projectId: null, }); }; window.addEventListener('openchamber:open-mini-chat', onOpenMiniChat); @@ -676,11 +672,13 @@ function App({ apis }: AppProps) { const projectId = typeof detail?.projectId === 'string' && detail.projectId.trim().length > 0 ? detail.projectId.trim() : null; + const hasProjectTarget = Boolean(directory || projectId); useUIStore.getState().setActiveMainTab('chat'); useUIStore.getState().setSessionSwitcherOpen(false); useSessionUIStore.getState().openNewSessionDraft({ - selectedProjectId: projectId, - directoryOverride: directory, + target: hasProjectTarget ? 'project' : 'chat', + selectedProjectId: hasProjectTarget ? projectId : null, + directoryOverride: hasProjectTarget ? directory : null, preserveDirectoryOverride: Boolean(directory), }); }; diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx index 10a993d8..d1d53b50 100644 --- a/packages/ui/src/apps/ElectronMiniChatApp.tsx +++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx @@ -25,6 +25,7 @@ import { worktreeMapsEqual, } from '@/lib/worktrees/worktreeManager'; import type { WorktreeMetadata } from '@/types/worktree'; +import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories'; const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence'; @@ -153,9 +154,9 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) => const sessionId = typeof detail?.sessionId === 'string' ? detail.sessionId.trim() : ''; if (!sessionId) return; if (useSessionUIStore.getState().currentSessionId === sessionId) return; - const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0 - ? detail.directory.trim() - : (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory ?? null; + const sessionDirectory = (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory?.trim(); + const directory = sessionDirectory + || (typeof detail?.directory === 'string' && detail.directory.trim().length > 0 ? detail.directory.trim() : null); void sync.ensureSessionRenderable(sessionId); setCurrentSession(sessionId, directory); sessionBootstrappedRef.current = true; @@ -166,9 +167,11 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) => React.useEffect(() => { if (config.mode !== 'draft' || draftOpen || currentSessionId) return; + const hasProjectTarget = Boolean(config.projectId || config.directory); openNewSessionDraft({ - selectedProjectId: config.projectId, - directoryOverride: config.directory, + target: hasProjectTarget ? 'project' : 'chat', + selectedProjectId: hasProjectTarget ? config.projectId : CHAT_DRAFT_PROJECT_ID, + directoryOverride: hasProjectTarget ? config.directory : null, preserveDirectoryOverride: Boolean(config.directory), }); }, [config, currentSessionId, draftOpen, openNewSessionDraft]); @@ -278,10 +281,11 @@ const MiniChatPresencePublisher: React.FC = () => { const useSessionUnavailable = (config: MiniChatConfig): boolean => { const sessions = useSessions(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const draftOpen = useSessionUIStore((state) => state.newSessionDraft.open); const [timedOut, setTimedOut] = React.useState(false); React.useEffect(() => { - if (config.mode !== 'session' || !config.sessionId || currentSessionId === config.sessionId) { + if (draftOpen || config.mode !== 'session' || !config.sessionId || currentSessionId) { setTimedOut(false); return; } @@ -291,7 +295,7 @@ const useSessionUnavailable = (config: MiniChatConfig): boolean => { } const timeout = window.setTimeout(() => setTimedOut(true), 5000); return () => window.clearTimeout(timeout); - }, [config.mode, config.sessionId, currentSessionId, sessions]); + }, [config.mode, config.sessionId, currentSessionId, draftOpen, sessions]); return timedOut; }; diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index a50ea6e7..8b07222e 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -504,14 +504,16 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea const DraftWelcome: React.FC = () => { const { t } = useI18n(); + const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target); const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null); const projectLabel = useProjectsStore(React.useCallback((state) => { + if (draftTarget === 'chat') return null; const projectId = selectedProjectId ?? state.activeProjectId; const project = (projectId ? state.projects.find((candidate) => candidate.id === projectId) : null) ?? state.projects[0] ?? null; return project ? getProjectDisplayLabel(project) : null; - }, [selectedProjectId])); + }, [draftTarget, selectedProjectId])); return (
diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 06f73900..fa98b7d0 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -326,6 +326,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget); const setDraftPermissionAutoAcceptEnabled = useSessionUIStore((s) => s.setDraftPermissionAutoAcceptEnabled); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); + const prepareChatDraftDirectory = useSessionUIStore((s) => s.prepareChatDraftDirectory); const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId); const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt); const attachedFiles = useInputStore((s) => s.attachedFiles); @@ -336,6 +337,11 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const pendingPresetSubmit = useInputStore((s) => s.pendingPresetSubmit); const setPendingInputText = useInputStore((s) => s.setPendingInputText); const pendingInputText = useInputStore((s) => s.pendingInputText); + + React.useEffect(() => { + if (!newSessionDraftOpen || newSessionDraft.target !== 'chat' || message.trim().length === 0) return; + void prepareChatDraftDirectory(); + }, [message, newSessionDraft.target, newSessionDraftOpen, prepareChatDraftDirectory]); const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts); const acknowledgeSessionAbort = useSessionUIStore((s) => s.acknowledgeSessionAbort); const abortCurrentOperation = React.useCallback( @@ -2382,7 +2388,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo React.useEffect(() => { - if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) { + if (!showDraftTargetSelectors || !selectedDraftProject || selectedDraftProject.kind === 'chat' || !selectedDraftDirectory) { return; } if (newSessionDraft?.pendingWorktreeRequestId || newSessionDraft?.bootstrapPendingDirectory || newSessionDraft?.preserveDirectoryOverride) { diff --git a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts index ae8cc408..d4586591 100644 --- a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts +++ b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts @@ -23,6 +23,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { buildSessionTargetOptions } from '@/sync/session-worktree-contract'; import { normalizePath } from '../attachments/filePaths'; +import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories'; +import { useI18n } from '@/lib/i18n'; /** How long a cached branch list is served before it is refreshed. */ const BRANCHES_SWR_TTL_MS = 30_000; @@ -35,6 +37,7 @@ export interface DraftTargetProject { color?: string | null; iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null; iconBackground?: string | null; + kind?: 'chat' | 'project'; } /** A project's display name, falling back to its directory name. */ @@ -43,7 +46,15 @@ export function getProjectDisplayLabel(project: { label?: string; path: string } } export function useDraftTarget(enabled: boolean) { - const projects = useProjectsStore((state) => state.projects) as DraftTargetProject[]; + const configuredProjects: readonly DraftTargetProject[] = useProjectsStore((state) => state.projects); + const { t } = useI18n(); + const chatProject = React.useMemo(() => ({ + id: CHAT_DRAFT_PROJECT_ID, + path: '', + label: t('layout.mainTab.chat'), + kind: 'chat', + }), [t]); + const projects = React.useMemo(() => [chatProject, ...configuredProjects], [chatProject, configuredProjects]); const activeProjectId = useProjectsStore((state) => state.activeProjectId); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); @@ -53,6 +64,7 @@ export function useDraftTarget(enabled: boolean) { const { git: runtimeGit } = useRuntimeAPIs(); const selectedDraftProject = React.useMemo(() => { + if (newSessionDraft?.target === 'chat') return chatProject; const explicit = newSessionDraft?.selectedProjectId ? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null : null; @@ -67,14 +79,16 @@ export function useDraftTarget(enabled: boolean) { return active; } - return projects[0] ?? null; - }, [activeProjectId, newSessionDraft?.selectedProjectId, projects]); + return configuredProjects[0] ?? chatProject; + }, [activeProjectId, chatProject, configuredProjects, newSessionDraft?.selectedProjectId, newSessionDraft?.target, projects]); const selectedDraftProjectPath = React.useMemo( - () => normalizePath(selectedDraftProject?.path ?? null), - [selectedDraftProject?.path], + () => selectedDraftProject?.kind === 'chat' ? null : normalizePath(selectedDraftProject?.path ?? null), + [selectedDraftProject?.kind, selectedDraftProject?.path], ); - const draftProjectLabel = selectedDraftProject ? getProjectDisplayLabel(selectedDraftProject) : null; + const draftProjectLabel = selectedDraftProject && selectedDraftProject.kind !== 'chat' + ? getProjectDisplayLabel(selectedDraftProject) + : null; const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath); const selectedDraftProjectBranchesFetchedAt = useGitStore( @@ -258,6 +272,10 @@ export function useDraftTarget(enabled: boolean) { if (!project) { return; } + if (project.kind === 'chat') { + setNewSessionDraftTarget({ projectId: CHAT_DRAFT_PROJECT_ID, directoryOverride: null }, { force: true }); + return; + } if (activeProjectId !== projectId) { setActiveProjectIdOnly(projectId); } diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index e3cefb15..35072c6c 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -57,7 +57,9 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined = function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) { const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null; const iconColor = getProjectIconColor(project.color); - const fallbackIcon = projectIconName ? ( + const fallbackIcon = project.kind === 'chat' ? ( + + ) : projectIconName ? ( ) : ( @@ -115,13 +117,15 @@ export function DraftTargetSelectors(props: DraftTargetProps) { className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent" > - {} + {selectedProject.kind === 'chat' + ? {t('chat.chatInput.chooseProject')} + : } {projects.map((project) => ( - {} + ))} @@ -195,7 +199,9 @@ export function MobileDraftTargetTriggers( className="inline-flex h-7 min-w-0 max-w-[42vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]" onClick={() => onOpenPicker('project')} > - {} + {selectedProject.kind === 'chat' + ? {t('chat.chatInput.chooseProject')} + : } {showBranchSelector ? ( @@ -275,7 +281,7 @@ export function MobileDraftTargetSheets( onOpenPickerChange(null); }} > - {} + {project.id === selectedProject.id ? ( ) : null} diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 37d9cac8..1dfa2275 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -22,6 +22,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; import { formatSessionWorktreeBadge } from '@/sync/session-worktree-contract'; import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionMessagesResolved } from '@/sync/sync-context'; +import { useDirectoryStore as useAppDirectoryStore } from '@/stores/useDirectoryStore'; +import { isChatDirectoryForHome } from '@/lib/chatDirectories'; import { useSync } from '@/sync/use-sync'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; @@ -1052,6 +1054,10 @@ export const Header: React.FC = ({ } return normalize(state.newSessionDraft.bootstrapPendingDirectory ?? state.newSessionDraft.directoryOverride ?? ''); }); + const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target); + const draftProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId); + const selectedSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); + const homeDirectory = useAppDirectoryStore((state) => state.homeDirectory); const openDirectory = React.useMemo(() => { return worktreeDirectory || sessionDirectory || draftDirectory; @@ -1080,10 +1086,13 @@ export const Header: React.FC = ({ const gitBranchForDirectory = useGitBranchLabel(openDirectory || null); const currentBranchLabel = gitBranchForDirectory || currentSessionWorktreeBranch || catalogWorktreeBranch; + const isChatContext = isNewSessionDraftOpen + ? draftTarget === 'chat' + : isChatDirectoryForHome(sessionDirectory || selectedSessionDirectory, homeDirectory); // Whether the title carries a second line under it. Hoisted because the // session menu's vertical alignment depends on the same answer. - const showHeaderMetaRow = !workStatusPanelVisible + const showHeaderMetaRow = !isChatContext && !workStatusPanelVisible && Boolean(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind)); @@ -1423,14 +1432,14 @@ export const Header: React.FC = ({ const handleOpenDraftMiniChat = React.useCallback(() => { void invokeDesktop('desktop_open_draft_mini_chat_window', { - directory: normalize(openDirectory || activeProject?.path || ''), - projectId: activeProject?.id ?? null, + directory: isChatContext ? '' : draftDirectory, + projectId: isChatContext ? null : draftProjectId, apiBaseUrl: getRuntimeApiBaseUrl(), clientToken: getRuntimeBearerTokenSync(), }).catch((error) => { console.warn('[header] failed to open draft mini chat window', error); }); - }, [activeProject?.id, activeProject?.path, openDirectory]); + }, [draftDirectory, draftProjectId, isChatContext]); const handleOpenCurrentMiniChat = React.useCallback(() => { if (isNewSessionDraftOpen) { @@ -1443,13 +1452,13 @@ export const Header: React.FC = ({ } void invokeDesktop('desktop_open_session_mini_chat_window', { sessionId: currentSessionId, - directory: normalize(openDirectory || activeProject?.path || ''), + directory: sessionDirectory || normalize(selectedSessionDirectory || '') || worktreeDirectory, apiBaseUrl: getRuntimeApiBaseUrl(), clientToken: getRuntimeBearerTokenSync(), }).catch((error) => { console.warn('[header] failed to open session mini chat window', error); }); - }, [activeProject?.path, currentSessionId, handleOpenDraftMiniChat, isNewSessionDraftOpen, openDirectory]); + }, [currentSessionId, handleOpenDraftMiniChat, isNewSessionDraftOpen, selectedSessionDirectory, sessionDirectory, worktreeDirectory]); const handleOpenContextPanel = React.useCallback(() => { const directory = normalize(openDirectory || ''); diff --git a/packages/ui/src/components/layout/RightSidebarTabs.tsx b/packages/ui/src/components/layout/RightSidebarTabs.tsx index cd8b2f1e..8138bef1 100644 --- a/packages/ui/src/components/layout/RightSidebarTabs.tsx +++ b/packages/ui/src/components/layout/RightSidebarTabs.tsx @@ -5,6 +5,9 @@ import { useGitStore } from '@/stores/useGitStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { formatDirectoryName } from '@/lib/utils'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories'; +import { useI18n } from '@/lib/i18n'; export const ProjectContextPanel: React.FC<{ onActionComplete?: () => void; @@ -13,16 +16,28 @@ export const ProjectContextPanel: React.FC<{ const activeProjectId = useProjectsStore((state) => state.activeProjectId); const projects = useProjectsStore((state) => state.projects); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); + const { t } = useI18n(); const gitDirectories = useGitStore((state) => state.directories); + const isChatContext = useSessionUIStore((state) => ( + state.newSessionDraft.open + ? state.newSessionDraft.target === 'chat' + : isChatDirectoryPath(state.currentSessionDirectory) + )); + const chatSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); + const chatsRoot = getChatsRootFromDirectory(chatSessionDirectory) ?? getChatsRootForHome(homeDirectory); const activeProject = React.useMemo(() => { + if (isChatContext) return null; if (activeProjectId) { return projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null; } return projects[0] ?? null; - }, [activeProjectId, projects]); + }, [activeProjectId, isChatContext, projects]); const projectRef = React.useMemo(() => { + if (isChatContext && chatsRoot) { + return { id: CHAT_DRAFT_PROJECT_ID, path: chatsRoot }; + } if (!activeProject) { return null; } @@ -30,16 +45,17 @@ export const ProjectContextPanel: React.FC<{ id: activeProject.id, path: activeProject.path, }; - }, [activeProject]); + }, [activeProject, chatsRoot, isChatContext]); const projectLabel = React.useMemo(() => { + if (isChatContext) return t('sessions.sidebar.activity.chatsTitle'); if (!activeProject) { return null; } return activeProject.label?.trim() || formatDirectoryName(activeProject.path, homeDirectory) || activeProject.path; - }, [activeProject, homeDirectory]); + }, [activeProject, homeDirectory, isChatContext, t]); const canCreateWorktree = React.useMemo(() => { if (!activeProject) { diff --git a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx index a32779a1..6e4eeec8 100644 --- a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx +++ b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx @@ -20,6 +20,7 @@ import { Icon } from "@/components/icon/Icon"; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; +import { isChatDirectoryPath } from '@/lib/chatDirectories'; type MiniChatMode = 'session' | 'draft'; @@ -51,6 +52,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const draftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); + const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target); const draftProjectId = useSessionUIStore((state) => state.newSessionDraft?.selectedProjectId ?? null); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const projects = useProjectsStore((state) => state.projects); @@ -99,6 +101,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { const worktreeDirectory = normalizePath(worktreePath || sessionWorktreeMetadata?.path || worktreeAttachment?.cwd || worktreeAttachment?.worktreeRoot || ''); const currentDirectoryNormalized = normalizePath(currentDirectory); const openDirectory = worktreeDirectory || sessionDirectory || draftDirectory || currentDirectoryNormalized; + const isChatContext = draftOpen ? draftTarget === 'chat' : isChatDirectoryPath(sessionDirectory); const directoryLabel = compactPath(openDirectory); const catalogWorktreeBranch = useSessionUIStore((state) => { const candidateDirectory = normalizePath(worktreeDirectory || sessionDirectory || ''); @@ -111,9 +114,9 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { return null; }); React.useEffect(() => { - if (!openDirectory) return; + if (!openDirectory || isChatContext) return; void ensureGitStatus(openDirectory, runtimeApis.git).catch(() => {}); - }, [ensureGitStatus, openDirectory, runtimeApis.git]); + }, [ensureGitStatus, isChatContext, openDirectory, runtimeApis.git]); const pathMatchedProject = React.useMemo(() => { const projectDirectory = normalizePath(sessionWorktreeMetadata?.projectDirectory ?? worktreeAttachment?.worktreeRoot ?? null); @@ -125,13 +128,14 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { .sort((left, right) => right.path.length - left.path.length)[0] ?? null; }, [openDirectory, projects, sessionWorktreeMetadata?.projectDirectory, worktreeAttachment?.worktreeRoot]); const projectLabel = React.useMemo(() => { + if (isChatContext) return null; const project = pathMatchedProject ?? activeProject; if (!project) return directoryLabel || 'OpenChamber'; const label = project.label?.trim(); if (label) return label; const segments = project.path.split(/[\\/]/).filter(Boolean); return segments.at(-1) ?? project.path; - }, [activeProject, directoryLabel, pathMatchedProject]); + }, [activeProject, directoryLabel, isChatContext, pathMatchedProject]); const gitBranchForDirectory = useGitBranchLabel(openDirectory || null); const rawBranchLabel = gitBranchForDirectory || worktreeMetadataBranch || sessionWorktreeMetadata?.branch?.trim() || worktreeAttachment?.branch?.trim() || catalogWorktreeBranch; const branchLabel = rawBranchLabel && rawBranchLabel !== 'HEAD' ? rawBranchLabel : null; @@ -241,7 +245,11 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { const handleOpenMainApp = React.useCallback(() => { const payload = currentSessionId ? { sessionId: currentSessionId, directory: (session as { directory?: string | null } | null)?.directory ?? currentDirectory ?? '' } - : { mode: 'draft', directory: openDirectory || currentDirectory || '', projectId: draftProjectId }; + : { + mode: 'draft', + directory: isChatContext ? '' : openDirectory || currentDirectory || '', + projectId: isChatContext ? null : draftProjectId, + }; void invokeDesktop<{ focused?: boolean }>('desktop_focus_main_window', payload) .then((result) => { if (result?.focused === true) { @@ -249,7 +257,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { } return null; }); - }, [currentDirectory, currentSessionId, draftProjectId, openDirectory, session]); + }, [currentDirectory, currentSessionId, draftProjectId, isChatContext, openDirectory, session]); return (
= ({ mode }) => { {title} - + {!isChatContext ? {projectLabel} {branchLabel ? ( @@ -281,7 +289,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { {branchLabel} ) : null} - + : null}
diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 1d14494f..11922074 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { isChatDirectoryForHome, isChatDirectoryPath } from '@/lib/chatDirectories'; +import { mergeSidebarSessionSources } from './sidebar/sidebarSessionSources'; import type { Session } from '@opencode-ai/sdk/v2'; import { toast } from '@/components/ui'; import { useI18n } from '@/lib/i18n'; @@ -439,6 +441,7 @@ const SessionSidebarComponent: React.FC = ({ const liveSessionIndex = getAllSyncSessionMap(); const liveSessions = React.useMemo(() => Array.from(liveSessionIndex.values()), [liveSessionIndex]); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); + const runtimeKey = getRuntimeKey(); const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready'); const activeSessionStructure = useGlobalSessionsStore(useShallow( (state) => state.activeSessions.map(getSessionStructuralSignature).sort(), @@ -506,20 +509,15 @@ const SessionSidebarComponent: React.FC = ({ ); const sessions = React.useMemo(() => { - const merged = [...globalActiveSessions]; - const seenIds = new Set(merged.map((session) => session.id)); + const merged = mergeSidebarSessionSources(globalActiveSessions, liveFallbackSessions); - liveFallbackSessions.forEach((session) => { - if (seenIds.has(session.id)) { - return; - } - merged.push(session); - }); - - return merged.filter((session) => isKnownActiveSessionDirectory(session, knownSessionDirectories, { - allowUnknownDirectory: !isVSCode, - allowEmptyDirectorySet: !isVSCode, - })); + return merged.filter((session) => ( + (!isVSCode && isChatDirectoryPath(session.directory)) + || isKnownActiveSessionDirectory(session, knownSessionDirectories, { + allowUnknownDirectory: !isVSCode, + allowEmptyDirectorySet: !isVSCode, + }) + )); }, [globalActiveSessions, isVSCode, knownSessionDirectories, liveFallbackSessions]); const persistenceSessions = React.useMemo( @@ -532,7 +530,6 @@ const SessionSidebarComponent: React.FC = ({ syncSessionsSnapshotRef.current = liveSessions; }, [liveSessions]); - const runtimeKey = getRuntimeKey(); const projectWorktreeDiscoveryKey = React.useMemo( () => `${runtimeKey}|${projects .map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`) @@ -1369,9 +1366,13 @@ const SessionSidebarComponent: React.FC = ({ return []; } - return deriveRecentSessions(sessions, activeSessionIdSet) + return deriveRecentSessions(sessions.filter((session) => !isChatDirectoryForHome(session.directory, homeDirectory)), activeSessionIdSet) .sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)); - }, [activeSessionIdSet, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions, showRecentSection]); + }, [activeSessionIdSet, homeDirectory, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions, showRecentSection]); + + const chatSessions = React.useMemo(() => sessions + .filter((session) => !session.parentID && !session.time?.archived && isChatDirectoryForHome(session.directory, homeDirectory)) + .sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)), [homeDirectory, pinnedSessionIds, sessionOrderRanks, sessions]); // Prefetch is wired below, after recentSessions is computed. @@ -1379,13 +1380,13 @@ const SessionSidebarComponent: React.FC = ({ // VS Code renders the full grouped project view (one group per open // workspace, folders + pinned native); the flat "recent" activity list is // web/desktop-only. - if (isVSCode || !showRecentSection) { + if (isVSCode) { return []; } const toItem = (session: Session) => { const existing = sessionSidebarMetaById.get(session.id); - const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + const sessionDirectory = normalizePath(session.directory ?? null); const node = existing?.node ?? { session, children: [], worktree: null }; const filteredNodes = hasSessionSearchQuery ? filterSessionNodesForSearch([node], normalizedSessionSearchQuery) @@ -1408,17 +1409,21 @@ const SessionSidebarComponent: React.FC = ({ }; }; - const items = recentSessions + const recentItems = showRecentSection ? recentSessions + .map(toItem) + .filter((item): item is NonNullable> => item !== null) : []; + + const chatItems = chatSessions .map(toItem) .filter((item): item is NonNullable> => item !== null); - return [ - { key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items }, + { key: 'chats' as const, title: t('sessions.sidebar.activity.chatsTitle'), items: chatItems }, + { key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items: recentItems }, ]; - }, [filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, recentSessions, sessionSidebarMetaById, showRecentSection, t]); + }, [chatSessions, filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, recentSessions, sessionSidebarMetaById, showRecentSection, t]); const hasActivitySectionItems = React.useMemo( - () => activitySections.some((section) => section.items.length > 0), + () => activitySections.some((section) => section.key === 'chats' || section.items.length > 0), [activitySections], ); @@ -1736,8 +1741,17 @@ const SessionSidebarComponent: React.FC = ({ ], ); + const handleOpenNewSessionDraftFromHeader = React.useCallback(() => { + useUIStore.getState().closeMainSurfaces(); + setActiveMainTab('chat'); + if (mobileVariant) { + setSessionSwitcherOpen(false); + } + openNewSessionDraft(); + }, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]); + const topContent = React.useMemo( - () => (!isVSCode && showRecentSection && !hasSessionSearchQuery) ? ( + () => (!isVSCode && !hasSessionSearchQuery && hasActivitySectionItems) ? ( = ({ expansionState={recentExpandedParents} variant="section" isDesktopShellRuntime={isDesktopShellRuntime} + onNewChat={handleOpenNewSessionDraftFromHeader} + alwaysShowActions={alwaysShowSidebarActions} /> ) : null, - [activitySections, editingId, hasSessionSearchQuery, isDesktopShellRuntime, isVSCode, openSidebarMenuKey, recentExpandedParents, renderSessionNode, showRecentSection], + [activitySections, alwaysShowSidebarActions, editingId, handleOpenNewSessionDraftFromHeader, hasActivitySectionItems, hasSessionSearchQuery, isDesktopShellRuntime, isVSCode, openSidebarMenuKey, recentExpandedParents, renderSessionNode], ); const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId); @@ -1789,15 +1805,6 @@ const SessionSidebarComponent: React.FC = ({ openMultiRunLauncher(); }, [mobileVariant, openMultiRunLauncher, setActiveMainTab, setSessionSwitcherOpen]); - const handleOpenNewSessionDraftFromHeader = React.useCallback(() => { - useUIStore.getState().closeMainSurfaces(); - setActiveMainTab('chat'); - if (mobileVariant) { - setSessionSwitcherOpen(false); - } - openNewSessionDraft(); - }, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]); - return ( // One shared tooltip provider for the whole sidebar: session tooltips open // instantly, and moving between rows hands the tooltip over (grouping) diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index ddaf8840..42bf3fb8 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -29,7 +29,7 @@ - `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all). - A successful add/create/clone from the project-directory dialog transitions to a new-session draft targeted at that project, matching the project's `+` action; changing project metadata alone must not leave the visible session or draft on a different directory. - `SidebarNav.tsx`: Text navigation rows above the tree (New session, Scheduled, Multi-run, Archive); hidden in VS Code. -- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only, styled as a zone header. +- `SidebarActivitySections.tsx`: Global top section renderer for project-only `recent` sessions followed by OpenChamber-managed `chats`, styled as zone headers. - `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions. - `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder. - `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows. diff --git a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx b/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx index 6387552c..d3f95a35 100644 --- a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx @@ -10,6 +10,7 @@ import { resolveMenuOpenSessionId, } from './sessionNodeItemUtils'; import type { SessionNodeRenderExtras } from './sessionNodeItemUtils'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; type ActivityItem = { node: SessionNode; @@ -22,7 +23,7 @@ type ActivityItem = { }; type ActivitySection = { - key: 'active-now'; + key: 'active-now' | 'chats'; title: string; items: ActivityItem[]; }; @@ -46,6 +47,8 @@ type Props = { initialVisibleCount?: number; batchSize?: number; isDesktopShellRuntime: boolean; + onNewChat?: () => void; + alwaysShowActions?: boolean; }; type RenderExtras = SessionNodeRenderExtras; @@ -129,7 +132,9 @@ export function SidebarActivitySections(props: Props): React.ReactNode { }); }, [editingId, openSidebarMenuKey]); - const visibleSections = sections.filter((section) => section.items.length > 0); + const visibleSections = sections.filter((section) => ( + section.items.length > 0 || (section.key === 'chats' && props.onNewChat) + )); if (visibleSections.length === 0) { return null; } @@ -179,23 +184,54 @@ export function SidebarActivitySections(props: Props): React.ReactNode { return (
+ {section.key === 'chats' && props.onNewChat ? ( +
+ + + + + +

{t('sessions.sidebar.header.actions.newSession')}

+
+
+
+ ) : null}
{!isCollapsed ? (
diff --git a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts index 1329daab..42e3e05b 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts @@ -9,6 +9,8 @@ import type { SessionNode } from '../types'; import { isPathWithinProject } from '../utils'; import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { isVSCodeRuntime } from '@/lib/desktop'; +import { isChatDirectoryPath } from '@/lib/chatDirectories'; export type SwitcherItem = { node: SessionNode; @@ -51,6 +53,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions const sessionOrderRanks = useSessionOrderingStore((state) => state.rankById); const branchesByDirectory = useGitAllBranches(); const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); // Worktree sessions live OUTSIDE their project's path, so prefix matching // can't resolve their project — and their branch is known from worktree @@ -114,6 +117,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions const parents = activeSessions .filter((session) => !session.time?.archived) + .filter((session) => !isVSCode || !isChatDirectoryPath(resolveGlobalSessionDirectory(session))) .filter((session) => !(session as Session & { parentID?: string | null }).parentID) .filter((session) => { if (!scopeProjectId) return true; @@ -151,7 +155,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions }, }; }); - }, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]); + }, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]); return items; }; diff --git a/packages/ui/src/components/session/sidebar/sidebarSessionSources.test.ts b/packages/ui/src/components/session/sidebar/sidebarSessionSources.test.ts new file mode 100644 index 00000000..e19213f7 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sidebarSessionSources.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; + +import { mergeSidebarSessionSources } from './sidebarSessionSources'; + +const session = (id: string, title: string): Session => ({ + id, + slug: id, + title, + directory: `/home/.config/openchamber/chats/2026-08-21/${id}`, + projectID: 'managed-chats', + version: '1', + time: { created: 1, updated: 1 }, +}); + +describe('sidebar session source merge', () => { + test('shows one row when the same cached global chat also exists live', () => { + const live = session('session-a', 'Live title'); + const cached = session('session-a', 'Cached title'); + + expect(mergeSidebarSessionSources([cached], [live])).toEqual([cached]); + }); + + test('prefers global authority over live fallback', () => { + const global = session('session-a', 'Global title'); + expect(mergeSidebarSessionSources([global], [session('session-a', 'Live title')])).toEqual([global]); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/sidebarSessionSources.ts b/packages/ui/src/components/session/sidebar/sidebarSessionSources.ts new file mode 100644 index 00000000..41e22d04 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sidebarSessionSources.ts @@ -0,0 +1,19 @@ +import type { Session } from '@opencode-ai/sdk/v2'; + +export function mergeSidebarSessionSources( + globalSessions: readonly Session[], + liveSessions: readonly Session[], +): Session[] { + const merged = [...globalSessions]; + const seenIds = new Set(merged.map((session) => session.id)); + const appendMissing = (sessions: readonly Session[]) => { + sessions.forEach((session) => { + if (seenIds.has(session.id)) return; + seenIds.add(session.id); + merged.push(session); + }); + }; + + appendMissing(liveSessions); + return merged; +} diff --git a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts index f3b24003..b7f9eeb8 100644 --- a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts @@ -3,16 +3,12 @@ import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop'; import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useSelectionStore } from '@/sync/selection-store'; import { useSessionUIStore } from '@/sync/session-ui-store'; export const useMiniChatKeyboardShortcuts = () => { const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); - const currentDirectory = useDirectoryStore((state) => state.currentDirectory); - const activeProject = useProjectsStore((state) => state.getActiveProject()); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); React.useEffect(() => { @@ -28,8 +24,8 @@ export const useMiniChatKeyboardShortcuts = () => { if (canUseElectronDesktopIPC() && eventMatchesShortcut(event, combo('new_mini_chat'))) { event.preventDefault(); void invokeDesktop('desktop_open_draft_mini_chat_window', { - directory: currentDirectory || activeProject?.path || '', - projectId: activeProject?.id ?? null, + directory: '', + projectId: null, })?.catch((error) => { console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error); }); @@ -38,11 +34,7 @@ export const useMiniChatKeyboardShortcuts = () => { if (eventMatchesShortcut(event, combo('new_chat'))) { event.preventDefault(); - openNewSessionDraft({ - selectedProjectId: activeProject?.id ?? null, - directoryOverride: currentDirectory || activeProject?.path || null, - preserveDirectoryOverride: Boolean(currentDirectory || activeProject?.path), - }); + openNewSessionDraft(); focusChatInput(); return; } @@ -98,5 +90,5 @@ export const useMiniChatKeyboardShortcuts = () => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [activeProject?.id, activeProject?.path, currentDirectory, openNewSessionDraft, shortcutOverrides]); + }, [openNewSessionDraft, shortcutOverrides]); }; diff --git a/packages/ui/src/lib/chatDirectories.test.ts b/packages/ui/src/lib/chatDirectories.test.ts new file mode 100644 index 00000000..6d6958b4 --- /dev/null +++ b/packages/ui/src/lib/chatDirectories.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +const createdDirectories: string[] = []; +const createDirectoryOptions: Array<{ allowOutsideWorkspace?: boolean } | undefined> = []; +const deletedDirectories: string[] = []; + +mock.module('@/lib/opencode/client', () => ({ + opencodeClient: { + getFilesystemHome: mock(async () => '/Users/tester'), + createDirectory: mock(async (path: string, options?: { allowOutsideWorkspace?: boolean }) => { + createdDirectories.push(path); + createDirectoryOptions.push(options); + return { success: true, path }; + }), + }, +})); + +mock.module('@/lib/runtime-fetch', () => ({ + runtimeFetch: mock(async (_path: string, init?: RequestInit) => { + deletedDirectories.push(JSON.parse(String(init?.body)).path); + return new Response(null, { status: 200 }); + }), +})); + +const { createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath } = await import('./chatDirectories'); + +describe('chat directories', () => { + beforeEach(() => { + createdDirectories.length = 0; + createDirectoryOptions.length = 0; + deletedDirectories.length = 0; + }); + + test('creates one isolated directory beneath the dated chats root', async () => { + const directory = await createChatDirectory(new Date(2026, 7, 21, 12)); + expect(createdDirectories[0]).toBe(directory); + expect(directory.startsWith('/Users/tester/.config/openchamber/chats/2026-08-21/session-')).toBe(true); + expect(createdDirectories).toEqual([directory]); + expect(createDirectoryOptions).toEqual([undefined]); + }); + + test('recognizes only descendants of the managed chats root', () => { + expect(isChatDirectoryForHome('/Users/tester/.config/openchamber/chats/2026-08-21/session-a', '/Users/tester')).toBe(true); + expect(isChatDirectoryForHome('/Users/tester/project', '/Users/tester')).toBe(false); + expect(isChatDirectoryForHome('/remote/home/.config/openchamber/chats/2026-08-21/session-a', '/Users/tester')).toBe(true); + expect(isChatDirectoryPath('/remote/home/.config/openchamber/chats/2026-08-21/session-a')).toBe(true); + expect(getChatsRootFromDirectory('/remote/home/.config/openchamber/chats/2026-08-21/session-a')).toBe('/remote/home/.config/openchamber/chats'); + }); + + test('deletes managed chat directories but leaves project directories alone', async () => { + await deleteChatDirectory('/Users/tester/.config/openchamber/chats/2026-08-21/session-a'); + await deleteChatDirectory('/Users/tester/project'); + expect(deletedDirectories).toEqual(['/Users/tester/.config/openchamber/chats/2026-08-21/session-a']); + }); +}); diff --git a/packages/ui/src/lib/chatDirectories.ts b/packages/ui/src/lib/chatDirectories.ts new file mode 100644 index 00000000..c7656dee --- /dev/null +++ b/packages/ui/src/lib/chatDirectories.ts @@ -0,0 +1,88 @@ +import { opencodeClient } from '@/lib/opencode/client'; +import { normalizePath } from '@/lib/pathNormalization'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRuntimeKey } from '@/lib/runtime-switch'; + +export const CHAT_DRAFT_PROJECT_ID = 'openchamber:chats'; +const MANAGED_CHATS_PATH_SEGMENT = '/.config/openchamber/chats/'; +const chatsRootByRuntime = new Map>(); + +const joinPath = (base: string, ...parts: string[]): string => { + const separator = base.includes('\\') ? '\\' : '/'; + return [base.replace(/[\\/]+$/, ''), ...parts].join(separator); +}; + +export function isChatDirectoryForHome(directory: string | null | undefined, home: string | null | undefined): boolean { + const normalized = normalizePath(directory ?? null); + if (normalized?.includes(MANAGED_CHATS_PATH_SEGMENT)) return true; + const normalizedHome = normalizePath(home ?? null); + if (!normalized || !normalizedHome) return false; + const root = normalizePath(joinPath(normalizedHome, '.config', 'openchamber', 'chats')); + return Boolean(root && normalized.startsWith(`${root}/`)); +} + +export function isChatDirectoryPath(directory: string | null | undefined): boolean { + return normalizePath(directory ?? null)?.includes(MANAGED_CHATS_PATH_SEGMENT) === true; +} + +export function getChatsRootFromDirectory(directory: string | null | undefined): string | null { + const normalized = normalizePath(directory ?? null); + const index = normalized?.indexOf(MANAGED_CHATS_PATH_SEGMENT) ?? -1; + return normalized && index >= 0 + ? normalized.slice(0, index + MANAGED_CHATS_PATH_SEGMENT.length - 1) + : null; +} + +export function getChatsRootForHome(home: string | null | undefined): string | null { + const normalizedHome = normalizePath(home ?? null); + return normalizedHome ? normalizePath(joinPath(normalizedHome, '.config', 'openchamber', 'chats')) : null; +} + +async function getChatsRootDirectory(): Promise { + const runtimeKey = getRuntimeKey(); + const existing = chatsRootByRuntime.get(runtimeKey); + if (existing) return existing; + + const pending = opencodeClient.getFilesystemHome().then((home) => { + if (!home) throw new Error('Unable to resolve the home directory'); + return joinPath(home, '.config', 'openchamber', 'chats'); + }).catch((error) => { + chatsRootByRuntime.delete(runtimeKey); + throw error; + }); + chatsRootByRuntime.set(runtimeKey, pending); + return pending; +} + +export function warmChatsRootDirectory(): void { + void getChatsRootDirectory().catch(() => undefined); +} + +export async function createChatDirectory(now = new Date()): Promise { + const root = await getChatsRootDirectory(); + const date = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, '0'), String(now.getDate()).padStart(2, '0')].join('-'); + const dateDirectory = joinPath(root, date); + const id = globalThis.crypto?.randomUUID?.() ?? `${now.getTime()}-${Math.random().toString(36).slice(2)}`; + const directory = joinPath(dateDirectory, `session-${id}`); + await opencodeClient.createDirectory(directory); + return directory; +} + +async function isChatDirectory(directory: string | null | undefined): Promise { + const normalized = normalizePath(directory ?? null); + if (!normalized) return false; + const root = normalizePath(await getChatsRootDirectory()); + return Boolean(root && (normalized === root || normalized.startsWith(`${root}/`))); +} + +export async function deleteChatDirectory(directory: string): Promise { + if (!await isChatDirectory(directory)) return; + const response = await runtimeFetch('/api/fs/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: directory }), + }); + if (!response.ok && response.status !== 404) { + throw new Error(`Failed to delete chat directory (${response.status})`); + } +} diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index fec60411..b40072d7 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -415,6 +415,8 @@ export const dict = { 'sessions.sidebar.empty.noMatches.title': 'Keine passenden Sitzungen', 'sessions.sidebar.empty.noMatches.description': 'Versuchen Sie einen anderen Titel, Branch, Ordner oder Pfad.', 'sessions.sidebar.activity.recentTitle': 'kürzlich', + 'sessions.sidebar.activity.chatsTitle': 'Chats', + 'chat.chatInput.chooseProject': 'Projekt auswählen', 'sessions.switcher.openAria': 'Sitzungswechsler öffnen', 'sessions.switcher.empty': 'Keine kürzlichen Sitzungen', 'sessions.switcher.draftTitle': 'Neue Sitzung', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 4abb4e1d..a2b229fc 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -437,6 +437,8 @@ export const dict = { 'sessions.sidebar.empty.noMatches.title': 'No matching sessions', 'sessions.sidebar.empty.noMatches.description': 'Try a different title, branch, folder, or path.', 'sessions.sidebar.activity.recentTitle': 'recent', + 'sessions.sidebar.activity.chatsTitle': 'chats', + 'chat.chatInput.chooseProject': 'Choose project', 'sessions.archivePage.allDirectories': 'All directories', 'sessions.sidebar.header.displayMode.stickyHeaders': 'Sticky project headers', 'sessions.sidebar.header.grouping.label': 'Group sessions', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 458a6adc..36c30760 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -438,6 +438,8 @@ export const dict: Record = { "sessions.sidebar.empty.noMatches.title": "No hay sesiones coincidentes", "sessions.sidebar.empty.noMatches.description": "Inténtalo con un título, rama, carpeta o ruta diferente.", "sessions.sidebar.activity.recentTitle": "reciente", + "sessions.sidebar.activity.chatsTitle": "chats", + "chat.chatInput.chooseProject": "Elegir proyecto", "sessions.archivePage.allDirectories": "Todos los directorios", "sessions.sidebar.header.displayMode.stickyHeaders": "Encabezados de proyecto fijos", "sessions.sidebar.header.grouping.label": "Agrupar sesiones", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index bba07ce2..40e496bf 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -268,6 +268,8 @@ export const dict = { 'sessions.sidebar.empty.noMatches.title': 'Aucune session correspondante', 'sessions.sidebar.empty.noMatches.description': 'Essayez un autre titre, branche, dossier ou chemin.', 'sessions.sidebar.activity.recentTitle': 'récent', + 'sessions.sidebar.activity.chatsTitle': 'discussions', + 'chat.chatInput.chooseProject': 'Choisir un projet', 'sessions.archivePage.allDirectories': 'Tous les répertoires', 'sessions.sidebar.header.displayMode.stickyHeaders': 'Épingler les en-têtes de projet', 'sessions.sidebar.header.grouping.label': 'Regrouper les sessions', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 7d26028d..980973e3 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -438,6 +438,8 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': '一致するセッションがありません', 'sessions.sidebar.empty.noMatches.description': '別のタイトル、ブランチ、フォルダ、パスをお試しください。', 'sessions.sidebar.activity.recentTitle': '最近', + 'sessions.sidebar.activity.chatsTitle': 'チャット', + 'chat.chatInput.chooseProject': 'プロジェクトを選択', 'sessions.archivePage.allDirectories': 'すべてのディレクトリ', 'sessions.sidebar.header.displayMode.stickyHeaders': 'プロジェクトヘッダーを固定', 'sessions.sidebar.header.grouping.label': 'セッションのグループ化', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index b7a0accd..6b409fdf 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -438,6 +438,8 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': '일치하는 세션 없음', 'sessions.sidebar.empty.noMatches.description': '다른 제목, 브랜치, 폴더 또는 경로로 검색해 보세요.', 'sessions.sidebar.activity.recentTitle': '최근', + 'sessions.sidebar.activity.chatsTitle': '채팅', + 'chat.chatInput.chooseProject': '프로젝트 선택', 'sessions.archivePage.allDirectories': '모든 디렉터리', 'sessions.sidebar.header.displayMode.stickyHeaders': '프로젝트 헤더 고정', 'sessions.sidebar.header.grouping.label': '세션 그룹화', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index d9247510..33ba4418 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -249,6 +249,8 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': 'Brak pasujących sesji', 'sessions.sidebar.empty.noMatches.description': 'Spróbuj innego tytułu, gałęzi, folderu lub ścieżki.', 'sessions.sidebar.activity.recentTitle': 'ostatnie', + 'sessions.sidebar.activity.chatsTitle': 'czaty', + 'chat.chatInput.chooseProject': 'Wybierz projekt', 'sessions.archivePage.allDirectories': 'Wszystkie katalogi', 'sessions.sidebar.header.displayMode.stickyHeaders': 'Przyklejone nagłówki projektów', 'sessions.sidebar.header.grouping.label': 'Grupowanie sesji', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index b5dd9ae7..48904a98 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -438,6 +438,8 @@ export const dict: Record = { "sessions.sidebar.empty.noMatches.title": "Não há sessões coincidentes", "sessions.sidebar.empty.noMatches.description": "Tente com outro título, branch, pasta ou caminho.", "sessions.sidebar.activity.recentTitle": "recente", + "sessions.sidebar.activity.chatsTitle": "conversas", + "chat.chatInput.chooseProject": "Escolher projeto", "sessions.archivePage.allDirectories": "Todos os diretórios", "sessions.sidebar.header.displayMode.stickyHeaders": "Cabeçalhos de projeto fixos", "sessions.sidebar.header.grouping.label": "Agrupar sessões", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index e30dea88..110ceb18 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -438,6 +438,8 @@ export const dict: Record = { "sessions.sidebar.empty.noMatches.title": "Немає відповідних сесій", "sessions.sidebar.empty.noMatches.description": "Спробуйте інший заголовок, гілку, папку або шлях.", "sessions.sidebar.activity.recentTitle": "Останні", + "sessions.sidebar.activity.chatsTitle": "Чати", + "chat.chatInput.chooseProject": "Вибрати проєкт", "sessions.archivePage.allDirectories": "Всі директорії", "sessions.sidebar.header.displayMode.stickyHeaders": "Липкі заголовки проектів", "sessions.sidebar.header.grouping.label": "Групування сесій", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 0b1034e3..fdfc55a7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -438,6 +438,8 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': '没有匹配的会话', 'sessions.sidebar.empty.noMatches.description': '请尝试其他标题、分支、文件夹或路径。', 'sessions.sidebar.activity.recentTitle': '最近', + 'sessions.sidebar.activity.chatsTitle': '聊天', + 'chat.chatInput.chooseProject': '选择项目', 'sessions.archivePage.allDirectories': '所有目录', 'sessions.sidebar.header.displayMode.stickyHeaders': '固定项目标题', 'sessions.sidebar.header.grouping.label': '会话分组', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 4b2caab8..aad95ad1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -451,6 +451,8 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': '沒有符合的會話', 'sessions.sidebar.empty.noMatches.description': '請嘗試其他標題、分支、資料夾或路徑。', 'sessions.sidebar.activity.recentTitle': '最近', + 'sessions.sidebar.activity.chatsTitle': '聊天', + 'chat.chatInput.chooseProject': '選擇專案', 'sessions.archivePage.allDirectories': '所有目錄', 'sessions.sidebar.header.displayMode.stickyHeaders': '固定專案標題', 'sessions.sidebar.header.grouping.label': '工作階段分組', diff --git a/packages/ui/src/stores/globalSessions.test.ts b/packages/ui/src/stores/globalSessions.test.ts index 5001bd40..10421d0c 100644 --- a/packages/ui/src/stores/globalSessions.test.ts +++ b/packages/ui/src/stores/globalSessions.test.ts @@ -1,7 +1,29 @@ import { describe, expect, test } from 'bun:test' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2' -import { listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions' +import { filterManagedChatsForRuntime, listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions' + +describe('managed Chats runtime visibility', () => { + const session = (id: string, directory: string): Session => ({ + id, + slug: id, + projectID: 'project', + directory, + title: id, + version: '1', + time: { created: 1, updated: 1 }, + }) + const chat = session('chat', '/home/user/.config/openchamber/chats/2026-08-21/session-a') + const project = session('project', '/workspace/project') + + test('VS Code rejects managed Chats before they enter global state', () => { + expect(filterManagedChatsForRuntime([chat, project], true)).toEqual([project]) + }) + + test('other runtimes retain managed Chats', () => { + expect(filterManagedChatsForRuntime([chat, project], false)).toEqual([chat, project]) + }) +}) describe('listGlobalSessionPages', () => { test('sanitizes session list records before returning them', async () => { diff --git a/packages/ui/src/stores/globalSessions.ts b/packages/ui/src/stores/globalSessions.ts index da8ed9ea..5ee695b9 100644 --- a/packages/ui/src/stores/globalSessions.ts +++ b/packages/ui/src/stores/globalSessions.ts @@ -3,6 +3,7 @@ import { runBackgroundNetworkTask } from '@/lib/background-network'; import { retry } from "@/sync/retry"; import { stripSessionListDetails } from "@/sync/sanitize"; import { startSessionLoadPerformanceEvent } from "@/sync/session-load-performance"; +import { isChatDirectoryPath } from '@/lib/chatDirectories'; export type GlobalSessionRecord = Session & { project?: { @@ -12,6 +13,12 @@ export type GlobalSessionRecord = Session & { } | null; }; +export const filterManagedChatsForRuntime = (sessions: Session[], vscode: boolean): Session[] => ( + vscode + ? sessions.filter((session) => !isChatDirectoryPath(session.directory)) + : sessions +); + const toNumber = (value: string | null): number | null => { if (!value) { return null; diff --git a/packages/ui/src/stores/useGlobalSessionsStore.ts b/packages/ui/src/stores/useGlobalSessionsStore.ts index 7f6f3854..68919369 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore.ts @@ -1,12 +1,14 @@ import { create } from 'zustand'; import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2'; import { opencodeClient } from '@/lib/opencode/client'; -import { listGlobalSessionPages, splitGlobalSessionsByArchived } from '@/stores/globalSessions'; +import { filterManagedChatsForRuntime, listGlobalSessionPages, splitGlobalSessionsByArchived } from '@/stores/globalSessions'; import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow'; import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata'; import { normalizePath } from '@/lib/pathNormalization'; import { raiseSessionOrderingBaselines } from '@/sync/session-ordering'; import { mapWithConcurrency } from '@/lib/concurrency'; +import { persistManagedChatSessions, readManagedChatSessions } from '@/sync/persist-cache'; +import { isVSCodeRuntime } from '@/lib/desktop'; type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error'; @@ -363,6 +365,10 @@ const applySnapshot = ( archivedSessions: Session[], status: GlobalSessionsStatus, ): Partial | GlobalSessionsState => { + if (isVSCodeRuntime()) { + activeSessions = filterManagedChatsForRuntime(activeSessions, true); + archivedSessions = filterManagedChatsForRuntime(archivedSessions, true); + } const nextActiveSessions = sameSessionList(state.activeSessions, activeSessions) ? state.activeSessions : activeSessions; @@ -430,6 +436,10 @@ const mutationRevisionPatch = (state: GlobalSessionsState, ids: Iterable }; const applySessionUpserts = (state: GlobalSessionsState, sessions: Session[]): Partial => { + if (isVSCodeRuntime()) { + sessions = filterManagedChatsForRuntime(sessions, true); + if (sessions.length === 0) return state; + } const revisionPatch = mutationRevisionPatch(state, sessions.map((session) => session.id)); let nextActiveSessions = state.activeSessions; let nextArchivedSessions = state.archivedSessions; @@ -483,11 +493,13 @@ const buildReviewTransferMap = (sessions: Session[]): Map((set, get) => ({ - activeSessions: [], + activeSessions: initialManagedChatSessions, archivedSessions: [], - sessionsByDirectory: new Map(), - reviewTransferBySessionId: new Map(), + sessionsByDirectory: buildSessionsByDirectory(initialManagedChatSessions), + reviewTransferBySessionId: buildReviewTransferMap(initialManagedChatSessions), mutationRevision: 0, mutationRevisionBySessionId: new Map(), hasLoaded: false, @@ -504,11 +516,12 @@ export const useGlobalSessionsStore = create((set, get) => resetForRuntimeSwitch: () => { loadGeneration += 1; inflightLoad = null; + const managedChatSessions = readManagedChatSessions(); set({ - activeSessions: [], + activeSessions: managedChatSessions, archivedSessions: [], - sessionsByDirectory: new Map(), - reviewTransferBySessionId: new Map(), + sessionsByDirectory: buildSessionsByDirectory(managedChatSessions), + reviewTransferBySessionId: buildReviewTransferMap(managedChatSessions), mutationRevision: 0, mutationRevisionBySessionId: new Map(), hasLoaded: false, @@ -722,6 +735,15 @@ export const useGlobalSessionsStore = create((set, get) => }, })); +useGlobalSessionsStore.subscribe((state, previous) => { + if ( + state.activeSessions !== previous.activeSessions + && (state.status !== 'idle' || state.activeSessions.length > 0) + ) { + persistManagedChatSessions(state.activeSessions); + } +}); + export const ensureGlobalSessionsLoaded = async (fallbackActive?: Session[]): Promise => { const state = useGlobalSessionsStore.getState(); if (state.hasLoaded && state.status !== 'error') { diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index e883d128..c88021a3 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -324,6 +324,16 @@ metadata and the next authoritative load reconciles it. ## The golden rule +### Managed chat directories + +Ordinary user-created drafts default to the OpenChamber-managed Chat target. The first submit creates one isolated directory under `~/.config/openchamber/chats/YYYY-MM-DD/session-` before creating the OpenCode session. The shared `~/.config/openchamber/chats` root acts as a system project owner for sidebar membership and Notes, Todo, Plans, pinned knowledge, and project memory, but it is never persisted or rendered as a user project and exposes no Git/worktree controls. Project and worktree actions remain explicit targets. Archiving retains a chat directory so restore remains lossless. Confirmed deletion removes that managed directory and never removes project directories. + +Typing the first character in a managed Chat draft starts one deduplicated directory preparation for that draft. Materialization consumes the prepared directory before `createSession`, removing filesystem creation from the usual submit path. Closing the draft, changing it to a project target, or completing preparation after the runtime/draft changed deletes the unclaimed directory. A create failure also deletes the consumed directory. + +The global sessions store persists and hydrates one bounded, runtime-scoped startup snapshot containing only active managed chat sessions. Every global session surface, including the main sidebar and Electron Mini Chat switcher, sees that stale snapshot while the global list is unresolved or failed; the first authoritative global snapshot replaces it. Runtime reset to idle must hydrate rather than erase the destination runtime's snapshot; authoritative empty, archive, and delete updates do persist the resulting empty or reduced list. + +VS Code intentionally has no managed Chats mode. It neither reads nor writes the managed Chats startup cache, regular drafts continue to target the open workspace, and the global session store rejects managed chat sessions from both snapshots and live upserts before any VS Code surface can consume them. Sidebar and switcher filters repeat that exclusion defensively. + When creating a draft in `handleDirectoryEvent`, **only clone the state fields the event will mutate**. Never spread all fields eagerly. ```typescript diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index 5768c3b2..70a3048e 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -73,6 +73,8 @@ mock.module("@/stores/utils/safeStorage", () => ({ mock.module("@/lib/opencode/client", () => ({ opencodeClient: { getDirectory: () => null, + getFilesystemHome: mock(async () => "/home/test"), + createDirectory: mock(async (path: string) => ({ success: true, path })), setDirectory: mock(() => undefined), }, })) @@ -327,9 +329,11 @@ describe("issue 2039 draft auto-accept", () => { currentSessionId: null, currentSessionDirectory: null, newSessionDraft: { + draftId: 0, open: false, directoryOverride: null, parentID: null, + target: "chat", }, }) }) diff --git a/packages/ui/src/sync/persist-cache.test.ts b/packages/ui/src/sync/persist-cache.test.ts index 7830f5f5..2afc3889 100644 --- a/packages/ui/src/sync/persist-cache.test.ts +++ b/packages/ui/src/sync/persist-cache.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import type { Session } from "@opencode-ai/sdk/v2/client" import { switchRuntimeEndpoint } from "@/lib/runtime-switch" -import { persistSessions, readDirCache } from "./persist-cache" +import { persistManagedChatSessions, persistSessions, readDirCache, readManagedChatSessions } from "./persist-cache" import { getSyncPerformanceDiagnostics, setSyncPerformanceDiagnosticsEnabled } from "./performance-diagnostics" class TestStorage implements Storage { @@ -81,6 +81,17 @@ afterEach(() => { }) describe("persisted directory sessions", () => { + test("keeps one runtime-scoped startup snapshot for managed chats", async () => { + const chat = session(1, 2, "Chat", "/home/user/.config/openchamber/chats/2026-08-21/session-a") + persistManagedChatSessions([session(2, 3), chat]) + await waitForPersistence() + + expect(readManagedChatSessions().map((item) => item.id)).toEqual([chat.id]) + + switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-other.test", runtimeKey: "runtime-other" }) + expect(readManagedChatSessions()).toEqual([]) + }) + test("keeps the 50 most recently updated sessions across restart reads", async () => { const sessions = Array.from({ length: 60 }, (_, updated) => session(59 - updated, updated)) diff --git a/packages/ui/src/sync/persist-cache.ts b/packages/ui/src/sync/persist-cache.ts index a6fe49c0..51d3e580 100644 --- a/packages/ui/src/sync/persist-cache.ts +++ b/packages/ui/src/sync/persist-cache.ts @@ -10,11 +10,14 @@ import type { Session, VcsInfo } from "@opencode-ai/sdk/v2/client" import type { ProjectMeta } from "./types" import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch" import { countSyncPersistenceSerialization, countSyncPersistenceStorageWrite } from "./performance-diagnostics" +import { isChatDirectoryPath } from "@/lib/chatDirectories" +import { isVSCodeRuntime } from "@/lib/desktop" /** Cap persisted session lists so localStorage stays bounded per directory. */ const PERSISTED_SESSION_LIMIT = 50 const SESSION_CACHE_FALLBACK_LIMITS = [PERSISTED_SESSION_LIMIT, 25, 10, 5, 1] as const const SESSION_PERSIST_DEBOUNCE_MS = 50 +const MANAGED_CHATS_CACHE_SCOPE = "openchamber:managed-chats" type PendingSessionWrite = { runtimeKey: string @@ -241,6 +244,21 @@ export function persistSessions(directory: string, sessions: Session[] | undefin scheduleSessionCacheWrite(directory, sessions) } +export function readManagedChatSessions(expectedRuntimeKey = getRuntimeKey()): Session[] { + if (isVSCodeRuntime()) return [] + if (expectedRuntimeKey !== getRuntimeKey()) return [] + return readDirCache(MANAGED_CHATS_CACHE_SCOPE).sessions?.filter((session) => ( + isChatDirectoryPath(session.directory) + )) ?? [] +} + +export function persistManagedChatSessions(sessions: Session[]): void { + if (isVSCodeRuntime()) return + persistSessions(MANAGED_CHATS_CACHE_SCOPE, sessions.filter((session) => ( + isChatDirectoryPath(session.directory) + ))) +} + /** Write vcs info to cache */ export function persistVcs(directory: string, vcs: VcsInfo | undefined): void { writeCache(directory, "vcs", vcs) diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index 4870e39f..758d99ed 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -125,6 +125,7 @@ mock.module("@/lib/opencode/client", () => ({ return mockScopedClient }, getDirectory: () => "/test/project", + getFilesystemHome: mock(async () => "/home/test"), getSdkClient: () => mockSdk, replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => { replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } }) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 806cc913..7ab6e370 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -35,6 +35,7 @@ import { getStaleRunningToolMessageID } from "./materialization" import { normalizePath } from "@/lib/pathNormalization" import { mergeMessages } from "./optimistic" import { messagesBefore, messagesFrom } from "./message-ordering" +import { deleteChatDirectory } from "@/lib/chatDirectories" const MESSAGE_REFETCH_LIMIT = 100 const SEND_CONFIRMATION_REFETCH_LIMIT = 30 @@ -919,6 +920,15 @@ function finalizeConfirmedSessionDeletion( } } +async function cleanupDeletedChatDirectory(directory: string | undefined, deleteDirectory: boolean): Promise { + if (!directory || !deleteDirectory) return + try { + await deleteChatDirectory(directory) + } catch (error) { + console.warn("[session-actions] deleted chat directory cleanup failed", error) + } +} + export type DeleteSessionOptions = { /** * Runtime key the deletion is scoped to. Defaults to the active runtime when @@ -947,6 +957,8 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey() if (isStaleRuntime(expectedRuntimeKey)) return false const sessionDirectory = getSessionDirectory(sessionId) + const sessionSnapshot = getGlobalSessionSnapshot(sessionId) + const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null) try { await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory, expectedRuntimeKey) if (isStaleRuntime(expectedRuntimeKey)) return false @@ -956,6 +968,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp throw new Error("session.delete failed: server did not confirm deletion") } finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey) + await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory) return true } catch (error) { console.error("[session-actions] deleteSession failed", error) @@ -965,6 +978,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp if ((error as { status?: number })?.status === 404) { if (isStaleRuntime(expectedRuntimeKey)) return false finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey) + await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory) return true } return false @@ -978,6 +992,8 @@ export async function deleteSessionInDirectory( expectedRuntimeKey = getRuntimeKey(), ): Promise { if (isStaleRuntime(expectedRuntimeKey)) return false + const sessionSnapshot = getGlobalSessionSnapshot(sessionId) + const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null) try { await cleanupReviewMetadataBeforeDelete(sessionId, directory, expectedRuntimeKey) if (isStaleRuntime(expectedRuntimeKey)) return false @@ -987,12 +1003,14 @@ export async function deleteSessionInDirectory( throw new Error("session.delete failed: server did not confirm deletion") } finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey) + await cleanupDeletedChatDirectory(directory, deleteManagedDirectory) return true } catch (error) { console.error("[session-actions] deleteSessionInDirectory failed", error) if ((error as { status?: number })?.status === 404) { if (isStaleRuntime(expectedRuntimeKey)) return false finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey) + await cleanupDeletedChatDirectory(directory, deleteManagedDirectory) return true } return false diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index 07a8a19f..6f2fdc5e 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -370,16 +370,17 @@ describe('openNewSessionDraft project binding', () => { useDirectoryStore.getState().setDirectory(projectB.path, { showOverlay: false }); }); - test('keeps implicit draft on current directory when active project differs', () => { + test('defaults an implicit draft to Chat when active project differs', () => { useSessionUIStore.getState().openNewSessionDraft(); const draft = useSessionUIStore.getState().newSessionDraft; expect(draft.open).toBe(true); - expect(draft.selectedProjectId).toBe(projectB.id); - expect(draft.directoryOverride).toBe(projectB.path); + expect(draft.target).toBe('chat'); + expect(draft.selectedProjectId).toBeNull(); + expect(draft.directoryOverride).toBeNull(); }); - test('does not attach active project when current directory is unmatched', () => { + test('defaults an implicit draft to Chat when current directory is unmatched', () => { useDirectoryStore.getState().setDirectory('/external/worktree', { showOverlay: false }); useSessionUIStore.getState().openNewSessionDraft(); @@ -387,7 +388,8 @@ describe('openNewSessionDraft project binding', () => { expect(draft.open).toBe(true); expect(draft.selectedProjectId).toBeNull(); - expect(draft.directoryOverride).toBe('/external/worktree'); + expect(draft.target).toBe('chat'); + expect(draft.directoryOverride).toBeNull(); }); test('respects explicit directoryOverride over active project', () => { @@ -464,7 +466,7 @@ describe('createSession draft lifecycle', () => { useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false }); opencodeClient.getDirectoryAvailability = async () => 'missing'; - useSessionUIStore.getState().openNewSessionDraft(); + useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' }); await Bun.sleep(0); expect(useSessionUIStore.getState().newSessionDraft.directoryOverride).toBe('/projects/main'); @@ -482,7 +484,7 @@ describe('createSession draft lifecycle', () => { activeProjectId: 'project-active', }); useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false }); - useSessionUIStore.getState().openNewSessionDraft(); + useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' }); opencodeClient.getDirectoryAvailability = async () => 'missing'; opencodeClient.createSession = async (_params, directory) => { createSessionCalls.push(directory); @@ -542,7 +544,7 @@ describe('createSession draft lifecycle', () => { activeProjectId: 'project-main', }); useDirectoryStore.getState().setDirectory('/private/unavailable-worktree', { showOverlay: false }); - useSessionUIStore.getState().openNewSessionDraft(); + useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/unavailable-worktree' }); opencodeClient.getDirectoryAvailability = async () => 'unknown'; opencodeClient.createSession = async (_params, directory) => { createSessionCalls.push(directory); @@ -571,7 +573,7 @@ describe('createSession draft lifecycle', () => { return { id: 'session-race', directory }; }; - useSessionUIStore.getState().openNewSessionDraft(); + useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' }); const createPromise = useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree'); expect(availabilityResolvers.length).toBe(2); diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 7793de6a..ff337e17 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -29,6 +29,8 @@ import { useSkillsStore } from "@/stores/useSkillsStore" import { getDeferredSafeStorage } from "@/stores/utils/safeStorage" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { normalizePath } from "@/lib/pathNormalization" +import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, warmChatsRootDirectory } from "@/lib/chatDirectories" +import { isVSCodeRuntime } from "@/lib/desktop" import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice" @@ -258,6 +260,7 @@ function notifyMessageSent(sessionId: string): void { // --------------------------------------------------------------------------- export type NewSessionDraftState = { + draftId: number open: boolean selectedProjectId?: string | null directoryOverride: string | null @@ -271,6 +274,8 @@ export type NewSessionDraftState = { syntheticParts?: SyntheticContextPart[] targetFolderId?: string projectContextPins?: { notes: string[]; plans: string[] } + target: "chat" | "project" + preparedChatDirectory?: string | null } export type ViewportAnchor = { @@ -316,6 +321,7 @@ export type SessionUIState = { prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void openNewSessionDraft: (options?: Partial & { automatic?: boolean }) => void + prepareChatDraftDirectory: () => Promise closeNewSessionDraft: () => void setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void setDraftPreserveDirectoryOverride: (value: boolean) => void @@ -548,10 +554,14 @@ const activateConfigForDirectory = async (directory: string | null | undefined): } const DEFAULT_DRAFT: NewSessionDraftState = { + draftId: 0, open: false, directoryOverride: null, parentID: null, + target: "chat", } +let nextDraftId = 1 +const pendingChatDirectoryByDraft = new Map>() const activeSessionByRuntime = new Map() type RuntimeSessionMemory = { @@ -726,6 +736,18 @@ export async function materializeOpenDraftSession(selection: { store.resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride) } + const isChatDraft = draft.target === "chat" + if (isChatDraft) { + draftDirectoryOverride = await store.prepareChatDraftDirectory() + if (!draftDirectoryOverride) throw new Error("Failed to prepare chat directory") + const currentDraft = useSessionUIStore.getState().newSessionDraft + if (currentDraft.draftId === draft.draftId) { + useSessionUIStore.setState({ + newSessionDraft: { ...currentDraft, preparedChatDirectory: null }, + }) + } + } + await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId) const draftPins = draft.projectContextPins ?? { notes: [], plans: [] } @@ -737,7 +759,12 @@ export async function materializeOpenDraftSession(selection: { ? { openchamber: { project_context_pins: draftPins } } : undefined, ) - if (!created?.id) throw new Error("Failed to create session") + if (!created?.id) { + if (isChatDraft && draftDirectoryOverride) { + await deleteChatDirectory(draftDirectoryOverride).catch(() => undefined) + } + throw new Error("Failed to create session") + } // The server response is authoritative. It may canonicalize a requested // worktree path (for example through a symlink or platform path casing). @@ -989,7 +1016,16 @@ export const useSessionUIStore = create()((set, get) => ({ const explicitDirectory = options?.directoryOverride !== undefined ? normalizePath(options.directoryOverride) : null - const explicitProject = options?.selectedProjectId + let target = isVSCodeRuntime() ? "project" : options?.target + if (!target) { + const hasExplicitProjectTarget = options?.directoryOverride !== undefined + || (options?.selectedProjectId !== undefined && options.selectedProjectId !== CHAT_DRAFT_PROJECT_ID) + || isVSCodeRuntime() + target = options?.selectedProjectId === CHAT_DRAFT_PROJECT_ID || !hasExplicitProjectTarget + ? "chat" + : "project" + } + const explicitProject = target === "project" && options?.selectedProjectId ? projects.find((p) => p.id === options.selectedProjectId) ?? null : null @@ -1006,14 +1042,14 @@ export const useSessionUIStore = create()((set, get) => ({ const persistedProjectByDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null) const currentDirProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory) - const selectedProject = (() => { + const selectedProject = target === "chat" ? null : (() => { if (explicitProject) return explicitProject if (explicitDirectory !== null) return inferredProjectFromDir if (currentDirectory) return currentDirProject return persistedProjectByDir ?? persistedProjectById ?? fallbackProject })() - const directory = (() => { + const directory = target === "chat" ? null : (() => { if (explicitDirectory !== null) return explicitDirectory if (explicitProject) return normalizePath(explicitProject.path ?? null) if (currentDirectory) return currentDirectory @@ -1021,10 +1057,17 @@ export const useSessionUIStore = create()((set, get) => ({ return normalizePath(selectedProject?.path ?? null) })() + if (target === "chat") { + warmChatsRootDirectory() + } + persistDraftTarget({ projectId: selectedProject?.id ?? null, directory }) const nextDraft: NewSessionDraftState = { + draftId: nextDraftId++, open: true, + target, + preparedChatDirectory: null, selectedProjectId: selectedProject?.id ?? null, directoryOverride: directory, permissionAutoAcceptEnabled: options?.permissionAutoAcceptEnabled === true, @@ -1040,9 +1083,7 @@ export const useSessionUIStore = create()((set, get) => ({ } set({ - newSessionDraft: { - ...nextDraft, - }, + newSessionDraft: nextDraft, currentSessionId: null, currentSessionDirectory: null, error: null, @@ -1078,11 +1119,44 @@ export const useSessionUIStore = create()((set, get) => ({ void recoverStaleDraftDirectory(nextDraft) }, + prepareChatDraftDirectory: async () => { + const draft = get().newSessionDraft + if (!draft.open || draft.target !== "chat") return null + if (draft.preparedChatDirectory) return draft.preparedChatDirectory + + const runtimeKey = getRuntimeKey() + const key = `${runtimeKey}:${draft.draftId}` + const existing = pendingChatDirectoryByDraft.get(key) + if (existing) return existing + + const pending = createChatDirectory().then(async (directory) => { + const current = get().newSessionDraft + if ( + getRuntimeKey() !== runtimeKey + || !current.open + || current.target !== "chat" + || current.draftId !== draft.draftId + ) { + await deleteChatDirectory(directory).catch(() => undefined) + return null + } + set({ newSessionDraft: { ...current, preparedChatDirectory: directory } }) + return directory + }).finally(() => { + pendingChatDirectoryByDraft.delete(key) + }) + pendingChatDirectoryByDraft.set(key, pending) + return pending + }, + // --------------------------------------------------------------------------- // closeNewSessionDraft // --------------------------------------------------------------------------- closeNewSessionDraft: () => { const currentDraft = get().newSessionDraft + if (currentDraft.preparedChatDirectory) { + void deleteChatDirectory(currentDraft.preparedChatDirectory).catch(() => undefined) + } if ( !currentDraft.open && currentDraft.selectedProjectId == null @@ -1100,18 +1174,21 @@ export const useSessionUIStore = create()((set, get) => ({ return } const nextDraft: NewSessionDraftState = { - open: false, - selectedProjectId: null, - directoryOverride: null, - pendingWorktreeRequestId: null, - bootstrapPendingDirectory: null, - preserveDirectoryOverride: false, - parentID: null, - title: undefined, - initialPrompt: undefined, - syntheticParts: undefined, - targetFolderId: undefined, - } + draftId: currentDraft.draftId, + open: false, + target: "chat", + preparedChatDirectory: null, + selectedProjectId: null, + directoryOverride: null, + pendingWorktreeRequestId: null, + bootstrapPendingDirectory: null, + preserveDirectoryOverride: false, + parentID: null, + title: undefined, + initialPrompt: undefined, + syntheticParts: undefined, + targetFolderId: undefined, + } set({ newSessionDraft: nextDraft, }) @@ -1119,14 +1196,21 @@ export const useSessionUIStore = create()((set, get) => ({ }, setNewSessionDraftTarget: (target) => { + if (isVSCodeRuntime() && target.projectId === CHAT_DRAFT_PROJECT_ID) return + const previousDraft = get().newSessionDraft + if (previousDraft.preparedChatDirectory && target.projectId !== CHAT_DRAFT_PROJECT_ID) { + void deleteChatDirectory(previousDraft.preparedChatDirectory).catch(() => undefined) + } let nextDirectory: string | null = null set((s) => { nextDirectory = normalizePath(target.directoryOverride ?? s.newSessionDraft.directoryOverride) return { newSessionDraft: { ...s.newSessionDraft, + target: target.projectId === CHAT_DRAFT_PROJECT_ID ? "chat" : "project", + preparedChatDirectory: target.projectId === CHAT_DRAFT_PROJECT_ID ? s.newSessionDraft.preparedChatDirectory : null, selectedProjectId: target.projectId ?? target.selectedProjectId ?? s.newSessionDraft.selectedProjectId, - directoryOverride: target.directoryOverride ?? s.newSessionDraft.directoryOverride, + directoryOverride: target.projectId === CHAT_DRAFT_PROJECT_ID ? null : target.directoryOverride ?? s.newSessionDraft.directoryOverride, }, } }) diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 470c63f1..31d8ff26 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1291,6 +1291,7 @@ const resolveMemoryProjectId = createMemoryProjectResolver({ return sanitizeProjects(settings?.projects || []).map((project) => project.path); }, resolvePrimaryWorktreeRoot, + managedProjectRoots: [path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats')], }); /** diff --git a/packages/web/server/lib/agent-memory/project-resolution.js b/packages/web/server/lib/agent-memory/project-resolution.js index 85442897..03bb00de 100644 --- a/packages/web/server/lib/agent-memory/project-resolution.js +++ b/packages/web/server/lib/agent-memory/project-resolution.js @@ -24,7 +24,8 @@ const normalize = (value) => { }; export const createMemoryProjectResolver = (dependencies) => { - const { listProjectPaths, resolvePrimaryWorktreeRoot } = dependencies; + const { listProjectPaths, resolvePrimaryWorktreeRoot, managedProjectRoots = [] } = dependencies; + const managedRoots = managedProjectRoots.map(normalize).filter(Boolean); return async (directory) => { const resolved = normalize(directory); @@ -32,6 +33,14 @@ export const createMemoryProjectResolver = (dependencies) => { return ''; } + const managedRoot = managedRoots.find((root) => { + const relative = path.relative(root, resolved); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + }); + if (managedRoot) { + return createProjectIdFromPath(managedRoot); + } + let configured = []; try { configured = ((await listProjectPaths()) || []).map(normalize).filter(Boolean); diff --git a/packages/web/server/lib/agent-memory/project-resolution.test.js b/packages/web/server/lib/agent-memory/project-resolution.test.js index f38c2bbf..9ca6d098 100644 --- a/packages/web/server/lib/agent-memory/project-resolution.test.js +++ b/packages/web/server/lib/agent-memory/project-resolution.test.js @@ -51,6 +51,15 @@ describe('resolving a session directory to its project', () => { expect(await resolve('/tmp/loose')).toBe(createProjectIdFromPath('/tmp/loose')); }); + test('managed chat session directories share the Chats root store', async () => { + const chatsRoot = '/Users/x/.config/openchamber/chats'; + const resolve = createResolver({ managedProjectRoots: [chatsRoot] }); + + expect(await resolve(`${chatsRoot}/2026-08-21/session-a`)).toBe(createProjectIdFromPath(chatsRoot)); + expect(await resolve(`${chatsRoot}/2026-08-21/session-b`)).toBe(createProjectIdFromPath(chatsRoot)); + expect(await resolve('/Users/x/.config/openchamber/chats-other/session-a')).not.toBe(createProjectIdFromPath(chatsRoot)); + }); + test('no directory resolves to nothing rather than to some default project', async () => { const resolve = createResolver(); diff --git a/packages/web/server/lib/project-context/DOCUMENTATION.md b/packages/web/server/lib/project-context/DOCUMENTATION.md index 52a584d3..ecd0205a 100644 --- a/packages/web/server/lib/project-context/DOCUMENTATION.md +++ b/packages/web/server/lib/project-context/DOCUMENTATION.md @@ -3,6 +3,8 @@ Server-owned storage for the Project Notes surface: free-form notes, todos, and plan markdown files. +The managed Chats root (`~/.config/openchamber/chats`) is also one context owner. Every dated per-session directory beneath it resolves to that root, so Notes, Todo, Plans, pinned knowledge, and project memory are shared across ordinary chats without registering Chats as a user project. + ## Ownership | Path | Owner | Contents | diff --git a/packages/web/server/lib/session-knowledge/DOCUMENTATION.md b/packages/web/server/lib/session-knowledge/DOCUMENTATION.md index aa13a095..3aaeb9fd 100644 --- a/packages/web/server/lib/session-knowledge/DOCUMENTATION.md +++ b/packages/web/server/lib/session-knowledge/DOCUMENTATION.md @@ -24,6 +24,8 @@ attached to that session. Pins never come from project-wide note or plan state. A new-session draft passes its pins into this metadata when its first message creates the session. +Directories beneath the managed `~/.config/openchamber/chats` root resolve to that root before project context and project memory are read. Every ordinary chat therefore shares one Chats knowledge owner instead of creating an unreachable context store for each dated session directory. + `session.metadata.openchamber.knowledge_context_delivered` holds the signature of what the session is carrying. It lives with the session, so it survives the tab closing and is visible to every sender, including the ones with no tab. From 59df2963ae95b0d0390fb3822bc8d91ceb0d51c5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 12:49:40 +0300 Subject: [PATCH 06/59] fix(sidebar): keep sticky activity label in sync Track Chats and Recent section sentinels so the desktop sticky overlay changes identity only when the corresponding section reaches the top of the sidebar. --- .../session/sidebar/DOCUMENTATION.md | 2 +- .../sidebar/SidebarActivitySections.tsx | 5 ++ .../session/sidebar/SidebarProjectsList.tsx | 46 ++++++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index 42bf3fb8..f3e66647 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -29,7 +29,7 @@ - `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all). - A successful add/create/clone from the project-directory dialog transitions to a new-session draft targeted at that project, matching the project's `+` action; changing project metadata alone must not leave the visible session or draft on a different directory. - `SidebarNav.tsx`: Text navigation rows above the tree (New session, Scheduled, Multi-run, Archive); hidden in VS Code. -- `SidebarActivitySections.tsx`: Global top section renderer for project-only `recent` sessions followed by OpenChamber-managed `chats`, styled as zone headers. +- `SidebarActivitySections.tsx`: Global top section renderer for OpenChamber-managed `chats` followed by optional project-only `recent` sessions, styled as zone headers. The desktop sticky identity overlay follows the activity header whose sentinel has crossed the scroller edge, so a small scroll cannot relabel Chats as Recent. - `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions. - `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder. - `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows. diff --git a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx b/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx index d3f95a35..d61b66b3 100644 --- a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx @@ -183,6 +183,11 @@ export function SidebarActivitySections(props: Props): React.ReactNode { return (
+ - ); - } - - if (sessionMessages.length === 0 && !sessionIsWorking) { - return ( - // No transform here either — same fixed-positioning constraint as the - // draft branch above. -
- {returnToParentButton} -
- {!isDesktopExpandedInput ? ( -
- -
- ) : null} -
-
- {promptReadOnly ? : } -
-
- ); - } + /> + ); + } - return ( -
-
- {returnToParentButton} - = ({ isLoadingOlderPrompts={timelineController.isLoadingOlder} onLoadEarlierPrompts={handleLoadOlderClick} /> + ); + })(); + + return ( +
+
+ {returnToParentButton} + {sessionSurface}
- {!isDesktopExpandedInput && sessionMessages.length > 0 && ( + {!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && ( )} - {promptReadOnly ? : } + {promptReadOnly ? ( + + ) : ( + + )}
{/* Inside the chat column, not beside it: as a row sibling it took diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index fa98b7d0..83a3c2e9 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -224,6 +224,7 @@ interface ChatInputProps { onOpenSettings?: () => void; scrollToBottom?: () => void; active?: boolean; + draftPresentationExiting?: boolean; } const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => { @@ -237,7 +238,12 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | return createChatDraftIdentity(getRuntimeKey(), directory, sessionId); }; -const ChatInputComponent: React.FC = ({ onOpenSettings, scrollToBottom, active = true }) => { +const ChatInputComponent: React.FC = ({ + onOpenSettings, + scrollToBottom, + active = true, + draftPresentationExiting = false, +}) => { const { t } = useI18n(); // Track if we restored a draft on mount (for text selection) const initialDraftRef = React.useRef(null); @@ -2375,6 +2381,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const chatSurfaceMode = useChatSurfaceMode(); const isMiniChatSurface = chatSurfaceMode === 'mini-chat'; + const showDesktopDraftPresentation = (newSessionDraftOpen || draftPresentationExiting) + && !isDesktopExpanded + && !isMobile + && !isVSCode + && !isMiniChatSurface; + const draftPresentationClassName = cn( + 'transition-opacity duration-100 ease-out motion-reduce:transition-none', + draftPresentationExiting && 'pointer-events-none opacity-0', + ); const hasPendingChanges = React.useMemo(() => { if (isMiniChatSurface) { @@ -2552,8 +2567,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo )} style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined} > - {newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? ( -
+ {showDesktopDraftPresentation ? ( +

{renderDraftTitle( draftProjectLabel @@ -2624,21 +2639,23 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ? null : } /> - {!isMobile && showDraftTargetSelectors && selectedDraftProject ? ( - + {!isMobile && (showDraftTargetSelectors || draftPresentationExiting) && selectedDraftProject ? ( +
+ +
) : null} {isMobile && showDraftTargetSelectors && selectedDraftProject ? ( = ({ onOpenSettings, scrollTo /> ) : null}

- {newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? ( + {showDesktopDraftPresentation ? ( submitPresetPrompt(starter.submitText, starter.ref.type)} - className="chat-input-column mt-4" + className={cn('chat-input-column mt-4', draftPresentationClassName)} /> ) : null} diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 667c2db7..63136460 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -7,6 +7,14 @@ everything between typing and sending. own state and wires these modules together; it should not grow logic that belongs to one of them. +`ChatContainer.tsx` keeps one `ChatInput` mounted while a new-session draft +becomes its first session. Draft-only UI first fades for 100ms while the editor +stays in place. The parent then moves the editor to its final session position +with a 120ms transform-only FLIP animation. Reduced-motion mode skips these +transitions. Do not restore separate draft and session composer branches: +remounting the editor loses focus and interrupts the transition. Keep the +existing mobile fixed-position rules unchanged. + ## Layers | Directory | Owns | From eda20654a662f9b5c19bb6aa571ff66412e3112c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 17:45:05 +0300 Subject: [PATCH 17/59] fix(chat): let bash output grow with content --- packages/ui/src/components/chat/message/parts/DOCUMENTATION.md | 2 +- packages/ui/src/components/chat/message/parts/ToolPart.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index edc16029..730b78e2 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -86,7 +86,7 @@ Use this doc when you ask an agent to change tool/header/description behavior. - The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card. - `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render. - The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`. -- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering. +- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its output viewport grows with the content up to `46vh`, then scrolls and follows new output until the user scrolls up; following resumes when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering. - Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`). ## "I want to change description for Perplexity" (example recipe) diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 29b5553b..18e6fa5d 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1548,7 +1548,7 @@ const ToolExpandedContent: React.FC = React.memo(({ output, { className: part.tool === 'bash' ? 'p-1 rounded-none' : 'p-1', - maxHeightClass: isStreamingBash ? 'h-[46vh]' : part.tool === 'bash' ? 'max-h-[46vh]' : undefined, + maxHeightClass: part.tool === 'bash' ? 'max-h-[46vh]' : undefined, followKey: isStreamingBash ? outputString : undefined, } ); From f29844b2f1629cfa94a3c7678569949e23576bca Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 20:45:49 +0300 Subject: [PATCH 18/59] feat(settings): mark integrations as experimental --- packages/docs/content/docs/de/integrations.mdx | 2 ++ packages/docs/content/docs/es/integrations.mdx | 2 ++ packages/docs/content/docs/fr/integrations.mdx | 2 ++ packages/docs/content/docs/integrations.mdx | 2 ++ packages/docs/content/docs/ja/integrations.mdx | 2 ++ packages/docs/content/docs/ko/integrations.mdx | 2 ++ packages/docs/content/docs/pl/integrations.mdx | 2 ++ packages/docs/content/docs/pt-br/integrations.mdx | 2 ++ packages/docs/content/docs/uk/integrations.mdx | 2 ++ packages/docs/content/docs/zh-cn/integrations.mdx | 2 ++ .../sections/integrations/IntegrationsPage.tsx | 7 +++++++ packages/ui/src/components/views/SettingsView.tsx | 2 +- .../messages/third-party-integrations.i18n.test.ts | 1 + .../i18n/messages/third-party-integrations.i18n.ts | 11 +++++++++++ 14 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/docs/content/docs/de/integrations.mdx b/packages/docs/content/docs/de/integrations.mdx index 42f5972b..ff674b1f 100644 --- a/packages/docs/content/docs/de/integrations.mdx +++ b/packages/docs/content/docs/de/integrations.mdx @@ -7,6 +7,8 @@ description: Nutze dein Claude-, Command-Code- oder Cursor-Abo als Provider. Eine Integration ist ein kleines Plugin, das OpenChamber einen Provider hinzufügt — auf Basis eines Abos, das du bereits hast. Verwalten kannst du sie unter **Settings → Integrations**. +> **Experimentelle Funktion:** Integrationen können sich ändern oder nicht mehr funktionieren. Nutze sie nach eigenem Ermessen. + Verfügbare Integrationen: - **Claude Code** — dein Claude Pro- oder Max-Plan, ohne API-Keys diff --git a/packages/docs/content/docs/es/integrations.mdx b/packages/docs/content/docs/es/integrations.mdx index 206e40d8..246f52ce 100644 --- a/packages/docs/content/docs/es/integrations.mdx +++ b/packages/docs/content/docs/es/integrations.mdx @@ -7,6 +7,8 @@ description: Usa tu suscripción de Claude, Command Code o Cursor como proveedor Una integración es un pequeño plugin que añade un proveedor a OpenChamber usando una suscripción que ya tienes. Las gestionas en **Settings → Integrations**. +> **Función experimental:** las integraciones pueden cambiar o dejar de funcionar. Úsalas bajo tu propia responsabilidad. + Integraciones disponibles: - **Claude Code** — tu plan Claude Pro o Max, sin claves de API diff --git a/packages/docs/content/docs/fr/integrations.mdx b/packages/docs/content/docs/fr/integrations.mdx index df347a34..f6e4711e 100644 --- a/packages/docs/content/docs/fr/integrations.mdx +++ b/packages/docs/content/docs/fr/integrations.mdx @@ -7,6 +7,8 @@ description: Utilise ton abonnement Claude, Command Code ou Cursor comme fournis Une intégration est un petit plugin qui ajoute un fournisseur à OpenChamber à partir d'un abonnement que tu possèdes déjà. Tu les gères dans **Settings → Integrations**. +> **Fonctionnalité expérimentale :** les intégrations peuvent changer ou cesser de fonctionner. Utilise-les à ta discrétion. + Intégrations disponibles : - **Claude Code** — ton plan Claude Pro ou Max, sans clés API diff --git a/packages/docs/content/docs/integrations.mdx b/packages/docs/content/docs/integrations.mdx index 14aee1f1..d45fe3f7 100644 --- a/packages/docs/content/docs/integrations.mdx +++ b/packages/docs/content/docs/integrations.mdx @@ -7,6 +7,8 @@ description: Use your Claude, Command Code, or Cursor subscription as a provider An integration is a small plugin that adds a provider to OpenChamber using a subscription you already have. You manage them at **Settings → Integrations**. +> **Experimental feature:** integrations may change or stop working. Use them at your own discretion. + Available integrations: - **Claude Code** — your Claude Pro or Max plan, no API keys diff --git a/packages/docs/content/docs/ja/integrations.mdx b/packages/docs/content/docs/ja/integrations.mdx index 02fa8e36..df3664d2 100644 --- a/packages/docs/content/docs/ja/integrations.mdx +++ b/packages/docs/content/docs/ja/integrations.mdx @@ -7,6 +7,8 @@ description: Claude、Command Code、Cursor のサブスクリプションをプ 統合機能(インテグレーション)は、すでに持っているサブスクリプションを使って OpenChamber にプロバイダーを追加する小さなプラグインです。**Settings → Integrations** で管理します。 +> **実験的な機能:** 連携は変更されたり、動作しなくなったりする可能性があります。自己責任で使用してください。 + 利用できる統合機能: - **Claude Code** — Claude Pro または Max プラン、API キー不要 diff --git a/packages/docs/content/docs/ko/integrations.mdx b/packages/docs/content/docs/ko/integrations.mdx index f2589d50..192f0f55 100644 --- a/packages/docs/content/docs/ko/integrations.mdx +++ b/packages/docs/content/docs/ko/integrations.mdx @@ -7,6 +7,8 @@ description: Claude, Command Code 또는 Cursor 구독을 공급자로 사용하 통합 기능(인테그레이션)은 이미 가지고 있는 구독을 사용해 OpenChamber에 공급자를 추가하는 작은 플러그인입니다. **Settings → Integrations**에서 관리합니다. +> **실험 단계 기능:** 통합 기능은 변경되거나 작동하지 않을 수 있습니다. 본인의 판단에 따라 사용하세요. + 사용 가능한 통합 기능: - **Claude Code** — Claude Pro 또는 Max 플랜, API 키 불필요 diff --git a/packages/docs/content/docs/pl/integrations.mdx b/packages/docs/content/docs/pl/integrations.mdx index aca4b269..82f2429c 100644 --- a/packages/docs/content/docs/pl/integrations.mdx +++ b/packages/docs/content/docs/pl/integrations.mdx @@ -7,6 +7,8 @@ description: Używaj subskrypcji Claude, Command Code lub Cursor jako dostawcy. Integracja to mała wtyczka, która dodaje dostawcę do OpenChamber na podstawie subskrypcji, którą już masz. Zarządzasz nimi w **Settings → Integrations**. +> **Funkcja eksperymentalna:** integracje mogą się zmienić lub przestać działać. Korzystasz z nich na własną odpowiedzialność. + Dostępne integracje: - **Claude Code** — Twój plan Claude Pro lub Max, bez kluczy API diff --git a/packages/docs/content/docs/pt-br/integrations.mdx b/packages/docs/content/docs/pt-br/integrations.mdx index 1d66cbda..d5df5cbf 100644 --- a/packages/docs/content/docs/pt-br/integrations.mdx +++ b/packages/docs/content/docs/pt-br/integrations.mdx @@ -7,6 +7,8 @@ description: Use sua assinatura Claude, Command Code ou Cursor como provedor. Uma integração é um pequeno plugin que adiciona um provedor ao OpenChamber usando uma assinatura que você já tem. Você as gerencia em **Settings → Integrations**. +> **Recurso experimental:** as integrações podem mudar ou deixar de funcionar. Use-as por sua conta e risco. + Integrações disponíveis: - **Claude Code** — seu plano Claude Pro ou Max, sem chaves de API diff --git a/packages/docs/content/docs/uk/integrations.mdx b/packages/docs/content/docs/uk/integrations.mdx index b68da46c..94925355 100644 --- a/packages/docs/content/docs/uk/integrations.mdx +++ b/packages/docs/content/docs/uk/integrations.mdx @@ -7,6 +7,8 @@ description: Використовуйте підписки Claude, Command Code Інтеграція — це невеликий плагін, що додає провайдера до OpenChamber на основі підписки, яка в вас уже є. Керувати ними можна в **Settings → Integrations**. +> **Експериментальна функція:** інтеграції можуть змінюватися або перестати працювати. Використовуйте їх на власний розсуд. + Доступні інтеграції: - **Claude Code** — ваша підписка Claude Pro або Max, без API-ключів diff --git a/packages/docs/content/docs/zh-cn/integrations.mdx b/packages/docs/content/docs/zh-cn/integrations.mdx index 94daf884..e13b5ee9 100644 --- a/packages/docs/content/docs/zh-cn/integrations.mdx +++ b/packages/docs/content/docs/zh-cn/integrations.mdx @@ -7,6 +7,8 @@ description: 将你的 Claude、Command Code 或 Cursor 订阅用作提供商。 集成是一个小型插件,它使用你已有的订阅为 OpenChamber 添加一个提供商。你可以在 **Settings → Integrations** 中管理它们。 +> **实验性功能:**集成可能会变更或停止工作。请自行酌情使用。 + 可用的集成: - **Claude Code** — 你的 Claude Pro 或 Max 套餐,无需 API 密钥 diff --git a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx index f07ce340..047755d0 100644 --- a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx +++ b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Icon } from '@/components/icon/Icon'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; import { useI18n } from '@/lib/i18n'; import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection'; @@ -20,6 +21,12 @@ export const IntegrationsPage: React.FC = ({ description={t('settings.page.integrations.description')} showSaveStatus={false} > +
+ +

+ {t('settings.integrations.experimentalWarning')} +

+
= ({ onClose, forceMobile : } {getPageTitle(page.slug)} - {page.slug === 'tunnel' && ( + {(page.slug === 'tunnel' || page.slug === 'integrations') && ( {t('settings.view.badge.beta')} diff --git a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts index 03a57e39..01cd2750 100644 --- a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts +++ b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts @@ -6,6 +6,7 @@ const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN const requiredKeys = [ 'settings.page.integrations.title', 'settings.page.integrations.description', + 'settings.integrations.experimentalWarning', 'settings.integrations.messengers.title', 'settings.integrations.messengers.discord.name', 'settings.integrations.messengers.telegram.name', diff --git a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts index 7bf69308..38b64549 100644 --- a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts +++ b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts @@ -3,6 +3,7 @@ export const thirdPartyIntegrationI18n = { en: { 'settings.page.integrations.title': 'Integrations', 'settings.page.integrations.description': 'Add third-party subscriptions to use as OpenChamber providers.', + 'settings.integrations.experimentalWarning': 'This is an experimental feature. Integrations may change or stop working. Use them at your own discretion.', 'settings.integrations.messengers.title': 'Messengers', 'settings.integrations.messengers.info': 'Chat with OpenChamber from Discord or Telegram. These bridges are not available yet.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -45,6 +46,7 @@ export const thirdPartyIntegrationI18n = { de: { 'settings.page.integrations.title': 'Integrationen', 'settings.page.integrations.description': 'Füge Drittanbieter-Abonnements hinzu, um sie als OpenChamber-Provider zu nutzen.', + 'settings.integrations.experimentalWarning': 'Dies ist eine experimentelle Funktion. Integrationen können sich ändern oder nicht mehr funktionieren. Nutze sie nach eigenem Ermessen.', 'settings.integrations.messengers.title': 'Messenger', 'settings.integrations.messengers.info': 'Chatte mit OpenChamber über Discord oder Telegram. Diese Bridges sind noch nicht verfügbar.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -87,6 +89,7 @@ export const thirdPartyIntegrationI18n = { fr: { 'settings.page.integrations.title': 'Intégrations', 'settings.page.integrations.description': 'Ajoutez des abonnements tiers à utiliser comme fournisseurs OpenChamber.', + 'settings.integrations.experimentalWarning': 'Cette fonctionnalité est expérimentale. Les intégrations peuvent changer ou cesser de fonctionner. Utilisez-les à votre discrétion.', 'settings.integrations.messengers.title': 'Messagers', 'settings.integrations.messengers.info': 'Discutez avec OpenChamber depuis Discord ou Telegram. Ces ponts ne sont pas encore disponibles.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -129,6 +132,7 @@ export const thirdPartyIntegrationI18n = { es: { 'settings.page.integrations.title': 'Integraciones', 'settings.page.integrations.description': 'Añade suscripciones de terceros para usarlas como proveedores de OpenChamber.', + 'settings.integrations.experimentalWarning': 'Esta función es experimental. Las integraciones pueden cambiar o dejar de funcionar. Úsalas bajo tu propia responsabilidad.', 'settings.integrations.messengers.title': 'Mensajeros', 'settings.integrations.messengers.info': 'Chatea con OpenChamber desde Discord o Telegram. Estos puentes aún no están disponibles.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -171,6 +175,7 @@ export const thirdPartyIntegrationI18n = { ja: { 'settings.page.integrations.title': '連携', 'settings.page.integrations.description': 'サードパーティのサブスクリプションを追加して、OpenChamber のプロバイダーとして使います。', + 'settings.integrations.experimentalWarning': 'これは実験的な機能です。連携は変更されたり、動作しなくなったりする可能性があります。自己責任で使用してください。', 'settings.integrations.messengers.title': 'メッセンジャー', 'settings.integrations.messengers.info': 'Discord または Telegram から OpenChamber とチャットできます。これらの連携はまだ利用できません。', 'settings.integrations.messengers.discord.name': 'Discord', @@ -213,6 +218,7 @@ export const thirdPartyIntegrationI18n = { ko: { 'settings.page.integrations.title': '통합', 'settings.page.integrations.description': '타사 구독을 추가해 OpenChamber 프로바이더로 사용하세요.', + 'settings.integrations.experimentalWarning': '이 기능은 실험 단계입니다. 통합 기능은 변경되거나 작동하지 않을 수 있습니다. 본인의 판단에 따라 사용하세요.', 'settings.integrations.messengers.title': '메신저', 'settings.integrations.messengers.info': 'Discord 또는 Telegram에서 OpenChamber와 채팅하세요. 이 브리지는 아직 사용할 수 없습니다.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -255,6 +261,7 @@ export const thirdPartyIntegrationI18n = { pl: { 'settings.page.integrations.title': 'Integracje', 'settings.page.integrations.description': 'Dodaj subskrypcje zewnętrzne, aby używać ich jako dostawców OpenChamber.', + 'settings.integrations.experimentalWarning': 'To funkcja eksperymentalna. Integracje mogą się zmienić lub przestać działać. Korzystasz z nich na własną odpowiedzialność.', 'settings.integrations.messengers.title': 'Komunikatory', 'settings.integrations.messengers.info': 'Czatuj z OpenChamber przez Discord lub Telegram. Te mosty nie są jeszcze dostępne.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -297,6 +304,7 @@ export const thirdPartyIntegrationI18n = { 'pt-BR': { 'settings.page.integrations.title': 'Integrações', 'settings.page.integrations.description': 'Adicione assinaturas de terceiros para usar como provedores do OpenChamber.', + 'settings.integrations.experimentalWarning': 'Este recurso é experimental. As integrações podem mudar ou deixar de funcionar. Use-as por sua conta e risco.', 'settings.integrations.messengers.title': 'Mensageiros', 'settings.integrations.messengers.info': 'Converse com o OpenChamber pelo Discord ou Telegram. Essas pontes ainda não estão disponíveis.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -339,6 +347,7 @@ export const thirdPartyIntegrationI18n = { uk: { 'settings.page.integrations.title': 'Інтеграції', 'settings.page.integrations.description': 'Додайте сторонні підписки, щоб використовувати їх як провайдери OpenChamber.', + 'settings.integrations.experimentalWarning': 'Це експериментальна функція. Інтеграції можуть змінюватися або перестати працювати. Використовуйте їх на власний розсуд.', 'settings.integrations.messengers.title': 'Месенджери', 'settings.integrations.messengers.info': 'Спілкуйтеся з OpenChamber у Discord або Telegram. Ці мости ще недоступні.', 'settings.integrations.messengers.discord.name': 'Discord', @@ -381,6 +390,7 @@ export const thirdPartyIntegrationI18n = { 'zh-CN': { 'settings.page.integrations.title': '集成', 'settings.page.integrations.description': '添加第三方订阅,将其用作 OpenChamber 提供商。', + 'settings.integrations.experimentalWarning': '这是实验性功能。集成可能会变更或停止工作。请自行酌情使用。', 'settings.integrations.messengers.title': '即时通讯', 'settings.integrations.messengers.info': '通过 Discord 或 Telegram 与 OpenChamber 聊天。这些桥接尚不可用。', 'settings.integrations.messengers.discord.name': 'Discord', @@ -423,6 +433,7 @@ export const thirdPartyIntegrationI18n = { 'zh-TW': { 'settings.page.integrations.title': '整合', 'settings.page.integrations.description': '新增第三方訂閱,將其用作 OpenChamber 供應商。', + 'settings.integrations.experimentalWarning': '這是實驗性功能。整合可能會變更或停止運作。請自行斟酌使用。', 'settings.integrations.messengers.title': '即時通訊', 'settings.integrations.messengers.info': '透過 Discord 或 Telegram 與 OpenChamber 聊天。這些橋接尚不可用。', 'settings.integrations.messengers.discord.name': 'Discord', From 9f7d839fc61dabc72f118d4293153e246da8ec9e Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 20:46:44 +0300 Subject: [PATCH 19/59] docs(integrations): add provider account notice --- packages/docs/content/docs/de/integrations.mdx | 2 ++ packages/docs/content/docs/es/integrations.mdx | 2 ++ packages/docs/content/docs/fr/integrations.mdx | 2 ++ packages/docs/content/docs/integrations.mdx | 2 ++ packages/docs/content/docs/ja/integrations.mdx | 2 ++ packages/docs/content/docs/ko/integrations.mdx | 2 ++ packages/docs/content/docs/pl/integrations.mdx | 2 ++ packages/docs/content/docs/pt-br/integrations.mdx | 2 ++ packages/docs/content/docs/uk/integrations.mdx | 2 ++ packages/docs/content/docs/zh-cn/integrations.mdx | 2 ++ 10 files changed, 20 insertions(+) diff --git a/packages/docs/content/docs/de/integrations.mdx b/packages/docs/content/docs/de/integrations.mdx index ff674b1f..58c2acf4 100644 --- a/packages/docs/content/docs/de/integrations.mdx +++ b/packages/docs/content/docs/de/integrations.mdx @@ -9,6 +9,8 @@ Eine Integration ist ein kleines Plugin, das OpenChamber einen Provider hinzufü > **Experimentelle Funktion:** Integrationen können sich ändern oder nicht mehr funktionieren. Nutze sie nach eigenem Ermessen. +Wir haben diese Integrationen so entwickelt, dass sie die vorgesehenen Anmeldewege der Anbieter nutzen und bekannte Verstöße gegen deren Nutzungsbedingungen vermeiden. Wir können nicht garantieren, dass ein Anbieter jede Nutzung oder jedes Konto akzeptiert. Lies die Bedingungen des Anbieters und nutze Integrationen auf eigenes Risiko. OpenChamber kann keine Kontobeschränkungen, Sperrungen oder Streitfälle mit einem Anbieter klären. + Verfügbare Integrationen: - **Claude Code** — dein Claude Pro- oder Max-Plan, ohne API-Keys diff --git a/packages/docs/content/docs/es/integrations.mdx b/packages/docs/content/docs/es/integrations.mdx index 246f52ce..d1f23cc7 100644 --- a/packages/docs/content/docs/es/integrations.mdx +++ b/packages/docs/content/docs/es/integrations.mdx @@ -9,6 +9,8 @@ Una integración es un pequeño plugin que añade un proveedor a OpenChamber usa > **Función experimental:** las integraciones pueden cambiar o dejar de funcionar. Úsalas bajo tu propia responsabilidad. +Diseñamos estas integraciones para seguir los flujos de inicio de sesión previstos por los proveedores y evitar infracciones conocidas de sus Términos de Servicio. No podemos garantizar que un proveedor acepte cada uso o cuenta. Revisa los términos del proveedor y usa las integraciones bajo tu propia responsabilidad. OpenChamber no puede resolver restricciones, suspensiones de cuentas ni disputas con un proveedor. + Integraciones disponibles: - **Claude Code** — tu plan Claude Pro o Max, sin claves de API diff --git a/packages/docs/content/docs/fr/integrations.mdx b/packages/docs/content/docs/fr/integrations.mdx index f6e4711e..c736ba85 100644 --- a/packages/docs/content/docs/fr/integrations.mdx +++ b/packages/docs/content/docs/fr/integrations.mdx @@ -9,6 +9,8 @@ Une intégration est un petit plugin qui ajoute un fournisseur à OpenChamber à > **Fonctionnalité expérimentale :** les intégrations peuvent changer ou cesser de fonctionner. Utilise-les à ta discrétion. +Nous avons conçu ces intégrations pour suivre les méthodes de connexion prévues par les fournisseurs et éviter les violations connues de leurs conditions d'utilisation. Nous ne pouvons pas garantir qu'un fournisseur acceptera chaque usage ou chaque compte. Consulte les conditions du fournisseur et utilise les intégrations à tes risques. OpenChamber ne peut pas résoudre les restrictions, suspensions de compte ou litiges avec un fournisseur. + Intégrations disponibles : - **Claude Code** — ton plan Claude Pro ou Max, sans clés API diff --git a/packages/docs/content/docs/integrations.mdx b/packages/docs/content/docs/integrations.mdx index d45fe3f7..599d0720 100644 --- a/packages/docs/content/docs/integrations.mdx +++ b/packages/docs/content/docs/integrations.mdx @@ -9,6 +9,8 @@ An integration is a small plugin that adds a provider to OpenChamber using a sub > **Experimental feature:** integrations may change or stop working. Use them at your own discretion. +We designed these integrations to follow providers' intended sign-in flows and avoid known Terms of Service violations. We cannot guarantee that a provider will accept every use or account. Review the provider's terms and use integrations at your own risk. OpenChamber cannot resolve account restrictions, suspensions, or disputes with a provider. + Available integrations: - **Claude Code** — your Claude Pro or Max plan, no API keys diff --git a/packages/docs/content/docs/ja/integrations.mdx b/packages/docs/content/docs/ja/integrations.mdx index df3664d2..b4f2c24c 100644 --- a/packages/docs/content/docs/ja/integrations.mdx +++ b/packages/docs/content/docs/ja/integrations.mdx @@ -9,6 +9,8 @@ description: Claude、Command Code、Cursor のサブスクリプションをプ > **実験的な機能:** 連携は変更されたり、動作しなくなったりする可能性があります。自己責任で使用してください。 +これらの連携は、プロバイダーが想定するサインインの流れに従い、既知の利用規約違反を避けるよう設計しています。ただし、プロバイダーがすべての利用方法やアカウントを受け入れることは保証できません。プロバイダーの規約を確認し、自己責任で連携を使用してください。OpenChamber は、プロバイダーによるアカウント制限、停止、または紛争を解決できません。 + 利用できる統合機能: - **Claude Code** — Claude Pro または Max プラン、API キー不要 diff --git a/packages/docs/content/docs/ko/integrations.mdx b/packages/docs/content/docs/ko/integrations.mdx index 192f0f55..bdc26f2a 100644 --- a/packages/docs/content/docs/ko/integrations.mdx +++ b/packages/docs/content/docs/ko/integrations.mdx @@ -9,6 +9,8 @@ description: Claude, Command Code 또는 Cursor 구독을 공급자로 사용하 > **실험 단계 기능:** 통합 기능은 변경되거나 작동하지 않을 수 있습니다. 본인의 판단에 따라 사용하세요. +이 통합 기능은 프로바이더가 의도한 로그인 흐름을 따르고 알려진 서비스 약관 위반을 피하도록 설계했습니다. 프로바이더가 모든 사용 방식이나 계정을 허용한다고 보장할 수는 없습니다. 프로바이더의 약관을 검토하고 본인의 책임 아래 통합 기능을 사용하세요. OpenChamber는 프로바이더와의 계정 제한, 정지 또는 분쟁을 해결할 수 없습니다. + 사용 가능한 통합 기능: - **Claude Code** — Claude Pro 또는 Max 플랜, API 키 불필요 diff --git a/packages/docs/content/docs/pl/integrations.mdx b/packages/docs/content/docs/pl/integrations.mdx index 82f2429c..da95fde2 100644 --- a/packages/docs/content/docs/pl/integrations.mdx +++ b/packages/docs/content/docs/pl/integrations.mdx @@ -9,6 +9,8 @@ Integracja to mała wtyczka, która dodaje dostawcę do OpenChamber na podstawie > **Funkcja eksperymentalna:** integracje mogą się zmienić lub przestać działać. Korzystasz z nich na własną odpowiedzialność. +Zaprojektowaliśmy te integracje tak, aby korzystały z zamierzonych przez dostawców sposobów logowania i unikały znanych naruszeń ich warunków korzystania. Nie możemy zagwarantować, że dostawca zaakceptuje każdy sposób użycia lub konto. Sprawdź warunki dostawcy i używaj integracji na własne ryzyko. OpenChamber nie może rozwiązać ograniczeń konta, zawieszeń ani sporów z dostawcą. + Dostępne integracje: - **Claude Code** — Twój plan Claude Pro lub Max, bez kluczy API diff --git a/packages/docs/content/docs/pt-br/integrations.mdx b/packages/docs/content/docs/pt-br/integrations.mdx index d5df5cbf..1fd915d3 100644 --- a/packages/docs/content/docs/pt-br/integrations.mdx +++ b/packages/docs/content/docs/pt-br/integrations.mdx @@ -9,6 +9,8 @@ Uma integração é um pequeno plugin que adiciona um provedor ao OpenChamber us > **Recurso experimental:** as integrações podem mudar ou deixar de funcionar. Use-as por sua conta e risco. +Projetamos estas integrações para seguir os fluxos de login pretendidos pelos provedores e evitar violações conhecidas de seus Termos de Serviço. Não podemos garantir que um provedor aceitará todos os usos ou contas. Consulte os termos do provedor e use as integrações por sua conta e risco. O OpenChamber não pode resolver restrições, suspensões de conta ou disputas com um provedor. + Integrações disponíveis: - **Claude Code** — seu plano Claude Pro ou Max, sem chaves de API diff --git a/packages/docs/content/docs/uk/integrations.mdx b/packages/docs/content/docs/uk/integrations.mdx index 94925355..50c620b9 100644 --- a/packages/docs/content/docs/uk/integrations.mdx +++ b/packages/docs/content/docs/uk/integrations.mdx @@ -9,6 +9,8 @@ description: Використовуйте підписки Claude, Command Code > **Експериментальна функція:** інтеграції можуть змінюватися або перестати працювати. Використовуйте їх на власний розсуд. +Ми розробили ці інтеграції так, щоб вони використовували передбачені провайдерами способи входу й не порушували відомі нам умови користування. Ми не можемо гарантувати, що провайдер прийме кожен спосіб використання або кожен обліковий запис. Ознайомтеся з умовами провайдера й використовуйте інтеграції на власний ризик. OpenChamber не може вирішувати обмеження, блокування облікових записів або суперечки з провайдером. + Доступні інтеграції: - **Claude Code** — ваша підписка Claude Pro або Max, без API-ключів diff --git a/packages/docs/content/docs/zh-cn/integrations.mdx b/packages/docs/content/docs/zh-cn/integrations.mdx index e13b5ee9..a0a081fe 100644 --- a/packages/docs/content/docs/zh-cn/integrations.mdx +++ b/packages/docs/content/docs/zh-cn/integrations.mdx @@ -9,6 +9,8 @@ description: 将你的 Claude、Command Code 或 Cursor 订阅用作提供商。 > **实验性功能:**集成可能会变更或停止工作。请自行酌情使用。 +我们设计这些集成时,力求遵循提供商预期的登录流程,并避免已知的服务条款违规。我们无法保证提供商会接受每种使用方式或每个帐户。请查看提供商的条款,并自行承担使用集成的风险。OpenChamber 无法处理提供商施加的帐户限制、暂停或争议。 + 可用的集成: - **Claude Code** — 你的 Claude Pro 或 Max 套餐,无需 API 密钥 From 0b01f5ae2d2beada53066afce5e633661808efff Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 23:16:58 +0300 Subject: [PATCH 20/59] feat(diff): add branch scope to context panel diff view Show every change on the current branch relative to its base in the Changed/Staged/Last turn dropdown. The base comes from the branch's reflog record or an explicit per-branch user choice (persisted), never a main/master guess; when git has no record the user picks a base once from a searchable branch list. - server: GET /api/git/branch-base (reflog-derived base), GET /api/git/range-files (name-status -z with rename/copy destination paths and -C copy detection) - shared UI: optional getBranchBase/getGitRangeFiles runtime APIs with boundary parsing; persisted per-branch overrides keyed by runtime+directory+branch - DiffView: branch scope with confirmed-unavailability coercion of persisted tabs (detached HEAD, default-branch checkout, metadata settled without a default), range-invalidated diff cache guarded against stale completions, bounded branch-metadata retry, read-only diff actions in branch scope; hidden in VS Code - helper module branchDiffScope.ts with tests for coercion, availability, race conditions, and retry exhaustion --- packages/ui/src/components/views/DiffView.tsx | 382 +++++++++++-- .../components/views/branchDiffScope.test.ts | 503 ++++++++++++++++++ .../src/components/views/branchDiffScope.ts | 211 ++++++++ packages/ui/src/lib/api/types.ts | 18 + packages/ui/src/lib/gitApi.ts | 18 + packages/ui/src/lib/gitApiHttp.ts | 46 ++ packages/ui/src/lib/i18n/messages/de.ts | 7 + packages/ui/src/lib/i18n/messages/en.ts | 7 + packages/ui/src/lib/i18n/messages/es.ts | 7 + packages/ui/src/lib/i18n/messages/fr.ts | 7 + packages/ui/src/lib/i18n/messages/ja.ts | 7 + packages/ui/src/lib/i18n/messages/ko.ts | 7 + packages/ui/src/lib/i18n/messages/pl.ts | 7 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 7 + packages/ui/src/lib/i18n/messages/uk.ts | 7 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 7 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 7 + .../src/stores/useGitBaseBranchStore.test.ts | 62 +++ .../ui/src/stores/useGitBaseBranchStore.ts | 71 +++ packages/ui/src/stores/useUIStore.ts | 4 +- packages/web/server/lib/git/routes.js | 43 ++ packages/web/server/lib/git/service.js | 90 +++- packages/web/server/lib/git/service.test.js | 93 ++++ packages/web/src/api/git.ts | 2 + 24 files changed, 1582 insertions(+), 38 deletions(-) create mode 100644 packages/ui/src/components/views/branchDiffScope.test.ts create mode 100644 packages/ui/src/components/views/branchDiffScope.ts create mode 100644 packages/ui/src/stores/useGitBaseBranchStore.test.ts create mode 100644 packages/ui/src/stores/useGitBaseBranchStore.ts diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 0dfd659e..896208b7 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -3,9 +3,12 @@ import React from 'react'; import { useUIStore } from '@/stores/useUIStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore'; +import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore'; +import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope'; +import { getBranchBase, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { cn } from '@/lib/utils'; -import type { GitStatus } from '@/lib/api/types'; +import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types'; import { DropdownMenu, DropdownMenuContent, @@ -79,7 +82,7 @@ type DiffData = { fileDiff?: FileDiffMetadata; contextMode?: DiffContextMode; }; -type DiffScope = 'all' | 'staged' | 'working' | 'turn'; +type DiffScope = 'all' | 'staged' | 'working' | 'turn' | 'branch'; type TurnSnapshotDiff = { file?: string; @@ -91,6 +94,17 @@ type TurnSnapshotDiff = { deletions?: number; }; +/** Reservation slot for a branch range diff while its fetch is in flight. */ +const EMPTY_BRANCH_DIFF_PLACEHOLDER: DiffData = { + original: '', + modified: '', + isBinary: false, + contextMode: 'patch', +}; + +/** Bounded retries for branch metadata in the context diff panel (see effect). */ +const BRANCH_METADATA_MAX_ATTEMPTS = 3; + const BinaryDiffPlaceholder = React.memo(() => { const { t } = useI18n(); return ( @@ -230,11 +244,13 @@ const formatDiffTotals = ( }; interface ChangeScopeSelectorProps { - scope: Extract; + scope: Extract; workingCount: number; stagedCount: number; turnCount: number; - onScopeChange?: (scope: Extract) => void; + branchCount: number | null; + showBranchOption: boolean; + onScopeChange?: (scope: Extract) => void; } const ChangeScopeSelector = React.memo(({ @@ -242,16 +258,20 @@ const ChangeScopeSelector = React.memo(({ workingCount, stagedCount, turnCount, + branchCount, + showBranchOption, onScopeChange, }) => { const { t } = useI18n(); const [open, setOpen] = React.useState(false); - const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : workingCount; + const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : scope === 'branch' ? (branchCount ?? 0) : workingCount; const currentLabel = scope === 'staged' ? t('diffView.scope.staged') : scope === 'turn' ? t('diffView.scope.lastTurn') - : t('diffView.scope.changed'); + : scope === 'branch' + ? t('diffView.scope.branch') + : t('diffView.scope.changed'); return ( @@ -271,7 +291,7 @@ const ChangeScopeSelector = React.memo(({ { - if (value === 'working' || value === 'staged' || value === 'turn') { + if (value === 'working' || value === 'staged' || value === 'turn' || value === 'branch') { onScopeChange?.(value); setOpen(false); } @@ -295,6 +315,14 @@ const ChangeScopeSelector = React.memo(({ {turnCount} + {showBranchOption ? ( + + + {t('diffView.scope.branch')} + {branchCount ?? '…'} + + + ) : null} @@ -574,6 +602,8 @@ interface MultiFileDiffEntryProps { staged?: boolean; loadFullFiles?: boolean; initialDiffData?: DiffData | null; + /** Hide stage/unstage/revert actions (read-only scopes like branch diffs). */ + readOnlyActions?: boolean; } const MultiFileDiffEntry = React.memo(({ @@ -593,6 +623,7 @@ const MultiFileDiffEntry = React.memo(({ staged = false, loadFullFiles = false, initialDiffData = null, + readOnlyActions = false, }) => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); @@ -922,13 +953,15 @@ const MultiFileDiffEntry = React.memo(({ />
- + {!readOnlyActions ? ( + + ) : null}
@@ -945,7 +978,7 @@ interface DiffViewProps { pinSelectedFileHeaderToTopOnNavigate?: boolean; showOpenInEditorAction?: boolean; diffScope?: DiffScope; - onDiffScopeChange?: (scope: Extract) => void; + onDiffScopeChange?: (scope: Extract) => void; targetFilePath?: string | null; /** Render diff content flush with the container edges (no outer padding). */ flushContent?: boolean; @@ -974,6 +1007,7 @@ export const DiffView: React.FC = ({ const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); const ensureStatus = useGitStore((state) => state.ensureStatus); const fetchStatus = useGitStore((state) => state.fetchStatus); + const fetchBranches = useGitStore((state) => state.fetchBranches); const clearDiffCache = useGitStore((state) => state.clearDiffCache); const setDiff = useGitStore((state) => state.setDiff); const [displayFile, setDisplayFile] = React.useState(null); @@ -1083,7 +1117,213 @@ export const DiffView: React.FC = ({ return map; }, [lastTurnDiffs]); + const workingFileCount = React.useMemo(() => { + if (!status?.files) return 0; + return status.files.filter(isWorkingStatusFile).length; + }, [status]); + + const stagedFileCount = React.useMemo(() => { + if (!status?.files) return 0; + return status.files.filter(isStagedStatusFile).length; + }, [status]); + + const turnFileCount = lastTurnDiffs.length; + + // ----- Branch scope (all changes on this branch vs its base) ----- + const currentBranch = status?.current ?? null; + const branches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.branches ?? null : null)); + const isLoadingBranches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.isLoadingBranches ?? false : false)); + + // The Branch scope needs defaultBranches metadata that nothing else loads + // when only the context diff panel is open (GitView and the composer fetch + // it, and their absence must not hide the option), so load it here. A + // failed fetch leaves `branches` null and the loading flag settles back to + // false; the bounded retry below re-issues it a few times per directory and + // reports exhaustion so a dead repository neither loops forever nor spins + // the Branch scope on base resolution. + const startBranchMetadataFetch = React.useCallback(() => { + if (effectiveDirectory) { + void fetchBranches(effectiveDirectory, git); + } + }, [effectiveDirectory, fetchBranches, git]); + const branchMetadataExhausted = useBoundedDirectoryRetry( + effectiveDirectory ?? null, + isGitRepo !== false, + isLoadingBranches, + Boolean(branches), + startBranchMetadataFetch, + BRANCH_METADATA_MAX_ATTEMPTS + ); + + const repositoryDefaultBranch = React.useMemo(() => { + const trackingRemote = status?.tracking?.trim().split('/')[0]; + return (trackingRemote && branches?.defaultBranches?.[trackingRemote]) + ?? branches?.defaultBranches?.origin + ?? null; + }, [branches, status?.tracking]); + // Offered only while the default branch is known and the current branch is + // not it (an unknown default must not flash the option on a guess), and + // only outside VS Code (the extension has no context diff panel). + const showBranchOption = !isVSCodeRuntime() && isBranchScopeAvailable(currentBranch, repositoryDefaultBranch); + // Coercion acts only on CONFIRMED unavailability: the runtime has no branch + // scope at all, a settled status has no branch (detached HEAD), the default + // branch is known and we are on it, or metadata retries were exhausted. + // While status/metadata are still loading a persisted branch scope must + // survive instead of being rewritten to working on the first render. + // `status !== null` is the settled test: before the first status request + // even starts, status is null with loading still false, and that must not + // read as "settled without a branch". + const isBranchStatusResolved = status !== null; + const branchScopeDefinitelyUnavailable = isVSCodeRuntime() + || branchMetadataExhausted + || isBranchScopeDefinitelyUnavailable( + currentBranch, + repositoryDefaultBranch, + isBranchStatusResolved, + branches !== null + ); + + const setBaseOverride = useGitBaseBranchStore((state) => state.setOverride); + // Subscribe to the overrides map directly: `getOverride` reads `get()` + // imperatively, so a memo over it never recomputes when the store changes + // and a freshly picked base would be invisible until an unrelated rerender. + // The key includes the current branch: a base picked for one feature branch + // is not an answer for another branch of the same repository. + const baseOverride = useGitBaseBranchStore( + React.useCallback( + (state) => (effectiveDirectory && currentBranch + ? state.overrides[gitBaseBranchEntryKey(effectiveDirectory, currentBranch)] ?? null + : null), + [currentBranch, effectiveDirectory] + ) + ); + const [detectedBranchBase, setDetectedBranchBase] = React.useState(null); + const [isBranchBaseResolved, setIsBranchBaseResolved] = React.useState(false); + const [basePickerSearch, setBasePickerSearch] = React.useState(''); + + // A context tab persists its scope across branch checkouts and runtime + // switches. When the Branch scope is CONFIRMED unavailable (checked out the + // known default branch, VS Code runtime), fall back to Working instead of + // rendering the base-resolution spinner forever. Persist the coercion so + // the tab and the selector agree. Note it keys off confirmed + // unavailability, not off `showBranchOption`: while metadata loads the + // option is hidden but a persisted branch scope must not be rewritten. + React.useEffect(() => { + const coercedScope = coerceDiffScope(activeDiffScope, !branchScopeDefinitelyUnavailable); + if (coercedScope !== activeDiffScope) { + setActiveDiffScope(coercedScope); + // The only coercion is 'branch' -> 'working', so the persisted + // value always fits the callback domain. + if (coercedScope === 'working') { + onDiffScopeChange?.('working'); + } + } + }, [activeDiffScope, branchScopeDefinitelyUnavailable, onDiffScopeChange]); + + React.useEffect(() => { + if (!showBranchOption || !effectiveDirectory || !currentBranch) { + setDetectedBranchBase(null); + setIsBranchBaseResolved(false); + return; + } + + let cancelled = false; + setIsBranchBaseResolved(false); + getBranchBase(effectiveDirectory, currentBranch) + .then((result) => { + if (!cancelled) setDetectedBranchBase(result.base); + }) + .catch(() => { + if (!cancelled) setDetectedBranchBase(null); + }) + .finally(() => { + if (!cancelled) setIsBranchBaseResolved(true); + }); + return () => { + cancelled = true; + }; + }, [currentBranch, effectiveDirectory, showBranchOption]); + + // Explicit user choice outranks the detected source; both are real answers + // from git or the user — never a main/master guess. + const branchBase = baseOverride ?? detectedBranchBase; + + const [branchFiles, setBranchFiles] = React.useState(null); + const [branchFilesError, setBranchFilesError] = React.useState(null); + + // Shared by the scope/base effect and the error-state Retry button; the + // fetch id discards completions from a superseded run (base or head + // changed, or an earlier retry is still in flight). + const branchFilesFetchIdRef = React.useRef(0); + const reloadBranchFiles = React.useCallback(() => { + if (!effectiveDirectory || !currentBranch || !branchBase) return; + const fetchId = branchFilesFetchIdRef.current + 1; + branchFilesFetchIdRef.current = fetchId; + setBranchFiles(null); + setBranchFilesError(null); + getGitRangeFiles(effectiveDirectory, { base: branchBase, head: currentBranch }) + .then((files) => { + if (branchFilesFetchIdRef.current === fetchId) setBranchFiles(files); + }) + .catch((error) => { + if (branchFilesFetchIdRef.current === fetchId) { + setBranchFilesError(error instanceof Error ? error.message : t('diffView.branch.loadError')); + } + }); + }, [branchBase, currentBranch, effectiveDirectory, t]); + + React.useEffect(() => { + if (activeDiffScope === 'branch') { + reloadBranchFiles(); + } + }, [activeDiffScope, reloadBranchFiles]); + + // Range diffs are fetched per expanded file: unlike working/staged diffs + // there is no per-file cache channel, so patch data lives in a range-keyed + // local cache. Stale completions from a previous range cannot write into + // the new range's cache (see useRangeKeyedCache). + const branchDiffRangeKey = activeDiffScope === 'branch' && effectiveDirectory && currentBranch && branchBase + ? branchRangeKey(effectiveDirectory, branchBase, currentBranch) + : null; + const branchDiffPathsKey = React.useMemo( + () => (activeDiffScope === 'branch' ? Array.from(expandedFiles).sort().join('\0') : ''), + [activeDiffScope, expandedFiles] + ); + + const fetchBranchDiffEntry = React.useCallback( + (filePath: string) => { + if (!effectiveDirectory || !branchBase || !currentBranch) { + return Promise.reject(new Error('branch range is unavailable')); + } + return getGitRangeDiff(effectiveDirectory, { base: branchBase, head: currentBranch, path: filePath }) + .then((response) => createTextDiffDataFromPatch(filePath, response.diff, 'patch')); + }, + [branchBase, currentBranch, effectiveDirectory] + ); + + const branchDiffData = useRangeKeyedCache( + branchDiffRangeKey, + branchDiffPathsKey, + branchDiffRangeKey ? fetchBranchDiffEntry : null, + EMPTY_BRANCH_DIFF_PLACEHOLDER + ); + + const branchFileCount = branchFiles?.length ?? null; + const changedFiles: FileEntry[] = React.useMemo(() => { + if (activeDiffScope === 'branch') { + return (branchFiles ?? []) + .map((file) => ({ + path: file.path, + index: '', + working_dir: file.status, + insertions: 0, + deletions: 0, + isNew: file.status === 'A', + })) + .sort((a, b) => a.path.localeCompare(b.path)); + } + if (activeDiffScope === 'turn') { return lastTurnDiffs .map((diff) => ({ @@ -1115,19 +1355,7 @@ export const DiffView: React.FC = ({ isNew: isNewStatusFile(file), })) .sort((a, b) => a.path.localeCompare(b.path)); - }, [activeDiffScope, lastTurnDiffs, status]); - - const workingFileCount = React.useMemo(() => { - if (!status?.files) return 0; - return status.files.filter(isWorkingStatusFile).length; - }, [status]); - - const stagedFileCount = React.useMemo(() => { - if (!status?.files) return 0; - return status.files.filter(isStagedStatusFile).length; - }, [status]); - - const turnFileCount = lastTurnDiffs.length; + }, [activeDiffScope, branchFiles, lastTurnDiffs, status]); const changedFilePathsKey = React.useMemo( () => changedFiles.map((file) => file.path).join('\0'), @@ -1670,7 +1898,14 @@ export const DiffView: React.FC = ({ }} staged={getFileStaged(file.path)} loadFullFiles={loadFullFiles} - initialDiffData={activeDiffScope === 'turn' ? lastTurnDiffData.get(file.path) ?? null : null} + readOnlyActions={activeDiffScope === 'branch'} + initialDiffData={ + activeDiffScope === 'turn' + ? lastTurnDiffData.get(file.path) ?? null + : activeDiffScope === 'branch' + ? branchDiffData.get(file.path) ?? null + : null + } /> ))}
@@ -1707,10 +1942,93 @@ export const DiffView: React.FC = ({ ); } + if (activeDiffScope === 'branch') { + if (!isBranchBaseResolved) { + return ( +
+ + {t('diffView.branch.resolvingBase')} +
+ ); + } + + if (!branchBase) { + const searchTerm = basePickerSearch.trim().toLowerCase(); + const candidateBranches = (branches?.all ?? []) + .map((name: string) => name.replace(/^remotes\//, '')) + .filter((name: string) => name !== currentBranch && !name.endsWith(`/${currentBranch}`)) + .filter((name: string) => !searchTerm || name.toLowerCase().includes(searchTerm)) + .sort(); + return ( +
+ +
{t('diffView.branch.noBaseTitle')}
+
{t('diffView.branch.noBaseDescription')}
+ setBasePickerSearch(event.target.value)} + placeholder={t('gitView.branch.searchPlaceholder')} + aria-label={t('gitView.branch.searchPlaceholder')} + className="w-full max-w-sm rounded-md border border-border/60 bg-[var(--surface-elevated)] px-2.5 py-1.5 typography-meta text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]" + /> + + {candidateBranches.length === 0 ? ( +
+ {t('gitView.branch.empty')} +
+ ) : ( +
+ {candidateBranches.map((branch: string) => ( + + ))} +
+ )} +
+
+ ); + } + + if (branchFilesError) { + return ( +
+
{t('diffView.branch.loadError')}
+
{branchFilesError}
+ +
+ ); + } + + if (branchFiles === null) { + return ( +
+ + {t('diffView.branch.loadingFiles')} +
+ ); + } + } + if (changedFiles.length === 0) { return (
- {activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges') : t('diffView.state.cleanWorkingTree')} + {activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges') + : activeDiffScope === 'branch' && branchBase ? t('diffView.branch.empty', { base: branchBase }) + : t('diffView.state.cleanWorkingTree')}
); } @@ -1722,12 +2040,14 @@ export const DiffView: React.FC = ({
{!isMobile && ( - activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' ? ( + activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? ( { setActiveDiffScope(scope); onDiffScopeChange?.(scope); diff --git a/packages/ui/src/components/views/branchDiffScope.test.ts b/packages/ui/src/components/views/branchDiffScope.test.ts new file mode 100644 index 00000000..f365b858 --- /dev/null +++ b/packages/ui/src/components/views/branchDiffScope.test.ts @@ -0,0 +1,503 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { describe, expect, test } from 'bun:test'; + +import { + branchRangeKey, + coerceDiffScope, + isBranchScopeAvailable, + isBranchScopeDefinitelyUnavailable, + useRangeKeyedCache, + useBoundedDirectoryRetry, +} from './branchDiffScope'; + +describe('coerceDiffScope', () => { + test('keeps the branch scope while it is offered', () => { + expect(coerceDiffScope('branch', true)).toBe('branch'); + }); + + test('falls back to working when the branch scope disappears', () => { + // Covers a persisted context tab after checking out the default branch + // or switching to a runtime without the branch scope: the tab must land + // on a renderable scope instead of a permanent spinner. + expect(coerceDiffScope('branch', false)).toBe('working'); + }); + + test('leaves every other scope untouched regardless of availability', () => { + for (const scope of ['working', 'staged', 'turn', 'all'] as const) { + expect(coerceDiffScope(scope, false)).toBe(scope); + expect(coerceDiffScope(scope, true)).toBe(scope); + } + }); +}); + +describe('isBranchScopeAvailable', () => { + test('available when the default branch is known and different', () => { + expect(isBranchScopeAvailable('feature-a', 'main')).toBe(true); + }); + + test('unavailable on the default branch itself', () => { + expect(isBranchScopeAvailable('main', 'main')).toBe(false); + }); + + test('unavailable while the default branch is unknown', () => { + // Branch metadata loads asynchronously; an unknown default must not + // flash the Branch option on the guess that the branch differs from it. + expect(isBranchScopeAvailable('feature-a', null)).toBe(false); + }); + + test('unavailable without a current branch', () => { + expect(isBranchScopeAvailable(null, 'main')).toBe(false); + expect(isBranchScopeAvailable(null, null)).toBe(false); + }); +}); + +describe('isBranchScopeDefinitelyUnavailable', () => { + test('unknown metadata is not confirmed unavailability', () => { + // While branch metadata loads the option stays hidden, but this is + // "unknown", not "confirmed gone" — coercion must not act on it. + expect(isBranchScopeDefinitelyUnavailable('feature-a', null, true, false)).toBe(false); + expect(isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, false)).toBe(false); + }); + + test('unresolved status means the branch is unknown, not gone', () => { + // During the first status load a null currentBranch is "not loaded + // yet"; coercing on it would discard a persisted branch scope before + // the answer arrives. + expect(isBranchScopeDefinitelyUnavailable(null, 'main', false, false)).toBe(false); + expect(isBranchScopeDefinitelyUnavailable(null, null, false, true)).toBe(false); + }); + + test('detached HEAD after a settled status is confirmed unavailability', () => { + // Status finished (or failed) without a branch: the Branch scope is + // impossible, so a persisted branch scope must coerce away instead of + // spinning on base resolution forever. + expect(isBranchScopeDefinitelyUnavailable(null, 'main', true, false)).toBe(true); + expect(isBranchScopeDefinitelyUnavailable(null, null, true, true)).toBe(true); + }); + + test('metadata settled without a default branch is confirmed unavailability', () => { + // `getBranches` can succeed while git/remote never reported a default + // branch: retries will not change that, the option stays hidden, and a + // persisted branch scope must coerce instead of spinning on base + // resolution forever. + expect(isBranchScopeDefinitelyUnavailable('feature-a', null, true, true)).toBe(true); + expect(isBranchScopeAvailable('feature-a', null)).toBe(false); + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('feature-a', null, true, true))).toBe('working'); + }); + + test('confirmed when the default branch is known and we are on it', () => { + expect(isBranchScopeDefinitelyUnavailable('main', 'main', true, true)).toBe(true); + expect(isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, true)).toBe(false); + }); + + test('a persisted branch scope survives loading metadata and is coerced once the answer arrives', () => { + // The scenario: a context tab persisted scope='branch' and the panel + // reopens while branch metadata is still loading (null default). + // First render — option hidden, but NOT coerced away: + expect(isBranchScopeAvailable('feature-a', null)).toBe(false); + expect(isBranchScopeDefinitelyUnavailable('feature-a', null, true, false)).toBe(false); + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('feature-a', null, true, false))).toBe('branch'); + + // Metadata arrives and confirms a feature branch — still available: + expect(isBranchScopeAvailable('feature-a', 'main')).toBe(true); + expect(isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, true)).toBe(false); + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('feature-a', 'main', true, true))).toBe('branch'); + + // User checks out the default branch — now confirmed, coerce: + expect(isBranchScopeDefinitelyUnavailable('main', 'main', true, true)).toBe(true); + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable('main', 'main', true, true))).toBe('working'); + }); + + test('a persisted branch scope coerces after detached HEAD once status settles', () => { + // Status still loading with a persisted branch scope — keep it: + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, 'main', false, false))).toBe('branch'); + // Status settles on detached HEAD — coerce: + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, 'main', true, false))).toBe('working'); + }); + + test('first render before the status request starts does not read as settled detached HEAD', () => { + // Sequence of a fresh mount with a persisted branch scope: + // 1. status===null, loading===false (request has not started yet), + // 2. loading===true, + // 3. settled status object with current===null (true detached HEAD). + // Only step 3 may coerce; steps 1-2 are "unknown" and keep the scope. + expect(isBranchScopeDefinitelyUnavailable(null, null, false, false)).toBe(false); + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, null, false, false))).toBe('branch'); + expect(isBranchScopeDefinitelyUnavailable(null, null, false, true)).toBe(false); + expect(isBranchScopeDefinitelyUnavailable(null, null, true, false)).toBe(true); + expect(coerceDiffScope('branch', !isBranchScopeDefinitelyUnavailable(null, null, true, false))).toBe('working'); + }); +}); + +describe('branchRangeKey', () => { + test('distinguishes bases, heads, and directories for the same path', () => { + // The same file path can carry different diff content per range; a cache + // keyed by path alone would leak a previous branch's patch. + const keys = [ + branchRangeKey('/repo', 'main', 'feature-a'), + branchRangeKey('/repo', 'develop', 'feature-a'), + branchRangeKey('/repo', 'main', 'feature-b'), + branchRangeKey('/other', 'main', 'feature-a'), + ]; + expect(new Set(keys).size).toBe(4); + }); +}); + +// --------------------------------------------------------------------------- +// useRangeKeyedCache +// --------------------------------------------------------------------------- + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((next, decline) => { + resolve = next; + reject = decline; + }); + return { promise, resolve, reject }; +}; + +const installMinimalDom = () => { + const descriptors = new Map(); + const setGlobal = (name: string, value: unknown) => { + descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + }; + class ElementStub {} + /** Minimal Document surface createRoot touches in these tests. */ + type DocumentStub = { + nodeType: 9; + defaultView: typeof globalThis; + activeElement: Element | null; + addEventListener: (type: string, listener: () => void) => void; + removeEventListener: (type: string, listener: () => void) => void; + documentElement: typeof container; + body: typeof container; + }; + const container = { + nodeType: 1, + tagName: 'DIV', + nodeName: 'DIV', + namespaceURI: 'http://www.w3.org/1999/xhtml', + ownerDocument: null as DocumentStub | null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }; + const documentStub: DocumentStub = { + nodeType: 9, + defaultView: globalThis, + activeElement: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + documentElement: container, + body: container, + }; + container.ownerDocument = documentStub; + setGlobal('document', documentStub); + setGlobal('window', globalThis); + setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' }); + setGlobal('Element', ElementStub); + setGlobal('HTMLElement', ElementStub); + setGlobal('HTMLIFrameElement', ElementStub); + setGlobal('IS_REACT_ACT_ENVIRONMENT', true); + setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0)); + setGlobal('cancelAnimationFrame', (id: ReturnType) => clearTimeout(id)); + return { + // SAFETY: the container stub implements the Element surface createRoot + // touches (nodeType/tagName/listeners); the real Element type is not + // constructible without a DOM implementation, so the gap goes through + // unknown deliberately. + container: container as unknown as Element, + restore: () => { + for (const [name, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + }, + }; +}; + +describe('useRangeKeyedCache', () => { + test('a stale completion from the previous range cannot write into the new range', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + // One shared path so the same key would be overwritten if the guard + // was missing. + const pathsKey = 'src/shared.ts'; + const rangeA = '["/repo","main","feature-a"]'; + const rangeB = '["/repo","develop","feature-b"]'; + const fetchA = deferred(); + const fetchB = deferred(); + type CapturedEntries = { entries: ReadonlyMap | null }; + const captured: CapturedEntries = { entries: null }; + + let currentFetcher: (path: string) => Promise = () => fetchA.promise; + + const Harness = () => { + captured.entries = useRangeKeyedCache( + rangeKey, + pathsKey, + currentFetcher, + 'placeholder' + ); + return null; + }; + let rangeKey: string = rangeA; + + try { + await act(async () => root.render(React.createElement(Harness))); + expect(captured.entries?.get(pathsKey)).toBe('placeholder'); + + // Switch the range while A's fetch is still in flight. + rangeKey = rangeB; + currentFetcher = () => fetchB.promise; + await act(async () => root.render(React.createElement(Harness))); + expect(captured.entries?.get(pathsKey)).toBe('placeholder'); + + // B completes first: its value must land. + await act(async () => { + fetchB.resolve('diff-from-develop'); + await Promise.resolve(); + }); + expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop'); + + // A completes last: the stale result must be discarded, not written + // over range B's entry. + await act(async () => { + fetchA.resolve('diff-from-main'); + await Promise.resolve(); + }); + expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop'); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); + + test('a stale rejection from the previous range cannot delete the new range entry', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const pathsKey = 'src/shared.ts'; + const rangeA = '["/repo","main","feature-a"]'; + const rangeB = '["/repo","develop","feature-b"]'; + const fetchA = deferred(); + const fetchB = deferred(); + type CapturedEntries = { entries: ReadonlyMap | null }; + const captured: CapturedEntries = { entries: null }; + + let currentFetcher: (path: string) => Promise = () => fetchA.promise; + let rangeKey: string = rangeA; + + const Harness = () => { + captured.entries = useRangeKeyedCache(rangeKey, pathsKey, currentFetcher, 'placeholder'); + return null; + }; + + try { + await act(async () => root.render(React.createElement(Harness))); + rangeKey = rangeB; + currentFetcher = () => fetchB.promise; + await act(async () => root.render(React.createElement(Harness))); + await act(async () => { + fetchB.resolve('diff-from-develop'); + await Promise.resolve(); + }); + expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop'); + + // The old range's fetch fails after the switch: it must not delete + // the new range's completed entry. + await act(async () => { + fetchA.reject(new Error('stale failure')); + await Promise.resolve(); + }); + expect(captured.entries?.get(pathsKey)).toBe('diff-from-develop'); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); + + test('releases reservations for paths that never completed so a later run retries them', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const pathsKey = 'src/first.ts'; + const stuck = deferred(); + type CapturedEntries = { entries: ReadonlyMap | null }; + const captured: CapturedEntries = { entries: null }; + + let currentPathsKey = pathsKey; + const fetched: string[] = []; + + const Harness = () => { + captured.entries = useRangeKeyedCache( + 'range', + currentPathsKey, + (path) => { + fetched.push(path); + return currentPathsKey === pathsKey ? stuck.promise : Promise.resolve(`resolved-${path}`); + }, + 'placeholder' + ); + return null; + }; + + try { + await act(async () => root.render(React.createElement(Harness))); + expect(fetched).toEqual(['src/first.ts']); + expect(captured.entries?.get('src/first.ts')).toBe('placeholder'); + + // Expand a different set of paths; the stuck reservation for + // src/first.ts is released, and a later run fetches it again. + currentPathsKey = 'src/first.ts\u0000src/second.ts'; + await act(async () => root.render(React.createElement(Harness))); + expect(fetched).toEqual(['src/first.ts', 'src/first.ts', 'src/second.ts']); + expect(captured.entries?.get('src/first.ts')).toBe('resolved-src/first.ts'); + expect(captured.entries?.get('src/second.ts')).toBe('resolved-src/second.ts'); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); +}); + +describe('useBoundedDirectoryRetry', () => { + test('starts once and reports no exhaustion on success', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const started: string[] = []; + let hasResult = false; + let latestExhausted: boolean | null = null; + + const Harness = () => { + latestExhausted = useBoundedDirectoryRetry( + '/repo', true, false, hasResult, + () => { started.push('/repo'); }, 3 + ); + return null; + }; + + try { + await act(async () => root.render(React.createElement(Harness))); + expect(started).toEqual(['/repo']); + expect(latestExhausted).toBe(false); + + // Result arrives: no further starts, no exhaustion. + hasResult = true; + await act(async () => root.render(React.createElement(Harness))); + expect(started).toEqual(['/repo']); + expect(latestExhausted).toBe(false); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); + + test('retries bounded times on failure, then reports exhaustion without looping', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const started: string[] = []; + let inFlight = false; + let latestExhausted: boolean | null = null; + + const Harness = () => { + latestExhausted = useBoundedDirectoryRetry( + '/repo', true, inFlight, false, + () => { started.push('/repo'); }, 3 + ); + return null; + }; + + try { + // Each attempt is one in-flight transition: the start flips the + // caller's flag up, the failed request settles it back down. + for (let attempt = 1; attempt <= 3; attempt += 1) { + inFlight = false; + await act(async () => root.render(React.createElement(Harness))); + expect(started).toHaveLength(attempt); + inFlight = true; + await act(async () => root.render(React.createElement(Harness))); + } + expect(latestExhausted).toBe(false); + + // Fourth transition: attempts exhausted, no more starts. + inFlight = false; + await act(async () => root.render(React.createElement(Harness))); + await act(async () => root.render(React.createElement(Harness))); + expect(started).toHaveLength(3); + expect(latestExhausted).toBe(true); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); + + test('exhaustion does not leak into the next directory on the first render', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const started: string[] = []; + let directory: string = '/repo-a'; + let inFlight = false; + let hasResult = false; + let latestExhausted: boolean | null = null; + + const Harness = () => { + latestExhausted = useBoundedDirectoryRetry( + directory, true, inFlight, hasResult, + () => { started.push(directory); }, 2 + ); + return null; + }; + + try { + // Burn through both retries for /repo-a until exhausted. + for (let attempt = 1; attempt <= 2; attempt += 1) { + inFlight = false; + await act(async () => root.render(React.createElement(Harness))); + inFlight = true; + await act(async () => root.render(React.createElement(Harness))); + } + inFlight = false; + await act(async () => root.render(React.createElement(Harness))); + expect(latestExhausted).toBe(true); + + // Switch to another directory (a new tab with a persisted branch + // scope): exhaustion must reset in the SAME render, before any + // effect could rewrite the scope, and retries restart for it. + directory = '/repo-b'; + await act(async () => root.render(React.createElement(Harness))); + expect(latestExhausted).toBe(false); + expect(started).toEqual(['/repo-a', '/repo-a', '/repo-b']); + + hasResult = true; + await act(async () => root.render(React.createElement(Harness))); + expect(latestExhausted).toBe(false); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); + + test('an in-flight request suppresses duplicate starts from another consumer', async () => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const started: string[] = []; + + const Harness = () => { + useBoundedDirectoryRetry( + '/repo', true, true, false, + () => { started.push('/repo'); }, 3 + ); + return null; + }; + + try { + await act(async () => root.render(React.createElement(Harness))); + await act(async () => root.render(React.createElement(Harness))); + expect(started).toEqual([]); + } finally { + await act(async () => root.unmount()); + dom.restore(); + } + }); +}); diff --git a/packages/ui/src/components/views/branchDiffScope.ts b/packages/ui/src/components/views/branchDiffScope.ts new file mode 100644 index 00000000..19d50d64 --- /dev/null +++ b/packages/ui/src/components/views/branchDiffScope.ts @@ -0,0 +1,211 @@ +import React from 'react'; + +/** + * Pure helpers backing the "Branch" diff scope in DiffView. Extracted so the + * coercion, availability, and range-cache invalidation contracts are testable + * without mounting the full diff surface. + */ + +/** + * The "Branch" scope only exists while the repository's default branch is + * known and the current branch differs from it (the caller decides runtime + * availability). An unknown default must NOT show the option: the scope is + * "this branch is not the default", which cannot be established, and offering + * it on a guess flashes the option while branch metadata is still loading. + */ +export const isBranchScopeAvailable = ( + currentBranch: string | null, + repositoryDefaultBranch: string | null +): boolean => ( + Boolean(currentBranch) + && repositoryDefaultBranch !== null + && currentBranch !== repositoryDefaultBranch +); + +/** + * Confirmed unavailability of the Branch scope, as opposed to "not (yet) + * known". Coercion of a persisted branch scope must wait for this: while + * metadata is loading the default branch is unknown, the option stays hidden, + * but rewriting the persisted scope to working on that first render would + * discard the user's choice the moment metadata arrives and confirms the + * branch differs from the default. + * + * - `isBranchStatusResolved` distinguishes "no branch yet because the first + * status load has not settled" (unknown — keep the persisted scope) from + * "status finished and there is no branch" (detached HEAD / failed load — + * the Branch scope is impossible and the scope must coerce away). + * - `isBranchMetadataLoaded` + a null default means the branch list settled + * WITHOUT a resolvable default branch (git/remote never reported one): the + * Branch scope is impossible in a different way, and must coerce too, + * otherwise the persisted scope spins on base resolution forever. + */ +export const isBranchScopeDefinitelyUnavailable = ( + currentBranch: string | null, + repositoryDefaultBranch: string | null, + isBranchStatusResolved: boolean, + isBranchMetadataLoaded: boolean +): boolean => { + if (!isBranchStatusResolved) return false; + if (currentBranch === null) return true; + if (isBranchMetadataLoaded && repositoryDefaultBranch === null) return true; + return repositoryDefaultBranch !== null && currentBranch === repositoryDefaultBranch; +}; + +/** + * A context tab persists its scope across branch checkouts and runtime + * switches. When the Branch scope stops being offered (checked out the + * default branch, VS Code runtime), fall back to a always-available one instead + * of rendering the base-resolution spinner forever. + */ +export const coerceDiffScope = ( + scope: T, + branchScopeAvailable: boolean +): T | 'working' => (scope === 'branch' && !branchScopeAvailable ? 'working' : scope); + +/** + * Identity of one `base...head` range in one repository. Range-cache entries + * are only valid within a single range: the same file path can carry different + * content under a different base or head, so a cache keyed by path alone leaks + * stale patches across branch and base switches. + */ +export const branchRangeKey = (directory: string, base: string, head: string): string => + JSON.stringify([directory, base, head]); + +/** + * Bounded per-directory retry for a request whose failure leaves no result and + * no signal beyond the in-flight flag settling back to false. + * + * - State carries its directory: after a directory switch the derived + * attempts/exhausted values reset IMMEDIATELY on the first render of the new + * directory (no reset effect, so no one-render window where a stale + * `exhausted: true` from the previous directory leaks into decisions). + * - Retries stop after `maxAttempts` and report exhaustion instead of looping + * forever against a dead target. + * - An in-flight request (possibly started by another mounted consumer of the + * same directory) suppresses duplicate starts. + */ +export const useBoundedDirectoryRetry = ( + directory: string | null, + isEnabled: boolean, + isRequestInFlight: boolean, + hasResult: boolean, + startRequest: () => void, + maxAttempts: number +): boolean => { + // Attempts live in a ref and the effect's deps deliberately exclude them: + // a retry may only be triggered by an EXTERNAL transition (the in-flight + // flag settling back to false, a directory switch, a result appearing), never + // by the attempt counter itself — otherwise one start cascades into all + // remaining attempts in a single commit. + const attemptsRef = React.useRef<{ directory: string; attempts: number }>({ directory: '', attempts: 0 }); + const [exhaustedState, setExhaustedState] = React.useState<{ directory: string; exhausted: boolean }>( + () => ({ directory: '', exhausted: false }) + ); + // The starter is read through a ref so an inline arrow from the caller + // cannot restart the effect in a render loop. + const startRequestRef = React.useRef(startRequest); + startRequestRef.current = startRequest; + + // A different directory's (or the initial empty) exhaustion state reads as + // not exhausted; this derivation is the instant-reset guarantee above. + const exhausted = Boolean(directory) && exhaustedState.directory === directory && exhaustedState.exhausted; + + React.useEffect(() => { + if (!directory || !isEnabled || hasResult || isRequestInFlight) { + return; + } + const attempts = attemptsRef.current.directory === directory ? attemptsRef.current.attempts : 0; + if (attempts >= maxAttempts) { + if (!(exhaustedState.directory === directory && exhaustedState.exhausted)) { + setExhaustedState({ directory, exhausted: true }); + } + return; + } + attemptsRef.current = { directory, attempts: attempts + 1 }; + startRequestRef.current(); + }, [directory, exhaustedState, hasResult, isEnabled, isRequestInFlight, maxAttempts]); + + return exhausted; +}; + +/** + * Per-path cache of lazily fetched values, valid within a single range. + * + * - Changing `rangeKey` clears every entry (new base/head/directory = new + * content for the same paths). + * - Each expanded path is reserved with `placeholder` before its fetch starts, + * so a re-run does not issue a duplicate request. + * - Completions from a previous run can never write into the new range's + * cache: every run is cancelled in its cleanup, and its callbacks ignore + * results after cancellation. This covers the stale-completion case where an + * old `fetchEntry` promise resolves (or rejects) after the range switched. + * - Reservations that never completed are released on cleanup so a later run + * retries those paths instead of showing the placeholder forever. + */ +export const useRangeKeyedCache = ( + rangeKey: string | null, + pathsKey: string, + fetchEntry: ((path: string) => Promise) | null, + placeholder: T +): ReadonlyMap => { + const [entries, setEntries] = React.useState>(() => new Map()); + const entriesRef = React.useRef(entries); + entriesRef.current = entries; + + // The fetcher is read through a ref so a caller passing an inline arrow (a + // new function every render) cannot restart the fetch effect in a loop. + const fetchEntryRef = React.useRef(fetchEntry); + fetchEntryRef.current = fetchEntry; + + const writeEntry = React.useCallback((path: string, value: T | null) => { + const next = new Map(entriesRef.current); + if (value === null) { + if (!next.delete(path)) return; + } else { + next.set(path, value); + } + entriesRef.current = next; + setEntries(next); + }, []); + + React.useEffect(() => { + if (!rangeKey) return; + entriesRef.current = new Map(); + setEntries(entriesRef.current); + }, [rangeKey]); + + React.useEffect(() => { + const fetcher = fetchEntryRef.current; + if (!rangeKey || !fetcher || !pathsKey) { + return; + } + let cancelled = false; + const pendingReservations = new Set(); + + for (const path of pathsKey.split('\0')) { + if (entriesRef.current.has(path)) continue; + pendingReservations.add(path); + writeEntry(path, placeholder); + fetcher(path) + .then((value) => { + if (cancelled) return; + pendingReservations.delete(path); + writeEntry(path, value); + }) + .catch(() => { + if (cancelled) return; + // Release the reservation so a later run can retry this path. + pendingReservations.delete(path); + writeEntry(path, null); + }); + } + return () => { + cancelled = true; + for (const path of pendingReservations) { + writeEntry(path, null); + } + }; + }, [pathsKey, placeholder, rangeKey, writeEntry]); + + return entries; +}; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 7c44cc62..fd555f3f 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -157,6 +157,22 @@ export interface GetGitRangeDiffOptions { contextLines?: number; } +export interface GetGitRangeFilesOptions { + base: string; + head: string; +} + +/** One changed file in a `base...head` range, with its change letter (A/M/D/R/C). */ +export interface GitRangeFileEntry { + path: string; + status: string; +} + +export interface GitBranchBaseResponse { + /** Null when git has no authoritative record of where the branch started. */ + base: string | null; +} + export interface GitFileDiffResponse { original: string; modified: string; @@ -466,6 +482,8 @@ export interface GitAPI { getGitDiff(directory: string, options: GetGitDiffOptions): Promise; getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise; getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise; + getGitRangeFiles?(directory: string, options: GetGitRangeFilesOptions): Promise; + getBranchBase?(directory: string, branch: string): Promise; revertGitFile(directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise; stageGitFile(directory: string, filePath: string): Promise; stageGitFiles?(directory: string, filePaths: string[]): Promise; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 56203ebb..1192cb06 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -119,6 +119,24 @@ export async function getGitRangeDiff( return gitHttp.getGitRangeDiff(directory, options); } +export async function getGitRangeFiles( + directory: string, + options: import('./api/types').GetGitRangeFilesOptions +): Promise { + const runtime = getRuntimeGit(); + if (runtime?.getGitRangeFiles) return runtime.getGitRangeFiles(directory, options); + return gitHttp.getGitRangeFiles(directory, options); +} + +export async function getBranchBase( + directory: string, + branch: string +): Promise { + const runtime = getRuntimeGit(); + if (runtime?.getBranchBase) return runtime.getBranchBase(directory, branch); + return gitHttp.getBranchBase(directory, branch); +} + export async function revertGitFile( directory: string, filePath: string, diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 24317b24..2c0c6603 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -3,6 +3,7 @@ import type { GitDiffResponse, GetGitDiffOptions, GetGitRangeDiffOptions, + GetGitRangeFilesOptions, GitFileDiffResponse, GetGitFileDiffOptions, GitBranch, @@ -248,6 +249,51 @@ export async function getGitRangeDiff( return response.json(); } +export async function getGitRangeFiles( + directory: string, + options: GetGitRangeFilesOptions +): Promise { + const { base, head } = options; + if (!base || !head) { + throw new Error('base and head are required to fetch git range files'); + } + + const response = await runtimeFetch( + buildUrl(`${API_BASE}/range-files`, directory, { base, head }) + ); + + if (!response.ok) { + throw new Error(`Failed to get git range files: ${response.statusText}`); + } + + const payload = (await response.json()) as { files?: unknown }; + if (!Array.isArray(payload.files)) return []; + return payload.files.filter((entry): entry is import('./api/types').GitRangeFileEntry => { + if (!entry || typeof entry !== 'object') return false; + const candidate = entry as { path?: unknown; status?: unknown }; + return typeof candidate.path === 'string' && typeof candidate.status === 'string'; + }); +} + +export async function getBranchBase( + directory: string, + branch: string +): Promise { + if (!branch) { + throw new Error('branch is required to get branch base'); + } + + const response = await runtimeFetch( + buildUrl(`${API_BASE}/branch-base`, directory, { branch }) + ); + + if (!response.ok) { + throw new Error(`Failed to get branch base: ${response.statusText}`); + } + + return response.json(); +} + export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise { const { path, staged } = options; if (!path) { diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 9fcf7760..d957a7b9 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1360,6 +1360,13 @@ export const dict = { 'diffView.scope.changed': 'Geändert', 'diffView.scope.staged': 'Staged', 'diffView.scope.lastTurn': 'Letzter Zug', + 'diffView.scope.branch': 'Branch', + 'diffView.branch.resolvingBase': 'Basis-Branch wird ermittelt...', + 'diffView.branch.noBaseTitle': 'Kein Basis-Branch', + 'diffView.branch.noBaseDescription': 'Git enthält keinen Eintrag, wo dieser Branch entstanden ist. Wähle einen Basis-Branch für den Vergleich.', + 'diffView.branch.loadError': 'Branch-Änderungen konnten nicht geladen werden', + 'diffView.branch.loadingFiles': 'Branch-Änderungen werden geladen...', + 'diffView.branch.empty': 'Keine Änderungen in diesem Branch gegenüber {base}', 'diffView.scope.selectorAria': 'Änderungsmodus auswählen', 'diffView.actions.retry': 'Erneut versuchen', 'diffView.actions.renderAnyway': 'Trotzdem rendern', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index e69452de..981f2b8f 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1517,6 +1517,13 @@ export const dict = { 'diffView.scope.changed': 'Changed', 'diffView.scope.staged': 'Staged', 'diffView.scope.lastTurn': 'Last turn', + 'diffView.scope.branch': 'Branch', + 'diffView.branch.resolvingBase': 'Detecting base branch...', + 'diffView.branch.noBaseTitle': 'No base branch', + 'diffView.branch.noBaseDescription': 'Git has no record of where this branch started. Choose a base branch to compare against.', + 'diffView.branch.loadError': 'Failed to load branch changes', + 'diffView.branch.loadingFiles': 'Loading branch changes...', + 'diffView.branch.empty': 'No changes on this branch relative to {base}', 'diffView.scope.selectorAria': 'Select change mode', 'diffView.actions.retry': 'Retry', 'diffView.actions.renderAnyway': 'Render anyway', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index acf65e36..394cc771 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1483,6 +1483,13 @@ export const dict: Record = { "diffView.scope.changed": "Cambiados", "diffView.scope.staged": "Staged", "diffView.scope.lastTurn": "Último turno", + "diffView.scope.branch": "Rama", + "diffView.branch.resolvingBase": "Detectando rama base...", + "diffView.branch.noBaseTitle": "Sin rama base", + "diffView.branch.noBaseDescription": "Git no tiene registro de dónde surgió esta rama. Elige una rama base para comparar.", + "diffView.branch.loadError": "No se pudieron cargar los cambios de la rama", + "diffView.branch.loadingFiles": "Cargando cambios de la rama...", + "diffView.branch.empty": "No hay cambios en esta rama respecto a {base}", "diffView.scope.selectorAria": "Seleccionar modo de cambios", "diffView.actions.retry": "Volver a intentar", "diffView.actions.renderAnyway": "Renderizar de todos modos", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 94d915e4..1315c8f7 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1282,6 +1282,13 @@ export const dict = { "diffView.scope.changed": "Modifiés", "diffView.scope.staged": "Staged", "diffView.scope.lastTurn": "Dernier tour", + "diffView.scope.branch": "Branche", + "diffView.branch.resolvingBase": "Détection de la branche de base...", + "diffView.branch.noBaseTitle": "Aucune branche de base", + "diffView.branch.noBaseDescription": "Git ne conserve aucune trace de la branche d’origine de cette branche. Choisissez une branche de base pour la comparaison.", + "diffView.branch.loadError": "Échec du chargement des modifications de la branche", + "diffView.branch.loadingFiles": "Chargement des modifications de la branche...", + "diffView.branch.empty": "Aucune modification sur cette branche par rapport à {base}", "diffView.scope.selectorAria": "Sélectionner le mode de changements", 'diffView.actions.retry': 'Réessayer', 'diffView.actions.renderAnyway': 'Afficher quand même', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 0a2529c5..09882066 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1513,6 +1513,13 @@ export const dict: Record = { 'diffView.scope.changed': '変更済み', 'diffView.scope.staged': 'ステージ済み', 'diffView.scope.lastTurn': '最後のターン', + 'diffView.scope.branch': 'ブランチ', + 'diffView.branch.resolvingBase': 'ベースブランチを検出中...', + 'diffView.branch.noBaseTitle': 'ベースブランチがありません', + 'diffView.branch.noBaseDescription': 'このブランチがどこから作られたかの記録がGitにありません。比較するベースブランチを選択してください。', + 'diffView.branch.loadError': 'ブランチの変更を読み込めませんでした', + 'diffView.branch.loadingFiles': 'ブランチの変更を読み込み中...', + 'diffView.branch.empty': 'このブランチには{base}に対する変更はありません', 'diffView.scope.selectorAria': '変更モードを選択', 'diffView.actions.retry': '再試行', 'diffView.actions.renderAnyway': 'とにかくレンダリング', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index fc164a8f..ef0eca76 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1519,6 +1519,13 @@ export const dict: Record = { "diffView.scope.changed": "Changed", "diffView.scope.staged": "Staged", "diffView.scope.lastTurn": "마지막 턴", + "diffView.scope.branch": "브랜치", + "diffView.branch.resolvingBase": "베이스 브랜치 감지 중...", + "diffView.branch.noBaseTitle": "베이스 브랜치 없음", + "diffView.branch.noBaseDescription": "이 브랜치가 어디서 시작되었는지 Git에 기록이 없습니다. 비교할 베이스 브랜치를 선택하세요.", + "diffView.branch.loadError": "브랜치 변경 사항을 불러오지 못했습니다", + "diffView.branch.loadingFiles": "브랜치 변경 사항 불러오는 중...", + "diffView.branch.empty": "이 브랜치에는 {base}에 대한 변경 사항이 없습니다", "diffView.scope.selectorAria": "변경 모드 선택", 'diffView.actions.retry': '다시 시도', 'diffView.actions.renderAnyway': '그래도 렌더링', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index ba9bc45e..fbe1dc4f 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1795,6 +1795,13 @@ export const dict: Record = { "diffView.scope.changed": "Zmienione", "diffView.scope.staged": "Staged", "diffView.scope.lastTurn": "Ostatnia tura", + "diffView.scope.branch": "Gałąź", + "diffView.branch.resolvingBase": "Wykrywanie gałęzi bazowej...", + "diffView.branch.noBaseTitle": "Brak gałęzi bazowej", + "diffView.branch.noBaseDescription": "Git nie zapisuje, od której gałęzi ta gałąź powstała. Wybierz gałąź bazową do porównania.", + "diffView.branch.loadError": "Nie udało się wczytać zmian gałęzi", + "diffView.branch.loadingFiles": "Wczytywanie zmian gałęzi...", + "diffView.branch.empty": "Brak zmian w tej gałęzi względem {base}", "diffView.scope.selectorAria": "Wybierz tryb zmian", 'diffView.summary.changedFilesSingle': 'Zmieniono {count} plik', 'directoryExplorerDialog.actions.addProject': 'Dodaj projekt', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index f13f1748..a467bc91 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1483,6 +1483,13 @@ export const dict: Record = { "diffView.scope.changed": "Alteradas", "diffView.scope.staged": "Staged", "diffView.scope.lastTurn": "Último turno", + "diffView.scope.branch": "Branch", + "diffView.branch.resolvingBase": "Detectando branch base...", + "diffView.branch.noBaseTitle": "Sem branch base", + "diffView.branch.noBaseDescription": "O Git não tem registro de onde este branch começou. Escolha um branch base para comparar.", + "diffView.branch.loadError": "Falha ao carregar as alterações do branch", + "diffView.branch.loadingFiles": "Carregando alterações do branch...", + "diffView.branch.empty": "Nenhuma alteração neste branch em relação a {base}", "diffView.scope.selectorAria": "Selecionar modo de alterações", "diffView.actions.retry": "Tentar novamente", "diffView.actions.renderAnyway": "Renderizar mesmo assim", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index a6867e50..b87b7876 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1483,6 +1483,13 @@ export const dict: Record = { "diffView.scope.changed": "Змінені", "diffView.scope.staged": "Індексовані", "diffView.scope.lastTurn": "Останній хід", + "diffView.scope.branch": "Гілка", + "diffView.branch.resolvingBase": "Визначаємо базову гілку...", + "diffView.branch.noBaseTitle": "Немає базової гілки", + "diffView.branch.noBaseDescription": "Git не зберігає, від якої гілки почалася ця гілка. Виберіть базову гілку для порівняння.", + "diffView.branch.loadError": "Не вдалося завантажити зміни гілки", + "diffView.branch.loadingFiles": "Завантаження змін гілки...", + "diffView.branch.empty": "Немає змін у цій гілці відносно {base}", "diffView.scope.selectorAria": "Вибрати режим змін", "diffView.actions.retry": "Повторити спробу", "diffView.actions.renderAnyway": "Все одно відрендерити", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 71c92446..e7852359 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1483,6 +1483,13 @@ export const dict: Record = { "diffView.scope.changed": "已更改", "diffView.scope.staged": "已暂存", "diffView.scope.lastTurn": "上一轮", + "diffView.scope.branch": "分支", + "diffView.branch.resolvingBase": "正在检测基础分支...", + "diffView.branch.noBaseTitle": "没有基础分支", + "diffView.branch.noBaseDescription": "Git 中没有记录此分支的起点。请选择一个基础分支进行比较。", + "diffView.branch.loadError": "加载分支更改失败", + "diffView.branch.loadingFiles": "正在加载分支更改...", + "diffView.branch.empty": "此分支相对于 {base} 没有更改", "diffView.scope.selectorAria": "选择更改模式", 'diffView.actions.retry': '重试', 'diffView.actions.renderAnyway': '仍然渲染', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 58c52dce..c8122b4c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1493,6 +1493,13 @@ export const dict: Record = { "diffView.scope.changed": "已變更", "diffView.scope.staged": "已暫存", "diffView.scope.lastTurn": "上一輪", + "diffView.scope.branch": "分支", + "diffView.branch.resolvingBase": "正在偵測基礎分支...", + "diffView.branch.noBaseTitle": "沒有基礎分支", + "diffView.branch.noBaseDescription": "Git 中沒有記錄此分支的起點。請選擇基礎分支進行比較。", + "diffView.branch.loadError": "載入分支變更失敗", + "diffView.branch.loadingFiles": "正在載入分支變更...", + "diffView.branch.empty": "此分支相對於 {base} 沒有變更", "diffView.scope.selectorAria": "選擇變更模式", 'diffView.actions.retry': '重試', 'diffView.actions.renderAnyway': '仍然渲染', diff --git a/packages/ui/src/stores/useGitBaseBranchStore.test.ts b/packages/ui/src/stores/useGitBaseBranchStore.test.ts new file mode 100644 index 00000000..3909d4e2 --- /dev/null +++ b/packages/ui/src/stores/useGitBaseBranchStore.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" + +let runtimeKey = "runtime-a" +mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey })) + +const { gitBaseBranchEntryKey, useGitBaseBranchStore } = await import("./useGitBaseBranchStore") + +describe("git base branch overrides", () => { + beforeEach(() => { + runtimeKey = "runtime-a" + useGitBaseBranchStore.setState({ overrides: {} }) + }) + + test("keys the same repository per branch and runtime", () => { + const featureA = gitBaseBranchEntryKey("/repo", "feature-a") + const featureB = gitBaseBranchEntryKey("/repo", "feature-b") + runtimeKey = "runtime-b" + const featureARemote = gitBaseBranchEntryKey("/repo", "feature-a") + + expect(new Set([featureA, featureB, featureARemote]).size).toBe(3) + }) + + test("a base picked for one branch does not apply to another branch", () => { + const store = useGitBaseBranchStore.getState() + store.setOverride("/repo", "feature-a", "main") + + expect(store.getOverride("/repo", "feature-a")).toBe("main") + // feature-b must fall back to its own detection, not feature-a's choice. + expect(store.getOverride("/repo", "feature-b")).toBeNull() + }) + + test("different branches of one repository keep independent bases", () => { + const store = useGitBaseBranchStore.getState() + store.setOverride("/repo", "feature-a", "main") + store.setOverride("/repo", "feature-b", "develop") + + expect(store.getOverride("/repo", "feature-a")).toBe("main") + expect(store.getOverride("/repo", "feature-b")).toBe("develop") + }) + + test("clearOverride removes only the targeted branch's choice", () => { + const store = useGitBaseBranchStore.getState() + store.setOverride("/repo", "feature-a", "main") + store.setOverride("/repo", "feature-b", "develop") + store.clearOverride("/repo", "feature-a") + + expect(store.getOverride("/repo", "feature-a")).toBeNull() + expect(store.getOverride("/repo", "feature-b")).toBe("develop") + }) + + test("rejects empty directory, branch, or base", () => { + const store = useGitBaseBranchStore.getState() + store.setOverride("", "feature-a", "main") + store.setOverride("/repo", "", "main") + store.setOverride("/repo", "feature-a", "") + store.clearOverride("", "feature-a") + + expect(useGitBaseBranchStore.getState().overrides).toEqual({}) + expect(store.getOverride("", "feature-a")).toBeNull() + expect(store.getOverride("/repo", "")).toBeNull() + }) +}) diff --git a/packages/ui/src/stores/useGitBaseBranchStore.ts b/packages/ui/src/stores/useGitBaseBranchStore.ts new file mode 100644 index 00000000..0d3d993f --- /dev/null +++ b/packages/ui/src/stores/useGitBaseBranchStore.ts @@ -0,0 +1,71 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { getRuntimeKey } from '@/lib/runtime-switch'; +import { createDeferredSafeJSONStorage } from './utils/safeStorage'; + +const GIT_BASE_BRANCH_STORAGE_KEY = 'openchamber.git-base-branch'; +const MAX_BASE_BRANCH_ENTRIES = 100; + +/** + * Build the persisted override key for one branch of one repository. + * + * The branch is part of the identity on purpose: a base picked for one feature + * branch is not an answer for a different branch of the same repository, and a + * directory-only key would silently shadow reflog detection after checkout. + * Keys include the runtime identity so a remote runtime's paths never shadow + * local ones. + */ +export const gitBaseBranchEntryKey = (directory: string, branch: string): string => + JSON.stringify([getRuntimeKey(), directory, branch]); + +type GitBaseBranchState = { + overrides: Record; + getOverride: (directory: string, branch: string) => string | null; + setOverride: (directory: string, branch: string, base: string) => void; + clearOverride: (directory: string, branch: string) => void; +}; + +/** + * Explicit per-branch base choices for the "Branch" diff scope. + * + * Git does not record a parent branch for every branch (clones, detached + * starts). When no authoritative source exists, the user picks a base once and + * the choice is remembered for that branch. + */ +export const useGitBaseBranchStore = create()( + persist( + (set, get) => ({ + overrides: {}, + getOverride: (directory, branch) => { + if (!directory || !branch) return null; + return get().overrides[gitBaseBranchEntryKey(directory, branch)] ?? null; + }, + setOverride: (directory, branch, base) => { + if (!directory || !branch || !base) return; + set((state) => { + const key = gitBaseBranchEntryKey(directory, branch); + const entries = Object.entries({ ...state.overrides, [key]: base }); + while (entries.length > MAX_BASE_BRANCH_ENTRIES) { + entries.shift(); + } + return { overrides: Object.fromEntries(entries) }; + }); + }, + clearOverride: (directory, branch) => { + if (!directory || !branch) return; + set((state) => { + const key = gitBaseBranchEntryKey(directory, branch); + if (!(key in state.overrides)) return state; + const next = { ...state.overrides }; + delete next[key]; + return { overrides: next }; + }); + }, + }), + { + name: GIT_BASE_BRANCH_STORAGE_KEY, + storage: createDeferredSafeJSONStorage(), + partialize: (state) => ({ overrides: state.overrides }), + } + ) +); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 7c772122..8ed14467 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -20,7 +20,7 @@ import { isVSCodeRuntime } from '@/lib/desktop'; export type WorkspaceSurface = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram'; /** @deprecated Use WorkspaceSurface. */ export type MainTab = WorkspaceSurface; -export type PendingDiffScope = 'working' | 'staged' | 'turn'; +export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch'; export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal'; export type MermaidRenderingMode = 'svg' | 'ascii'; export type UserMessageRenderingMode = 'markdown' | 'plain'; @@ -205,7 +205,7 @@ const normalizeContextTabLabel = (value: string | null | undefined): string | nu }; const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => { - return value === 'working' || value === 'staged' || value === 'turn' ? value : null; + return value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' ? value : null; }; const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath: string | null): string => { diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index 3766fea2..0fe38cdf 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -428,6 +428,49 @@ export function registerGitRoutes(app) { } }); + app.get('/api/git/branch-base', async (req, res) => { + const { getBranchBase } = await getGitLibraries(); + try { + const directory = resolveDirectoryQuery(req.query.directory); + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const branch = resolveDirectoryQuery(req.query.branch); + if (!branch) { + return res.status(400).json({ error: 'branch parameter is required' }); + } + + const result = await getBranchBase(directory, branch); + res.json(result); + } catch (error) { + console.error('Failed to get branch base:', error); + res.status(500).json({ error: error.message || 'Failed to get branch base' }); + } + }); + + app.get('/api/git/range-files', async (req, res) => { + const { getRangeFiles } = await getGitLibraries(); + try { + const directory = resolveDirectoryQuery(req.query.directory); + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const base = resolveDirectoryQuery(req.query.base); + const head = resolveDirectoryQuery(req.query.head); + if (!base || !head) { + return res.status(400).json({ error: 'base and head parameters are required' }); + } + + const files = await getRangeFiles(directory, { base, head }); + res.json({ files }); + } catch (error) { + console.error('Failed to get git range files:', error); + res.status(500).json({ error: error.message || 'Failed to get git range files' }); + } + }); + app.post('/api/git/revert', async (req, res) => { const { revertFile } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index e82d3774..d8e97b37 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -2654,6 +2654,71 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont return diff; } +const BRANCH_CREATION_SOURCE_RE = /^branch: Created from (.+)$/; + +/** + * Parse a branch reflog (`git reflog show --format=%gs `) and return the + * ref the branch was created from, when that source is itself a named ref. + * + * Returns null when the branch was created from `HEAD@{...}` or a raw commit + * (detached start): the original branch name is not recorded anywhere in that + * case, and guessing a base from commit topology would be a heuristic, not an + * answer. Callers should ask the user to pick a base instead. + */ +export function parseBranchCreationSource(reflogText) { + const lines = String(reflogText || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + // Reflog lists newest entries first; the creation entry is the oldest one. + for (let index = lines.length - 1; index >= 0; index -= 1) { + const match = lines[index].match(BRANCH_CREATION_SOURCE_RE); + if (!match) continue; + const source = match[1].trim(); + if (!source || /^HEAD@/.test(source) || /^[0-9a-f]{7,40}$/i.test(source)) { + return null; + } + return source; + } + return null; +} + +/** + * Resolve the branch the given branch was created from, from its reflog. + * Returns { base: null } when git has no authoritative record (clone, detached + * start, reflog expired) — callers must not fall back to main/master. + */ +export async function getBranchBase(directory, branch) { + const branchName = String(branch || '').trim(); + if (!branchName) { + throw new Error('branch is required'); + } + + const { git } = await createRepositoryGitContext(directory); + + let reflog = ''; + try { + reflog = await git.raw(['reflog', 'show', '--format=%gs', branchName]); + } catch { + return { base: null }; + } + + const source = parseBranchCreationSource(reflog); + if (!source || source === branchName) { + return { base: null }; + } + + const resolves = await git + .raw(['rev-parse', '--verify', '--quiet', source]) + .then((value) => Boolean(String(value || '').trim())) + .catch(() => false); + if (!resolves) { + return { base: null }; + } + + return { base: source }; +} + export async function getRangeFiles(directory, { base, head } = {}) { const { git } = await createRepositoryGitContext(directory); const baseRef = typeof base === 'string' ? base.trim() : ''; @@ -2673,11 +2738,26 @@ export async function getRangeFiles(directory, { base, head } = {}) { // ignore } - const raw = await git.raw(['diff', '--name-only', `${resolvedBase}...${headRef}`]); - return String(raw || '') - .split('\n') - .map((l) => l.trim()) - .filter(Boolean); + // `-C` (copy detection among changed files only, so cheap) makes copies + // surface as C entries instead of plain additions; rename detection is on + // by default. + const raw = await git.raw(['diff', '--name-status', '-z', '-C', `${resolvedBase}...${headRef}`]); + // -z format: STATUS\0PATH\0[ORIG\0] repeated. For rename/copy entries + // (`R100`, `C75`) the first path token is the ORIGINAL path and the second + // is the DESTINATION — the diff (and the UI) must address the destination. + const tokens = String(raw || '').split('\0'); + const files = []; + for (let index = 0; index < tokens.length; index += 1) { + const status = (tokens[index] || '').trim(); + if (!status) continue; + const isRenameOrCopy = status.startsWith('R') || status.startsWith('C'); + const path = isRenameOrCopy ? (tokens[index + 2] || '').trim() : (tokens[index + 1] || '').trim(); + index += isRenameOrCopy ? 2 : 1; + if (path) { + files.push({ path, status: status.charAt(0) }); + } + } + return files; } const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif']; diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 88baecc3..eb86c2ac 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -28,6 +28,8 @@ import { getDiff, getFileDiff, validateWorktreeCreate, + parseBranchCreationSource, + getRangeFiles, } from './service.js'; // --------------------------------------------------------------------------- @@ -1336,3 +1338,94 @@ describe.runIf(canRunGit())('getRangeDiff', () => { expect(diff).toContain('feature.txt'); }); }); + +describe('parseBranchCreationSource', () => { + it('returns the source ref from the oldest creation entry', () => { + // Reflog lists newest entries first; creation is the last line. + const reflog = [ + 'commit: abc123', + 'branch: Created from origin/main', + ].join('\n'); + expect(parseBranchCreationSource(reflog)).toBe('origin/main'); + }); + + it('returns null when the branch was created from a detached HEAD pointer', () => { + const reflog = 'branch: Created from HEAD@{0}'; + expect(parseBranchCreationSource(reflog)).toBeNull(); + }); + + it('returns null when the branch was created from a raw commit', () => { + const reflog = 'branch: Created from 9a3b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b'; + expect(parseBranchCreationSource(reflog)).toBeNull(); + }); + + it('returns null when there is no creation entry', () => { + const reflog = ['commit: abc123', 'reset: moving to HEAD'].join('\n'); + expect(parseBranchCreationSource(reflog)).toBeNull(); + }); + + it('returns null for empty input', () => { + expect(parseBranchCreationSource('')).toBeNull(); + expect(parseBranchCreationSource(undefined)).toBeNull(); + }); +}); + +describe.runIf(canRunGit())('getRangeFiles', () => { + it('returns added and modified paths with their status letters', async () => { + const { repository } = createRepositoryWithRemote(); + fs.writeFileSync(path.join(repository, 'added.txt'), 'new\n'); + fs.writeFileSync(path.join(repository, 'README.md'), '# Test\nchanged\n'); + runGit(repository, ['add', 'added.txt', 'README.md']); + runGit(repository, ['commit', '-m', 'changes']); + + const files = await getRangeFiles(repository, { base: 'react', head: 'next' }); + + expect(files).toEqual(expect.arrayContaining([ + { path: 'added.txt', status: 'A' }, + { path: 'README.md', status: 'M' }, + ])); + }); + + it('reports the destination path for renamed files, including spaces', async () => { + const { repository } = createRepositoryWithRemote(); + // The original file must exist in the base: rename detection pairs a + // deletion against an addition relative to base, not within the branch. + fs.writeFileSync(path.join(repository, 'old name with spaces.md'), '# Test\n'); + runGit(repository, ['add', 'old name with spaces.md']); + runGit(repository, ['commit', '-m', 'add file to rename']); + runGit(repository, ['push', 'origin', 'HEAD:react']); + // Spaces in filenames exercise the -z token split: a newline split would + // mangle these paths long before status letters matter. + fs.renameSync(path.join(repository, 'old name with spaces.md'), path.join(repository, 'new name with spaces.md')); + runGit(repository, ['add', '-A']); + runGit(repository, ['commit', '-m', 'rename']); + + const files = await getRangeFiles(repository, { base: 'react', head: 'next' }); + + const renameEntry = files.find((file) => file.status === 'R'); + expect(renameEntry).toBeDefined(); + expect(renameEntry.path).toBe('new name with spaces.md'); + expect(files.some((file) => file.path === 'old name with spaces.md')).toBe(false); + }); + + it('reports the destination path for copied files', async () => { + const { repository } = createRepositoryWithRemote(); + // The source must exist in the base. Copy detection needs the repository's + // own `diff.renames=copies` setting on top of the service's -C flag; the + // parser must survive whatever C entries git emits. + runGit(repository, ['config', 'diff.renames', 'copies']); + fs.writeFileSync(path.join(repository, 'copied source.md'), '# Copy me\n'); + runGit(repository, ['add', 'copied source.md']); + runGit(repository, ['commit', '-m', 'add source']); + runGit(repository, ['push', 'origin', 'HEAD:react']); + fs.copyFileSync(path.join(repository, 'copied source.md'), path.join(repository, 'copied destination.md')); + runGit(repository, ['add', '-A']); + runGit(repository, ['commit', '-m', 'copy']); + + const files = await getRangeFiles(repository, { base: 'react', head: 'next' }); + + const copyEntry = files.find((file) => file.status === 'C'); + expect(copyEntry).toBeDefined(); + expect(copyEntry.path).toBe('copied destination.md'); + }); +}); diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index 80c48a46..d6bc9fd9 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -11,6 +11,8 @@ export const createWebGitAPI = (): GitAPI => ({ getGitDiff: gitApiHttp.getGitDiff, getGitFileDiff: gitApiHttp.getGitFileDiff, getGitRangeDiff: gitApiHttp.getGitRangeDiff, + getGitRangeFiles: gitApiHttp.getGitRangeFiles, + getBranchBase: gitApiHttp.getBranchBase, revertGitFile: gitApiHttp.revertGitFile, stageGitFile: gitApiHttp.stageGitFile, stageGitFiles: gitApiHttp.stageGitFiles, From 0d50253efaa5e62b33c5fa4bb591b41d50d948ce Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 00:05:44 +0300 Subject: [PATCH 21/59] refactor(integrations): retire unavailable options Remove retired Command Code, Discord, and Telegram integration entries, search targets, and documentation. Keep Command Code provider usage and logo support available through normalized provider ID aliases. --- .../docs/content/docs/de/integrations.mdx | 18 +-- .../docs/content/docs/es/integrations.mdx | 18 +-- packages/docs/content/docs/es/providers.mdx | 2 +- .../docs/content/docs/fr/integrations.mdx | 18 +-- packages/docs/content/docs/fr/providers.mdx | 2 +- packages/docs/content/docs/integrations.mdx | 18 +-- .../docs/content/docs/ja/integrations.mdx | 18 +-- packages/docs/content/docs/ja/providers.mdx | 2 +- .../docs/content/docs/ko/integrations.mdx | 18 +-- packages/docs/content/docs/ko/providers.mdx | 2 +- .../docs/content/docs/pl/integrations.mdx | 18 +-- packages/docs/content/docs/pl/providers.mdx | 2 +- packages/docs/content/docs/providers.mdx | 2 +- .../docs/content/docs/pt-br/integrations.mdx | 18 +-- .../docs/content/docs/pt-br/providers.mdx | 2 +- .../docs/content/docs/uk/integrations.mdx | 18 +-- packages/docs/content/docs/uk/providers.mdx | 2 +- .../docs/content/docs/zh-cn/integrations.mdx | 18 +-- .../docs/content/docs/zh-cn/providers.mdx | 2 +- .../ComingSoonMessengersSection.tsx | 74 ------------ .../integrations/IntegrationsPage.tsx | 19 +-- .../ThirdPartyIntegrationsSection.tsx | 7 +- .../integrations/thirdPartyPlugins.test.ts | 5 - .../integrations/thirdPartyPlugins.ts | 10 -- .../ui/providerLogoFallback.test.ts | 6 +- .../src/components/ui/providerLogoFallback.ts | 6 +- .../third-party-integrations.i18n.test.ts | 4 - .../messages/third-party-integrations.i18n.ts | 110 ++---------------- packages/ui/src/lib/settings/search.ts | 7 -- .../lib/quota/providers/command-code.js | 2 +- .../lib/quota/providers/command-code.test.js | 13 +++ .../web/server/lib/quota/providers/index.js | 16 ++- 32 files changed, 98 insertions(+), 379 deletions(-) delete mode 100644 packages/ui/src/components/sections/integrations/ComingSoonMessengersSection.tsx diff --git a/packages/docs/content/docs/de/integrations.mdx b/packages/docs/content/docs/de/integrations.mdx index 58c2acf4..38b24051 100644 --- a/packages/docs/content/docs/de/integrations.mdx +++ b/packages/docs/content/docs/de/integrations.mdx @@ -1,20 +1,17 @@ --- title: Integrationen -description: Nutze dein Claude-, Command-Code- oder Cursor-Abo als Provider. +description: Nutze dein Claude- oder Cursor-Abo als Provider. --- # Integrationen Eine Integration ist ein kleines Plugin, das OpenChamber einen Provider hinzufügt — auf Basis eines Abos, das du bereits hast. Verwalten kannst du sie unter **Settings → Integrations**. -> **Experimentelle Funktion:** Integrationen können sich ändern oder nicht mehr funktionieren. Nutze sie nach eigenem Ermessen. - -Wir haben diese Integrationen so entwickelt, dass sie die vorgesehenen Anmeldewege der Anbieter nutzen und bekannte Verstöße gegen deren Nutzungsbedingungen vermeiden. Wir können nicht garantieren, dass ein Anbieter jede Nutzung oder jedes Konto akzeptiert. Lies die Bedingungen des Anbieters und nutze Integrationen auf eigenes Risiko. OpenChamber kann keine Kontobeschränkungen, Sperrungen oder Streitfälle mit einem Anbieter klären. +> **Experimentelle Funktion.** Wir bemühen uns, die Richtlinien der Anbieter zu respektieren, aber Kontobeschränkungen und Sperrungen liegen bei jedem Anbieter. Nutze Integrationen auf eigenes Risiko. Verfügbare Integrationen: - **Claude Code** — dein Claude Pro- oder Max-Plan, ohne API-Keys -- **Command Code** — dein Command-Code-Plan - **Cursor** — die Modell-Limits deines Cursor-Plans ## Integration installieren @@ -33,19 +30,10 @@ Claude Code nutzt deinen Claude Pro- oder Max-Plan — ohne API-Keys und ohne se 1. Installiere die Integration (siehe oben). 2. Wähle **Set up** und melde dich an. Wenn du die Claude Code CLI noch nicht hast, bietet die Einrichtung an, sie zuerst zu installieren, und meldet dich danach an. -Claude Code ist die einzige Integration hier, die ihre Provider-CLI installiert und angemeldet benötigt. Command Code und Cursor brauchen ihre CLIs nicht. +Claude Code ist die einzige Integration hier, die ihre Provider-CLI installiert und angemeldet benötigt. Cursor braucht seine CLI nicht. **Wie dein Claude-Konto geschützt bleibt:** Diese Integration nutzt das offizielle Claude Agent SDK von Anthropic und deine installierte Claude Code CLI. Sie kapert kein OAuth, extrahiert oder wiederholt keine Browser-Tokens, gibt sich nicht als nicht unterstützter Client aus und umgeht nicht Anthropics Authentifizierung. Sie bleibt auf dem von Anthropic unterstützten Zugriffsweg und trägt daher nicht das mit Token-Hijacking oder unautorisierten Authentifizierungsumgehungen verbundene Sperrrisiko. -## Command Code - -Command Code nutzt deinen Command-Code-Plan. - -1. Installiere die Integration (siehe oben). -2. Wähle **Set up** — es öffnet sich eine Browserseite. Genehmige den Zugriff und kehre zu OpenChamber zurück. - -Auf einem Headless-Server oder in CI setze stattdessen die Umgebungsvariable `COMMAND_CODE_API_KEY`, anstatt dich im Browser anzumelden. - ## Cursor Cursor macht die Modelle deines Cursor-Plans in OpenChamber nutzbar. diff --git a/packages/docs/content/docs/es/integrations.mdx b/packages/docs/content/docs/es/integrations.mdx index d1f23cc7..17582762 100644 --- a/packages/docs/content/docs/es/integrations.mdx +++ b/packages/docs/content/docs/es/integrations.mdx @@ -1,20 +1,17 @@ --- title: Integraciones -description: Usa tu suscripción de Claude, Command Code o Cursor como proveedor. +description: Usa tu suscripción de Claude o Cursor como proveedor. --- # Integraciones Una integración es un pequeño plugin que añade un proveedor a OpenChamber usando una suscripción que ya tienes. Las gestionas en **Settings → Integrations**. -> **Función experimental:** las integraciones pueden cambiar o dejar de funcionar. Úsalas bajo tu propia responsabilidad. - -Diseñamos estas integraciones para seguir los flujos de inicio de sesión previstos por los proveedores y evitar infracciones conocidas de sus Términos de Servicio. No podemos garantizar que un proveedor acepte cada uso o cuenta. Revisa los términos del proveedor y usa las integraciones bajo tu propia responsabilidad. OpenChamber no puede resolver restricciones, suspensiones de cuentas ni disputas con un proveedor. +> **Función experimental.** Buscamos respetar las políticas de los proveedores, pero las restricciones y suspensiones de cuentas son decisión de cada proveedor. Usa las integraciones bajo tu propia responsabilidad. Integraciones disponibles: - **Claude Code** — tu plan Claude Pro o Max, sin claves de API -- **Command Code** — tu plan de Command Code - **Cursor** — los límites de modelos de tu plan de Cursor ## Instalar una integración @@ -33,19 +30,10 @@ Claude Code usa tu plan Claude Pro o Max — sin claves de API y sin una app de 1. Instala la integración (arriba). 2. Elige **Set up** e inicia sesión. Si aún no tienes la CLI de Claude Code, la configuración ofrece instalarla primero y luego iniciar sesión. -Claude Code es la única integración de esta página que requiere tener la CLI de su proveedor instalada y con sesión iniciada. Command Code y Cursor no requieren sus CLIs. +Claude Code es la única integración de esta página que requiere tener la CLI de su proveedor instalada y con sesión iniciada. Cursor no requiere su CLI. **Cómo se protege tu cuenta de Claude:** esta integración usa el Claude Agent SDK oficial de Anthropic y tu CLI de Claude Code instalada. No secuestra OAuth, no extrae ni reutiliza tokens del navegador, no se hace pasar por un cliente no admitido ni omite la autenticación de Anthropic. Se mantiene en la vía de acceso admitida por Anthropic, por lo que no conlleva el riesgo de baneo asociado al secuestro de tokens o a rodeos de autenticación no autorizados. -## Command Code - -Command Code usa tu plan de Command Code. - -1. Instala la integración (arriba). -2. Elige **Set up** — se abre una página en el navegador. Autoriza el acceso y vuelve a OpenChamber. - -En una máquina sin interfaz gráfica o en CI, define la variable de entorno `COMMAND_CODE_API_KEY` en lugar de iniciar sesión en el navegador. - ## Cursor Cursor hace disponibles en OpenChamber los modelos incluidos en tu plan de Cursor. diff --git a/packages/docs/content/docs/es/providers.mdx b/packages/docs/content/docs/es/providers.mdx index 24e5b94f..65526b23 100644 --- a/packages/docs/content/docs/es/providers.mdx +++ b/packages/docs/content/docs/es/providers.mdx @@ -45,6 +45,6 @@ Los inicios de sesión de los proveedores los guarda OpenCode, no OpenChamber, a ## Relacionado -- [Integraciones](/es/integrations/) — usa una suscripción de Claude, Command Code o Cursor como proveedor +- [Integraciones](/es/integrations/) — usa una suscripción de Claude o Cursor como proveedor - [Servidores MCP](/es/mcp/) — añade herramientas extra para los agentes - [Uso y cuotas](/es/usage/) — controla cuánto has consumido diff --git a/packages/docs/content/docs/fr/integrations.mdx b/packages/docs/content/docs/fr/integrations.mdx index c736ba85..2628a870 100644 --- a/packages/docs/content/docs/fr/integrations.mdx +++ b/packages/docs/content/docs/fr/integrations.mdx @@ -1,20 +1,17 @@ --- title: Intégrations -description: Utilise ton abonnement Claude, Command Code ou Cursor comme fournisseur. +description: Utilise ton abonnement Claude ou Cursor comme fournisseur. --- # Intégrations Une intégration est un petit plugin qui ajoute un fournisseur à OpenChamber à partir d'un abonnement que tu possèdes déjà. Tu les gères dans **Settings → Integrations**. -> **Fonctionnalité expérimentale :** les intégrations peuvent changer ou cesser de fonctionner. Utilise-les à ta discrétion. - -Nous avons conçu ces intégrations pour suivre les méthodes de connexion prévues par les fournisseurs et éviter les violations connues de leurs conditions d'utilisation. Nous ne pouvons pas garantir qu'un fournisseur acceptera chaque usage ou chaque compte. Consulte les conditions du fournisseur et utilise les intégrations à tes risques. OpenChamber ne peut pas résoudre les restrictions, suspensions de compte ou litiges avec un fournisseur. +> **Fonctionnalité expérimentale.** Nous cherchons à respecter les règles des fournisseurs, mais les restrictions et suspensions de compte relèvent de leur décision. Utilise les intégrations à tes risques. Intégrations disponibles : - **Claude Code** — ton plan Claude Pro ou Max, sans clés API -- **Command Code** — ton plan Command Code - **Cursor** — les limites de modèles de ton plan Cursor ## Installer une intégration @@ -33,19 +30,10 @@ Claude Code utilise ton plan Claude Pro ou Max — sans clés API et sans applic 1. Installe l'intégration (ci-dessus). 2. Choisis **Set up** et connecte-toi. Si tu n'as pas encore la CLI Claude Code, la configuration propose de l'installer d'abord, puis de te connecter. -Claude Code est la seule intégration ici qui exige que la CLI de son fournisseur soit installée et connectée. Command Code et Cursor n'exigent pas leurs CLIs. +Claude Code est la seule intégration ici qui exige que la CLI de son fournisseur soit installée et connectée. Cursor n'exige pas sa CLI. **Comment ton compte Claude reste protégé :** cette intégration utilise le Claude Agent SDK officiel d'Anthropic et ta CLI Claude Code installée. Elle ne détourne pas l'OAuth, n'extrait ni rejoue de tokens de navigateur, ne se fait pas passer pour un client non pris en charge et ne contourne pas l'authentification d'Anthropic. Elle reste sur la voie d'accès prise en charge par Anthropic et ne porte donc pas le risque de bannissement associé au détournement de tokens ou aux contournements d'authentification non autorisés. -## Command Code - -Command Code utilise ton plan Command Code. - -1. Installe l'intégration (ci-dessus). -2. Choisis **Set up** — une page s'ouvre dans le navigateur. Autorise l'accès, puis reviens dans OpenChamber. - -Sur une machine sans interface graphique ou en CI, définis la variable d'environnement `COMMAND_CODE_API_KEY` au lieu de te connecter via le navigateur. - ## Cursor Cursor rend disponibles dans OpenChamber les modèles inclus dans ton plan Cursor. diff --git a/packages/docs/content/docs/fr/providers.mdx b/packages/docs/content/docs/fr/providers.mdx index d79633d8..a3ce0149 100644 --- a/packages/docs/content/docs/fr/providers.mdx +++ b/packages/docs/content/docs/fr/providers.mdx @@ -45,6 +45,6 @@ Les connexions aux fournisseurs sont stockées par OpenCode, pas OpenChamber ; e ## Pages liées -- [Intégrations](/integrations/) — utiliser un abonnement Claude, Command Code ou Cursor comme fournisseur +- [Intégrations](/integrations/) — utiliser un abonnement Claude ou Cursor comme fournisseur - [Serveurs MCP](/mcp/) — ajouter des outils supplémentaires aux agents - [Utilisation et quotas](/usage/) — suivre votre consommation diff --git a/packages/docs/content/docs/integrations.mdx b/packages/docs/content/docs/integrations.mdx index 599d0720..8aef2d38 100644 --- a/packages/docs/content/docs/integrations.mdx +++ b/packages/docs/content/docs/integrations.mdx @@ -1,20 +1,17 @@ --- title: Integrations -description: Use your Claude, Command Code, or Cursor subscription as a provider. +description: Use your Claude or Cursor subscription as a provider. --- # Integrations An integration is a small plugin that adds a provider to OpenChamber using a subscription you already have. You manage them at **Settings → Integrations**. -> **Experimental feature:** integrations may change or stop working. Use them at your own discretion. - -We designed these integrations to follow providers' intended sign-in flows and avoid known Terms of Service violations. We cannot guarantee that a provider will accept every use or account. Review the provider's terms and use integrations at your own risk. OpenChamber cannot resolve account restrictions, suspensions, or disputes with a provider. +> **Experimental feature.** We aim to respect provider policies, but account restrictions and suspensions remain each provider's decision. Use integrations at your own risk. Available integrations: - **Claude Code** — your Claude Pro or Max plan, no API keys -- **Command Code** — your Command Code plan - **Cursor** — the model limits of your Cursor plan ## Install an integration @@ -33,19 +30,10 @@ Claude Code uses your Claude Pro or Max plan — no API keys and no separate Cla 1. Install the integration (above). 2. Choose **Set up** and sign in. If you don't have the Claude Code CLI yet, setup offers to install it first and then sign you in. -Claude Code is the only integration here that requires its provider CLI to be installed and signed in. Command Code and Cursor do not require their CLIs. +Claude Code is the only integration here that requires its provider CLI to be installed and signed in. Cursor does not require its CLI. **How your Claude account stays safe:** this integration uses Anthropic's official Claude Agent SDK and your installed Claude Code CLI. It does not hijack OAuth, extract or replay browser tokens, impersonate an unsupported client, or bypass Anthropic's authentication flow. It stays on Anthropic's supported access path, so it does not carry the account-ban risk of token hijacking or unauthorized authentication workarounds. -## Command Code - -Command Code uses your Command Code plan. - -1. Install the integration (above). -2. Choose **Set up** — a browser page opens. Approve access, then return to OpenChamber. - -On a headless machine or in CI, set the `COMMAND_CODE_API_KEY` environment variable instead of signing in in the browser. - ## Cursor Cursor makes the models included in your Cursor plan available in OpenChamber. diff --git a/packages/docs/content/docs/ja/integrations.mdx b/packages/docs/content/docs/ja/integrations.mdx index b4f2c24c..b13ac0cd 100644 --- a/packages/docs/content/docs/ja/integrations.mdx +++ b/packages/docs/content/docs/ja/integrations.mdx @@ -1,20 +1,17 @@ --- title: 統合機能 -description: Claude、Command Code、Cursor のサブスクリプションをプロバイダーとして使う。 +description: Claude または Cursor のサブスクリプションをプロバイダーとして使う。 --- # 統合機能 統合機能(インテグレーション)は、すでに持っているサブスクリプションを使って OpenChamber にプロバイダーを追加する小さなプラグインです。**Settings → Integrations** で管理します。 -> **実験的な機能:** 連携は変更されたり、動作しなくなったりする可能性があります。自己責任で使用してください。 - -これらの連携は、プロバイダーが想定するサインインの流れに従い、既知の利用規約違反を避けるよう設計しています。ただし、プロバイダーがすべての利用方法やアカウントを受け入れることは保証できません。プロバイダーの規約を確認し、自己責任で連携を使用してください。OpenChamber は、プロバイダーによるアカウント制限、停止、または紛争を解決できません。 +> **実験的な機能。** プロバイダーの方針を尊重するよう努めていますが、アカウントの制限や停止は各プロバイダーの判断に委ねられます。自己責任で連携を使用してください。 利用できる統合機能: - **Claude Code** — Claude Pro または Max プラン、API キー不要 -- **Command Code** — Command Code のプラン - **Cursor** — Cursor プランのモデル利用枠 ## 統合機能をインストールする @@ -33,19 +30,10 @@ Claude Code は Claude Pro または Max プランを使います — API キー 1. 統合機能をインストールします(上記)。 2. **Set up** を選んでサインインします。Claude Code CLI がまだない場合は、セットアップがまずインストールを提案し、その後サインインします。 -Claude Code は、ここで唯一プロバイダーの CLI のインストールとサインインを必要とする統合機能です。Command Code と Cursor は CLI を必要としません。 +Claude Code は、ここで唯一プロバイダーの CLI のインストールとサインインを必要とする統合機能です。Cursor は CLI を必要としません。 **Claude アカウントが守られる仕組み:** この統合機能は Anthropic の公式 Claude Agent SDK と、インストール済みの Claude Code CLI を使用します。OAuth の乗っ取り、ブラウザートークンの抽出や再生、未対応クライアントへの偽装、Anthropic の認証フローの回避は一切行いません。Anthropic がサポートする正規のアクセス経路を使うため、トークン乗っ取りや不正な認証の回避につきもののアカウント停止リスクはありません。 -## Command Code - -Command Code は Command Code のプランを使います。 - -1. 統合機能をインストールします(上記)。 -2. **Set up** を選ぶとブラウザーでページが開きます。アクセスを許可して OpenChamber に戻ります。 - -画面のないサーバーや CI では、ブラウザーでサインインする代わりに環境変数 `COMMAND_CODE_API_KEY` を設定してください。 - ## Cursor Cursor は Cursor プランに含まれるモデルを OpenChamber で使えるようにします。 diff --git a/packages/docs/content/docs/ja/providers.mdx b/packages/docs/content/docs/ja/providers.mdx index 62f1c663..7e9c0525 100644 --- a/packages/docs/content/docs/ja/providers.mdx +++ b/packages/docs/content/docs/ja/providers.mdx @@ -45,6 +45,6 @@ OpenChamber が何かを行うには、少なくとも 1 つの AI プロバイ ## 関連 -- [統合機能](/integrations/) — Claude、Command Code、Cursor のサブスクリプションをプロバイダーとして使う +- [統合機能](/integrations/) — Claude または Cursor のサブスクリプションをプロバイダーとして使う - [MCP サーバー](/mcp/) — エージェントに追加ツールを加える - [使用量とクォータ](/usage/) — 使った量を追跡する diff --git a/packages/docs/content/docs/ko/integrations.mdx b/packages/docs/content/docs/ko/integrations.mdx index bdc26f2a..0663879e 100644 --- a/packages/docs/content/docs/ko/integrations.mdx +++ b/packages/docs/content/docs/ko/integrations.mdx @@ -1,20 +1,17 @@ --- title: 통합 기능 -description: Claude, Command Code 또는 Cursor 구독을 공급자로 사용하세요. +description: Claude 또는 Cursor 구독을 공급자로 사용하세요. --- # 통합 기능 통합 기능(인테그레이션)은 이미 가지고 있는 구독을 사용해 OpenChamber에 공급자를 추가하는 작은 플러그인입니다. **Settings → Integrations**에서 관리합니다. -> **실험 단계 기능:** 통합 기능은 변경되거나 작동하지 않을 수 있습니다. 본인의 판단에 따라 사용하세요. - -이 통합 기능은 프로바이더가 의도한 로그인 흐름을 따르고 알려진 서비스 약관 위반을 피하도록 설계했습니다. 프로바이더가 모든 사용 방식이나 계정을 허용한다고 보장할 수는 없습니다. 프로바이더의 약관을 검토하고 본인의 책임 아래 통합 기능을 사용하세요. OpenChamber는 프로바이더와의 계정 제한, 정지 또는 분쟁을 해결할 수 없습니다. +> **실험 단계 기능.** 프로바이더 정책을 존중하려 노력하지만, 계정 제한과 정지는 각 프로바이더의 결정입니다. 본인의 책임 아래 통합 기능을 사용하세요. 사용 가능한 통합 기능: - **Claude Code** — Claude Pro 또는 Max 플랜, API 키 불필요 -- **Command Code** — Command Code 플랜 - **Cursor** — Cursor 플랜의 모델 한도 ## 통합 기능 설치 @@ -33,19 +30,10 @@ Claude Code는 Claude Pro 또는 Max 플랜을 사용합니다 — API 키도 1. 통합 기능을 설치합니다(위 참고). 2. **Set up**를 선택하고 로그인합니다. Claude Code CLI가 아직 없으면 설정에서 먼저 설치를 제안한 뒤 로그인을 진행합니다. -Claude Code는 여기에서 유일하게 공급자 CLI 설치와 로그인을 필요로 하는 통합 기능입니다. Command Code와 Cursor는 CLI가 필요 없습니다. +Claude Code는 여기에서 유일하게 공급자 CLI 설치와 로그인을 필요로 하는 통합 기능입니다. Cursor는 CLI가 필요 없습니다. **Claude 계정이 안전하게 유지되는 방식:** 이 통합 기능은 Anthropic의 공식 Claude Agent SDK와 설치된 Claude Code CLI를 사용합니다. OAuth 탈취, 브라우저 토큰 추출·재사용, 지원되지 않는 클라이언트로의 위장, Anthropic 인증 우회를 하지 않습니다. Anthropic이 지원하는 정상 경로를 사용하므로 토큰 탈취나 비인가 인증 우회에 따른 계정 정지 위험이 없습니다. -## Command Code - -Command Code는 Command Code 플랜을 사용합니다. - -1. 통합 기능을 설치합니다(위 참고). -2. **Set up**를 선택하면 브라우저에서 페이지가 열립니다. 접근을 승인한 뒤 OpenChamber로 돌아옵니다. - -화면이 없는 서버나 CI 환경에서는 브라우저 로그인 대신 `COMMAND_CODE_API_KEY` 환경 변수를 설정하세요. - ## Cursor Cursor는 Cursor 플랜에 포함된 모델을 OpenChamber에서 사용할 수 있게 합니다. diff --git a/packages/docs/content/docs/ko/providers.mdx b/packages/docs/content/docs/ko/providers.mdx index 11d9007f..98fb109d 100644 --- a/packages/docs/content/docs/ko/providers.mdx +++ b/packages/docs/content/docs/ko/providers.mdx @@ -45,6 +45,6 @@ OpenChamber가 무언가를 하려면 먼저 최소한 하나의 AI 공급자가 ## 관련 항목 -- [통합 기능](/ko/integrations/) — Claude, Command Code, Cursor 구독을 공급자로 사용 +- [통합 기능](/ko/integrations/) — Claude 또는 Cursor 구독을 공급자로 사용 - [MCP Servers](/ko/mcp/) — 에이전트에 추가 도구를 제공합니다 - [Usage & Quotas](/ko/usage/) — 사용량을 추적합니다 diff --git a/packages/docs/content/docs/pl/integrations.mdx b/packages/docs/content/docs/pl/integrations.mdx index da95fde2..7e3ddf2c 100644 --- a/packages/docs/content/docs/pl/integrations.mdx +++ b/packages/docs/content/docs/pl/integrations.mdx @@ -1,20 +1,17 @@ --- title: Integracje -description: Używaj subskrypcji Claude, Command Code lub Cursor jako dostawcy. +description: Używaj subskrypcji Claude lub Cursor jako dostawcy. --- # Integracje Integracja to mała wtyczka, która dodaje dostawcę do OpenChamber na podstawie subskrypcji, którą już masz. Zarządzasz nimi w **Settings → Integrations**. -> **Funkcja eksperymentalna:** integracje mogą się zmienić lub przestać działać. Korzystasz z nich na własną odpowiedzialność. - -Zaprojektowaliśmy te integracje tak, aby korzystały z zamierzonych przez dostawców sposobów logowania i unikały znanych naruszeń ich warunków korzystania. Nie możemy zagwarantować, że dostawca zaakceptuje każdy sposób użycia lub konto. Sprawdź warunki dostawcy i używaj integracji na własne ryzyko. OpenChamber nie może rozwiązać ograniczeń konta, zawieszeń ani sporów z dostawcą. +> **Funkcja eksperymentalna.** Staramy się przestrzegać zasad dostawców, ale ograniczenia i zawieszenia kont pozostają decyzją każdego dostawcy. Używaj integracji na własne ryzyko. Dostępne integracje: - **Claude Code** — Twój plan Claude Pro lub Max, bez kluczy API -- **Command Code** — Twój plan Command Code - **Cursor** — limity modeli z Twojego planu Cursor ## Instalacja integracji @@ -33,19 +30,10 @@ Claude Code korzysta z Twojego planu Claude Pro lub Max — bez kluczy API i bez 1. Zainstaluj integrację (patrz wyżej). 2. Wybierz **Set up** i zaloguj się. Jeśli nie masz jeszcze Claude Code CLI, konfiguracja zaoferuje najpierw jego instalację, a potem logowanie. -Claude Code jest jedyną integracją tutaj, która wymaga zainstalowanego i zalogowanego CLI swojego dostawcy. Command Code i Cursor nie wymagają swoich CLI. +Claude Code jest jedyną integracją tutaj, która wymaga zainstalowanego i zalogowanego CLI swojego dostawcy. Cursor nie wymaga swojego CLI. **Jak chronione jest Twoje konto Claude:** ta integracja używa oficjalnego Claude Agent SDK od Anthropic i Twojego zainstalowanego Claude Code CLI. Nie przechwytuje OAuth, nie wyodrębnia ani nie odtwarza tokenów przeglądarki, nie podszywa się pod nieobsługiwany klient i nie omija uwierzytelniania Anthropic. Działa na obsługiwanej przez Anthropic ścieżce dostępu, więc nie niesie ryzyka zablokowania konta związanego z przechwytywaniem tokenów lub nieautoryzowanymi obejściami uwierzytelniania. -## Command Code - -Command Code korzysta z Twojego planu Command Code. - -1. Zainstaluj integrację (patrz wyżej). -2. Wybierz **Set up** — w przeglądarce otworzy się strona. Zatwierdź dostęp i wróć do OpenChamber. - -Na maszynie bez interfejsu graficznego lub w CI ustaw zmienną środowiskową `COMMAND_CODE_API_KEY` zamiast logowania w przeglądarce. - ## Cursor Cursor udostępnia w OpenChamber modele zawarte w Twoim planie Cursor. diff --git a/packages/docs/content/docs/pl/providers.mdx b/packages/docs/content/docs/pl/providers.mdx index 79d1ad41..4c777176 100644 --- a/packages/docs/content/docs/pl/providers.mdx +++ b/packages/docs/content/docs/pl/providers.mdx @@ -45,6 +45,6 @@ Logowania dostawców są przechowywane przez OpenCode, a nie OpenChamber, więc ## Powiązane -- [Integracje](/pl/integrations/) — używaj subskrypcji Claude, Command Code lub Cursor jako dostawcy +- [Integracje](/pl/integrations/) — używaj subskrypcji Claude lub Cursor jako dostawcy - [Serwery MCP](/pl/mcp/) — dodaj agentom dodatkowe narzędzia - [Zużycie i limity](/pl/usage/) — śledź, ile już wykorzystałeś diff --git a/packages/docs/content/docs/providers.mdx b/packages/docs/content/docs/providers.mdx index df7a7d1e..2505ced4 100644 --- a/packages/docs/content/docs/providers.mdx +++ b/packages/docs/content/docs/providers.mdx @@ -57,6 +57,6 @@ Provider sign-ins are stored by OpenCode, not OpenChamber, so they're shared wit ## Related -- [Integrations](/integrations/) — use a Claude, Command Code, or Cursor subscription as a provider +- [Integrations](/integrations/) — use a Claude or Cursor subscription as a provider - [MCP Servers](/mcp/) — add extra tools for agents - [Usage & Quotas](/usage/) — track how much you've used diff --git a/packages/docs/content/docs/pt-br/integrations.mdx b/packages/docs/content/docs/pt-br/integrations.mdx index 1fd915d3..82ed2e68 100644 --- a/packages/docs/content/docs/pt-br/integrations.mdx +++ b/packages/docs/content/docs/pt-br/integrations.mdx @@ -1,20 +1,17 @@ --- title: Integrações -description: Use sua assinatura Claude, Command Code ou Cursor como provedor. +description: Use sua assinatura Claude ou Cursor como provedor. --- # Integrações Uma integração é um pequeno plugin que adiciona um provedor ao OpenChamber usando uma assinatura que você já tem. Você as gerencia em **Settings → Integrations**. -> **Recurso experimental:** as integrações podem mudar ou deixar de funcionar. Use-as por sua conta e risco. - -Projetamos estas integrações para seguir os fluxos de login pretendidos pelos provedores e evitar violações conhecidas de seus Termos de Serviço. Não podemos garantir que um provedor aceitará todos os usos ou contas. Consulte os termos do provedor e use as integrações por sua conta e risco. O OpenChamber não pode resolver restrições, suspensões de conta ou disputas com um provedor. +> **Recurso experimental.** Buscamos respeitar as políticas dos provedores, mas restrições e suspensões de conta continuam sendo decisão de cada provedor. Use as integrações por sua conta e risco. Integrações disponíveis: - **Claude Code** — seu plano Claude Pro ou Max, sem chaves de API -- **Command Code** — seu plano Command Code - **Cursor** — os limites de modelos do seu plano Cursor ## Instalar uma integração @@ -33,19 +30,10 @@ O Claude Code usa seu plano Claude Pro ou Max — sem chaves de API e sem um app 1. Instale a integração (acima). 2. Escolha **Set up** e faça login. Se você ainda não tem a CLI do Claude Code, a configuração oferece instalá-la primeiro e depois fazer login. -O Claude Code é a única integração aqui que exige que a CLI do provedor esteja instalada e autenticada. Command Code e Cursor não exigem suas CLIs. +O Claude Code é a única integração aqui que exige que a CLI do provedor esteja instalada e autenticada. Cursor não exige sua CLI. **Como sua conta Claude fica protegida:** esta integração usa o Claude Agent SDK oficial da Anthropic e a CLI do Claude Code instalada em sua máquina. Ela não sequestra OAuth, não extrai nem reproduz tokens do navegador, não se passa por um cliente não suportado e não contorna a autenticação da Anthropic. Ela permanece no caminho de acesso suportado pela Anthropic, portanto não traz o risco de banimento de conta associado a sequestro de tokens ou a contornos de autenticação não autorizados. -## Command Code - -O Command Code usa seu plano Command Code. - -1. Instale a integração (acima). -2. Escolha **Set up** — uma página abre no navegador. Autorize o acesso e volte ao OpenChamber. - -Em uma máquina sem interface gráfica ou em CI, defina a variável de ambiente `COMMAND_CODE_API_KEY` em vez de fazer login pelo navegador. - ## Cursor O Cursor torna disponíveis no OpenChamber os modelos incluídos no seu plano Cursor. diff --git a/packages/docs/content/docs/pt-br/providers.mdx b/packages/docs/content/docs/pt-br/providers.mdx index b05dc222..70ece24f 100644 --- a/packages/docs/content/docs/pt-br/providers.mdx +++ b/packages/docs/content/docs/pt-br/providers.mdx @@ -45,6 +45,6 @@ Os logins de provedores são armazenados pelo OpenCode, não pelo OpenChamber, e ## Relacionado -- [Integrações](/pt-br/integrations/) — use uma assinatura Claude, Command Code ou Cursor como provedor +- [Integrações](/pt-br/integrations/) — use uma assinatura Claude ou Cursor como provedor - [Servidores MCP](/pt-br/mcp/) — adicione ferramentas extras para os agentes - [Uso e Cotas](/pt-br/usage/) — acompanhe quanto você já usou diff --git a/packages/docs/content/docs/uk/integrations.mdx b/packages/docs/content/docs/uk/integrations.mdx index 50c620b9..d6d075f4 100644 --- a/packages/docs/content/docs/uk/integrations.mdx +++ b/packages/docs/content/docs/uk/integrations.mdx @@ -1,20 +1,17 @@ --- title: Інтеграції -description: Використовуйте підписки Claude, Command Code або Cursor як провайдерів. +description: Використовуйте підписки Claude або Cursor як провайдерів. --- # Інтеграції Інтеграція — це невеликий плагін, що додає провайдера до OpenChamber на основі підписки, яка в вас уже є. Керувати ними можна в **Settings → Integrations**. -> **Експериментальна функція:** інтеграції можуть змінюватися або перестати працювати. Використовуйте їх на власний розсуд. - -Ми розробили ці інтеграції так, щоб вони використовували передбачені провайдерами способи входу й не порушували відомі нам умови користування. Ми не можемо гарантувати, що провайдер прийме кожен спосіб використання або кожен обліковий запис. Ознайомтеся з умовами провайдера й використовуйте інтеграції на власний ризик. OpenChamber не може вирішувати обмеження, блокування облікових записів або суперечки з провайдером. +> **Експериментальна функція.** Ми прагнемо дотримуватися політик провайдерів, але обмеження та блокування облікових записів залишаються рішенням кожного провайдера. Використовуйте інтеграції на власний ризик. Доступні інтеграції: - **Claude Code** — ваша підписка Claude Pro або Max, без API-ключів -- **Command Code** — ваша підписка Command Code - **Cursor** — ліміти моделей вашої підписки Cursor ## Встановлення інтеграції @@ -33,19 +30,10 @@ Claude Code використовує вашу підписку Claude Pro або 1. Встановіть інтеграцію (вище). 2. Натисніть **Set up** і увійдіть. Якщо у вас ще немає Claude Code CLI, програма встановлення спершу запропонує його встановити, а потім виконає вхід. -Claude Code — єдина інтеграція тут, яка вимагає встановленого та залогіненого CLI свого провайдера. Для Command Code і Cursor їхні CLI не потрібні. +Claude Code — єдина інтеграція тут, яка вимагає встановленого та залогіненого CLI свого провайдера. Для Cursor CLI не потрібен. **Як захищається ваш обліковий запис Claude:** ця інтеграція використовує офіційний Claude Agent SDK від Anthropic і ваш встановлений Claude Code CLI. Вона не перехоплює OAuth, не витягує й не відтворює браузерні токени, не видає себе за непідтримуваний клієнт і не обходить процес автентифікації Anthropic. Усе працює через підтримуваний Anthropic шлях доступу, тож інтеграція не несе ризику блокування облікового запису, пов'язаного з перехопленням токенів або несанкціонованими способами автентифікації. -## Command Code - -Command Code використовує вашу підписку Command Code. - -1. Встановіть інтеграцію (вище). -2. Натисніть **Set up** — відкриється сторінка в браузері. Підтвердьте доступ і поверніться до OpenChamber. - -На сервері без графічного інтерфейсу або в CI замість входу через браузер задайте змінну середовища `COMMAND_CODE_API_KEY`. - ## Cursor Cursor робить доступними в OpenChamber моделі, що входять у вашу підписку Cursor. diff --git a/packages/docs/content/docs/uk/providers.mdx b/packages/docs/content/docs/uk/providers.mdx index 4fb61d65..9f2777e5 100644 --- a/packages/docs/content/docs/uk/providers.mdx +++ b/packages/docs/content/docs/uk/providers.mdx @@ -45,6 +45,6 @@ description: Підключайте AI-провайдерів, обирайте ## Пов'язане -- [Інтеграції](/uk/integrations/) — використовуйте підписки Claude, Command Code або Cursor як провайдерів +- [Інтеграції](/uk/integrations/) — використовуйте підписки Claude або Cursor як провайдерів - [MCP Servers](/uk/mcp/) — додайте агентам додаткові інструменти - [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили diff --git a/packages/docs/content/docs/zh-cn/integrations.mdx b/packages/docs/content/docs/zh-cn/integrations.mdx index a0a081fe..a5686e3c 100644 --- a/packages/docs/content/docs/zh-cn/integrations.mdx +++ b/packages/docs/content/docs/zh-cn/integrations.mdx @@ -1,20 +1,17 @@ --- title: 集成 -description: 将你的 Claude、Command Code 或 Cursor 订阅用作提供商。 +description: 将你的 Claude 或 Cursor 订阅用作提供商。 --- # 集成 集成是一个小型插件,它使用你已有的订阅为 OpenChamber 添加一个提供商。你可以在 **Settings → Integrations** 中管理它们。 -> **实验性功能:**集成可能会变更或停止工作。请自行酌情使用。 - -我们设计这些集成时,力求遵循提供商预期的登录流程,并避免已知的服务条款违规。我们无法保证提供商会接受每种使用方式或每个帐户。请查看提供商的条款,并自行承担使用集成的风险。OpenChamber 无法处理提供商施加的帐户限制、暂停或争议。 +> **实验性功能。**我们力求遵守提供商的政策,但帐户限制和暂停仍由各提供商决定。请自行承担使用集成的风险。 可用的集成: - **Claude Code** — 你的 Claude Pro 或 Max 套餐,无需 API 密钥 -- **Command Code** — 你的 Command Code 套餐 - **Cursor** — 你的 Cursor 套餐的模型额度 ## 安装集成 @@ -33,19 +30,10 @@ Claude Code 使用你的 Claude Pro 或 Max 套餐 — 无需 API 密钥,也 1. 安装集成(见上文)。 2. 选择 **Set up** 并登录。如果你还没有 Claude Code CLI,安装向导会先提供安装,然后再登录。 -Claude Code 是这里唯一要求安装并登录其提供商 CLI 的集成。Command Code 和 Cursor 不需要它们的 CLI。 +Claude Code 是这里唯一要求安装并登录其提供商 CLI 的集成。Cursor 不需要其 CLI。 **你的 Claude 账户如何受到保护:** 此集成使用 Anthropic 官方的 Claude Agent SDK 和你已安装的 Claude Code CLI。它不会劫持 OAuth,不会提取或重放浏览器令牌,不会冒充不受支持的客户端,也不会绕过 Anthropic 的身份验证。它始终运行在 Anthropic 支持的访问路径上,因此不会带来与令牌劫持或未授权身份验证变通手段相关的封号风险。 -## Command Code - -Command Code 使用你的 Command Code 套餐。 - -1. 安装集成(见上文)。 -2. 选择 **Set up** — 浏览器中会打开一个页面。授权访问,然后返回 OpenChamber。 - -在没有图形界面的服务器或 CI 环境中,请设置环境变量 `COMMAND_CODE_API_KEY` 来代替浏览器登录。 - ## Cursor Cursor 让你的 Cursor 套餐中包含的模型可以在 OpenChamber 中使用。 diff --git a/packages/docs/content/docs/zh-cn/providers.mdx b/packages/docs/content/docs/zh-cn/providers.mdx index 62cf4019..5e81e7d3 100644 --- a/packages/docs/content/docs/zh-cn/providers.mdx +++ b/packages/docs/content/docs/zh-cn/providers.mdx @@ -45,6 +45,6 @@ description: 连接 AI 提供商、选择模型并设置智能体。 ## 相关内容 -- [集成](/zh-cn/integrations/) — 将 Claude、Command Code 或 Cursor 订阅用作提供商 +- [集成](/zh-cn/integrations/) — 将 Claude 或 Cursor 订阅用作提供商 - [MCP Servers](/zh-cn/mcp/) — 为智能体添加额外工具 - [用量与配额](/zh-cn/usage/) — 跟踪你已使用的量 diff --git a/packages/ui/src/components/sections/integrations/ComingSoonMessengersSection.tsx b/packages/ui/src/components/sections/integrations/ComingSoonMessengersSection.tsx deleted file mode 100644 index ab2693ed..00000000 --- a/packages/ui/src/components/sections/integrations/ComingSoonMessengersSection.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react'; -import { Icon } from '@/components/icon/Icon'; -import { SettingsSection } from '@/components/sections/shared/SettingsSection'; -import type { IconName } from '@/components/icon/icons'; -import { useI18n, type I18nKey } from '@/lib/i18n'; -import { cn } from '@/lib/utils'; - -type ComingSoonMessenger = { - id: 'discord' | 'telegram'; - icon: IconName; - brandClassName: string; - nameKey: I18nKey; - descriptionKey: I18nKey; -}; - -const COMING_SOON_MESSENGERS: readonly ComingSoonMessenger[] = [ - { - id: 'discord', - icon: 'discord-fill', - brandClassName: 'text-[#5865F2]', - nameKey: 'settings.integrations.messengers.discord.name', - descriptionKey: 'settings.integrations.messengers.discord.description', - }, - { - id: 'telegram', - icon: 'telegram-fill', - brandClassName: 'text-[#2AABEE]', - nameKey: 'settings.integrations.messengers.telegram.name', - descriptionKey: 'settings.integrations.messengers.telegram.description', - }, -] as const; - -/** - * Non-interactive Discord/Telegram placeholders — same card chrome as live - * integrations, greyed out, with a Coming soon badge and no expandable body. - */ -export const ComingSoonMessengersSection: React.FC = () => { - const { t } = useI18n(); - - return ( - - {COMING_SOON_MESSENGERS.map((messenger) => ( -
-
- -
-
-
{t(messenger.nameKey)}
-

- {t(messenger.descriptionKey)} -

-
- - {t('settings.common.state.comingSoon')} - -
- ))} -
- ); -}; diff --git a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx index 047755d0..666c0801 100644 --- a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx +++ b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; +import { SETTINGS_DESCRIPTION_CLASS } from '@/components/sections/shared/SettingsSection'; import { useI18n } from '@/lib/i18n'; import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection'; @@ -18,15 +19,19 @@ export const IntegrationsPage: React.FC = ({ return ( +

{t('settings.page.integrations.description')}

+
+ +

+ {t('settings.integrations.experimentalWarning')} +

+
+
+ )} showSaveStatus={false} > -
- -

- {t('settings.integrations.experimentalWarning')} -

-
- {plugin.providerId === 'command-code' ? ( - - ) : ( - - )} +
{t(plugin.nameKey)}
diff --git a/packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts b/packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts index 35dc0846..83a9dd65 100644 --- a/packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts +++ b/packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts @@ -131,11 +131,6 @@ describe('third-party plugin catalog helpers', () => { packageName: '@openchamber/opencode-claude', homepage: 'https://github.com/openchamber/opencode-claude', }, - { - id: 'opencode-commandcode', - packageName: '@openchamber/opencode-commandcode', - homepage: 'https://github.com/openchamber/opencode-commandcode', - }, { id: 'opencode-cursor-oauth', packageName: '@openchamber/opencode-cursor', diff --git a/packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts b/packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts index ecbf1bba..5b3cb4f5 100644 --- a/packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts +++ b/packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts @@ -25,16 +25,6 @@ export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [ descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description', homepage: 'https://github.com/openchamber/opencode-claude', }, - { - id: 'opencode-commandcode', - packageName: '@openchamber/opencode-commandcode', - providerId: 'command-code', - icon: 'command-code', - brandClassName: 'text-foreground', - nameKey: 'settings.integrations.thirdParty.opencodeCommandcode.name', - descriptionKey: 'settings.integrations.thirdParty.opencodeCommandcode.description', - homepage: 'https://github.com/openchamber/opencode-commandcode', - }, { id: 'opencode-cursor-oauth', packageName: '@openchamber/opencode-cursor', diff --git a/packages/ui/src/components/ui/providerLogoFallback.test.ts b/packages/ui/src/components/ui/providerLogoFallback.test.ts index c82b9ad3..a95e41e0 100644 --- a/packages/ui/src/components/ui/providerLogoFallback.test.ts +++ b/packages/ui/src/components/ui/providerLogoFallback.test.ts @@ -2,8 +2,10 @@ import { describe, expect, test } from 'bun:test'; import { getProviderLogoFallbackIcon } from './providerLogoFallback'; describe('provider logo fallbacks', () => { - test('uses a local terminal icon when Command Code has no resolved logo', () => { - expect(getProviderLogoFallbackIcon('command-code')).toBe('terminal-box'); + test('uses a local terminal icon for Command Code provider ID variants', () => { + for (const providerId of ['command-code', 'commandcode', 'command_code', 'command code']) { + expect(getProviderLogoFallbackIcon(providerId)).toBe('terminal-box'); + } }); test('does not replace providers with their own logo assets', () => { diff --git a/packages/ui/src/components/ui/providerLogoFallback.ts b/packages/ui/src/components/ui/providerLogoFallback.ts index 9aa871fd..68a40c28 100644 --- a/packages/ui/src/components/ui/providerLogoFallback.ts +++ b/packages/ui/src/components/ui/providerLogoFallback.ts @@ -1,5 +1,9 @@ import type { IconName } from '@/components/icon/icons'; +const COMMAND_CODE_PROVIDER_IDS = new Set(['command-code', 'commandcode', 'command_code', 'command code']); + export function getProviderLogoFallbackIcon(providerId: string | null | undefined): IconName | null { - return providerId?.trim().toLowerCase() === 'command-code' ? 'terminal-box' : null; + return providerId && COMMAND_CODE_PROVIDER_IDS.has(providerId.trim().toLowerCase()) + ? 'terminal-box' + : null; } diff --git a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts index 01cd2750..d854e2bf 100644 --- a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts +++ b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts @@ -7,9 +7,6 @@ const requiredKeys = [ 'settings.page.integrations.title', 'settings.page.integrations.description', 'settings.integrations.experimentalWarning', - 'settings.integrations.messengers.title', - 'settings.integrations.messengers.discord.name', - 'settings.integrations.messengers.telegram.name', 'settings.integrations.thirdParty.title', 'settings.integrations.thirdParty.actions.install', 'settings.integrations.thirdParty.actions.update', @@ -17,7 +14,6 @@ const requiredKeys = [ 'settings.integrations.thirdParty.actions.remove', 'settings.integrations.thirdParty.status.notInstalled', 'settings.integrations.thirdParty.opencodeClaude.description', - 'settings.integrations.thirdParty.opencodeCommandcode.description', 'settings.integrations.thirdParty.opencodeCursorOauth.description', ] as const; diff --git a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts index 38b64549..cebfc0b1 100644 --- a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts +++ b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts @@ -3,13 +3,7 @@ export const thirdPartyIntegrationI18n = { en: { 'settings.page.integrations.title': 'Integrations', 'settings.page.integrations.description': 'Add third-party subscriptions to use as OpenChamber providers.', - 'settings.integrations.experimentalWarning': 'This is an experimental feature. Integrations may change or stop working. Use them at your own discretion.', - 'settings.integrations.messengers.title': 'Messengers', - 'settings.integrations.messengers.info': 'Chat with OpenChamber from Discord or Telegram. These bridges are not available yet.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Connect a Discord bot to chat with OpenChamber.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Connect a Telegram bot to chat with OpenChamber.', + 'settings.integrations.experimentalWarning': 'Experimental feature. We aim to respect provider policies, but account restrictions and suspensions remain each provider\'s decision. Use integrations at your own risk.', 'settings.integrations.thirdParty.title': 'Third-party integrations', 'settings.integrations.thirdParty.info': 'Install a provider plugin, then set up your subscription so OpenChamber can use it.', 'settings.integrations.thirdParty.actions.install': 'Install', @@ -38,21 +32,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': 'Restart OpenCode for changes to take effect', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Use your Claude Pro/Max plan — no API keys, no Claude apps.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': '$1 Go Plan: unlimited Laguna S 2.1 + $40 DeepSeek V4 Pro. Sign in, no CLI.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor’s generous in-house model limits, now in OpenChamber.', }, de: { 'settings.page.integrations.title': 'Integrationen', 'settings.page.integrations.description': 'Füge Drittanbieter-Abonnements hinzu, um sie als OpenChamber-Provider zu nutzen.', - 'settings.integrations.experimentalWarning': 'Dies ist eine experimentelle Funktion. Integrationen können sich ändern oder nicht mehr funktionieren. Nutze sie nach eigenem Ermessen.', - 'settings.integrations.messengers.title': 'Messenger', - 'settings.integrations.messengers.info': 'Chatte mit OpenChamber über Discord oder Telegram. Diese Bridges sind noch nicht verfügbar.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Verbinde einen Discord-Bot, um mit OpenChamber zu chatten.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Verbinde einen Telegram-Bot, um mit OpenChamber zu chatten.', + 'settings.integrations.experimentalWarning': 'Experimentelle Funktion. Wir bemühen uns, die Richtlinien der Anbieter zu respektieren, aber Kontobeschränkungen und Sperrungen liegen bei jedem Anbieter. Nutze Integrationen auf eigenes Risiko.', 'settings.integrations.thirdParty.title': 'Drittanbieter-Integrationen', 'settings.integrations.thirdParty.info': 'Installiere ein Provider-Plugin und richte dein Abonnement ein, damit OpenChamber es nutzen kann.', 'settings.integrations.thirdParty.actions.install': 'Installieren', @@ -81,21 +67,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': 'Starte OpenCode neu, damit die Änderungen wirksam werden', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Nutze deinen Claude-Pro/Max-Plan — ohne API-Keys, ohne Claude-Apps.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go-Plan für 1 $: unbegrenztes Laguna S 2.1 + 40 $ DeepSeek V4 Pro. Anmelden, kein CLI.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Die großzügigen Limits der Cursor-eigenen Modelle jetzt in OpenChamber.', }, fr: { 'settings.page.integrations.title': 'Intégrations', 'settings.page.integrations.description': 'Ajoutez des abonnements tiers à utiliser comme fournisseurs OpenChamber.', - 'settings.integrations.experimentalWarning': 'Cette fonctionnalité est expérimentale. Les intégrations peuvent changer ou cesser de fonctionner. Utilisez-les à votre discrétion.', - 'settings.integrations.messengers.title': 'Messagers', - 'settings.integrations.messengers.info': 'Discutez avec OpenChamber depuis Discord ou Telegram. Ces ponts ne sont pas encore disponibles.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Connectez un bot Discord pour discuter avec OpenChamber.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Connectez un bot Telegram pour discuter avec OpenChamber.', + 'settings.integrations.experimentalWarning': 'Fonctionnalité expérimentale. Nous cherchons à respecter les règles des fournisseurs, mais les restrictions et suspensions de compte relèvent de leur décision. Utilisez les intégrations à vos risques.', 'settings.integrations.thirdParty.title': 'Intégrations tierces', 'settings.integrations.thirdParty.info': 'Installez un plugin de fournisseur, puis configurez votre abonnement pour qu’OpenChamber puisse l’utiliser.', 'settings.integrations.thirdParty.actions.install': 'Installer', @@ -124,21 +102,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': 'Redémarrez OpenCode pour que les modifications prennent effet', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Utilisez votre forfait Claude Pro/Max — sans clés API, sans apps Claude.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan à 1 $ : Laguna S 2.1 illimité + 40 $ DeepSeek V4 Pro. Connectez-vous, sans CLI.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Les généreuses limites des modèles internes Cursor, désormais dans OpenChamber.', }, es: { 'settings.page.integrations.title': 'Integraciones', 'settings.page.integrations.description': 'Añade suscripciones de terceros para usarlas como proveedores de OpenChamber.', - 'settings.integrations.experimentalWarning': 'Esta función es experimental. Las integraciones pueden cambiar o dejar de funcionar. Úsalas bajo tu propia responsabilidad.', - 'settings.integrations.messengers.title': 'Mensajeros', - 'settings.integrations.messengers.info': 'Chatea con OpenChamber desde Discord o Telegram. Estos puentes aún no están disponibles.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Conecta un bot de Discord para chatear con OpenChamber.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Conecta un bot de Telegram para chatear con OpenChamber.', + 'settings.integrations.experimentalWarning': 'Función experimental. Buscamos respetar las políticas de los proveedores, pero las restricciones y suspensiones de cuentas son decisión de cada proveedor. Usa las integraciones bajo tu propia responsabilidad.', 'settings.integrations.thirdParty.title': 'Integraciones de terceros', 'settings.integrations.thirdParty.info': 'Instala un plugin de proveedor y configura tu suscripción para que OpenChamber pueda usarla.', 'settings.integrations.thirdParty.actions.install': 'Instalar', @@ -167,21 +137,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': 'Reinicia OpenCode para que los cambios surtan efecto', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Usa tu plan Claude Pro/Max: sin claves API ni apps de Claude.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan por 1 $: Laguna S 2.1 ilimitado + 40 $ de DeepSeek V4 Pro. Entra, sin CLI.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Los generosos límites de los modelos internos de Cursor, ahora en OpenChamber.', }, ja: { 'settings.page.integrations.title': '連携', 'settings.page.integrations.description': 'サードパーティのサブスクリプションを追加して、OpenChamber のプロバイダーとして使います。', - 'settings.integrations.experimentalWarning': 'これは実験的な機能です。連携は変更されたり、動作しなくなったりする可能性があります。自己責任で使用してください。', - 'settings.integrations.messengers.title': 'メッセンジャー', - 'settings.integrations.messengers.info': 'Discord または Telegram から OpenChamber とチャットできます。これらの連携はまだ利用できません。', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Discord ボットを接続して OpenChamber とチャットします。', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Telegram ボットを接続して OpenChamber とチャットします。', + 'settings.integrations.experimentalWarning': '実験的な機能です。プロバイダーの方針を尊重するよう努めていますが、アカウントの制限や停止は各プロバイダーの判断に委ねられます。自己責任で連携を使用してください。', 'settings.integrations.thirdParty.title': 'サードパーティー連携', 'settings.integrations.thirdParty.info': 'プロバイダープラグインをインストールし、サブスクリプションを設定して OpenChamber で使えるようにします。', 'settings.integrations.thirdParty.actions.install': 'インストール', @@ -210,21 +172,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': '変更を反映するには OpenCode を再起動してください', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro/Max プランを利用 — API キーも Claude アプリも不要。', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': '1ドルの Go Plan:Laguna S 2.1 無制限 + DeepSeek V4 Pro 40ドル分。ログインするだけで CLI 不要。', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 内蔵モデルの余裕ある制限が、OpenChamber で使えます。', }, ko: { 'settings.page.integrations.title': '통합', 'settings.page.integrations.description': '타사 구독을 추가해 OpenChamber 프로바이더로 사용하세요.', - 'settings.integrations.experimentalWarning': '이 기능은 실험 단계입니다. 통합 기능은 변경되거나 작동하지 않을 수 있습니다. 본인의 판단에 따라 사용하세요.', - 'settings.integrations.messengers.title': '메신저', - 'settings.integrations.messengers.info': 'Discord 또는 Telegram에서 OpenChamber와 채팅하세요. 이 브리지는 아직 사용할 수 없습니다.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Discord 봇을 연결해 OpenChamber와 채팅하세요.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Telegram 봇을 연결해 OpenChamber와 채팅하세요.', + 'settings.integrations.experimentalWarning': '실험 단계 기능입니다. 프로바이더 정책을 존중하려 노력하지만, 계정 제한과 정지는 각 프로바이더의 결정입니다. 본인의 책임 아래 통합 기능을 사용하세요.', 'settings.integrations.thirdParty.title': '서드파티 통합', 'settings.integrations.thirdParty.info': '프로바이더 플러그인을 설치한 뒤 구독을 설정하면 OpenChamber에서 사용할 수 있습니다.', 'settings.integrations.thirdParty.actions.install': '설치', @@ -253,21 +207,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': '변경 사항을 적용하려면 OpenCode를 다시 시작하세요', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro/Max 요금제를 사용하세요. API 키와 Claude 앱은 필요 없습니다.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': '1달러 Go Plan: Laguna S 2.1 무제한 + DeepSeek V4 Pro 40달러. 로그인만 하면 되고 CLI는 필요 없습니다.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 자체 모델의 넉넉한 한도를 이제 OpenChamber에서.', }, pl: { 'settings.page.integrations.title': 'Integracje', 'settings.page.integrations.description': 'Dodaj subskrypcje zewnętrzne, aby używać ich jako dostawców OpenChamber.', - 'settings.integrations.experimentalWarning': 'To funkcja eksperymentalna. Integracje mogą się zmienić lub przestać działać. Korzystasz z nich na własną odpowiedzialność.', - 'settings.integrations.messengers.title': 'Komunikatory', - 'settings.integrations.messengers.info': 'Czatuj z OpenChamber przez Discord lub Telegram. Te mosty nie są jeszcze dostępne.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Połącz bota Discord, aby czatować z OpenChamber.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Połącz bota Telegram, aby czatować z OpenChamber.', + 'settings.integrations.experimentalWarning': 'Funkcja eksperymentalna. Staramy się przestrzegać zasad dostawców, ale ograniczenia i zawieszenia kont pozostają decyzją każdego dostawcy. Używaj integracji na własne ryzyko.', 'settings.integrations.thirdParty.title': 'Integracje zewnętrzne', 'settings.integrations.thirdParty.info': 'Zainstaluj wtyczkę dostawcy, a następnie skonfiguruj subskrypcję, aby OpenChamber mógł z niej korzystać.', 'settings.integrations.thirdParty.actions.install': 'Zainstaluj', @@ -296,21 +242,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': 'Uruchom ponownie OpenCode, aby zastosować zmiany', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Korzystaj z planu Claude Pro/Max — bez kluczy API i aplikacji Claude.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan za 1 $: nielimitowane Laguna S 2.1 + 40 $ DeepSeek V4 Pro. Zaloguj się, bez CLI.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Hojne limity wewnętrznych modeli Cursor teraz w OpenChamber.', }, 'pt-BR': { 'settings.page.integrations.title': 'Integrações', 'settings.page.integrations.description': 'Adicione assinaturas de terceiros para usar como provedores do OpenChamber.', - 'settings.integrations.experimentalWarning': 'Este recurso é experimental. As integrações podem mudar ou deixar de funcionar. Use-as por sua conta e risco.', - 'settings.integrations.messengers.title': 'Mensageiros', - 'settings.integrations.messengers.info': 'Converse com o OpenChamber pelo Discord ou Telegram. Essas pontes ainda não estão disponíveis.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Conecte um bot do Discord para conversar com o OpenChamber.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Conecte um bot do Telegram para conversar com o OpenChamber.', + 'settings.integrations.experimentalWarning': 'Recurso experimental. Buscamos respeitar as políticas dos provedores, mas restrições e suspensões de conta continuam sendo decisão de cada provedor. Use as integrações por sua conta e risco.', 'settings.integrations.thirdParty.title': 'Integrações de terceiros', 'settings.integrations.thirdParty.info': 'Instale um plugin de provedor e configure sua assinatura para o OpenChamber poder usá-la.', 'settings.integrations.thirdParty.actions.install': 'Instalar', @@ -339,21 +277,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': 'Reinicie o OpenCode para que as alterações entrem em vigor', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Use seu plano Claude Pro/Max — sem chaves de API nem apps da Claude.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan por US$ 1: Laguna S 2.1 ilimitado + US$ 40 de DeepSeek V4 Pro. Entre, sem CLI.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Os limites generosos dos modelos internos do Cursor, agora no OpenChamber.', }, uk: { 'settings.page.integrations.title': 'Інтеграції', 'settings.page.integrations.description': 'Додайте сторонні підписки, щоб використовувати їх як провайдери OpenChamber.', - 'settings.integrations.experimentalWarning': 'Це експериментальна функція. Інтеграції можуть змінюватися або перестати працювати. Використовуйте їх на власний розсуд.', - 'settings.integrations.messengers.title': 'Месенджери', - 'settings.integrations.messengers.info': 'Спілкуйтеся з OpenChamber у Discord або Telegram. Ці мости ще недоступні.', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': 'Підключіть бота Discord, щоб спілкуватися з OpenChamber.', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': 'Підключіть бота Telegram, щоб спілкуватися з OpenChamber.', + 'settings.integrations.experimentalWarning': 'Експериментальна функція. Ми прагнемо дотримуватися політик провайдерів, але обмеження та блокування облікових записів залишаються рішенням кожного провайдера. Використовуйте інтеграції на власний ризик.', 'settings.integrations.thirdParty.title': 'Сторонні інтеграції', 'settings.integrations.thirdParty.info': 'Установіть плагін провайдера, а потім налаштуйте підписку, щоб OpenChamber міг її використовувати.', 'settings.integrations.thirdParty.actions.install': 'Встановити', @@ -382,21 +312,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': 'Перезапустіть OpenCode, щоб застосувати зміни', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro/Max за підпискою — без API-ключів і без додатків Claude.', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan за $1: безліміт Laguna S 2.1 і $40 на DeepSeek V4 Pro. Вхід без CLI.', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Щедрі ліміти внутрішніх моделей Cursor — тепер в OpenChamber.', }, 'zh-CN': { 'settings.page.integrations.title': '集成', 'settings.page.integrations.description': '添加第三方订阅,将其用作 OpenChamber 提供商。', - 'settings.integrations.experimentalWarning': '这是实验性功能。集成可能会变更或停止工作。请自行酌情使用。', - 'settings.integrations.messengers.title': '即时通讯', - 'settings.integrations.messengers.info': '通过 Discord 或 Telegram 与 OpenChamber 聊天。这些桥接尚不可用。', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': '连接 Discord 机器人以与 OpenChamber 聊天。', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': '连接 Telegram 机器人以与 OpenChamber 聊天。', + 'settings.integrations.experimentalWarning': '实验性功能。我们力求遵守提供商的政策,但帐户限制和暂停仍由各提供商决定。请自行承担使用集成的风险。', 'settings.integrations.thirdParty.title': '第三方集成', 'settings.integrations.thirdParty.info': '安装提供商插件并设置订阅,以便 OpenChamber 可以使用它。', 'settings.integrations.thirdParty.actions.install': '安装', @@ -425,21 +347,13 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': '请重启 OpenCode 以使更改生效', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': '使用 Claude Pro/Max 套餐——无需 API 密钥,也无需 Claude 应用。', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': '1 美元 Go Plan:无限 Laguna S 2.1,另含 40 美元 DeepSeek V4 Pro。登录即可,无需 CLI。', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 内部模型的充足额度,现已可用于 OpenChamber。', }, 'zh-TW': { 'settings.page.integrations.title': '整合', 'settings.page.integrations.description': '新增第三方訂閱,將其用作 OpenChamber 供應商。', - 'settings.integrations.experimentalWarning': '這是實驗性功能。整合可能會變更或停止運作。請自行斟酌使用。', - 'settings.integrations.messengers.title': '即時通訊', - 'settings.integrations.messengers.info': '透過 Discord 或 Telegram 與 OpenChamber 聊天。這些橋接尚不可用。', - 'settings.integrations.messengers.discord.name': 'Discord', - 'settings.integrations.messengers.discord.description': '連接 Discord 機器人以與 OpenChamber 聊天。', - 'settings.integrations.messengers.telegram.name': 'Telegram', - 'settings.integrations.messengers.telegram.description': '連接 Telegram 機器人以與 OpenChamber 聊天。', + 'settings.integrations.experimentalWarning': '實驗性功能。我們致力遵守供應商的政策,但帳戶限制和停用仍由各供應商決定。請自行承擔使用整合的風險。', 'settings.integrations.thirdParty.title': '第三方整合', 'settings.integrations.thirdParty.info': '安裝供應商外掛並設定訂閱,以便 OpenChamber 可以使用它。', 'settings.integrations.thirdParty.actions.install': '安裝', @@ -468,8 +382,6 @@ export const thirdPartyIntegrationI18n = { 'settings.integrations.thirdParty.toast.restartRequired': '請重新啟動 OpenCode 以使變更生效', 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', 'settings.integrations.thirdParty.opencodeClaude.description': '使用 Claude Pro/Max 方案——無需 API 金鑰,也無需 Claude 應用程式。', - 'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code', - 'settings.integrations.thirdParty.opencodeCommandcode.description': '1 美元 Go Plan:無限 Laguna S 2.1,另含 40 美元 DeepSeek V4 Pro。登入即可,無需 CLI。', 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 內部模型的充足額度,現已可用於 OpenChamber。', }, diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index a9a7b264..c7fb00d4 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -958,13 +958,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description', keywords: ['claude', 'anthropic', 'claude code', 'pro', 'max', 'agent sdk', '@openchamber/opencode-claude'], }, - { - id: 'integrations.third-party.opencode-commandcode', - page: 'integrations', - titleKey: 'settings.integrations.thirdParty.opencodeCommandcode.name', - descriptionKey: 'settings.integrations.thirdParty.opencodeCommandcode.description', - keywords: ['command code', 'commandcode', 'laguna', 'poolside', 'gateway', '@openchamber/opencode-commandcode'], - }, { id: 'integrations.third-party.opencode-cursor-oauth', page: 'integrations', diff --git a/packages/web/server/lib/quota/providers/command-code.js b/packages/web/server/lib/quota/providers/command-code.js index c8881435..4c848245 100644 --- a/packages/web/server/lib/quota/providers/command-code.js +++ b/packages/web/server/lib/quota/providers/command-code.js @@ -3,7 +3,7 @@ import { asObject, buildResult, getAuthEntry, normalizeAuthEntry, toNumber, toUs export const providerId = 'command-code'; export const providerName = 'Command Code'; -export const aliases = ['command-code']; +export const aliases = ['command-code', 'commandcode', 'command_code', 'command code']; const API_BASE_URL = 'https://api.commandcode.ai'; diff --git a/packages/web/server/lib/quota/providers/command-code.test.js b/packages/web/server/lib/quota/providers/command-code.test.js index 4a6285c8..e2dc1dde 100644 --- a/packages/web/server/lib/quota/providers/command-code.test.js +++ b/packages/web/server/lib/quota/providers/command-code.test.js @@ -69,4 +69,17 @@ describe('Command Code quota provider', () => { expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer test-token'); vi.unstubAllGlobals(); }); + + it('recognizes Command Code auth entries under supported provider ID variants', async () => { + for (const providerId of ['commandcode', 'command_code', 'command code']) { + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ org: { id: 'org-1' } }))) + .mockResolvedValueOnce(new Response(JSON.stringify(creditsPayload))); + vi.stubGlobal('fetch', fetchMock); + + const result = await fetchQuota({ [providerId]: { type: 'oauth', access: 'test-token' } }); + expect(result).toMatchObject({ providerId: 'command-code', ok: true, configured: true }); + vi.unstubAllGlobals(); + } + }); }); diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index 1ce58558..1f97d159 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -160,6 +160,13 @@ const registry = { const pendingFetches = new Map(); +const normalizeQuotaProviderId = (providerId) => { + if (typeof providerId !== 'string') return providerId; + return ['command-code', 'commandcode', 'command_code', 'command code'].includes(providerId.trim().toLowerCase()) + ? 'command-code' + : providerId; +}; + export const listConfiguredQuotaProviders = () => { const configured = []; @@ -203,13 +210,14 @@ const fetchQuotaForProviderUncoalesced = async (providerId) => { }; export const fetchQuotaForProvider = (providerId) => { - const existing = pendingFetches.get(providerId); + const normalizedProviderId = normalizeQuotaProviderId(providerId); + const existing = pendingFetches.get(normalizedProviderId); if (existing) return existing; - const pending = fetchQuotaForProviderUncoalesced(providerId).finally(() => { - if (pendingFetches.get(providerId) === pending) pendingFetches.delete(providerId); + const pending = fetchQuotaForProviderUncoalesced(normalizedProviderId).finally(() => { + if (pendingFetches.get(normalizedProviderId) === pending) pendingFetches.delete(normalizedProviderId); }); - pendingFetches.set(providerId, pending); + pendingFetches.set(normalizedProviderId, pending); return pending; }; From 35998f9f4dc72e9a0634577f042db702103ad6af Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 00:30:53 +0300 Subject: [PATCH 22/59] fix(sidebar): indent sessions inside folders --- packages/ui/src/components/session/SessionFolderItem.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/session/SessionFolderItem.tsx b/packages/ui/src/components/session/SessionFolderItem.tsx index 58992963..21f5078c 100644 --- a/packages/ui/src/components/session/SessionFolderItem.tsx +++ b/packages/ui/src/components/session/SessionFolderItem.tsx @@ -346,9 +346,11 @@ const SessionFolderItemBase = ({ {subFolderItems} {/* Then sessions */} {sessions.length > 0 ? ( - sessions.map((node) => - renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket, undefined, 'project', getRenderExtras?.(node)), - ) +
+ {sessions.map((node) => + renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket, undefined, 'project', getRenderExtras?.(node)), + )} +
) : !subFolderItems ? (
{t('sessions.sidebar.folderItem.emptyFolder')} From 23928d342ce14c3e25b6b65c5109b4a1556a124a Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 00:36:25 +0300 Subject: [PATCH 23/59] feat(dictation): transcribe after recording instead of live Parakeet is an offline model trained on whole utterances, so re-decoding the growing buffer to animate a live transcript cost O(n^2) work for a result the final decode replaced. Sessions now decode once per committed segment, and the composer shows a scrolling waveform of the mic level instead of running text. Long dictations split at a pause once past 60s (hard cap 90s) instead of on a blind 15s timer, so cuts no longer land mid-word. Committed segments decode while the user is still speaking: a 185s dictation returns 4.1s after stop instead of 11.0s, with identical text (816 vs 817 words). Also fixes two ways the stream manager could silently drop transcribed audio. It now counts the commits it issued instead of trusting the session's echoed events, so a commit still in flight when the client finishes can no longer be left out of the final text. And segment byte/peak accounting is reset where the commit is issued rather than when the event arrives, which could mistake the tail of a dictation for silence and clear it. --- CHANGELOG.md | 1 + .../dictation/ComposerDictation.tsx | 34 ++--- .../dictation/DictationWaveform.tsx | 130 ++++++++++++++++++ packages/ui/src/hooks/useDictation.ts | 23 ++-- .../dictation/use-dictation-audio-source.ts | 38 +++-- .../web/server/lib/dictation/DOCUMENTATION.md | 40 ++++-- .../lib/dictation/local/sherpa-recognizer.js | 113 +++++---------- .../lib/dictation/local/worker-process.js | 4 +- .../dictation/openai-compatible-session.js | 5 +- .../server/lib/dictation/stream-manager.js | 111 ++++++++++----- .../lib/dictation/stream-manager.test.js | 62 ++++++++- 11 files changed, 393 insertions(+), 168 deletions(-) create mode 100644 packages/ui/src/components/dictation/DictationWaveform.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 65659752..03cf8954 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Dictation:** speech is now transcribed after you stop talking, instead of being re-guessed word by word while you speak. The offline models OpenChamber runs are built to read a whole utterance at once, so the running transcript was consistently worse than the final one. While recording, the composer shows a live waveform of your voice and a timer, then Transcribing while the text is produced. Long recordings are split at pauses in your speech rather than on a timer, so a three-minute dictation still returns a few seconds after you stop, and words are no longer cut in half at the split. - Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). ## [1.19.0] - 2026-08-19 diff --git a/packages/ui/src/components/dictation/ComposerDictation.tsx b/packages/ui/src/components/dictation/ComposerDictation.tsx index c458e5d1..c66bc804 100644 --- a/packages/ui/src/components/dictation/ComposerDictation.tsx +++ b/packages/ui/src/components/dictation/ComposerDictation.tsx @@ -5,6 +5,10 @@ * area uses the same paddings/typography as the textarea and the action row * reuses the footer icon-button styling — so toggling dictation causes no * vertical shift. + * + * No text appears while recording. The server transcribes the audio once the + * user stops, so the overlay shows the recording state and then Transcribing. + * The only transcript rendered here is the salvage text of a failed dictation. */ import React from 'react'; @@ -15,6 +19,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { cn } from '@/lib/utils'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { useDictation } from '@/hooks/useDictation'; +import { DictationWaveform } from '@/components/dictation/DictationWaveform'; import { isDictationCaptureSupported } from '@/lib/dictation/use-dictation-audio-source'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -50,25 +55,6 @@ const formatDuration = (seconds: number): string => { return `${mins}:${String(secs).padStart(2, '0')}`; }; -const VolumeMeter: React.FC<{ volume: number }> = ({ volume }) => { - const { currentTheme } = useThemeSystem(); - return ( - diff --git a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md index 0d9b9712..d2a872c6 100644 --- a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md @@ -54,6 +54,11 @@ mode. It remains available on a new-session draft: when the draft targets a project or pending worktree, the panel uses that directory for project, MCP, and usage readouts before a session exists. +Managed Chats never render or warm the Project repository section. A Chat draft +also passes no fallback directory to the panel, so an active project's branch +cannot leak into the draft while directory-independent sections remain +available. + `rowRef` is a **callback ref, not an object ref**. An object ref gives no signal when the node attaches, so the measuring effect read `.current`, found nothing whenever the row mounted after the effect first ran, and only recovered on the diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx index 3f769d58..af097a23 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx @@ -26,6 +26,8 @@ type Props = { /** Null on a new-session draft: repository readouts still apply. */ sessionId: string | null; directory: string | null; + /** Managed Chats have no project repository, even if another project remains active. */ + repositoryEnabled?: boolean; /** Whether the panel should currently occupy space. */ visible: boolean; /** @@ -63,7 +65,7 @@ const PANEL_TRANSITION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; * eat a visible slice of every row's trailing value, and the shadows already * say there is more to see. */ -export const WorkStatusPanel: React.FC = ({ sessionId, directory, visible, overlay = false }) => { +export const WorkStatusPanel: React.FC = ({ sessionId, directory, visible, repositoryEnabled = true, overlay = false }) => { const { t } = useI18n(); const setScrollTop = useUIStore((state) => state.setWorkStatusScrollTop); const setOverlayOpen = useUIStore((state) => state.setWorkStatusOverlayOpen); @@ -248,7 +250,7 @@ export const WorkStatusPanel: React.FC = ({ sessionId, directory, visible sessionId={sessionId} directory={directory} showSession={sectionVisible('session')} - showRepository={sectionVisible('repository')} + showRepository={repositoryEnabled && sectionVisible('repository')} goalRow={} /> {sectionVisible('usage') ? : null} From 4078deb90a2573452342e4efea497420e1c288a8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 22 Aug 2026 01:36:12 +0300 Subject: [PATCH 27/59] fix(desktop): align Windows close button chrome --- packages/ui/src/components/desktop/WindowsWindowControls.tsx | 5 ++++- packages/ui/src/components/layout/Header.tsx | 3 ++- packages/ui/src/components/mini-chat/MiniChatLayout.tsx | 3 ++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/desktop/WindowsWindowControls.tsx b/packages/ui/src/components/desktop/WindowsWindowControls.tsx index 8c8e88c5..4d257e30 100644 --- a/packages/ui/src/components/desktop/WindowsWindowControls.tsx +++ b/packages/ui/src/components/desktop/WindowsWindowControls.tsx @@ -205,7 +205,10 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({ @@ -1782,50 +1948,71 @@ export const RemoteInstancesPage: React.FC = () => { contentClassName="space-y-2.5" > {isLoading ? ( -

{t('settings.remoteInstances.page.import.loading')}

+

{t('settings.remoteInstances.page.state.loadingInstances')}

) : instances.length === 0 ? ( -

{t('settings.remoteInstances.page.import.noneFound')}

+

+ {importCandidates.length === 1 + ? t('settings.remoteInstances.page.empty.noInstancesWithOneImport') + : importCandidates.length > 1 + ? t('settings.remoteInstances.page.empty.noInstancesWithImports', { count: importCandidates.length }) + : t('settings.remoteInstances.page.empty.noInstances')} +

) : instances.map((instance) => { const instanceStatus = statusesById[instance.id]; const title = instance.nickname?.trim() || instance.sshParsed?.destination || instance.id; const phase = instanceStatus?.phase; const ready = phase === 'ready'; + const state = instanceState(phase); + const failureDetail = state === 'error' ? instanceStatus?.detail : undefined; return ( -
-
-
- -

{title}

+
+
+
+
+ +

{title}

+
+

+ {t(instanceStateLabelKey(state))} + {state === 'connecting' ? ` · ${t(phaseLabelKey(phase))}` : ''} + {ready && instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''} +

+
+
+ {ready ? ( + + ) : null} + + +
-

- {t(phaseLabelKey(phase))}{instanceStatus?.localUrl ? ` · ${instanceStatus.localUrl}` : ''} -

-
-
- - -
+ {failureDetail ? ( +

{failureDetail}

+ ) : null}
); })} @@ -1835,52 +2022,70 @@ export const RemoteInstancesPage: React.FC = () => { {t('settings.remoteInstances.sidebar.actions.addSshInstance')} - {t('settings.remoteInstances.page.section.instanceDescription')} + {t('settings.remoteInstances.page.addDialog.description')} -
{ event.preventDefault(); void createSshInstanceFromDialog(); }}> - setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} /> - setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus /> -
- - + + {sshAddMode === 'saved' ? ( +
+ setSshHostSearch(event.target.value)} + placeholder={t('settings.remoteInstances.page.addDialog.searchPlaceholder')} + autoFocus + /> + {isImportsLoading ? ( +

{t('settings.remoteInstances.page.import.loading')}

+ ) : importCandidates.length === 0 ? ( +

{t('settings.remoteInstances.page.addDialog.emptySaved')}

+ ) : filteredImportCandidates.length === 0 ? ( +

{t('settings.remoteInstances.page.addDialog.searchEmpty')}

+ ) : ( +
+ {filteredImportCandidates.map((candidate) => ( +
+
+
+ {candidate.host} + {candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''} +
+
{candidate.sshCommand}
+
+ +
+ ))} +
+ )}
- + ) : ( +
{ event.preventDefault(); void createSshInstanceFromDialog(); }}> + setSshNameDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} disabled={isSaving} /> + setSshCommandDraft(event.target.value)} placeholder={t('settings.remoteInstances.page.field.sshCommandPlaceholder')} disabled={isSaving} autoFocus /> +
+ + +
+
+ )} : null} - {showInstanceManagement ? - {isImportsLoading ? ( -

{t('settings.remoteInstances.page.import.loading')}

- ) : importCandidates.length === 0 ? ( -

{t('settings.remoteInstances.page.import.noneFound')}

- ) : ( -
- {importCandidates.map((candidate) => ( -
-
-
- {candidate.host} - {candidate.pattern ? ` ${t('settings.remoteInstances.page.import.patternSuffix')}` : ''} -
-
{candidate.sshCommand}
-
- -
- ))} -
- )} -
: null} - { @@ -1925,6 +2130,10 @@ export const RemoteInstancesPage: React.FC = () => { } const isManagedMode = draft.remoteOpenchamber.mode === 'managed'; + // Publishing the remote server to its network turns the UI password from an + // option into the only thing standing in front of it. + const remoteLanExposed = isManagedMode && draft.remoteOpenchamber.bindHost === '0.0.0.0'; + const uiPasswordMissing = remoteLanExposed && !draft.auth.openchamberPassword?.value?.trim(); const instanceTitle = draft.nickname?.trim() || draft.sshParsed?.destination || draft.id; return ( @@ -1934,7 +2143,8 @@ export const RemoteInstancesPage: React.FC = () => {

{instanceTitle}

- {t(phaseLabelKey(statusPhase))} + {t(instanceStateLabelKey(currentState))} + {currentState === 'connecting' ? {t(phaseLabelKey(statusPhase))} : null} {status?.localUrl ? {status.localUrl} : null} {reconnectAppearsStuck ? {t('settings.remoteInstances.page.status.reconnectStale')} : null}
@@ -2005,6 +2215,29 @@ export const RemoteInstancesPage: React.FC = () => { {t('settings.remoteInstances.sidebar.actions.remove')}
+ {currentState === 'error' && status?.detail ? ( +
+

{status.detail}

+ {currentRemedyHintKey ? ( +

{t(currentRemedyHintKey)}

+ ) : null} + {currentRemedy && !currentRemedyHintKey ? ( + + ) : null} +
+ ) : null} {status?.localUrl ? (
{t('settings.remoteInstances.page.status.currentLocalUrl')} @@ -2046,30 +2279,6 @@ export const RemoteInstancesPage: React.FC = () => { placeholder={t('settings.remoteInstances.page.field.nicknamePlaceholder')} />
-
- {t('settings.remoteInstances.page.field.connectionTimeoutSeconds')} - { - updateDraft((current) => ({ - ...current, - connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec, - })); - }} - /> -
- - -
{
+ + + + + {t('settings.remoteInstances.page.section.advanced')} + + + +

{t('settings.remoteInstances.page.section.advancedHint')}

+
+ {t('settings.remoteInstances.page.field.connectionTimeoutSeconds')} + { + updateDraft((current) => ({ + ...current, + connectionTimeoutSec: Number.isFinite(next) ? next : current.connectionTimeoutSec, + })); + }} + /> +
+ +
-
+
{ ...current, remoteOpenchamber: { ...current.remoteOpenchamber, - installMethod: - value === 'npm' || value === 'download_release' || value === 'upload_bundle' - ? value - : 'bun', + installMethod: value === 'npm' || value === 'bun' ? value : 'auto', }, })) } @@ -2162,15 +2400,45 @@ export const RemoteInstancesPage: React.FC = () => { + {t('settings.remoteInstances.page.field.installMethodAuto')} bun npm - {t('settings.remoteInstances.page.field.installMethodDownloadRelease')} - {t('settings.remoteInstances.page.field.installMethodUploadBundle')}
) : null} + {isManagedMode ? ( +
+
+
+ +
+ + updateDraft((current) => ({ + ...current, + remoteOpenchamber: { + ...current.remoteOpenchamber, + bindHost: checked ? '0.0.0.0' : '127.0.0.1', + }, + })) + } + aria-label={t('settings.remoteInstances.page.field.remoteLanAccess')} + /> +
+ {remoteLanExposed ? ( +

+ {t('settings.remoteInstances.page.field.remoteLanAccessWarning')} +

+ ) : null} +
+ ) : null} + {isManagedMode ? (
@@ -2227,13 +2495,13 @@ export const RemoteInstancesPage: React.FC = () => { })); }} > - + - 127.0.0.1 - localhost - 0.0.0.0 + {t('settings.remoteInstances.page.field.bindHostOption.loopback')} + {t('settings.remoteInstances.page.field.bindHostOption.localhost')} + {t('settings.remoteInstances.page.field.bindHostOption.lan')}
@@ -2293,6 +2561,13 @@ export const RemoteInstancesPage: React.FC = () => {
+ +
+

{t('settings.remoteInstances.page.tunnelPreview.caption')}

+

+ {`${draft.localForward.bindHost}:${draft.localForward.preferredLocalPort || 'auto'} → ${draft.sshParsed?.destination || draft.nickname || 'remote'}:${draft.remoteOpenchamber.preferredPort || 'auto'}`} +

+
{ contentClassName="space-y-3" >
- {t('settings.remoteInstances.page.field.sshPasswordOptional')} +
+ +
{
- {t('settings.remoteInstances.page.field.uiPasswordOptional')} +
+ +
updateDraft((current) => ({ @@ -2345,6 +2636,11 @@ export const RemoteInstancesPage: React.FC = () => { placeholder={t('settings.remoteInstances.page.field.uiPasswordPlaceholder')} />
+ {uiPasswordMissing ? ( +

+ {t('settings.remoteInstances.page.field.uiPasswordMissingForLan')} +

+ ) : null}
{ + + +
+ ) : ( + + +

+ {title} +

+
+ )} +
+ {actions} +
+ {children ? ( + <> + {children} +
+ + ) : null} +
+
+); + +const BtwSheet: React.FC<{ + sessionRef: BtwSessionRef; + title: string; + boundaryMessageID: string | null; + collapsed: boolean; +}> = ({ sessionRef, title, boundaryMessageID, collapsed }) => { + const { t } = useI18n(); + const handleDestroy = useBtwDestroy(sessionRef); + const setCollapsed = React.useCallback((next: boolean) => { + useBtwStore.getState().setPanelState(sessionRef.parentSessionId, { collapsed: next }); + }, [sessionRef.parentSessionId]); + const handleToggleCollapsed = React.useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]); + const handleCollapse = React.useCallback(() => setCollapsed(true), [setCollapsed]); + const handlePromote = React.useCallback(() => { + void promoteBtwSession(sessionRef).catch(() => { + toast.error(t('chat.btw.toast.promoteFailed')); + }); + }, [sessionRef, t]); + useEscapeToCollapse(handleCollapse); + + const toggleLabel = collapsed ? t('chat.btw.expandAria') : t('chat.btw.collapseAria'); + const headerButtonClass = 'size-7 rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:!bg-transparent active:!bg-transparent'; + const actions = ( +
+ + +
+ ); + + if (collapsed) { + return ( + + ); + } + + return ( + + ); +}; + +/** + * Collapsed mode: only the header strip stays docked above the composer. The + * fork keeps running in the background; a spinner replaces the header icon + * while it is busy so activity stays visible without the message list. + */ +const BtwCollapsedStrip: React.FC<{ + sessionRef: BtwSessionRef; + title: string; + actions: React.ReactNode; + onExpand: () => void; + expandLabel: string; +}> = ({ sessionRef, title, actions, onExpand, expandLabel }) => { + const status = useSessionStatus(sessionRef.btwSessionId, sessionRef.directory) ?? IDLE_SESSION_STATUS; + const isBusy = status.type === 'busy' || status.type === 'retry'; + return ( + + ); +}; + +const BtwExpandedSheet: React.FC<{ + sessionRef: BtwSessionRef; + title: string; + boundaryMessageID: string | null; + actions: React.ReactNode; + onTitleClick: () => void; + titleClickLabel: string; +}> = ({ sessionRef, title, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => { + const data = useBtwSessionData(sessionRef.btwSessionId, sessionRef.directory, boundaryMessageID); + const bodyRef = React.useRef(null); + const contentRef = React.useRef(null); + const handleBodyScroll = useAutoScroll(bodyRef, contentRef, !data.isEmpty); + // With the on-screen keyboard open the composer (this panel's anchor) + // rises, and a vh-based cap would push the panel under the app header. + // Same protection as the composer autocomplete popups: clamp the scroll + // body to the space actually available above the anchor. The hook measures + // room for the scroll body itself, but the panel header and bottom spacer + // sit inside the same frame above/below it — reserve their height too. + const BTW_FRAME_CHROME_PX = 48; + const availableMaxHeight = useMobileAutocompleteMaxHeight(bodyRef, true, 520 + BTW_FRAME_CHROME_PX); + const mobileMaxHeight = availableMaxHeight !== undefined + ? Math.max(120, availableMaxHeight - BTW_FRAME_CHROME_PX) + : undefined; + + return ( + + + + + + ); +}; + +const BtwMessages: React.FC<{ + data: BtwSessionData; + bodyRef: React.RefObject; + contentRef: React.RefObject; + onBodyScroll: (event: React.UIEvent) => void; + maxHeight?: number; +}> = ({ data, bodyRef, contentRef, onBodyScroll, maxHeight }) => { + const { t } = useI18n(); + + if (data.isEmpty) { + return ( +
+ + {t('chat.btw.loading')} +
+ ); + } + + return ( + +
+ {data.messageRecords.map((record, index) => ( + + ))} + {data.sessionQuestions.length > 0 || data.sessionPermissions.length > 0 ? ( +
+ {data.sessionQuestions.map((question) => ( + + ))} + {data.sessionPermissions.map((permission) => ( + + ))} +
+ ) : null} + {/* Always reserve this row so the content does not shift down + by a line when the indicator disappears. */} +
+ + {t('chat.btw.working')} +
+
+
+ ); +}; diff --git a/packages/ui/src/components/chat/btw/useBtwPanelState.ts b/packages/ui/src/components/chat/btw/useBtwPanelState.ts new file mode 100644 index 00000000..6d36f060 --- /dev/null +++ b/packages/ui/src/components/chat/btw/useBtwPanelState.ts @@ -0,0 +1,54 @@ +import React from 'react'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { useSession } from '@/sync/sync-context'; +import { getBtwBoundaryMessageID, getBtwSessionID } from '@/lib/sessionBtwMetadata'; +import { useBtwStore } from '@/stores/useBtwStore'; + +export type BtwPanelState = { + /** The active fork for this parent, or null when no panel should exist. */ + btwSessionId: string | null; + btwSession: Session | null; + /** The fork's directory identity (may be canonicalized by the server). */ + btwDirectory: string | null; + /** Last message id inherited from the parent; the panel shows what's after it. */ + boundaryMessageID: string | null; + collapsed: boolean; + creating: boolean; +}; + +/** + * Derive the `/btw` panel identity for one parent session from authoritative + * session metadata (`openchamber.btwSessionID`), plus the transient UI state + * kept in `useBtwStore`. The panel exists only while the parent's link AND the + * fork itself are present in the live stores, so a fork deleted anywhere + * (sidebar, another client) makes the panel disappear without extra tracking. + */ +export function useBtwPanelState( + parentSessionId: string | null | undefined, + directory: string | undefined, +): BtwPanelState { + const parentSession = useSession(parentSessionId, directory); + const linkedBtwSessionId = getBtwSessionID(parentSession); + const btwSession = useSession(linkedBtwSessionId, directory) ?? null; + const uiState = useBtwStore( + React.useCallback( + (s) => (parentSessionId ? s.byParent[parentSessionId] : undefined), + [parentSessionId], + ), + ); + + const destroying = Boolean(uiState?.destroying); + const btwSessionId = btwSession && !destroying ? linkedBtwSessionId : null; + return { + btwSessionId, + btwSession: btwSessionId ? btwSession : null, + // SAFETY: the SDK Session type omits the server's `directory` field; this + // widening only reads it, with the parent's directory as the fallback. + btwDirectory: btwSessionId + ? ((btwSession as (Session & { directory?: string | null }) | null)?.directory ?? directory ?? null) + : null, + boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null, + collapsed: Boolean(uiState?.collapsed), + creating: Boolean(uiState?.creating), + }; +} diff --git a/packages/ui/src/components/chat/chatSurfaceContextValue.ts b/packages/ui/src/components/chat/chatSurfaceContextValue.ts index 30065ad0..74470c17 100644 --- a/packages/ui/src/components/chat/chatSurfaceContextValue.ts +++ b/packages/ui/src/components/chat/chatSurfaceContextValue.ts @@ -1,5 +1,11 @@ import React from 'react'; -export type ChatSurfaceMode = 'default' | 'mini-chat'; +/** + * 'mini-chat' is the browser-panel side chat (compact, no fork/plan actions). + * 'peek' is a read-only glance surface (the /btw panel): messages render with + * no per-message controls at all — no user action row, no assistant action + * buttons, no turn footer. + */ +export type ChatSurfaceMode = 'default' | 'mini-chat' | 'peek'; export const ChatSurfaceContext = React.createContext('default'); diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index c6c09f46..dc870847 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -567,7 +567,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi const formatted = formatTimestampForDisplay(messageCreatedAt, timeFormatPreference); return formatted.length > 0 ? formatted : null; }, [locale, messageCreatedAt, timeFormatPreference]); - const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? ( + const actionsBlock = chatSurfaceMode !== 'peek' && ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
= ({ const merged = mergeSidebarSessionSources(globalActiveSessions, liveFallbackSessions); return merged.filter((session) => ( - (!isVSCode && isChatDirectoryPath(session.directory)) - || isKnownActiveSessionDirectory(session, knownSessionDirectories, { - allowUnknownDirectory: !isVSCode, - allowEmptyDirectorySet: !isVSCode, - }) + // btw forks stay hidden until promoted to a full session + !isBtwSession(session) + && ( + (!isVSCode && isChatDirectoryPath(session.directory)) + || isKnownActiveSessionDirectory(session, knownSessionDirectories, { + allowUnknownDirectory: !isVSCode, + allowEmptyDirectorySet: !isVSCode, + }) + ) )); }, [globalActiveSessions, isVSCode, knownSessionDirectories, liveFallbackSessions]); diff --git a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts index 42e3e05b..8e0f5e77 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts @@ -2,6 +2,7 @@ import React from 'react'; import type { Session } from '@opencode-ai/sdk/v2'; import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { isBtwSession } from '@/lib/sessionBtwMetadata'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; import { useGitAllBranches } from '@/stores/useGitStore'; @@ -117,6 +118,8 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions const parents = activeSessions .filter((session) => !session.time?.archived) + // btw forks stay hidden until promoted to a full session + .filter((session) => !isBtwSession(session)) .filter((session) => !isVSCode || !isChatDirectoryPath(resolveGlobalSessionDirectory(session))) .filter((session) => !(session as Session & { parentID?: string | null }).parentID) .filter((session) => { diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index d54861be..2fe8a942 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -18,6 +18,7 @@ import { import { useUIStore } from '@/stores/useUIStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { isBtwSession } from '@/lib/sessionBtwMetadata'; import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; import { EMPTY_SESSION_ORDER_RANKS, @@ -308,7 +309,9 @@ export const CommandPalette: React.FC = () => { // Sessions // --------------------------------------------------------------------------- const orderedActiveSessions = React.useMemo(() => { - return orderSessionsByLifecycleScopes(activeSessions, pinnedSessionIds, sessionOrderRanks); + // btw forks stay hidden until promoted to a full session + const visibleSessions = activeSessions.filter((session) => !isBtwSession(session)); + return orderSessionsByLifecycleScopes(visibleSessions, pinnedSessionIds, sessionOrderRanks); }, [activeSessions, pinnedSessionIds, sessionOrderRanks]); const allBranches = useGitAllBranches(); diff --git a/packages/ui/src/hooks/useSessionActivity.ts b/packages/ui/src/hooks/useSessionActivity.ts index a19eda62..0d7163fe 100644 --- a/packages/ui/src/hooks/useSessionActivity.ts +++ b/packages/ui/src/hooks/useSessionActivity.ts @@ -27,7 +27,7 @@ const IDLE_RESULT: SessionActivityResult = { * question indicator takes priority, and the send button must stay available so * the user can supersede the prompt with a new message). */ -function useSessionActivity(sessionId: string | null | undefined, directory?: string): SessionActivityResult { +export function useSessionActivity(sessionId: string | null | undefined, directory?: string): SessionActivityResult { const status = useSessionStatus(sessionId ?? '', directory); const messages = useSessionMessages(sessionId ?? '', directory); const permissions = useSessionPermissions(sessionId ?? '', directory); diff --git a/packages/ui/src/lib/btw.test.ts b/packages/ui/src/lib/btw.test.ts new file mode 100644 index 00000000..ce541989 --- /dev/null +++ b/packages/ui/src/lib/btw.test.ts @@ -0,0 +1,242 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { Message, Part, Session } from '@opencode-ai/sdk/v2'; + +let forkSessionImpl: (sessionId: string, messageId?: string, directory?: string | null) => Promise; +let getSessionMessagesImpl: (id: string, limit?: number, directory?: string | null) => Promise>; +let sendMessageImpl: (...args: unknown[]) => Promise; +let deleteSessionImpl: (sessionId: string) => Promise; +let updateSessionTitleImpl: (sessionId: string, title: string) => Promise; +let patchSessionMetadataImpl: ( + sessionId: string, + directory: string | null | undefined, + updater: (metadata: Record) => Record, +) => Promise; +const registeredDirectories: string[] = []; +const upsertedSessions: unknown[] = []; +const childStoreSessions: Session[] = []; +const currentSessionSwitches: string[] = []; +const metadataPatches: Array<{ sessionId: string; result: Record }> = []; + +mock.module('@/lib/opencode/client', () => ({ + opencodeClient: { + forkSession: (sessionId: string, messageId?: string, directory?: string | null) => + forkSessionImpl(sessionId, messageId, directory), + getSessionMessages: (id: string, limit?: number, directory?: string | null) => + getSessionMessagesImpl(id, limit, directory), + }, +})); +mock.module('@/sync/session-actions', () => ({ + waitForConnectionOrThrow: () => Promise.resolve(), + deleteSession: (sessionId: string) => deleteSessionImpl(sessionId), + updateSessionTitle: (sessionId: string, title: string) => updateSessionTitleImpl(sessionId, title), + patchSessionMetadata: ( + sessionId: string, + directory: string | null | undefined, + updater: (metadata: Record) => Record, + ) => patchSessionMetadataImpl(sessionId, directory, updater), +})); +mock.module('@/sync/session-ui-store', () => ({ + useSessionUIStore: { + getState: () => ({ + sendMessage: (...args: unknown[]) => sendMessageImpl(...args), + setCurrentSession: (sessionId: string) => { currentSessionSwitches.push(sessionId); }, + }), + }, +})); +mock.module('@/stores/useGlobalSessionsStore', () => ({ + useGlobalSessionsStore: { getState: () => ({ upsertSession: (session: unknown) => { upsertedSessions.push(session); } }) }, +})); +mock.module('@/sync/sync-refs', () => ({ + registerSessionDirectory: (sessionId: string, directory: string) => { registeredDirectories.push(`${sessionId}:${directory}`); }, + getSyncChildStores: () => ({ + children: new Map([['/project', { + getState: () => ({ session: childStoreSessions }), + setState: (patch: { session: Session[] }) => { childStoreSessions.length = 0; childStoreSessions.push(...patch.session); }, + }]]), + }), +})); + +const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages } = + await import('@/lib/btw'); +const { useBtwStore } = await import('@/stores/useBtwStore'); + +const makeSession = (id: string, directory?: string): Session => ({ + id, + directory, + title: 'btw: q', + time: { created: Date.now(), updated: Date.now() }, + parentID: undefined, + version: 1, +}) as unknown as Session; + +const record = (id: string): { info: Message; parts: Part[] } => ({ + info: { id, role: 'user', time: { created: 1 } } as unknown as Message, + parts: [], +}); + +const startInput = { + parentSessionId: 'parent-1', + question: 'wtf is kafka', + directory: '/project', + providerID: 'provider', + modelID: 'model', + agent: 'build', + variant: 'v', +}; + +beforeEach(() => { + registeredDirectories.length = 0; + upsertedSessions.length = 0; + childStoreSessions.length = 0; + currentSessionSwitches.length = 0; + metadataPatches.length = 0; + useBtwStore.setState({ byParent: {} }); + forkSessionImpl = () => Promise.reject(new Error('no forkSession stub')); + getSessionMessagesImpl = () => Promise.resolve([record('msg-boundary')]); + sendMessageImpl = () => Promise.resolve(); + deleteSessionImpl = () => Promise.resolve(true); + updateSessionTitleImpl = () => Promise.resolve(); + patchSessionMetadataImpl = (sessionId, _directory, updater) => { + const result = updater({}); + metadataPatches.push({ sessionId, result }); + return Promise.resolve(makeSession(sessionId)); + }; +}); + +describe('btwSessionTitle', () => { + test('prefixes the question', () => { + expect(btwSessionTitle('wtf is kafka')).toBe('btw: wtf is kafka'); + }); +}); + +describe('filterBtwTailMessages', () => { + test('keeps only messages after the boundary id', () => { + const records = [record('msg-1'), record('msg-2'), record('msg-3')]; + expect(filterBtwTailMessages(records, 'msg-2').map((r) => r.info.id)).toEqual(['msg-3']); + }); + + test('a null boundary keeps everything (fork of an empty parent)', () => { + const records = [record('msg-1'), record('msg-2')]; + expect(filterBtwTailMessages(records, null)).toBe(records); + }); +}); + +describe('startBtwSession', () => { + test('forks, marks the fork, links the parent, and routes the question to the fork', async () => { + forkSessionImpl = (sessionId, messageId, directory) => { + expect(sessionId).toBe('parent-1'); + expect(messageId).toBe(undefined); + return Promise.resolve(makeSession('fork-1', directory ?? '/project')); + }; + let sentText: unknown = null; + let sentOptions: unknown = null; + sendMessageImpl = (...args) => { + sentText = args[0]; + sentOptions = args[9]; + return Promise.resolve(); + }; + + const session = await startBtwSession(startInput); + + expect(session.id).toBe('fork-1'); + expect(registeredDirectories).toEqual(['fork-1:/project']); + expect(childStoreSessions.map((s) => s.id)).toEqual(['fork-1']); + expect(sentText).toBe('wtf is kafka'); + expect(sentOptions).toEqual({ sessionId: 'fork-1', directory: '/project' }); + expect(metadataPatches).toEqual([ + { sessionId: 'fork-1', result: { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-boundary' } } }, + { sessionId: 'parent-1', result: { openchamber: { btwSessionID: 'fork-1' } } }, + ]); + // Transient creating flag is cleared once the flow settles. + expect(useBtwStore.getState().byParent).toEqual({}); + }); + + test('an empty parent produces a marker without a boundary', async () => { + forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project')); + getSessionMessagesImpl = () => Promise.resolve([]); + await startBtwSession(startInput); + expect(metadataPatches[0]?.result).toEqual({ openchamber: { kind: 'btw', originalSessionID: 'parent-1' } }); + }); + + test('a failed first send unlinks the parent and deletes the fork', async () => { + forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project')); + sendMessageImpl = () => Promise.reject(new Error('send failed')); + const deleted: string[] = []; + deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); }; + + await expect(startBtwSession(startInput)).rejects.toThrow('send failed'); + + expect(deleted).toEqual(['fork-1']); + // marker, link, then unlink rollback + expect(metadataPatches.map((p) => p.sessionId)).toEqual(['fork-1', 'parent-1', 'parent-1']); + expect(metadataPatches[2]?.result).toEqual({}); + expect(useBtwStore.getState().byParent).toEqual({}); + }); + + test('a failed boundary fetch deletes the fork', async () => { + forkSessionImpl = () => Promise.resolve(makeSession('fork-1', '/project')); + getSessionMessagesImpl = () => Promise.reject(new Error('messages failed')); + const deleted: string[] = []; + deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); }; + + await expect(startBtwSession(startInput)).rejects.toThrow('messages failed'); + expect(deleted).toEqual(['fork-1']); + expect(metadataPatches).toEqual([]); + }); +}); + +describe('destroyBtwSession', () => { + const ref = { parentSessionId: 'parent-1', btwSessionId: 'fork-1', directory: '/project' }; + + test('unlinks the parent and deletes the fork', async () => { + const deleted: string[] = []; + deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); }; + expect(await destroyBtwSession(ref)).toBe(true); + expect(metadataPatches).toEqual([{ sessionId: 'parent-1', result: {} }]); + expect(deleted).toEqual(['fork-1']); + expect(useBtwStore.getState().byParent).toEqual({}); + }); + + test('reports an unconfirmed delete and still cleans UI state', async () => { + deleteSessionImpl = () => Promise.resolve(false); + expect(await destroyBtwSession(ref)).toBe(false); + expect(useBtwStore.getState().byParent).toEqual({}); + }); + + test('a failed unlink still attempts the delete', async () => { + patchSessionMetadataImpl = () => Promise.reject(new Error('patch failed')); + const deleted: string[] = []; + deleteSessionImpl = (sessionId) => { deleted.push(sessionId); return Promise.resolve(true); }; + expect(await destroyBtwSession(ref)).toBe(true); + expect(deleted).toEqual(['fork-1']); + }); +}); + +describe('promoteBtwSession', () => { + const ref = { parentSessionId: 'parent-1', btwSessionId: 'fork-1', directory: '/project' }; + + test('unlinks the parent, strips the marker, and navigates to the fork', async () => { + patchSessionMetadataImpl = (sessionId, _directory, updater) => { + const base = sessionId === 'fork-1' + ? { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' } } + : { openchamber: { btwSessionID: 'fork-1' } }; + const result = updater(base); + metadataPatches.push({ sessionId, result }); + return Promise.resolve(makeSession(sessionId)); + }; + + await promoteBtwSession(ref); + + expect(metadataPatches).toEqual([ + { sessionId: 'parent-1', result: {} }, + { sessionId: 'fork-1', result: {} }, + ]); + expect(currentSessionSwitches).toEqual(['fork-1']); + }); + + test('a failed unlink aborts the promote without navigating', async () => { + patchSessionMetadataImpl = () => Promise.reject(new Error('patch failed')); + await expect(promoteBtwSession(ref)).rejects.toThrow('patch failed'); + expect(currentSessionSwitches).toEqual([]); + }); +}); diff --git a/packages/ui/src/lib/btw.ts b/packages/ui/src/lib/btw.ts new file mode 100644 index 00000000..9ba9068e --- /dev/null +++ b/packages/ui/src/lib/btw.ts @@ -0,0 +1,170 @@ +import type { Message, Part, Session } from '@opencode-ai/sdk/v2'; +import { opencodeClient } from '@/lib/opencode/client'; +import * as sessionActions from '@/sync/session-actions'; +import { withBtwSessionLink, withBtwSessionMarker, withoutBtwSessionLink, withoutBtwSessionMarker } from '@/lib/sessionBtwMetadata'; +import { useBtwStore } from '@/stores/useBtwStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { getSyncChildStores, registerSessionDirectory } from '@/sync/sync-refs'; +import { Binary } from '@/sync/binary'; + +/** + * `/btw `: fork the main session into a temporary session and send + * the question there. + * + * A fork (not an empty child) gives the agent the full inherited conversation + * as its window context. The fork is created through the SDK directly (like + * reviewFlow) so the main chat's `currentSessionId` is never switched; the + * prompt is routed to the fork with `SendMessageOptions.sessionId`. + * + * The parent session's metadata carries `openchamber.btwSessionID` (see + * `sessionBtwMetadata`), so the panel belongs to the parent session alone, + * follows the user as they navigate between sessions, and survives reloads. + */ +export type StartBtwInput = { + parentSessionId: string; + question: string; + directory: string; + providerID: string; + modelID: string; + agent?: string; + variant?: string; +}; + +export const btwSessionTitle = (question: string): string => `btw: ${question}`; + +/** + * Insert the fork into its directory child store so the sidebar picks it up + * immediately, mirroring `forkFromMessage` in session-actions. + */ +function insertForkIntoDirectoryStore(session: Session, directory: string): void { + const store = getSyncChildStores().children.get(directory); + if (!store) return; + const current = store.getState(); + const sessions = [...current.session]; + const searchResult = Binary.search(sessions, session.id, (s) => s.id); + if (!searchResult.found) { + sessions.splice(searchResult.index, 0, session); + store.setState({ session: sessions }); + } +} + +export async function startBtwSession(input: StartBtwInput): Promise { + const { setPanelState, clearPanelState } = useBtwStore.getState(); + setPanelState(input.parentSessionId, { creating: true }); + try { + await sessionActions.waitForConnectionOrThrow(); + const forked = await opencodeClient.forkSession(input.parentSessionId, undefined, input.directory); + + // The server may canonicalize the worktree path; the prompt must use the + // same directory identity as the forked session. + // SAFETY: the SDK Session type omits the server's `directory` field; this + // widening only reads it, with the requested directory as the fallback. + const sessionDirectory = (forked as Session & { directory?: string | null }).directory ?? input.directory; + registerSessionDirectory(forked.id, sessionDirectory); + + try { + // The boundary between inherited history and the fork's own tail is the + // id of the newest cloned message. Message ids are server-generated and + // ascending, so everything the fork produces sorts after it. + const newestCloned = await opencodeClient.getSessionMessages(forked.id, 1, sessionDirectory); + const boundaryMessageID = newestCloned[newestCloned.length - 1]?.info.id ?? null; + + // The fork inherits the parent's metadata and title wholesale: replace + // the metadata with the btw marker, and rename it (rename is + // best-effort — a failed rename must not fail the btw flow). + // The marker lands BEFORE the fork is inserted into local stores: btw + // forks are hidden from session lists by this marker, so inserting an + // unmarked fork first would flash it in the sidebar. + const marked = await sessionActions.patchSessionMetadata(forked.id, sessionDirectory, (metadata) => + withBtwSessionMarker(metadata, input.parentSessionId, boundaryMessageID)); + // patchSessionMetadata already upserted the marked fork into the global + // store; the directory child store still needs the explicit insert. + insertForkIntoDirectoryStore(marked, sessionDirectory); + void sessionActions.updateSessionTitle(forked.id, btwSessionTitle(input.question)).catch(() => undefined); + + // Link the parent before sending so the panel opens as soon as the + // metadata lands; the question streams into it. + await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) => + withBtwSessionLink(metadata, forked.id)); + + try { + await useSessionUIStore.getState().sendMessage( + input.question, + input.providerID, + input.modelID, + input.agent, + [], + undefined, + undefined, + input.variant, + 'normal', + { sessionId: forked.id, directory: sessionDirectory }, + ); + } catch (error) { + // A fork without its first question is not a usable btw session: + // unlink the parent again before deleting the fork. + await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) => + withoutBtwSessionLink(metadata, forked.id)).catch(() => undefined); + throw error; + } + } catch (error) { + await sessionActions.deleteSession(forked.id).catch(() => undefined); + throw error; + } + return forked; + } finally { + clearPanelState(input.parentSessionId); + } +} + +/** + * Keep only the fork's own tail: messages after the last message cloned from + * the parent. A `null` boundary means the fork inherited nothing. + */ +export function filterBtwTailMessages( + records: Array<{ info: Message; parts: Part[] }>, + boundaryMessageID: string | null, +): Array<{ info: Message; parts: Part[] }> { + if (!boundaryMessageID) return records; + return records.filter((record) => record.info.id > boundaryMessageID); +} + +export type BtwSessionRef = { + parentSessionId: string; + btwSessionId: string; + directory: string; +}; + +/** + * Destroy the temporary fork. The panel disappears immediately (optimistic + * `destroying` flag); the parent is unlinked and the fork deleted in the + * background. Resolves `false` when the server could not confirm deletion — + * the fork then remains in the sidebar and the caller should surface that. + */ +export async function destroyBtwSession(ref: BtwSessionRef): Promise { + const { setPanelState, clearPanelState } = useBtwStore.getState(); + setPanelState(ref.parentSessionId, { destroying: true }); + try { + // deleteSession's metadata cleanup also unlinks the parent; doing it first + // makes the panel close authoritative even if the delete then fails. + await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) => + withoutBtwSessionLink(metadata, ref.btwSessionId)).catch(() => undefined); + return await sessionActions.deleteSession(ref.btwSessionId); + } finally { + clearPanelState(ref.parentSessionId); + } +} + +/** + * Keep the fork as a normal session: unlink it from the parent, drop its btw + * marker, and navigate to it. The conversation continues there as a regular + * session. + */ +export async function promoteBtwSession(ref: BtwSessionRef): Promise { + await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) => + withoutBtwSessionLink(metadata, ref.btwSessionId)); + await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, withoutBtwSessionMarker) + .catch(() => undefined); + useBtwStore.getState().clearPanelState(ref.parentSessionId); + useSessionUIStore.getState().setCurrentSession(ref.btwSessionId); +} diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 673ec2fe..6874fc8c 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1938,6 +1938,7 @@ export const dict = { 'chat.commandAutocomplete.command.catchUpDescription': 'Kontext wiederherstellen: Was du getan hast und wo du weitermachen sollst.', 'chat.commandAutocomplete.command.debugDescription': 'Geführte Ursachenforschung für einen Fehler, bevor eine Lösung vorgeschlagen wird.', 'chat.commandAutocomplete.command.weighDescription': 'Zwei bis drei Ansätze mit Kompromissen und einer Empfehlung bewerten, bevor du dich entscheidest.', + 'chat.commandAutocomplete.command.btwDescription': 'Stelle eine Neben-Frage in einer temporären Kind-Sitzung, ohne diesen Chat zu unterbrechen.', 'chat.commandAutocomplete.command.exploreDescription': 'Vertraut machen mit diesem Codebase: Eine Übersicht über die Architektur und Hauptbestandteile.', 'chat.commandAutocomplete.badge.skill': 'Fähigkeit', 'chat.commandAutocomplete.badge.command': 'Befehl', @@ -1958,6 +1959,18 @@ export const dict = { 'chat.container.returnToParent.titleNamed': 'Zurück zu: {title}', 'chat.container.returnToParent.title': 'Zurück zur übergeordneten Sitzung', 'chat.container.returnToParent.label': 'Übergeordnet', + 'chat.btw.destroyAria': 'Diese btw-Sitzung löschen', + 'chat.btw.titleFallback': 'btw-Sitzung', + 'chat.btw.mainComposerPlaceholder': 'In dieser btw-Sitzung fragen…', + 'chat.btw.loading': 'btw-Sitzung wird gestartet…', + 'chat.btw.toast.emptyArgument': 'Gib eine Frage nach /btw ein', + 'chat.btw.toast.createFailed': 'Die btw-Sitzung konnte nicht gestartet werden', + 'chat.btw.toast.destroyFailed': 'Die btw-Sitzung konnte nicht gelöscht werden. Sie bleibt in der Seitenleiste.', + 'chat.btw.working': 'Arbeitet…', + 'chat.btw.collapseAria': 'btw-Panel einklappen', + 'chat.btw.expandAria': 'btw-Panel ausklappen', + 'chat.btw.promoteAria': 'Als eigene Sitzung behalten', + 'chat.btw.toast.promoteFailed': 'Die btw-Sitzung konnte nicht behalten werden', 'chat.container.readOnlySubagentPromptBanner': 'Subagent-Sitzungen können nicht abgefragt werden.', 'chat.unifiedControls.title': 'Steuerung', 'chat.unifiedControls.model.title': 'Modell', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index d10d992a..e48145b3 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2104,6 +2104,7 @@ export const dict = { 'chat.commandAutocomplete.command.debugDescription': 'Guided root-cause investigation for a bug before proposing a fix.', 'chat.commandAutocomplete.command.weighDescription': 'Weigh 2-3 approaches with trade-offs and a recommendation before you commit.', 'chat.commandAutocomplete.command.exploreDescription': 'Get oriented in this codebase: a high-level tour of the architecture and main parts.', + 'chat.commandAutocomplete.command.btwDescription': 'Ask a side question in a temporary child session without derailing this chat.', 'chat.commandAutocomplete.badge.skill': 'skill', 'chat.commandAutocomplete.badge.command': 'command', 'chat.commandAutocomplete.badge.system': 'system', @@ -2124,6 +2125,18 @@ export const dict = { 'chat.container.returnToParent.title': 'Return to parent session', 'chat.container.returnToParent.label': 'Parent', 'chat.container.readOnlySubagentPromptBanner': 'Subagent sessions cannot be prompted.', + 'chat.btw.destroyAria': 'Destroy this btw session', + 'chat.btw.titleFallback': 'btw session', + 'chat.btw.mainComposerPlaceholder': 'Ask in this btw session…', + 'chat.btw.loading': 'Starting btw session…', + 'chat.btw.toast.emptyArgument': 'Type a question after /btw', + 'chat.btw.toast.createFailed': 'Failed to start the btw session', + 'chat.btw.toast.destroyFailed': 'Failed to destroy the btw session. It will remain in the sidebar.', + 'chat.btw.working': 'Working…', + 'chat.btw.collapseAria': 'Collapse the btw panel', + 'chat.btw.expandAria': 'Expand the btw panel', + 'chat.btw.promoteAria': 'Keep as a separate session', + 'chat.btw.toast.promoteFailed': 'Failed to keep the btw session', 'chat.container.sessionLoadError.title': 'Session could not be loaded', 'chat.container.sessionLoadError.description': 'Check the connection and try loading this session again.', 'chat.container.sessionLoadError.retry': 'Try again', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 344c4eed..79625f08 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2081,6 +2081,7 @@ export const dict: Record = { "chat.commandAutocomplete.command.catchUpDescription": "Recupera el contexto: qué estabas haciendo y por dónde continuar.", "chat.commandAutocomplete.command.debugDescription": "Investigación guiada de la causa raíz de un error antes de proponer una solución.", "chat.commandAutocomplete.command.weighDescription": "Compara 2-3 enfoques con sus ventajas y desventajas y una recomendación antes de decidir.", + 'chat.commandAutocomplete.command.btwDescription': 'Haz una pregunta paralela en una sesión hija temporal sin desviar este chat.', "chat.commandAutocomplete.command.exploreDescription": "Oriéntate en este código: un recorrido general de la arquitectura y las partes principales.", "chat.commandAutocomplete.badge.skill": "habilidad", "chat.commandAutocomplete.badge.command": "comando", @@ -2101,6 +2102,18 @@ export const dict: Record = { "chat.container.returnToParent.titleNamed": "Volver a: {title}", "chat.container.returnToParent.title": "Volver a la sesión principal", "chat.container.returnToParent.label": "Principal", + 'chat.btw.destroyAria': 'Destruir esta sesión btw', + 'chat.btw.titleFallback': 'sesión btw', + 'chat.btw.mainComposerPlaceholder': 'Pregunta en esta sesión btw…', + 'chat.btw.loading': 'Iniciando sesión btw…', + 'chat.btw.toast.emptyArgument': 'Escribe una pregunta después de /btw', + 'chat.btw.toast.createFailed': 'No se pudo iniciar la sesión btw', + 'chat.btw.toast.destroyFailed': 'No se pudo destruir la sesión btw. Permanecerá en la barra lateral.', + 'chat.btw.working': 'Trabajando…', + 'chat.btw.collapseAria': 'Contraer el panel btw', + 'chat.btw.expandAria': 'Expandir el panel btw', + 'chat.btw.promoteAria': 'Conservar como sesión aparte', + 'chat.btw.toast.promoteFailed': 'No se pudo conservar la sesión btw', "chat.container.readOnlySubagentPromptBanner": "Las sesiones de subagentes no pueden recibir prompts.", "chat.container.sessionLoadError.title": "No se pudo cargar la sesión", "chat.container.sessionLoadError.description": "Comprueba la conexión e intenta cargar esta sesión de nuevo.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 14973f40..fd989b8f 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1855,6 +1855,18 @@ export const dict = { 'chat.container.returnToParent.titleNamed': 'Retourner à : {title}', 'chat.container.returnToParent.title': 'Retour à la session parents', 'chat.container.returnToParent.label': 'Mère', + 'chat.btw.destroyAria': 'Détruire cette session btw', + 'chat.btw.titleFallback': 'session btw', + 'chat.btw.mainComposerPlaceholder': 'Poser une question dans cette session btw…', + 'chat.btw.loading': 'Démarrage de la session btw…', + 'chat.btw.toast.emptyArgument': 'Saisissez une question après /btw', + 'chat.btw.toast.createFailed': 'Échec du démarrage de la session btw', + 'chat.btw.toast.destroyFailed': 'Échec de la suppression de la session btw. Elle restera dans la barre latérale.', + 'chat.btw.working': 'En cours…', + 'chat.btw.collapseAria': 'Réduire le panneau btw', + 'chat.btw.expandAria': 'Développer le panneau btw', + 'chat.btw.promoteAria': 'Conserver comme session à part', + 'chat.btw.toast.promoteFailed': 'Échec de la conservation de la session btw', 'chat.container.readOnlySubagentPromptBanner': 'Les sessions de sous-agent ne peuvent pas être invitées.', 'chat.container.sessionLoadError.title': 'Impossible de charger la session', 'chat.container.sessionLoadError.description': 'Vérifiez la connexion et essayez de charger à nouveau cette session.', @@ -3021,6 +3033,7 @@ export const dict = { 'chat.commandAutocomplete.command.catchUpDescription': 'Rétablir le contexte : ce que vous faisiez et où reprendre.', 'chat.commandAutocomplete.command.debugDescription': 'Investigation guidée de la cause racine d’un bug avant de proposer une correction.', 'chat.commandAutocomplete.command.weighDescription': 'Comparer 2 à 3 approches avec compromis et recommandation avant de vous engager.', + 'chat.commandAutocomplete.command.btwDescription': 'Posez une question annexe dans une session enfant temporaire sans interrompre cette conversation.', 'chat.commandAutocomplete.command.exploreDescription': 'Vous orienter dans ce codebase : tour d’ensemble de l’architecture et des parties principales.', 'chat.questionCard.submitFailed': 'Impossible d’envoyer la réponse', 'chat.questionCard.dismissFailed': 'Impossible d’ignorer la question', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index bef4e313..f233371b 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2099,6 +2099,7 @@ export const dict: Record = { 'chat.commandAutocomplete.command.catchUpDescription': 'コンテキストを再確立: 何をしていたか、どこから再開するか。', 'chat.commandAutocomplete.command.debugDescription': '修正を提案する前に、バグのガイド付き根本原因調査。', 'chat.commandAutocomplete.command.weighDescription': 'トレードオフと推奨事項を含む2~3のアプローチを比較検討してからコミット。', + 'chat.commandAutocomplete.command.btwDescription': 'このチャットを乱さず、一時的な子セッションで脇の質問をする', 'chat.commandAutocomplete.command.exploreDescription': 'このコードベースに慣れる: アーキテクチャと主要部分の概要ツアー。', 'chat.commandAutocomplete.badge.skill': 'スキル', 'chat.commandAutocomplete.badge.command': 'コマンド', @@ -2119,6 +2120,18 @@ export const dict: Record = { 'chat.container.returnToParent.titleNamed': '戻る: {title}', 'chat.container.returnToParent.title': '親セッションに戻る', 'chat.container.returnToParent.label': '親', + 'chat.btw.destroyAria': 'このbtwセッションを破棄', + 'chat.btw.titleFallback': 'btwセッション', + 'chat.btw.mainComposerPlaceholder': 'このbtwセッションで質問する…', + 'chat.btw.loading': 'btwセッションを開始中…', + 'chat.btw.toast.emptyArgument': '/btwの後に質問を入力してください', + 'chat.btw.toast.createFailed': 'btwセッションを開始できませんでした', + 'chat.btw.toast.destroyFailed': 'btwセッションを破棄できませんでした。サイドバーに残ります。', + 'chat.btw.working': '処理中…', + 'chat.btw.collapseAria': 'btwパネルを折りたたむ', + 'chat.btw.expandAria': 'btwパネルを展開する', + 'chat.btw.promoteAria': '独立したセッションとして保持', + 'chat.btw.toast.promoteFailed': 'btwセッションを保持できませんでした', 'chat.container.readOnlySubagentPromptBanner': 'サブエージェントセッションはプロンプトを受け付けません。', 'chat.container.sessionLoadError.title': 'セッションを読み込めませんでした', 'chat.container.sessionLoadError.description': '接続を確認して、このセッションをもう一度読み込んでください。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 5e2ca75a..bfe88f96 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2105,6 +2105,7 @@ export const dict: Record = { 'chat.commandAutocomplete.command.catchUpDescription': '맥락을 다시 파악합니다: 무엇을 하고 있었고 어디서 이어서 할지.', 'chat.commandAutocomplete.command.debugDescription': '수정안을 제시하기 전에 버그의 근본 원인을 단계적으로 조사합니다.', 'chat.commandAutocomplete.command.weighDescription': '결정하기 전에 2~3가지 접근 방식을 장단점과 함께 비교하고 추천을 제시합니다.', + 'chat.commandAutocomplete.command.btwDescription': '이 채팅을 방해하지 않고 임시 하위 세션에서 별도 질문하기', 'chat.commandAutocomplete.command.exploreDescription': '코드베이스에 대한 방향을 잡습니다: 아키텍처와 주요 부분을 한눈에 살펴봅니다.', 'chat.commandAutocomplete.badge.skill': '스킬', 'chat.commandAutocomplete.badge.command': '명령', @@ -2125,6 +2126,18 @@ export const dict: Record = { 'chat.container.returnToParent.titleNamed': '돌아가기: {title}', 'chat.container.returnToParent.title': '상위 세션으로 돌아가기', 'chat.container.returnToParent.label': '상위', + 'chat.btw.destroyAria': '이 btw 세션 삭제', + 'chat.btw.titleFallback': 'btw 세션', + 'chat.btw.mainComposerPlaceholder': '이 btw 세션에서 질문하세요…', + 'chat.btw.loading': 'btw 세션 시작 중…', + 'chat.btw.toast.emptyArgument': '/btw 뒤에 질문을 입력하세요', + 'chat.btw.toast.createFailed': 'btw 세션을 시작하지 못했습니다', + 'chat.btw.toast.destroyFailed': 'btw 세션을 삭제하지 못했습니다. 사이드바에 남아 있습니다.', + 'chat.btw.working': '작업 중…', + 'chat.btw.collapseAria': 'btw 패널 접기', + 'chat.btw.expandAria': 'btw 패널 펼치기', + 'chat.btw.promoteAria': '별도 세션으로 유지', + 'chat.btw.toast.promoteFailed': 'btw 세션을 유지하지 못했습니다', 'chat.container.readOnlySubagentPromptBanner': '하위 에이전트 세션에는 프롬프트를 보낼 수 없습니다.', 'chat.container.sessionLoadError.title': '세션을 불러올 수 없습니다', 'chat.container.sessionLoadError.description': '연결을 확인한 후 이 세션을 다시 불러오세요.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 73d8a952..c8ded4ba 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -801,6 +801,7 @@ export const dict: Record = { 'chat.commandAutocomplete.command.catchUpDescription': 'Przywróć kontekst: nad czym pracowałeś i od czego kontynuować.', 'chat.commandAutocomplete.command.debugDescription': 'Prowadzone badanie pierwotnej przyczyny błędu przed zaproponowaniem poprawki.', 'chat.commandAutocomplete.command.weighDescription': 'Rozważ 2-3 podejścia z kompromisami i rekomendacją, zanim się zdecydujesz.', + 'chat.commandAutocomplete.command.btwDescription': 'Zadaj pytanie poboczne w tymczasowej sesji potomnej, nie przerywając tego czatu.', 'chat.commandAutocomplete.command.exploreDescription': 'Zorientuj się w bazie kodu: ogólny przegląd architektury i głównych części.', 'chat.commandAutocomplete.badge.skill': 'skill', 'chat.commandAutocomplete.badge.command': 'polecenie', @@ -821,6 +822,18 @@ export const dict: Record = { 'chat.container.returnToParent.titleNamed': 'Powrót do: {title}', 'chat.container.returnToParent.title': 'Powrót do sesji nadrzędnej', 'chat.container.returnToParent.label': 'Nadrzędna', + 'chat.btw.destroyAria': 'Zniszcz tę sesję btw', + 'chat.btw.titleFallback': 'sesja btw', + 'chat.btw.mainComposerPlaceholder': 'Zadaj pytanie w tej sesji btw…', + 'chat.btw.loading': 'Uruchamianie sesji btw…', + 'chat.btw.toast.emptyArgument': 'Wpisz pytanie po /btw', + 'chat.btw.toast.createFailed': 'Nie udało się uruchomić sesji btw', + 'chat.btw.toast.destroyFailed': 'Nie udało się zniszczyć sesji btw. Pozostanie na pasku bocznym.', + 'chat.btw.working': 'Pracuje…', + 'chat.btw.collapseAria': 'Zwiń panel btw', + 'chat.btw.expandAria': 'Rozwiń panel btw', + 'chat.btw.promoteAria': 'Zachowaj jako osobną sesję', + 'chat.btw.toast.promoteFailed': 'Nie udało się zachować sesji btw', 'chat.container.readOnlySubagentPromptBanner': 'Sesje podagentów nie mogą otrzymywać promptów.', 'chat.container.sessionLoadError.title': 'Nie udało się wczytać sesji', 'chat.container.sessionLoadError.description': 'Sprawdź połączenie i spróbuj ponownie wczytać tę sesję.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 2fe14a2c..48235bf6 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2081,6 +2081,7 @@ export const dict: Record = { "chat.commandAutocomplete.command.catchUpDescription": "Retome o contexto: o que você estava fazendo e por onde continuar.", "chat.commandAutocomplete.command.debugDescription": "Investigação guiada da causa raiz de um bug antes de propor uma correção.", "chat.commandAutocomplete.command.weighDescription": "Compare 2-3 abordagens com seus prós e contras e uma recomendação antes de decidir.", + 'chat.commandAutocomplete.command.btwDescription': 'Faça uma pergunta paralela em uma sessão filha temporária sem desviar este chat.', "chat.commandAutocomplete.command.exploreDescription": "Oriente-se neste código: um tour geral pela arquitetura e pelas partes principais.", "chat.commandAutocomplete.badge.skill": "habilidade", "chat.commandAutocomplete.badge.command": "comando", @@ -2101,6 +2102,18 @@ export const dict: Record = { "chat.container.returnToParent.titleNamed": "Voltar para: {title}", "chat.container.returnToParent.title": "Voltar para a sessão principal", "chat.container.returnToParent.label": "Principal", + 'chat.btw.destroyAria': 'Destruir esta sessão btw', + 'chat.btw.titleFallback': 'sessão btw', + 'chat.btw.mainComposerPlaceholder': 'Pergunte nesta sessão btw…', + 'chat.btw.loading': 'Iniciando sessão btw…', + 'chat.btw.toast.emptyArgument': 'Digite uma pergunta depois de /btw', + 'chat.btw.toast.createFailed': 'Falha ao iniciar a sessão btw', + 'chat.btw.toast.destroyFailed': 'Falha ao destruir a sessão btw. Ela permanecerá na barra lateral.', + 'chat.btw.working': 'Trabalhando…', + 'chat.btw.collapseAria': 'Recolher o painel btw', + 'chat.btw.expandAria': 'Expandir o painel btw', + 'chat.btw.promoteAria': 'Manter como sessão separada', + 'chat.btw.toast.promoteFailed': 'Falha ao manter a sessão btw', "chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.", "chat.container.sessionLoadError.title": "Não foi possível carregar a sessão", "chat.container.sessionLoadError.description": "Verifique a conexão e tente carregar esta sessão novamente.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index d7675954..618fafa4 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2081,6 +2081,7 @@ export const dict: Record = { "chat.commandAutocomplete.command.catchUpDescription": "Повернутись у контекст: над чим працювали і звідки продовжити.", "chat.commandAutocomplete.command.debugDescription": "Кероване дослідження першопричини бага перед тим, як пропонувати фікс.", "chat.commandAutocomplete.command.weighDescription": "Зважити 2-3 підходи з trade-offs і рекомендацією перш ніж братися до роботи.", + 'chat.commandAutocomplete.command.btwDescription': 'Поставте побічне питання в тимчасовій дочірній сесії, не відволікаючи цей чат.', "chat.commandAutocomplete.command.exploreDescription": "Зорієнтуватись у кодовій базі: високорівневий тур архітектурою й основними частинами.", "chat.commandAutocomplete.badge.skill": "навичка", "chat.commandAutocomplete.badge.command": "команда", @@ -2101,6 +2102,18 @@ export const dict: Record = { "chat.container.returnToParent.titleNamed": "Повернутися до: {title}", "chat.container.returnToParent.title": "Повернутися до батьківської сесії", "chat.container.returnToParent.label": "Батьківська", + 'chat.btw.destroyAria': 'Знищити цю сесію btw', + 'chat.btw.titleFallback': 'сесія btw', + 'chat.btw.mainComposerPlaceholder': 'Поставте питання в цій сесії btw…', + 'chat.btw.loading': 'Запуск сесії btw…', + 'chat.btw.toast.emptyArgument': 'Введіть питання після /btw', + 'chat.btw.toast.createFailed': 'Не вдалося запустити сесію btw', + 'chat.btw.toast.destroyFailed': 'Не вдалося знищити сесію btw. Вона залишиться в бічній панелі.', + 'chat.btw.working': 'Працює…', + 'chat.btw.collapseAria': 'Згорнути панель btw', + 'chat.btw.expandAria': 'Розгорнути панель btw', + 'chat.btw.promoteAria': 'Залишити як окрему сесію', + 'chat.btw.toast.promoteFailed': 'Не вдалося залишити сесію btw', "chat.container.readOnlySubagentPromptBanner": "Сесії субагентів не можна запитувати.", "chat.container.sessionLoadError.title": "Не вдалося завантажити сесію", "chat.container.sessionLoadError.description": "Перевірте з’єднання та спробуйте завантажити цю сесію ще раз.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 50b5164d..df7f48c5 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2069,6 +2069,7 @@ export const dict: Record = { 'chat.commandAutocomplete.command.catchUpDescription': '重新进入上下文:你之前在做什么、从哪里继续。', 'chat.commandAutocomplete.command.debugDescription': '在提出修复方案前,引导式地排查 bug 的根本原因。', 'chat.commandAutocomplete.command.weighDescription': '在动手前,权衡 2-3 种方案的利弊并给出推荐。', + 'chat.commandAutocomplete.command.btwDescription': '在临时子会话中提问,不打断当前对话', 'chat.commandAutocomplete.command.exploreDescription': '快速熟悉这个代码库:对架构和主要部分的概览。', 'chat.commandAutocomplete.badge.skill': '技能', 'chat.commandAutocomplete.badge.command': '命令', @@ -2089,6 +2090,18 @@ export const dict: Record = { 'chat.container.returnToParent.titleNamed': '返回到:{title}', 'chat.container.returnToParent.title': '返回父会话', 'chat.container.returnToParent.label': '父级', + 'chat.btw.destroyAria': '销毁此 btw 会话', + 'chat.btw.titleFallback': 'btw 会话', + 'chat.btw.mainComposerPlaceholder': '在此 btw 会话中提问…', + 'chat.btw.loading': '正在启动 btw 会话…', + 'chat.btw.toast.emptyArgument': '在 /btw 后输入问题', + 'chat.btw.toast.createFailed': '启动 btw 会话失败', + 'chat.btw.toast.destroyFailed': '销毁 btw 会话失败。它将保留在侧边栏中。', + 'chat.btw.working': '处理中…', + 'chat.btw.collapseAria': '收起 btw 面板', + 'chat.btw.expandAria': '展开 btw 面板', + 'chat.btw.promoteAria': '保留为独立会话', + 'chat.btw.toast.promoteFailed': '保留 btw 会话失败', 'chat.container.readOnlySubagentPromptBanner': '无法向子智能体会话发送提示。', 'chat.container.sessionLoadError.title': '无法加载会话', 'chat.container.sessionLoadError.description': '请检查连接,然后重新加载此会话。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 644b95c1..673ae58e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2073,6 +2073,7 @@ export const dict: Record = { 'chat.commandAutocomplete.command.catchUpDescription': '重新進入上下文:你之前在做什麼、從哪裡繼續。', 'chat.commandAutocomplete.command.debugDescription': '在提出修復方案前,引導式地排查 bug 的根本原因。', 'chat.commandAutocomplete.command.weighDescription': '在動手前,權衡 2-3 種方案的利弊並給出推薦。', + 'chat.commandAutocomplete.command.btwDescription': '在臨時子工作階段中提問,不打斷目前對話', 'chat.commandAutocomplete.command.exploreDescription': '快速熟悉這個程式碼庫:對架構和主要部分的概覽。', 'chat.commandAutocomplete.badge.skill': 'Skills', 'chat.commandAutocomplete.badge.command': '命令', @@ -2093,6 +2094,18 @@ export const dict: Record = { 'chat.container.returnToParent.titleNamed': '返回到:{title}', 'chat.container.returnToParent.title': '返回父會話', 'chat.container.returnToParent.label': '父級', + 'chat.btw.destroyAria': '銷毀此 btw 工作階段', + 'chat.btw.titleFallback': 'btw 工作階段', + 'chat.btw.mainComposerPlaceholder': '在此 btw 工作階段中提問…', + 'chat.btw.loading': '正在啟動 btw 工作階段…', + 'chat.btw.toast.emptyArgument': '在 /btw 後輸入問題', + 'chat.btw.toast.createFailed': '啟動 btw 工作階段失敗', + 'chat.btw.toast.destroyFailed': '銷毀 btw 工作階段失敗。它將保留在側邊欄中。', + 'chat.btw.working': '處理中…', + 'chat.btw.collapseAria': '收合 btw 面板', + 'chat.btw.expandAria': '展開 btw 面板', + 'chat.btw.promoteAria': '保留為獨立工作階段', + 'chat.btw.toast.promoteFailed': '保留 btw 工作階段失敗', 'chat.container.readOnlySubagentPromptBanner': '無法向子 Agent 會話傳送提示。', 'chat.container.sessionLoadError.title': '無法載入工作階段', 'chat.container.sessionLoadError.description': '請檢查連線,然後重新載入此工作階段。', diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 37e12620..42f155c1 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -602,10 +602,11 @@ class OpencodeService { return unwrapSdkData(response, 'session.update'); } - async getSessionMessages(id: string, limit?: number): Promise<{ info: Message; parts: Part[] }[]> { + async getSessionMessages(id: string, limit?: number, directory?: string | null): Promise<{ info: Message; parts: Part[] }[]> { + const requestDirectory = this.normalizeCandidatePath(directory) ?? this.currentDirectory; const response = await this.client.session.messages({ sessionID: id, - ...(this.currentDirectory ? { directory: this.currentDirectory } : {}), + ...(requestDirectory ? { directory: requestDirectory } : {}), ...(typeof limit === 'number' ? { limit } : {}), }); return unwrapSdkData(response, 'session.messages'); diff --git a/packages/ui/src/lib/sessionBtwMetadata.test.ts b/packages/ui/src/lib/sessionBtwMetadata.test.ts new file mode 100644 index 00000000..56ca52b9 --- /dev/null +++ b/packages/ui/src/lib/sessionBtwMetadata.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { + getBtwBoundaryMessageID, + getBtwOriginalSessionID, + getBtwSessionID, + isBtwSession, + withBtwSessionLink, + withBtwSessionMarker, + withoutBtwSessionLink, + withoutBtwSessionMarker, +} from './sessionBtwMetadata'; + +const sessionWith = (metadata: unknown): Session => ({ id: 's', metadata }) as unknown as Session; + +describe('parent link', () => { + test('withBtwSessionLink preserves unrelated openchamber metadata', () => { + const next = withBtwSessionLink({ openchamber: { reviewSessionID: 'r-1' }, other: 1 }, 'fork-1'); + expect(next).toEqual({ openchamber: { reviewSessionID: 'r-1', btwSessionID: 'fork-1' }, other: 1 }); + }); + + test('getBtwSessionID reads the link and rejects blank values', () => { + expect(getBtwSessionID(sessionWith({ openchamber: { btwSessionID: 'fork-1' } }))).toBe('fork-1'); + expect(getBtwSessionID(sessionWith({ openchamber: { btwSessionID: ' ' } }))).toBeNull(); + expect(getBtwSessionID(sessionWith(undefined))).toBeNull(); + expect(getBtwSessionID(null)).toBeNull(); + }); + + test('withoutBtwSessionLink removes only a matching link', () => { + const linked = { openchamber: { btwSessionID: 'fork-1', reviewSessionID: 'r-1' } }; + expect(withoutBtwSessionLink(linked, 'fork-2')).toBe(linked); + expect(withoutBtwSessionLink(linked, 'fork-1')).toEqual({ openchamber: { reviewSessionID: 'r-1' } }); + }); + + test('withoutBtwSessionLink drops an emptied openchamber object', () => { + expect(withoutBtwSessionLink({ openchamber: { btwSessionID: 'fork-1' } }, 'fork-1')).toEqual({}); + }); +}); + +describe('fork marker', () => { + test('withBtwSessionMarker replaces inherited openchamber metadata', () => { + const inherited = { openchamber: { btwSessionID: 'stale', reviewSessionID: 'r-1' }, other: 1 }; + expect(withBtwSessionMarker(inherited, 'parent-1', 'msg-9')).toEqual({ + openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-9' }, + other: 1, + }); + }); + + test('withBtwSessionMarker omits a null boundary (empty parent)', () => { + expect(withBtwSessionMarker({}, 'parent-1', null)).toEqual({ + openchamber: { kind: 'btw', originalSessionID: 'parent-1' }, + }); + }); + + test('marker readers only apply to btw-kind sessions', () => { + const fork = sessionWith({ openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-9' } }); + expect(isBtwSession(fork)).toBe(true); + expect(getBtwOriginalSessionID(fork)).toBe('parent-1'); + expect(getBtwBoundaryMessageID(fork)).toBe('msg-9'); + + const review = sessionWith({ openchamber: { kind: 'review', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-9' } }); + expect(isBtwSession(review)).toBe(false); + expect(getBtwOriginalSessionID(review)).toBeNull(); + expect(getBtwBoundaryMessageID(review)).toBeNull(); + }); + + test('withoutBtwSessionMarker strips the marker and keeps other keys', () => { + const marked = { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-9', btwSessionID: 'nested' } }; + expect(withoutBtwSessionMarker(marked)).toEqual({ openchamber: { btwSessionID: 'nested' } }); + expect(withoutBtwSessionMarker({ openchamber: { kind: 'btw', originalSessionID: 'parent-1' } })).toEqual({}); + const plain = { openchamber: { kind: 'review' } }; + expect(withoutBtwSessionMarker(plain)).toBe(plain); + }); +}); diff --git a/packages/ui/src/lib/sessionBtwMetadata.ts b/packages/ui/src/lib/sessionBtwMetadata.ts new file mode 100644 index 00000000..1aace74f --- /dev/null +++ b/packages/ui/src/lib/sessionBtwMetadata.ts @@ -0,0 +1,120 @@ +import type { Session } from '@opencode-ai/sdk/v2'; +import { getSessionMetadata, type SessionMetadataRecord } from '@/lib/sessionReviewMetadata'; + +/** + * Session-metadata contract for the `/btw` flow, mirroring the review-session + * link in `sessionReviewMetadata`: + * + * - The parent (the session `/btw` was typed into) carries + * `openchamber.btwSessionID` pointing at its active btw fork. The panel is + * derived from this link, so it appears only in the parent session and + * survives reloads. + * - The fork itself is marked `openchamber.kind = 'btw'` with + * `originalSessionID` (its parent) and `btwBoundaryMessageID` — the id of + * the last message cloned from the parent. Messages with a greater id are + * the fork's own tail and are what the panel renders. Message ids are + * server-generated ascending identifiers, so the boundary is a plain string + * comparison and immune to client clock skew. + */ +type BtwMetadata = { + kind?: string; + originalSessionID?: string; + btwSessionID?: string; + btwBoundaryMessageID?: string; +}; + +const getOpenChamberMetadata = (metadata: SessionMetadataRecord): BtwMetadata => { + const value = metadata.openchamber; + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + // SAFETY: session metadata is persisted, externally writable data; this is + // its parsing boundary. `BtwMetadata` only declares optional fields and + // every reader re-validates the field it consumes in `nonEmpty`. + return value as BtwMetadata; +}; + +const nonEmpty = (value: string | undefined): string | null => + typeof value === 'string' && value.trim().length > 0 ? value : null; + +/** The parent's link to its active btw fork, or null. */ +export const getBtwSessionID = (session: Session | null | undefined): string | null => + nonEmpty(getOpenChamberMetadata(getSessionMetadata(session)).btwSessionID); + +export const isBtwSession = (session: Session | null | undefined): boolean => + getOpenChamberMetadata(getSessionMetadata(session)).kind === 'btw' + && Boolean(getBtwOriginalSessionID(session)); + +/** The fork's back-pointer to the session `/btw` was typed into. */ +export const getBtwOriginalSessionID = (session: Session | null | undefined): string | null => { + const openchamber = getOpenChamberMetadata(getSessionMetadata(session)); + return openchamber.kind === 'btw' ? nonEmpty(openchamber.originalSessionID) : null; +}; + +/** + * The id of the last message the fork inherited from the parent. `null` means + * the fork inherited nothing (empty parent) and every message is its own. + */ +export const getBtwBoundaryMessageID = (session: Session | null | undefined): string | null => { + const openchamber = getOpenChamberMetadata(getSessionMetadata(session)); + return openchamber.kind === 'btw' ? nonEmpty(openchamber.btwBoundaryMessageID) : null; +}; + +export const withBtwSessionLink = ( + metadata: SessionMetadataRecord, + btwSessionID: string, +): SessionMetadataRecord => ({ + ...metadata, + openchamber: { + ...getOpenChamberMetadata(metadata), + btwSessionID, + }, +}); + +/** + * Mark the fork as a btw session. The fork clones the parent's metadata + * wholesale (including review links or a stale `btwSessionID`), so the + * inherited `openchamber` object is replaced, not merged. + */ +export const withBtwSessionMarker = ( + metadata: SessionMetadataRecord, + originalSessionID: string, + boundaryMessageID: string | null, +): SessionMetadataRecord => { + const openchamber: BtwMetadata = { kind: 'btw', originalSessionID }; + if (boundaryMessageID) openchamber.btwBoundaryMessageID = boundaryMessageID; + return { ...metadata, openchamber }; +}; + +/** Remove the btw marker so a promoted fork becomes a plain session. */ +export const withoutBtwSessionMarker = (metadata: SessionMetadataRecord): SessionMetadataRecord => { + const openchamber = getOpenChamberMetadata(metadata); + if (openchamber.kind !== 'btw') return metadata; + const rest: BtwMetadata = { ...openchamber }; + delete rest.kind; + delete rest.originalSessionID; + delete rest.btwBoundaryMessageID; + const next: SessionMetadataRecord = { ...metadata }; + if (Object.keys(rest).length > 0) { + next.openchamber = rest; + } else { + delete next.openchamber; + } + return next; +}; + +/** Unlink the parent, but only if it still points at this fork. */ +export const withoutBtwSessionLink = ( + metadata: SessionMetadataRecord, + btwSessionID: string, +): SessionMetadataRecord => { + const openchamber = getOpenChamberMetadata(metadata); + if (openchamber.btwSessionID !== btwSessionID) return metadata; + const rest: BtwMetadata = { ...openchamber }; + delete rest.btwSessionID; + const next: SessionMetadataRecord = { ...metadata }; + if (Object.keys(rest).length > 0) { + next.openchamber = rest; + } else { + delete next.openchamber; + } + return next; +}; diff --git a/packages/ui/src/stores/useBtwStore.test.ts b/packages/ui/src/stores/useBtwStore.test.ts new file mode 100644 index 00000000..9711d182 --- /dev/null +++ b/packages/ui/src/stores/useBtwStore.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { useBtwStore } from './useBtwStore'; + +describe('useBtwStore', () => { + beforeEach(() => { + useBtwStore.setState({ byParent: {} }); + }); + + test('starts empty', () => { + expect(useBtwStore.getState().byParent).toEqual({}); + }); + + test('setPanelState merges patches per parent', () => { + useBtwStore.getState().setPanelState('parent-1', { creating: true }); + useBtwStore.getState().setPanelState('parent-1', { collapsed: true }); + expect(useBtwStore.getState().byParent['parent-1']).toEqual({ creating: true, collapsed: true }); + }); + + test('parents are independent', () => { + useBtwStore.getState().setPanelState('parent-1', { collapsed: true }); + useBtwStore.getState().setPanelState('parent-2', { destroying: true }); + expect(useBtwStore.getState().byParent['parent-1']).toEqual({ collapsed: true }); + expect(useBtwStore.getState().byParent['parent-2']).toEqual({ destroying: true }); + }); + + test('clearPanelState removes only its parent entry', () => { + useBtwStore.getState().setPanelState('parent-1', { collapsed: true }); + useBtwStore.getState().setPanelState('parent-2', { collapsed: true }); + useBtwStore.getState().clearPanelState('parent-1'); + expect(useBtwStore.getState().byParent).toEqual({ 'parent-2': { collapsed: true } }); + }); + + test('clearPanelState on an unknown parent is a no-op', () => { + const before = useBtwStore.getState().byParent; + useBtwStore.getState().clearPanelState('missing'); + expect(useBtwStore.getState().byParent).toBe(before); + }); +}); diff --git a/packages/ui/src/stores/useBtwStore.ts b/packages/ui/src/stores/useBtwStore.ts new file mode 100644 index 00000000..c885f3bc --- /dev/null +++ b/packages/ui/src/stores/useBtwStore.ts @@ -0,0 +1,47 @@ +import { create } from 'zustand'; + +/** + * UI-only state for the `/btw` peek panel. + * + * The panel's identity is NOT stored here: it is derived from session + * metadata (`openchamber.btwSessionID` on the parent — see + * `sessionBtwMetadata`), so the panel appears only in the session `/btw` was + * typed into and survives reloads. This store keeps only transient + * per-parent presentation state that has no authoritative home: + * + * - `collapsed`: the panel is minimized to the composer chip; the composer + * talks to the main session again until it is expanded. + * - `creating`: `/btw` is between submit and the parent-metadata link + * landing, so the panel can show its starting state immediately. + * - `destroying`: close was clicked; hides the panel optimistically while the + * unlink/delete round-trip completes. + */ +type BtwPanelUIState = { + collapsed?: boolean; + creating?: boolean; + destroying?: boolean; +}; + +type BtwStore = { + byParent: Record; + setPanelState: (parentSessionId: string, patch: BtwPanelUIState) => void; + clearPanelState: (parentSessionId: string) => void; +}; + +export const useBtwStore = create()((set) => ({ + byParent: {}, + setPanelState: (parentSessionId, patch) => + set((state) => ({ + byParent: { + ...state.byParent, + [parentSessionId]: { ...state.byParent[parentSessionId], ...patch }, + }, + })), + clearPanelState: (parentSessionId) => + set((state) => { + if (!(parentSessionId in state.byParent)) return state; + const byParent = { ...state.byParent }; + delete byParent[parentSessionId]; + return { byParent }; + }), +})); diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 027e8f27..fa56cc25 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -26,6 +26,7 @@ import { type SessionMetadataRecord, } from "@/lib/sessionReviewMetadata" import { withContextObligatoryMessage, type ContextObligatoryMessage } from "@/lib/contextObligatoryMessages" +import { getBtwOriginalSessionID, getBtwSessionID, isBtwSession, withoutBtwSessionLink } from "@/lib/sessionBtwMetadata" import { withLinkedIssue, type LinkedIssue } from "@/lib/linkedIssues" import { getImperativeSessionMessageLoader } from "./session-message-loader" import { cleanupPersistedSessionState } from "./session-deletion-cleanup" @@ -794,6 +795,7 @@ export async function patchSessionMetadata( useGlobalSessionsStore.getState().upsertSession(updated) const sessionDirectory = (updated as { directory?: string | null }).directory ?? targetDirectory if (sessionDirectory) registerSessionDirectory(updated.id, sessionDirectory) + mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined) return updated } @@ -803,11 +805,8 @@ export async function setLinkedIssue( issue: LinkedIssue, linked: boolean, ): Promise { - const updated = await patchSessionMetadata(sessionId, directory, (metadata) => + return patchSessionMetadata(sessionId, directory, (metadata) => withLinkedIssue(metadata, issue, linked)) - const sessionDirectory = (updated as Session & { directory?: string | null }).directory ?? directory ?? undefined - mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined) - return updated } export async function setContextObligatoryMessage( @@ -816,11 +815,8 @@ export async function setContextObligatoryMessage( message: ContextObligatoryMessage, pinned: boolean, ): Promise { - const updated = await patchSessionMetadata(sessionId, directory, (metadata) => + return patchSessionMetadata(sessionId, directory, (metadata) => withContextObligatoryMessage(metadata, message, pinned)) - const sessionDirectory = (updated as Session & { directory?: string | null }).directory ?? directory ?? undefined - mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined) - return updated } async function cleanupReviewMetadataBeforeDelete( @@ -836,18 +832,41 @@ async function cleanupReviewMetadataBeforeDelete( return } if (isStaleRuntime(expectedRuntimeKey)) return - if (!isReviewSession(session)) return - const originalSessionID = getOriginalSessionID(session) - if (!originalSessionID) return - try { - await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), (metadata) => - withoutReviewSessionLink(metadata, sessionId), - expectedRuntimeKey, - ) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (/not found/i.test(message)) return - console.warn("[session-actions] review metadata cleanup failed before delete", error) + + const unlinkParent = async (originalSessionID: string, unlink: (metadata: SessionMetadataRecord) => SessionMetadataRecord) => { + try { + await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), unlink, expectedRuntimeKey) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (/not found/i.test(message)) return + console.warn("[session-actions] linked-session metadata cleanup failed before delete", error) + } + } + + if (isReviewSession(session)) { + const originalSessionID = getOriginalSessionID(session) + if (originalSessionID) await unlinkParent(originalSessionID, (metadata) => withoutReviewSessionLink(metadata, sessionId)) + return + } + + if (isBtwSession(session)) { + const originalSessionID = getBtwOriginalSessionID(session) + if (originalSessionID) await unlinkParent(originalSessionID, (metadata) => withoutBtwSessionLink(metadata, sessionId)) + return + } + + // Deleting or archiving a session that has an active btw fork also removes + // the fork: it is a temporary session that only exists for its parent's + // panel. Best-effort — a failed fork delete must not block the parent's + // operation; the orphaned fork stays visible in the sidebar. + const btwSessionID = getBtwSessionID(session) + if (btwSessionID) { + try { + if (isStaleRuntime(expectedRuntimeKey)) return + await deleteSession(btwSessionID, { expectedRuntimeKey }) + } catch (error) { + console.warn("[session-actions] failed to delete btw fork before parent delete", error) + } } } From 3195fb119097c2f9694f86ef2403af50a5301ee5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 23 Aug 2026 00:24:33 +0300 Subject: [PATCH 54/59] fix(chat): make the working status row swap seamlessly into the turn footer The streaming status line and the finished turn footer are the same visual line, but the swap used to jump: different font/color, a 2px left inset, a 20px row vs the footer's 32px (its h-8 action buttons set the height), and the bottom-anchored chat pulling the line up because the finished message carries more structure below its footer than the status row had. Match the footer exactly: text-sm at muted-foreground/60, no left inset, h-8 row, and mb-6 reserving the missing space below. Verified against the live DOM: the footer appears at the exact pixel position the status row occupied. --- packages/ui/src/components/chat/StatusRow.tsx | 16 +++++++++++----- .../chat/message/parts/WorkingPlaceholder.tsx | 8 ++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index 66ecde5f..1159e822 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -306,13 +306,19 @@ export const StatusRow: React.FC = ({ return (
is running…" row sits flush against - // the message above. - className={cn("mb-1", isMobile && "mt-2", !hasLeftAccessory && "chat-column")} + // This row must land exactly where the assistant turn footer (mt-2 + // inside the message) appears when the turn completes. Measured against + // the live DOM: the gap ABOVE already matches (message pb-2 = footer + // mt-2 = 8px), but the chat is bottom-anchored and the finished message + // carries ~12px more structure BELOW its footer than this row has — so + // the swap used to lift the line up. mb-6 (24px) reserves that space + // under this row instead (verified: row top 636 == footer top 636). + className={cn("mb-6", !hasLeftAccessory && "chat-column")} style={STATUS_ROW_CONTAINER_STYLE} > -
+ {/* h-8 matches the turn footer's real row height: its h-8 action + buttons define the footer line, with the meta text centered in it. */} +
{/* Left: Abort status | Working placeholder | leftAccessory */}
{showAssistantStatus && showAbortStatus ? ( diff --git a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx index 2a980448..9260cc51 100644 --- a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx +++ b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx @@ -229,15 +229,19 @@ export function WorkingPlaceholder({ return (
- + {hasProviderLogo && providerLogoSrc ? ( Date: Sun, 23 Aug 2026 00:42:23 +0300 Subject: [PATCH 55/59] docs(changelog): note /btw side questions and the status-line handoff --- CHANGELOG.md | 2 ++ packages/vscode/CHANGELOG.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d6cb91b..4bc876de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. - Settings/Projects: a project can now pin a thinking level next to its model, for models that offer levels. Both sit in one Defaults for new chats group, laid out like the Sessions defaults. - **Settings:** the project selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show. It used to switch the whole app, so opening another project's configuration moved your chat, session list and file tree with it. - Settings/Providers: the provider you select no longer jumps to a different one on its own. Changing the chat's model or agent, and background provider refreshes, used to move the settings selection with them. +- **Chat: /btw side questions.** Type `/btw ` to ask something off-topic in a temporary session forked from the current conversation, so it inherits the full context but leaves the chat itself untouched. The answer streams into a panel above the composer, which talks to that session while the panel is open; you can collapse it to a slim header bar, keep it as a full session, or discard it. The temporary session stays out of the sidebar and session lists until you keep it (thanks to @jaygupta17). - **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context. - **Skills catalog:** browse curated GitHub skill collections in a card-based catalog with cross-source search, skill counts, stars, recent updates, and links back to each skill's repository. - **Diff:** the context-panel diff can now show every change on the current branch against its base branch. OpenChamber detects the base when Git knows it, or lets you choose one once when it does not. @@ -26,6 +27,7 @@ All notable changes to this project will be documented in this file. - Git: generating a pull request description now picks up the repository's own PR template when it has one, so the draft comes back in your project's sections and checklists instead of the built-in Summary/Why/Testing layout. - Sidebar: switch between the full project list and a focused view of one project. Sessions created outside OpenChamber now also appear in the sidebar and Recent list without a page refresh (thanks to @tomzx). - Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). +- Chat: while a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes. - Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off. - Usage: Z.ai credit limits now appear alongside its other quota windows. - Git: pull-request checks in Work status stay current as their status changes. diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 58dec215..bdd03883 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,11 +1,13 @@ ## [Unreleased] +- **/btw side questions:** type `/btw ` to ask something off-topic in a temporary session forked from the current conversation. The answer streams into a panel above the composer; collapse it, keep it as a full session, or discard it without touching the chat (thanks to @jaygupta17). - **Skills catalog:** browse curated GitHub skill collections in a card-based catalog with cross-source search and direct links to each skill's repository. - Providers: expanded support for custom providers. - Sessions created outside OpenChamber now appear in the sidebar and Recent list without a page refresh (thanks to @tomzx). - If OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). - Usage: Z.ai credit limits now appear alongside its other quota windows. - Chat: file paths in messages now open from the session's workspace, even if you last browsed files in another workspace (thanks to @tomzx). +- While a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes. - Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off. - UI: the default dialog close button is easier to click or tap (thanks to @rockinrimmer). From 8f2c1ecc8b6e7a399e105d1b471d9435d1fec871 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 23 Aug 2026 01:05:09 +0300 Subject: [PATCH 56/59] fix(chat): hide autocomplete tooltips on mobile --- packages/ui/src/components/chat/CommandAutocomplete.tsx | 2 +- packages/ui/src/components/chat/FileMentionAutocomplete.tsx | 2 +- packages/ui/src/components/chat/SkillAutocomplete.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/chat/CommandAutocomplete.tsx b/packages/ui/src/components/chat/CommandAutocomplete.tsx index aa9e5d0c..c1cebccc 100644 --- a/packages/ui/src/components/chat/CommandAutocomplete.tsx +++ b/packages/ui/src/components/chat/CommandAutocomplete.tsx @@ -385,7 +385,7 @@ export const CommandAutocomplete = React.forwardRef +
{ itemRefs.current[index] = el; }} diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index 89bef76a..9192fbc7 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -459,7 +459,7 @@ export const FileMentionAutocomplete = React.forwardRef { const isSelected = selectedIndex === index; return ( - +
{ itemRefs.current[index] = el; }} diff --git a/packages/ui/src/components/chat/SkillAutocomplete.tsx b/packages/ui/src/components/chat/SkillAutocomplete.tsx index 42bfe583..a5d05e4a 100644 --- a/packages/ui/src/components/chat/SkillAutocomplete.tsx +++ b/packages/ui/src/components/chat/SkillAutocomplete.tsx @@ -127,7 +127,7 @@ export const SkillAutocomplete = React.forwardRef +
{ From a5b0272f01b424557cbc6299ff0b6c33432fbc79 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 23 Aug 2026 01:05:14 +0300 Subject: [PATCH 57/59] chore: update unreleased changelog --- CHANGELOG.md | 19 +++++++++++-------- packages/vscode/CHANGELOG.md | 8 +++++++- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc876de..96723c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,21 +4,22 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Chat: /btw side questions.** Type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation, so it inherits the full context but leaves the chat itself untouched. The answer streams into a panel above the composer, which talks to that session while the panel is open; you can collapse it to a slim header bar, keep it as a full session, or discard it. The temporary session stays out of the sidebar and session lists until you keep it (thanks to @jaygupta17). +- **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context. - **Desktop/Remote instances:** adding an SSH connection now starts from the hosts in your SSH config instead of a blank command field. Ports, install method and passwords moved behind Advanced settings, and each connection shows Connected, Connecting, or Needs attention with the failure text and a button that resolves it. - Desktop/Remote instances: connecting to a remote machine now works when bun, OpenChamber or the opencode CLI live in your home directory rather than on the system path. Installing no longer fails with a permission error, and a missing opencode CLI is now reported before the connection starts instead of as a stack trace. - Desktop/Remote instances: a managed remote server can now also be published to the remote machine's own network, so other devices there reach it without the SSH tunnel. It requires a UI password, and stays private to the tunnel otherwise. - Desktop/Remote instances: disconnecting from a connection set to not keep the server running now actually stops that remote server. -- Chat: in a chat without a project, the work status card again steps aside when the context panel is open, instead of sitting next to it. -- Settings/General: changing the default model, variant or agent no longer repoints an open chat that already carries a model you picked for it. Chats following the default still switch immediately. +- Skills catalog: browse curated GitHub skill collections in a card-based catalog with cross-source search, skill counts, stars, recent updates, and links back to each skill's repository. +- Diff: the context-panel diff can now show every change on the current branch against its base branch. OpenChamber detects the base when Git knows it, or lets you choose one once when it does not. +- Dictation: speech is now transcribed after you stop recording. The composer shows a live waveform and timer, and long recordings split at pauses instead of cutting words. +- Settings: the project selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show. It used to switch the whole app, so opening another project's configuration moved your chat, session list and file tree with it. - Settings/Projects: a project can now pin a thinking level next to its model, for models that offer levels. Both sit in one Defaults for new chats group, laid out like the Sessions defaults. -- **Settings:** the project selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show. It used to switch the whole app, so opening another project's configuration moved your chat, session list and file tree with it. +- Settings/General: changing the default model, variant or agent no longer repoints an open chat that already carries a model you picked for it. Chats following the default still switch immediately. - Settings/Providers: the provider you select no longer jumps to a different one on its own. Changing the chat's model or agent, and background provider refreshes, used to move the settings selection with them. -- **Chat: /btw side questions.** Type `/btw ` to ask something off-topic in a temporary session forked from the current conversation, so it inherits the full context but leaves the chat itself untouched. The answer streams into a panel above the composer, which talks to that session while the panel is open; you can collapse it to a slim header bar, keep it as a full session, or discard it. The temporary session stays out of the sidebar and session lists until you keep it (thanks to @jaygupta17). -- **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context. -- **Skills catalog:** browse curated GitHub skill collections in a card-based catalog with cross-source search, skill counts, stars, recent updates, and links back to each skill's repository. -- **Diff:** the context-panel diff can now show every change on the current branch against its base branch. OpenChamber detects the base when Git knows it, or lets you choose one once when it does not. -- **Dictation:** speech is now transcribed after you stop recording. The composer shows a live waveform and timer, and long recordings split at pauses instead of cutting words. +- Settings/Integrations: the experimental page now only lists integrations that can be installed; unavailable and Coming soon entries were removed. - Chat: file paths in messages now open from the session's project, even if you last browsed files in another project (thanks to @tomzx). +- Files/Desktop: files opened from outside the workspace remain readable after their temporary access expires instead of failing until you reopen them (thanks to @pascalandr). - Diff: creating an inline comment now opens the chat and focuses the composer for your follow-up. - Chat: in the expanded composer, Enter now starts a new line and Cmd/Ctrl+Enter sends, so a long prompt is harder to send by accident. - Providers: expanded support for custom providers. @@ -29,6 +30,8 @@ All notable changes to this project will be documented in this file. - Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). - Chat: while a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes. - Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off. +- Chat: long user messages can be expanded even when their final layout finishes after they first appear. +- Chat: in a chat without a project, the work status card again steps aside when the context panel is open, instead of sitting next to it. - Usage: Z.ai credit limits now appear alongside its other quota windows. - Git: pull-request checks in Work status stay current as their status changes. - UI: the default dialog close button is easier to click or tap (thanks to @rockinrimmer). diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index bdd03883..47e08755 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,7 +1,12 @@ ## [Unreleased] -- **/btw side questions:** type `/btw ` to ask something off-topic in a temporary session forked from the current conversation. The answer streams into a panel above the composer; collapse it, keep it as a full session, or discard it without touching the chat (thanks to @jaygupta17). +- **/btw side questions:** type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation. The answer streams into a panel above the composer; collapse it, keep it as a full session, or discard it without touching the chat (thanks to @jaygupta17). - **Skills catalog:** browse curated GitHub skill collections in a card-based catalog with cross-source search and direct links to each skill's repository. +- Settings: the workspace selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show instead of moving the chat, session list and file tree to another workspace. +- Settings/Projects: a project can now pin a thinking level next to its model, for models that offer levels. +- Settings/General: changing the default model, variant or agent no longer repoints an open chat that already carries a model you picked for it. Chats following the default still switch immediately. +- Settings/Providers: the provider you select no longer jumps to a different one when the chat selection or provider data changes. +- Settings/Integrations: the experimental page now only lists integrations that can be installed; unavailable and Coming soon entries were removed. - Providers: expanded support for custom providers. - Sessions created outside OpenChamber now appear in the sidebar and Recent list without a page refresh (thanks to @tomzx). - If OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). @@ -9,6 +14,7 @@ - Chat: file paths in messages now open from the session's workspace, even if you last browsed files in another workspace (thanks to @tomzx). - While a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes. - Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off. +- Chat: long user messages can be expanded even when their final layout finishes after they first appear. - UI: the default dialog close button is easier to click or tap (thanks to @rockinrimmer). ## [1.19.0] - 2026-08-19 From 3a78d862488824e0a492b7212dfcd956fa96388a Mon Sep 17 00:00:00 2001 From: ChangeHow <23733347+ChangeHow@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:53:21 +0800 Subject: [PATCH 58/59] fix(ui): open app deep links from chat after confirmation (#2932) * fix(ui): open app deep links from chat after confirmation DOMPurify's default URI policy stripped href from anchors with custom application schemes (obsidian://, vscode://, ...), so every app link rendered in chat was dead across web, desktop, VS Code, and mobile. - Classify safe app-link schemes in lib/url.ts (browser-handled, scriptable, webview-internal, network, and self-deep-link schemes stay excluded) and let openExternalUrl accept them - Keep app-link hrefs through the markdown sanitize hook - Intercept app-link clicks in the markdown renderer and route them through a confirmation dialog (Trust and open / Open once, dismiss to cancel) mounted in the desktop/web app root and the mobile shell - Persist per-device trusted schemes in a zustand store; trusted schemes open without asking again * feat(settings): manage trusted app link schemes in General Add an App links section to Settings > General listing the application schemes trusted on this device with a delete action; removing a scheme restores the confirmation dialog for it. Register the section in settings search. * fix(ui): enforce app link confirmation * fix(ui): handle app links by runtime * fix(vscode): keep app links unsupported * fix(settings): clarify trusted app links --------- Co-authored-by: Bohdan Triapitsyn --- CHANGELOG.md | 1 + packages/ui/src/App.tsx | 3 + packages/ui/src/apps/ElectronMiniChatApp.tsx | 2 + packages/ui/src/apps/MobileApp.tsx | 2 + packages/ui/src/apps/VSCodeApp.tsx | 3 + .../chat/AppLinkConfirmDialog.test.tsx | 46 +++++++++ .../components/chat/AppLinkConfirmDialog.tsx | 77 +++++++++++++++ .../components/chat/MarkdownRendererImpl.tsx | 53 +++-------- .../chat/appLinkConfirmation.test.ts | 67 +++++++++++++ .../components/chat/appLinkConfirmation.ts | 71 ++++++++++++++ .../chat/appLinkInteractions.test.ts | 93 +++++++++++++++++++ .../components/chat/appLinkInteractions.ts | 75 +++++++++++++++ .../chat/markdown/markdownCore.test.ts | 52 ++++++++++- .../components/chat/markdown/markdownCore.ts | 16 +++- .../chat/message/parts/DOCUMENTATION.md | 3 +- .../openchamber/AppLinkSecuritySettings.tsx | 48 ++++++++++ .../sections/openchamber/OpenChamberPage.tsx | 3 + .../ui/src/lib/i18n/messages/de.settings.ts | 4 + packages/ui/src/lib/i18n/messages/de.ts | 6 ++ .../ui/src/lib/i18n/messages/en.settings.ts | 4 + packages/ui/src/lib/i18n/messages/en.ts | 6 ++ .../ui/src/lib/i18n/messages/es.settings.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 6 ++ .../ui/src/lib/i18n/messages/fr.settings.ts | 4 + packages/ui/src/lib/i18n/messages/fr.ts | 6 ++ .../ui/src/lib/i18n/messages/ja.settings.ts | 4 + packages/ui/src/lib/i18n/messages/ja.ts | 6 ++ .../ui/src/lib/i18n/messages/ko.settings.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 6 ++ .../ui/src/lib/i18n/messages/pl.settings.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 6 ++ .../src/lib/i18n/messages/pt-BR.settings.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 6 ++ .../ui/src/lib/i18n/messages/uk.settings.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 6 ++ .../src/lib/i18n/messages/zh-CN.settings.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 6 ++ .../src/lib/i18n/messages/zh-TW.settings.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 6 ++ packages/ui/src/lib/settings/search.ts | 8 +- packages/ui/src/lib/url.test.ts | 62 +++++++++++++ packages/ui/src/lib/url.ts | 68 +++++++++++++- .../ui/src/stores/appLinkTrustStore.test.ts | 50 ++++++++++ packages/ui/src/stores/appLinkTrustStore.ts | 48 ++++++++++ packages/vscode/CHANGELOG.md | 1 + 45 files changed, 909 insertions(+), 53 deletions(-) create mode 100644 packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx create mode 100644 packages/ui/src/components/chat/AppLinkConfirmDialog.tsx create mode 100644 packages/ui/src/components/chat/appLinkConfirmation.test.ts create mode 100644 packages/ui/src/components/chat/appLinkConfirmation.ts create mode 100644 packages/ui/src/components/chat/appLinkInteractions.test.ts create mode 100644 packages/ui/src/components/chat/appLinkInteractions.ts create mode 100644 packages/ui/src/components/sections/openchamber/AppLinkSecuritySettings.tsx create mode 100644 packages/ui/src/lib/url.test.ts create mode 100644 packages/ui/src/stores/appLinkTrustStore.test.ts create mode 100644 packages/ui/src/stores/appLinkTrustStore.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 96723c6f..a0923699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file. - Settings/Providers: the provider you select no longer jumps to a different one on its own. Changing the chat's model or agent, and background provider refreshes, used to move the settings selection with them. - Settings/Integrations: the experimental page now only lists integrations that can be installed; unavailable and Coming soon entries were removed. - Chat: file paths in messages now open from the session's project, even if you last browsed files in another project (thanks to @tomzx). +- Chat: app links such as `spotify://` now ask for confirmation before opening another app. You can trust an app link type on one device and manage trusted links in Settings. - Files/Desktop: files opened from outside the workspace remain readable after their temporary access expires instead of failing until you reopen them (thanks to @pascalandr). - Diff: creating an inline comment now opens the chat and focuses the composer for your follow-up. - Chat: in the expanded composer, Enter now starts a new line and Cmd/Ctrl+Enter sends, so a long prompt is harder to send by accident. diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 3470f692..5c9e8806 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { MainLayout } from '@/components/layout/MainLayout'; import { ChatView } from '@/components/views/ChatView'; +import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { FireworksProvider } from '@/contexts/FireworksContext'; import { Toaster } from '@/components/ui/sonner'; import { Button } from '@/components/ui/button'; @@ -908,6 +909,7 @@ function App({ apis }: AppProps) { isVSCodeRuntime={isVSCodeRuntime} embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} /> +
@@ -951,6 +953,7 @@ function App({ apis }: AppProps) { + {!isBootShell && ( <> diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx index d1d53b50..7aed5ba0 100644 --- a/packages/ui/src/apps/ElectronMiniChatApp.tsx +++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx @@ -5,6 +5,7 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { TooltipProvider } from '@/components/ui/tooltip'; import { Toaster } from '@/components/ui/sonner'; import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout'; +import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { opencodeClient } from '@/lib/opencode/client'; @@ -325,6 +326,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
+
diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index b790270c..4d30dc00 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -9,6 +9,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { ChatView } from '@/components/views/ChatView'; import { PlanView } from '@/components/views/PlanView'; import { SettingsView } from '@/components/views/SettingsView'; +import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; @@ -1258,6 +1259,7 @@ export function MobileApp({ apis }: MobileAppProps) { switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); setConnectionEpoch((value) => value + 1); }} /> + {isInitialized ? : null}
diff --git a/packages/ui/src/apps/VSCodeApp.tsx b/packages/ui/src/apps/VSCodeApp.tsx index 9090cd1d..737a0239 100644 --- a/packages/ui/src/apps/VSCodeApp.tsx +++ b/packages/ui/src/apps/VSCodeApp.tsx @@ -8,6 +8,7 @@ import { Toaster } from '@/components/ui/sonner'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; +import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { VSCodeLayout } from '@/components/layout/VSCodeLayout'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling'; @@ -110,6 +111,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
+
@@ -129,6 +131,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
+ diff --git a/packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx b/packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx new file mode 100644 index 00000000..ceb2624e --- /dev/null +++ b/packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { I18nProvider } from '@/lib/i18n'; + +mock.module('@/components/ui/dialog', () => ({ + Dialog: ({ children }: React.PropsWithChildren) => <>{children}, + DialogContent: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogDescription: ({ children }: React.PropsWithChildren) =>

{children}

, + DialogFooter: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogHeader: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogTitle: ({ children }: React.PropsWithChildren) =>

{children}

, +})); + +const { AppLinkConfirmDialog } = await import('./AppLinkConfirmDialog'); +const { + getAppLinkConfirmationSnapshot, + openAppLinkWithConfirmation, + settleAppLinkConfirmation, +} = await import('./appLinkConfirmation'); + +describe('AppLinkConfirmDialog', () => { + beforeEach(() => { + if (getAppLinkConfirmationSnapshot()) { + settleAppLinkConfirmation('cancel'); + } + }); + + test('keeps cancel visible and focused beside both open choices', () => { + void openAppLinkWithConfirmation('obsidian://open?vault=Notebook'); + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain('>Cancel'); + expect(markup).toContain('autofocus=""'); + expect(markup).toContain('>Open once'); + expect(markup).toContain('>Trust and open'); + + settleAppLinkConfirmation('cancel'); + }); +}); diff --git a/packages/ui/src/components/chat/AppLinkConfirmDialog.tsx b/packages/ui/src/components/chat/AppLinkConfirmDialog.tsx new file mode 100644 index 00000000..73960a9e --- /dev/null +++ b/packages/ui/src/components/chat/AppLinkConfirmDialog.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useI18n } from '@/lib/i18n'; +import { getUrlScheme } from '@/lib/url'; + +import { + getAppLinkConfirmationSnapshot, + settleAppLinkConfirmation, + subscribeAppLinkConfirmation, + type AppLinkConfirmationChoice, +} from './appLinkConfirmation'; + +/** + * App-level dialog confirming application deep links (obsidian://, vscode://, + * ...) rendered in chat markdown before the OS is asked to open them. + * Dismissing via the close button, Escape, or the backdrop cancels the open. + */ +export const AppLinkConfirmDialog = () => { + const { t } = useI18n(); + const request = React.useSyncExternalStore( + subscribeAppLinkConfirmation, + getAppLinkConfirmationSnapshot, + getAppLinkConfirmationSnapshot, + ); + + const url = request?.url ?? ''; + const scheme = getUrlScheme(url) ?? ''; + + const settle = React.useCallback((choice: AppLinkConfirmationChoice) => { + settleAppLinkConfirmation(choice); + }, []); + + return ( + { + if (!open) { + settle('cancel'); + } + }} + > + + + {t('chat.appLink.confirm.title')} + + {scheme + ? t('chat.appLink.confirm.description', { scheme: `${scheme}://` }) + : t('chat.appLink.confirm.descriptionPlain')} + + +
+ {url} +
+ + + + + +
+
+ ); +}; diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 29d5416d..b8af3e03 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -4,10 +4,12 @@ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid'; import type { Part } from '@opencode-ai/sdk/v2'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; -import { isExternalHttpUrl, openExternalUrl } from '@/lib/url'; +import { openExternalUrl } from '@/lib/url'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { getDefaultTheme } from '@/lib/theme/themes'; import type { Theme } from '@/types/theme'; +import { openAppLinkWithConfirmation } from './appLinkConfirmation'; +import { attachAppLinkInteractions } from './appLinkInteractions'; import type { ToolPopupContent } from './message/types'; import { FadeInOnReveal } from './message/FadeInOnReveal'; import { useUIStore } from '@/stores/useUIStore'; @@ -55,7 +57,7 @@ const useCurrentMermaidTheme = () => { : fallbackLight); }; -const useExternalLinkInteractions = ({ +const useLinkInteractions = ({ containerRef, enabled, }: { @@ -63,48 +65,16 @@ const useExternalLinkInteractions = ({ enabled?: boolean; }) => { React.useEffect(() => { - if (enabled === false) { - return; - } - const container = containerRef.current; if (!container) { return; } - const handleClick = (event: MouseEvent) => { - if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) { - return; - } - - const target = event.target; - if (!(target instanceof Element)) { - return; - } - - const anchor = target.closest('a[href]'); - if (!(anchor instanceof HTMLAnchorElement)) { - return; - } - - if (anchor.getAttribute('data-openchamber-file-link') === 'true') { - return; - } - - const href = anchor.getAttribute('href') ?? ''; - if (!isExternalHttpUrl(href)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - void openExternalUrl(href); - }; - - container.addEventListener('click', handleClick); - return () => { - container.removeEventListener('click', handleClick); - }; + return attachAppLinkInteractions(container, { + allowExternalHttp: enabled !== false, + openAppLink: (href) => void openAppLinkWithConfirmation(href), + openExternalHttp: (href) => void openExternalUrl(href), + }); }, [containerRef, enabled]); }; @@ -969,7 +939,7 @@ const MarkdownRendererImpl: React.FC = ({ preferRuntimeEditor: runtime.isVSCode, enabled: enableFileReferences && !isStreaming, }); - useExternalLinkInteractions({ containerRef }); + useLinkInteractions({ containerRef }); const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS); @@ -1020,6 +990,7 @@ const SimpleMarkdownRendererImpl: React.FC<{ content: string; className?: string; variant?: MarkdownVariant; + // App links remain confirmed even where ordinary HTTP link handling is off. disableLinkSafety?: boolean; stripFrontmatter?: boolean; onShowPopup?: (content: ToolPopupContent) => void; @@ -1061,7 +1032,7 @@ const SimpleMarkdownRendererImpl: React.FC<{ preferRuntimeEditor: runtime.isVSCode, enabled: enableFileReferences, }); - useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety }); + useLinkInteractions({ containerRef, enabled: !disableLinkSafety }); const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]); const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls); diff --git a/packages/ui/src/components/chat/appLinkConfirmation.test.ts b/packages/ui/src/components/chat/appLinkConfirmation.test.ts new file mode 100644 index 00000000..1fb41557 --- /dev/null +++ b/packages/ui/src/components/chat/appLinkConfirmation.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; + +import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore'; + +import { + getAppLinkConfirmationSnapshot, + openAppLinkWithConfirmation, + settleAppLinkConfirmation, +} from './appLinkConfirmation'; + +describe('app link confirmation', () => { + beforeEach(() => { + useAppLinkTrustStore.setState({ trustedSchemes: [] }); + const pending = getAppLinkConfirmationSnapshot(); + if (pending) { + settleAppLinkConfirmation('cancel'); + } + }); + + test('opens trusted schemes without asking', async () => { + useAppLinkTrustStore.getState().trustScheme('obsidian'); + + await openAppLinkWithConfirmation('obsidian://open?vault=Notebook&file=notes'); + + expect(getAppLinkConfirmationSnapshot()).toBeNull(); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(true); + }); + + test('asks once and trusts the scheme when the user chooses trust', async () => { + const pending = openAppLinkWithConfirmation('linear://issue/ABC-1'); + + expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://issue/ABC-1'); + + settleAppLinkConfirmation('trust'); + await pending; + + expect(getAppLinkConfirmationSnapshot()).toBeNull(); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(true); + }); + + test('cancel opens nothing and keeps the scheme untrusted', async () => { + const pending = openAppLinkWithConfirmation('notion://note/xyz'); + + settleAppLinkConfirmation('cancel'); + await pending; + + expect(getAppLinkConfirmationSnapshot()).toBeNull(); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('notion')).toBe(false); + }); + + test('a newer request cancels the pending one', async () => { + const first = openAppLinkWithConfirmation('obsidian://open?vault=a'); + const firstChoice = first.then( + () => 'settled', + () => 'settled', + ); + const second = openAppLinkWithConfirmation('linear://open/1'); + + expect(await firstChoice).toBe('settled'); + expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://open/1'); + + settleAppLinkConfirmation('open'); + await second; + + expect(getAppLinkConfirmationSnapshot()).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/chat/appLinkConfirmation.ts b/packages/ui/src/components/chat/appLinkConfirmation.ts new file mode 100644 index 00000000..d9eea31e --- /dev/null +++ b/packages/ui/src/components/chat/appLinkConfirmation.ts @@ -0,0 +1,71 @@ +import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore'; +import { getUrlScheme, openConfirmedAppLinkUrl } from '@/lib/url'; + +export type AppLinkConfirmationChoice = 'open' | 'trust' | 'cancel'; + +type PendingAppLinkRequest = { + url: string; + resolve: (choice: AppLinkConfirmationChoice) => void; +}; + +let pendingRequest: PendingAppLinkRequest | null = null; +const listeners = new Set<() => void>(); + +const emitChange = (): void => { + for (const listener of listeners) { + listener(); + } +}; + +const getSnapshot = (): PendingAppLinkRequest | null => pendingRequest; + +const subscribe = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +/** + * Ask the user (via the app-level confirmation dialog) whether an application + * deep link may be opened. Resolves immediately when the scheme was trusted + * earlier. Only one request is active at a time; a new request cancels the + * pending one. + */ +export const openAppLinkWithConfirmation = (url: string): Promise => { + const scheme = getUrlScheme(url); + if (!scheme) { + return Promise.resolve(); + } + + const trustStore = useAppLinkTrustStore.getState(); + if (trustStore.isSchemeTrusted(scheme)) { + return openConfirmedAppLinkUrl(url).then(() => undefined); + } + + if (pendingRequest) { + pendingRequest.resolve('cancel'); + } + + return new Promise((resolve) => { + pendingRequest = { url, resolve }; + emitChange(); + }).then((choice) => { + if (choice === 'trust') { + useAppLinkTrustStore.getState().trustScheme(scheme); + } + if (choice === 'open' || choice === 'trust') { + return openConfirmedAppLinkUrl(url).then(() => undefined); + } + }); +}; + +export const settleAppLinkConfirmation = (choice: AppLinkConfirmationChoice): void => { + const request = pendingRequest; + pendingRequest = null; + emitChange(); + request?.resolve(choice); +}; + +export const subscribeAppLinkConfirmation = subscribe; +export const getAppLinkConfirmationSnapshot = getSnapshot; diff --git a/packages/ui/src/components/chat/appLinkInteractions.test.ts b/packages/ui/src/components/chat/appLinkInteractions.test.ts new file mode 100644 index 00000000..e32acbd9 --- /dev/null +++ b/packages/ui/src/components/chat/appLinkInteractions.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from 'bun:test'; + +import { attachAppLinkInteractions } from './appLinkInteractions'; + +const TestElement = class Element {}; +const TestHTMLAnchorElement = class HTMLAnchorElement extends TestElement {}; +Object.assign(globalThis, { Element: TestElement, HTMLAnchorElement: TestHTMLAnchorElement }); + +class TestAnchor extends HTMLAnchorElement { + constructor(private readonly rawHref: string) { + super(); + } + + getAttribute(name: string): string | null { + return name === 'href' ? this.rawHref : null; + } + + closest(): TestAnchor { + return this; + } +} + +class TestContainer { + listeners = new Map(); + + addEventListener(name: string, listener: (event: MouseEvent) => void): void { + // SAFETY: dispatch constructs every mouse field read by the production listener. + this.listeners.set(name, (event) => listener(event as MouseEvent)); + } + + removeEventListener(name: string, listener: (event: MouseEvent) => void): void { + void listener; + this.listeners.delete(name); + } + + dispatch(name: string, href: string, init: Partial = {}): Event { + const event = new Event(name, { cancelable: true }); + Object.defineProperties(event, { + target: { value: new TestAnchor(href) }, + button: { value: init.button ?? 0 }, + metaKey: { value: init.metaKey ?? false }, + ctrlKey: { value: init.ctrlKey ?? false }, + altKey: { value: init.altKey ?? false }, + shiftKey: { value: init.shiftKey ?? false }, + }); + this.listeners.get(name)?.(event); + return event; + } +} + +const setup = (allowExternalHttp = true) => { + const container = new TestContainer(); + const appLinks: string[] = []; + const httpLinks: string[] = []; + const cleanup = attachAppLinkInteractions(container, { + allowExternalHttp, + openAppLink: (url) => appLinks.push(url), + openExternalHttp: (url) => httpLinks.push(url), + }); + return { container, appLinks, httpLinks, cleanup }; +}; + +describe('app link interactions', () => { + test('confirms plain, modifier, and middle-click activations', () => { + const { container, appLinks } = setup(); + const href = 'obsidian://open?vault=Notes'; + + expect(container.dispatch('click', href).defaultPrevented).toBe(true); + expect(container.dispatch('click', href, { metaKey: true }).defaultPrevented).toBe(true); + expect(container.dispatch('auxclick', href, { button: 1 }).defaultPrevented).toBe(true); + expect(appLinks).toEqual([href, href, href]); + }); + + test('blocks drag activation without opening immediately', () => { + const { container, appLinks } = setup(); + const href = 'obsidian://open?vault=Notes'; + + expect(container.dispatch('dragstart', href).defaultPrevented).toBe(true); + expect(appLinks).toEqual([]); + }); + + test('keeps HTTP modifier behavior and the disabled HTTP path unchanged', () => { + const enabled = setup(); + const disabled = setup(false); + const href = 'https://example.com'; + + expect(enabled.container.dispatch('click', href, { ctrlKey: true }).defaultPrevented).toBe(false); + expect(enabled.container.dispatch('click', href).defaultPrevented).toBe(true); + expect(disabled.container.dispatch('click', href).defaultPrevented).toBe(false); + expect(enabled.httpLinks).toEqual([href]); + expect(disabled.httpLinks).toEqual([]); + }); +}); diff --git a/packages/ui/src/components/chat/appLinkInteractions.ts b/packages/ui/src/components/chat/appLinkInteractions.ts new file mode 100644 index 00000000..595e671a --- /dev/null +++ b/packages/ui/src/components/chat/appLinkInteractions.ts @@ -0,0 +1,75 @@ +import { isAppLinkUrl, isExternalHttpUrl } from '@/lib/url'; + +type AppLinkInteractionOptions = { + allowExternalHttp: boolean; + openAppLink: (url: string) => void; + openExternalHttp: (url: string) => void; +}; + +type LinkInteractionContainer = { + addEventListener: (type: string, listener: (event: MouseEvent) => void) => void; + removeEventListener: (type: string, listener: (event: MouseEvent) => void) => void; +}; + +const findLink = (event: MouseEvent | DragEvent): HTMLAnchorElement | null => { + const target = event.target; + if (!(target instanceof Element)) return null; + const anchor = target.closest('a[href]'); + if (!(anchor instanceof HTMLAnchorElement)) return null; + if (anchor.getAttribute('data-openchamber-file-link') === 'true') return null; + return anchor; +}; + +const interceptAppLink = ( + event: MouseEvent | DragEvent, + openAppLink?: (url: string) => void, +): boolean => { + if (event.defaultPrevented) return false; + const anchor = findLink(event); + const href = anchor?.getAttribute('href') ?? ''; + if (!isAppLinkUrl(href)) return false; + + event.preventDefault(); + event.stopPropagation(); + openAppLink?.(href); + return true; +}; + +const isPlainPrimaryClick = (event: MouseEvent): boolean => ( + event.button === 0 + && !event.metaKey + && !event.ctrlKey + && !event.altKey + && !event.shiftKey +); + +export const attachAppLinkInteractions = ( + container: LinkInteractionContainer, + options: AppLinkInteractionOptions, +): (() => void) => { + const handleClick = (event: MouseEvent) => { + if (interceptAppLink(event, options.openAppLink)) return; + if (!options.allowExternalHttp || event.defaultPrevented || !isPlainPrimaryClick(event)) return; + + const href = findLink(event)?.getAttribute('href') ?? ''; + if (!isExternalHttpUrl(href)) return; + event.preventDefault(); + event.stopPropagation(); + options.openExternalHttp(href); + }; + const handleAuxClick = (event: MouseEvent) => { + if (event.button === 1) interceptAppLink(event, options.openAppLink); + }; + const blockAlternateAppLinkActivation = (event: MouseEvent | DragEvent) => { + interceptAppLink(event); + }; + + container.addEventListener('click', handleClick); + container.addEventListener('auxclick', handleAuxClick); + container.addEventListener('dragstart', blockAlternateAppLinkActivation); + return () => { + container.removeEventListener('click', handleClick); + container.removeEventListener('auxclick', handleAuxClick); + container.removeEventListener('dragstart', blockAlternateAppLinkActivation); + }; +}; diff --git a/packages/ui/src/components/chat/markdown/markdownCore.test.ts b/packages/ui/src/components/chat/markdown/markdownCore.test.ts index 968363d3..9153250d 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.test.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.test.ts @@ -1,10 +1,43 @@ import { describe, expect, mock, test } from 'bun:test'; +type SanitizeAttribute = { + attrName: string; + attrValue: string; + forceKeepAttr?: boolean; +}; + +class TestAnchorElement { + target = ''; + + setAttribute(name: string, value: string): void { + if (name === 'target') this.target = value; + } +} + +const sanitizeHooks: { + uponSanitizeAttribute?: (node: unknown, data: SanitizeAttribute) => void; + afterSanitizeAttributes?: (node: unknown) => void; +} = {}; + +Object.assign(globalThis, { + window: {}, + HTMLAnchorElement: TestAnchorElement, +}); + mock.module('dompurify', () => ({ default: { isSupported: true, - addHook: () => undefined, - sanitize: (html: string) => html, + addHook: (name: keyof typeof sanitizeHooks, hook: never) => { + sanitizeHooks[name] = hook; + }, + sanitize: (html: string) => html.replace(/ href="([^"]*)"/g, (attribute, href: string) => { + const anchor = new TestAnchorElement(); + const data: SanitizeAttribute = { attrName: 'href', attrValue: href }; + sanitizeHooks.uponSanitizeAttribute?.(anchor, data); + sanitizeHooks.afterSanitizeAttributes?.(anchor); + + return data.forceKeepAttr || /^(?:https?|mailto|tel):/i.test(href) ? attribute : ''; + }), }, })); mock.module('./markdown-worker', () => ({ @@ -40,6 +73,21 @@ describe('markdown sanitization', () => { expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false); expect(isLocalFileUrl('javascript:alert(1)')).toBe(false); }); + + test('keeps app and local file links while stripping blocked schemes', () => { + const html = renderMarkdownSync([ + '[app](obsidian://open?vault=Notebook)', + '[file](file:///workspace/notes.md)', + '[script](javascript:alert(1))', + '[diagnostic](ms-msdt:/id%20PCWDiagnostic)', + ].join('\n\n'), 'inline'); + + expect(html).toContain('href="obsidian://open?vault=Notebook"'); + expect(html).toContain('href="file:///workspace/notes.md"'); + expect(html).not.toContain('href="javascript:alert(1)"'); + expect(html).not.toContain('href="ms-msdt:/id%20PCWDiagnostic"'); + }); + }); describe('Markdown images', () => { diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index 2d3cb7a9..822a3168 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -3,6 +3,7 @@ import remend from 'remend'; import katex from 'katex'; import DOMPurify from 'dompurify'; import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks'; +import { isAppLinkUrl } from '@/lib/url'; import { isVSCodeRuntime } from '@/lib/desktop'; import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache'; import { highlightCodeInWorker } from './markdown-worker'; @@ -472,7 +473,10 @@ const ensureSanitizeHook = (): void => { sanitizeHookInstalled = true; DOMPurify.addHook('uponSanitizeAttribute', (node, data) => { if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return; - if (isLocalFileUrl(data.attrValue)) data.forceKeepAttr = true; + // DOMPurify's default URI policy strips custom application schemes + // (obsidian://, vscode://, ...). Keep them for anchors; dangerous schemes + // stay excluded via isAppLinkUrl and clicks go through confirmation. + if (isLocalFileUrl(data.attrValue) || isAppLinkUrl(data.attrValue)) data.forceKeepAttr = true; }); DOMPurify.addHook('afterSanitizeAttributes', (node) => { if (!(node instanceof HTMLAnchorElement)) return; @@ -544,7 +548,10 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe live: liveBlockCache.size, }); -const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise => { +const parseBlock = async ( + block: MarkdownBlock, + imageMode: MarkdownImageMode, +): Promise => { const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser; const parsed = await Promise.resolve(parser.parse(block.src)); const withMath = renderMathExpressions(parsed); @@ -561,7 +568,10 @@ const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): P * is synchronous (marked is not configured `async`), so this never blocks on a * worker round-trip. */ -export const renderMarkdownSync = (text: string, imageMode: MarkdownImageMode = 'inline'): string => { +export const renderMarkdownSync = ( + text: string, + imageMode: MarkdownImageMode = 'inline', +): string => { if (!text) return ''; const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser; const parsed = parser.parse(text) as string; diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 730b78e2..9cd7bf7a 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -54,7 +54,8 @@ Use this doc when you ask an agent to change tool/header/description behavior. - Assistant markdown treats raw HTML as inert visible text. The final generated HTML is sanitized as defense in depth, with script and style elements forbidden, so message content cannot inject active DOM or application-wide - CSS into any runtime surface. + CSS into any runtime surface. Safe custom application links go through the + app-link confirmation flow in every supported renderer, including VS Code. - Final assistant Markdown rendering is independent from image gallery extraction: gallery presence never changes the chat body. Assistant image syntax consistently renders as a shared image icon followed by its filename, diff --git a/packages/ui/src/components/sections/openchamber/AppLinkSecuritySettings.tsx b/packages/ui/src/components/sections/openchamber/AppLinkSecuritySettings.tsx new file mode 100644 index 00000000..55eafdda --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/AppLinkSecuritySettings.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import { Button } from '@/components/ui/button'; +import { SettingsSection } from '@/components/sections/shared/SettingsSection'; +import { useI18n } from '@/lib/i18n'; +import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore'; + +/** + * Security section for application deep links (obsidian://, notion://, ...) + * that the user chose to always allow from chat. Removing a scheme restores + * the confirmation dialog for it. + */ +export const AppLinkSecuritySettings: React.FC = () => { + const { t } = useI18n(); + const trustedSchemes = useAppLinkTrustStore((state) => state.trustedSchemes); + const removeTrustedScheme = useAppLinkTrustStore((state) => state.removeTrustedScheme); + + return ( + +
+ {trustedSchemes.length === 0 ? ( +

+ {t('settings.openchamber.appLinks.empty')} +

+ ) : ( + trustedSchemes.map((scheme) => ( +
+ {`${scheme}://`} + +
+ )) + )} +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 08614a31..43fd56c0 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -3,6 +3,7 @@ import { OpenChamberVisualSettings } from './OpenChamberVisualSettings'; import { AboutSettings } from './AboutSettings'; import { SessionRetentionSettings } from './SessionRetentionSettings'; import { PasskeySettings } from './PasskeySettings'; +import { AppLinkSecuritySettings } from './AppLinkSecuritySettings'; import { DefaultsSettings } from './DefaultsSettings'; import { GitSettings } from './GitSettings'; import { NotificationSettings } from './NotificationSettings'; @@ -55,6 +56,7 @@ export const OpenChamberPage: React.FC = ({ section }) => {!isVSCode && } {!isVSCode && } + {isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp() && } {showAbout && } @@ -145,6 +147,7 @@ const GeneralSectionContent: React.FC = () => { <> {showDesktopNetworkSettings && } {showPasskeySettings && } + {!isVSCode && } {!isVSCode && } = { 'chat.dictation.retry': 'Reintentar transcripción', 'chat.dictation.discard': 'Descartar grabación', 'chat.history.loadOlder': 'Cargar mensajes anteriores', + "chat.appLink.confirm.title": "¿Abrir este enlace en otra aplicación?", + "chat.appLink.confirm.description": "Este enlace del chat usa el protocolo {scheme} y se abrirá en otra aplicación.", + "chat.appLink.confirm.descriptionPlain": "Este enlace del chat se abrirá en otra aplicación.", + "chat.appLink.confirm.cancel": "Cancelar", + "chat.appLink.confirm.open": "Abrir una vez", + "chat.appLink.confirm.trustAndOpen": "Confiar y abrir", 'chat.autoReview.title': 'El ciclo de revisión de código está en curso', 'chat.autoReview.status.waitingForReviewer': 'Esperando al revisor', 'chat.autoReview.status.waitingForImplementer': 'Esperando al implementador', diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index cdaffae8..88d46f33 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -324,6 +324,10 @@ export const settingsDict = { 'settings.common.actions.cancel': 'Annuler', 'settings.common.actions.create': 'Créer', 'settings.common.actions.delete': 'Supprimer', + 'settings.openchamber.appLinks.title': 'Liens d’application approuvés', + 'settings.openchamber.appLinks.info': 'Les liens de cette liste s’ouvrent sans nouvelle demande sur cet appareil. Les autres liens d’application demandent toujours une confirmation.', + 'settings.openchamber.appLinks.empty': 'Aucun lien d’application approuvé sur cet appareil. Choisissez « Approuver et ouvrir » lors de l’ouverture d’un lien pour l’ajouter ici.', + 'settings.openchamber.appLinks.removeAria': 'Supprimer les liens {scheme} approuvés', 'settings.common.actions.reset': 'Réinitialiser', 'settings.common.actions.rename': 'Rebaptiser', 'settings.common.actions.duplicate': 'Dupliquer', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index fd989b8f..af006030 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1314,6 +1314,12 @@ export const dict = { 'diffView.reviewDialog.toast.noSessionDirectory': 'Le dossier de session est indisponible', 'diffView.reviewDialog.toast.startFailed': 'Impossible de démarrer le flux de revue', 'chat.history.loadOlder': 'Charger les messages précédents', + 'chat.appLink.confirm.title': 'Ouvrir ce lien dans une autre application ?', + 'chat.appLink.confirm.description': "Ce lien de discussion utilise le protocole {scheme} et s'ouvrira dans une autre application.", + 'chat.appLink.confirm.descriptionPlain': "Ce lien de discussion s'ouvrira dans une autre application.", + 'chat.appLink.confirm.cancel': 'Annuler', + 'chat.appLink.confirm.open': 'Ouvrir une fois', + 'chat.appLink.confirm.trustAndOpen': 'Approuver et ouvrir', 'chat.autoReview.title': 'La boucle de revue de code est en cours', 'chat.autoReview.status.waitingForReviewer': 'En attente du reviewer', 'chat.autoReview.status.waitingForImplementer': 'En attente de l’implémenteur', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index b83da80c..f4070831 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -434,6 +434,10 @@ export const settingsDict = { 'settings.common.actions.cancel': 'キャンセル', 'settings.common.actions.create': '作成', 'settings.common.actions.delete': '削除', + 'settings.openchamber.appLinks.title': '信頼済みのアプリリンク', + 'settings.openchamber.appLinks.info': 'ここに表示されたリンクは、このデバイスでは次回から確認せずに開きます。その他のアプリリンクは開く前に必ず確認します。', + 'settings.openchamber.appLinks.empty': 'このデバイスには信頼済みのアプリリンクがありません。リンクを開く際に「信頼して開く」を選ぶとここに追加されます。', + 'settings.openchamber.appLinks.removeAria': '信頼済みの {scheme} リンクを削除', 'settings.common.actions.reset': 'リセット', 'settings.common.actions.rename': '名前変更', 'settings.common.actions.duplicate': '複製', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index f233371b..ebe9b889 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1554,6 +1554,12 @@ export const dict: Record = { 'diffView.hunk.unavailable': 'このハンクはもう利用できません。差分を更新してからもう一度お試しください。', 'diffView.hunk.unsupported': '個別のハンクのステージングはこのランタイムではサポートされていません。', 'chat.history.loadOlder': '以前のメッセージを読み込む', + 'chat.appLink.confirm.title': 'このリンクを別のアプリで開きますか?', + 'chat.appLink.confirm.description': 'このチャットのリンクは {scheme} プロトコルを使用し、別のアプリで開かれます。', + 'chat.appLink.confirm.descriptionPlain': 'このチャットのリンクは別のアプリで開かれます。', + 'chat.appLink.confirm.cancel': 'キャンセル', + 'chat.appLink.confirm.open': '一度だけ開く', + 'chat.appLink.confirm.trustAndOpen': '信頼して開く', 'chat.autoReview.title': 'コードレビューループが実行中です', 'chat.autoReview.status.waitingForReviewer': 'レビュアーを待機中', 'chat.autoReview.status.waitingForImplementer': '実装者を待機中', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index a8e05bba..e4e2ef9e 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -401,6 +401,10 @@ export const settingsDict = { 'settings.common.actions.cancel': '취소', 'settings.common.actions.create': '생성', 'settings.common.actions.delete': '삭제', + 'settings.openchamber.appLinks.title': '신뢰한 앱 링크', + 'settings.openchamber.appLinks.info': '여기에 표시된 링크는 이 기기에서 다시 묻지 않고 열립니다. 그 밖의 앱 링크는 열기 전에 항상 확인합니다.', + 'settings.openchamber.appLinks.empty': '이 기기에 신뢰한 앱 링크가 없습니다. 링크를 열 때 "신뢰하고 열기"를 선택하면 여기에 추가됩니다.', + 'settings.openchamber.appLinks.removeAria': '신뢰된 {scheme} 링크 제거', 'settings.common.actions.reset': '초기화', 'settings.common.actions.rename': '이름 변경', 'settings.common.actions.duplicate': '복제', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index bfe88f96..6300cd13 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1551,6 +1551,12 @@ export const dict: Record = { 'diffView.reviewDialog.toast.noSessionDirectory': '세션 디렉터리를 사용할 수 없습니다', 'diffView.reviewDialog.toast.startFailed': '리뷰 흐름을 시작하지 못했습니다', 'chat.history.loadOlder': '이전 메시지 불러오기', + 'chat.appLink.confirm.title': '이 링크를 다른 앱에서 열까요?', + 'chat.appLink.confirm.description': '이 채팅 링크는 {scheme} 프로토콜을 사용하며 다른 앱에서 열립니다.', + 'chat.appLink.confirm.descriptionPlain': '이 채팅 링크는 다른 앱에서 열립니다.', + 'chat.appLink.confirm.cancel': '취소', + 'chat.appLink.confirm.open': '한 번만 열기', + 'chat.appLink.confirm.trustAndOpen': '신뢰하고 열기', 'chat.autoReview.title': '코드 리뷰 루프 실행 중', 'chat.autoReview.status.waitingForReviewer': '리뷰어를 기다리는 중', 'chat.autoReview.status.waitingForImplementer': '구현 에이전트를 기다리는 중', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 3555958f..7bebca44 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -216,6 +216,10 @@ export const settingsDict = { 'settings.common.actions.copyAll': 'Kopiuj wszystko', 'settings.common.actions.create': 'Utwórz', 'settings.common.actions.delete': 'Usuń', + 'settings.openchamber.appLinks.title': 'Zaufane linki aplikacji', + 'settings.openchamber.appLinks.info': 'Linki z tej listy otwierają się na tym urządzeniu bez ponownego pytania. Inne linki aplikacji zawsze wymagają potwierdzenia.', + 'settings.openchamber.appLinks.empty': 'Brak zaufanych linków aplikacji na tym urządzeniu. Wybierz „Zaufaj i otwórz” podczas otwierania linku, aby dodać go tutaj.', + 'settings.openchamber.appLinks.removeAria': 'Usuń zaufane linki {scheme}', 'settings.common.actions.duplicate': 'Duplikuj', 'settings.common.actions.import': 'Importuj', 'settings.common.actions.rename': 'Zmień nazwę', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index c8ded4ba..666c1d73 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1763,6 +1763,12 @@ export const dict: Record = { 'diffView.reviewDialog.toast.noSessionDirectory': 'Katalog sesji jest niedostępny', 'diffView.reviewDialog.toast.startFailed': 'Nie udało się uruchomić flow review', 'chat.history.loadOlder': 'Wczytaj starsze wiadomości', + 'chat.appLink.confirm.title': 'Otworzyć ten link w innej aplikacji?', + 'chat.appLink.confirm.description': 'Ten link z czatu używa protokołu {scheme} i zostanie otwarty w innej aplikacji.', + 'chat.appLink.confirm.descriptionPlain': 'Ten link z czatu zostanie otwarty w innej aplikacji.', + 'chat.appLink.confirm.cancel': 'Anuluj', + 'chat.appLink.confirm.open': 'Otwórz raz', + 'chat.appLink.confirm.trustAndOpen': 'Zaufaj i otwórz', 'chat.autoReview.title': 'Pętla code review trwa', 'chat.autoReview.status.waitingForReviewer': 'Oczekiwanie na reviewera', 'chat.autoReview.status.waitingForImplementer': 'Oczekiwanie na implementatora', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 963fbf91..ce24a8d0 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -401,6 +401,10 @@ export const settingsDict = { "settings.common.actions.cancel": "Cancelar", "settings.common.actions.create": "Criar", "settings.common.actions.delete": "Excluir", + "settings.openchamber.appLinks.title": "Links de aplicativos confiáveis", + "settings.openchamber.appLinks.info": "Os links desta lista abrem sem perguntar novamente neste dispositivo. Outros links de aplicativos sempre pedem confirmação antes de abrir.", + "settings.openchamber.appLinks.empty": "Não há links de aplicativos confiáveis neste dispositivo. Escolha \"Confiar e abrir\" ao abrir um link para adicioná-lo aqui.", + "settings.openchamber.appLinks.removeAria": "Remover links {scheme} confiáveis", "settings.common.actions.reset": "Reiniciar", "settings.common.actions.rename": "Renomear", "settings.common.actions.duplicate": "Duplicar", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 48235bf6..59c2ff9e 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1527,6 +1527,12 @@ export const dict: Record = { 'chat.dictation.retry': 'Tentar transcrever novamente', 'chat.dictation.discard': 'Descartar gravação', 'chat.history.loadOlder': 'Carregar mensagens anteriores', + "chat.appLink.confirm.title": "Abrir este link em outro aplicativo?", + "chat.appLink.confirm.description": "Este link do chat usa o protocolo {scheme} e será aberto em outro aplicativo.", + "chat.appLink.confirm.descriptionPlain": "Este link do chat será aberto em outro aplicativo.", + "chat.appLink.confirm.cancel": "Cancelar", + "chat.appLink.confirm.open": "Abrir uma vez", + "chat.appLink.confirm.trustAndOpen": "Confiar e abrir", 'chat.autoReview.title': 'O ciclo de revisão de código está em andamento', 'chat.autoReview.status.waitingForReviewer': 'Aguardando o revisor', 'chat.autoReview.status.waitingForImplementer': 'Aguardando o implementador', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 00035bca..2d7dcb16 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -401,6 +401,10 @@ export const settingsDict = { "settings.common.actions.cancel": "Скасувати", "settings.common.actions.create": "Створити", "settings.common.actions.delete": "Видалити", + "settings.openchamber.appLinks.title": "Довірені посилання програм", + "settings.openchamber.appLinks.info": "Посилання в цьому списку відкриваються без повторного запиту на цьому пристрої. Для інших посилань програм ми завжди просимо підтвердження.", + "settings.openchamber.appLinks.empty": "На цьому пристрої ще немає довірених посилань програм. Виберіть «Довірити і відкрити» під час відкриття посилання, щоб додати його сюди.", + "settings.openchamber.appLinks.removeAria": "Видалити довірені посилання {scheme}", "settings.common.actions.reset": "Скинути", "settings.common.actions.rename": "Перейменувати", "settings.common.actions.duplicate": "Дублювати", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 618fafa4..a90cab4c 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1527,6 +1527,12 @@ export const dict: Record = { 'chat.dictation.retry': 'Повторити розшифровку', 'chat.dictation.discard': 'Відхилити запис', 'chat.history.loadOlder': 'Завантажити ще', + "chat.appLink.confirm.title": "Відкрити це посилання в іншій програмі?", + "chat.appLink.confirm.description": "Це посилання з чату використовує протокол {scheme} і буде відкрито в іншій програмі.", + "chat.appLink.confirm.descriptionPlain": "Це посилання з чату буде відкрито в іншій програмі.", + "chat.appLink.confirm.cancel": "Скасувати", + "chat.appLink.confirm.open": "Відкрити один раз", + "chat.appLink.confirm.trustAndOpen": "Довірити і відкрити", 'chat.autoReview.title': 'Цикл код-ревʼю триває', 'chat.autoReview.status.waitingForReviewer': 'Очікуємо ревʼювера', 'chat.autoReview.status.waitingForImplementer': 'Очікуємо імплементатора', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index eddb6b73..d648d30e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -401,6 +401,10 @@ export const settingsDict = { 'settings.common.actions.cancel': '取消', 'settings.common.actions.create': '创建', 'settings.common.actions.delete': '删除', + 'settings.openchamber.appLinks.title': '受信任的应用链接', + 'settings.openchamber.appLinks.info': '此列表中的链接在本设备上打开时不再询问。其他应用链接在打开前始终需要确认。', + 'settings.openchamber.appLinks.empty': '本设备上暂无受信任的应用链接。打开链接时选择“信任并打开”即可添加到这里。', + 'settings.openchamber.appLinks.removeAria': '移除受信任的 {scheme} 链接', 'settings.common.actions.reset': '重置', 'settings.common.actions.rename': '重命名', 'settings.common.actions.duplicate': '复制', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index df7f48c5..175b755b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1515,6 +1515,12 @@ export const dict: Record = { 'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable', 'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow', 'chat.history.loadOlder': '加载更早的消息', + 'chat.appLink.confirm.title': '要在其他应用中打开此链接吗?', + 'chat.appLink.confirm.description': '此聊天链接使用 {scheme} 协议,将在其他应用中打开。', + 'chat.appLink.confirm.descriptionPlain': '此聊天链接将在其他应用中打开。', + 'chat.appLink.confirm.cancel': '取消', + 'chat.appLink.confirm.open': '打开一次', + 'chat.appLink.confirm.trustAndOpen': '信任并打开', 'chat.autoReview.title': '代码审查循环正在运行', 'chat.autoReview.status.waitingForReviewer': '等待审查者', 'chat.autoReview.status.waitingForImplementer': '等待实现者', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index b1fdb079..28e71162 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -398,6 +398,10 @@ export const settingsDict = { 'settings.common.actions.cancel': '取消', 'settings.common.actions.create': '建立', 'settings.common.actions.delete': '刪除', + 'settings.openchamber.appLinks.title': '受信任的應用程式連結', + 'settings.openchamber.appLinks.info': '此清單中的連結在這台裝置上開啟時不再詢問。其他應用程式連結在開啟前一律需要確認。', + 'settings.openchamber.appLinks.empty': '這台裝置上目前沒有受信任的應用程式連結。開啟連結時選擇「信任並開啟」即可加入這裡。', + 'settings.openchamber.appLinks.removeAria': '移除受信任的 {scheme} 連結', 'settings.common.actions.reset': '重設', 'settings.common.actions.rename': '重新命名', 'settings.common.actions.duplicate': '複製', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 673ae58e..ae1d8a49 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1525,6 +1525,12 @@ export const dict: Record = { 'diffView.reviewDialog.toast.noSessionDirectory': 'Session directory is unavailable', 'diffView.reviewDialog.toast.startFailed': 'Failed to start review flow', 'chat.history.loadOlder': '載入更早的訊息', + 'chat.appLink.confirm.title': '要在其他應用程式中開啟此連結嗎?', + 'chat.appLink.confirm.description': '此聊天連結使用 {scheme} 通訊協定,將在其他應用程式中開啟。', + 'chat.appLink.confirm.descriptionPlain': '此聊天連結將在其他應用程式中開啟。', + 'chat.appLink.confirm.cancel': '取消', + 'chat.appLink.confirm.open': '開啟一次', + 'chat.appLink.confirm.trustAndOpen': '信任並開啟', 'chat.autoReview.title': '程式碼審查循環執行中', 'chat.autoReview.status.waitingForReviewer': '等待審查者', 'chat.autoReview.status.waitingForImplementer': '等待實作者', diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index f247f679..9945f92c 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -50,7 +50,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ page: 'appearance', titleKey: 'settings.openchamber.visual.field.weekStartsOn', keywords: ['calendar', 'monday', 'sunday'], - isAvailable: (ctx) => !ctx.isVSCode, }, { id: 'appearance.light-theme', @@ -177,6 +176,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ descriptionKey: 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint', keywords: ['telemetry', 'analytics'], }, + { + id: 'general.app-links', + page: 'general', + titleKey: 'settings.openchamber.appLinks.title', + descriptionKey: 'settings.openchamber.appLinks.info', + keywords: ['security', 'app link', 'deep link', 'scheme', 'protocol', 'obsidian', 'notion'], + }, { id: 'chat.render-mode', page: 'chat', diff --git a/packages/ui/src/lib/url.test.ts b/packages/ui/src/lib/url.test.ts new file mode 100644 index 00000000..4a3c5278 --- /dev/null +++ b/packages/ui/src/lib/url.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test'; + +import { getUrlScheme, isAppLinkUrl } from '@/lib/url'; + +describe('getUrlScheme', () => { + test('extracts the lowercased scheme', () => { + expect(getUrlScheme('Obsidian://open?vault=X')).toBe('obsidian'); + expect(getUrlScheme('https://example.test')).toBe('https'); + }); + + test('returns null for unparseable values', () => { + expect(getUrlScheme('')).toBeNull(); + expect(getUrlScheme('not a url')).toBeNull(); + }); +}); + +describe('isAppLinkUrl', () => { + test('accepts custom application schemes', () => { + expect(isAppLinkUrl('obsidian://open?vault=Notebook&file=a%20b')).toBe(true); + expect(isAppLinkUrl('vscode://file/path/to/file.ts')).toBe(true); + expect(isAppLinkUrl('linear://issue/ABC-1')).toBe(true); + expect(isAppLinkUrl('notion://note/xyz')).toBe(true); + expect(isAppLinkUrl('slack://channel?id=C123')).toBe(true); + }); + + test('rejects browser and communication schemes', () => { + expect(isAppLinkUrl('https://example.test')).toBe(false); + expect(isAppLinkUrl('http://example.test')).toBe(false); + expect(isAppLinkUrl('mailto:user@example.test')).toBe(false); + expect(isAppLinkUrl('tel:+1234567890')).toBe(false); + expect(isAppLinkUrl('sms:+1234567890')).toBe(false); + expect(isAppLinkUrl('webcal://example.test/cal.ics')).toBe(false); + }); + + test('rejects dangerous and internal schemes', () => { + expect(isAppLinkUrl('javascript:alert(1)')).toBe(false); + expect(isAppLinkUrl('data:text/html;base64,PHNjcmlwdD4=')).toBe(false); + expect(isAppLinkUrl('vbscript:msgbox(1)')).toBe(false); + expect(isAppLinkUrl('blob:https://example.test/uuid')).toBe(false); + expect(isAppLinkUrl('about:blank')).toBe(false); + expect(isAppLinkUrl('file:///etc/passwd')).toBe(false); + expect(isAppLinkUrl('ws://localhost:8080')).toBe(false); + expect(isAppLinkUrl('ftp://files.example.test')).toBe(false); + expect(isAppLinkUrl('intent://scan/#Intent;scheme=zxing;end')).toBe(false); + expect(isAppLinkUrl('chrome://settings')).toBe(false); + expect(isAppLinkUrl('devtools://devtools/bundled/inspector.html')).toBe(false); + expect(isAppLinkUrl('ms-msdt:/id%20PCWDiagnostic')).toBe(false); + expect(isAppLinkUrl('search-ms:query=report')).toBe(false); + expect(isAppLinkUrl('shell:AppsFolder')).toBe(false); + }); + + test('rejects OpenChamber and Capacitor self-deep-links', () => { + expect(isAppLinkUrl('openchamber://connect?host=x')).toBe(false); + expect(isAppLinkUrl('openchamber-ui://app/index.html')).toBe(false); + expect(isAppLinkUrl('capacitor://localhost/index.html')).toBe(false); + }); + + test('rejects malformed input', () => { + expect(isAppLinkUrl('')).toBe(false); + expect(isAppLinkUrl('random text')).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/url.ts b/packages/ui/src/lib/url.ts index 0e688c97..9696082b 100644 --- a/packages/ui/src/lib/url.ts +++ b/packages/ui/src/lib/url.ts @@ -20,6 +20,61 @@ export const isExternalHttpUrl = (url: string): boolean => { return parsed.protocol === 'http:' || parsed.protocol === 'https:'; }; +/** Lowercased URL scheme without the trailing colon, or null when unparseable. */ +export const getUrlScheme = (url: string): string | null => { + const parsed = parseUrlSafely(url.trim()); + if (!parsed) { + return null; + } + return parsed.protocol.replace(/:$/, '').toLowerCase(); +}; + +/** + * Schemes the browser or OS communication apps already handle natively + * (mailto:, tel:, sms:, ...). They are not application deep links. + */ +const BROWSER_HANDLED_SCHEMES = new Set(['http', 'https', 'mailto', 'tel', 'sms', 'callto', 'cid', 'xmpp', 'irc', 'news', 'nntp', 'feed', 'webcal']); + +/** + * Schemes that must never be preserved or opened from rendered chat content. + */ +const BLOCKED_APP_LINK_SCHEMES = new Set([ + // Scriptable or web-content schemes + 'javascript', 'data', 'vbscript', 'blob', 'filesystem', 'about', + // WebView/Electron internal schemes + 'chrome', 'chrome-extension', 'devtools', 'moz-extension', 'ms-browser-extension', + // Local files flow through the dedicated file-link handling + 'file', + // Network protocols that are not application links + 'ws', 'wss', 'ftp', 'ftps', + // Android intent URIs can launch arbitrary components with extras + 'intent', + // Historically abused Windows handlers can invoke diagnostic, shell, or + // file-search flows that must not be offered from untrusted chat content. + 'ms-msdt', 'search-ms', 'shell', + // OpenChamber's own schemes must not be re-launched from chat content + 'openchamber', 'openchamber-ui', 'capacitor', +]); + +const APP_LINK_SCHEME_RE = /^[a-z][a-z0-9+.-]{1,31}$/; + +/** + * True for custom application deep links such as `obsidian://`, `linear://`, + * or `vscode://`. Browser-handled and dangerous/internal schemes are excluded, + * so a true result means the link may be offered to the user behind a + * confirmation the first time its scheme appears. + */ +export const isAppLinkUrl = (url: string): boolean => { + const scheme = getUrlScheme(url); + if (!scheme) { + return false; + } + if (BROWSER_HANDLED_SCHEMES.has(scheme) || BLOCKED_APP_LINK_SCHEMES.has(scheme)) { + return false; + } + return APP_LINK_SCHEME_RE.test(scheme); +}; + export const getExternalFaviconUrl = (url: string): string | null => { const parsed = parseUrlSafely(url.trim()); if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) { @@ -88,7 +143,7 @@ export const extractLoopbackUrls = (text: string): string[] => { * @param url - The URL to open * @returns Promise - true if the URL was opened successfully */ -export const openExternalUrl = async (url: string): Promise => { +const openValidatedExternalUrl = async (url: string): Promise => { if (typeof window === 'undefined') { return false; } @@ -103,10 +158,6 @@ export const openExternalUrl = async (url: string): Promise => { return false; } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return false; - } - const normalizedTarget = parsed.toString(); const runtimeApis = getRegisteredRuntimeAPIs(); @@ -136,3 +187,10 @@ export const openExternalUrl = async (url: string): Promise => { return false; } }; + +export const openExternalUrl = (url: string): Promise => + isExternalHttpUrl(url) ? openValidatedExternalUrl(url) : Promise.resolve(false); + +/** Opens a classified app link after the caller has completed confirmation. */ +export const openConfirmedAppLinkUrl = (url: string): Promise => + isAppLinkUrl(url) ? openValidatedExternalUrl(url) : Promise.resolve(false); diff --git a/packages/ui/src/stores/appLinkTrustStore.test.ts b/packages/ui/src/stores/appLinkTrustStore.test.ts new file mode 100644 index 00000000..91616e14 --- /dev/null +++ b/packages/ui/src/stores/appLinkTrustStore.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; + +import { useAppLinkTrustStore, MAX_TRUSTED_SCHEMES } from './appLinkTrustStore'; + +describe('app link trust store', () => { + beforeEach(() => { + useAppLinkTrustStore.setState({ trustedSchemes: [] }); + }); + + test('trusts a scheme with case and whitespace normalization', () => { + const store = useAppLinkTrustStore.getState(); + + store.trustScheme(' Obsidian '); + + expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['obsidian']); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('OBSIDIAN')).toBe(true); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(false); + }); + + test('re-trusting moves the scheme to the front without duplicates', () => { + const store = useAppLinkTrustStore.getState(); + store.trustScheme('obsidian'); + store.trustScheme('linear'); + store.trustScheme('obsidian'); + + expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['obsidian', 'linear']); + }); + + test('removes a trusted scheme', () => { + const store = useAppLinkTrustStore.getState(); + store.trustScheme('obsidian'); + store.trustScheme('linear'); + + useAppLinkTrustStore.getState().removeTrustedScheme('obsidian'); + + expect(useAppLinkTrustStore.getState().trustedSchemes).toEqual(['linear']); + expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(false); + }); + + test('caps the stored scheme list', () => { + const store = useAppLinkTrustStore.getState(); + for (let index = 0; index < MAX_TRUSTED_SCHEMES + 5; index += 1) { + store.trustScheme(`scheme${index}`); + } + + const schemes = useAppLinkTrustStore.getState().trustedSchemes; + expect(schemes).toHaveLength(MAX_TRUSTED_SCHEMES); + expect(schemes[0]).toBe(`scheme${MAX_TRUSTED_SCHEMES + 4}`); + }); +}); diff --git a/packages/ui/src/stores/appLinkTrustStore.ts b/packages/ui/src/stores/appLinkTrustStore.ts new file mode 100644 index 00000000..b20de2dc --- /dev/null +++ b/packages/ui/src/stores/appLinkTrustStore.ts @@ -0,0 +1,48 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { createDeferredSafeJSONStorage } from '@/stores/utils/safeStorage'; + +export const MAX_TRUSTED_SCHEMES = 64; + +interface AppLinkTrustState { + /** Application deep-link schemes (obsidian, vscode, ...) the user chose to always allow. */ + trustedSchemes: string[]; + trustScheme: (scheme: string) => void; + removeTrustedScheme: (scheme: string) => void; + isSchemeTrusted: (scheme: string) => boolean; +} + +const normalizeScheme = (scheme: string): string => scheme.trim().toLowerCase(); + +/** + * Per-device trust for application deep links rendered in chat. Security + * decisions do not roam, so this persists locally through the shared safe + * storage rather than server-synced settings. + */ +export const useAppLinkTrustStore = create()( + persist( + (set, get) => ({ + trustedSchemes: [], + trustScheme: (scheme) => { + const normalized = normalizeScheme(scheme); + if (!normalized) return; + set((state) => { + const next = [normalized, ...state.trustedSchemes.filter((entry) => entry !== normalized)]; + return { trustedSchemes: next.slice(0, MAX_TRUSTED_SCHEMES) }; + }); + }, + removeTrustedScheme: (scheme) => { + const normalized = normalizeScheme(scheme); + set((state) => ({ trustedSchemes: state.trustedSchemes.filter((entry) => entry !== normalized) })); + }, + isSchemeTrusted: (scheme) => get().trustedSchemes.includes(normalizeScheme(scheme)), + }), + { + name: 'app-link-trust-store', + storage: createDeferredSafeJSONStorage(), + version: 1, + partialize: (state) => ({ trustedSchemes: state.trustedSchemes }), + }, + ), +); diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 47e08755..26c4bac6 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -12,6 +12,7 @@ - If OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). - Usage: Z.ai credit limits now appear alongside its other quota windows. - Chat: file paths in messages now open from the session's workspace, even if you last browsed files in another workspace (thanks to @tomzx). +- Chat: app links such as `spotify://` now ask for confirmation before opening another app. You can trust an app link type on one device and manage trusted links in Settings. - While a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes. - Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off. - Chat: long user messages can be expanded even when their final layout finishes after they first appear. From fe80d246eaf761dcc34a2997575ccc94122805aa Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 23 Aug 2026 02:10:48 +0300 Subject: [PATCH 59/59] release v1.20.0 --- CHANGELOG.md | 4 +++- package.json | 2 +- packages/electron/package.json | 2 +- packages/ui/package.json | 2 +- packages/vscode/CHANGELOG.md | 2 +- packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- 7 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0923699..38d653c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- **Chat: /btw side questions.** Type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation, so it inherits the full context but leaves the chat itself untouched. The answer streams into a panel above the composer, which talks to that session while the panel is open; you can collapse it to a slim header bar, keep it as a full session, or discard it. The temporary session stays out of the sidebar and session lists until you keep it (thanks to @jaygupta17). +## [1.20.0] - 2026-08-23 + +- **Session: /btw side questions.** Type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation, so it inherits the full context but leaves the chat itself untouched. The answer streams into a panel above the composer, which talks to that session while the panel is open; you can collapse it to a slim header bar, keep it as a full session, or discard it. The temporary session stays out of the sidebar and session lists until you keep it (thanks to @jaygupta17). - **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context. - **Desktop/Remote instances:** adding an SSH connection now starts from the hosts in your SSH config instead of a blank command field. Ports, install method and passwords moved behind Advanced settings, and each connection shows Connected, Connecting, or Needs attention with the failure text and a button that resolves it. - Desktop/Remote instances: connecting to a remote machine now works when bun, OpenChamber or the opencode CLI live in your home directory rather than on the system path. Installing no longer fails with a permission error, and a missing opencode CLI is now reported before the connection starts instead of as a stack trace. diff --git a/package.json b/package.json index 9e518b9d..6e4cd2b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.19.0", + "version": "1.20.0", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", diff --git a/packages/electron/package.json b/packages/electron/package.json index 8432487f..b4d03bec 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.19.0", + "version": "1.20.0", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/ui/package.json b/packages/ui/package.json index dd218aa6..14eaf104 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.19.0", + "version": "1.20.0", "private": true, "type": "module", "main": "src/main.tsx", diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 26c4bac6..a40c5a1d 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,4 +1,4 @@ -## [Unreleased] +## [1.20.0] - 2026-08-23 - **/btw side questions:** type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation. The answer streams into a panel above the composer; collapse it, keep it as a full session, or discard it without touching the chat (thanks to @jaygupta17). - **Skills catalog:** browse curated GitHub skill collections in a card-based catalog with cross-source search and direct links to each skill's repository. diff --git a/packages/vscode/package.json b/packages/vscode/package.json index fc983df6..4fa8976c 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.19.0", + "version": "1.20.0", "publisher": "fedaykindev", "private": true, "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 3424f2cf..8da2d361 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/web", - "version": "1.19.0", + "version": "1.20.0", "private": false, "type": "module", "main": "./server/index.js",