Merge remote-tracking branch 'origin/main' into feat/gitlab-issues-mrs
# Conflicts: # packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx # packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Integrationen
|
||||
description: Nutze dein Claude-, Command-Code- 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**.
|
||||
|
||||
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
|
||||
|
||||
1. Öffne **Settings → Integrations**.
|
||||
2. Suche die Integration und wähle **Install**.
|
||||
3. Starte OpenCode neu, wenn darum gebeten wird — der Provider erscheint nach dem Neustart.
|
||||
4. Wähle **Set up** und melde dich an. Die Modelle erscheinen danach in der Modellauswahl im Chat.
|
||||
|
||||
Integrationen werden für deinen Benutzer installiert und funktionieren damit in jedem Projekt. Aktualisieren oder entfernen kannst du sie jederzeit über dieselbe Karte.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code nutzt deinen Claude Pro- oder Max-Plan — ohne API-Keys und ohne separate Claude-App.
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
1. Installiere die Integration (siehe oben).
|
||||
2. Wähle **Set up**, öffne den Link und genehmige den Zugriff im Browser. Kein API-Key nötig. Die Modellliste lädt nach der Anmeldung automatisch.
|
||||
|
||||
## Aktualisieren oder entfernen
|
||||
|
||||
- **Update** installiert die neueste veröffentlichte Version des Plugins.
|
||||
- **Remove** löscht das Plugin aus deiner OpenCode-Konfiguration. Der Provider wird beim nächsten Neuladen von OpenCode nicht mehr geladen.
|
||||
|
||||
Wenn eine Karte meldet, dass Einträge manuell verwaltet werden müssen, wähle **Manage plugins** und bereinige die Duplikate dort.
|
||||
|
||||
## Verwandtes
|
||||
|
||||
- [Anbieter, Modelle und Agenten](/de/providers/) — weitere Provider verbinden und Modelle wählen
|
||||
- [Nutzung und Kontingente](/de/usage/) — verfolge, wie viel du genutzt hast
|
||||
@@ -45,5 +45,6 @@ Provider-Anmeldungen werden von OpenCode gespeichert, nicht von OpenChamber, dah
|
||||
|
||||
## Weiterführend
|
||||
|
||||
- [Integrationen](/integrations/) — nutze ein Claude-, Command-Code- oder Cursor-Abo als Provider
|
||||
- [MCP-Server](/mcp/) — füge Agents zusätzliche Werkzeuge hinzu
|
||||
- [Nutzung & Kontingente](/usage/) — verfolge, wie viel du verbraucht hast
|
||||
|
||||
@@ -12,7 +12,7 @@ Zum Schreiben eigener Skills siehe [Skills](/skills/).
|
||||
## Einen Skill installieren
|
||||
|
||||
1. Öffne den Katalog.
|
||||
2. Durchsuche die eingebauten Quellen — das Anthropic-Skills-Repo und die ClawHub-Community-Registry — oder nutze die Suche.
|
||||
2. Durchsuche die eingebauten Quellen — wie das Anthropic-Skills-Repo — oder nutze die Suche.
|
||||
3. Wähle einen Skill aus und installiere ihn.
|
||||
4. Entscheide, wo er installiert werden soll: für alles, was du tust, oder nur für das aktuelle Projekt.
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Integraciones
|
||||
description: Usa tu suscripción de Claude, Command Code 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**.
|
||||
|
||||
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
|
||||
|
||||
1. Abre **Settings → Integrations**.
|
||||
2. Busca la integración y elige **Install**.
|
||||
3. Reinicia OpenCode cuando se te pida — el proveedor aparece tras el reinicio.
|
||||
4. Elige **Set up** e inicia sesión. Los modelos aparecerán luego en el selector de modelos del chat.
|
||||
|
||||
Las integraciones se instalan para tu usuario, así que funcionan en todos los proyectos. Puedes actualizarlas o eliminarlas desde la misma tarjeta en cualquier momento.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code usa tu plan Claude Pro o Max — sin claves de API y sin una app de Claude aparte.
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
1. Instala la integración (arriba).
|
||||
2. Elige **Set up**, abre el enlace y autoriza el acceso en tu navegador. No necesitas clave de API. La lista de modelos se carga automáticamente tras iniciar sesión.
|
||||
|
||||
## Actualizar o eliminar
|
||||
|
||||
- **Update** instala la última versión publicada del plugin.
|
||||
- **Remove** elimina el plugin de tu configuración de OpenCode. El proveedor deja de cargarse cuando OpenCode se recarga.
|
||||
|
||||
Si una tarjeta indica que las entradas requieren gestión manual, elige **Manage plugins** y limpia ahí los duplicados.
|
||||
|
||||
## Relacionado
|
||||
|
||||
- [Proveedores, modelos y agentes](/es/providers/) — conecta otros proveedores y elige modelos
|
||||
- [Uso y cuotas](/es/usage/) — sigue cuánto has usado
|
||||
@@ -45,5 +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
|
||||
- [Servidores MCP](/es/mcp/) — añade herramientas extra para los agentes
|
||||
- [Uso y cuotas](/es/usage/) — controla cuánto has consumido
|
||||
|
||||
@@ -12,7 +12,7 @@ Para escribir tus propias skills, consulta [Skills](/es/skills/).
|
||||
## Instala una skill
|
||||
|
||||
1. Abre el catálogo.
|
||||
2. Explora las fuentes integradas —el repositorio de skills de Anthropic y el registro comunitario de ClawHub— o busca.
|
||||
2. Explora las fuentes integradas —como el repositorio de skills de Anthropic— o busca.
|
||||
3. Elige una skill e instálala.
|
||||
4. Elige dónde instalarla: para todo lo que hagas, o solo en el proyecto actual.
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Intégrations
|
||||
description: Utilise ton abonnement Claude, Command Code 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**.
|
||||
|
||||
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
|
||||
|
||||
1. Ouvre **Settings → Integrations**.
|
||||
2. Trouve l'intégration et choisis **Install**.
|
||||
3. Redémarre OpenCode quand c'est demandé — le fournisseur apparaît après le redémarrage.
|
||||
4. Choisis **Set up** et connecte-toi. Les modèles apparaissent ensuite dans le sélecteur de modèles du chat.
|
||||
|
||||
Les intégrations s'installent pour ton utilisateur et fonctionnent donc dans tous les projets. Tu peux les mettre à jour ou les retirer à tout moment depuis la même carte.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code utilise ton plan Claude Pro ou Max — sans clés API et sans application Claude séparée.
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
1. Installe l'intégration (ci-dessus).
|
||||
2. Choisis **Set up**, ouvre le lien et autorise l'accès dans ton navigateur. Aucune clé API n'est nécessaire. La liste des modèles se charge automatiquement après la connexion.
|
||||
|
||||
## Mettre à jour ou retirer
|
||||
|
||||
- **Update** installe la dernière version publiée du plugin.
|
||||
- **Remove** supprime le plugin de ta configuration OpenCode. Le fournisseur cesse d'être chargé au prochain rechargement d'OpenCode.
|
||||
|
||||
Si une carte indique que les entrées nécessitent une gestion manuelle, choisis **Manage plugins** et nettoie les doublons à cet endroit.
|
||||
|
||||
## À voir aussi
|
||||
|
||||
- [Fournisseurs, modèles et agents](/fr/providers/) — connecter d'autres fournisseurs et choisir des modèles
|
||||
- [Utilisation et quotas](/fr/usage/) — suis ta consommation
|
||||
@@ -45,5 +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
|
||||
- [Serveurs MCP](/mcp/) — ajouter des outils supplémentaires aux agents
|
||||
- [Utilisation et quotas](/usage/) — suivre votre consommation
|
||||
|
||||
@@ -12,7 +12,7 @@ Pour écrire vos propres skills, voir [Skills](/skills/).
|
||||
## Installer un skill
|
||||
|
||||
1. Ouvrez le catalogue.
|
||||
2. Parcourez les sources intégrées — le dépôt de skills Anthropic et le registre communautaire ClawHub — ou lancez une recherche.
|
||||
2. Parcourez les sources intégrées — comme le dépôt de skills Anthropic — ou lancez une recherche.
|
||||
3. Choisissez un skill et installez-le.
|
||||
4. Choisissez où l’installer : pour tout ce que vous faites, ou seulement pour le projet actuel.
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Integrations
|
||||
description: Use your Claude, Command Code, 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**.
|
||||
|
||||
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
|
||||
|
||||
1. Open **Settings → Integrations**.
|
||||
2. Find the integration and choose **Install**.
|
||||
3. Restart OpenCode when asked — the provider appears after the restart.
|
||||
4. Choose **Set up** and sign in. The models then appear in the chat model picker.
|
||||
|
||||
Integrations install for your user, so they work in every project. You can update or remove them from the same card at any time.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code uses your Claude Pro or Max plan — no API keys and no separate Claude app.
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
1. Install the integration (above).
|
||||
2. Choose **Set up**, open the link, and approve access in your browser. No API key is required. The model list loads automatically after you sign in.
|
||||
|
||||
## Update or remove
|
||||
|
||||
- **Update** installs the latest published version of the plugin.
|
||||
- **Remove** deletes the plugin from your OpenCode config. The provider stops loading after OpenCode refreshes.
|
||||
|
||||
If a card says the entries need manual management, choose **Manage plugins** and clean up the duplicates there.
|
||||
|
||||
## Related
|
||||
|
||||
- [Providers, Models & Agents](/providers/) — connect other providers and pick models
|
||||
- [Usage & Quotas](/usage/) — track how much you've used
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: 統合機能
|
||||
description: Claude、Command Code、Cursor のサブスクリプションをプロバイダーとして使う。
|
||||
---
|
||||
|
||||
# 統合機能
|
||||
|
||||
統合機能(インテグレーション)は、すでに持っているサブスクリプションを使って OpenChamber にプロバイダーを追加する小さなプラグインです。**Settings → Integrations** で管理します。
|
||||
|
||||
利用できる統合機能:
|
||||
|
||||
- **Claude Code** — Claude Pro または Max プラン、API キー不要
|
||||
- **Command Code** — Command Code のプラン
|
||||
- **Cursor** — Cursor プランのモデル利用枠
|
||||
|
||||
## 統合機能をインストールする
|
||||
|
||||
1. **Settings → Integrations** を開きます。
|
||||
2. 統合機能を見つけて **Install** を選びます。
|
||||
3. 求められたら OpenCode を再起動します — 再起動後にプロバイダーが現れます。
|
||||
4. **Set up** を選んでサインインします。するとチャットのモデル選択にモデルが表示されます。
|
||||
|
||||
統合機能はユーザー単位でインストールされるため、すべてのプロジェクトで使えます。同じカードからいつでも更新や削除ができます。
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code は Claude Pro または Max プランを使います — API キーも Claude アプリも不要です。
|
||||
|
||||
1. 統合機能をインストールします(上記)。
|
||||
2. **Set up** を選んでサインインします。Claude Code CLI がまだない場合は、セットアップがまずインストールを提案し、その後サインインします。
|
||||
|
||||
Claude Code は、ここで唯一プロバイダーの CLI のインストールとサインインを必要とする統合機能です。Command Code と 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 で使えるようにします。
|
||||
|
||||
1. 統合機能をインストールします(上記)。
|
||||
2. **Set up** を選び、リンクを開いてブラウザーでアクセスを許可します。API キーは不要です。サインイン後、モデル一覧は自動的に読み込まれます。
|
||||
|
||||
## 更新と削除
|
||||
|
||||
- **Update** はプラグインの最新の公開バージョンをインストールします。
|
||||
- **Remove** は OpenCode の設定からプラグインを削除します。OpenCode が再読み込みされるとプロバイダーは読み込まれなくなります。
|
||||
|
||||
カードに手動での管理が必要だと表示された場合は、**Manage plugins** を選んで重複を整理してください。
|
||||
|
||||
## 関連情報
|
||||
|
||||
- [プロバイダー、モデル、エージェント](/ja/providers/) — 他のプロバイダーの接続とモデルの選択
|
||||
- [使用量とクォータ](/ja/usage/) — 利用量を追跡
|
||||
@@ -45,5 +45,6 @@ OpenChamber が何かを行うには、少なくとも 1 つの AI プロバイ
|
||||
|
||||
## 関連
|
||||
|
||||
- [統合機能](/integrations/) — Claude、Command Code、Cursor のサブスクリプションをプロバイダーとして使う
|
||||
- [MCP サーバー](/mcp/) — エージェントに追加ツールを加える
|
||||
- [使用量とクォータ](/usage/) — 使った量を追跡する
|
||||
|
||||
@@ -12,7 +12,7 @@ Skills Catalog では、自分で書く代わりに、他の人が公開した
|
||||
## スキルをインストールする
|
||||
|
||||
1. カタログを開きます。
|
||||
2. 組み込みソース(Anthropic skills repo と ClawHub community registry)を閲覧するか、検索します。
|
||||
2. 組み込みソース(Anthropic skills repo など)を閲覧するか、検索します。
|
||||
3. スキルを選び、インストールします。
|
||||
4. インストール先を選びます。すべての作業で使うか、現在のプロジェクトだけで使うかです。
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: 통합 기능
|
||||
description: Claude, Command Code 또는 Cursor 구독을 공급자로 사용하세요.
|
||||
---
|
||||
|
||||
# 통합 기능
|
||||
|
||||
통합 기능(인테그레이션)은 이미 가지고 있는 구독을 사용해 OpenChamber에 공급자를 추가하는 작은 플러그인입니다. **Settings → Integrations**에서 관리합니다.
|
||||
|
||||
사용 가능한 통합 기능:
|
||||
|
||||
- **Claude Code** — Claude Pro 또는 Max 플랜, API 키 불필요
|
||||
- **Command Code** — Command Code 플랜
|
||||
- **Cursor** — Cursor 플랜의 모델 한도
|
||||
|
||||
## 통합 기능 설치
|
||||
|
||||
1. **Settings → Integrations**를 엽니다.
|
||||
2. 통합 기능을 찾아 **Install**을 선택합니다.
|
||||
3. 요청되면 OpenCode를 다시 시작합니다 — 재시작 후 공급자가 나타납니다.
|
||||
4. **Set up**를 선택하고 로그인합니다. 그러면 채팅의 모델 선택기에 모델이 나타납니다.
|
||||
|
||||
통합 기능은 사용자 단위로 설치되므로 모든 프로젝트에서 작동합니다. 같은 카드에서 언제든 업데이트하거나 제거할 수 있습니다.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code는 Claude Pro 또는 Max 플랜을 사용합니다 — API 키도 별도의 Claude 앱도 필요 없습니다.
|
||||
|
||||
1. 통합 기능을 설치합니다(위 참고).
|
||||
2. **Set up**를 선택하고 로그인합니다. Claude Code CLI가 아직 없으면 설정에서 먼저 설치를 제안한 뒤 로그인을 진행합니다.
|
||||
|
||||
Claude Code는 여기에서 유일하게 공급자 CLI 설치와 로그인을 필요로 하는 통합 기능입니다. Command Code와 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에서 사용할 수 있게 합니다.
|
||||
|
||||
1. 통합 기능을 설치합니다(위 참고).
|
||||
2. **Set up**를 선택하고 링크를 열어 브라우저에서 접근을 승인합니다. API 키는 필요 없습니다. 로그인 후 모델 목록이 자동으로 로드됩니다.
|
||||
|
||||
## 업데이트 및 제거
|
||||
|
||||
- **Update**는 플러그인의 최신 공개 버전을 설치합니다.
|
||||
- **Remove**는 OpenCode 설정에서 플러그인을 삭제합니다. OpenCode가 다시 로드되면 공급자는 더 이상 로드되지 않습니다.
|
||||
|
||||
카드에 항목을 수동으로 관리해야 한다고 표시되면 **Manage plugins**를 선택해 중복 항목을 정리하세요.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [공급자, 모델, 에이전트](/ko/providers/) — 다른 공급자 연결과 모델 선택
|
||||
- [사용량 및 할당량](/ko/usage/) — 사용량 추적
|
||||
@@ -45,5 +45,6 @@ OpenChamber가 무언가를 하려면 먼저 최소한 하나의 AI 공급자가
|
||||
|
||||
## 관련 항목
|
||||
|
||||
- [통합 기능](/ko/integrations/) — Claude, Command Code, Cursor 구독을 공급자로 사용
|
||||
- [MCP Servers](/ko/mcp/) — 에이전트에 추가 도구를 제공합니다
|
||||
- [Usage & Quotas](/ko/usage/) — 사용량을 추적합니다
|
||||
|
||||
@@ -12,7 +12,7 @@ Skills Catalog를 사용하면 직접 작성하는 대신 다른 사람이 게
|
||||
## 스킬 설치하기
|
||||
|
||||
1. 카탈로그를 엽니다.
|
||||
2. 내장된 소스(Anthropic 스킬 저장소와 ClawHub 커뮤니티 레지스트리)를 둘러보거나 검색합니다.
|
||||
2. 내장된 소스(예: Anthropic 스킬 저장소)를 둘러보거나 검색합니다.
|
||||
3. 스킬을 선택하고 설치합니다.
|
||||
4. 설치 위치를 선택합니다. 모든 작업에 적용할지, 현재 프로젝트에만 적용할지 선택합니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Integracje
|
||||
description: Używaj subskrypcji Claude, Command Code 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**.
|
||||
|
||||
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
|
||||
|
||||
1. Otwórz **Settings → Integrations**.
|
||||
2. Znajdź integrację i wybierz **Install**.
|
||||
3. Uruchom OpenCode ponownie, gdy o to poproszą — dostawca pojawi się po restarcie.
|
||||
4. Wybierz **Set up** i zaloguj się. Modele pojawią się potem w selektorze modeli na czacie.
|
||||
|
||||
Integracje instalują się dla Twojego użytkownika, więc działają w każdym projekcie. W każdej chwili możesz je zaktualizować lub usunąć z tej samej karty.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code korzysta z Twojego planu Claude Pro lub Max — bez kluczy API i bez osobnej aplikacji Claude.
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
1. Zainstaluj integrację (patrz wyżej).
|
||||
2. Wybierz **Set up**, otwórz link i zatwierdź dostęp w przeglądarce. Klucz API nie jest potrzebny. Lista modeli wczyta się automatycznie po zalogowaniu.
|
||||
|
||||
## Aktualizacja i usuwanie
|
||||
|
||||
- **Update** instaluje najnowszą opublikowaną wersję wtyczki.
|
||||
- **Remove** usuwa wtyczkę z konfiguracji OpenCode. Dostawca przestanie się ładować po odświeżeniu OpenCode.
|
||||
|
||||
Jeśli karta wskazuje, że wpisy wymagają ręcznego zarządzania, wybierz **Manage plugins** i wyczyść tam duplikaty.
|
||||
|
||||
## Powiązane
|
||||
|
||||
- [Dostawcy, modele i agenci](/pl/providers/) — podłączanie innych dostawców i wybór modeli
|
||||
- [Zużycie i limity](/pl/usage/) — śledź, ile wykorzystałeś
|
||||
@@ -45,5 +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
|
||||
- [Serwery MCP](/pl/mcp/) — dodaj agentom dodatkowe narzędzia
|
||||
- [Zużycie i limity](/pl/usage/) — śledź, ile już wykorzystałeś
|
||||
|
||||
@@ -12,7 +12,7 @@ Aby pisać własne skille, zobacz [Skille](/pl/skills/).
|
||||
## Zainstaluj skill
|
||||
|
||||
1. Otwórz katalog.
|
||||
2. Przeglądaj wbudowane źródła — repozytorium skilli Anthropic oraz rejestr społeczności ClawHub — albo wyszukaj.
|
||||
2. Przeglądaj wbudowane źródła — na przykład repozytorium skilli Anthropic — albo wyszukaj.
|
||||
3. Wybierz skill i zainstaluj go.
|
||||
4. Wybierz, gdzie go zainstalować: dla wszystkiego, co robisz, albo tylko dla bieżącego projektu.
|
||||
|
||||
|
||||
@@ -57,5 +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
|
||||
- [MCP Servers](/mcp/) — add extra tools for agents
|
||||
- [Usage & Quotas](/usage/) — track how much you've used
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Integrações
|
||||
description: Use sua assinatura Claude, Command Code 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**.
|
||||
|
||||
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
|
||||
|
||||
1. Abra **Settings → Integrations**.
|
||||
2. Encontre a integração e escolha **Install**.
|
||||
3. Reinicie o OpenCode quando solicitado — o provedor aparece após a reinicialização.
|
||||
4. Escolha **Set up** e faça login. Os modelos aparecem então no seletor de modelos do chat.
|
||||
|
||||
As integrações são instaladas para o seu usuário, então funcionam em todos os projetos. Você pode atualizá-las ou removê-las do mesmo cartão a qualquer momento.
|
||||
|
||||
## Claude Code
|
||||
|
||||
O Claude Code usa seu plano Claude Pro ou Max — sem chaves de API e sem um app Claude separado.
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
1. Instale a integração (acima).
|
||||
2. Escolha **Set up**, abra o link e autorize o acesso no navegador. Nenhuma chave de API é necessária. A lista de modelos carrega automaticamente após o login.
|
||||
|
||||
## Atualizar ou remover
|
||||
|
||||
- **Update** instala a versão publicada mais recente do plugin.
|
||||
- **Remove** exclui o plugin da sua configuração do OpenCode. O provedor deixa de ser carregado quando o OpenCode é recarregado.
|
||||
|
||||
Se um cartão indicar que as entradas precisam de gerenciamento manual, escolha **Manage plugins** e limpe as duplicatas lá.
|
||||
|
||||
## Relacionado
|
||||
|
||||
- [Provedores, modelos e agentes](/pt-br/providers/) — conecte outros provedores e escolha modelos
|
||||
- [Uso e cotas](/pt-br/usage/) — acompanhe quanto você usou
|
||||
@@ -45,5 +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
|
||||
- [Servidores MCP](/pt-br/mcp/) — adicione ferramentas extras para os agentes
|
||||
- [Uso e Cotas](/pt-br/usage/) — acompanhe quanto você já usou
|
||||
|
||||
@@ -12,7 +12,7 @@ Para escrever suas próprias skills, veja [Skills](/pt-br/skills/).
|
||||
## Instalar uma skill
|
||||
|
||||
1. Abra o catálogo.
|
||||
2. Navegue pelas fontes integradas — o repositório de skills da Anthropic e o registro comunitário ClawHub — ou pesquise.
|
||||
2. Navegue pelas fontes integradas — como o repositório de skills da Anthropic — ou pesquise.
|
||||
3. Escolha uma skill e instale-a.
|
||||
4. Escolha onde instalá-la: para tudo o que você faz, ou apenas no projeto atual.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ For writing your own skills, see [Skills](/skills/).
|
||||
## Install a skill
|
||||
|
||||
1. Open the catalog.
|
||||
2. Browse the built-in sources — the Anthropic skills repo and the ClawHub community registry — or search.
|
||||
2. Browse the built-in sources — like the Anthropic skills repo — or search.
|
||||
3. Pick a skill and install it.
|
||||
4. Choose where to install it: for everything you do, or just the current project.
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Інтеграції
|
||||
description: Використовуйте підписки Claude, Command Code або Cursor як провайдерів.
|
||||
---
|
||||
|
||||
# Інтеграції
|
||||
|
||||
Інтеграція — це невеликий плагін, що додає провайдера до OpenChamber на основі підписки, яка в вас уже є. Керувати ними можна в **Settings → Integrations**.
|
||||
|
||||
Доступні інтеграції:
|
||||
|
||||
- **Claude Code** — ваша підписка Claude Pro або Max, без API-ключів
|
||||
- **Command Code** — ваша підписка Command Code
|
||||
- **Cursor** — ліміти моделей вашої підписки Cursor
|
||||
|
||||
## Встановлення інтеграції
|
||||
|
||||
1. Відкрийте **Settings → Integrations**.
|
||||
2. Знайдіть інтеграцію та натисніть **Install**.
|
||||
3. Перезапустіть OpenCode, коли про це попросять, — провайдер з'явиться після перезапуску.
|
||||
4. Натисніть **Set up** і увійдіть. Після цього моделі з'являться в перемикачі моделей у чаті.
|
||||
|
||||
Інтеграції встановлюються для вашого користувача, тож працюють у всіх проєктах. Оновити або видалити їх можна в будь-який момент з тієї ж карточки.
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code використовує вашу підписку Claude Pro або Max — без API-ключів і без окремого застосунку Claude.
|
||||
|
||||
1. Встановіть інтеграцію (вище).
|
||||
2. Натисніть **Set up** і увійдіть. Якщо у вас ще немає Claude Code CLI, програма встановлення спершу запропонує його встановити, а потім виконає вхід.
|
||||
|
||||
Claude Code — єдина інтеграція тут, яка вимагає встановленого та залогіненого CLI свого провайдера. Для Command Code і 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.
|
||||
|
||||
1. Встановіть інтеграцію (вище).
|
||||
2. Натисніть **Set up**, відкрийте посилання та підтвердьте доступ у браузері. API-ключ не потрібен. Список моделей завантажиться автоматично після входу.
|
||||
|
||||
## Оновлення та видалення
|
||||
|
||||
- **Update** встановлює найновішу опубліковану версію плагіна.
|
||||
- **Remove** видаляє плагін із конфігурації OpenCode. Провайдер перестане завантажуватися після оновлення OpenCode.
|
||||
|
||||
Якщо карточка повідомляє, що записи потребують ручного керування, натисніть **Manage plugins** і приберіть дублікати там.
|
||||
|
||||
## Пов'язане
|
||||
|
||||
- [Провайдери, моделі та агенти](/uk/providers/) — підключення інших провайдерів і вибір моделей
|
||||
- [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили
|
||||
@@ -45,5 +45,6 @@ description: Підключайте AI-провайдерів, обирайте
|
||||
|
||||
## Пов'язане
|
||||
|
||||
- [Інтеграції](/uk/integrations/) — використовуйте підписки Claude, Command Code або Cursor як провайдерів
|
||||
- [MCP Servers](/uk/mcp/) — додайте агентам додаткові інструменти
|
||||
- [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили
|
||||
|
||||
@@ -12,7 +12,7 @@ description: Переглядайте та встановлюйте готові
|
||||
## Встановлення навички
|
||||
|
||||
1. Відкрийте каталог.
|
||||
2. Перегляньте вбудовані джерела — репозиторій навичок Anthropic та спільнотний реєстр ClawHub — або скористайтеся пошуком.
|
||||
2. Перегляньте вбудовані джерела — наприклад репозиторій навичок Anthropic — або скористайтеся пошуком.
|
||||
3. Оберіть навичку й установіть її.
|
||||
4. Виберіть, куди встановити: для всього, що ви робите, чи лише для поточного проєкту.
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: 集成
|
||||
description: 将你的 Claude、Command Code 或 Cursor 订阅用作提供商。
|
||||
---
|
||||
|
||||
# 集成
|
||||
|
||||
集成是一个小型插件,它使用你已有的订阅为 OpenChamber 添加一个提供商。你可以在 **Settings → Integrations** 中管理它们。
|
||||
|
||||
可用的集成:
|
||||
|
||||
- **Claude Code** — 你的 Claude Pro 或 Max 套餐,无需 API 密钥
|
||||
- **Command Code** — 你的 Command Code 套餐
|
||||
- **Cursor** — 你的 Cursor 套餐的模型额度
|
||||
|
||||
## 安装集成
|
||||
|
||||
1. 打开 **Settings → Integrations**。
|
||||
2. 找到所需的集成并选择 **Install**。
|
||||
3. 在提示时重启 OpenCode — 重启后提供商就会出现。
|
||||
4. 选择 **Set up** 并登录。之后模型会出现在聊天室的模型选择器中。
|
||||
|
||||
集成按用户安装,因此在所有项目中都可用。你可以随时在同一张卡片上更新或移除它们。
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code 使用你的 Claude Pro 或 Max 套餐 — 无需 API 密钥,也无需单独的 Claude 应用。
|
||||
|
||||
1. 安装集成(见上文)。
|
||||
2. 选择 **Set up** 并登录。如果你还没有 Claude Code CLI,安装向导会先提供安装,然后再登录。
|
||||
|
||||
Claude Code 是这里唯一要求安装并登录其提供商 CLI 的集成。Command Code 和 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 中使用。
|
||||
|
||||
1. 安装集成(见上文)。
|
||||
2. 选择 **Set up**,打开链接并在浏览器中授权访问。无需 API 密钥。登录后模型列表会自动加载。
|
||||
|
||||
## 更新或移除
|
||||
|
||||
- **Update** 安装插件的最新发布版本。
|
||||
- **Remove** 从你的 OpenCode 配置中删除该插件。OpenCode 重新加载后,该提供商将不再加载。
|
||||
|
||||
如果卡片提示条目需要手动管理,请选择 **Manage plugins** 并在那里清理重复项。
|
||||
|
||||
## 相关内容
|
||||
|
||||
- [提供商、模型与智能体](/zh-cn/providers/) — 连接其他提供商并选择模型
|
||||
- [用量与配额](/zh-cn/usage/) — 跟踪你的使用量
|
||||
@@ -45,5 +45,6 @@ description: 连接 AI 提供商、选择模型并设置智能体。
|
||||
|
||||
## 相关内容
|
||||
|
||||
- [集成](/zh-cn/integrations/) — 将 Claude、Command Code 或 Cursor 订阅用作提供商
|
||||
- [MCP Servers](/zh-cn/mcp/) — 为智能体添加额外工具
|
||||
- [用量与配额](/zh-cn/usage/) — 跟踪你已使用的量
|
||||
|
||||
@@ -12,7 +12,7 @@ Skills 目录让你能够安装其他人发布的 skill,而不必自己编写
|
||||
## 安装 skill
|
||||
|
||||
1. 打开目录。
|
||||
2. 浏览内置来源 — Anthropic skills 仓库和 ClawHub 社区注册表 — 或进行搜索。
|
||||
2. 浏览内置来源 — 例如 Anthropic skills 仓库 — 或进行搜索。
|
||||
3. 选择一个 skill 并安装它。
|
||||
4. 选择安装位置:用于你的所有工作,或仅用于当前项目。
|
||||
|
||||
|
||||
@@ -364,6 +364,21 @@
|
||||
"de": "Anbieter, Modelle und Agenten"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Integrations",
|
||||
"link": "/integrations/",
|
||||
"translations": {
|
||||
"uk": "Інтеграції",
|
||||
"zh-CN": "集成",
|
||||
"es": "Integraciones",
|
||||
"pt-BR": "Integrações",
|
||||
"ko": "통합 기능",
|
||||
"pl": "Integracje",
|
||||
"fr": "Intégrations",
|
||||
"ja": "統合機能",
|
||||
"de": "Integrationen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "MCP Servers",
|
||||
"link": "/mcp/",
|
||||
|
||||
@@ -159,7 +159,9 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
|
||||
grants permission requests by default when no handler is set, and the panel
|
||||
loads whatever address the user types. Tab favicons are fetched in this
|
||||
session too, so icons behind the page's own login resolve and the app's origin
|
||||
never requests anything from a third-party host.
|
||||
never requests anything from a third-party host. Self-signed loopback HTTPS
|
||||
pages may use an untrusted certificate authority; certificate failures for
|
||||
external hosts and all other certificate errors remain blocked.
|
||||
|
||||
## IPC Pattern
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
|
||||
export const shouldAllowBrowserPanelCertificateError = ({ url, error }) => {
|
||||
if (error !== 'net::ERR_CERT_AUTHORITY_INVALID') return false;
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'https:' && LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs';
|
||||
|
||||
test('allows untrusted certificate authorities for loopback HTTPS pages', () => {
|
||||
for (const url of [
|
||||
'https://localhost:58580/',
|
||||
'https://127.0.0.1:58580/',
|
||||
'https://[::1]:58580/',
|
||||
]) {
|
||||
assert.equal(shouldAllowBrowserPanelCertificateError({
|
||||
url,
|
||||
error: 'net::ERR_CERT_AUTHORITY_INVALID',
|
||||
}), true);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps certificate validation for non-loopback pages', () => {
|
||||
for (const url of [
|
||||
'https://example.com/',
|
||||
'https://localhost.example.com/',
|
||||
'https://0.0.0.0:58580/',
|
||||
]) {
|
||||
assert.equal(shouldAllowBrowserPanelCertificateError({
|
||||
url,
|
||||
error: 'net::ERR_CERT_AUTHORITY_INVALID',
|
||||
}), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not bypass other certificate failures or malformed URLs', () => {
|
||||
assert.equal(shouldAllowBrowserPanelCertificateError({
|
||||
url: 'https://localhost:58580/',
|
||||
error: 'net::ERR_CERT_DATE_INVALID',
|
||||
}), false);
|
||||
assert.equal(shouldAllowBrowserPanelCertificateError({
|
||||
url: 'not a url',
|
||||
error: 'net::ERR_CERT_AUTHORITY_INVALID',
|
||||
}), false);
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
setLinuxAutostartEnabled,
|
||||
} from './linux-autostart.mjs';
|
||||
import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs';
|
||||
import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs';
|
||||
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -1186,6 +1187,15 @@ const resolveBrowserPanelContents = (rawId) => {
|
||||
const hardenBrowserPanelSession = () => {
|
||||
const panelSession = session.fromPartition(BROWSER_PANEL_PARTITION);
|
||||
|
||||
app.on('certificate-error', (event, contents, url, error, _certificate, callback) => {
|
||||
if (contents.session === panelSession && shouldAllowBrowserPanelCertificateError({ url, error })) {
|
||||
event.preventDefault();
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
callback(false);
|
||||
});
|
||||
|
||||
panelSession.setPermissionRequestHandler((_contents, permission, callback, details) => {
|
||||
log.info('[electron] browser panel denied a permission request', {
|
||||
permission,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openchamber/electron",
|
||||
"version": "1.18.4",
|
||||
"version": "1.19.0",
|
||||
"private": true,
|
||||
"description": "Electron desktop runtime for OpenChamber",
|
||||
"author": "OpenChamber",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.18.4",
|
||||
"version": "1.19.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/main.tsx",
|
||||
|
||||
@@ -555,19 +555,56 @@ interface FilePart {
|
||||
source?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const GITHUB_ISSUE_LINK_MIME = 'application/vnd.github.issue-link';
|
||||
const GITHUB_PR_LINK_MIME = 'application/vnd.github.pull-request-link';
|
||||
const FORGE_LINK_MIMES = new Set([
|
||||
'application/vnd.github.issue-link',
|
||||
'application/vnd.github.pull-request-link',
|
||||
'application/vnd.gitlab.issue-link',
|
||||
'application/vnd.gitlab.merge-request-link',
|
||||
'application/vnd.gitea.issue-link',
|
||||
'application/vnd.gitea.pull-request-link',
|
||||
]);
|
||||
|
||||
const getGitHubLinkKind = (file: FilePart): 'issue' | 'pr' | null => {
|
||||
if (file.mime === GITHUB_ISSUE_LINK_MIME) {
|
||||
return 'issue';
|
||||
const ISSUE_LINK_MIMES = new Set([
|
||||
'application/vnd.github.issue-link',
|
||||
'application/vnd.gitlab.issue-link',
|
||||
'application/vnd.gitea.issue-link',
|
||||
]);
|
||||
|
||||
const PR_LINK_MIMES = new Set([
|
||||
'application/vnd.github.pull-request-link',
|
||||
'application/vnd.gitlab.merge-request-link',
|
||||
'application/vnd.gitea.pull-request-link',
|
||||
]);
|
||||
|
||||
type ForgeLinkInfo = { kind: 'issue' | 'pr'; provider: 'github' | 'gitlab' | 'gitea' } | null;
|
||||
|
||||
const getForgeLinkInfo = (file: FilePart): ForgeLinkInfo => {
|
||||
const mime = file.mime;
|
||||
if (!mime || !FORGE_LINK_MIMES.has(mime)) return null;
|
||||
|
||||
if (ISSUE_LINK_MIMES.has(mime)) {
|
||||
if (mime.includes('gitlab')) return { kind: 'issue', provider: 'gitlab' };
|
||||
if (mime.includes('gitea')) return { kind: 'issue', provider: 'gitea' };
|
||||
return { kind: 'issue', provider: 'github' };
|
||||
}
|
||||
if (file.mime === GITHUB_PR_LINK_MIME) {
|
||||
return 'pr';
|
||||
|
||||
if (PR_LINK_MIMES.has(mime)) {
|
||||
if (mime.includes('gitlab')) return { kind: 'pr', provider: 'gitlab' };
|
||||
if (mime.includes('gitea')) return { kind: 'pr', provider: 'gitea' };
|
||||
return { kind: 'pr', provider: 'github' };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const forgeLinkIconName = (info: ForgeLinkInfo): 'github' | 'gitlab' | 'git-branch' | 'git-pull-request' => {
|
||||
if (!info) return 'github';
|
||||
if (info.kind === 'pr') return 'git-pull-request';
|
||||
if (info.provider === 'gitlab') return 'gitlab';
|
||||
if (info.provider === 'gitea') return 'git-branch';
|
||||
return 'github';
|
||||
};
|
||||
|
||||
interface MessageFilesDisplayProps {
|
||||
files: FilePart[];
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
@@ -590,8 +627,8 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
};
|
||||
|
||||
const resolveDisplayName = React.useCallback((file: FilePart): string => {
|
||||
const isGitHubLink = getGitHubLinkKind(file) !== null;
|
||||
if (isGitHubLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
|
||||
const isForgeLink = getForgeLinkInfo(file) !== null;
|
||||
if (isForgeLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
|
||||
return file.filename.trim();
|
||||
}
|
||||
return extractFilename(file.filename || file.url);
|
||||
@@ -664,11 +701,11 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
const fileName = resolveDisplayName(file);
|
||||
const ext = fileName.split('.').pop() || '';
|
||||
const sizeText = formatFileSize(file.size);
|
||||
const githubLinkKind = getGitHubLinkKind(file);
|
||||
const forgeLink = getForgeLinkInfo(file);
|
||||
return (
|
||||
<Tooltip key={`file-${file.url || file.filename || index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
{githubLinkKind && file.url ? (
|
||||
{forgeLink && file.url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -676,11 +713,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
}}
|
||||
className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{githubLinkKind === 'pr' ? (
|
||||
<Icon name="git-pull-request" className="text-muted-foreground h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Icon name="github" className="text-muted-foreground h-3.5 w-3.5" />
|
||||
)}
|
||||
<Icon name={forgeLinkIconName(forgeLink)} className="text-muted-foreground h-3.5 w-3.5" />
|
||||
<div className="overflow-hidden max-w-[220px]">
|
||||
<span className="truncate block" title={fileName}>{fileName}</span>
|
||||
</div>
|
||||
@@ -763,7 +796,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
const fileName = resolveDisplayName(file);
|
||||
const isImage = file.mime?.startsWith('image/');
|
||||
const sizeText = formatFileSize(file.size);
|
||||
const githubLinkKind = getGitHubLinkKind(file);
|
||||
const forgeLink = getForgeLinkInfo(file);
|
||||
|
||||
if (isImage && file.url) {
|
||||
return (
|
||||
@@ -786,7 +819,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
);
|
||||
}
|
||||
|
||||
if (githubLinkKind && file.url) {
|
||||
if (forgeLink && file.url) {
|
||||
return (
|
||||
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -801,11 +834,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{githubLinkKind === 'pr' ? (
|
||||
<Icon name="git-pull-request" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
) : (
|
||||
<Icon name="github" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
)}
|
||||
<Icon name={forgeLinkIconName(forgeLink)} className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{fileName}</p>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import {
|
||||
GITHUB_ISSUE_CONTEXT_PREFIX,
|
||||
GITHUB_PR_CONTEXT_PREFIX,
|
||||
GITLAB_ISSUE_CONTEXT_PREFIX,
|
||||
GITLAB_MR_CONTEXT_PREFIX,
|
||||
GITEA_ISSUE_CONTEXT_PREFIX,
|
||||
GITEA_PR_CONTEXT_PREFIX,
|
||||
startsWithForgeContextPrefix,
|
||||
} from '@/lib/messages/synthetic';
|
||||
|
||||
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
||||
|
||||
type GitHubIssueContextPayload = {
|
||||
type IssueContextPayload = {
|
||||
issue?: {
|
||||
number?: unknown;
|
||||
title?: unknown;
|
||||
@@ -19,6 +25,22 @@ type GitHubPrContextPayload = {
|
||||
};
|
||||
};
|
||||
|
||||
type GitLabMrContextPayload = {
|
||||
mr?: {
|
||||
number?: unknown;
|
||||
title?: unknown;
|
||||
url?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type GiteaPrContextPayload = {
|
||||
pr?: {
|
||||
number?: unknown;
|
||||
title?: unknown;
|
||||
url?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const isPositiveNumber = (value: unknown): value is number => {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
};
|
||||
@@ -41,17 +63,17 @@ const parseSyntheticJsonPayload = <T>(text: string, prefix: string): T | null =>
|
||||
}
|
||||
};
|
||||
|
||||
const buildGitHubAttachmentPart = (text: string): Part | null => {
|
||||
const issuePayload = parseSyntheticJsonPayload<GitHubIssueContextPayload>(text, GITHUB_ISSUE_CONTEXT_PREFIX);
|
||||
if (issuePayload) {
|
||||
const issue = issuePayload.issue;
|
||||
const buildForgeAttachmentPart = (text: string): Part | null => {
|
||||
// GitHub issues
|
||||
const ghIssuePayload = parseSyntheticJsonPayload<IssueContextPayload>(text, GITHUB_ISSUE_CONTEXT_PREFIX);
|
||||
if (ghIssuePayload) {
|
||||
const issue = ghIssuePayload.issue;
|
||||
const number = issue?.number;
|
||||
const title = issue?.title;
|
||||
const url = issue?.url;
|
||||
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.github.issue-link',
|
||||
@@ -60,16 +82,16 @@ const buildGitHubAttachmentPart = (text: string): Part | null => {
|
||||
} as Part;
|
||||
}
|
||||
|
||||
const prPayload = parseSyntheticJsonPayload<GitHubPrContextPayload>(text, GITHUB_PR_CONTEXT_PREFIX);
|
||||
if (prPayload) {
|
||||
const pr = prPayload.pr;
|
||||
// GitHub PRs
|
||||
const ghPrPayload = parseSyntheticJsonPayload<GitHubPrContextPayload>(text, GITHUB_PR_CONTEXT_PREFIX);
|
||||
if (ghPrPayload) {
|
||||
const pr = ghPrPayload.pr;
|
||||
const number = pr?.number;
|
||||
const title = pr?.title;
|
||||
const url = pr?.url;
|
||||
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.github.pull-request-link',
|
||||
@@ -78,6 +100,78 @@ const buildGitHubAttachmentPart = (text: string): Part | null => {
|
||||
} as Part;
|
||||
}
|
||||
|
||||
// GitLab issues
|
||||
const glIssuePayload = parseSyntheticJsonPayload<IssueContextPayload>(text, GITLAB_ISSUE_CONTEXT_PREFIX);
|
||||
if (glIssuePayload) {
|
||||
const issue = glIssuePayload.issue;
|
||||
const number = issue?.number;
|
||||
const title = issue?.title;
|
||||
const url = issue?.url;
|
||||
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.gitlab.issue-link',
|
||||
filename: `Issue #${number}: ${title}`,
|
||||
url,
|
||||
} as Part;
|
||||
}
|
||||
|
||||
// GitLab MRs
|
||||
const glMrPayload = parseSyntheticJsonPayload<GitLabMrContextPayload>(text, GITLAB_MR_CONTEXT_PREFIX);
|
||||
if (glMrPayload) {
|
||||
const mr = glMrPayload.mr;
|
||||
const number = mr?.number;
|
||||
const title = mr?.title;
|
||||
const url = mr?.url;
|
||||
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.gitlab.merge-request-link',
|
||||
filename: `MR !${number}: ${title}`,
|
||||
url,
|
||||
} as Part;
|
||||
}
|
||||
|
||||
// Gitea issues
|
||||
const gtIssuePayload = parseSyntheticJsonPayload<IssueContextPayload>(text, GITEA_ISSUE_CONTEXT_PREFIX);
|
||||
if (gtIssuePayload) {
|
||||
const issue = gtIssuePayload.issue;
|
||||
const number = issue?.number;
|
||||
const title = issue?.title;
|
||||
const url = issue?.url;
|
||||
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.gitea.issue-link',
|
||||
filename: `Issue #${number}: ${title}`,
|
||||
url,
|
||||
} as Part;
|
||||
}
|
||||
|
||||
// Gitea PRs
|
||||
const gtPrPayload = parseSyntheticJsonPayload<GiteaPrContextPayload>(text, GITEA_PR_CONTEXT_PREFIX);
|
||||
if (gtPrPayload) {
|
||||
const pr = gtPrPayload.pr;
|
||||
const number = pr?.number;
|
||||
const title = pr?.title;
|
||||
const url = pr?.url;
|
||||
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.gitea.pull-request-link',
|
||||
filename: `PR #${number}: ${title}`,
|
||||
url,
|
||||
} as Part;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -103,8 +197,7 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
|
||||
const normalizedText = text.trimStart();
|
||||
return shouldKeepSyntheticUserText(text, planModeEnabled)
|
||||
|| normalizedText.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX)
|
||||
|| normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX);
|
||||
|| startsWithForgeContextPrefix(normalizedText);
|
||||
})
|
||||
.map((part) => {
|
||||
const rawPart = part as Record<string, unknown>;
|
||||
@@ -116,7 +209,7 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
const synthetic = rawPart.synthetic === true;
|
||||
|
||||
if (synthetic) {
|
||||
const attachmentPart = buildGitHubAttachmentPart(text);
|
||||
const attachmentPart = buildForgeAttachmentPart(text);
|
||||
if (attachmentPart) {
|
||||
return attachmentPart;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ which requests only providers enabled for this panel.
|
||||
| Context + cost | `contextUsage.ts` over `useSessionMessages`, `Session.cost` | see below — the store getters cannot serve this |
|
||||
| Branch, ahead/behind, attention | `useGitStore` directory state | warmed via `runBackgroundNetworkTask(ensureStatus)` and refreshed from Git mutation hints |
|
||||
| Changed files | `useGitStore` status `files` + `diffStats` | working tree, not session-authored edits |
|
||||
| PR + checks | `usePrVisualSummary` | **read-only** |
|
||||
| PR + checks | `useFreshestPrVisualSummaryForBranch` | **read-only**; follows the freshest remote-keyed entry for the branch |
|
||||
| Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses` | |
|
||||
| Subagent blockers | directory `permission` / `question` maps | one subscription covers every child |
|
||||
| Usage | `components/usage/usageGroups.ts` over `useQuotaStore` | grouping shared with the mobile popover; presentation is not |
|
||||
@@ -145,6 +145,10 @@ The panel never calls `startWatching`. PR watching is owned by the background
|
||||
tracker, and its concurrency gate exists because per-consumer PR fetches once
|
||||
saturated the browser's connection pool and stalled startup for ~20s. A panel
|
||||
that started a watch per open session would reintroduce exactly that fan-out.
|
||||
The PR surface can watch a concrete remote while passive readers initially know
|
||||
only the automatic remote key, so the panel reads the freshest entry for the
|
||||
directory and branch across remote keys. This keeps its PR and checks rows in
|
||||
sync with the live PR surface without adding another request owner.
|
||||
|
||||
### Changed files come from git status, not the session
|
||||
|
||||
|
||||
@@ -10,16 +10,18 @@ import { useSession } from '@/sync/sync-context';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { getLinkedIssues, parseLinkedIssueRef, type LinkedIssue } from '@/lib/linkedIssues';
|
||||
import { linkedEntityLiveInvalidate, useLinkedEntityLive, type LinkedEntityLive } from '@/lib/linkedEntityLive';
|
||||
import { fetchSessionKnowledgeSummary, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
|
||||
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { setLinkedIssue } from '@/sync/session-actions';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { resolveProjectContextId } from '@/lib/projectContextApi';
|
||||
import { WorkStatusCollapsibleSection, WorkStatusPill, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
|
||||
import { useReportWorkStatusPresence } from './presenceContext';
|
||||
import { WorkStatusLinkDialog } from './WorkStatusLinkDialog';
|
||||
import { resolveDraftPinnedKnowledge } from './draftKnowledge';
|
||||
|
||||
type Props = {
|
||||
sessionId: string | null;
|
||||
@@ -179,6 +181,11 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
const [linkDialogOpen, setLinkDialogOpen] = React.useState(false);
|
||||
|
||||
const session = useSession(sessionId ?? '', directory ?? undefined);
|
||||
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
|
||||
const setDraftProjectContextPin = useSessionUIStore((state) => state.setDraftProjectContextPin);
|
||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const isDraft = sessionId === null && newSessionDraft.open;
|
||||
const skills = useSkillsStore((state) => state.skills);
|
||||
const mcpStatus = useMcpStore(
|
||||
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
|
||||
@@ -200,7 +207,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
}, [directory, loadSkills]);
|
||||
|
||||
/**
|
||||
* What the project sends along with every message. Read from the server
|
||||
* What this session carries. Read from the server
|
||||
* rather than from the notes panel's store, because this must be right
|
||||
* whether or not that panel has ever been opened.
|
||||
*/
|
||||
@@ -208,44 +215,77 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
{ notes: [], plans: [], memory: { global: 0, project: 0 } },
|
||||
);
|
||||
|
||||
// Re-read whenever the stores that own pins or memory change, not only when
|
||||
// the directory does. Unpinning is a write those stores make, and a panel
|
||||
// that keeps listing what was just unpinned tells the user it is still going
|
||||
// to the agent when it is not.
|
||||
// Re-read when source content or memory changes, not only when the session does.
|
||||
const contextEntries = useProjectContextStore((state) => state.entries);
|
||||
const loadProjectContext = useProjectContextStore((state) => state.load);
|
||||
const memoryProject = useAgentMemoryStore((state) => state.project);
|
||||
const memoryGlobal = useAgentMemoryStore((state) => state.global);
|
||||
|
||||
const draftProject = React.useMemo(() => {
|
||||
if (!isDraft) return null;
|
||||
const selected = newSessionDraft.selectedProjectId
|
||||
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
|
||||
: null;
|
||||
return selected ?? resolveProjectForSessionDirectory(
|
||||
projects,
|
||||
availableWorktreesByProject,
|
||||
newSessionDraft.directoryOverride ?? directory,
|
||||
);
|
||||
}, [availableWorktreesByProject, directory, isDraft, newSessionDraft.directoryOverride, newSessionDraft.selectedProjectId, projects]);
|
||||
|
||||
const draftContextEntry = draftProject
|
||||
? contextEntries[resolveProjectContextId({ id: draftProject.id, path: draftProject.path })]
|
||||
: undefined;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDraft || !draftProject) return;
|
||||
void loadProjectContext({ id: draftProject.id, path: draftProject.path });
|
||||
}, [draftProject, isDraft, loadProjectContext]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchSessionKnowledgeSummary(directory).then((summary) => {
|
||||
void fetchSessionKnowledgeSummary(directory, sessionId).then((summary) => {
|
||||
if (!cancelled) setKnowledge(summary);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [directory, contextEntries, memoryProject, memoryGlobal]);
|
||||
}, [directory, sessionId, session, contextEntries, memoryProject, memoryGlobal]);
|
||||
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
const setNotePinned = useProjectContextStore((state) => state.setNotePinned);
|
||||
const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned);
|
||||
|
||||
const projectRef = React.useMemo(() => {
|
||||
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory ?? '');
|
||||
return resolved ? { id: resolved.id, path: resolved.path } : null;
|
||||
}, [availableWorktreesByProject, directory, projects]);
|
||||
const visibleKnowledge = React.useMemo<SessionKnowledgeSummary>(() => {
|
||||
if (!isDraft) return knowledge;
|
||||
const pinned = resolveDraftPinnedKnowledge(
|
||||
draftContextEntry?.notes ?? [],
|
||||
draftContextEntry?.plans ?? [],
|
||||
newSessionDraft.projectContextPins ?? { notes: [], plans: [] },
|
||||
);
|
||||
return { ...knowledge, ...pinned };
|
||||
}, [draftContextEntry?.notes, draftContextEntry?.plans, isDraft, knowledge, newSessionDraft.projectContextPins]);
|
||||
|
||||
// Unpinning from here, like the pinned-messages section: a panel that says
|
||||
// what is attached should be able to detach it, or the user has to go find
|
||||
// the surface that can.
|
||||
const unpinNote = React.useCallback((noteId: string) => {
|
||||
if (projectRef) void setNotePinned(projectRef, noteId, false);
|
||||
}, [projectRef, setNotePinned]);
|
||||
if (isDraft) {
|
||||
setDraftProjectContextPin('note', noteId, false);
|
||||
return;
|
||||
}
|
||||
if (!directory || !sessionId) return;
|
||||
void setSessionProjectContextPin(directory, sessionId, 'note', noteId, false).then((pins) => {
|
||||
if (pins) setKnowledge((current) => ({ ...current, notes: current.notes.filter((note) => note.id !== noteId) }));
|
||||
});
|
||||
}, [directory, isDraft, sessionId, setDraftProjectContextPin]);
|
||||
const unpinPlan = React.useCallback((planId: string) => {
|
||||
if (projectRef) void setPlanPinned(projectRef, planId, false);
|
||||
}, [projectRef, setPlanPinned]);
|
||||
if (isDraft) {
|
||||
setDraftProjectContextPin('plan', planId, false);
|
||||
return;
|
||||
}
|
||||
if (!directory || !sessionId) return;
|
||||
void setSessionProjectContextPin(directory, sessionId, 'plan', planId, false).then((pins) => {
|
||||
if (pins) setKnowledge((current) => ({ ...current, plans: current.plans.filter((plan) => plan.id !== planId) }));
|
||||
});
|
||||
}, [directory, isDraft, sessionId, setDraftProjectContextPin]);
|
||||
|
||||
const memoryCount = knowledge.memory.global + knowledge.memory.project;
|
||||
const pinnedCount = knowledge.notes.length + knowledge.plans.length;
|
||||
const memoryCount = visibleKnowledge.memory.global + visibleKnowledge.memory.project;
|
||||
const pinnedCount = visibleKnowledge.notes.length + visibleKnowledge.plans.length;
|
||||
|
||||
const linked = React.useMemo(() => getLinkedIssues(session), [session]);
|
||||
// Connected servers only. A disabled server contributes nothing to the
|
||||
@@ -281,9 +321,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
? t('chat.workStatus.breakdown.prCountSingle', { count: prCount })
|
||||
: t('chat.workStatus.breakdown.prCountPlural', { count: prCount }));
|
||||
}
|
||||
// Pinned knowledge outranks the ambient counts in the summary: it is
|
||||
// something the user chose for this project, not something that happens to
|
||||
// be installed.
|
||||
// Pinned knowledge outranks ambient counts because the user chose it for this session.
|
||||
if (summaryParts.length === 0 && pinnedCount > 0) {
|
||||
summaryParts.push(pinnedCount === 1
|
||||
? t('chat.workStatus.breakdown.pinnedKnowledgeSingle', { count: pinnedCount })
|
||||
@@ -339,19 +377,18 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
<WorkStatusRow muted label={t('chat.workStatus.linkedIssues.empty')} />
|
||||
) : null}
|
||||
|
||||
{/* Named individually: a count alone would not tell the user which note
|
||||
is riding along with every message they send. */}
|
||||
{/* Named individually: a count alone would not identify this session's context. */}
|
||||
{/* The pin is the control, exactly as in the pinned-messages section
|
||||
above: same icon, same placement, same behaviour. Two pins that look
|
||||
different in one panel would read as two different things. */}
|
||||
{knowledge.notes.map((note) => (
|
||||
{visibleKnowledge.notes.map((note) => (
|
||||
<WorkStatusRow
|
||||
key={note.id}
|
||||
muted
|
||||
leading={(
|
||||
<button
|
||||
type="button"
|
||||
disabled={!projectRef}
|
||||
disabled={!isDraft && (!sessionId || !directory)}
|
||||
aria-label={t('chat.workStatus.breakdown.unpin')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -366,14 +403,14 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
value={<WorkStatusValue tone="muted">{t('chat.workStatus.breakdown.pinnedNote')}</WorkStatusValue>}
|
||||
/>
|
||||
))}
|
||||
{knowledge.plans.map((plan) => (
|
||||
{visibleKnowledge.plans.map((plan) => (
|
||||
<WorkStatusRow
|
||||
key={plan.id}
|
||||
muted
|
||||
leading={(
|
||||
<button
|
||||
type="button"
|
||||
disabled={!projectRef}
|
||||
disabled={!isDraft && (!sessionId || !directory)}
|
||||
aria-label={t('chat.workStatus.breakdown.unpin')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { runBackgroundNetworkTask } from '@/lib/background-network';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummary, useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useGitLabMrForBranch } from '@/lib/gitlabMrStatus';
|
||||
import { useGiteaPrForBranch } from '@/lib/giteaPrStatus';
|
||||
import { useGitProvider } from '@/lib/gitProvider';
|
||||
@@ -110,11 +110,7 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
|
||||
// Read-only: PR watching is owned by the background tracker. Starting a watch
|
||||
// here would multiply GitHub requests per open session, which is exactly the
|
||||
// fan-out the PR-status concurrency gate exists to prevent.
|
||||
const prKey = React.useMemo(
|
||||
() => (directory && branch ? getGitHubPrStatusKey(directory, branch) : null),
|
||||
[directory, branch],
|
||||
);
|
||||
const prSummary = usePrVisualSummary(prKey);
|
||||
const prSummary = useFreshestPrVisualSummaryForBranch(directory, branch);
|
||||
|
||||
// GitLab merge requests and Gitea pull requests ride the same shared TTL
|
||||
// cache as the git view and the walkthrough, so every surface that reports
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveDraftPinnedKnowledge } from './draftKnowledge';
|
||||
|
||||
describe('resolveDraftPinnedKnowledge', () => {
|
||||
test('shows only notes and plans pinned on this draft', () => {
|
||||
expect(resolveDraftPinnedKnowledge(
|
||||
[{ id: 'note-a', body: 'Attached' }, { id: 'note-b', body: 'Not attached' }],
|
||||
[{ id: 'plan-a', title: 'Attached plan' }, { id: 'plan-b', title: 'Other plan' }],
|
||||
{ notes: ['note-a'], plans: ['plan-a'] },
|
||||
)).toEqual({
|
||||
notes: [{ id: 'note-a', body: 'Attached' }],
|
||||
plans: [{ id: 'plan-a', title: 'Attached plan' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('drops stale ids without borrowing project-wide pins', () => {
|
||||
expect(resolveDraftPinnedKnowledge(
|
||||
[{ id: 'note-a', body: 'Project note' }],
|
||||
[{ id: 'plan-a', title: 'Project plan' }],
|
||||
{ notes: ['missing'], plans: [] },
|
||||
)).toEqual({ notes: [], plans: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SessionKnowledgeSummary, SessionProjectContextPins } from '@/lib/sessionKnowledgeApi';
|
||||
|
||||
type NoteSource = { id: string; body: string };
|
||||
type PlanSource = { id: string; title: string };
|
||||
|
||||
export const resolveDraftPinnedKnowledge = (
|
||||
notes: NoteSource[],
|
||||
plans: PlanSource[],
|
||||
pins: SessionProjectContextPins,
|
||||
): Pick<SessionKnowledgeSummary, 'notes' | 'plans'> => {
|
||||
const noteIds = new Set(pins.notes);
|
||||
const planIds = new Set(pins.plans);
|
||||
return {
|
||||
notes: notes.filter((note) => noteIds.has(note.id)).map(({ id, body }) => ({ id, body })),
|
||||
plans: plans.filter((plan) => planIds.has(plan.id)).map(({ id, title }) => ({ id, title })),
|
||||
};
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { computeCacheHitRate } from '@/stores/utils/tokenUtils';
|
||||
import { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
import {
|
||||
derivePartsLabel,
|
||||
deriveUserSnippet,
|
||||
@@ -236,16 +237,6 @@ const computeContextBreakdown = (
|
||||
|
||||
const formatNumber = (value: number): string => value.toLocaleString(getCurrentIntlLocale());
|
||||
|
||||
const formatMoney = (value: number): string => {
|
||||
if (!Number.isFinite(value) || value <= 0) return new Intl.NumberFormat(getCurrentIntlLocale(), { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(0);
|
||||
return new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: value < 0.01 ? 4 : 2,
|
||||
maximumFractionDigits: value < 0.01 ? 4 : 2,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatDateTime = (timestamp: number | null, timeFormatPreference: TimeFormatPreference): string => {
|
||||
if (!timestamp || !Number.isFinite(timestamp)) return '-';
|
||||
return formatDateTimeForPreference(timestamp, timeFormatPreference, {
|
||||
|
||||
@@ -44,6 +44,7 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
|
||||
import { isFilesystemError } from '@/lib/api/files-errors';
|
||||
import { notifyFileContentInvalidated } from '@/lib/fileContentInvalidation';
|
||||
import { isBrowserClientRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
@@ -1044,9 +1045,18 @@ export const SidebarFilesTree: React.FC = () => {
|
||||
const uploadedCount = outcomes.filter((outcome) => outcome === 'uploaded').length;
|
||||
const failedCount = outcomes.filter((outcome) => outcome === 'failed').length;
|
||||
const conflictingFiles = droppedFiles.filter((_, index) => outcomes[index] === 'conflict');
|
||||
const uploadedPaths = droppedFiles.flatMap((file, index) => {
|
||||
const name = getUploadName(file);
|
||||
return outcomes[index] === 'uploaded' && name
|
||||
? [normalizePath(`${directory}/${name}`)]
|
||||
: [];
|
||||
});
|
||||
const isCurrentDestination = rootRef.current === operationRoot && getRuntimeKey() === operationRuntime;
|
||||
|
||||
try {
|
||||
if (uploadedPaths.length > 0) {
|
||||
notifyFileContentInvalidated({ runtimeKey: operationRuntime, paths: uploadedPaths });
|
||||
}
|
||||
if (uploadedCount > 0 && isCurrentDestination) {
|
||||
await refreshDirectory(directory);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SessionDialogs } from '@/components/session/SessionDialogs';
|
||||
import { ChatView } from '@/components/views/ChatView';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useViewportStore } from '@/sync/viewport-store';
|
||||
import { useSessions, useDirectorySync, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context';
|
||||
import { useSessions, useDirectorySync, useSession, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
|
||||
@@ -666,6 +666,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const currentSession = useSession(currentSessionId ?? '');
|
||||
const currentSessionMessages = useSessionMessages(currentSessionId ?? '');
|
||||
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
|
||||
const quotaResults = useQuotaStore((state) => state.results);
|
||||
@@ -1022,6 +1023,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
||||
percentage={stableContextUsage.percentage}
|
||||
contextLimit={stableContextUsage.contextLimit}
|
||||
outputLimit={stableContextUsage.outputLimit ?? 0}
|
||||
cost={(currentSession?.cost ?? 0) > 0 ? currentSession?.cost : null}
|
||||
className="h-9 shrink-0 pl-1 pr-1 typography-ui-label"
|
||||
valueClassName="font-semibold leading-none"
|
||||
hideIcon
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { ComingSoonMessengersSection } from './ComingSoonMessengersSection';
|
||||
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
@@ -21,8 +20,8 @@ export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
|
||||
description={t('settings.page.integrations.description')}
|
||||
showSaveStatus={false}
|
||||
>
|
||||
<ComingSoonMessengersSection />
|
||||
<ThirdPartyIntegrationsSection
|
||||
divider={false}
|
||||
onOpenProviderSetup={onOpenProviderSetup}
|
||||
onOpenPluginManager={onOpenPluginManager}
|
||||
/>
|
||||
|
||||
@@ -36,6 +36,7 @@ type PendingAction = 'install' | 'update' | 'setup' | 'remove';
|
||||
type RemoveTarget = ThirdPartyPluginDefinition | null;
|
||||
|
||||
interface ThirdPartyIntegrationsSectionProps {
|
||||
divider?: boolean;
|
||||
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
|
||||
onOpenPluginManager: () => void;
|
||||
}
|
||||
@@ -46,6 +47,7 @@ const requiresRestart = (result: PluginMutationResult): boolean =>
|
||||
|| result.reloadFailed === true;
|
||||
|
||||
export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSectionProps> = ({
|
||||
divider = true,
|
||||
onOpenProviderSetup,
|
||||
onOpenPluginManager,
|
||||
}) => {
|
||||
@@ -413,6 +415,7 @@ export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSecti
|
||||
<SettingsSection
|
||||
title={t('settings.integrations.thirdParty.title')}
|
||||
info={t('settings.integrations.thirdParty.info')}
|
||||
divider={divider}
|
||||
settingsItem="integrations.third-party"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
|
||||
@@ -275,8 +275,11 @@ export const DefaultsSettings: React.FC = () => {
|
||||
[walkthroughModelOverride]
|
||||
);
|
||||
React.useEffect(() => {
|
||||
// Both pickers filter by the same authenticated-provider list, and the
|
||||
// walkthrough picker is always visible, so this is always worth fetching.
|
||||
// Both pickers offer the same providers — the walkthrough runs through the
|
||||
// small model — and the walkthrough picker is always visible, so this is
|
||||
// always worth fetching. The server answers with the providers it has a
|
||||
// credential and an endpoint for, including plugin-registered ones that
|
||||
// exist only inside the running OpenCode.
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
||||
import { requiresProviderAuth, shouldLoadAvailableProviders } from './providerAvailability';
|
||||
import {
|
||||
getOAuthAuthMethods,
|
||||
normalizeAuthType,
|
||||
@@ -15,6 +15,14 @@ describe('ProvidersPage available provider loading', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProvidersPage provider authentication', () => {
|
||||
test('does not require credentials for a custom provider defined in config', () => {
|
||||
expect(requiresProviderAuth(true, false, true)).toBe(false);
|
||||
expect(requiresProviderAuth(true, false, false)).toBe(true);
|
||||
expect(requiresProviderAuth(true, true, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider auth method helpers', () => {
|
||||
test('normalizeAuthType recognizes oauth and api labels', () => {
|
||||
expect(normalizeAuthType({ type: 'oauth', label: 'Login with Cursor' })).toBe('oauth');
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { ModelMetadata } from '@/types';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
||||
import { requiresProviderAuth, shouldLoadAvailableProviders } from './providerAvailability';
|
||||
import {
|
||||
getOAuthAuthMethods,
|
||||
parseAuthPayload,
|
||||
@@ -324,7 +324,8 @@ export const ProvidersPage: React.FC = () => {
|
||||
? provider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||
: [];
|
||||
const hasCreds = Boolean(sources.auth.exists) || envEntries.length > 0;
|
||||
if (!hasCreds) {
|
||||
const isCustomProvider = Boolean(provider && isConfigDefinedCustomProvider(provider, sources));
|
||||
if (requiresProviderAuth(true, hasCreds, isCustomProvider)) {
|
||||
setShowAuthPanel(true);
|
||||
}
|
||||
}, [selectedProviderId, providerSources, providers]);
|
||||
@@ -773,8 +774,12 @@ export const ProvidersPage: React.FC = () => {
|
||||
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
|
||||
const hasEnvCredentials = providerEnv.length > 0;
|
||||
const hasCredentials = hasStoredAuth || hasEnvCredentials;
|
||||
const authStatusIncomplete = sourcesLoaded && !hasCredentials;
|
||||
const showModelsSection = providerModels.length > 0 && (!sourcesLoaded || hasCredentials);
|
||||
const authStatusIncomplete = requiresProviderAuth(
|
||||
sourcesLoaded,
|
||||
hasCredentials,
|
||||
isEditableCustomProvider,
|
||||
);
|
||||
const showModelsSection = providerModels.length > 0 && !authStatusIncomplete;
|
||||
const incompleteAuthHint = !showApiKeyAuth && oauthAuthMethods.length > 0
|
||||
? t('settings.providers.page.auth.useReconnectHint')
|
||||
: t('settings.providers.page.auth.incompleteHint');
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export const shouldLoadAvailableProviders = (isAddMode: boolean): boolean => isAddMode;
|
||||
|
||||
export const requiresProviderAuth = (
|
||||
sourcesLoaded: boolean,
|
||||
hasCredentials: boolean,
|
||||
isConfigDefinedCustomProvider: boolean,
|
||||
): boolean => sourcesLoaded && !hasCredentials && !isConfigDefinedCustomProvider;
|
||||
|
||||
@@ -127,24 +127,13 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
|
||||
directoryOverride?: string | null;
|
||||
conflictDecisions?: Record<string, ConflictDecision>;
|
||||
}) => {
|
||||
// Build selection with clawdhub metadata if present
|
||||
const selection: { skillDir: string; clawdhub?: { slug: string; version: string } } = {
|
||||
skillDir: request.skillDir,
|
||||
};
|
||||
if (item?.clawdhub) {
|
||||
selection.clawdhub = {
|
||||
slug: item.clawdhub.slug,
|
||||
version: item.clawdhub.version,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await installSkills({
|
||||
source: request.source,
|
||||
subpath: request.subpath,
|
||||
gitIdentityId: item?.gitIdentityId,
|
||||
scope: request.scope,
|
||||
targetSource: request.targetSource,
|
||||
selections: [selection],
|
||||
selections: [{ skillDir: request.skillDir }],
|
||||
conflictPolicy: 'prompt',
|
||||
conflictDecisions: request.conflictDecisions,
|
||||
}, { directory: request.directoryOverride ?? null });
|
||||
|
||||
@@ -4,11 +4,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import {
|
||||
SettingsSection,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
SETTINGS_SELECT_TRIGGER_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,24 +14,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
|
||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SkillsCatalogItem } from '@/lib/api/types';
|
||||
|
||||
import type { SkillsCatalogItem, SkillsCatalogSource } from '@/lib/api/types';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
|
||||
import { AddCatalogDialog } from './AddCatalogDialog';
|
||||
import { InstallSkillDialog } from './InstallSkillDialog';
|
||||
@@ -48,6 +36,71 @@ interface SkillsCatalogPageProps {
|
||||
showModeTabs?: boolean;
|
||||
}
|
||||
|
||||
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
|
||||
const getRepoUrl = (source: string): string | null => {
|
||||
const trimmed = source.trim();
|
||||
if (!GITHUB_REPO_PATTERN.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return `https://github.com/${trimmed}`;
|
||||
};
|
||||
|
||||
const getSkillUrl = (item: SkillsCatalogItem): string | null => {
|
||||
const repoUrl = getRepoUrl(item.repoSource);
|
||||
if (!repoUrl) {
|
||||
return null;
|
||||
}
|
||||
const skillPath = [item.repoSubpath, item.skillDir].filter(Boolean).join('/');
|
||||
return skillPath ? `${repoUrl}/tree/HEAD/${skillPath}` : repoUrl;
|
||||
};
|
||||
|
||||
let cachedStarsFormatter: { locale: string; formatter: Intl.NumberFormat } | null = null;
|
||||
|
||||
const formatStars = (stars: number): string => {
|
||||
const locale = getCurrentIntlLocale();
|
||||
if (!cachedStarsFormatter || cachedStarsFormatter.locale !== locale) {
|
||||
cachedStarsFormatter = { locale, formatter: new Intl.NumberFormat(locale, { notation: 'compact' }) };
|
||||
}
|
||||
return cachedStarsFormatter.formatter.format(stars);
|
||||
};
|
||||
|
||||
type RelativeTimeKey =
|
||||
| 'common.relative.justNow'
|
||||
| 'common.relative.minutesAgoShort'
|
||||
| 'common.relative.hoursAgoShort'
|
||||
| 'common.relative.daysAgoShort'
|
||||
| 'common.relative.weeksAgoShort'
|
||||
| 'common.relative.yearsAgoShort';
|
||||
|
||||
const formatRelativeShort = (isoDate: string): { key: RelativeTimeKey; count: number } | null => {
|
||||
const timestamp = Date.parse(isoDate);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return null;
|
||||
}
|
||||
const diffMs = Date.now() - timestamp;
|
||||
if (diffMs < 60_000) {
|
||||
return { key: 'common.relative.justNow', count: 0 };
|
||||
}
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 60) {
|
||||
return { key: 'common.relative.minutesAgoShort', count: minutes };
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return { key: 'common.relative.hoursAgoShort', count: hours };
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) {
|
||||
return { key: 'common.relative.daysAgoShort', count: days };
|
||||
}
|
||||
const weeks = Math.floor(days / 7);
|
||||
if (weeks < 52) {
|
||||
return { key: 'common.relative.weeksAgoShort', count: weeks };
|
||||
}
|
||||
return { key: 'common.relative.yearsAgoShort', count: Math.floor(days / 365) };
|
||||
};
|
||||
|
||||
const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||
try {
|
||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||
@@ -71,6 +124,67 @@ const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||
}
|
||||
};
|
||||
|
||||
const SourceCard: React.FC<{
|
||||
source: SkillsCatalogSource;
|
||||
isActive: boolean;
|
||||
isLoading: boolean;
|
||||
skillsCount: number | null;
|
||||
onSelect: () => void;
|
||||
t: ReturnType<typeof useI18n>['t'];
|
||||
}> = ({ source, isActive, isLoading, skillsCount, onSelect, t }) => {
|
||||
const stars = source.stars ?? null;
|
||||
const updated = source.repoUpdatedAt ? formatRelativeShort(source.repoUpdatedAt) : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'w-full min-h-24 text-left rounded-lg border bg-[var(--surface-elevated)] p-3.5 flex gap-3 items-start transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive
|
||||
? 'border-primary'
|
||||
: 'border-[var(--surface-subtle)] hover:border-[var(--interactive-border-hover)]'
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 block">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="typography-ui-label font-medium text-foreground truncate">{source.label}</span>
|
||||
{isLoading ? (
|
||||
<Icon name="refresh" className="h-3 w-3 animate-spin text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
skillsCount !== null && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{t('settings.skills.catalog.page.source.skillsCount', { count: skillsCount })}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
<span className="typography-micro font-mono text-muted-foreground block mt-0.5 truncate">{source.source}</span>
|
||||
<span className="flex items-center gap-3 mt-1">
|
||||
{stars !== null && (
|
||||
<span
|
||||
className="typography-micro text-muted-foreground flex items-center gap-1"
|
||||
title={t('settings.skills.catalog.page.source.stars', { count: stars })}
|
||||
>
|
||||
<Icon name="star" className="h-3 w-3" />
|
||||
{formatStars(stars)}
|
||||
</span>
|
||||
)}
|
||||
{updated && (
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{updated.key === 'common.relative.justNow'
|
||||
? t(updated.key)
|
||||
: t('settings.skills.catalog.page.source.updated', { time: t(updated.key, { count: updated.count }) })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange, showModeTabs = true }) => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
@@ -80,12 +194,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
setSelectedSource,
|
||||
loadCatalog,
|
||||
loadSource,
|
||||
loadMoreClawdHub,
|
||||
isLoadingCatalog,
|
||||
isLoadingSource,
|
||||
isLoadingMore,
|
||||
loadedSourceIds,
|
||||
clawdhubHasMoreBySource,
|
||||
lastCatalogError,
|
||||
} = useSkillsCatalogStore(useShallow((s) => ({
|
||||
sources: s.sources,
|
||||
@@ -94,12 +205,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
setSelectedSource: s.setSelectedSource,
|
||||
loadCatalog: s.loadCatalog,
|
||||
loadSource: s.loadSource,
|
||||
loadMoreClawdHub: s.loadMoreClawdHub,
|
||||
isLoadingCatalog: s.isLoadingCatalog,
|
||||
isLoadingSource: s.isLoadingSource,
|
||||
isLoadingMore: s.isLoadingMore,
|
||||
loadedSourceIds: s.loadedSourceIds,
|
||||
clawdhubHasMoreBySource: s.clawdhubHasMoreBySource,
|
||||
lastCatalogError: s.lastCatalogError,
|
||||
})));
|
||||
|
||||
@@ -109,43 +217,72 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
const [installItem, setInstallItem] = React.useState<SkillsCatalogItem | null>(null);
|
||||
const [isRemovingCatalog, setIsRemovingCatalog] = React.useState(false);
|
||||
const [isRemoveCatalogDialogOpen, setIsRemoveCatalogDialogOpen] = React.useState(false);
|
||||
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
// Load every source in the background so global search covers all of them.
|
||||
React.useEffect(() => {
|
||||
if (!selectedSourceId) {
|
||||
const unloaded = sources.filter((src) => !loadedSourceIds[src.id]);
|
||||
if (unloaded.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!loadedSourceIds[selectedSourceId]) {
|
||||
void loadSource(selectedSourceId);
|
||||
let cancelled = false;
|
||||
const loadRest = async () => {
|
||||
for (const src of unloaded) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
await loadSource(src.id);
|
||||
}
|
||||
};
|
||||
void loadRest();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sources, loadedSourceIds, loadSource]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedSourceId || loadedSourceIds[selectedSourceId]) {
|
||||
return;
|
||||
}
|
||||
void loadSource(selectedSourceId);
|
||||
}, [selectedSourceId, loadedSourceIds, loadSource]);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (!selectedSourceId) return [];
|
||||
return itemsBySource[selectedSourceId] || [];
|
||||
}, [itemsBySource, selectedSourceId]);
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
const isSearching = search.trim().length > 0;
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter((item) => {
|
||||
const name = item.skillName.toLowerCase();
|
||||
const desc = (item.description || '').toLowerCase();
|
||||
const fm = (item.frontmatterName || '').toLowerCase();
|
||||
return name.includes(q) || desc.includes(q) || fm.includes(q);
|
||||
});
|
||||
}, [items, search]);
|
||||
const matches = (item: SkillsCatalogItem) =>
|
||||
item.skillName.toLowerCase().includes(q)
|
||||
|| (item.description || '').toLowerCase().includes(q)
|
||||
|| (item.frontmatterName || '').toLowerCase().includes(q);
|
||||
|
||||
if (isSearching) {
|
||||
return sources.flatMap((src) => (itemsBySource[src.id] || []).filter(matches));
|
||||
}
|
||||
if (!selectedSourceId) {
|
||||
return [];
|
||||
}
|
||||
return itemsBySource[selectedSourceId] || [];
|
||||
}, [sources, itemsBySource, selectedSourceId, search, isSearching]);
|
||||
|
||||
const selectedSource = React.useMemo(() => sources.find((s) => s.id === selectedSourceId) || null, [sources, selectedSourceId]);
|
||||
|
||||
const isCustomSource = Boolean(selectedSourceId && selectedSourceId.startsWith('custom:'));
|
||||
const isClawdHubSource = selectedSource?.source === 'clawdhub:registry' || selectedSource?.sourceType === 'clawdhub';
|
||||
const hasMoreClawdHub = Boolean(
|
||||
selectedSourceId && (clawdhubHasMoreBySource[selectedSourceId] ?? true)
|
||||
);
|
||||
|
||||
const removeSelectedCatalog = async () => {
|
||||
if (!selectedSourceId || !isCustomSource) {
|
||||
@@ -165,6 +302,17 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
}
|
||||
};
|
||||
|
||||
const listTitle = isSearching
|
||||
? t('settings.skills.catalog.page.list.searchTitle')
|
||||
: (selectedSource?.label ?? '');
|
||||
|
||||
// The selected source has no items yet and a load is in flight — show the
|
||||
// loading state instead of a stale list from the previously selected source.
|
||||
const isSelectedSourceLoading = !isSearching
|
||||
&& selectedSourceId !== null
|
||||
&& !loadedSourceIds[selectedSourceId]
|
||||
&& (isLoadingSource || isLoadingCatalog);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPageLayout
|
||||
@@ -190,90 +338,74 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="typography-meta text-muted-foreground mb-4">
|
||||
{t('settings.skills.catalog.page.subtitle')}
|
||||
</p>
|
||||
|
||||
|
||||
<div data-settings-item="skills.catalog.search" className="mb-5">
|
||||
<div className="relative max-w-md">
|
||||
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('settings.skills.catalog.page.searchAllPlaceholder')}
|
||||
className={cn('h-8 pl-8 w-full', search && 'pr-8')}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center justify-center h-4 w-4 rounded text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t('settings.skills.catalog.page.search.clear')}
|
||||
>
|
||||
<Icon name="close" className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.skills.catalog.page.section.sourceRepository')}
|
||||
title={t('settings.skills.catalog.page.section.sources')}
|
||||
divider={false}
|
||||
settingsItem="skills.catalog.source"
|
||||
contentClassName="space-y-0"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 py-1.5">
|
||||
<Select
|
||||
value={selectedSourceId || ''}
|
||||
onValueChange={(v) => setSelectedSource(v)}
|
||||
>
|
||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'w-fit')}>
|
||||
<SelectValue placeholder={t('settings.skills.catalog.page.field.selectSourcePlaceholder')}>
|
||||
{selectedSource?.label}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{sources.map((src) => (
|
||||
<SelectItem key={src.id} value={src.id}>
|
||||
{src.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 py-1.5">
|
||||
{sources.map((src) => (
|
||||
<SourceCard
|
||||
key={src.id}
|
||||
source={src}
|
||||
isActive={src.id === selectedSourceId}
|
||||
isLoading={isLoadingSource && !loadedSourceIds[src.id]}
|
||||
skillsCount={loadedSourceIds[src.id] ? (itemsBySource[src.id] || []).length : null}
|
||||
onSelect={() => setSelectedSource(src.id)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0"
|
||||
onClick={() => {
|
||||
if (selectedSourceId) {
|
||||
void loadSource(selectedSourceId, { refresh: true });
|
||||
} else {
|
||||
void loadCatalog({ refresh: true });
|
||||
}
|
||||
}}
|
||||
disabled={isLoadingCatalog || isLoadingSource}
|
||||
title={t('settings.skills.catalog.page.actions.refreshTitle')}
|
||||
>
|
||||
<Icon name="refresh" className={cn("h-3.5 w-3.5", (isLoadingCatalog || isLoadingSource) && "animate-spin")} />
|
||||
</Button>
|
||||
|
||||
{isCustomSource && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
|
||||
onClick={() => setIsRemoveCatalogDialogOpen(true)}
|
||||
disabled={isRemovingCatalog}
|
||||
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
data-settings-item="skills.catalog.add-catalog"
|
||||
size="xs"
|
||||
className="!font-normal gap-1"
|
||||
onClick={() => setAddCatalogOpen(true)}
|
||||
>
|
||||
<Icon name="add" className="h-3.5 w-3.5" /> {t('settings.skills.catalog.page.actions.addCatalog')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div data-settings-item="skills.catalog.search" className="py-1.5">
|
||||
<div className="relative">
|
||||
<Icon name="search" className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('settings.skills.catalog.shared.field.searchSkillsPlaceholder')}
|
||||
className="h-7 pl-8 w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
<span className="typography-meta text-muted-foreground mt-1 block">
|
||||
{isLoadingCatalog
|
||||
? t('settings.skills.catalog.page.loading.catalog')
|
||||
: t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<span className="flex items-center justify-center rounded-md bg-transparent text-muted-foreground w-8 h-8 shrink-0">
|
||||
<Icon name="add" className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
<span className="min-w-0">
|
||||
<span className="typography-ui-label text-muted-foreground block">
|
||||
{t('settings.skills.catalog.page.source.addOwnTitle')}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground/70 block mt-0.5">
|
||||
{t('settings.skills.catalog.page.source.addOwnDescription')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{lastCatalogError && (
|
||||
@@ -286,21 +418,63 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
)}
|
||||
|
||||
<SettingsSection>
|
||||
{filtered.length === 0 && !isLoadingSource ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
|
||||
</div>
|
||||
) : isLoadingSource ? (
|
||||
<div className="flex items-center justify-between gap-2 pb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="typography-micro font-medium uppercase tracking-wide text-muted-foreground truncate">
|
||||
{listTitle}
|
||||
</span>
|
||||
<span className="typography-micro text-muted-foreground/70 shrink-0">
|
||||
{t('settings.skills.catalog.page.foundCount', { count: filtered.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0"
|
||||
onClick={() => {
|
||||
if (selectedSourceId && !isSearching) {
|
||||
void loadSource(selectedSourceId, { refresh: true });
|
||||
} else {
|
||||
void loadCatalog({ refresh: true });
|
||||
}
|
||||
}}
|
||||
disabled={isLoadingCatalog || isLoadingSource}
|
||||
title={t('settings.skills.catalog.page.actions.refreshTitle')}
|
||||
>
|
||||
<Icon name="refresh" className={cn('h-3.5 w-3.5', (isLoadingCatalog || isLoadingSource) && 'animate-spin')} />
|
||||
</Button>
|
||||
{isCustomSource && !isSearching && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0 text-[var(--status-error)] hover:text-[var(--status-error)]"
|
||||
onClick={() => setIsRemoveCatalogDialogOpen(true)}
|
||||
disabled={isRemovingCatalog}
|
||||
title={t('settings.skills.catalog.page.actions.removeCatalogTitle')}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelectedSourceLoading || (isLoadingSource && filtered.length === 0) ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<Icon name="refresh" className="mx-auto mb-3 h-5 w-5 animate-spin opacity-50" />
|
||||
<p className="typography-meta">{t('settings.skills.catalog.page.loading.skills')}</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--surface-subtle)]">
|
||||
{filtered.map((item) => {
|
||||
const installed = item.installed?.isInstalled;
|
||||
const installedScope = item.installed?.scope;
|
||||
const skillUrl = getSkillUrl(item);
|
||||
|
||||
return (
|
||||
<div key={`${item.sourceId}:${item.skillDir}`} className="py-2">
|
||||
@@ -326,24 +500,28 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
<div className="typography-meta text-muted-foreground/50 mt-0.5 italic">{t('settings.skills.catalog.shared.noDescription')}</div>
|
||||
)}
|
||||
|
||||
{item.clawdhub && (
|
||||
<div className="typography-micro text-muted-foreground mt-1.5 flex items-center gap-3">
|
||||
{item.clawdhub.owner && (
|
||||
<span>{t('settings.skills.catalog.page.byOwnerPrefix')} <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon name="download" className="h-3 w-3" />
|
||||
{item.clawdhub.downloads?.toLocaleString() ?? 0}
|
||||
</span>
|
||||
{(item.clawdhub.stars ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon name="star" className="h-3 w-3" />
|
||||
{item.clawdhub.stars}
|
||||
</span>
|
||||
)}
|
||||
<span className="bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">v{item.clawdhub.version}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="typography-micro text-muted-foreground/80 mt-1 flex items-center gap-2 min-w-0">
|
||||
{skillUrl ? (
|
||||
<a
|
||||
href={skillUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono hover:underline truncate inline-flex items-center gap-1"
|
||||
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
|
||||
>
|
||||
<Icon name="github" className="h-3 w-3 shrink-0" />
|
||||
{item.repoSource}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-mono truncate">{item.repoSource}</span>
|
||||
)}
|
||||
{item.skillDir && (
|
||||
<>
|
||||
<span className="opacity-40">·</span>
|
||||
<span className="truncate">{item.skillDir}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.warnings?.length ? (
|
||||
<div className="typography-micro text-[var(--status-warning)] mt-1.5 bg-[var(--status-warning)]/10 px-2 py-1 rounded w-fit">
|
||||
@@ -352,37 +530,43 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal shrink-0"
|
||||
disabled={!item.installable}
|
||||
onClick={() => {
|
||||
setInstallItem(item);
|
||||
setInstallDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('settings.skills.catalog.shared.actions.install')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{skillUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="!font-normal h-6 w-6 px-0"
|
||||
onClick={() => window.open(skillUrl, '_blank', 'noreferrer')}
|
||||
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
|
||||
>
|
||||
<Icon name="external-link" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{installed ? (
|
||||
<span className="text-[var(--status-success)] flex items-center justify-center w-7 h-7" title={t('settings.skills.catalog.page.badge.installed', { scope: installedScope || '' })}>
|
||||
<Icon name="check" className="h-4 w-4" />
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
disabled={!item.installable}
|
||||
onClick={() => {
|
||||
setInstallItem(item);
|
||||
setInstallDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('settings.skills.catalog.shared.actions.install')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isClawdHubSource && hasMoreClawdHub && !isLoadingSource && filtered.length > 0 && (
|
||||
<div className="flex justify-center mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="!font-normal"
|
||||
onClick={() => void loadMoreClawdHub()}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? t('settings.skills.catalog.page.loading.more') : t('settings.skills.catalog.page.actions.loadMoreSkills')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</SettingsPageLayout>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const ArchiveAllDropdown: React.FC<ArchiveAllDropdownProps> = ({ onArchiveAll })
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -60,14 +60,13 @@ Leaving the section or the project closes it, so its editor never sits over a
|
||||
list it no longer matches. Hosts that own a fullscreen plan surface (mobile)
|
||||
still pass `onOpenPlan` and keep theirs.
|
||||
|
||||
## Pins are project state, not a message attachment
|
||||
## Pins belong to one session
|
||||
|
||||
Pinning a note or plan writes to the project, not to the session, so it holds
|
||||
across every session in that project until it is unpinned. The composer once
|
||||
carried a chip for it, from when pinned context was a one-shot attachment to the
|
||||
next message; standing state shown permanently above the input reads as
|
||||
something being attached to what you are typing, which it is not. What is
|
||||
attached, and the control to detach it, live in the work status panel instead.
|
||||
Notes and plans are project data, but attaching one writes its id to the current
|
||||
session metadata. Other sessions in the project do not inherit it. A pin made
|
||||
while a new-session draft is open lives on that draft and transfers only to the
|
||||
session created by its first message. Work status lists and detaches draft pins
|
||||
before that first message, then reads them from the created session metadata.
|
||||
|
||||
## Memory is not a fifth kind of note
|
||||
|
||||
@@ -211,10 +210,8 @@ matched the old project would silently hide everything in the new one.
|
||||
|
||||
## Pinned context
|
||||
|
||||
The pin toggle on a note or plan marks it as standing context for the agent.
|
||||
Assembly and delivery live in `packages/ui/src/lib/projectContextPinning.ts`;
|
||||
this surface only owns the toggle. `ComposerPinnedContextChip` shows the user
|
||||
what is riding along.
|
||||
The pin toggle on a note or plan attaches it to the current session or draft.
|
||||
Assembly and delivery live in `packages/web/server/lib/session-knowledge`.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -23,12 +23,13 @@ const NOTE_SAVE_DEBOUNCE_MS = 400;
|
||||
*/
|
||||
const NoteRow: React.FC<{
|
||||
note: ProjectNote;
|
||||
pinned: boolean;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
onSaveBody: (body: string) => void;
|
||||
onTogglePinned: () => void;
|
||||
onDelete: () => void;
|
||||
}> = ({ note, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
|
||||
}> = ({ note, pinned, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
|
||||
const { t } = useI18n();
|
||||
const [draft, setDraft] = React.useState(note.body);
|
||||
const lastSavedRef = React.useRef(note.body);
|
||||
@@ -107,19 +108,19 @@ const NoteRow: React.FC<{
|
||||
onClick={onTogglePinned}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
note.pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-pressed={note.pinned}
|
||||
aria-label={note.pinned
|
||||
aria-pressed={pinned}
|
||||
aria-label={pinned
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
title={note.pinned
|
||||
title={pinned
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
>
|
||||
{/* Filled means pinned, outline means "pin this" — the same
|
||||
language the work status panel uses. */}
|
||||
<Icon name={note.pinned ? 'pushpin-2-fill' : 'pushpin'} className="h-3.5 w-3.5" />
|
||||
<Icon name={pinned ? 'pushpin-2-fill' : 'pushpin'} className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -163,7 +164,9 @@ export const NotesSection: React.FC<{
|
||||
notes: ProjectNote[];
|
||||
disabled: boolean;
|
||||
query: string;
|
||||
}> = ({ projectRef, notes, disabled, query }) => {
|
||||
pinnedNoteIds: ReadonlySet<string>;
|
||||
onTogglePinned: (noteId: string, pinned: boolean) => Promise<boolean>;
|
||||
}> = ({ projectRef, notes, disabled, query, pinnedNoteIds, onTogglePinned }) => {
|
||||
const { t } = useI18n();
|
||||
const [composerText, setComposerText] = React.useState('');
|
||||
// One at a time on purpose: notes can run to 3000 characters each, and
|
||||
@@ -173,7 +176,6 @@ export const NotesSection: React.FC<{
|
||||
const setNotesPanelHeight = useUIStore((state) => state.setNotesPanelHeight);
|
||||
const createNote = useProjectContextStore((state) => state.createNote);
|
||||
const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody);
|
||||
const setNotePinned = useProjectContextStore((state) => state.setNotePinned);
|
||||
const deleteNote = useProjectContextStore((state) => state.deleteNote);
|
||||
|
||||
const visibleNotes = React.useMemo(() => {
|
||||
@@ -214,12 +216,12 @@ export const NotesSection: React.FC<{
|
||||
|
||||
const handleTogglePinned = React.useCallback(
|
||||
async (noteId: string, pinned: boolean) => {
|
||||
const ok = await setNotePinned(projectRef, noteId, pinned);
|
||||
const ok = await onTogglePinned(noteId, pinned);
|
||||
if (!ok) {
|
||||
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
|
||||
}
|
||||
},
|
||||
[projectRef, reportFailure, setNotePinned, t]
|
||||
[onTogglePinned, reportFailure, t]
|
||||
);
|
||||
|
||||
const handleSaveBody = React.useCallback(
|
||||
@@ -281,10 +283,11 @@ export const NotesSection: React.FC<{
|
||||
<NoteRow
|
||||
key={note.id}
|
||||
note={note}
|
||||
pinned={pinnedNoteIds.has(note.id)}
|
||||
expanded={expandedNoteId === note.id}
|
||||
onToggleExpanded={() => setExpandedNoteId((current) => (current === note.id ? null : note.id))}
|
||||
onSaveBody={(body) => handleSaveBody(note.id, body)}
|
||||
onTogglePinned={() => void handleTogglePinned(note.id, !note.pinned)}
|
||||
onTogglePinned={() => void handleTogglePinned(note.id, !pinnedNoteIds.has(note.id))}
|
||||
onDelete={() => void handleDelete(note.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -24,14 +24,15 @@ export const PlansSection: React.FC<{
|
||||
query: string;
|
||||
/** Hosts without a ContextPanel (mobile) render their own plan viewer. */
|
||||
onOpenPlan?: (plan: { id: string; title: string }) => void;
|
||||
}> = ({ projectRef, plans, query, onOpenPlan }) => {
|
||||
pinnedPlanIds: ReadonlySet<string>;
|
||||
onTogglePinned: (planId: string, pinned: boolean) => Promise<boolean>;
|
||||
}> = ({ projectRef, plans, query, onOpenPlan, pinnedPlanIds, onTogglePinned }) => {
|
||||
const { t } = useI18n();
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [isImporting, setIsImporting] = React.useState(false);
|
||||
const [deletingPlanId, setDeletingPlanId] = React.useState<string | null>(null);
|
||||
const createPlan = useProjectContextStore((state) => state.createPlan);
|
||||
const removePlan = useProjectContextStore((state) => state.deletePlan);
|
||||
const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
|
||||
@@ -136,13 +137,13 @@ export const PlansSection: React.FC<{
|
||||
|
||||
const handleTogglePinned = React.useCallback(
|
||||
async (planId: string, pinned: boolean) => {
|
||||
const ok = await setPlanPinned(projectRef, planId, pinned);
|
||||
const ok = await onTogglePinned(planId, pinned);
|
||||
if (!ok) {
|
||||
const detail = useProjectContextStore.getState().getEntry(projectRef).error;
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.updatePlanFailed'), detail ? { description: detail } : undefined);
|
||||
}
|
||||
},
|
||||
[projectRef, setPlanPinned, t]
|
||||
[onTogglePinned, projectRef, t]
|
||||
);
|
||||
|
||||
const visiblePlans = React.useMemo(() => {
|
||||
@@ -220,16 +221,16 @@ export const PlansSection: React.FC<{
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleTogglePinned(plan.id, !plan.pinned)}
|
||||
onClick={() => void handleTogglePinned(plan.id, !pinnedPlanIds.has(plan.id))}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
plan.pinned ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
pinnedPlanIds.has(plan.id) ? 'text-primary' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-pressed={plan.pinned}
|
||||
aria-label={plan.pinned
|
||||
aria-pressed={pinnedPlanIds.has(plan.id)}
|
||||
aria-label={pinnedPlanIds.has(plan.id)
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
title={plan.pinned
|
||||
title={pinnedPlanIds.has(plan.id)
|
||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||
>
|
||||
|
||||
@@ -17,6 +17,8 @@ import { NotesSection } from './NotesSection';
|
||||
import { PlansSection } from './PlansSection';
|
||||
import { TodosSection } from './TodosSection';
|
||||
import { useProjectTodoSend } from './useProjectTodoSend';
|
||||
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionProjectContextPins } from '@/lib/sessionKnowledgeApi';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
/** Lazy: the plan editor is a large view, and most panel visits never open it. */
|
||||
const PlanView = React.lazy(() => import('@/components/views/PlanView').then((module) => ({ default: module.PlanView })));
|
||||
@@ -85,6 +87,43 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
);
|
||||
const loadProjectContext = useProjectContextStore((state) => state.load);
|
||||
const saveTodos = useProjectContextStore((state) => state.saveTodos);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
|
||||
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
|
||||
const setDraftProjectContextPin = useSessionUIStore((state) => state.setDraftProjectContextPin);
|
||||
const [sessionPins, setSessionPins] = React.useState<SessionProjectContextPins>({ notes: [], plans: [] });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (newSessionDraft.open) {
|
||||
setSessionPins(newSessionDraft.projectContextPins ?? { notes: [], plans: [] });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void fetchSessionKnowledgeSummary(currentSessionDirectory, currentSessionId).then((summary) => {
|
||||
if (!cancelled) {
|
||||
setSessionPins({
|
||||
notes: summary.notes.map((note) => note.id),
|
||||
plans: summary.plans.map((plan) => plan.id),
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [currentSessionDirectory, currentSessionId, newSessionDraft.open, newSessionDraft.projectContextPins]);
|
||||
|
||||
const toggleSessionPin = React.useCallback(async (kind: 'note' | 'plan', id: string, pinned: boolean) => {
|
||||
if (newSessionDraft.open) {
|
||||
setDraftProjectContextPin(kind, id, pinned);
|
||||
return true;
|
||||
}
|
||||
if (!currentSessionId || !currentSessionDirectory) return false;
|
||||
const next = await setSessionProjectContextPin(currentSessionDirectory, currentSessionId, kind, id, pinned);
|
||||
if (!next) return false;
|
||||
setSessionPins(next);
|
||||
return true;
|
||||
}, [currentSessionDirectory, currentSessionId, newSessionDraft.open, setDraftProjectContextPin]);
|
||||
|
||||
const pinnedNoteIds = React.useMemo(() => new Set(sessionPins.notes), [sessionPins.notes]);
|
||||
const pinnedPlanIds = React.useMemo(() => new Set(sessionPins.plans), [sessionPins.plans]);
|
||||
|
||||
// The whole feature is one switch: with memory off there is nothing for the
|
||||
// agent to manage, so showing the user what is stored would be pointless.
|
||||
@@ -387,6 +426,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
notes={contextEntry.notes}
|
||||
disabled={isLoading}
|
||||
query={query}
|
||||
pinnedNoteIds={pinnedNoteIds}
|
||||
onTogglePinned={(noteId, pinned) => toggleSessionPin('note', noteId, pinned)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -413,6 +454,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
projectRef={projectRef}
|
||||
plans={contextEntry.plans}
|
||||
query={query}
|
||||
pinnedPlanIds={pinnedPlanIds}
|
||||
onTogglePinned={(planId, pinned) => toggleSessionPin('plan', planId, pinned)}
|
||||
// Hosts that own a fullscreen plan surface (mobile) keep it; on the
|
||||
// desktop panel the plan opens here, in place of the list.
|
||||
onOpenPlan={onOpenPlan ?? setOpenPlan}
|
||||
|
||||
@@ -1185,7 +1185,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
</div>
|
||||
{group.isArchivedBucket && allGroupSessions.length > 0 ? (
|
||||
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1208,7 +1208,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
) : null}
|
||||
{group.directory && !group.isMain && group.worktree ? (
|
||||
<div className={cn('absolute right-7 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1232,7 +1232,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
) : null}
|
||||
{group.directory ? (
|
||||
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -224,7 +224,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -84,7 +84,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
icon inset inside the 24px buttons so the first glyph lines up
|
||||
with the New-session icon above (16px from the sidebar edge). */}
|
||||
<div className="ml-[3px] flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -98,7 +98,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.addProject')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -112,7 +112,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.scheduledTasks')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -127,7 +127,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.newMultiRun')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -143,7 +143,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -158,7 +158,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.searchSessions')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -180,7 +180,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -312,7 +312,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
showCreateButtons ? 'right-7' : 'right-0.5',
|
||||
)}>
|
||||
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? (
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -368,7 +368,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
|
||||
{showCreateButtons && onNewSession ? (
|
||||
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { formatMoney } from '@/lib/money';
|
||||
import { clampPercent, resolveUsageTone } from '@/lib/quota';
|
||||
|
||||
interface ContextUsageDisplayProps {
|
||||
@@ -12,6 +13,7 @@ interface ContextUsageDisplayProps {
|
||||
colorPercentage?: number;
|
||||
contextLimit: number;
|
||||
outputLimit?: number;
|
||||
cost?: number | null;
|
||||
size?: 'default' | 'compact';
|
||||
isMobile?: boolean;
|
||||
hideIcon?: boolean;
|
||||
@@ -29,6 +31,7 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
||||
colorPercentage,
|
||||
contextLimit,
|
||||
outputLimit,
|
||||
cost = null,
|
||||
size = 'default',
|
||||
isMobile = false,
|
||||
hideIcon = false,
|
||||
@@ -73,10 +76,13 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
||||
const circularProgressOffset = circularProgressCircumference * (1 - progressPct / 100);
|
||||
|
||||
const safeOutputLimit = typeof outputLimit === 'number' ? Math.max(outputLimit, 0) : 0;
|
||||
const normalizedCost = cost ?? 0;
|
||||
const hasCost = normalizedCost > 0 && Number.isFinite(normalizedCost);
|
||||
const tooltipLines = [
|
||||
t('contextUsage.tooltip.usedTokens', { tokens: formatTokens(totalTokens) }),
|
||||
t('contextUsage.tooltip.contextLimit', { tokens: formatTokens(contextLimit) }),
|
||||
t('contextUsage.tooltip.outputLimit', { tokens: formatTokens(safeOutputLimit) }),
|
||||
...(hasCost ? [t('contextUsage.tooltip.cost', { cost: formatMoney(normalizedCost) })] : []),
|
||||
];
|
||||
|
||||
const isInteractive = !isMobile && typeof onClick === 'function';
|
||||
@@ -183,6 +189,12 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
||||
<span className="typography-meta text-muted-foreground">{t('contextUsage.mobile.outputLimit')}</span>
|
||||
<span className="typography-meta text-foreground font-medium">{formatTokens(safeOutputLimit)}</span>
|
||||
</div>
|
||||
{hasCost ? (
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="typography-meta text-muted-foreground">{t('contextUsage.mobile.cost')}</span>
|
||||
<span className="typography-meta text-foreground font-medium">{formatMoney(normalizedCost)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between items-center pt-1 border-t border-border/40">
|
||||
<span className="typography-meta text-muted-foreground">{t('contextUsage.mobile.usage')}</span>
|
||||
<span className={cn('typography-meta font-semibold', getPercentageColor(colorPct))}>
|
||||
|
||||
@@ -111,7 +111,7 @@ function DialogContent({
|
||||
{showCloseButton && (
|
||||
<BaseDialog.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[open]:bg-interactive-active data-[open]:text-foreground absolute top-2 right-2 rounded-lg opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none text-muted-foreground hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
className="ring-offset-background focus:ring-ring data-[open]:bg-interactive-active data-[open]:text-foreground absolute top-2 right-2 z-10 inline-flex size-7 items-center justify-center rounded-lg opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none text-muted-foreground hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<Icon name="close"/>
|
||||
<span className="sr-only">{t('dialog.common.actions.close')}</span>
|
||||
|
||||
@@ -48,8 +48,9 @@ import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile,
|
||||
import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave';
|
||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
import { acquireRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, subscribeRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getOutsideFileGrant } from '@/lib/outsideFileGrants';
|
||||
import { subscribeToFileContentInvalidation } from '@/lib/fileContentInvalidation';
|
||||
import { DiagramEditor } from '@/components/diagram';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
@@ -920,6 +921,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
const lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
|
||||
const activeFileLoadIdRef = React.useRef(0);
|
||||
const loadingFilePathRef = React.useRef<string | null>(null);
|
||||
const [fileContentRevision, setFileContentRevision] = React.useState(0);
|
||||
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
|
||||
const [diagramSaved, setDiagramSaved] = React.useState(false);
|
||||
const [contentDetectedBinary, setContentDetectedBinary] = React.useState(false);
|
||||
@@ -2046,13 +2048,33 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
loadingFilePathRef.current = null;
|
||||
}
|
||||
});
|
||||
}, [loadSelectedFile, loadedFilePath, selectedFile]);
|
||||
}, [fileContentRevision, loadSelectedFile, loadedFilePath, selectedFile]);
|
||||
|
||||
// Sync isDirty to a ref so the polling interval can read the latest value
|
||||
// without isDirty in its dependency array (avoids interval restart on every edit/save).
|
||||
const isDirtyRef = React.useRef(isDirty);
|
||||
isDirtyRef.current = isDirty;
|
||||
|
||||
React.useEffect(() => subscribeToFileContentInvalidation(({ runtimeKey, paths }) => {
|
||||
const selectedPath = selectedFile?.path;
|
||||
if (
|
||||
runtimeKey !== getRuntimeKey()
|
||||
|| !selectedPath
|
||||
|| isDirtyRef.current
|
||||
|| !paths.includes(normalizePath(selectedPath))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeFileLoadIdRef.current += 1;
|
||||
loadingFilePathRef.current = null;
|
||||
lastLoadedFileStatRef.current = null;
|
||||
setDesktopImageSrc('');
|
||||
setFileError(null);
|
||||
setLoadedFilePath(null);
|
||||
setFileContentRevision((revision) => revision + 1);
|
||||
}), [selectedFile?.path]);
|
||||
|
||||
// Poll open file for external changes.
|
||||
// When a change is detected, reset loadedFilePath so the effect above
|
||||
// triggers a single reload — no double-load.
|
||||
@@ -3006,11 +3028,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
);
|
||||
|
||||
const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf
|
||||
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
|
||||
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}|${fileContentRevision}`
|
||||
: '';
|
||||
|
||||
const htmlAssetAuthKey = selectedFile?.path && isHtml && htmlViewMode === 'preview' && !runtime.isVSCode
|
||||
? selectedFile.path
|
||||
? `${selectedFile.path}|${fileContentRevision}`
|
||||
: '';
|
||||
|
||||
const assetAuthErrorFallback = t('filesView.error.readFileFailed');
|
||||
@@ -3113,7 +3135,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [files, isSelectedImage, isSelectedSvg, root, selectedFile?.path, selectedFileReadOptions, t]);
|
||||
}, [fileContentRevision, files, isSelectedImage, isSelectedSvg, root, selectedFile?.path, selectedFileReadOptions, t]);
|
||||
|
||||
const handleCloseDialog = React.useCallback(() => setActiveDialog(null), []);
|
||||
|
||||
|
||||
@@ -2072,7 +2072,7 @@ export type RuntimeAPISelector<TValue> = (apis: RuntimeAPIs) => TValue;
|
||||
|
||||
type SkillsCatalogSourceId = string;
|
||||
|
||||
type SkillsCatalogSourceType = 'github' | 'clawdhub';
|
||||
type SkillsCatalogSourceType = 'github';
|
||||
|
||||
export interface SkillsCatalogSource {
|
||||
id: SkillsCatalogSourceId;
|
||||
@@ -2081,6 +2081,10 @@ export interface SkillsCatalogSource {
|
||||
source: string;
|
||||
defaultSubpath?: string;
|
||||
sourceType?: SkillsCatalogSourceType;
|
||||
/** GitHub repository star count (null when unavailable) */
|
||||
stars?: number | null;
|
||||
/** GitHub repository last-push timestamp, ISO (null when unavailable) */
|
||||
repoUpdatedAt?: string | null;
|
||||
}
|
||||
|
||||
interface SkillsCatalogItemInstalledBadge {
|
||||
@@ -2089,18 +2093,6 @@ interface SkillsCatalogItemInstalledBadge {
|
||||
source?: 'opencode' | 'agents' | 'claude';
|
||||
}
|
||||
|
||||
interface ClawdHubSkillMetadata {
|
||||
slug: string;
|
||||
version: string;
|
||||
displayName?: string;
|
||||
owner?: string;
|
||||
downloads?: number;
|
||||
stars?: number;
|
||||
versionsCount?: number;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface SkillsCatalogItem {
|
||||
sourceId: SkillsCatalogSourceId;
|
||||
repoSource: string;
|
||||
@@ -2113,22 +2105,18 @@ export interface SkillsCatalogItem {
|
||||
installable: boolean;
|
||||
warnings?: string[];
|
||||
installed?: SkillsCatalogItemInstalledBadge;
|
||||
/** ClawdHub-specific metadata (present only for ClawdHub sources) */
|
||||
clawdhub?: ClawdHubSkillMetadata;
|
||||
}
|
||||
|
||||
export interface SkillsCatalogResponse {
|
||||
ok: boolean;
|
||||
sources?: SkillsCatalogSource[];
|
||||
itemsBySource?: Record<SkillsCatalogSourceId, SkillsCatalogItem[]>;
|
||||
pageInfoBySource?: Record<SkillsCatalogSourceId, { nextCursor?: string | null }>;
|
||||
error?: { kind: string; message: string };
|
||||
}
|
||||
|
||||
export interface SkillsCatalogSourceResponse {
|
||||
ok: boolean;
|
||||
items?: SkillsCatalogItem[];
|
||||
nextCursor?: string | null;
|
||||
error?: { kind: string; message: string };
|
||||
}
|
||||
|
||||
@@ -2153,11 +2141,6 @@ export interface SkillsRepoScanResponse {
|
||||
|
||||
interface SkillsInstallSelection {
|
||||
skillDir: string;
|
||||
/** ClawdHub-specific metadata for installation */
|
||||
clawdhub?: {
|
||||
slug: string;
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SkillsInstallRequest {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
notifyFileContentInvalidated,
|
||||
subscribeToFileContentInvalidation,
|
||||
} from './fileContentInvalidation';
|
||||
|
||||
describe('fileContentInvalidation', () => {
|
||||
test('publishes normalized paths within the captured runtime', () => {
|
||||
const received: Array<{ runtimeKey: string; paths: readonly string[] }> = [];
|
||||
const unsubscribe = subscribeToFileContentInvalidation((invalidation) => {
|
||||
received.push(invalidation);
|
||||
});
|
||||
|
||||
notifyFileContentInvalidated({
|
||||
runtimeKey: ' runtime-a ',
|
||||
paths: [' /repo/a.txt ', '/repo/a.txt', '', '/repo/b.txt'],
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
expect(received).toEqual([{
|
||||
runtimeKey: 'runtime-a',
|
||||
paths: ['/repo/a.txt', '/repo/b.txt'],
|
||||
}]);
|
||||
});
|
||||
|
||||
test('stops publishing after unsubscribe', () => {
|
||||
let calls = 0;
|
||||
const unsubscribe = subscribeToFileContentInvalidation(() => {
|
||||
calls += 1;
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
notifyFileContentInvalidated({ runtimeKey: 'runtime-a', paths: ['/repo/a.txt'] });
|
||||
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
type FileContentInvalidation = {
|
||||
runtimeKey: string;
|
||||
paths: readonly string[];
|
||||
};
|
||||
|
||||
type FileContentInvalidationListener = (invalidation: FileContentInvalidation) => void;
|
||||
|
||||
const listeners = new Set<FileContentInvalidationListener>();
|
||||
|
||||
export const notifyFileContentInvalidated = (invalidation: FileContentInvalidation): void => {
|
||||
const runtimeKey = invalidation.runtimeKey.trim();
|
||||
const paths = Array.from(new Set(invalidation.paths.map((path) => path.trim()).filter(Boolean)));
|
||||
if (!runtimeKey || paths.length === 0) return;
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener({ runtimeKey, paths });
|
||||
}
|
||||
};
|
||||
|
||||
export const subscribeToFileContentInvalidation = (
|
||||
listener: FileContentInvalidationListener,
|
||||
): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
@@ -854,16 +854,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': 'Manuell',
|
||||
'settings.skills.catalog.page.mode.external': 'Extern',
|
||||
'settings.skills.catalog.page.title': 'Fähigkeitskatalog',
|
||||
'settings.skills.catalog.page.subtitle': 'Installiere fertige Skills aus kuratierten Repositories oder füge eine eigene Quelle hinzu.',
|
||||
'settings.skills.catalog.page.section.sources': 'Quellen',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Skills in allen Quellen suchen…',
|
||||
'settings.skills.catalog.page.search.clear': 'Suche löschen',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Sterne: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Aktualisiert {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Eigene Quelle hinzufügen',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Beliebiges Git-Repository mit Skills',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Repository auf GitHub öffnen',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Skill auf GitHub ansehen',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Suchergebnisse',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'Quell-Repository',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Quelle auswählen',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': 'Aktualisieren',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Katalog entfernen',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Katalog hinzufügen',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Katalog entfernen',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'Weitere Fähigkeiten laden',
|
||||
'settings.skills.catalog.page.loading.catalog': 'Wird geladen...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Fähigkeiten werden geladen...',
|
||||
'settings.skills.catalog.page.loading.more': 'Wird geladen...',
|
||||
'settings.skills.catalog.page.foundCount': '{count} Fähigkeit(en) gefunden',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Katalogfehler',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Keine Fähigkeiten gefunden',
|
||||
@@ -871,7 +881,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': 'installiert ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'nicht installierbar',
|
||||
'settings.skills.catalog.page.badge.unknown': 'unbekannt',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': 'von',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Katalog entfernen',
|
||||
'settings.skills.catalog.page.removeDialog.description': 'Sind Sie sicher, dass Sie diesen Katalog entfernen möchten?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -1383,10 +1383,12 @@ export const dict = {
|
||||
'contextUsage.mobile.usedTokens': 'Verwendete Tokens',
|
||||
'contextUsage.mobile.contextLimit': 'Kontextlimit',
|
||||
'contextUsage.mobile.outputLimit': 'Ausgabelimit',
|
||||
'contextUsage.mobile.cost': 'Kosten',
|
||||
'contextUsage.mobile.usage': 'Nutzung',
|
||||
'contextUsage.tooltip.usedTokens': 'Verwendete Tokens: {tokens}',
|
||||
'contextUsage.tooltip.contextLimit': 'Kontextlimit: {tokens}',
|
||||
'contextUsage.tooltip.outputLimit': 'Ausgabelimit: {tokens}',
|
||||
'contextUsage.tooltip.cost': 'Kosten: {cost}',
|
||||
'contextSidebar.session.untitled': 'Unbenannte Sitzung',
|
||||
'contextSidebar.empty.openSession': 'Öffnen Sie eine Sitzung, um den Kontext zu prüfen.',
|
||||
'contextSidebar.section.context': 'Kontext',
|
||||
@@ -2163,6 +2165,9 @@ export const dict = {
|
||||
'chat.revert.toast.undo': 'Zurückgesetzt auf {preview}',
|
||||
'chat.revert.toast.redo': 'Wiederholt',
|
||||
'chat.revert.toast.restored': 'Alle Nachrichten wiederhergestellt',
|
||||
'chat.toast.opencodeRestartInterrupted.title': 'Chat unterbrochen',
|
||||
'chat.toast.opencodeRestartInterrupted.description': 'OpenCode wurde neu gestartet, während noch eine Antwort lief. Senden Sie eine Nachricht, um fortzufahren.',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': 'Sitzung öffnen',
|
||||
'chat.errorBoundary.title': 'Chat-Fehler',
|
||||
'chat.errorBoundary.description': 'Die Chat-Oberfläche hat einen Fehler festgestellt. Dies könnte auf ein vorübergehendes Netzwerkproblem oder beschädigte Nachrichtendaten zurückzuführen sein.',
|
||||
'chat.errorBoundary.sessionLabel': 'Sitzung',
|
||||
|
||||
@@ -906,16 +906,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': 'Manual',
|
||||
'settings.skills.catalog.page.mode.external': 'External',
|
||||
'settings.skills.catalog.page.title': 'Skills Catalog',
|
||||
'settings.skills.catalog.page.subtitle': 'Install ready-made skills from curated repositories, or add your own source.',
|
||||
'settings.skills.catalog.page.section.sources': 'Sources',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Search skills across all sources…',
|
||||
'settings.skills.catalog.page.search.clear': 'Clear search',
|
||||
'settings.skills.catalog.page.source.skillsCount': '{count} skills',
|
||||
'settings.skills.catalog.page.source.stars': '{count} stars',
|
||||
'settings.skills.catalog.page.source.updated': 'Updated {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Add your own source',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Any Git repository with skills',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Open repository on GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'View skill on GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Search results',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'Source Repository',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Select source',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': 'Refresh',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Remove Catalog',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Add Catalog',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Remove Catalog',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'Load More Skills',
|
||||
'settings.skills.catalog.page.loading.catalog': 'Loading...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Loading skills...',
|
||||
'settings.skills.catalog.page.loading.more': 'Loading...',
|
||||
'settings.skills.catalog.page.foundCount': '{count} skill(s) found',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Catalog error',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'No skills found',
|
||||
@@ -923,7 +933,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': 'installed ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'not installable',
|
||||
'settings.skills.catalog.page.badge.unknown': 'unknown',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': 'by',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Remove Catalog',
|
||||
'settings.skills.catalog.page.removeDialog.description': 'Are you sure you want to remove this catalog?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -1635,10 +1635,12 @@ export const dict = {
|
||||
'contextUsage.mobile.usedTokens': 'Used tokens',
|
||||
'contextUsage.mobile.contextLimit': 'Context limit',
|
||||
'contextUsage.mobile.outputLimit': 'Output limit',
|
||||
'contextUsage.mobile.cost': 'Cost',
|
||||
'contextUsage.mobile.usage': 'Usage',
|
||||
'contextUsage.tooltip.usedTokens': 'Used tokens: {tokens}',
|
||||
'contextUsage.tooltip.contextLimit': 'Context limit: {tokens}',
|
||||
'contextUsage.tooltip.outputLimit': 'Output limit: {tokens}',
|
||||
'contextUsage.tooltip.cost': 'Cost: {cost}',
|
||||
'contextSidebar.session.untitled': 'Untitled Session',
|
||||
'contextSidebar.empty.openSession': 'Open a session to inspect context.',
|
||||
'contextSidebar.section.context': 'Context',
|
||||
@@ -2421,6 +2423,9 @@ export const dict = {
|
||||
'chat.revert.toast.undo': 'Reverted to {preview}',
|
||||
'chat.revert.toast.redo': 'Redone',
|
||||
'chat.revert.toast.restored': 'Restored all messages',
|
||||
'chat.toast.opencodeRestartInterrupted.title': 'Chat interrupted',
|
||||
'chat.toast.opencodeRestartInterrupted.description': 'OpenCode restarted while a response was still running. Send a message to continue.',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': 'Open session',
|
||||
'chat.errorBoundary.title': 'Chat Error',
|
||||
'chat.errorBoundary.description': 'The chat interface encountered an error. This might be due to a temporary network issue or corrupted message data.',
|
||||
'chat.errorBoundary.sessionLabel': 'Session',
|
||||
|
||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.mode.manual": "Manual",
|
||||
"settings.skills.catalog.page.mode.external": "Externo",
|
||||
"settings.skills.catalog.page.title": "Catálogo de habilidades",
|
||||
'settings.skills.catalog.page.subtitle': 'Instala skills listos desde repositorios curados o añade tu propia fuente.',
|
||||
'settings.skills.catalog.page.section.sources': 'Fuentes',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Buscar skills en todas las fuentes…',
|
||||
'settings.skills.catalog.page.search.clear': 'Borrar búsqueda',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Estrellas: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Actualizado {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Añadir tu propia fuente',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Cualquier repositorio Git con skills',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Abrir repositorio en GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Ver skill en GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Resultados de búsqueda',
|
||||
"settings.skills.catalog.page.section.sourceRepository": "Repositorio de origen",
|
||||
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Seleccionar origen",
|
||||
"settings.skills.catalog.page.actions.refreshTitle": "Actualizar",
|
||||
"settings.skills.catalog.page.actions.removeCatalogTitle": "Eliminar catálogo",
|
||||
"settings.skills.catalog.page.actions.addCatalog": "Añadir catálogo",
|
||||
"settings.skills.catalog.page.actions.removeCatalog": "Eliminar catálogo",
|
||||
"settings.skills.catalog.page.actions.loadMoreSkills": "Cargar más habilidades",
|
||||
"settings.skills.catalog.page.loading.catalog": "Cargando...",
|
||||
"settings.skills.catalog.page.loading.skills": "Cargando habilidades...",
|
||||
"settings.skills.catalog.page.loading.more": "Cargando...",
|
||||
"settings.skills.catalog.page.foundCount": "{count} habilidad(es) encontrada(s)",
|
||||
"settings.skills.catalog.page.error.catalogTitle": "Error del catálogo",
|
||||
"settings.skills.catalog.page.empty.noSkillsTitle": "No se encontraron habilidades",
|
||||
@@ -891,7 +901,6 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.badge.installed": "instalado ({scope})",
|
||||
"settings.skills.catalog.page.badge.notInstallable": "no instalable",
|
||||
"settings.skills.catalog.page.badge.unknown": "desconocido",
|
||||
"settings.skills.catalog.page.byOwnerPrefix": "por",
|
||||
"settings.skills.catalog.page.removeDialog.title": "Eliminar catálogo",
|
||||
"settings.skills.catalog.page.removeDialog.description": "¿Estás seguro de que quieres eliminar este catálogo?",
|
||||
"settings.openchamber.passkeys.title": "Claves de paso",
|
||||
|
||||
@@ -1602,10 +1602,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextUsage.mobile.usedTokens": "Tokens usados",
|
||||
"contextUsage.mobile.contextLimit": "Límite de contexto",
|
||||
"contextUsage.mobile.outputLimit": "Límite de salida",
|
||||
"contextUsage.mobile.cost": "Costo",
|
||||
"contextUsage.mobile.usage": "Uso",
|
||||
"contextUsage.tooltip.usedTokens": "Tokens usados: {tokens}",
|
||||
"contextUsage.tooltip.contextLimit": "Límite de contexto: {tokens}",
|
||||
"contextUsage.tooltip.outputLimit": "Límite de salida: {tokens}",
|
||||
"contextUsage.tooltip.cost": "Costo: {cost}",
|
||||
"contextSidebar.session.untitled": "Sesión sin título",
|
||||
"contextSidebar.empty.openSession": "Abrir una sesión para inspeccionar el contexto.",
|
||||
"contextSidebar.section.context": "Contexto",
|
||||
@@ -2400,6 +2402,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.revert.toast.undo": "Revertido a {preview}",
|
||||
"chat.revert.toast.redo": "Rehecho",
|
||||
"chat.revert.toast.restored": "Todos los mensajes restaurados",
|
||||
"chat.toast.opencodeRestartInterrupted.title": "Conversación interrumpida",
|
||||
"chat.toast.opencodeRestartInterrupted.description": "OpenCode se reinició mientras aún se estaba generando una respuesta. Envía un mensaje para continuar.",
|
||||
"chat.toast.opencodeRestartInterrupted.openSession": "Abrir sesión",
|
||||
"chat.errorBoundary.title": "Error en la conversación",
|
||||
"chat.errorBoundary.description": "La interfaz de la conversación encontró un error. Esto podría deberse a un problema de red temporal o a datos de mensaje corruptos.",
|
||||
"chat.errorBoundary.sessionLabel": "Sesión",
|
||||
|
||||
@@ -792,16 +792,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': 'Manuel',
|
||||
'settings.skills.catalog.page.mode.external': 'Externe',
|
||||
'settings.skills.catalog.page.title': 'Catalogue de skills',
|
||||
'settings.skills.catalog.page.subtitle': "Installez des skills prêts à l'emploi depuis des dépôts curatés ou ajoutez votre propre source.",
|
||||
'settings.skills.catalog.page.section.sources': 'Sources',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Rechercher des skills dans toutes les sources…',
|
||||
'settings.skills.catalog.page.search.clear': 'Effacer la recherche',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Skills : {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Étoiles : {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Mis à jour {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Ajouter votre propre source',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': "N'importe quel dépôt Git avec des skills",
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Ouvrir le dépôt sur GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Voir le skill sur GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Résultats de recherche',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'Dépôt source',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Sélectionnez la source',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': 'Rafraîchir',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Supprimer le catalogue',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Ajouter un catalogue',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Supprimer le catalogue',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'Charger plus de skills',
|
||||
'settings.skills.catalog.page.loading.catalog': 'Chargement...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Chargement des skills...',
|
||||
'settings.skills.catalog.page.loading.more': 'Chargement...',
|
||||
'settings.skills.catalog.page.foundCount': '{count} skill(s) trouvé(s)',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Erreur de catalogue',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Aucun skill trouvé',
|
||||
@@ -809,7 +819,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': 'installé ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'non installable',
|
||||
'settings.skills.catalog.page.badge.unknown': 'inconnu',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': 'par',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Supprimer le catalogue',
|
||||
'settings.skills.catalog.page.removeDialog.description': 'Êtes-vous sûr de vouloir supprimer ce catalogue ?',
|
||||
'settings.openchamber.passkeys.title': 'Mots-clés',
|
||||
|
||||
@@ -1311,10 +1311,12 @@ export const dict = {
|
||||
'contextUsage.mobile.usedTokens': 'Jetons utilisés',
|
||||
'contextUsage.mobile.contextLimit': 'Limite de contexte',
|
||||
'contextUsage.mobile.outputLimit': 'Limite de sortie',
|
||||
'contextUsage.mobile.cost': 'Coût',
|
||||
'contextUsage.mobile.usage': 'Usage',
|
||||
'contextUsage.tooltip.usedTokens': 'Jetons utilisés : {tokens}',
|
||||
'contextUsage.tooltip.contextLimit': 'Limite de contexte : {tokens}',
|
||||
'contextUsage.tooltip.outputLimit': 'Limite de sortie : {tokens}',
|
||||
'contextUsage.tooltip.cost': 'Coût : {cost}',
|
||||
'contextSidebar.session.untitled': 'Session sans titre',
|
||||
'contextSidebar.empty.openSession': 'Ouvrez une session pour inspecter le contexte.',
|
||||
'contextSidebar.section.context': 'Contexte',
|
||||
@@ -2074,6 +2076,9 @@ export const dict = {
|
||||
'chat.revert.toast.undo': 'Revenu à {preview}',
|
||||
'chat.revert.toast.redo': 'Refait',
|
||||
'chat.revert.toast.restored': 'Restauré tous les messages',
|
||||
'chat.toast.opencodeRestartInterrupted.title': 'Discussion interrompue',
|
||||
'chat.toast.opencodeRestartInterrupted.description': 'OpenCode a redémarré alors qu’une réponse était encore en cours. Envoyez un message pour continuer.',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': 'Ouvrir la session',
|
||||
'chat.errorBoundary.title': 'Erreur de discussion',
|
||||
'chat.errorBoundary.description': 'L\'interface de discussion a rencontré une erreur. Cela peut être dû à un problème de réseau temporaire ou à des données de message corrompues.',
|
||||
'chat.errorBoundary.sessionLabel': 'Session',
|
||||
|
||||
@@ -907,16 +907,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': '手動',
|
||||
'settings.skills.catalog.page.mode.external': '外部',
|
||||
'settings.skills.catalog.page.title': 'スキルカタログ',
|
||||
'settings.skills.catalog.page.subtitle': 'キュレーションされたリポジトリからすぐ使えるスキルをインストール、または独自のソースを追加。',
|
||||
'settings.skills.catalog.page.section.sources': 'ソース',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'すべてのソースのスキルを検索…',
|
||||
'settings.skills.catalog.page.search.clear': '検索をクリア',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'スキル数: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'スター: {count}',
|
||||
'settings.skills.catalog.page.source.updated': '更新: {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': '独自のソースを追加',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'スキルを含む任意の Git リポジトリ',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'GitHub でリポジトリを開く',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'GitHub でスキルを表示',
|
||||
'settings.skills.catalog.page.list.searchTitle': '検索結果',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'ソースリポジトリ',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'ソースを選択',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': '更新',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'カタログを削除',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'カタログを追加',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'カタログを削除',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'さらに Skill を読み込む',
|
||||
'settings.skills.catalog.page.loading.catalog': '読み込み中...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Skill を読み込み中...',
|
||||
'settings.skills.catalog.page.loading.more': '読み込み中...',
|
||||
'settings.skills.catalog.page.foundCount': '{count} 個の Skill が見つかりました',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'カタログエラー',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Skill が見つかりません',
|
||||
@@ -924,7 +934,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': 'インストール済み ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'インストール不可',
|
||||
'settings.skills.catalog.page.badge.unknown': '不明',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': '提供',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'カタログを削除',
|
||||
'settings.skills.catalog.page.removeDialog.description': 'このカタログを削除してもよろしいですか?',
|
||||
'settings.openchamber.passkeys.title': 'パスキー',
|
||||
|
||||
@@ -1632,10 +1632,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextUsage.mobile.usedTokens': '使用トークン',
|
||||
'contextUsage.mobile.contextLimit': 'コンテキスト制限',
|
||||
'contextUsage.mobile.outputLimit': '出力制限',
|
||||
'contextUsage.mobile.cost': 'コスト',
|
||||
'contextUsage.mobile.usage': '使用量',
|
||||
'contextUsage.tooltip.usedTokens': '使用トークン: {tokens}',
|
||||
'contextUsage.tooltip.contextLimit': 'コンテキスト制限: {tokens}',
|
||||
'contextUsage.tooltip.outputLimit': '出力制限: {tokens}',
|
||||
'contextUsage.tooltip.cost': 'コスト: {cost}',
|
||||
'contextSidebar.session.untitled': '無題のセッション',
|
||||
'contextSidebar.empty.openSession': 'セッションを開いてコンテキストを確認します。',
|
||||
'contextSidebar.section.context': 'コンテキスト',
|
||||
@@ -2418,6 +2420,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.revert.toast.undo': '{preview}に元に戻しました',
|
||||
'chat.revert.toast.redo': 'やり直しました',
|
||||
'chat.revert.toast.restored': 'すべてのメッセージを復元しました',
|
||||
'chat.toast.opencodeRestartInterrupted.title': 'チャットが中断されました',
|
||||
'chat.toast.opencodeRestartInterrupted.description': '応答の生成中に OpenCode が再起動しました。続行するにはメッセージを送信してください。',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': 'セッションを開く',
|
||||
'chat.errorBoundary.title': 'チャットエラー',
|
||||
'chat.errorBoundary.description': 'チャットインターフェースでエラーが発生しました。一時的なネットワーク問題または破損したメッセージデータが原因の可能性があります。',
|
||||
'chat.errorBoundary.sessionLabel': 'セッション',
|
||||
|
||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': '수동',
|
||||
'settings.skills.catalog.page.mode.external': 'External',
|
||||
'settings.skills.catalog.page.title': '스킬 카탈로그',
|
||||
'settings.skills.catalog.page.subtitle': '선별된 저장소에서 바로 사용 가능한 스킬을 설치하거나 직접 소스를 추가하세요.',
|
||||
'settings.skills.catalog.page.section.sources': '소스',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': '모든 소스에서 스킬 검색…',
|
||||
'settings.skills.catalog.page.search.clear': '검색 지우기',
|
||||
'settings.skills.catalog.page.source.skillsCount': '스킬: {count}개',
|
||||
'settings.skills.catalog.page.source.stars': '스타: {count}',
|
||||
'settings.skills.catalog.page.source.updated': '업데이트: {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': '직접 소스 추가',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': '스킬이 있는 아무 Git 저장소',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'GitHub에서 저장소 열기',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'GitHub에서 스킬 보기',
|
||||
'settings.skills.catalog.page.list.searchTitle': '검색 결과',
|
||||
'settings.skills.catalog.page.section.sourceRepository': '카탈로그 저장소',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': '저장소 선택',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': '새로고침',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Catalog 제거',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Catalog 추가',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Catalog 제거',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': '스킬 더 불러오기',
|
||||
'settings.skills.catalog.page.loading.catalog': '로딩 중...',
|
||||
'settings.skills.catalog.page.loading.skills': '스킬 불러오는 중...',
|
||||
'settings.skills.catalog.page.loading.more': '로딩 중...',
|
||||
'settings.skills.catalog.page.foundCount': '스킬 {count}개 발견',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Catalog 오류',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': '스킬을 찾을 수 없습니다',
|
||||
@@ -891,7 +901,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': '설치됨({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': '설치할 수 없음',
|
||||
'settings.skills.catalog.page.badge.unknown': '알 수 없음',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': '작성자',
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Catalog 제거',
|
||||
'settings.skills.catalog.page.removeDialog.description': '이 카탈로그를 제거하시겠습니까?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -1638,10 +1638,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextUsage.mobile.usedTokens': '사용한 토큰',
|
||||
'contextUsage.mobile.contextLimit': '컨텍스트 한도',
|
||||
'contextUsage.mobile.outputLimit': '출력 한도',
|
||||
'contextUsage.mobile.cost': '비용',
|
||||
'contextUsage.mobile.usage': '사용량',
|
||||
'contextUsage.tooltip.usedTokens': '사용됨 토큰: {tokens}',
|
||||
'contextUsage.tooltip.contextLimit': '컨텍스트 한도: {tokens}',
|
||||
'contextUsage.tooltip.outputLimit': '출력 한도: {tokens}',
|
||||
'contextUsage.tooltip.cost': '비용: {cost}',
|
||||
'contextSidebar.session.untitled': '제목 없는 세션',
|
||||
'contextSidebar.empty.openSession': '컨텍스트를 볼 세션을 여세요.',
|
||||
'contextSidebar.section.context': '컨텍스트',
|
||||
@@ -2424,6 +2426,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.revert.toast.undo': '{preview}(으)로 되돌림',
|
||||
'chat.revert.toast.redo': '다시 실행',
|
||||
'chat.revert.toast.restored': '모든 메시지 복원됨',
|
||||
'chat.toast.opencodeRestartInterrupted.title': '채팅이 중단되었습니다',
|
||||
'chat.toast.opencodeRestartInterrupted.description': '응답이 진행 중인 동안 OpenCode가 다시 시작되었습니다. 계속하려면 메시지를 보내세요.',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': '세션 열기',
|
||||
'chat.errorBoundary.title': '채팅 오류',
|
||||
'chat.errorBoundary.description': '채팅 인터페이스에서 오류가 발생했습니다. 일시적인 네트워크 이슈 또는 손상된 메시지 데이터 때문일 수 있습니다.',
|
||||
'chat.errorBoundary.sessionLabel': '세션',
|
||||
|
||||
@@ -1918,21 +1918,18 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.installSkill.toast.installFailed': 'Nie udało się zainstalować umiejętności',
|
||||
'settings.skills.catalog.installSkill.toast.installed': 'Umiejętność została zainstalowana',
|
||||
'settings.skills.catalog.page.actions.addCatalog': 'Dodaj katalog',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'Załaduj więcej umiejętności',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': 'Odśwież',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': 'Usuń katalog',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Usuń katalog',
|
||||
'settings.skills.catalog.page.badge.installed': 'zainstalowano ({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': 'nie można zainstalować',
|
||||
'settings.skills.catalog.page.badge.unknown': 'nieznane',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': 'autor:',
|
||||
'settings.skills.catalog.page.empty.noSkillsDescription': 'Spróbuj innego wyszukiwania lub odśwież katalog',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Nie znaleziono umiejętności',
|
||||
'settings.skills.catalog.page.error.catalogTitle': 'Błąd katalogu',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Wybierz źródło',
|
||||
'settings.skills.catalog.page.foundCount': 'Znaleziono {count} umiejętności',
|
||||
'settings.skills.catalog.page.loading.catalog': 'Ładowanie...',
|
||||
'settings.skills.catalog.page.loading.more': 'Ładowanie...',
|
||||
'settings.skills.catalog.page.loading.skills': 'Ładowanie umiejętności...',
|
||||
'settings.skills.catalog.page.mode.external': 'Zewnętrzny',
|
||||
'settings.skills.catalog.page.mode.manual': 'Ręczny',
|
||||
@@ -1940,6 +1937,18 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.removeDialog.title': 'Usuń katalog',
|
||||
'settings.skills.catalog.page.section.sourceRepository': 'Repozytorium źródłowe',
|
||||
'settings.skills.catalog.page.title': 'Katalog umiejętności',
|
||||
'settings.skills.catalog.page.subtitle': 'Instaluj gotowe umiejętności z kuratorowanych repozytoriów lub dodaj własne źródło.',
|
||||
'settings.skills.catalog.page.section.sources': 'Źródła',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Szukaj umiejętności we wszystkich źródłach…',
|
||||
'settings.skills.catalog.page.search.clear': 'Wyczyść wyszukiwanie',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Umiejętności: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Gwiazdki: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Zaktualizowano {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Dodaj własne źródło',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Dowolne repozytorium Git z umiejętnościami',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Otwórz repozytorium na GitHubie',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Zobacz umiejętność na GitHubie',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Wyniki wyszukiwania',
|
||||
'settings.skills.catalog.shared.actions.install': 'Zainstaluj',
|
||||
'settings.skills.catalog.shared.actions.installing': 'Instalowanie...',
|
||||
'settings.skills.catalog.shared.actions.scan': 'Skanuj',
|
||||
|
||||
@@ -812,6 +812,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.revert.toast.undo': 'Cofnięte do {preview}',
|
||||
'chat.revert.toast.redo': 'Ponowione',
|
||||
'chat.revert.toast.restored': 'Przywrócono wszystkie wiadomości',
|
||||
'chat.toast.opencodeRestartInterrupted.title': 'Czat został przerwany',
|
||||
'chat.toast.opencodeRestartInterrupted.description': 'OpenCode uruchomił się ponownie podczas generowania odpowiedzi. Wyślij wiadomość, aby kontynuować.',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': 'Otwórz sesję',
|
||||
'chat.errorBoundary.title': 'Błąd Czatu',
|
||||
'chat.errorBoundary.description': 'Interfejs czatu napotkał błąd. Może to być spowodowane tymczasowym problemem sieciowym lub uszkodzonymi danymi wiadomości.',
|
||||
'chat.errorBoundary.sessionLabel': 'Sesja',
|
||||
@@ -1789,11 +1792,13 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextUsage.aria.label': 'Użycie kontekstu',
|
||||
'contextUsage.mobile.contextLimit': 'Limit kontekstu',
|
||||
'contextUsage.mobile.outputLimit': 'Limit wyjścia',
|
||||
'contextUsage.mobile.cost': 'Koszt',
|
||||
'contextUsage.mobile.title': 'Użycie kontekstu',
|
||||
'contextUsage.mobile.usage': 'Zużycie',
|
||||
'contextUsage.mobile.usedTokens': 'Zużyte tokeny',
|
||||
'contextUsage.tooltip.contextLimit': 'Limit kontekstu: {tokens}',
|
||||
'contextUsage.tooltip.outputLimit': 'Limit wyjścia: {tokens}',
|
||||
'contextUsage.tooltip.cost': 'Koszt: {cost}',
|
||||
'contextUsage.tooltip.usedTokens': 'Zużyte tokeny: {tokens}',
|
||||
'desktopHostSwitcher.actions.add': 'Dodaj',
|
||||
'desktopHostSwitcher.actions.addInstance': 'Dodaj instancję',
|
||||
|
||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.mode.manual": "Manual",
|
||||
"settings.skills.catalog.page.mode.external": "Externo",
|
||||
"settings.skills.catalog.page.title": "Catálogo de habilidades",
|
||||
'settings.skills.catalog.page.subtitle': 'Instale skills prontas de repositórios curados ou adicione sua própria fonte.',
|
||||
'settings.skills.catalog.page.section.sources': 'Fontes',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Pesquisar skills em todas as fontes…',
|
||||
'settings.skills.catalog.page.search.clear': 'Limpar pesquisa',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Skills: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Estrelas: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Atualizado {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Adicionar sua própria fonte',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Qualquer repositório Git com skills',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Abrir repositório no GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Ver skill no GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Resultados da pesquisa',
|
||||
"settings.skills.catalog.page.section.sourceRepository": "Repositório de origem",
|
||||
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Selecionar origem",
|
||||
"settings.skills.catalog.page.actions.refreshTitle": "Atualizar",
|
||||
"settings.skills.catalog.page.actions.removeCatalogTitle": "Excluir catálogo",
|
||||
"settings.skills.catalog.page.actions.addCatalog": "Adicionar catálogo",
|
||||
"settings.skills.catalog.page.actions.removeCatalog": "Excluir catálogo",
|
||||
"settings.skills.catalog.page.actions.loadMoreSkills": "Carregar mais habilidades",
|
||||
"settings.skills.catalog.page.loading.catalog": "Carregando...",
|
||||
"settings.skills.catalog.page.loading.skills": "Carregando habilidades...",
|
||||
"settings.skills.catalog.page.loading.more": "Carregando...",
|
||||
"settings.skills.catalog.page.foundCount": "{count} habilidade(es) encontrada(s)",
|
||||
"settings.skills.catalog.page.error.catalogTitle": "Erro do catálogo",
|
||||
"settings.skills.catalog.page.empty.noSkillsTitle": "Nenhuma habilidade encontrada",
|
||||
@@ -891,7 +901,6 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.badge.installed": "instalado ({scope})",
|
||||
"settings.skills.catalog.page.badge.notInstallable": "não instalável",
|
||||
"settings.skills.catalog.page.badge.unknown": "desconhecido",
|
||||
"settings.skills.catalog.page.byOwnerPrefix": "por",
|
||||
"settings.skills.catalog.page.removeDialog.title": "Excluir catálogo",
|
||||
"settings.skills.catalog.page.removeDialog.description": "Tem certeza de que deseja excluir este catálogo?",
|
||||
"settings.openchamber.passkeys.title": "Chaves de acesso",
|
||||
|
||||
@@ -1602,10 +1602,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextUsage.mobile.usedTokens": "Tokens usados",
|
||||
"contextUsage.mobile.contextLimit": "Limite de contexto",
|
||||
"contextUsage.mobile.outputLimit": "Limite de saída",
|
||||
"contextUsage.mobile.cost": "Custo",
|
||||
"contextUsage.mobile.usage": "Uso",
|
||||
"contextUsage.tooltip.usedTokens": "Tokens usados: {tokens}",
|
||||
"contextUsage.tooltip.contextLimit": "Limite de contexto: {tokens}",
|
||||
"contextUsage.tooltip.outputLimit": "Limite de saída: {tokens}",
|
||||
"contextUsage.tooltip.cost": "Custo: {cost}",
|
||||
"contextSidebar.session.untitled": "Sessão sem título",
|
||||
"contextSidebar.empty.openSession": "Abrir uma sessão para inspecionar o contexto.",
|
||||
"contextSidebar.section.context": "Contexto",
|
||||
@@ -2400,6 +2402,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.revert.toast.undo": "Revertido para {preview}",
|
||||
"chat.revert.toast.redo": "Refeito",
|
||||
"chat.revert.toast.restored": "Todas as mensagens restauradas",
|
||||
"chat.toast.opencodeRestartInterrupted.title": "Conversa interrompida",
|
||||
"chat.toast.opencodeRestartInterrupted.description": "O OpenCode foi reiniciado enquanto uma resposta ainda estava em andamento. Envie uma mensagem para continuar.",
|
||||
"chat.toast.opencodeRestartInterrupted.openSession": "Abrir sessão",
|
||||
"chat.errorBoundary.title": "Erro na conversa",
|
||||
"chat.errorBoundary.description": "A interface da conversa encontrou um erro. Isso pode ter sido causado por um problema temporário de rede ou por dados de mensagem corrompidos.",
|
||||
"chat.errorBoundary.sessionLabel": "Sessão",
|
||||
|
||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.mode.manual": "Вручну",
|
||||
"settings.skills.catalog.page.mode.external": "зовнішній",
|
||||
"settings.skills.catalog.page.title": "Каталог навичок",
|
||||
'settings.skills.catalog.page.subtitle': 'Встановлюйте готові скіли з курованих репозиторіїв або додайте власне джерело.',
|
||||
'settings.skills.catalog.page.section.sources': 'Джерела',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': 'Пошук скілів у всіх джерелах…',
|
||||
'settings.skills.catalog.page.search.clear': 'Очистити пошук',
|
||||
'settings.skills.catalog.page.source.skillsCount': 'Скілів: {count}',
|
||||
'settings.skills.catalog.page.source.stars': 'Зірок: {count}',
|
||||
'settings.skills.catalog.page.source.updated': 'Оновлено {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': 'Додати власне джерело',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': 'Будь-який git-репозиторій зі скілами',
|
||||
'settings.skills.catalog.page.source.viewRepo': 'Відкрити репозиторій на GitHub',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': 'Переглянути скіл на GitHub',
|
||||
'settings.skills.catalog.page.list.searchTitle': 'Результати пошуку',
|
||||
"settings.skills.catalog.page.section.sourceRepository": "Репозиторій вихідного коду",
|
||||
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Виберіть джерело",
|
||||
"settings.skills.catalog.page.actions.refreshTitle": "Оновити",
|
||||
"settings.skills.catalog.page.actions.removeCatalogTitle": "Видалити каталог",
|
||||
"settings.skills.catalog.page.actions.addCatalog": "Додати каталог",
|
||||
"settings.skills.catalog.page.actions.removeCatalog": "Видалити каталог",
|
||||
"settings.skills.catalog.page.actions.loadMoreSkills": "Завантажити додаткові навички",
|
||||
"settings.skills.catalog.page.loading.catalog": "Завантаження...",
|
||||
"settings.skills.catalog.page.loading.skills": "Завантаження навичок...",
|
||||
"settings.skills.catalog.page.loading.more": "Завантаження...",
|
||||
"settings.skills.catalog.page.foundCount": "Знайдено навички {count}",
|
||||
"settings.skills.catalog.page.error.catalogTitle": "Помилка каталогу",
|
||||
"settings.skills.catalog.page.empty.noSkillsTitle": "Навички не знайдено",
|
||||
@@ -891,7 +901,6 @@ export const settingsDict = {
|
||||
"settings.skills.catalog.page.badge.installed": "встановлено ({scope})",
|
||||
"settings.skills.catalog.page.badge.notInstallable": "не встановлюється",
|
||||
"settings.skills.catalog.page.badge.unknown": "невідомий",
|
||||
"settings.skills.catalog.page.byOwnerPrefix": "за",
|
||||
"settings.skills.catalog.page.removeDialog.title": "Видалити каталог",
|
||||
"settings.skills.catalog.page.removeDialog.description": "Ви впевнені, що хочете видалити цей каталог?",
|
||||
"settings.openchamber.passkeys.title": "Ключі доступу",
|
||||
|
||||
@@ -1602,10 +1602,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextUsage.mobile.usedTokens": "Використані токени",
|
||||
"contextUsage.mobile.contextLimit": "Обмеження контексту",
|
||||
"contextUsage.mobile.outputLimit": "Ліміт виводу",
|
||||
"contextUsage.mobile.cost": "Вартість",
|
||||
"contextUsage.mobile.usage": "Використання",
|
||||
"contextUsage.tooltip.usedTokens": "Використані токени: {tokens}",
|
||||
"contextUsage.tooltip.contextLimit": "Обмеження контексту: {tokens}",
|
||||
"contextUsage.tooltip.outputLimit": "Ліміт виводу: {tokens}",
|
||||
"contextUsage.tooltip.cost": "Вартість: {cost}",
|
||||
"contextSidebar.session.untitled": "Сесія без назви",
|
||||
"contextSidebar.empty.openSession": "Відкрийте сесію, щоб перевірити контекст.",
|
||||
"contextSidebar.section.context": "Контекст",
|
||||
@@ -2400,6 +2402,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.revert.toast.undo": "Відкочено до {preview}",
|
||||
"chat.revert.toast.redo": "Повторено",
|
||||
"chat.revert.toast.restored": "Всі повідомлення відновлено",
|
||||
"chat.toast.opencodeRestartInterrupted.title": "Чат перервано",
|
||||
"chat.toast.opencodeRestartInterrupted.description": "OpenCode перезапустився, поки відповідь ще формувалася. Надішліть повідомлення, щоб продовжити.",
|
||||
"chat.toast.opencodeRestartInterrupted.openSession": "Відкрити сесію",
|
||||
"chat.errorBoundary.title": "Помилка чату",
|
||||
"chat.errorBoundary.description": "В інтерфейсі чату сталася помилка. Причиною може бути тимчасова проблема з мережею або пошкоджені дані повідомлення.",
|
||||
"chat.errorBoundary.sessionLabel": "Сесія",
|
||||
|
||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': '手动',
|
||||
'settings.skills.catalog.page.mode.external': '外部',
|
||||
'settings.skills.catalog.page.title': '技能目录',
|
||||
'settings.skills.catalog.page.subtitle': '从精选仓库安装现成技能,或添加你自己的来源。',
|
||||
'settings.skills.catalog.page.section.sources': '来源',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': '在所有来源中搜索技能…',
|
||||
'settings.skills.catalog.page.search.clear': '清除搜索',
|
||||
'settings.skills.catalog.page.source.skillsCount': '技能数:{count}',
|
||||
'settings.skills.catalog.page.source.stars': '星标:{count}',
|
||||
'settings.skills.catalog.page.source.updated': '更新于 {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': '添加自己的来源',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': '任何包含技能的 Git 仓库',
|
||||
'settings.skills.catalog.page.source.viewRepo': '在 GitHub 上打开仓库',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': '在 GitHub 上查看技能',
|
||||
'settings.skills.catalog.page.list.searchTitle': '搜索结果',
|
||||
'settings.skills.catalog.page.section.sourceRepository': '来源仓库',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': '选择来源',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': '刷新',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目录',
|
||||
'settings.skills.catalog.page.actions.addCatalog': '添加目录',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': '移除目录',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': '加载更多技能',
|
||||
'settings.skills.catalog.page.loading.catalog': '加载中...',
|
||||
'settings.skills.catalog.page.loading.skills': '正在加载技能...',
|
||||
'settings.skills.catalog.page.loading.more': '加载中...',
|
||||
'settings.skills.catalog.page.foundCount': '找到 {count} 个技能',
|
||||
'settings.skills.catalog.page.error.catalogTitle': '目录错误',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': '未找到技能',
|
||||
@@ -891,7 +901,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': '已安装({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': '不可安装',
|
||||
'settings.skills.catalog.page.badge.unknown': '未知',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': '作者',
|
||||
'settings.skills.catalog.page.removeDialog.title': '移除目录',
|
||||
'settings.skills.catalog.page.removeDialog.description': '确定要移除此目录吗?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -1602,10 +1602,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextUsage.mobile.usedTokens': '已用 Token',
|
||||
'contextUsage.mobile.contextLimit': '上下文上限',
|
||||
'contextUsage.mobile.outputLimit': '输出上限',
|
||||
'contextUsage.mobile.cost': '成本',
|
||||
'contextUsage.mobile.usage': '使用率',
|
||||
'contextUsage.tooltip.usedTokens': '已用 Token:{tokens}',
|
||||
'contextUsage.tooltip.contextLimit': '上下文上限:{tokens}',
|
||||
'contextUsage.tooltip.outputLimit': '输出上限:{tokens}',
|
||||
'contextUsage.tooltip.cost': '成本:{cost}',
|
||||
'contextSidebar.session.untitled': '未命名会话',
|
||||
'contextSidebar.empty.openSession': '请先打开会话以查看上下文。',
|
||||
'contextSidebar.section.context': '上下文',
|
||||
@@ -2388,6 +2390,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.revert.toast.undo': '已撤回至 {preview}',
|
||||
'chat.revert.toast.redo': '已重做',
|
||||
'chat.revert.toast.restored': '已恢复全部消息',
|
||||
'chat.toast.opencodeRestartInterrupted.title': '聊天已中断',
|
||||
'chat.toast.opencodeRestartInterrupted.description': 'OpenCode 在回复仍在生成时重启了。发送一条消息以继续。',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': '打开会话',
|
||||
'chat.errorBoundary.title': '聊天错误',
|
||||
'chat.errorBoundary.description': '聊天界面发生错误,可能是临时网络问题或消息数据损坏导致。',
|
||||
'chat.errorBoundary.sessionLabel': '会话',
|
||||
|
||||
@@ -871,16 +871,26 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.mode.manual': '手動',
|
||||
'settings.skills.catalog.page.mode.external': '外部',
|
||||
'settings.skills.catalog.page.title': 'Skills 目錄',
|
||||
'settings.skills.catalog.page.subtitle': '從精選儲存庫安裝現成技能,或新增你自己的來源。',
|
||||
'settings.skills.catalog.page.section.sources': '來源',
|
||||
'settings.skills.catalog.page.searchAllPlaceholder': '在所有來源中搜尋技能…',
|
||||
'settings.skills.catalog.page.search.clear': '清除搜尋',
|
||||
'settings.skills.catalog.page.source.skillsCount': '技能數:{count}',
|
||||
'settings.skills.catalog.page.source.stars': '星標:{count}',
|
||||
'settings.skills.catalog.page.source.updated': '更新於 {time}',
|
||||
'settings.skills.catalog.page.source.addOwnTitle': '新增自己的來源',
|
||||
'settings.skills.catalog.page.source.addOwnDescription': '任何包含技能的 Git 儲存庫',
|
||||
'settings.skills.catalog.page.source.viewRepo': '在 GitHub 上開啟儲存庫',
|
||||
'settings.skills.catalog.page.skill.viewOnGithub': '在 GitHub 上檢視技能',
|
||||
'settings.skills.catalog.page.list.searchTitle': '搜尋結果',
|
||||
'settings.skills.catalog.page.section.sourceRepository': '來源儲存庫',
|
||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': '選擇來源',
|
||||
'settings.skills.catalog.page.actions.refreshTitle': '重新整理',
|
||||
'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目錄',
|
||||
'settings.skills.catalog.page.actions.addCatalog': '新增目錄',
|
||||
'settings.skills.catalog.page.actions.removeCatalog': '移除目錄',
|
||||
'settings.skills.catalog.page.actions.loadMoreSkills': '載入更多 Skills',
|
||||
'settings.skills.catalog.page.loading.catalog': '載入中...',
|
||||
'settings.skills.catalog.page.loading.skills': '正在載入 skills...',
|
||||
'settings.skills.catalog.page.loading.more': '載入中...',
|
||||
'settings.skills.catalog.page.foundCount': '找到 {count} 個 skill(s)',
|
||||
'settings.skills.catalog.page.error.catalogTitle': '目錄錯誤',
|
||||
'settings.skills.catalog.page.empty.noSkillsTitle': '找不到 skills',
|
||||
@@ -888,7 +898,6 @@ export const settingsDict = {
|
||||
'settings.skills.catalog.page.badge.installed': '已安裝({scope})',
|
||||
'settings.skills.catalog.page.badge.notInstallable': '不可安裝',
|
||||
'settings.skills.catalog.page.badge.unknown': '未知',
|
||||
'settings.skills.catalog.page.byOwnerPrefix': '作者',
|
||||
'settings.skills.catalog.page.removeDialog.title': '移除目錄',
|
||||
'settings.skills.catalog.page.removeDialog.description': '確定要移除此目錄嗎?',
|
||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||
|
||||
@@ -1612,10 +1612,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextUsage.mobile.usedTokens': '已用 Token',
|
||||
'contextUsage.mobile.contextLimit': '上下文上限',
|
||||
'contextUsage.mobile.outputLimit': '輸出上限',
|
||||
'contextUsage.mobile.cost': '成本',
|
||||
'contextUsage.mobile.usage': '使用率',
|
||||
'contextUsage.tooltip.usedTokens': '已用 Token:{tokens}',
|
||||
'contextUsage.tooltip.contextLimit': '上下文上限:{tokens}',
|
||||
'contextUsage.tooltip.outputLimit': '輸出上限:{tokens}',
|
||||
'contextUsage.tooltip.cost': '成本:{cost}',
|
||||
'contextSidebar.session.untitled': '未命名會話',
|
||||
'contextSidebar.empty.openSession': '請先開啟會話以查看上下文。',
|
||||
'contextSidebar.section.context': '上下文',
|
||||
@@ -2392,6 +2394,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.revert.toast.undo': '已收回至 {preview}',
|
||||
'chat.revert.toast.redo': '已重做',
|
||||
'chat.revert.toast.restored': '已恢復全部訊息',
|
||||
'chat.toast.opencodeRestartInterrupted.title': '聊天已中斷',
|
||||
'chat.toast.opencodeRestartInterrupted.description': 'OpenCode 在回覆仍在產生時重新啟動。傳送訊息以繼續。',
|
||||
'chat.toast.opencodeRestartInterrupted.openSession': '開啟會話',
|
||||
'chat.errorBoundary.title': '聊天錯誤',
|
||||
'chat.errorBoundary.description': '聊天介面發生錯誤,可能是暫時網路問題或訊息資料損毀導致。',
|
||||
'chat.errorBoundary.sessionLabel': '會話',
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import type { Part } from "@opencode-ai/sdk/v2";
|
||||
|
||||
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
||||
export const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||
export const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
||||
export const GITLAB_ISSUE_CONTEXT_PREFIX = 'GitLab issue context (JSON)';
|
||||
export const GITLAB_MR_CONTEXT_PREFIX = 'GitLab merge request context (JSON)';
|
||||
export const GITEA_ISSUE_CONTEXT_PREFIX = 'Gitea issue context (JSON)';
|
||||
export const GITEA_PR_CONTEXT_PREFIX = 'Gitea pull request context (JSON)';
|
||||
|
||||
export const FORGE_CONTEXT_PREFIXES = [
|
||||
GITHUB_ISSUE_CONTEXT_PREFIX,
|
||||
GITHUB_PR_CONTEXT_PREFIX,
|
||||
GITLAB_ISSUE_CONTEXT_PREFIX,
|
||||
GITLAB_MR_CONTEXT_PREFIX,
|
||||
GITEA_ISSUE_CONTEXT_PREFIX,
|
||||
GITEA_PR_CONTEXT_PREFIX,
|
||||
];
|
||||
|
||||
export const startsWithForgeContextPrefix = (text: string): boolean =>
|
||||
FORGE_CONTEXT_PREFIXES.some((prefix) => text.startsWith(prefix));
|
||||
|
||||
export const isSyntheticPart = (part: Part | undefined): boolean => {
|
||||
if (!part || typeof part !== "object") {
|
||||
@@ -45,7 +61,7 @@ export const filterSyntheticParts = (parts: Part[] | undefined): Part[] => {
|
||||
}
|
||||
|
||||
const trimmed = text.trimStart();
|
||||
return trimmed.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX) || trimmed.startsWith(GITHUB_PR_CONTEXT_PREFIX);
|
||||
return startsWithForgeContextPrefix(trimmed);
|
||||
};
|
||||
|
||||
// If there are non-synthetic parts, filter out synthetic ones
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getCurrentIntlLocale } from './i18n';
|
||||
|
||||
|
||||
export const formatMoney = (value: number): string => {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(0);
|
||||
}
|
||||
return new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: value < 0.01 ? 4 : 2,
|
||||
maximumFractionDigits: value < 0.01 ? 4 : 2,
|
||||
}).format(value);
|
||||
};
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface SessionKnowledge {
|
||||
/** Empty when the session already carries what it needs. */
|
||||
@@ -86,14 +87,17 @@ const EMPTY_SUMMARY: SessionKnowledgeSummary = { notes: [], plans: [], memory: {
|
||||
/** What the session is carrying, for display. Never throws; shows nothing instead. */
|
||||
export const fetchSessionKnowledgeSummary = async (
|
||||
directory: string | null,
|
||||
sessionId?: string | null,
|
||||
): Promise<SessionKnowledgeSummary> => {
|
||||
if (!directory) {
|
||||
return EMPTY_SUMMARY;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ directory });
|
||||
if (sessionId) params.set('sessionId', sessionId);
|
||||
const response = await runtimeFetch(
|
||||
`/api/session-knowledge/summary?${new URLSearchParams({ directory }).toString()}`,
|
||||
`/api/session-knowledge/summary?${params.toString()}`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -112,3 +116,29 @@ export const fetchSessionKnowledgeSummary = async (
|
||||
return EMPTY_SUMMARY;
|
||||
}
|
||||
};
|
||||
|
||||
export type SessionProjectContextPins = { notes: string[]; plans: string[] };
|
||||
|
||||
const sessionProjectContextPinsResponseSchema = z.object({
|
||||
pins: z.object({ notes: z.array(z.string()), plans: z.array(z.string()) }),
|
||||
});
|
||||
|
||||
export const setSessionProjectContextPin = async (
|
||||
directory: string,
|
||||
sessionId: string,
|
||||
kind: 'note' | 'plan',
|
||||
id: string,
|
||||
pinned: boolean,
|
||||
): Promise<SessionProjectContextPins | null> => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/session-knowledge/pin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ directory, sessionId, kind, id, pinned }),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return sessionProjectContextPinsResponseSchema.parse(await response.json()).pins;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -39,15 +39,4 @@ describe('settings search', () => {
|
||||
|
||||
expect(results.some((result) => result.id === 'integrations.third-party.opencode-cursor-oauth')).toBe(true);
|
||||
});
|
||||
|
||||
test('finds coming-soon messenger placeholders', () => {
|
||||
const results = buildSettingsSearchResults({
|
||||
query: 'discord',
|
||||
runtimeCtx,
|
||||
t,
|
||||
getPageTitle: (page) => page,
|
||||
});
|
||||
|
||||
expect(results.some((result) => result.id === 'integrations.messengers.discord')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1015,26 +1015,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
isAvailable: (ctx) => ctx.isWeb && !ctx.isDesktop && !ctx.isVSCode,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'integrations.messengers',
|
||||
page: 'integrations',
|
||||
titleKey: 'settings.integrations.messengers.title',
|
||||
keywords: ['messenger', 'discord', 'telegram', 'bot', 'coming soon'],
|
||||
},
|
||||
{
|
||||
id: 'integrations.messengers.discord',
|
||||
page: 'integrations',
|
||||
titleKey: 'settings.integrations.messengers.discord.name',
|
||||
descriptionKey: 'settings.integrations.messengers.discord.description',
|
||||
keywords: ['discord', 'bot', 'messenger', 'coming soon'],
|
||||
},
|
||||
{
|
||||
id: 'integrations.messengers.telegram',
|
||||
page: 'integrations',
|
||||
titleKey: 'settings.integrations.messengers.telegram.name',
|
||||
descriptionKey: 'settings.integrations.messengers.telegram.description',
|
||||
keywords: ['telegram', 'bot', 'messenger', 'coming soon'],
|
||||
},
|
||||
{
|
||||
id: 'integrations.third-party',
|
||||
page: 'integrations',
|
||||
|
||||
@@ -88,7 +88,7 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
|
||||
descriptionKey: 'contextRail.surface.editor.description',
|
||||
defaultWidthFraction: 3 / 5,
|
||||
mode: 'file',
|
||||
icon: 'braces',
|
||||
icon: 'file-edit',
|
||||
labelKey: 'contextPanel.mode.files',
|
||||
availability: 'always',
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { GitHubPullRequestStatus, RuntimeAPIs } from "@/lib/api/types"
|
||||
let runtimeKey = "runtime-a"
|
||||
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey }))
|
||||
|
||||
const { getGitHubPrStatusKey, useGitHubPrStatusStore } = await import("./useGitHubPrStatusStore")
|
||||
const { getFreshestPrStatusForBranch, getGitHubPrStatusKey, useGitHubPrStatusStore } = await import("./useGitHubPrStatusStore")
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void
|
||||
@@ -38,6 +38,34 @@ describe("GitHub PR status cache ownership", () => {
|
||||
expect(new Set([originA, upstreamA, originB]).size).toBe(3)
|
||||
})
|
||||
|
||||
test("passive branch readers follow the freshest remote-keyed status", () => {
|
||||
const automatic = getGitHubPrStatusKey("/repo", "feature")
|
||||
const origin = getGitHubPrStatusKey("/repo", "feature", "origin")
|
||||
useGitHubPrStatusStore.getState().ensureEntry(automatic)
|
||||
useGitHubPrStatusStore.getState().ensureEntry(origin)
|
||||
useGitHubPrStatusStore.getState().updateStatus(automatic, () => ({
|
||||
connected: true,
|
||||
pr: { number: 7, title: "old", url: "u7", state: "open", draft: false, base: "main", head: "feature" },
|
||||
checks: { state: "pending", total: 3, success: 1, failure: 0, pending: 2 },
|
||||
}))
|
||||
useGitHubPrStatusStore.getState().updateStatus(origin, () => ({
|
||||
connected: true,
|
||||
pr: { number: 7, title: "current", url: "u7", state: "open", draft: false, base: "main", head: "feature" },
|
||||
checks: { state: "success", total: 3, success: 3, failure: 0, pending: 0 },
|
||||
}))
|
||||
useGitHubPrStatusStore.setState((state) => ({
|
||||
entries: {
|
||||
...state.entries,
|
||||
[automatic]: { ...state.entries[automatic], lastRefreshAt: 1 },
|
||||
[origin]: { ...state.entries[origin], lastRefreshAt: 2 },
|
||||
},
|
||||
}))
|
||||
|
||||
const freshest = getFreshestPrStatusForBranch(useGitHubPrStatusStore.getState().entries, "/repo", "feature")
|
||||
expect(freshest?.pr?.title).toBe("current")
|
||||
expect(freshest?.checks?.pending).toBe(0)
|
||||
})
|
||||
|
||||
test("rejects a response after params change", async () => {
|
||||
const request = deferred<GitHubPullRequestStatus>()
|
||||
const github = { prStatus: () => request.promise } as unknown as RuntimeAPIs["github"]
|
||||
|
||||
@@ -238,11 +238,11 @@ const findResolvedSiblingEntry = (
|
||||
* instead of a single key: the entry being actively watched/refreshed may be
|
||||
* keyed by a concrete remote while the 'auto' entry goes stale.
|
||||
*/
|
||||
export const getFreshestPrStatusForBranch = (
|
||||
const getFreshestPrEntryForBranch = (
|
||||
entries: Record<string, PrStatusEntry>,
|
||||
directory: string,
|
||||
branch: string,
|
||||
): GitHubPullRequestStatus | null => {
|
||||
): PrStatusEntry | null => {
|
||||
const runtimeKey = getRuntimeKey();
|
||||
let best: PrStatusEntry | null = null;
|
||||
for (const [key, entry] of Object.entries(entries)) {
|
||||
@@ -260,7 +260,15 @@ export const getFreshestPrStatusForBranch = (
|
||||
best = entry;
|
||||
}
|
||||
}
|
||||
return best?.status ?? null;
|
||||
return best;
|
||||
};
|
||||
|
||||
export const getFreshestPrStatusForBranch = (
|
||||
entries: Record<string, PrStatusEntry>,
|
||||
directory: string,
|
||||
branch: string,
|
||||
): GitHubPullRequestStatus | null => {
|
||||
return getFreshestPrEntryForBranch(entries, directory, branch)?.status ?? null;
|
||||
};
|
||||
|
||||
const getKeysBySignature = (entries: Record<string, PrStatusEntry>, signature: string): string[] => {
|
||||
@@ -951,23 +959,39 @@ const summarySignature = (s: PrVisualSummary): string =>
|
||||
const PR_SUMMARY_CACHE_MAX_ENTRIES = 300;
|
||||
const prSummaryCacheByKey = new Map<string, { sig: string; summary: PrVisualSummary }>();
|
||||
|
||||
const getCachedPrSummary = (cacheKey: string, entry: PrStatusEntry | null | undefined): PrVisualSummary | null => {
|
||||
const summary = entry ? deriveSummary(entry) : null;
|
||||
if (!summary) {
|
||||
prSummaryCacheByKey.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
const sig = summarySignature(summary);
|
||||
const cached = prSummaryCacheByKey.get(cacheKey);
|
||||
if (cached?.sig === sig) return cached.summary;
|
||||
|
||||
if (!cached && prSummaryCacheByKey.size >= PR_SUMMARY_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = prSummaryCacheByKey.keys().next().value;
|
||||
if (oldestKey !== undefined) prSummaryCacheByKey.delete(oldestKey);
|
||||
}
|
||||
prSummaryCacheByKey.set(cacheKey, { sig, summary });
|
||||
return summary;
|
||||
};
|
||||
|
||||
export const usePrVisualSummary = (key: string | null): PrVisualSummary | null => {
|
||||
return useGitHubPrStatusStore((state) => {
|
||||
if (!key) return null;
|
||||
const entry = state.entries[key];
|
||||
const summary = entry ? deriveSummary(entry) : null;
|
||||
if (!summary) {
|
||||
prSummaryCacheByKey.delete(key);
|
||||
return null;
|
||||
}
|
||||
const sig = summarySignature(summary);
|
||||
const cached = prSummaryCacheByKey.get(key);
|
||||
if (cached && cached.sig === sig) return cached.summary;
|
||||
if (!cached && prSummaryCacheByKey.size >= PR_SUMMARY_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = prSummaryCacheByKey.keys().next().value;
|
||||
if (oldestKey !== undefined) prSummaryCacheByKey.delete(oldestKey);
|
||||
}
|
||||
prSummaryCacheByKey.set(key, { sig, summary });
|
||||
return summary;
|
||||
return getCachedPrSummary(key, state.entries[key]);
|
||||
});
|
||||
};
|
||||
|
||||
export const useFreshestPrVisualSummaryForBranch = (
|
||||
directory: string | null,
|
||||
branch: string | null,
|
||||
): PrVisualSummary | null => {
|
||||
const cacheKey = directory && branch ? JSON.stringify(['branch', getRuntimeKey(), directory, branch]) : null;
|
||||
return useGitHubPrStatusStore((state) => {
|
||||
if (!directory || !branch || !cacheKey) return null;
|
||||
return getCachedPrSummary(cacheKey, getFreshestPrEntryForBranch(state.entries, directory, branch));
|
||||
});
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user