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,81 @@
|
|||||||
|
---
|
||||||
|
name: communication-style
|
||||||
|
description: Use it always.
|
||||||
|
author: poteto (pstack)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Communication style
|
||||||
|
|
||||||
|
Edit text to remove AI patterns and add human voice.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
1. Scan for the patterns below.
|
||||||
|
2. Rewrite. Preserve meaning, match intended tone.
|
||||||
|
3. Add soul (see next section).
|
||||||
|
4. Self-audit: "What makes this obviously AI generated?" Fix remaining tells.
|
||||||
|
|
||||||
|
## Adding soul
|
||||||
|
|
||||||
|
Removing patterns is half the job. Sterile, voiceless writing is just as obvious.
|
||||||
|
|
||||||
|
- **Have opinions.** React to facts instead of neutrally listing pros and cons.
|
||||||
|
- **Vary rhythm.** Short sentences. Then longer ones that take their time. Mix it up.
|
||||||
|
- **Acknowledge complexity.** "Impressive but also kind of unsettling" beats "impressive."
|
||||||
|
- **Use "I" when it fits.** First person isn't unprofessional.
|
||||||
|
- **Let some mess in.** Perfect structure looks machine-made.
|
||||||
|
- **Be specific.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am."
|
||||||
|
|
||||||
|
## Patterns to detect and fix
|
||||||
|
|
||||||
|
### Content
|
||||||
|
|
||||||
|
1. **Puffery.** "pivotal moment", "testament to", "evolving landscape", "setting the stage for", "indelible mark", "deeply rooted". Cut puffery, state what happened.
|
||||||
|
2. **Name-dropping.** Listing media outlets without context. Pick one, say what was said.
|
||||||
|
3. **Superficial -ing phrases.** "highlighting...", "ensuring...", "reflecting...", "showcasing...", "fostering...". Delete or expand with real sources.
|
||||||
|
4. **Promotional language.** "nestled", "vibrant", "breathtaking", "groundbreaking", "renowned", "stunning", "must-visit". Use neutral descriptions.
|
||||||
|
5. **Vague attributions.** "Experts believe", "Industry reports suggest", "Some critics argue". Name the source or delete.
|
||||||
|
6. **Formulaic challenges.** "Despite challenges... continues to thrive." Replace with specific facts.
|
||||||
|
|
||||||
|
### Language
|
||||||
|
|
||||||
|
7. **AI vocabulary.** Additionally, crucial, delve, enduring, enhance, fostering, garner, interplay, intricate, landscape (abstract), pivotal, showcase, tapestry (abstract), testament, underscore, vibrant. Replace with plain words.
|
||||||
|
8. **Fancy ways to say "is".** "serves as", "stands as", "boasts", "features". Just say "is" or "has".
|
||||||
|
9. **"Not just X, but Y."** State the point directly instead.
|
||||||
|
10. **Rule of three.** Forcing ideas into groups of three. Use the natural number.
|
||||||
|
11. **Synonym cycling.** Protagonist, main character, central figure, hero all in one paragraph. Pick one, repeat it.
|
||||||
|
12. **False ranges.** "from X to Y" where X and Y aren't on a meaningful scale. List topics directly.
|
||||||
|
|
||||||
|
### Style
|
||||||
|
|
||||||
|
13. **Em dash overuse.** Avoid em dashes entirely. Use periods or commas only (no parentheses, no en dashes, no hyphen-as-dash substitutes). Em dashes are an AI tell, and reaching for parentheses instead just trades one tell for another. If a thought needs separation, end the sentence or use a comma.
|
||||||
|
14. **Colon overuse.** Colons are fine before a list or example. Not as mid-sentence connectors. "If you're coming from traditional automation: instead of registering event handlers, you describe conditions" adds nothing with the colon. Rewrite to let the point stand on its own without comparison framing. "Describing when the scheduler should fire works best as plain English." Same meaning, no crutch punctuation.
|
||||||
|
15. **Boldface overuse.** Don't bold every proper noun or acronym.
|
||||||
|
16. **Inline-header lists.** The tell is a bold label and colon that restates the line: "**Performance:** Performance improved...". Convert to prose. A bold lead-in that ends in a period, names the item, and is followed by genuinely new detail ("**Schema in TypeScript.** Tables live in one file.") is fine.
|
||||||
|
17. **Title case headings.** Use sentence case.
|
||||||
|
18. **Decorative emojis.** Remove from headings and bullets.
|
||||||
|
19. **Curly quotes.** Replace with straight quotes.
|
||||||
|
|
||||||
|
### Communication artifacts
|
||||||
|
|
||||||
|
20. **Chatbot phrases.** "I hope this helps!", "Let me know if...", "Of course!", "Certainly!", "Found the smoking gun!" Remove.
|
||||||
|
21. **Cutoff disclaimers.** "While specific details are limited..." Find sources or remove.
|
||||||
|
22. **Sycophantic tone.** "Great question! You're absolutely right!" Respond directly.
|
||||||
|
|
||||||
|
### Filler
|
||||||
|
|
||||||
|
23. **Filler phrases.** "In order to" becomes "To". "Due to the fact that" becomes "Because". "It is important to note that" gets deleted.
|
||||||
|
24. **Excessive hedging.** "could potentially possibly be argued that it might" becomes "may".
|
||||||
|
25. **Generic conclusions.** "The future looks bright." State specific plans or facts.
|
||||||
|
|
||||||
|
### Jargon
|
||||||
|
|
||||||
|
26. **Abstract metaphor nouns.** Substrate, wedge, vector, locus, vantage, nexus, primitive (as noun), harness (as metaphor), surface (as in "API surface"), bedrock, scaffolding (as metaphor), modality, paradigm, gold-plating, ratchet (as metaphor), evacuate (for moving code), endgame, north star, flywheel. These read as technical but usually have a plainer concrete word. "Substrate" becomes "base". "Wedge in" becomes "add". "Vector" becomes "way" or "method". "Gold-plating" becomes "more than the job needs". "Ratchet" becomes the mechanism's real name or "a limit that only tightens". "Evacuate" becomes "move out". "Endgame" becomes "the last phase". Pick the concrete word.
|
||||||
|
|
||||||
|
### Plain speech
|
||||||
|
|
||||||
|
27. **Say what it does, not how it feels.** "the database stays close at hand", "SQL you can read", "types that follow your schema" name a feeling. The fix names the mechanism or a number: "`.toSQL()` returns the exact string sent to the database", "a column rename fails the build". Ask what the sentence tells the reader to do or know, then write it. If you can't restate it as a concrete instruction, fact, or number, cut it. One more check: if the sentence could appear unchanged in another project's docs, it says nothing about this one. Cut it.
|
||||||
|
28. **Shorten or split dense sentences.** If the reader has to backtrack to parse a sentence, break it in two or drop clauses. One idea per sentence.
|
||||||
|
29. **Active voice.** Prefer it. Catch "is/are/was/were + past participle" and name the actor: "queries are validated" becomes "the compiler validates queries", "the file is parsed by the loader" becomes "the loader parses the file". Passive is fine only when the actor is unknown or genuinely doesn't matter.
|
||||||
|
30. **Cut adverbs, or use a stronger verb.** "runs quickly" becomes "is fast" or the number. "significantly improves" becomes the measured delta. An adverb propping up a weak verb means the verb is wrong.
|
||||||
|
31. **Prefer the plain word.** "utilize" becomes "use", "leverage" becomes "use", "facilitate" becomes "help", "numerous" becomes "many", "in the event that" becomes "if". The fancier synonym is rarely clearer.
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
name: pr-review
|
name: pr-review
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
workflow_dispatch:
|
||||||
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
|
|
||||||
issue_comment:
|
|
||||||
types: [created]
|
|
||||||
pull_request_review_comment:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
# PR conversation comments arrive as `issue_comment` events, so their PR number
|
# PR conversation comments arrive as `issue_comment` events, so their PR number
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
mode: primary
|
mode: primary
|
||||||
hidden: true
|
hidden: true
|
||||||
model: opencode-go/deepseek-v4-flash
|
model: opencode-go/mimo-v2.5
|
||||||
color: "#c0392b"
|
color: "#c0392b"
|
||||||
permission:
|
permission:
|
||||||
edit: allow
|
edit: allow
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
mode: primary
|
mode: primary
|
||||||
hidden: true
|
hidden: true
|
||||||
model: opencode-go/deepseek-v4-flash
|
model: opencode-go/mimo-v2.5
|
||||||
color: "#4f8f8f"
|
color: "#4f8f8f"
|
||||||
permission:
|
permission:
|
||||||
edit: deny
|
edit: deny
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
mode: primary
|
mode: primary
|
||||||
hidden: true
|
hidden: true
|
||||||
model: opencode-go/deepseek-v4-flash
|
model: opencode-go/mimo-v2.5
|
||||||
color: "#c4920a"
|
color: "#c4920a"
|
||||||
permission:
|
permission:
|
||||||
edit: deny
|
edit: deny
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ task-required reference named by those skills. Skills are canonical for their
|
|||||||
detailed workflows and checklists. Treating this table as optional advice is a
|
detailed workflows and checklists. Treating this table as optional advice is a
|
||||||
process violation.
|
process violation.
|
||||||
|
|
||||||
|
**Always load `.agents/skills/communication-style/SKILL.md` at the start of
|
||||||
|
every task, before any analysis, tool call, or response. Apply its guidance to
|
||||||
|
all messages and written output, not only to user-facing copy or documentation.**
|
||||||
|
|
||||||
| Trigger | Required skill |
|
| Trigger | Required skill |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Source/dependency changes, exports or package contracts, build/generated assets, or module ownership | `openchamber-change-discipline` |
|
| Source/dependency changes, exports or package contracts, build/generated assets, or module ownership | `openchamber-change-discipline` |
|
||||||
|
|||||||
+17
-5
@@ -4,20 +4,32 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
- **Project knowledge:** the Project notes panel is now Project knowledge, with notes, todos, plans and their search in a resizable sidebar. Notes are cards you expand by clicking anywhere on them, plans open and edit in the panel itself instead of a separate tab, and notes and plans you pin travel with every message you send in that project until you unpin them.
|
- Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117).
|
||||||
- **Work status:** the Context sources section now names each pinned note and plan riding along with your messages, and its pin button unpins them from there.
|
|
||||||
- **Settings:** OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech).
|
## [1.19.0] - 2026-08-19
|
||||||
|
|
||||||
- **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders.
|
- **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders.
|
||||||
- **Chat:** an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech).
|
- **Project knowledge:** the Project notes panel is now Project knowledge, with notes, todos, plans and their search in a resizable sidebar. Notes are cards you expand by clicking anywhere on them, plans open and edit in the panel itself instead of a separate tab, and notes and plans can be pinned as context.
|
||||||
- **Stability/Proxy:** the local server now reuses its connection to OpenCode instead of opening a new one for every API request. Under sustained traffic the old behavior could use up every outgoing network port on the machine, at which point nothing on the computer could open a new connection until the traffic stopped and the ports were released (thanks to @alohaninja).
|
- **Files:** drag files onto the Files sidebar to upload them into the project or a specific folder; existing files require confirmation before replacement, and open previews refresh after an upload (thanks to @makeittech, @alanzchen).
|
||||||
|
- Settings: OpenChamber no longer replaces a full OpenCode config with an empty `$schema`-only stub when the file uses JSON5-style unquoted keys; Settings changes now fail instead of wiping plugins, MCP servers, and providers (thanks to @makeittech).
|
||||||
|
- Chat: an open conversation no longer keeps re-coloring the same code blocks in the background, so browsing files with a chat open stops pinning a CPU core and spinning up the fans (thanks to @makeittech).
|
||||||
|
- Stability/Proxy: the local server now reuses its connection to OpenCode instead of opening a new one for every API request. Under sustained traffic the old behavior could use up every outgoing network port on the machine, at which point nothing on the computer could open a new connection until the traffic stopped and the ports were released (thanks to @alohaninja).
|
||||||
- Usage/Claude: Claude plan limits now work when you are signed in through Claude Code, without also signing into Anthropic in OpenCode; the account is read from Claude Code's own login on macOS, Linux, and WSL. The page shows your session and weekly limits again, adds per-model weekly limits and extra usage spending, and names your plan. Limits are kept on screen instead of disappearing when Anthropic temporarily blocks refreshes.
|
- Usage/Claude: Claude plan limits now work when you are signed in through Claude Code, without also signing into Anthropic in OpenCode; the account is read from Claude Code's own login on macOS, Linux, and WSL. The page shows your session and weekly limits again, adds per-model weekly limits and extra usage spending, and names your plan. Limits are kept on screen instead of disappearing when Anthropic temporarily blocks refreshes.
|
||||||
|
- Usage/Command Code: Command Code plan limits now appear in the Usage page and work status panel.
|
||||||
- Git: the pull request panel now follows the branch's current open PR, and an open PR always wins over an older merged or closed one. After a PR is merged or closed the panel keeps showing it as the branch's last PR and offers creating the next one right below it (thanks to @makeittech).
|
- Git: the pull request panel now follows the branch's current open PR, and an open PR always wins over an older merged or closed one. After a PR is merged or closed the panel keeps showing it as the branch's last PR and offers creating the next one right below it (thanks to @makeittech).
|
||||||
|
- Git/Worktrees: creating a worktree from a pull request now falls back to GitHub's pull-request reference when the source fork was deleted or cannot be reached, instead of failing before creating the worktree (thanks to @makeittech).
|
||||||
- Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting.
|
- Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting.
|
||||||
|
- Chat: typing with Chinese, Japanese, or Korean input methods no longer interrupts composition or jumps the cursor to the end of the composer (thanks to @makeittech).
|
||||||
- Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech).
|
- Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech).
|
||||||
- Chat: saved chats in the context panel open again instead of staying blank.
|
- Chat: saved chats in the context panel open again instead of staying blank.
|
||||||
- Chat: the context meter no longer climbs over 100% (330% readouts) after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds, everywhere the value appears — header, context sidebar, work status panel, mini chat, and mobile (thanks to @pocharlies).
|
- Chat: the context meter no longer climbs over 100% (330% readouts) after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds, everywhere the value appears — header, context sidebar, work status panel, mini chat, and mobile (thanks to @pocharlies).
|
||||||
|
- Chat/Attachments: extracted Office and OpenDocument content is now capped and presented more compactly, preventing large documents and their images from overwhelming the message context.
|
||||||
- Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude` in the sidebar, window title, settings and notifications; names you renamed yourself are kept.
|
- Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude` in the sidebar, window title, settings and notifications; names you renamed yourself are kept.
|
||||||
|
- Files: files reached through a symlink inside the workspace now open correctly instead of being rejected as outside the workspace.
|
||||||
- Settings: the session retention action you pick is now saved instead of being dropped (thanks to @Gautam0507).
|
- Settings: the session retention action you pick is now saved instead of being dropped (thanks to @Gautam0507).
|
||||||
|
- Mobile: connecting through an ngrok address now bypasses ngrok's browser warning page instead of failing the server check.
|
||||||
|
- Mobile/iOS: text selection in the chat composer now uses native CodeMirror selection handles.
|
||||||
|
- Desktop: browser pages served from a self-signed loopback HTTPS address now load instead of being blocked by the certificate warning.
|
||||||
- Browser: typing a comment on a page no longer triggers app shortcuts.
|
- Browser: typing a comment on a page no longer triggers app shortcuts.
|
||||||
- Skills Catalog: the source is now named ClawHub instead of "ClawdHub" (thanks to @makeittech).
|
- Skills Catalog: the source is now named ClawHub instead of "ClawdHub" (thanks to @makeittech).
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron": {
|
"packages/electron": {
|
||||||
"name": "@openchamber/electron",
|
"name": "@openchamber/electron",
|
||||||
"version": "1.18.4",
|
"version": "1.19.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/web": "workspace:*",
|
"@openchamber/web": "workspace:*",
|
||||||
"electron-context-menu": "^4.1.2",
|
"electron-context-menu": "^4.1.2",
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.18.4",
|
"version": "1.19.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "openchamber",
|
"name": "openchamber",
|
||||||
"version": "1.18.4",
|
"version": "1.19.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "1.18.18",
|
"@opencode-ai/sdk": "1.18.18",
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@openchamber/web",
|
"name": "@openchamber/web",
|
||||||
"version": "1.18.4",
|
"version": "1.19.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openchamber": "./bin/cli.js",
|
"openchamber": "./bin/cli.js",
|
||||||
},
|
},
|
||||||
@@ -270,7 +270,6 @@
|
|||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "1.18.18",
|
"@opencode-ai/sdk": "1.18.18",
|
||||||
"@simplewebauthn/server": "13.3.1",
|
"@simplewebauthn/server": "13.3.1",
|
||||||
"adm-zip": "^0.6.0",
|
|
||||||
"bun-pty": "^0.4.5",
|
"bun-pty": "^0.4.5",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"cron-parser": "^4.9.0",
|
"cron-parser": "^4.9.0",
|
||||||
@@ -306,7 +305,6 @@
|
|||||||
"@remixicon/react": "^4.7.0",
|
"@remixicon/react": "^4.7.0",
|
||||||
"@simplewebauthn/browser": "13.3.0",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/adm-zip": "^0.5.7",
|
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^24.3.1",
|
||||||
"@types/react": "^19.1.10",
|
"@types/react": "^19.1.10",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openchamber-monorepo",
|
"name": "openchamber-monorepo",
|
||||||
"version": "1.18.4",
|
"version": "1.19.0",
|
||||||
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
|
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -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
|
## Weiterführend
|
||||||
|
|
||||||
|
- [Integrationen](/integrations/) — nutze ein Claude-, Command-Code- oder Cursor-Abo als Provider
|
||||||
- [MCP-Server](/mcp/) — füge Agents zusätzliche Werkzeuge hinzu
|
- [MCP-Server](/mcp/) — füge Agents zusätzliche Werkzeuge hinzu
|
||||||
- [Nutzung & Kontingente](/usage/) — verfolge, wie viel du verbraucht hast
|
- [Nutzung & Kontingente](/usage/) — verfolge, wie viel du verbraucht hast
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Zum Schreiben eigener Skills siehe [Skills](/skills/).
|
|||||||
## Einen Skill installieren
|
## Einen Skill installieren
|
||||||
|
|
||||||
1. Öffne den Katalog.
|
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.
|
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.
|
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
|
## 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
|
- [Servidores MCP](/es/mcp/) — añade herramientas extra para los agentes
|
||||||
- [Uso y cuotas](/es/usage/) — controla cuánto has consumido
|
- [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
|
## Instala una skill
|
||||||
|
|
||||||
1. Abre el catálogo.
|
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.
|
3. Elige una skill e instálala.
|
||||||
4. Elige dónde instalarla: para todo lo que hagas, o solo en el proyecto actual.
|
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
|
## 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
|
- [Serveurs MCP](/mcp/) — ajouter des outils supplémentaires aux agents
|
||||||
- [Utilisation et quotas](/usage/) — suivre votre consommation
|
- [Utilisation et quotas](/usage/) — suivre votre consommation
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Pour écrire vos propres skills, voir [Skills](/skills/).
|
|||||||
## Installer un skill
|
## Installer un skill
|
||||||
|
|
||||||
1. Ouvrez le catalogue.
|
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.
|
3. Choisissez un skill et installez-le.
|
||||||
4. Choisissez où l’installer : pour tout ce que vous faites, ou seulement pour le projet actuel.
|
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/) — エージェントに追加ツールを加える
|
- [MCP サーバー](/mcp/) — エージェントに追加ツールを加える
|
||||||
- [使用量とクォータ](/usage/) — 使った量を追跡する
|
- [使用量とクォータ](/usage/) — 使った量を追跡する
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Skills Catalog では、自分で書く代わりに、他の人が公開した
|
|||||||
## スキルをインストールする
|
## スキルをインストールする
|
||||||
|
|
||||||
1. カタログを開きます。
|
1. カタログを開きます。
|
||||||
2. 組み込みソース(Anthropic skills repo と ClawHub community registry)を閲覧するか、検索します。
|
2. 組み込みソース(Anthropic skills repo など)を閲覧するか、検索します。
|
||||||
3. スキルを選び、インストールします。
|
3. スキルを選び、インストールします。
|
||||||
4. インストール先を選びます。すべての作業で使うか、現在のプロジェクトだけで使うかです。
|
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/) — 에이전트에 추가 도구를 제공합니다
|
- [MCP Servers](/ko/mcp/) — 에이전트에 추가 도구를 제공합니다
|
||||||
- [Usage & Quotas](/ko/usage/) — 사용량을 추적합니다
|
- [Usage & Quotas](/ko/usage/) — 사용량을 추적합니다
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Skills Catalog를 사용하면 직접 작성하는 대신 다른 사람이 게
|
|||||||
## 스킬 설치하기
|
## 스킬 설치하기
|
||||||
|
|
||||||
1. 카탈로그를 엽니다.
|
1. 카탈로그를 엽니다.
|
||||||
2. 내장된 소스(Anthropic 스킬 저장소와 ClawHub 커뮤니티 레지스트리)를 둘러보거나 검색합니다.
|
2. 내장된 소스(예: Anthropic 스킬 저장소)를 둘러보거나 검색합니다.
|
||||||
3. 스킬을 선택하고 설치합니다.
|
3. 스킬을 선택하고 설치합니다.
|
||||||
4. 설치 위치를 선택합니다. 모든 작업에 적용할지, 현재 프로젝트에만 적용할지 선택합니다.
|
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
|
## Powiązane
|
||||||
|
|
||||||
|
- [Integracje](/pl/integrations/) — używaj subskrypcji Claude, Command Code lub Cursor jako dostawcy
|
||||||
- [Serwery MCP](/pl/mcp/) — dodaj agentom dodatkowe narzędzia
|
- [Serwery MCP](/pl/mcp/) — dodaj agentom dodatkowe narzędzia
|
||||||
- [Zużycie i limity](/pl/usage/) — śledź, ile już wykorzystałeś
|
- [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
|
## Zainstaluj skill
|
||||||
|
|
||||||
1. Otwórz katalog.
|
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.
|
3. Wybierz skill i zainstaluj go.
|
||||||
4. Wybierz, gdzie go zainstalować: dla wszystkiego, co robisz, albo tylko dla bieżącego projektu.
|
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
|
## Related
|
||||||
|
|
||||||
|
- [Integrations](/integrations/) — use a Claude, Command Code, or Cursor subscription as a provider
|
||||||
- [MCP Servers](/mcp/) — add extra tools for agents
|
- [MCP Servers](/mcp/) — add extra tools for agents
|
||||||
- [Usage & Quotas](/usage/) — track how much you've used
|
- [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
|
## 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
|
- [Servidores MCP](/pt-br/mcp/) — adicione ferramentas extras para os agentes
|
||||||
- [Uso e Cotas](/pt-br/usage/) — acompanhe quanto você já usou
|
- [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
|
## Instalar uma skill
|
||||||
|
|
||||||
1. Abra o catálogo.
|
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.
|
3. Escolha uma skill e instale-a.
|
||||||
4. Escolha onde instalá-la: para tudo o que você faz, ou apenas no projeto atual.
|
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
|
## Install a skill
|
||||||
|
|
||||||
1. Open the catalog.
|
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.
|
3. Pick a skill and install it.
|
||||||
4. Choose where to install it: for everything you do, or just the current project.
|
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/) — додайте агентам додаткові інструменти
|
- [MCP Servers](/uk/mcp/) — додайте агентам додаткові інструменти
|
||||||
- [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили
|
- [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ description: Переглядайте та встановлюйте готові
|
|||||||
## Встановлення навички
|
## Встановлення навички
|
||||||
|
|
||||||
1. Відкрийте каталог.
|
1. Відкрийте каталог.
|
||||||
2. Перегляньте вбудовані джерела — репозиторій навичок Anthropic та спільнотний реєстр ClawHub — або скористайтеся пошуком.
|
2. Перегляньте вбудовані джерела — наприклад репозиторій навичок Anthropic — або скористайтеся пошуком.
|
||||||
3. Оберіть навичку й установіть її.
|
3. Оберіть навичку й установіть її.
|
||||||
4. Виберіть, куди встановити: для всього, що ви робите, чи лише для поточного проєкту.
|
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/) — 为智能体添加额外工具
|
- [MCP Servers](/zh-cn/mcp/) — 为智能体添加额外工具
|
||||||
- [用量与配额](/zh-cn/usage/) — 跟踪你已使用的量
|
- [用量与配额](/zh-cn/usage/) — 跟踪你已使用的量
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Skills 目录让你能够安装其他人发布的 skill,而不必自己编写
|
|||||||
## 安装 skill
|
## 安装 skill
|
||||||
|
|
||||||
1. 打开目录。
|
1. 打开目录。
|
||||||
2. 浏览内置来源 — Anthropic skills 仓库和 ClawHub 社区注册表 — 或进行搜索。
|
2. 浏览内置来源 — 例如 Anthropic skills 仓库 — 或进行搜索。
|
||||||
3. 选择一个 skill 并安装它。
|
3. 选择一个 skill 并安装它。
|
||||||
4. 选择安装位置:用于你的所有工作,或仅用于当前项目。
|
4. 选择安装位置:用于你的所有工作,或仅用于当前项目。
|
||||||
|
|
||||||
|
|||||||
@@ -364,6 +364,21 @@
|
|||||||
"de": "Anbieter, Modelle und Agenten"
|
"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",
|
"label": "MCP Servers",
|
||||||
"link": "/mcp/",
|
"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
|
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
|
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
|
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
|
## 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,
|
setLinuxAutostartEnabled,
|
||||||
} from './linux-autostart.mjs';
|
} from './linux-autostart.mjs';
|
||||||
import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.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';
|
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
@@ -1186,6 +1187,15 @@ const resolveBrowserPanelContents = (rawId) => {
|
|||||||
const hardenBrowserPanelSession = () => {
|
const hardenBrowserPanelSession = () => {
|
||||||
const panelSession = session.fromPartition(BROWSER_PANEL_PARTITION);
|
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) => {
|
panelSession.setPermissionRequestHandler((_contents, permission, callback, details) => {
|
||||||
log.info('[electron] browser panel denied a permission request', {
|
log.info('[electron] browser panel denied a permission request', {
|
||||||
permission,
|
permission,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@openchamber/electron",
|
"name": "@openchamber/electron",
|
||||||
"version": "1.18.4",
|
"version": "1.19.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Electron desktop runtime for OpenChamber",
|
"description": "Electron desktop runtime for OpenChamber",
|
||||||
"author": "OpenChamber",
|
"author": "OpenChamber",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.18.4",
|
"version": "1.19.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/main.tsx",
|
"main": "src/main.tsx",
|
||||||
|
|||||||
@@ -555,19 +555,56 @@ interface FilePart {
|
|||||||
source?: Record<string, unknown>;
|
source?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GITHUB_ISSUE_LINK_MIME = 'application/vnd.github.issue-link';
|
const FORGE_LINK_MIMES = new Set([
|
||||||
const GITHUB_PR_LINK_MIME = 'application/vnd.github.pull-request-link';
|
'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 => {
|
const ISSUE_LINK_MIMES = new Set([
|
||||||
if (file.mime === GITHUB_ISSUE_LINK_MIME) {
|
'application/vnd.github.issue-link',
|
||||||
return 'issue';
|
'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;
|
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 {
|
interface MessageFilesDisplayProps {
|
||||||
files: FilePart[];
|
files: FilePart[];
|
||||||
onShowPopup?: (content: ToolPopupContent) => void;
|
onShowPopup?: (content: ToolPopupContent) => void;
|
||||||
@@ -590,8 +627,8 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveDisplayName = React.useCallback((file: FilePart): string => {
|
const resolveDisplayName = React.useCallback((file: FilePart): string => {
|
||||||
const isGitHubLink = getGitHubLinkKind(file) !== null;
|
const isForgeLink = getForgeLinkInfo(file) !== null;
|
||||||
if (isGitHubLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
|
if (isForgeLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
|
||||||
return file.filename.trim();
|
return file.filename.trim();
|
||||||
}
|
}
|
||||||
return extractFilename(file.filename || file.url);
|
return extractFilename(file.filename || file.url);
|
||||||
@@ -664,11 +701,11 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
const fileName = resolveDisplayName(file);
|
const fileName = resolveDisplayName(file);
|
||||||
const ext = fileName.split('.').pop() || '';
|
const ext = fileName.split('.').pop() || '';
|
||||||
const sizeText = formatFileSize(file.size);
|
const sizeText = formatFileSize(file.size);
|
||||||
const githubLinkKind = getGitHubLinkKind(file);
|
const forgeLink = getForgeLinkInfo(file);
|
||||||
return (
|
return (
|
||||||
<Tooltip key={`file-${file.url || file.filename || index}`}>
|
<Tooltip key={`file-${file.url || file.filename || index}`}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
{githubLinkKind && file.url ? (
|
{forgeLink && file.url ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
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"
|
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={forgeLinkIconName(forgeLink)} className="text-muted-foreground h-3.5 w-3.5" />
|
||||||
<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" />
|
|
||||||
)}
|
|
||||||
<div className="overflow-hidden max-w-[220px]">
|
<div className="overflow-hidden max-w-[220px]">
|
||||||
<span className="truncate block" title={fileName}>{fileName}</span>
|
<span className="truncate block" title={fileName}>{fileName}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -763,7 +796,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
const fileName = resolveDisplayName(file);
|
const fileName = resolveDisplayName(file);
|
||||||
const isImage = file.mime?.startsWith('image/');
|
const isImage = file.mime?.startsWith('image/');
|
||||||
const sizeText = formatFileSize(file.size);
|
const sizeText = formatFileSize(file.size);
|
||||||
const githubLinkKind = getGitHubLinkKind(file);
|
const forgeLink = getForgeLinkInfo(file);
|
||||||
|
|
||||||
if (isImage && file.url) {
|
if (isImage && file.url) {
|
||||||
return (
|
return (
|
||||||
@@ -786,7 +819,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (githubLinkKind && file.url) {
|
if (forgeLink && file.url) {
|
||||||
return (
|
return (
|
||||||
<Tooltip key={file.url || `${fileName}-${index}`}>
|
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -801,11 +834,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{githubLinkKind === 'pr' ? (
|
<Icon name={forgeLinkIconName(forgeLink)} className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||||
<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")} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium truncate">{fileName}</p>
|
<p className="font-medium truncate">{fileName}</p>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import type { Part } from '@opencode-ai/sdk/v2';
|
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)';
|
type IssueContextPayload = {
|
||||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
|
||||||
|
|
||||||
type GitHubIssueContextPayload = {
|
|
||||||
issue?: {
|
issue?: {
|
||||||
number?: unknown;
|
number?: unknown;
|
||||||
title?: 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 => {
|
const isPositiveNumber = (value: unknown): value is number => {
|
||||||
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
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 buildForgeAttachmentPart = (text: string): Part | null => {
|
||||||
const issuePayload = parseSyntheticJsonPayload<GitHubIssueContextPayload>(text, GITHUB_ISSUE_CONTEXT_PREFIX);
|
// GitHub issues
|
||||||
if (issuePayload) {
|
const ghIssuePayload = parseSyntheticJsonPayload<IssueContextPayload>(text, GITHUB_ISSUE_CONTEXT_PREFIX);
|
||||||
const issue = issuePayload.issue;
|
if (ghIssuePayload) {
|
||||||
|
const issue = ghIssuePayload.issue;
|
||||||
const number = issue?.number;
|
const number = issue?.number;
|
||||||
const title = issue?.title;
|
const title = issue?.title;
|
||||||
const url = issue?.url;
|
const url = issue?.url;
|
||||||
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'file',
|
type: 'file',
|
||||||
mime: 'application/vnd.github.issue-link',
|
mime: 'application/vnd.github.issue-link',
|
||||||
@@ -60,16 +82,16 @@ const buildGitHubAttachmentPart = (text: string): Part | null => {
|
|||||||
} as Part;
|
} as Part;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prPayload = parseSyntheticJsonPayload<GitHubPrContextPayload>(text, GITHUB_PR_CONTEXT_PREFIX);
|
// GitHub PRs
|
||||||
if (prPayload) {
|
const ghPrPayload = parseSyntheticJsonPayload<GitHubPrContextPayload>(text, GITHUB_PR_CONTEXT_PREFIX);
|
||||||
const pr = prPayload.pr;
|
if (ghPrPayload) {
|
||||||
|
const pr = ghPrPayload.pr;
|
||||||
const number = pr?.number;
|
const number = pr?.number;
|
||||||
const title = pr?.title;
|
const title = pr?.title;
|
||||||
const url = pr?.url;
|
const url = pr?.url;
|
||||||
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
if (!isPositiveNumber(number) || typeof title !== 'string' || typeof url !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'file',
|
type: 'file',
|
||||||
mime: 'application/vnd.github.pull-request-link',
|
mime: 'application/vnd.github.pull-request-link',
|
||||||
@@ -78,6 +100,78 @@ const buildGitHubAttachmentPart = (text: string): Part | null => {
|
|||||||
} as Part;
|
} 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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -103,8 +197,7 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
|||||||
|
|
||||||
const normalizedText = text.trimStart();
|
const normalizedText = text.trimStart();
|
||||||
return shouldKeepSyntheticUserText(text, planModeEnabled)
|
return shouldKeepSyntheticUserText(text, planModeEnabled)
|
||||||
|| normalizedText.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX)
|
|| startsWithForgeContextPrefix(normalizedText);
|
||||||
|| normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX);
|
|
||||||
})
|
})
|
||||||
.map((part) => {
|
.map((part) => {
|
||||||
const rawPart = part as Record<string, unknown>;
|
const rawPart = part as Record<string, unknown>;
|
||||||
@@ -116,7 +209,7 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
|||||||
const synthetic = rawPart.synthetic === true;
|
const synthetic = rawPart.synthetic === true;
|
||||||
|
|
||||||
if (synthetic) {
|
if (synthetic) {
|
||||||
const attachmentPart = buildGitHubAttachmentPart(text);
|
const attachmentPart = buildForgeAttachmentPart(text);
|
||||||
if (attachmentPart) {
|
if (attachmentPart) {
|
||||||
return 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 |
|
| 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 |
|
| 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 |
|
| 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` | |
|
| Subagents | child sessions from `useAllLiveSessions` (`parentID`) + `useAllSessionStatuses` | |
|
||||||
| Subagent blockers | directory `permission` / `question` maps | one subscription covers every child |
|
| 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 |
|
| 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
|
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
|
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.
|
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
|
### 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 { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { getLinkedIssues, parseLinkedIssueRef, type LinkedIssue } from '@/lib/linkedIssues';
|
import { getLinkedIssues, parseLinkedIssueRef, type LinkedIssue } from '@/lib/linkedIssues';
|
||||||
import { linkedEntityLiveInvalidate, useLinkedEntityLive, type LinkedEntityLive } from '@/lib/linkedEntityLive';
|
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 { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||||
import { setLinkedIssue } from '@/sync/session-actions';
|
import { setLinkedIssue } from '@/sync/session-actions';
|
||||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
||||||
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
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 { WorkStatusCollapsibleSection, WorkStatusPill, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
|
||||||
import { useReportWorkStatusPresence } from './presenceContext';
|
import { useReportWorkStatusPresence } from './presenceContext';
|
||||||
import { WorkStatusLinkDialog } from './WorkStatusLinkDialog';
|
import { WorkStatusLinkDialog } from './WorkStatusLinkDialog';
|
||||||
|
import { resolveDraftPinnedKnowledge } from './draftKnowledge';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
@@ -179,6 +181,11 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
|||||||
const [linkDialogOpen, setLinkDialogOpen] = React.useState(false);
|
const [linkDialogOpen, setLinkDialogOpen] = React.useState(false);
|
||||||
|
|
||||||
const session = useSession(sessionId ?? '', directory ?? undefined);
|
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 skills = useSkillsStore((state) => state.skills);
|
||||||
const mcpStatus = useMcpStore(
|
const mcpStatus = useMcpStore(
|
||||||
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
|
React.useCallback((state) => state.getStatusForDirectory(directory), [directory]),
|
||||||
@@ -200,7 +207,7 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
|||||||
}, [directory, loadSkills]);
|
}, [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
|
* rather than from the notes panel's store, because this must be right
|
||||||
* whether or not that panel has ever been opened.
|
* 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 } },
|
{ notes: [], plans: [], memory: { global: 0, project: 0 } },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Re-read whenever the stores that own pins or memory change, not only when
|
// Re-read when source content or memory changes, not only when the session does.
|
||||||
// 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.
|
|
||||||
const contextEntries = useProjectContextStore((state) => state.entries);
|
const contextEntries = useProjectContextStore((state) => state.entries);
|
||||||
|
const loadProjectContext = useProjectContextStore((state) => state.load);
|
||||||
const memoryProject = useAgentMemoryStore((state) => state.project);
|
const memoryProject = useAgentMemoryStore((state) => state.project);
|
||||||
const memoryGlobal = useAgentMemoryStore((state) => state.global);
|
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(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void fetchSessionKnowledgeSummary(directory).then((summary) => {
|
void fetchSessionKnowledgeSummary(directory, sessionId).then((summary) => {
|
||||||
if (!cancelled) setKnowledge(summary);
|
if (!cancelled) setKnowledge(summary);
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [directory, contextEntries, memoryProject, memoryGlobal]);
|
}, [directory, sessionId, session, contextEntries, memoryProject, memoryGlobal]);
|
||||||
|
|
||||||
const projects = useProjectsStore((state) => state.projects);
|
const visibleKnowledge = React.useMemo<SessionKnowledgeSummary>(() => {
|
||||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
if (!isDraft) return knowledge;
|
||||||
const setNotePinned = useProjectContextStore((state) => state.setNotePinned);
|
const pinned = resolveDraftPinnedKnowledge(
|
||||||
const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned);
|
draftContextEntry?.notes ?? [],
|
||||||
|
draftContextEntry?.plans ?? [],
|
||||||
const projectRef = React.useMemo(() => {
|
newSessionDraft.projectContextPins ?? { notes: [], plans: [] },
|
||||||
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory ?? '');
|
);
|
||||||
return resolved ? { id: resolved.id, path: resolved.path } : null;
|
return { ...knowledge, ...pinned };
|
||||||
}, [availableWorktreesByProject, directory, projects]);
|
}, [draftContextEntry?.notes, draftContextEntry?.plans, isDraft, knowledge, newSessionDraft.projectContextPins]);
|
||||||
|
|
||||||
// Unpinning from here, like the pinned-messages section: a panel that says
|
// 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
|
// what is attached should be able to detach it, or the user has to go find
|
||||||
// the surface that can.
|
// the surface that can.
|
||||||
const unpinNote = React.useCallback((noteId: string) => {
|
const unpinNote = React.useCallback((noteId: string) => {
|
||||||
if (projectRef) void setNotePinned(projectRef, noteId, false);
|
if (isDraft) {
|
||||||
}, [projectRef, setNotePinned]);
|
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) => {
|
const unpinPlan = React.useCallback((planId: string) => {
|
||||||
if (projectRef) void setPlanPinned(projectRef, planId, false);
|
if (isDraft) {
|
||||||
}, [projectRef, setPlanPinned]);
|
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 memoryCount = visibleKnowledge.memory.global + visibleKnowledge.memory.project;
|
||||||
const pinnedCount = knowledge.notes.length + knowledge.plans.length;
|
const pinnedCount = visibleKnowledge.notes.length + visibleKnowledge.plans.length;
|
||||||
|
|
||||||
const linked = React.useMemo(() => getLinkedIssues(session), [session]);
|
const linked = React.useMemo(() => getLinkedIssues(session), [session]);
|
||||||
// Connected servers only. A disabled server contributes nothing to the
|
// 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.prCountSingle', { count: prCount })
|
||||||
: t('chat.workStatus.breakdown.prCountPlural', { count: prCount }));
|
: t('chat.workStatus.breakdown.prCountPlural', { count: prCount }));
|
||||||
}
|
}
|
||||||
// Pinned knowledge outranks the ambient counts in the summary: it is
|
// Pinned knowledge outranks ambient counts because the user chose it for this session.
|
||||||
// something the user chose for this project, not something that happens to
|
|
||||||
// be installed.
|
|
||||||
if (summaryParts.length === 0 && pinnedCount > 0) {
|
if (summaryParts.length === 0 && pinnedCount > 0) {
|
||||||
summaryParts.push(pinnedCount === 1
|
summaryParts.push(pinnedCount === 1
|
||||||
? t('chat.workStatus.breakdown.pinnedKnowledgeSingle', { count: pinnedCount })
|
? 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')} />
|
<WorkStatusRow muted label={t('chat.workStatus.linkedIssues.empty')} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Named individually: a count alone would not tell the user which note
|
{/* Named individually: a count alone would not identify this session's context. */}
|
||||||
is riding along with every message they send. */}
|
|
||||||
{/* The pin is the control, exactly as in the pinned-messages section
|
{/* The pin is the control, exactly as in the pinned-messages section
|
||||||
above: same icon, same placement, same behaviour. Two pins that look
|
above: same icon, same placement, same behaviour. Two pins that look
|
||||||
different in one panel would read as two different things. */}
|
different in one panel would read as two different things. */}
|
||||||
{knowledge.notes.map((note) => (
|
{visibleKnowledge.notes.map((note) => (
|
||||||
<WorkStatusRow
|
<WorkStatusRow
|
||||||
key={note.id}
|
key={note.id}
|
||||||
muted
|
muted
|
||||||
leading={(
|
leading={(
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!projectRef}
|
disabled={!isDraft && (!sessionId || !directory)}
|
||||||
aria-label={t('chat.workStatus.breakdown.unpin')}
|
aria-label={t('chat.workStatus.breakdown.unpin')}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -366,14 +403,14 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
|||||||
value={<WorkStatusValue tone="muted">{t('chat.workStatus.breakdown.pinnedNote')}</WorkStatusValue>}
|
value={<WorkStatusValue tone="muted">{t('chat.workStatus.breakdown.pinnedNote')}</WorkStatusValue>}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{knowledge.plans.map((plan) => (
|
{visibleKnowledge.plans.map((plan) => (
|
||||||
<WorkStatusRow
|
<WorkStatusRow
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
muted
|
muted
|
||||||
leading={(
|
leading={(
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!projectRef}
|
disabled={!isDraft && (!sessionId || !directory)}
|
||||||
aria-label={t('chat.workStatus.breakdown.unpin')}
|
aria-label={t('chat.workStatus.breakdown.unpin')}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { useGitStore } from '@/stores/useGitStore';
|
import { useGitStore } from '@/stores/useGitStore';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { runBackgroundNetworkTask } from '@/lib/background-network';
|
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 { useGitLabMrForBranch } from '@/lib/gitlabMrStatus';
|
||||||
import { useGiteaPrForBranch } from '@/lib/giteaPrStatus';
|
import { useGiteaPrForBranch } from '@/lib/giteaPrStatus';
|
||||||
import { useGitProvider } from '@/lib/gitProvider';
|
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
|
// 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
|
// here would multiply GitHub requests per open session, which is exactly the
|
||||||
// fan-out the PR-status concurrency gate exists to prevent.
|
// fan-out the PR-status concurrency gate exists to prevent.
|
||||||
const prKey = React.useMemo(
|
const prSummary = useFreshestPrVisualSummaryForBranch(directory, branch);
|
||||||
() => (directory && branch ? getGitHubPrStatusKey(directory, branch) : null),
|
|
||||||
[directory, branch],
|
|
||||||
);
|
|
||||||
const prSummary = usePrVisualSummary(prKey);
|
|
||||||
|
|
||||||
// GitLab merge requests and Gitea pull requests ride the same shared TTL
|
// 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
|
// 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 { useSessions, useSessionMessageRecords } from '@/sync/sync-context';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||||
|
import { formatMoney } from '@/lib/money';
|
||||||
import {
|
import {
|
||||||
derivePartsLabel,
|
derivePartsLabel,
|
||||||
deriveUserSnippet,
|
deriveUserSnippet,
|
||||||
@@ -236,16 +237,6 @@ const computeContextBreakdown = (
|
|||||||
|
|
||||||
const formatNumber = (value: number): string => value.toLocaleString(getCurrentIntlLocale());
|
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 => {
|
const formatDateTime = (timestamp: number | null, timeFormatPreference: TimeFormatPreference): string => {
|
||||||
if (!timestamp || !Number.isFinite(timestamp)) return '-';
|
if (!timestamp || !Number.isFinite(timestamp)) return '-';
|
||||||
return formatDateTimeForPreference(timestamp, timeFormatPreference, {
|
return formatDateTimeForPreference(timestamp, timeFormatPreference, {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
|||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
|
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
|
||||||
import { isFilesystemError } from '@/lib/api/files-errors';
|
import { isFilesystemError } from '@/lib/api/files-errors';
|
||||||
|
import { notifyFileContentInvalidated } from '@/lib/fileContentInvalidation';
|
||||||
import { isBrowserClientRuntime } from '@/lib/desktop';
|
import { isBrowserClientRuntime } from '@/lib/desktop';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -1044,9 +1045,18 @@ export const SidebarFilesTree: React.FC = () => {
|
|||||||
const uploadedCount = outcomes.filter((outcome) => outcome === 'uploaded').length;
|
const uploadedCount = outcomes.filter((outcome) => outcome === 'uploaded').length;
|
||||||
const failedCount = outcomes.filter((outcome) => outcome === 'failed').length;
|
const failedCount = outcomes.filter((outcome) => outcome === 'failed').length;
|
||||||
const conflictingFiles = droppedFiles.filter((_, index) => outcomes[index] === 'conflict');
|
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;
|
const isCurrentDestination = rootRef.current === operationRoot && getRuntimeKey() === operationRuntime;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (uploadedPaths.length > 0) {
|
||||||
|
notifyFileContentInvalidated({ runtimeKey: operationRuntime, paths: uploadedPaths });
|
||||||
|
}
|
||||||
if (uploadedCount > 0 && isCurrentDestination) {
|
if (uploadedCount > 0 && isCurrentDestination) {
|
||||||
await refreshDirectory(directory);
|
await refreshDirectory(directory);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { SessionDialogs } from '@/components/session/SessionDialogs';
|
|||||||
import { ChatView } from '@/components/views/ChatView';
|
import { ChatView } from '@/components/views/ChatView';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useViewportStore } from '@/sync/viewport-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 { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||||
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
|
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 providers = useConfigStore((state) => state.providers);
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||||
|
const currentSession = useSession(currentSessionId ?? '');
|
||||||
const currentSessionMessages = useSessionMessages(currentSessionId ?? '');
|
const currentSessionMessages = useSessionMessages(currentSessionId ?? '');
|
||||||
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
|
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
|
||||||
const quotaResults = useQuotaStore((state) => state.results);
|
const quotaResults = useQuotaStore((state) => state.results);
|
||||||
@@ -1022,6 +1023,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
|||||||
percentage={stableContextUsage.percentage}
|
percentage={stableContextUsage.percentage}
|
||||||
contextLimit={stableContextUsage.contextLimit}
|
contextLimit={stableContextUsage.contextLimit}
|
||||||
outputLimit={stableContextUsage.outputLimit ?? 0}
|
outputLimit={stableContextUsage.outputLimit ?? 0}
|
||||||
|
cost={(currentSession?.cost ?? 0) > 0 ? currentSession?.cost : null}
|
||||||
className="h-9 shrink-0 pl-1 pr-1 typography-ui-label"
|
className="h-9 shrink-0 pl-1 pr-1 typography-ui-label"
|
||||||
valueClassName="font-semibold leading-none"
|
valueClassName="font-semibold leading-none"
|
||||||
hideIcon
|
hideIcon
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { ComingSoonMessengersSection } from './ComingSoonMessengersSection';
|
|
||||||
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
|
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
|
||||||
|
|
||||||
interface IntegrationsPageProps {
|
interface IntegrationsPageProps {
|
||||||
@@ -21,8 +20,8 @@ export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
|
|||||||
description={t('settings.page.integrations.description')}
|
description={t('settings.page.integrations.description')}
|
||||||
showSaveStatus={false}
|
showSaveStatus={false}
|
||||||
>
|
>
|
||||||
<ComingSoonMessengersSection />
|
|
||||||
<ThirdPartyIntegrationsSection
|
<ThirdPartyIntegrationsSection
|
||||||
|
divider={false}
|
||||||
onOpenProviderSetup={onOpenProviderSetup}
|
onOpenProviderSetup={onOpenProviderSetup}
|
||||||
onOpenPluginManager={onOpenPluginManager}
|
onOpenPluginManager={onOpenPluginManager}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type PendingAction = 'install' | 'update' | 'setup' | 'remove';
|
|||||||
type RemoveTarget = ThirdPartyPluginDefinition | null;
|
type RemoveTarget = ThirdPartyPluginDefinition | null;
|
||||||
|
|
||||||
interface ThirdPartyIntegrationsSectionProps {
|
interface ThirdPartyIntegrationsSectionProps {
|
||||||
|
divider?: boolean;
|
||||||
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
|
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
|
||||||
onOpenPluginManager: () => void;
|
onOpenPluginManager: () => void;
|
||||||
}
|
}
|
||||||
@@ -46,6 +47,7 @@ const requiresRestart = (result: PluginMutationResult): boolean =>
|
|||||||
|| result.reloadFailed === true;
|
|| result.reloadFailed === true;
|
||||||
|
|
||||||
export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSectionProps> = ({
|
export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSectionProps> = ({
|
||||||
|
divider = true,
|
||||||
onOpenProviderSetup,
|
onOpenProviderSetup,
|
||||||
onOpenPluginManager,
|
onOpenPluginManager,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -413,6 +415,7 @@ export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSecti
|
|||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t('settings.integrations.thirdParty.title')}
|
title={t('settings.integrations.thirdParty.title')}
|
||||||
info={t('settings.integrations.thirdParty.info')}
|
info={t('settings.integrations.thirdParty.info')}
|
||||||
|
divider={divider}
|
||||||
settingsItem="integrations.third-party"
|
settingsItem="integrations.third-party"
|
||||||
contentClassName="space-y-3"
|
contentClassName="space-y-3"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -275,8 +275,11 @@ export const DefaultsSettings: React.FC = () => {
|
|||||||
[walkthroughModelOverride]
|
[walkthroughModelOverride]
|
||||||
);
|
);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// Both pickers filter by the same authenticated-provider list, and the
|
// Both pickers offer the same providers — the walkthrough runs through the
|
||||||
// walkthrough picker is always visible, so this is always worth fetching.
|
// 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;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
import { requiresProviderAuth, shouldLoadAvailableProviders } from './providerAvailability';
|
||||||
import {
|
import {
|
||||||
getOAuthAuthMethods,
|
getOAuthAuthMethods,
|
||||||
normalizeAuthType,
|
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', () => {
|
describe('provider auth method helpers', () => {
|
||||||
test('normalizeAuthType recognizes oauth and api labels', () => {
|
test('normalizeAuthType recognizes oauth and api labels', () => {
|
||||||
expect(normalizeAuthType({ type: 'oauth', label: 'Login with Cursor' })).toBe('oauth');
|
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 { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
import { requiresProviderAuth, shouldLoadAvailableProviders } from './providerAvailability';
|
||||||
import {
|
import {
|
||||||
getOAuthAuthMethods,
|
getOAuthAuthMethods,
|
||||||
parseAuthPayload,
|
parseAuthPayload,
|
||||||
@@ -324,7 +324,8 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
? provider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
? provider.env.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||||
: [];
|
: [];
|
||||||
const hasCreds = Boolean(sources.auth.exists) || envEntries.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);
|
setShowAuthPanel(true);
|
||||||
}
|
}
|
||||||
}, [selectedProviderId, providerSources, providers]);
|
}, [selectedProviderId, providerSources, providers]);
|
||||||
@@ -773,8 +774,12 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
|
const hasStoredAuth = Boolean(selectedSources?.auth.exists);
|
||||||
const hasEnvCredentials = providerEnv.length > 0;
|
const hasEnvCredentials = providerEnv.length > 0;
|
||||||
const hasCredentials = hasStoredAuth || hasEnvCredentials;
|
const hasCredentials = hasStoredAuth || hasEnvCredentials;
|
||||||
const authStatusIncomplete = sourcesLoaded && !hasCredentials;
|
const authStatusIncomplete = requiresProviderAuth(
|
||||||
const showModelsSection = providerModels.length > 0 && (!sourcesLoaded || hasCredentials);
|
sourcesLoaded,
|
||||||
|
hasCredentials,
|
||||||
|
isEditableCustomProvider,
|
||||||
|
);
|
||||||
|
const showModelsSection = providerModels.length > 0 && !authStatusIncomplete;
|
||||||
const incompleteAuthHint = !showApiKeyAuth && oauthAuthMethods.length > 0
|
const incompleteAuthHint = !showApiKeyAuth && oauthAuthMethods.length > 0
|
||||||
? t('settings.providers.page.auth.useReconnectHint')
|
? t('settings.providers.page.auth.useReconnectHint')
|
||||||
: t('settings.providers.page.auth.incompleteHint');
|
: t('settings.providers.page.auth.incompleteHint');
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
export const shouldLoadAvailableProviders = (isAddMode: boolean): boolean => isAddMode;
|
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;
|
directoryOverride?: string | null;
|
||||||
conflictDecisions?: Record<string, ConflictDecision>;
|
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({
|
const result = await installSkills({
|
||||||
source: request.source,
|
source: request.source,
|
||||||
subpath: request.subpath,
|
subpath: request.subpath,
|
||||||
gitIdentityId: item?.gitIdentityId,
|
gitIdentityId: item?.gitIdentityId,
|
||||||
scope: request.scope,
|
scope: request.scope,
|
||||||
targetSource: request.targetSource,
|
targetSource: request.targetSource,
|
||||||
selections: [selection],
|
selections: [{ skillDir: request.skillDir }],
|
||||||
conflictPolicy: 'prompt',
|
conflictPolicy: 'prompt',
|
||||||
conflictDecisions: request.conflictDecisions,
|
conflictDecisions: request.conflictDecisions,
|
||||||
}, { directory: request.directoryOverride ?? null });
|
}, { directory: request.directoryOverride ?? null });
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||||
import {
|
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||||
SettingsSection,
|
|
||||||
SETTINGS_SELECT_SIZE,
|
|
||||||
SETTINGS_SELECT_TRIGGER_CLASS,
|
|
||||||
} from '@/components/sections/shared/SettingsSection';
|
|
||||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -18,24 +14,16 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import {
|
import { Icon } from '@/components/icon/Icon';
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Icon } from "@/components/icon/Icon";
|
|
||||||
|
|
||||||
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { cn } from '@/lib/utils';
|
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 { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
import { AddCatalogDialog } from './AddCatalogDialog';
|
import { AddCatalogDialog } from './AddCatalogDialog';
|
||||||
import { InstallSkillDialog } from './InstallSkillDialog';
|
import { InstallSkillDialog } from './InstallSkillDialog';
|
||||||
@@ -48,6 +36,71 @@ interface SkillsCatalogPageProps {
|
|||||||
showModeTabs?: boolean;
|
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> => {
|
const loadSettings = async (): Promise<DesktopSettings | null> => {
|
||||||
try {
|
try {
|
||||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
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 }) => {
|
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange, showModeTabs = true }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const {
|
const {
|
||||||
@@ -80,12 +194,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
|||||||
setSelectedSource,
|
setSelectedSource,
|
||||||
loadCatalog,
|
loadCatalog,
|
||||||
loadSource,
|
loadSource,
|
||||||
loadMoreClawdHub,
|
|
||||||
isLoadingCatalog,
|
isLoadingCatalog,
|
||||||
isLoadingSource,
|
isLoadingSource,
|
||||||
isLoadingMore,
|
|
||||||
loadedSourceIds,
|
loadedSourceIds,
|
||||||
clawdhubHasMoreBySource,
|
|
||||||
lastCatalogError,
|
lastCatalogError,
|
||||||
} = useSkillsCatalogStore(useShallow((s) => ({
|
} = useSkillsCatalogStore(useShallow((s) => ({
|
||||||
sources: s.sources,
|
sources: s.sources,
|
||||||
@@ -94,12 +205,9 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
|||||||
setSelectedSource: s.setSelectedSource,
|
setSelectedSource: s.setSelectedSource,
|
||||||
loadCatalog: s.loadCatalog,
|
loadCatalog: s.loadCatalog,
|
||||||
loadSource: s.loadSource,
|
loadSource: s.loadSource,
|
||||||
loadMoreClawdHub: s.loadMoreClawdHub,
|
|
||||||
isLoadingCatalog: s.isLoadingCatalog,
|
isLoadingCatalog: s.isLoadingCatalog,
|
||||||
isLoadingSource: s.isLoadingSource,
|
isLoadingSource: s.isLoadingSource,
|
||||||
isLoadingMore: s.isLoadingMore,
|
|
||||||
loadedSourceIds: s.loadedSourceIds,
|
loadedSourceIds: s.loadedSourceIds,
|
||||||
clawdhubHasMoreBySource: s.clawdhubHasMoreBySource,
|
|
||||||
lastCatalogError: s.lastCatalogError,
|
lastCatalogError: s.lastCatalogError,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
@@ -109,43 +217,72 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
|||||||
const [installItem, setInstallItem] = React.useState<SkillsCatalogItem | null>(null);
|
const [installItem, setInstallItem] = React.useState<SkillsCatalogItem | null>(null);
|
||||||
const [isRemovingCatalog, setIsRemovingCatalog] = React.useState(false);
|
const [isRemovingCatalog, setIsRemovingCatalog] = React.useState(false);
|
||||||
const [isRemoveCatalogDialogOpen, setIsRemoveCatalogDialogOpen] = React.useState(false);
|
const [isRemoveCatalogDialogOpen, setIsRemoveCatalogDialogOpen] = React.useState(false);
|
||||||
|
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void loadCatalog();
|
void loadCatalog();
|
||||||
}, [loadCatalog]);
|
}, [loadCatalog]);
|
||||||
|
|
||||||
|
// Load every source in the background so global search covers all of them.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!selectedSourceId) {
|
const unloaded = sources.filter((src) => !loadedSourceIds[src.id]);
|
||||||
|
if (unloaded.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!loadedSourceIds[selectedSourceId]) {
|
let cancelled = false;
|
||||||
void loadSource(selectedSourceId);
|
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]);
|
}, [selectedSourceId, loadedSourceIds, loadSource]);
|
||||||
|
|
||||||
const items = React.useMemo(() => {
|
React.useEffect(() => {
|
||||||
if (!selectedSourceId) return [];
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
return itemsBySource[selectedSourceId] || [];
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||||
}, [itemsBySource, selectedSourceId]);
|
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 filtered = React.useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
if (!q) return items;
|
const matches = (item: SkillsCatalogItem) =>
|
||||||
return items.filter((item) => {
|
item.skillName.toLowerCase().includes(q)
|
||||||
const name = item.skillName.toLowerCase();
|
|| (item.description || '').toLowerCase().includes(q)
|
||||||
const desc = (item.description || '').toLowerCase();
|
|| (item.frontmatterName || '').toLowerCase().includes(q);
|
||||||
const fm = (item.frontmatterName || '').toLowerCase();
|
|
||||||
return name.includes(q) || desc.includes(q) || fm.includes(q);
|
if (isSearching) {
|
||||||
});
|
return sources.flatMap((src) => (itemsBySource[src.id] || []).filter(matches));
|
||||||
}, [items, search]);
|
}
|
||||||
|
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 selectedSource = React.useMemo(() => sources.find((s) => s.id === selectedSourceId) || null, [sources, selectedSourceId]);
|
||||||
|
|
||||||
const isCustomSource = Boolean(selectedSourceId && selectedSourceId.startsWith('custom:'));
|
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 () => {
|
const removeSelectedCatalog = async () => {
|
||||||
if (!selectedSourceId || !isCustomSource) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsPageLayout
|
<SettingsPageLayout
|
||||||
@@ -190,90 +338,74 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
|||||||
</div>
|
</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
|
<SettingsSection
|
||||||
title={t('settings.skills.catalog.page.section.sourceRepository')}
|
title={t('settings.skills.catalog.page.section.sources')}
|
||||||
divider={false}
|
divider={false}
|
||||||
settingsItem="skills.catalog.source"
|
settingsItem="skills.catalog.source"
|
||||||
contentClassName="space-y-0"
|
contentClassName="space-y-0"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2 py-1.5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 py-1.5">
|
||||||
<Select
|
{sources.map((src) => (
|
||||||
value={selectedSourceId || ''}
|
<SourceCard
|
||||||
onValueChange={(v) => setSelectedSource(v)}
|
key={src.id}
|
||||||
>
|
source={src}
|
||||||
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'w-fit')}>
|
isActive={src.id === selectedSourceId}
|
||||||
<SelectValue placeholder={t('settings.skills.catalog.page.field.selectSourcePlaceholder')}>
|
isLoading={isLoadingSource && !loadedSourceIds[src.id]}
|
||||||
{selectedSource?.label}
|
skillsCount={loadedSourceIds[src.id] ? (itemsBySource[src.id] || []).length : null}
|
||||||
</SelectValue>
|
onSelect={() => setSelectedSource(src.id)}
|
||||||
</SelectTrigger>
|
t={t}
|
||||||
<SelectContent align="start">
|
/>
|
||||||
{sources.map((src) => (
|
))}
|
||||||
<SelectItem key={src.id} value={src.id}>
|
|
||||||
{src.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="xs"
|
data-settings-item="skills.catalog.add-catalog"
|
||||||
className="!font-normal h-6 w-6 px-0"
|
onClick={() => setAddCatalogOpen(true)}
|
||||||
onClick={() => {
|
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"
|
||||||
if (selectedSourceId) {
|
>
|
||||||
void loadSource(selectedSourceId, { refresh: true });
|
<span className="flex items-center justify-center rounded-md bg-transparent text-muted-foreground w-8 h-8 shrink-0">
|
||||||
} else {
|
<Icon name="add" className="h-4 w-4" />
|
||||||
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 })}
|
|
||||||
</span>
|
</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>
|
</SettingsSection>
|
||||||
|
|
||||||
{lastCatalogError && (
|
{lastCatalogError && (
|
||||||
@@ -286,21 +418,63 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
{filtered.length === 0 && !isLoadingSource ? (
|
<div className="flex items-center justify-between gap-2 pb-2">
|
||||||
<div className="py-8 text-center text-muted-foreground">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<p className="typography-body">{t('settings.skills.catalog.page.empty.noSkillsTitle')}</p>
|
<span className="typography-micro font-medium uppercase tracking-wide text-muted-foreground truncate">
|
||||||
<p className="typography-meta mt-1 opacity-75">{t('settings.skills.catalog.page.empty.noSkillsDescription')}</p>
|
{listTitle}
|
||||||
</div>
|
</span>
|
||||||
) : isLoadingSource ? (
|
<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">
|
<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" />
|
<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>
|
<p className="typography-meta">{t('settings.skills.catalog.page.loading.skills')}</p>
|
||||||
</div>
|
</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)]">
|
<div className="divide-y divide-[var(--surface-subtle)]">
|
||||||
{filtered.map((item) => {
|
{filtered.map((item) => {
|
||||||
const installed = item.installed?.isInstalled;
|
const installed = item.installed?.isInstalled;
|
||||||
const installedScope = item.installed?.scope;
|
const installedScope = item.installed?.scope;
|
||||||
|
const skillUrl = getSkillUrl(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${item.sourceId}:${item.skillDir}`} className="py-2">
|
<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>
|
<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/80 mt-1 flex items-center gap-2 min-w-0">
|
||||||
<div className="typography-micro text-muted-foreground mt-1.5 flex items-center gap-3">
|
{skillUrl ? (
|
||||||
{item.clawdhub.owner && (
|
<a
|
||||||
<span>{t('settings.skills.catalog.page.byOwnerPrefix')} <span className="font-medium text-foreground/80">{item.clawdhub.owner}</span></span>
|
href={skillUrl}
|
||||||
)}
|
target="_blank"
|
||||||
<span className="flex items-center gap-1">
|
rel="noreferrer"
|
||||||
<Icon name="download" className="h-3 w-3" />
|
className="font-mono hover:underline truncate inline-flex items-center gap-1"
|
||||||
{item.clawdhub.downloads?.toLocaleString() ?? 0}
|
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
|
||||||
</span>
|
>
|
||||||
{(item.clawdhub.stars ?? 0) > 0 && (
|
<Icon name="github" className="h-3 w-3 shrink-0" />
|
||||||
<span className="flex items-center gap-1">
|
{item.repoSource}
|
||||||
<Icon name="star" className="h-3 w-3" />
|
</a>
|
||||||
{item.clawdhub.stars}
|
) : (
|
||||||
</span>
|
<span className="font-mono truncate">{item.repoSource}</span>
|
||||||
)}
|
)}
|
||||||
<span className="bg-[var(--surface-muted)] px-1.5 py-0.5 rounded">v{item.clawdhub.version}</span>
|
{item.skillDir && (
|
||||||
</div>
|
<>
|
||||||
)}
|
<span className="opacity-40">·</span>
|
||||||
|
<span className="truncate">{item.skillDir}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{item.warnings?.length ? (
|
{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">
|
<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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
variant="outline"
|
{skillUrl && (
|
||||||
size="xs"
|
<Button
|
||||||
className="!font-normal shrink-0"
|
variant="ghost"
|
||||||
disabled={!item.installable}
|
size="xs"
|
||||||
onClick={() => {
|
className="!font-normal h-6 w-6 px-0"
|
||||||
setInstallItem(item);
|
onClick={() => window.open(skillUrl, '_blank', 'noreferrer')}
|
||||||
setInstallDialogOpen(true);
|
title={t('settings.skills.catalog.page.skill.viewOnGithub')}
|
||||||
}}
|
>
|
||||||
>
|
<Icon name="external-link" className="h-3.5 w-3.5" />
|
||||||
{t('settings.skills.catalog.shared.actions.install')}
|
</Button>
|
||||||
</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>
|
</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>
|
</SettingsSection>
|
||||||
</SettingsPageLayout>
|
</SettingsPageLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const ArchiveAllDropdown: React.FC<ArchiveAllDropdownProps> = ({ onArchiveAll })
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<Tooltip>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<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)
|
list it no longer matches. Hosts that own a fullscreen plan surface (mobile)
|
||||||
still pass `onOpenPlan` and keep theirs.
|
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
|
Notes and plans are project data, but attaching one writes its id to the current
|
||||||
across every session in that project until it is unpinned. The composer once
|
session metadata. Other sessions in the project do not inherit it. A pin made
|
||||||
carried a chip for it, from when pinned context was a one-shot attachment to the
|
while a new-session draft is open lives on that draft and transfers only to the
|
||||||
next message; standing state shown permanently above the input reads as
|
session created by its first message. Work status lists and detaches draft pins
|
||||||
something being attached to what you are typing, which it is not. What is
|
before that first message, then reads them from the created session metadata.
|
||||||
attached, and the control to detach it, live in the work status panel instead.
|
|
||||||
|
|
||||||
## Memory is not a fifth kind of note
|
## 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
|
## Pinned context
|
||||||
|
|
||||||
The pin toggle on a note or plan marks it as standing context for the agent.
|
The pin toggle on a note or plan attaches it to the current session or draft.
|
||||||
Assembly and delivery live in `packages/ui/src/lib/projectContextPinning.ts`;
|
Assembly and delivery live in `packages/web/server/lib/session-knowledge`.
|
||||||
this surface only owns the toggle. `ComposerPinnedContextChip` shows the user
|
|
||||||
what is riding along.
|
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,13 @@ const NOTE_SAVE_DEBOUNCE_MS = 400;
|
|||||||
*/
|
*/
|
||||||
const NoteRow: React.FC<{
|
const NoteRow: React.FC<{
|
||||||
note: ProjectNote;
|
note: ProjectNote;
|
||||||
|
pinned: boolean;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggleExpanded: () => void;
|
onToggleExpanded: () => void;
|
||||||
onSaveBody: (body: string) => void;
|
onSaveBody: (body: string) => void;
|
||||||
onTogglePinned: () => void;
|
onTogglePinned: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}> = ({ note, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
|
}> = ({ note, pinned, expanded, onToggleExpanded, onSaveBody, onTogglePinned, onDelete }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [draft, setDraft] = React.useState(note.body);
|
const [draft, setDraft] = React.useState(note.body);
|
||||||
const lastSavedRef = React.useRef(note.body);
|
const lastSavedRef = React.useRef(note.body);
|
||||||
@@ -107,19 +108,19 @@ const NoteRow: React.FC<{
|
|||||||
onClick={onTogglePinned}
|
onClick={onTogglePinned}
|
||||||
className={cn(
|
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',
|
'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-pressed={pinned}
|
||||||
aria-label={note.pinned
|
aria-label={pinned
|
||||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||||
title={note.pinned
|
title={pinned
|
||||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||||
>
|
>
|
||||||
{/* Filled means pinned, outline means "pin this" — the same
|
{/* Filled means pinned, outline means "pin this" — the same
|
||||||
language the work status panel uses. */}
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -163,7 +164,9 @@ export const NotesSection: React.FC<{
|
|||||||
notes: ProjectNote[];
|
notes: ProjectNote[];
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
query: string;
|
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 { t } = useI18n();
|
||||||
const [composerText, setComposerText] = React.useState('');
|
const [composerText, setComposerText] = React.useState('');
|
||||||
// One at a time on purpose: notes can run to 3000 characters each, and
|
// 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 setNotesPanelHeight = useUIStore((state) => state.setNotesPanelHeight);
|
||||||
const createNote = useProjectContextStore((state) => state.createNote);
|
const createNote = useProjectContextStore((state) => state.createNote);
|
||||||
const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody);
|
const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody);
|
||||||
const setNotePinned = useProjectContextStore((state) => state.setNotePinned);
|
|
||||||
const deleteNote = useProjectContextStore((state) => state.deleteNote);
|
const deleteNote = useProjectContextStore((state) => state.deleteNote);
|
||||||
|
|
||||||
const visibleNotes = React.useMemo(() => {
|
const visibleNotes = React.useMemo(() => {
|
||||||
@@ -214,12 +216,12 @@ export const NotesSection: React.FC<{
|
|||||||
|
|
||||||
const handleTogglePinned = React.useCallback(
|
const handleTogglePinned = React.useCallback(
|
||||||
async (noteId: string, pinned: boolean) => {
|
async (noteId: string, pinned: boolean) => {
|
||||||
const ok = await setNotePinned(projectRef, noteId, pinned);
|
const ok = await onTogglePinned(noteId, pinned);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
|
reportFailure(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectRef, reportFailure, setNotePinned, t]
|
[onTogglePinned, reportFailure, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSaveBody = React.useCallback(
|
const handleSaveBody = React.useCallback(
|
||||||
@@ -281,10 +283,11 @@ export const NotesSection: React.FC<{
|
|||||||
<NoteRow
|
<NoteRow
|
||||||
key={note.id}
|
key={note.id}
|
||||||
note={note}
|
note={note}
|
||||||
|
pinned={pinnedNoteIds.has(note.id)}
|
||||||
expanded={expandedNoteId === note.id}
|
expanded={expandedNoteId === note.id}
|
||||||
onToggleExpanded={() => setExpandedNoteId((current) => (current === note.id ? null : note.id))}
|
onToggleExpanded={() => setExpandedNoteId((current) => (current === note.id ? null : note.id))}
|
||||||
onSaveBody={(body) => handleSaveBody(note.id, body)}
|
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)}
|
onDelete={() => void handleDelete(note.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -24,14 +24,15 @@ export const PlansSection: React.FC<{
|
|||||||
query: string;
|
query: string;
|
||||||
/** Hosts without a ContextPanel (mobile) render their own plan viewer. */
|
/** Hosts without a ContextPanel (mobile) render their own plan viewer. */
|
||||||
onOpenPlan?: (plan: { id: string; title: string }) => void;
|
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 { t } = useI18n();
|
||||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const [isImporting, setIsImporting] = React.useState(false);
|
const [isImporting, setIsImporting] = React.useState(false);
|
||||||
const [deletingPlanId, setDeletingPlanId] = React.useState<string | null>(null);
|
const [deletingPlanId, setDeletingPlanId] = React.useState<string | null>(null);
|
||||||
const createPlan = useProjectContextStore((state) => state.createPlan);
|
const createPlan = useProjectContextStore((state) => state.createPlan);
|
||||||
const removePlan = useProjectContextStore((state) => state.deletePlan);
|
const removePlan = useProjectContextStore((state) => state.deletePlan);
|
||||||
const setPlanPinned = useProjectContextStore((state) => state.setPlanPinned);
|
|
||||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||||
|
|
||||||
@@ -136,13 +137,13 @@ export const PlansSection: React.FC<{
|
|||||||
|
|
||||||
const handleTogglePinned = React.useCallback(
|
const handleTogglePinned = React.useCallback(
|
||||||
async (planId: string, pinned: boolean) => {
|
async (planId: string, pinned: boolean) => {
|
||||||
const ok = await setPlanPinned(projectRef, planId, pinned);
|
const ok = await onTogglePinned(planId, pinned);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
const detail = useProjectContextStore.getState().getEntry(projectRef).error;
|
const detail = useProjectContextStore.getState().getEntry(projectRef).error;
|
||||||
toast.error(t('rightSidebar.contextNotesTodo.toast.updatePlanFailed'), detail ? { description: detail } : undefined);
|
toast.error(t('rightSidebar.contextNotesTodo.toast.updatePlanFailed'), detail ? { description: detail } : undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectRef, setPlanPinned, t]
|
[onTogglePinned, projectRef, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const visiblePlans = React.useMemo(() => {
|
const visiblePlans = React.useMemo(() => {
|
||||||
@@ -220,16 +221,16 @@ export const PlansSection: React.FC<{
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleTogglePinned(plan.id, !plan.pinned)}
|
onClick={() => void handleTogglePinned(plan.id, !pinnedPlanIds.has(plan.id))}
|
||||||
className={cn(
|
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',
|
'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-pressed={pinnedPlanIds.has(plan.id)}
|
||||||
aria-label={plan.pinned
|
aria-label={pinnedPlanIds.has(plan.id)
|
||||||
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
? t('rightSidebar.contextNotesTodo.notes.actions.unpin')
|
||||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
: 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.unpin')
|
||||||
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
: t('rightSidebar.contextNotesTodo.notes.actions.pin')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { NotesSection } from './NotesSection';
|
|||||||
import { PlansSection } from './PlansSection';
|
import { PlansSection } from './PlansSection';
|
||||||
import { TodosSection } from './TodosSection';
|
import { TodosSection } from './TodosSection';
|
||||||
import { useProjectTodoSend } from './useProjectTodoSend';
|
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. */
|
/** 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 })));
|
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 loadProjectContext = useProjectContextStore((state) => state.load);
|
||||||
const saveTodos = useProjectContextStore((state) => state.saveTodos);
|
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
|
// 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.
|
// 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}
|
notes={contextEntry.notes}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
query={query}
|
query={query}
|
||||||
|
pinnedNoteIds={pinnedNoteIds}
|
||||||
|
onTogglePinned={(noteId, pinned) => toggleSessionPin('note', noteId, pinned)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -413,6 +454,8 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
|||||||
projectRef={projectRef}
|
projectRef={projectRef}
|
||||||
plans={contextEntry.plans}
|
plans={contextEntry.plans}
|
||||||
query={query}
|
query={query}
|
||||||
|
pinnedPlanIds={pinnedPlanIds}
|
||||||
|
onTogglePinned={(planId, pinned) => toggleSessionPin('plan', planId, pinned)}
|
||||||
// Hosts that own a fullscreen plan surface (mobile) keep it; on the
|
// Hosts that own a fullscreen plan surface (mobile) keep it; on the
|
||||||
// desktop panel the plan opens here, in place of the list.
|
// desktop panel the plan opens here, in place of the list.
|
||||||
onOpenPlan={onOpenPlan ?? setOpenPlan}
|
onOpenPlan={onOpenPlan ?? setOpenPlan}
|
||||||
|
|||||||
@@ -1185,7 +1185,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
</div>
|
</div>
|
||||||
{group.isArchivedBucket && allGroupSessions.length > 0 ? (
|
{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')}>
|
<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>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1208,7 +1208,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
) : null}
|
) : null}
|
||||||
{group.directory && !group.isMain && group.worktree ? (
|
{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')}>
|
<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>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1232,7 +1232,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
) : null}
|
) : null}
|
||||||
{group.directory ? (
|
{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')}>
|
<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>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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
|
icon inset inside the 24px buttons so the first glyph lines up
|
||||||
with the New-session icon above (16px from the sidebar edge). */}
|
with the New-session icon above (16px from the sidebar edge). */}
|
||||||
<div className="ml-[3px] flex items-center gap-1.5">
|
<div className="ml-[3px] flex items-center gap-1.5">
|
||||||
<Tooltip>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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>
|
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.addProject')}</p></TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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>
|
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.scheduledTasks')}</p></TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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>
|
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.newMultiRun')}</p></TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -143,7 +143,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Tooltip>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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>
|
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.searchSessions')}</p></TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -180,7 +180,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<Tooltip>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
showCreateButtons ? 'right-7' : 'right-0.5',
|
showCreateButtons ? 'right-7' : 'right-0.5',
|
||||||
)}>
|
)}>
|
||||||
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? (
|
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession ? (
|
||||||
<Tooltip>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -368,7 +368,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
|
|
||||||
{showCreateButtons && onNewSession ? (
|
{showCreateButtons && onNewSession ? (
|
||||||
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
|
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
|
||||||
<Tooltip>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
|||||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { formatMoney } from '@/lib/money';
|
||||||
import { clampPercent, resolveUsageTone } from '@/lib/quota';
|
import { clampPercent, resolveUsageTone } from '@/lib/quota';
|
||||||
|
|
||||||
interface ContextUsageDisplayProps {
|
interface ContextUsageDisplayProps {
|
||||||
@@ -12,6 +13,7 @@ interface ContextUsageDisplayProps {
|
|||||||
colorPercentage?: number;
|
colorPercentage?: number;
|
||||||
contextLimit: number;
|
contextLimit: number;
|
||||||
outputLimit?: number;
|
outputLimit?: number;
|
||||||
|
cost?: number | null;
|
||||||
size?: 'default' | 'compact';
|
size?: 'default' | 'compact';
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
hideIcon?: boolean;
|
hideIcon?: boolean;
|
||||||
@@ -29,6 +31,7 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
|||||||
colorPercentage,
|
colorPercentage,
|
||||||
contextLimit,
|
contextLimit,
|
||||||
outputLimit,
|
outputLimit,
|
||||||
|
cost = null,
|
||||||
size = 'default',
|
size = 'default',
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
hideIcon = false,
|
hideIcon = false,
|
||||||
@@ -73,10 +76,13 @@ export const ContextUsageDisplay: React.FC<ContextUsageDisplayProps> = ({
|
|||||||
const circularProgressOffset = circularProgressCircumference * (1 - progressPct / 100);
|
const circularProgressOffset = circularProgressCircumference * (1 - progressPct / 100);
|
||||||
|
|
||||||
const safeOutputLimit = typeof outputLimit === 'number' ? Math.max(outputLimit, 0) : 0;
|
const safeOutputLimit = typeof outputLimit === 'number' ? Math.max(outputLimit, 0) : 0;
|
||||||
|
const normalizedCost = cost ?? 0;
|
||||||
|
const hasCost = normalizedCost > 0 && Number.isFinite(normalizedCost);
|
||||||
const tooltipLines = [
|
const tooltipLines = [
|
||||||
t('contextUsage.tooltip.usedTokens', { tokens: formatTokens(totalTokens) }),
|
t('contextUsage.tooltip.usedTokens', { tokens: formatTokens(totalTokens) }),
|
||||||
t('contextUsage.tooltip.contextLimit', { tokens: formatTokens(contextLimit) }),
|
t('contextUsage.tooltip.contextLimit', { tokens: formatTokens(contextLimit) }),
|
||||||
t('contextUsage.tooltip.outputLimit', { tokens: formatTokens(safeOutputLimit) }),
|
t('contextUsage.tooltip.outputLimit', { tokens: formatTokens(safeOutputLimit) }),
|
||||||
|
...(hasCost ? [t('contextUsage.tooltip.cost', { cost: formatMoney(normalizedCost) })] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const isInteractive = !isMobile && typeof onClick === 'function';
|
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-muted-foreground">{t('contextUsage.mobile.outputLimit')}</span>
|
||||||
<span className="typography-meta text-foreground font-medium">{formatTokens(safeOutputLimit)}</span>
|
<span className="typography-meta text-foreground font-medium">{formatTokens(safeOutputLimit)}</span>
|
||||||
</div>
|
</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">
|
<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="typography-meta text-muted-foreground">{t('contextUsage.mobile.usage')}</span>
|
||||||
<span className={cn('typography-meta font-semibold', getPercentageColor(colorPct))}>
|
<span className={cn('typography-meta font-semibold', getPercentageColor(colorPct))}>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ function DialogContent({
|
|||||||
{showCloseButton && (
|
{showCloseButton && (
|
||||||
<BaseDialog.Close
|
<BaseDialog.Close
|
||||||
data-slot="dialog-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"/>
|
<Icon name="close"/>
|
||||||
<span className="sr-only">{t('dialog.common.actions.close')}</span>
|
<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 { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave';
|
||||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||||
import { acquireRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, subscribeRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
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 { getOutsideFileGrant } from '@/lib/outsideFileGrants';
|
||||||
|
import { subscribeToFileContentInvalidation } from '@/lib/fileContentInvalidation';
|
||||||
import { DiagramEditor } from '@/components/diagram';
|
import { DiagramEditor } from '@/components/diagram';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { EditorView } from '@codemirror/view';
|
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 lastLoadedFileStatRef = React.useRef<FileStatSnapshot | null>(null);
|
||||||
const activeFileLoadIdRef = React.useRef(0);
|
const activeFileLoadIdRef = React.useRef(0);
|
||||||
const loadingFilePathRef = React.useRef<string | null>(null);
|
const loadingFilePathRef = React.useRef<string | null>(null);
|
||||||
|
const [fileContentRevision, setFileContentRevision] = React.useState(0);
|
||||||
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
|
const [autoSaveStatus, setAutoSaveStatus] = React.useState<'idle' | 'saved'>('idle');
|
||||||
const [diagramSaved, setDiagramSaved] = React.useState(false);
|
const [diagramSaved, setDiagramSaved] = React.useState(false);
|
||||||
const [contentDetectedBinary, setContentDetectedBinary] = React.useState(false);
|
const [contentDetectedBinary, setContentDetectedBinary] = React.useState(false);
|
||||||
@@ -2046,13 +2048,33 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
loadingFilePathRef.current = null;
|
loadingFilePathRef.current = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [loadSelectedFile, loadedFilePath, selectedFile]);
|
}, [fileContentRevision, loadSelectedFile, loadedFilePath, selectedFile]);
|
||||||
|
|
||||||
// Sync isDirty to a ref so the polling interval can read the latest value
|
// 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).
|
// without isDirty in its dependency array (avoids interval restart on every edit/save).
|
||||||
const isDirtyRef = React.useRef(isDirty);
|
const isDirtyRef = React.useRef(isDirty);
|
||||||
isDirtyRef.current = 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.
|
// Poll open file for external changes.
|
||||||
// When a change is detected, reset loadedFilePath so the effect above
|
// When a change is detected, reset loadedFilePath so the effect above
|
||||||
// triggers a single reload — no double-load.
|
// triggers a single reload — no double-load.
|
||||||
@@ -3006,11 +3028,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf
|
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
|
const htmlAssetAuthKey = selectedFile?.path && isHtml && htmlViewMode === 'preview' && !runtime.isVSCode
|
||||||
? selectedFile.path
|
? `${selectedFile.path}|${fileContentRevision}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const assetAuthErrorFallback = t('filesView.error.readFileFailed');
|
const assetAuthErrorFallback = t('filesView.error.readFileFailed');
|
||||||
@@ -3113,7 +3135,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
URL.revokeObjectURL(objectUrl);
|
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), []);
|
const handleCloseDialog = React.useCallback(() => setActiveDialog(null), []);
|
||||||
|
|
||||||
|
|||||||
@@ -2072,7 +2072,7 @@ export type RuntimeAPISelector<TValue> = (apis: RuntimeAPIs) => TValue;
|
|||||||
|
|
||||||
type SkillsCatalogSourceId = string;
|
type SkillsCatalogSourceId = string;
|
||||||
|
|
||||||
type SkillsCatalogSourceType = 'github' | 'clawdhub';
|
type SkillsCatalogSourceType = 'github';
|
||||||
|
|
||||||
export interface SkillsCatalogSource {
|
export interface SkillsCatalogSource {
|
||||||
id: SkillsCatalogSourceId;
|
id: SkillsCatalogSourceId;
|
||||||
@@ -2081,6 +2081,10 @@ export interface SkillsCatalogSource {
|
|||||||
source: string;
|
source: string;
|
||||||
defaultSubpath?: string;
|
defaultSubpath?: string;
|
||||||
sourceType?: SkillsCatalogSourceType;
|
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 {
|
interface SkillsCatalogItemInstalledBadge {
|
||||||
@@ -2089,18 +2093,6 @@ interface SkillsCatalogItemInstalledBadge {
|
|||||||
source?: 'opencode' | 'agents' | 'claude';
|
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 {
|
export interface SkillsCatalogItem {
|
||||||
sourceId: SkillsCatalogSourceId;
|
sourceId: SkillsCatalogSourceId;
|
||||||
repoSource: string;
|
repoSource: string;
|
||||||
@@ -2113,22 +2105,18 @@ export interface SkillsCatalogItem {
|
|||||||
installable: boolean;
|
installable: boolean;
|
||||||
warnings?: string[];
|
warnings?: string[];
|
||||||
installed?: SkillsCatalogItemInstalledBadge;
|
installed?: SkillsCatalogItemInstalledBadge;
|
||||||
/** ClawdHub-specific metadata (present only for ClawdHub sources) */
|
|
||||||
clawdhub?: ClawdHubSkillMetadata;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillsCatalogResponse {
|
export interface SkillsCatalogResponse {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
sources?: SkillsCatalogSource[];
|
sources?: SkillsCatalogSource[];
|
||||||
itemsBySource?: Record<SkillsCatalogSourceId, SkillsCatalogItem[]>;
|
itemsBySource?: Record<SkillsCatalogSourceId, SkillsCatalogItem[]>;
|
||||||
pageInfoBySource?: Record<SkillsCatalogSourceId, { nextCursor?: string | null }>;
|
|
||||||
error?: { kind: string; message: string };
|
error?: { kind: string; message: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillsCatalogSourceResponse {
|
export interface SkillsCatalogSourceResponse {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
items?: SkillsCatalogItem[];
|
items?: SkillsCatalogItem[];
|
||||||
nextCursor?: string | null;
|
|
||||||
error?: { kind: string; message: string };
|
error?: { kind: string; message: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2153,11 +2141,6 @@ export interface SkillsRepoScanResponse {
|
|||||||
|
|
||||||
interface SkillsInstallSelection {
|
interface SkillsInstallSelection {
|
||||||
skillDir: string;
|
skillDir: string;
|
||||||
/** ClawdHub-specific metadata for installation */
|
|
||||||
clawdhub?: {
|
|
||||||
slug: string;
|
|
||||||
version: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillsInstallRequest {
|
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.manual': 'Manuell',
|
||||||
'settings.skills.catalog.page.mode.external': 'Extern',
|
'settings.skills.catalog.page.mode.external': 'Extern',
|
||||||
'settings.skills.catalog.page.title': 'Fähigkeitskatalog',
|
'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.section.sourceRepository': 'Quell-Repository',
|
||||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Quelle auswählen',
|
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Quelle auswählen',
|
||||||
'settings.skills.catalog.page.actions.refreshTitle': 'Aktualisieren',
|
'settings.skills.catalog.page.actions.refreshTitle': 'Aktualisieren',
|
||||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Katalog entfernen',
|
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Katalog entfernen',
|
||||||
'settings.skills.catalog.page.actions.addCatalog': 'Katalog hinzufügen',
|
'settings.skills.catalog.page.actions.addCatalog': 'Katalog hinzufügen',
|
||||||
'settings.skills.catalog.page.actions.removeCatalog': 'Katalog entfernen',
|
'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.catalog': 'Wird geladen...',
|
||||||
'settings.skills.catalog.page.loading.skills': 'Fähigkeiten werden 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.foundCount': '{count} Fähigkeit(en) gefunden',
|
||||||
'settings.skills.catalog.page.error.catalogTitle': 'Katalogfehler',
|
'settings.skills.catalog.page.error.catalogTitle': 'Katalogfehler',
|
||||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Keine Fähigkeiten gefunden',
|
'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.installed': 'installiert ({scope})',
|
||||||
'settings.skills.catalog.page.badge.notInstallable': 'nicht installierbar',
|
'settings.skills.catalog.page.badge.notInstallable': 'nicht installierbar',
|
||||||
'settings.skills.catalog.page.badge.unknown': 'unbekannt',
|
'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.title': 'Katalog entfernen',
|
||||||
'settings.skills.catalog.page.removeDialog.description': 'Sind Sie sicher, dass Sie diesen Katalog entfernen möchten?',
|
'settings.skills.catalog.page.removeDialog.description': 'Sind Sie sicher, dass Sie diesen Katalog entfernen möchten?',
|
||||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||||
|
|||||||
@@ -1383,10 +1383,12 @@ export const dict = {
|
|||||||
'contextUsage.mobile.usedTokens': 'Verwendete Tokens',
|
'contextUsage.mobile.usedTokens': 'Verwendete Tokens',
|
||||||
'contextUsage.mobile.contextLimit': 'Kontextlimit',
|
'contextUsage.mobile.contextLimit': 'Kontextlimit',
|
||||||
'contextUsage.mobile.outputLimit': 'Ausgabelimit',
|
'contextUsage.mobile.outputLimit': 'Ausgabelimit',
|
||||||
|
'contextUsage.mobile.cost': 'Kosten',
|
||||||
'contextUsage.mobile.usage': 'Nutzung',
|
'contextUsage.mobile.usage': 'Nutzung',
|
||||||
'contextUsage.tooltip.usedTokens': 'Verwendete Tokens: {tokens}',
|
'contextUsage.tooltip.usedTokens': 'Verwendete Tokens: {tokens}',
|
||||||
'contextUsage.tooltip.contextLimit': 'Kontextlimit: {tokens}',
|
'contextUsage.tooltip.contextLimit': 'Kontextlimit: {tokens}',
|
||||||
'contextUsage.tooltip.outputLimit': 'Ausgabelimit: {tokens}',
|
'contextUsage.tooltip.outputLimit': 'Ausgabelimit: {tokens}',
|
||||||
|
'contextUsage.tooltip.cost': 'Kosten: {cost}',
|
||||||
'contextSidebar.session.untitled': 'Unbenannte Sitzung',
|
'contextSidebar.session.untitled': 'Unbenannte Sitzung',
|
||||||
'contextSidebar.empty.openSession': 'Öffnen Sie eine Sitzung, um den Kontext zu prüfen.',
|
'contextSidebar.empty.openSession': 'Öffnen Sie eine Sitzung, um den Kontext zu prüfen.',
|
||||||
'contextSidebar.section.context': 'Kontext',
|
'contextSidebar.section.context': 'Kontext',
|
||||||
@@ -2163,6 +2165,9 @@ export const dict = {
|
|||||||
'chat.revert.toast.undo': 'Zurückgesetzt auf {preview}',
|
'chat.revert.toast.undo': 'Zurückgesetzt auf {preview}',
|
||||||
'chat.revert.toast.redo': 'Wiederholt',
|
'chat.revert.toast.redo': 'Wiederholt',
|
||||||
'chat.revert.toast.restored': 'Alle Nachrichten wiederhergestellt',
|
'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.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.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',
|
'chat.errorBoundary.sessionLabel': 'Sitzung',
|
||||||
|
|||||||
@@ -906,16 +906,26 @@ export const settingsDict = {
|
|||||||
'settings.skills.catalog.page.mode.manual': 'Manual',
|
'settings.skills.catalog.page.mode.manual': 'Manual',
|
||||||
'settings.skills.catalog.page.mode.external': 'External',
|
'settings.skills.catalog.page.mode.external': 'External',
|
||||||
'settings.skills.catalog.page.title': 'Skills Catalog',
|
'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.section.sourceRepository': 'Source Repository',
|
||||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Select source',
|
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Select source',
|
||||||
'settings.skills.catalog.page.actions.refreshTitle': 'Refresh',
|
'settings.skills.catalog.page.actions.refreshTitle': 'Refresh',
|
||||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Remove Catalog',
|
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Remove Catalog',
|
||||||
'settings.skills.catalog.page.actions.addCatalog': 'Add Catalog',
|
'settings.skills.catalog.page.actions.addCatalog': 'Add Catalog',
|
||||||
'settings.skills.catalog.page.actions.removeCatalog': 'Remove 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.catalog': 'Loading...',
|
||||||
'settings.skills.catalog.page.loading.skills': 'Loading skills...',
|
'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.foundCount': '{count} skill(s) found',
|
||||||
'settings.skills.catalog.page.error.catalogTitle': 'Catalog error',
|
'settings.skills.catalog.page.error.catalogTitle': 'Catalog error',
|
||||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'No skills found',
|
'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.installed': 'installed ({scope})',
|
||||||
'settings.skills.catalog.page.badge.notInstallable': 'not installable',
|
'settings.skills.catalog.page.badge.notInstallable': 'not installable',
|
||||||
'settings.skills.catalog.page.badge.unknown': 'unknown',
|
'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.title': 'Remove Catalog',
|
||||||
'settings.skills.catalog.page.removeDialog.description': 'Are you sure you want to remove this catalog?',
|
'settings.skills.catalog.page.removeDialog.description': 'Are you sure you want to remove this catalog?',
|
||||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||||
|
|||||||
@@ -1635,10 +1635,12 @@ export const dict = {
|
|||||||
'contextUsage.mobile.usedTokens': 'Used tokens',
|
'contextUsage.mobile.usedTokens': 'Used tokens',
|
||||||
'contextUsage.mobile.contextLimit': 'Context limit',
|
'contextUsage.mobile.contextLimit': 'Context limit',
|
||||||
'contextUsage.mobile.outputLimit': 'Output limit',
|
'contextUsage.mobile.outputLimit': 'Output limit',
|
||||||
|
'contextUsage.mobile.cost': 'Cost',
|
||||||
'contextUsage.mobile.usage': 'Usage',
|
'contextUsage.mobile.usage': 'Usage',
|
||||||
'contextUsage.tooltip.usedTokens': 'Used tokens: {tokens}',
|
'contextUsage.tooltip.usedTokens': 'Used tokens: {tokens}',
|
||||||
'contextUsage.tooltip.contextLimit': 'Context limit: {tokens}',
|
'contextUsage.tooltip.contextLimit': 'Context limit: {tokens}',
|
||||||
'contextUsage.tooltip.outputLimit': 'Output limit: {tokens}',
|
'contextUsage.tooltip.outputLimit': 'Output limit: {tokens}',
|
||||||
|
'contextUsage.tooltip.cost': 'Cost: {cost}',
|
||||||
'contextSidebar.session.untitled': 'Untitled Session',
|
'contextSidebar.session.untitled': 'Untitled Session',
|
||||||
'contextSidebar.empty.openSession': 'Open a session to inspect context.',
|
'contextSidebar.empty.openSession': 'Open a session to inspect context.',
|
||||||
'contextSidebar.section.context': 'Context',
|
'contextSidebar.section.context': 'Context',
|
||||||
@@ -2421,6 +2423,9 @@ export const dict = {
|
|||||||
'chat.revert.toast.undo': 'Reverted to {preview}',
|
'chat.revert.toast.undo': 'Reverted to {preview}',
|
||||||
'chat.revert.toast.redo': 'Redone',
|
'chat.revert.toast.redo': 'Redone',
|
||||||
'chat.revert.toast.restored': 'Restored all messages',
|
'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.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.description': 'The chat interface encountered an error. This might be due to a temporary network issue or corrupted message data.',
|
||||||
'chat.errorBoundary.sessionLabel': 'Session',
|
'chat.errorBoundary.sessionLabel': 'Session',
|
||||||
|
|||||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
|||||||
"settings.skills.catalog.page.mode.manual": "Manual",
|
"settings.skills.catalog.page.mode.manual": "Manual",
|
||||||
"settings.skills.catalog.page.mode.external": "Externo",
|
"settings.skills.catalog.page.mode.external": "Externo",
|
||||||
"settings.skills.catalog.page.title": "Catálogo de habilidades",
|
"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.section.sourceRepository": "Repositorio de origen",
|
||||||
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Seleccionar origen",
|
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Seleccionar origen",
|
||||||
"settings.skills.catalog.page.actions.refreshTitle": "Actualizar",
|
"settings.skills.catalog.page.actions.refreshTitle": "Actualizar",
|
||||||
"settings.skills.catalog.page.actions.removeCatalogTitle": "Eliminar catálogo",
|
"settings.skills.catalog.page.actions.removeCatalogTitle": "Eliminar catálogo",
|
||||||
"settings.skills.catalog.page.actions.addCatalog": "Añadir 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.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.catalog": "Cargando...",
|
||||||
"settings.skills.catalog.page.loading.skills": "Cargando habilidades...",
|
"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.foundCount": "{count} habilidad(es) encontrada(s)",
|
||||||
"settings.skills.catalog.page.error.catalogTitle": "Error del catálogo",
|
"settings.skills.catalog.page.error.catalogTitle": "Error del catálogo",
|
||||||
"settings.skills.catalog.page.empty.noSkillsTitle": "No se encontraron habilidades",
|
"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.installed": "instalado ({scope})",
|
||||||
"settings.skills.catalog.page.badge.notInstallable": "no instalable",
|
"settings.skills.catalog.page.badge.notInstallable": "no instalable",
|
||||||
"settings.skills.catalog.page.badge.unknown": "desconocido",
|
"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.title": "Eliminar catálogo",
|
||||||
"settings.skills.catalog.page.removeDialog.description": "¿Estás seguro de que quieres eliminar este 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",
|
"settings.openchamber.passkeys.title": "Claves de paso",
|
||||||
|
|||||||
@@ -1602,10 +1602,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"contextUsage.mobile.usedTokens": "Tokens usados",
|
"contextUsage.mobile.usedTokens": "Tokens usados",
|
||||||
"contextUsage.mobile.contextLimit": "Límite de contexto",
|
"contextUsage.mobile.contextLimit": "Límite de contexto",
|
||||||
"contextUsage.mobile.outputLimit": "Límite de salida",
|
"contextUsage.mobile.outputLimit": "Límite de salida",
|
||||||
|
"contextUsage.mobile.cost": "Costo",
|
||||||
"contextUsage.mobile.usage": "Uso",
|
"contextUsage.mobile.usage": "Uso",
|
||||||
"contextUsage.tooltip.usedTokens": "Tokens usados: {tokens}",
|
"contextUsage.tooltip.usedTokens": "Tokens usados: {tokens}",
|
||||||
"contextUsage.tooltip.contextLimit": "Límite de contexto: {tokens}",
|
"contextUsage.tooltip.contextLimit": "Límite de contexto: {tokens}",
|
||||||
"contextUsage.tooltip.outputLimit": "Límite de salida: {tokens}",
|
"contextUsage.tooltip.outputLimit": "Límite de salida: {tokens}",
|
||||||
|
"contextUsage.tooltip.cost": "Costo: {cost}",
|
||||||
"contextSidebar.session.untitled": "Sesión sin título",
|
"contextSidebar.session.untitled": "Sesión sin título",
|
||||||
"contextSidebar.empty.openSession": "Abrir una sesión para inspeccionar el contexto.",
|
"contextSidebar.empty.openSession": "Abrir una sesión para inspeccionar el contexto.",
|
||||||
"contextSidebar.section.context": "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.undo": "Revertido a {preview}",
|
||||||
"chat.revert.toast.redo": "Rehecho",
|
"chat.revert.toast.redo": "Rehecho",
|
||||||
"chat.revert.toast.restored": "Todos los mensajes restaurados",
|
"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.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.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",
|
"chat.errorBoundary.sessionLabel": "Sesión",
|
||||||
|
|||||||
@@ -792,16 +792,26 @@ export const settingsDict = {
|
|||||||
'settings.skills.catalog.page.mode.manual': 'Manuel',
|
'settings.skills.catalog.page.mode.manual': 'Manuel',
|
||||||
'settings.skills.catalog.page.mode.external': 'Externe',
|
'settings.skills.catalog.page.mode.external': 'Externe',
|
||||||
'settings.skills.catalog.page.title': 'Catalogue de skills',
|
'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.section.sourceRepository': 'Dépôt source',
|
||||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Sélectionnez la source',
|
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Sélectionnez la source',
|
||||||
'settings.skills.catalog.page.actions.refreshTitle': 'Rafraîchir',
|
'settings.skills.catalog.page.actions.refreshTitle': 'Rafraîchir',
|
||||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Supprimer le catalogue',
|
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Supprimer le catalogue',
|
||||||
'settings.skills.catalog.page.actions.addCatalog': 'Ajouter un catalogue',
|
'settings.skills.catalog.page.actions.addCatalog': 'Ajouter un catalogue',
|
||||||
'settings.skills.catalog.page.actions.removeCatalog': 'Supprimer le 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.catalog': 'Chargement...',
|
||||||
'settings.skills.catalog.page.loading.skills': 'Chargement des skills...',
|
'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.foundCount': '{count} skill(s) trouvé(s)',
|
||||||
'settings.skills.catalog.page.error.catalogTitle': 'Erreur de catalogue',
|
'settings.skills.catalog.page.error.catalogTitle': 'Erreur de catalogue',
|
||||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Aucun skill trouvé',
|
'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.installed': 'installé ({scope})',
|
||||||
'settings.skills.catalog.page.badge.notInstallable': 'non installable',
|
'settings.skills.catalog.page.badge.notInstallable': 'non installable',
|
||||||
'settings.skills.catalog.page.badge.unknown': 'inconnu',
|
'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.title': 'Supprimer le catalogue',
|
||||||
'settings.skills.catalog.page.removeDialog.description': 'Êtes-vous sûr de vouloir supprimer ce catalogue ?',
|
'settings.skills.catalog.page.removeDialog.description': 'Êtes-vous sûr de vouloir supprimer ce catalogue ?',
|
||||||
'settings.openchamber.passkeys.title': 'Mots-clés',
|
'settings.openchamber.passkeys.title': 'Mots-clés',
|
||||||
|
|||||||
@@ -1311,10 +1311,12 @@ export const dict = {
|
|||||||
'contextUsage.mobile.usedTokens': 'Jetons utilisés',
|
'contextUsage.mobile.usedTokens': 'Jetons utilisés',
|
||||||
'contextUsage.mobile.contextLimit': 'Limite de contexte',
|
'contextUsage.mobile.contextLimit': 'Limite de contexte',
|
||||||
'contextUsage.mobile.outputLimit': 'Limite de sortie',
|
'contextUsage.mobile.outputLimit': 'Limite de sortie',
|
||||||
|
'contextUsage.mobile.cost': 'Coût',
|
||||||
'contextUsage.mobile.usage': 'Usage',
|
'contextUsage.mobile.usage': 'Usage',
|
||||||
'contextUsage.tooltip.usedTokens': 'Jetons utilisés : {tokens}',
|
'contextUsage.tooltip.usedTokens': 'Jetons utilisés : {tokens}',
|
||||||
'contextUsage.tooltip.contextLimit': 'Limite de contexte : {tokens}',
|
'contextUsage.tooltip.contextLimit': 'Limite de contexte : {tokens}',
|
||||||
'contextUsage.tooltip.outputLimit': 'Limite de sortie : {tokens}',
|
'contextUsage.tooltip.outputLimit': 'Limite de sortie : {tokens}',
|
||||||
|
'contextUsage.tooltip.cost': 'Coût : {cost}',
|
||||||
'contextSidebar.session.untitled': 'Session sans titre',
|
'contextSidebar.session.untitled': 'Session sans titre',
|
||||||
'contextSidebar.empty.openSession': 'Ouvrez une session pour inspecter le contexte.',
|
'contextSidebar.empty.openSession': 'Ouvrez une session pour inspecter le contexte.',
|
||||||
'contextSidebar.section.context': 'Contexte',
|
'contextSidebar.section.context': 'Contexte',
|
||||||
@@ -2074,6 +2076,9 @@ export const dict = {
|
|||||||
'chat.revert.toast.undo': 'Revenu à {preview}',
|
'chat.revert.toast.undo': 'Revenu à {preview}',
|
||||||
'chat.revert.toast.redo': 'Refait',
|
'chat.revert.toast.redo': 'Refait',
|
||||||
'chat.revert.toast.restored': 'Restauré tous les messages',
|
'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.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.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',
|
'chat.errorBoundary.sessionLabel': 'Session',
|
||||||
|
|||||||
@@ -907,16 +907,26 @@ export const settingsDict = {
|
|||||||
'settings.skills.catalog.page.mode.manual': '手動',
|
'settings.skills.catalog.page.mode.manual': '手動',
|
||||||
'settings.skills.catalog.page.mode.external': '外部',
|
'settings.skills.catalog.page.mode.external': '外部',
|
||||||
'settings.skills.catalog.page.title': 'スキルカタログ',
|
'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.section.sourceRepository': 'ソースリポジトリ',
|
||||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'ソースを選択',
|
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'ソースを選択',
|
||||||
'settings.skills.catalog.page.actions.refreshTitle': '更新',
|
'settings.skills.catalog.page.actions.refreshTitle': '更新',
|
||||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'カタログを削除',
|
'settings.skills.catalog.page.actions.removeCatalogTitle': 'カタログを削除',
|
||||||
'settings.skills.catalog.page.actions.addCatalog': 'カタログを追加',
|
'settings.skills.catalog.page.actions.addCatalog': 'カタログを追加',
|
||||||
'settings.skills.catalog.page.actions.removeCatalog': 'カタログを削除',
|
'settings.skills.catalog.page.actions.removeCatalog': 'カタログを削除',
|
||||||
'settings.skills.catalog.page.actions.loadMoreSkills': 'さらに Skill を読み込む',
|
|
||||||
'settings.skills.catalog.page.loading.catalog': '読み込み中...',
|
'settings.skills.catalog.page.loading.catalog': '読み込み中...',
|
||||||
'settings.skills.catalog.page.loading.skills': 'Skill を読み込み中...',
|
'settings.skills.catalog.page.loading.skills': 'Skill を読み込み中...',
|
||||||
'settings.skills.catalog.page.loading.more': '読み込み中...',
|
|
||||||
'settings.skills.catalog.page.foundCount': '{count} 個の Skill が見つかりました',
|
'settings.skills.catalog.page.foundCount': '{count} 個の Skill が見つかりました',
|
||||||
'settings.skills.catalog.page.error.catalogTitle': 'カタログエラー',
|
'settings.skills.catalog.page.error.catalogTitle': 'カタログエラー',
|
||||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Skill が見つかりません',
|
'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.installed': 'インストール済み ({scope})',
|
||||||
'settings.skills.catalog.page.badge.notInstallable': 'インストール不可',
|
'settings.skills.catalog.page.badge.notInstallable': 'インストール不可',
|
||||||
'settings.skills.catalog.page.badge.unknown': '不明',
|
'settings.skills.catalog.page.badge.unknown': '不明',
|
||||||
'settings.skills.catalog.page.byOwnerPrefix': '提供',
|
|
||||||
'settings.skills.catalog.page.removeDialog.title': 'カタログを削除',
|
'settings.skills.catalog.page.removeDialog.title': 'カタログを削除',
|
||||||
'settings.skills.catalog.page.removeDialog.description': 'このカタログを削除してもよろしいですか?',
|
'settings.skills.catalog.page.removeDialog.description': 'このカタログを削除してもよろしいですか?',
|
||||||
'settings.openchamber.passkeys.title': 'パスキー',
|
'settings.openchamber.passkeys.title': 'パスキー',
|
||||||
|
|||||||
@@ -1632,10 +1632,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'contextUsage.mobile.usedTokens': '使用トークン',
|
'contextUsage.mobile.usedTokens': '使用トークン',
|
||||||
'contextUsage.mobile.contextLimit': 'コンテキスト制限',
|
'contextUsage.mobile.contextLimit': 'コンテキスト制限',
|
||||||
'contextUsage.mobile.outputLimit': '出力制限',
|
'contextUsage.mobile.outputLimit': '出力制限',
|
||||||
|
'contextUsage.mobile.cost': 'コスト',
|
||||||
'contextUsage.mobile.usage': '使用量',
|
'contextUsage.mobile.usage': '使用量',
|
||||||
'contextUsage.tooltip.usedTokens': '使用トークン: {tokens}',
|
'contextUsage.tooltip.usedTokens': '使用トークン: {tokens}',
|
||||||
'contextUsage.tooltip.contextLimit': 'コンテキスト制限: {tokens}',
|
'contextUsage.tooltip.contextLimit': 'コンテキスト制限: {tokens}',
|
||||||
'contextUsage.tooltip.outputLimit': '出力制限: {tokens}',
|
'contextUsage.tooltip.outputLimit': '出力制限: {tokens}',
|
||||||
|
'contextUsage.tooltip.cost': 'コスト: {cost}',
|
||||||
'contextSidebar.session.untitled': '無題のセッション',
|
'contextSidebar.session.untitled': '無題のセッション',
|
||||||
'contextSidebar.empty.openSession': 'セッションを開いてコンテキストを確認します。',
|
'contextSidebar.empty.openSession': 'セッションを開いてコンテキストを確認します。',
|
||||||
'contextSidebar.section.context': 'コンテキスト',
|
'contextSidebar.section.context': 'コンテキスト',
|
||||||
@@ -2418,6 +2420,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.revert.toast.undo': '{preview}に元に戻しました',
|
'chat.revert.toast.undo': '{preview}に元に戻しました',
|
||||||
'chat.revert.toast.redo': 'やり直しました',
|
'chat.revert.toast.redo': 'やり直しました',
|
||||||
'chat.revert.toast.restored': 'すべてのメッセージを復元しました',
|
'chat.revert.toast.restored': 'すべてのメッセージを復元しました',
|
||||||
|
'chat.toast.opencodeRestartInterrupted.title': 'チャットが中断されました',
|
||||||
|
'chat.toast.opencodeRestartInterrupted.description': '応答の生成中に OpenCode が再起動しました。続行するにはメッセージを送信してください。',
|
||||||
|
'chat.toast.opencodeRestartInterrupted.openSession': 'セッションを開く',
|
||||||
'chat.errorBoundary.title': 'チャットエラー',
|
'chat.errorBoundary.title': 'チャットエラー',
|
||||||
'chat.errorBoundary.description': 'チャットインターフェースでエラーが発生しました。一時的なネットワーク問題または破損したメッセージデータが原因の可能性があります。',
|
'chat.errorBoundary.description': 'チャットインターフェースでエラーが発生しました。一時的なネットワーク問題または破損したメッセージデータが原因の可能性があります。',
|
||||||
'chat.errorBoundary.sessionLabel': 'セッション',
|
'chat.errorBoundary.sessionLabel': 'セッション',
|
||||||
|
|||||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
|||||||
'settings.skills.catalog.page.mode.manual': '수동',
|
'settings.skills.catalog.page.mode.manual': '수동',
|
||||||
'settings.skills.catalog.page.mode.external': 'External',
|
'settings.skills.catalog.page.mode.external': 'External',
|
||||||
'settings.skills.catalog.page.title': '스킬 카탈로그',
|
'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.section.sourceRepository': '카탈로그 저장소',
|
||||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': '저장소 선택',
|
'settings.skills.catalog.page.field.selectSourcePlaceholder': '저장소 선택',
|
||||||
'settings.skills.catalog.page.actions.refreshTitle': '새로고침',
|
'settings.skills.catalog.page.actions.refreshTitle': '새로고침',
|
||||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Catalog 제거',
|
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Catalog 제거',
|
||||||
'settings.skills.catalog.page.actions.addCatalog': 'Catalog 추가',
|
'settings.skills.catalog.page.actions.addCatalog': 'Catalog 추가',
|
||||||
'settings.skills.catalog.page.actions.removeCatalog': '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.catalog': '로딩 중...',
|
||||||
'settings.skills.catalog.page.loading.skills': '스킬 불러오는 중...',
|
'settings.skills.catalog.page.loading.skills': '스킬 불러오는 중...',
|
||||||
'settings.skills.catalog.page.loading.more': '로딩 중...',
|
|
||||||
'settings.skills.catalog.page.foundCount': '스킬 {count}개 발견',
|
'settings.skills.catalog.page.foundCount': '스킬 {count}개 발견',
|
||||||
'settings.skills.catalog.page.error.catalogTitle': 'Catalog 오류',
|
'settings.skills.catalog.page.error.catalogTitle': 'Catalog 오류',
|
||||||
'settings.skills.catalog.page.empty.noSkillsTitle': '스킬을 찾을 수 없습니다',
|
'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.installed': '설치됨({scope})',
|
||||||
'settings.skills.catalog.page.badge.notInstallable': '설치할 수 없음',
|
'settings.skills.catalog.page.badge.notInstallable': '설치할 수 없음',
|
||||||
'settings.skills.catalog.page.badge.unknown': '알 수 없음',
|
'settings.skills.catalog.page.badge.unknown': '알 수 없음',
|
||||||
'settings.skills.catalog.page.byOwnerPrefix': '작성자',
|
|
||||||
'settings.skills.catalog.page.removeDialog.title': 'Catalog 제거',
|
'settings.skills.catalog.page.removeDialog.title': 'Catalog 제거',
|
||||||
'settings.skills.catalog.page.removeDialog.description': '이 카탈로그를 제거하시겠습니까?',
|
'settings.skills.catalog.page.removeDialog.description': '이 카탈로그를 제거하시겠습니까?',
|
||||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||||
|
|||||||
@@ -1638,10 +1638,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'contextUsage.mobile.usedTokens': '사용한 토큰',
|
'contextUsage.mobile.usedTokens': '사용한 토큰',
|
||||||
'contextUsage.mobile.contextLimit': '컨텍스트 한도',
|
'contextUsage.mobile.contextLimit': '컨텍스트 한도',
|
||||||
'contextUsage.mobile.outputLimit': '출력 한도',
|
'contextUsage.mobile.outputLimit': '출력 한도',
|
||||||
|
'contextUsage.mobile.cost': '비용',
|
||||||
'contextUsage.mobile.usage': '사용량',
|
'contextUsage.mobile.usage': '사용량',
|
||||||
'contextUsage.tooltip.usedTokens': '사용됨 토큰: {tokens}',
|
'contextUsage.tooltip.usedTokens': '사용됨 토큰: {tokens}',
|
||||||
'contextUsage.tooltip.contextLimit': '컨텍스트 한도: {tokens}',
|
'contextUsage.tooltip.contextLimit': '컨텍스트 한도: {tokens}',
|
||||||
'contextUsage.tooltip.outputLimit': '출력 한도: {tokens}',
|
'contextUsage.tooltip.outputLimit': '출력 한도: {tokens}',
|
||||||
|
'contextUsage.tooltip.cost': '비용: {cost}',
|
||||||
'contextSidebar.session.untitled': '제목 없는 세션',
|
'contextSidebar.session.untitled': '제목 없는 세션',
|
||||||
'contextSidebar.empty.openSession': '컨텍스트를 볼 세션을 여세요.',
|
'contextSidebar.empty.openSession': '컨텍스트를 볼 세션을 여세요.',
|
||||||
'contextSidebar.section.context': '컨텍스트',
|
'contextSidebar.section.context': '컨텍스트',
|
||||||
@@ -2424,6 +2426,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.revert.toast.undo': '{preview}(으)로 되돌림',
|
'chat.revert.toast.undo': '{preview}(으)로 되돌림',
|
||||||
'chat.revert.toast.redo': '다시 실행',
|
'chat.revert.toast.redo': '다시 실행',
|
||||||
'chat.revert.toast.restored': '모든 메시지 복원됨',
|
'chat.revert.toast.restored': '모든 메시지 복원됨',
|
||||||
|
'chat.toast.opencodeRestartInterrupted.title': '채팅이 중단되었습니다',
|
||||||
|
'chat.toast.opencodeRestartInterrupted.description': '응답이 진행 중인 동안 OpenCode가 다시 시작되었습니다. 계속하려면 메시지를 보내세요.',
|
||||||
|
'chat.toast.opencodeRestartInterrupted.openSession': '세션 열기',
|
||||||
'chat.errorBoundary.title': '채팅 오류',
|
'chat.errorBoundary.title': '채팅 오류',
|
||||||
'chat.errorBoundary.description': '채팅 인터페이스에서 오류가 발생했습니다. 일시적인 네트워크 이슈 또는 손상된 메시지 데이터 때문일 수 있습니다.',
|
'chat.errorBoundary.description': '채팅 인터페이스에서 오류가 발생했습니다. 일시적인 네트워크 이슈 또는 손상된 메시지 데이터 때문일 수 있습니다.',
|
||||||
'chat.errorBoundary.sessionLabel': '세션',
|
'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.installFailed': 'Nie udało się zainstalować umiejętności',
|
||||||
'settings.skills.catalog.installSkill.toast.installed': 'Umiejętność została zainstalowana',
|
'settings.skills.catalog.installSkill.toast.installed': 'Umiejętność została zainstalowana',
|
||||||
'settings.skills.catalog.page.actions.addCatalog': 'Dodaj katalog',
|
'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.refreshTitle': 'Odśwież',
|
||||||
'settings.skills.catalog.page.actions.removeCatalog': 'Usuń katalog',
|
'settings.skills.catalog.page.actions.removeCatalog': 'Usuń katalog',
|
||||||
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Usuń katalog',
|
'settings.skills.catalog.page.actions.removeCatalogTitle': 'Usuń katalog',
|
||||||
'settings.skills.catalog.page.badge.installed': 'zainstalowano ({scope})',
|
'settings.skills.catalog.page.badge.installed': 'zainstalowano ({scope})',
|
||||||
'settings.skills.catalog.page.badge.notInstallable': 'nie można zainstalować',
|
'settings.skills.catalog.page.badge.notInstallable': 'nie można zainstalować',
|
||||||
'settings.skills.catalog.page.badge.unknown': 'nieznane',
|
'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.noSkillsDescription': 'Spróbuj innego wyszukiwania lub odśwież katalog',
|
||||||
'settings.skills.catalog.page.empty.noSkillsTitle': 'Nie znaleziono umiejętności',
|
'settings.skills.catalog.page.empty.noSkillsTitle': 'Nie znaleziono umiejętności',
|
||||||
'settings.skills.catalog.page.error.catalogTitle': 'Błąd katalogu',
|
'settings.skills.catalog.page.error.catalogTitle': 'Błąd katalogu',
|
||||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Wybierz źródło',
|
'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Wybierz źródło',
|
||||||
'settings.skills.catalog.page.foundCount': 'Znaleziono {count} umiejętności',
|
'settings.skills.catalog.page.foundCount': 'Znaleziono {count} umiejętności',
|
||||||
'settings.skills.catalog.page.loading.catalog': 'Ładowanie...',
|
'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.loading.skills': 'Ładowanie umiejętności...',
|
||||||
'settings.skills.catalog.page.mode.external': 'Zewnętrzny',
|
'settings.skills.catalog.page.mode.external': 'Zewnętrzny',
|
||||||
'settings.skills.catalog.page.mode.manual': 'Ręczny',
|
'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.removeDialog.title': 'Usuń katalog',
|
||||||
'settings.skills.catalog.page.section.sourceRepository': 'Repozytorium źródłowe',
|
'settings.skills.catalog.page.section.sourceRepository': 'Repozytorium źródłowe',
|
||||||
'settings.skills.catalog.page.title': 'Katalog umiejętności',
|
'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.install': 'Zainstaluj',
|
||||||
'settings.skills.catalog.shared.actions.installing': 'Instalowanie...',
|
'settings.skills.catalog.shared.actions.installing': 'Instalowanie...',
|
||||||
'settings.skills.catalog.shared.actions.scan': 'Skanuj',
|
'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.undo': 'Cofnięte do {preview}',
|
||||||
'chat.revert.toast.redo': 'Ponowione',
|
'chat.revert.toast.redo': 'Ponowione',
|
||||||
'chat.revert.toast.restored': 'Przywrócono wszystkie wiadomości',
|
'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.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.description': 'Interfejs czatu napotkał błąd. Może to być spowodowane tymczasowym problemem sieciowym lub uszkodzonymi danymi wiadomości.',
|
||||||
'chat.errorBoundary.sessionLabel': 'Sesja',
|
'chat.errorBoundary.sessionLabel': 'Sesja',
|
||||||
@@ -1789,11 +1792,13 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'contextUsage.aria.label': 'Użycie kontekstu',
|
'contextUsage.aria.label': 'Użycie kontekstu',
|
||||||
'contextUsage.mobile.contextLimit': 'Limit kontekstu',
|
'contextUsage.mobile.contextLimit': 'Limit kontekstu',
|
||||||
'contextUsage.mobile.outputLimit': 'Limit wyjścia',
|
'contextUsage.mobile.outputLimit': 'Limit wyjścia',
|
||||||
|
'contextUsage.mobile.cost': 'Koszt',
|
||||||
'contextUsage.mobile.title': 'Użycie kontekstu',
|
'contextUsage.mobile.title': 'Użycie kontekstu',
|
||||||
'contextUsage.mobile.usage': 'Zużycie',
|
'contextUsage.mobile.usage': 'Zużycie',
|
||||||
'contextUsage.mobile.usedTokens': 'Zużyte tokeny',
|
'contextUsage.mobile.usedTokens': 'Zużyte tokeny',
|
||||||
'contextUsage.tooltip.contextLimit': 'Limit kontekstu: {tokens}',
|
'contextUsage.tooltip.contextLimit': 'Limit kontekstu: {tokens}',
|
||||||
'contextUsage.tooltip.outputLimit': 'Limit wyjścia: {tokens}',
|
'contextUsage.tooltip.outputLimit': 'Limit wyjścia: {tokens}',
|
||||||
|
'contextUsage.tooltip.cost': 'Koszt: {cost}',
|
||||||
'contextUsage.tooltip.usedTokens': 'Zużyte tokeny: {tokens}',
|
'contextUsage.tooltip.usedTokens': 'Zużyte tokeny: {tokens}',
|
||||||
'desktopHostSwitcher.actions.add': 'Dodaj',
|
'desktopHostSwitcher.actions.add': 'Dodaj',
|
||||||
'desktopHostSwitcher.actions.addInstance': 'Dodaj instancję',
|
'desktopHostSwitcher.actions.addInstance': 'Dodaj instancję',
|
||||||
|
|||||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
|||||||
"settings.skills.catalog.page.mode.manual": "Manual",
|
"settings.skills.catalog.page.mode.manual": "Manual",
|
||||||
"settings.skills.catalog.page.mode.external": "Externo",
|
"settings.skills.catalog.page.mode.external": "Externo",
|
||||||
"settings.skills.catalog.page.title": "Catálogo de habilidades",
|
"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.section.sourceRepository": "Repositório de origem",
|
||||||
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Selecionar origem",
|
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Selecionar origem",
|
||||||
"settings.skills.catalog.page.actions.refreshTitle": "Atualizar",
|
"settings.skills.catalog.page.actions.refreshTitle": "Atualizar",
|
||||||
"settings.skills.catalog.page.actions.removeCatalogTitle": "Excluir catálogo",
|
"settings.skills.catalog.page.actions.removeCatalogTitle": "Excluir catálogo",
|
||||||
"settings.skills.catalog.page.actions.addCatalog": "Adicionar 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.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.catalog": "Carregando...",
|
||||||
"settings.skills.catalog.page.loading.skills": "Carregando habilidades...",
|
"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.foundCount": "{count} habilidade(es) encontrada(s)",
|
||||||
"settings.skills.catalog.page.error.catalogTitle": "Erro do catálogo",
|
"settings.skills.catalog.page.error.catalogTitle": "Erro do catálogo",
|
||||||
"settings.skills.catalog.page.empty.noSkillsTitle": "Nenhuma habilidade encontrada",
|
"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.installed": "instalado ({scope})",
|
||||||
"settings.skills.catalog.page.badge.notInstallable": "não instalável",
|
"settings.skills.catalog.page.badge.notInstallable": "não instalável",
|
||||||
"settings.skills.catalog.page.badge.unknown": "desconhecido",
|
"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.title": "Excluir catálogo",
|
||||||
"settings.skills.catalog.page.removeDialog.description": "Tem certeza de que deseja excluir este catálogo?",
|
"settings.skills.catalog.page.removeDialog.description": "Tem certeza de que deseja excluir este catálogo?",
|
||||||
"settings.openchamber.passkeys.title": "Chaves de acesso",
|
"settings.openchamber.passkeys.title": "Chaves de acesso",
|
||||||
|
|||||||
@@ -1602,10 +1602,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"contextUsage.mobile.usedTokens": "Tokens usados",
|
"contextUsage.mobile.usedTokens": "Tokens usados",
|
||||||
"contextUsage.mobile.contextLimit": "Limite de contexto",
|
"contextUsage.mobile.contextLimit": "Limite de contexto",
|
||||||
"contextUsage.mobile.outputLimit": "Limite de saída",
|
"contextUsage.mobile.outputLimit": "Limite de saída",
|
||||||
|
"contextUsage.mobile.cost": "Custo",
|
||||||
"contextUsage.mobile.usage": "Uso",
|
"contextUsage.mobile.usage": "Uso",
|
||||||
"contextUsage.tooltip.usedTokens": "Tokens usados: {tokens}",
|
"contextUsage.tooltip.usedTokens": "Tokens usados: {tokens}",
|
||||||
"contextUsage.tooltip.contextLimit": "Limite de contexto: {tokens}",
|
"contextUsage.tooltip.contextLimit": "Limite de contexto: {tokens}",
|
||||||
"contextUsage.tooltip.outputLimit": "Limite de saída: {tokens}",
|
"contextUsage.tooltip.outputLimit": "Limite de saída: {tokens}",
|
||||||
|
"contextUsage.tooltip.cost": "Custo: {cost}",
|
||||||
"contextSidebar.session.untitled": "Sessão sem título",
|
"contextSidebar.session.untitled": "Sessão sem título",
|
||||||
"contextSidebar.empty.openSession": "Abrir uma sessão para inspecionar o contexto.",
|
"contextSidebar.empty.openSession": "Abrir uma sessão para inspecionar o contexto.",
|
||||||
"contextSidebar.section.context": "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.undo": "Revertido para {preview}",
|
||||||
"chat.revert.toast.redo": "Refeito",
|
"chat.revert.toast.redo": "Refeito",
|
||||||
"chat.revert.toast.restored": "Todas as mensagens restauradas",
|
"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.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.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",
|
"chat.errorBoundary.sessionLabel": "Sessão",
|
||||||
|
|||||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
|||||||
"settings.skills.catalog.page.mode.manual": "Вручну",
|
"settings.skills.catalog.page.mode.manual": "Вручну",
|
||||||
"settings.skills.catalog.page.mode.external": "зовнішній",
|
"settings.skills.catalog.page.mode.external": "зовнішній",
|
||||||
"settings.skills.catalog.page.title": "Каталог навичок",
|
"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.section.sourceRepository": "Репозиторій вихідного коду",
|
||||||
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Виберіть джерело",
|
"settings.skills.catalog.page.field.selectSourcePlaceholder": "Виберіть джерело",
|
||||||
"settings.skills.catalog.page.actions.refreshTitle": "Оновити",
|
"settings.skills.catalog.page.actions.refreshTitle": "Оновити",
|
||||||
"settings.skills.catalog.page.actions.removeCatalogTitle": "Видалити каталог",
|
"settings.skills.catalog.page.actions.removeCatalogTitle": "Видалити каталог",
|
||||||
"settings.skills.catalog.page.actions.addCatalog": "Додати каталог",
|
"settings.skills.catalog.page.actions.addCatalog": "Додати каталог",
|
||||||
"settings.skills.catalog.page.actions.removeCatalog": "Видалити каталог",
|
"settings.skills.catalog.page.actions.removeCatalog": "Видалити каталог",
|
||||||
"settings.skills.catalog.page.actions.loadMoreSkills": "Завантажити додаткові навички",
|
|
||||||
"settings.skills.catalog.page.loading.catalog": "Завантаження...",
|
"settings.skills.catalog.page.loading.catalog": "Завантаження...",
|
||||||
"settings.skills.catalog.page.loading.skills": "Завантаження навичок...",
|
"settings.skills.catalog.page.loading.skills": "Завантаження навичок...",
|
||||||
"settings.skills.catalog.page.loading.more": "Завантаження...",
|
|
||||||
"settings.skills.catalog.page.foundCount": "Знайдено навички {count}",
|
"settings.skills.catalog.page.foundCount": "Знайдено навички {count}",
|
||||||
"settings.skills.catalog.page.error.catalogTitle": "Помилка каталогу",
|
"settings.skills.catalog.page.error.catalogTitle": "Помилка каталогу",
|
||||||
"settings.skills.catalog.page.empty.noSkillsTitle": "Навички не знайдено",
|
"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.installed": "встановлено ({scope})",
|
||||||
"settings.skills.catalog.page.badge.notInstallable": "не встановлюється",
|
"settings.skills.catalog.page.badge.notInstallable": "не встановлюється",
|
||||||
"settings.skills.catalog.page.badge.unknown": "невідомий",
|
"settings.skills.catalog.page.badge.unknown": "невідомий",
|
||||||
"settings.skills.catalog.page.byOwnerPrefix": "за",
|
|
||||||
"settings.skills.catalog.page.removeDialog.title": "Видалити каталог",
|
"settings.skills.catalog.page.removeDialog.title": "Видалити каталог",
|
||||||
"settings.skills.catalog.page.removeDialog.description": "Ви впевнені, що хочете видалити цей каталог?",
|
"settings.skills.catalog.page.removeDialog.description": "Ви впевнені, що хочете видалити цей каталог?",
|
||||||
"settings.openchamber.passkeys.title": "Ключі доступу",
|
"settings.openchamber.passkeys.title": "Ключі доступу",
|
||||||
|
|||||||
@@ -1602,10 +1602,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"contextUsage.mobile.usedTokens": "Використані токени",
|
"contextUsage.mobile.usedTokens": "Використані токени",
|
||||||
"contextUsage.mobile.contextLimit": "Обмеження контексту",
|
"contextUsage.mobile.contextLimit": "Обмеження контексту",
|
||||||
"contextUsage.mobile.outputLimit": "Ліміт виводу",
|
"contextUsage.mobile.outputLimit": "Ліміт виводу",
|
||||||
|
"contextUsage.mobile.cost": "Вартість",
|
||||||
"contextUsage.mobile.usage": "Використання",
|
"contextUsage.mobile.usage": "Використання",
|
||||||
"contextUsage.tooltip.usedTokens": "Використані токени: {tokens}",
|
"contextUsage.tooltip.usedTokens": "Використані токени: {tokens}",
|
||||||
"contextUsage.tooltip.contextLimit": "Обмеження контексту: {tokens}",
|
"contextUsage.tooltip.contextLimit": "Обмеження контексту: {tokens}",
|
||||||
"contextUsage.tooltip.outputLimit": "Ліміт виводу: {tokens}",
|
"contextUsage.tooltip.outputLimit": "Ліміт виводу: {tokens}",
|
||||||
|
"contextUsage.tooltip.cost": "Вартість: {cost}",
|
||||||
"contextSidebar.session.untitled": "Сесія без назви",
|
"contextSidebar.session.untitled": "Сесія без назви",
|
||||||
"contextSidebar.empty.openSession": "Відкрийте сесію, щоб перевірити контекст.",
|
"contextSidebar.empty.openSession": "Відкрийте сесію, щоб перевірити контекст.",
|
||||||
"contextSidebar.section.context": "Контекст",
|
"contextSidebar.section.context": "Контекст",
|
||||||
@@ -2400,6 +2402,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.revert.toast.undo": "Відкочено до {preview}",
|
"chat.revert.toast.undo": "Відкочено до {preview}",
|
||||||
"chat.revert.toast.redo": "Повторено",
|
"chat.revert.toast.redo": "Повторено",
|
||||||
"chat.revert.toast.restored": "Всі повідомлення відновлено",
|
"chat.revert.toast.restored": "Всі повідомлення відновлено",
|
||||||
|
"chat.toast.opencodeRestartInterrupted.title": "Чат перервано",
|
||||||
|
"chat.toast.opencodeRestartInterrupted.description": "OpenCode перезапустився, поки відповідь ще формувалася. Надішліть повідомлення, щоб продовжити.",
|
||||||
|
"chat.toast.opencodeRestartInterrupted.openSession": "Відкрити сесію",
|
||||||
"chat.errorBoundary.title": "Помилка чату",
|
"chat.errorBoundary.title": "Помилка чату",
|
||||||
"chat.errorBoundary.description": "В інтерфейсі чату сталася помилка. Причиною може бути тимчасова проблема з мережею або пошкоджені дані повідомлення.",
|
"chat.errorBoundary.description": "В інтерфейсі чату сталася помилка. Причиною може бути тимчасова проблема з мережею або пошкоджені дані повідомлення.",
|
||||||
"chat.errorBoundary.sessionLabel": "Сесія",
|
"chat.errorBoundary.sessionLabel": "Сесія",
|
||||||
|
|||||||
@@ -874,16 +874,26 @@ export const settingsDict = {
|
|||||||
'settings.skills.catalog.page.mode.manual': '手动',
|
'settings.skills.catalog.page.mode.manual': '手动',
|
||||||
'settings.skills.catalog.page.mode.external': '外部',
|
'settings.skills.catalog.page.mode.external': '外部',
|
||||||
'settings.skills.catalog.page.title': '技能目录',
|
'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.section.sourceRepository': '来源仓库',
|
||||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': '选择来源',
|
'settings.skills.catalog.page.field.selectSourcePlaceholder': '选择来源',
|
||||||
'settings.skills.catalog.page.actions.refreshTitle': '刷新',
|
'settings.skills.catalog.page.actions.refreshTitle': '刷新',
|
||||||
'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目录',
|
'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目录',
|
||||||
'settings.skills.catalog.page.actions.addCatalog': '添加目录',
|
'settings.skills.catalog.page.actions.addCatalog': '添加目录',
|
||||||
'settings.skills.catalog.page.actions.removeCatalog': '移除目录',
|
'settings.skills.catalog.page.actions.removeCatalog': '移除目录',
|
||||||
'settings.skills.catalog.page.actions.loadMoreSkills': '加载更多技能',
|
|
||||||
'settings.skills.catalog.page.loading.catalog': '加载中...',
|
'settings.skills.catalog.page.loading.catalog': '加载中...',
|
||||||
'settings.skills.catalog.page.loading.skills': '正在加载技能...',
|
'settings.skills.catalog.page.loading.skills': '正在加载技能...',
|
||||||
'settings.skills.catalog.page.loading.more': '加载中...',
|
|
||||||
'settings.skills.catalog.page.foundCount': '找到 {count} 个技能',
|
'settings.skills.catalog.page.foundCount': '找到 {count} 个技能',
|
||||||
'settings.skills.catalog.page.error.catalogTitle': '目录错误',
|
'settings.skills.catalog.page.error.catalogTitle': '目录错误',
|
||||||
'settings.skills.catalog.page.empty.noSkillsTitle': '未找到技能',
|
'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.installed': '已安装({scope})',
|
||||||
'settings.skills.catalog.page.badge.notInstallable': '不可安装',
|
'settings.skills.catalog.page.badge.notInstallable': '不可安装',
|
||||||
'settings.skills.catalog.page.badge.unknown': '未知',
|
'settings.skills.catalog.page.badge.unknown': '未知',
|
||||||
'settings.skills.catalog.page.byOwnerPrefix': '作者',
|
|
||||||
'settings.skills.catalog.page.removeDialog.title': '移除目录',
|
'settings.skills.catalog.page.removeDialog.title': '移除目录',
|
||||||
'settings.skills.catalog.page.removeDialog.description': '确定要移除此目录吗?',
|
'settings.skills.catalog.page.removeDialog.description': '确定要移除此目录吗?',
|
||||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||||
|
|||||||
@@ -1602,10 +1602,12 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'contextUsage.mobile.usedTokens': '已用 Token',
|
'contextUsage.mobile.usedTokens': '已用 Token',
|
||||||
'contextUsage.mobile.contextLimit': '上下文上限',
|
'contextUsage.mobile.contextLimit': '上下文上限',
|
||||||
'contextUsage.mobile.outputLimit': '输出上限',
|
'contextUsage.mobile.outputLimit': '输出上限',
|
||||||
|
'contextUsage.mobile.cost': '成本',
|
||||||
'contextUsage.mobile.usage': '使用率',
|
'contextUsage.mobile.usage': '使用率',
|
||||||
'contextUsage.tooltip.usedTokens': '已用 Token:{tokens}',
|
'contextUsage.tooltip.usedTokens': '已用 Token:{tokens}',
|
||||||
'contextUsage.tooltip.contextLimit': '上下文上限:{tokens}',
|
'contextUsage.tooltip.contextLimit': '上下文上限:{tokens}',
|
||||||
'contextUsage.tooltip.outputLimit': '输出上限:{tokens}',
|
'contextUsage.tooltip.outputLimit': '输出上限:{tokens}',
|
||||||
|
'contextUsage.tooltip.cost': '成本:{cost}',
|
||||||
'contextSidebar.session.untitled': '未命名会话',
|
'contextSidebar.session.untitled': '未命名会话',
|
||||||
'contextSidebar.empty.openSession': '请先打开会话以查看上下文。',
|
'contextSidebar.empty.openSession': '请先打开会话以查看上下文。',
|
||||||
'contextSidebar.section.context': '上下文',
|
'contextSidebar.section.context': '上下文',
|
||||||
@@ -2388,6 +2390,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.revert.toast.undo': '已撤回至 {preview}',
|
'chat.revert.toast.undo': '已撤回至 {preview}',
|
||||||
'chat.revert.toast.redo': '已重做',
|
'chat.revert.toast.redo': '已重做',
|
||||||
'chat.revert.toast.restored': '已恢复全部消息',
|
'chat.revert.toast.restored': '已恢复全部消息',
|
||||||
|
'chat.toast.opencodeRestartInterrupted.title': '聊天已中断',
|
||||||
|
'chat.toast.opencodeRestartInterrupted.description': 'OpenCode 在回复仍在生成时重启了。发送一条消息以继续。',
|
||||||
|
'chat.toast.opencodeRestartInterrupted.openSession': '打开会话',
|
||||||
'chat.errorBoundary.title': '聊天错误',
|
'chat.errorBoundary.title': '聊天错误',
|
||||||
'chat.errorBoundary.description': '聊天界面发生错误,可能是临时网络问题或消息数据损坏导致。',
|
'chat.errorBoundary.description': '聊天界面发生错误,可能是临时网络问题或消息数据损坏导致。',
|
||||||
'chat.errorBoundary.sessionLabel': '会话',
|
'chat.errorBoundary.sessionLabel': '会话',
|
||||||
|
|||||||
@@ -871,16 +871,26 @@ export const settingsDict = {
|
|||||||
'settings.skills.catalog.page.mode.manual': '手動',
|
'settings.skills.catalog.page.mode.manual': '手動',
|
||||||
'settings.skills.catalog.page.mode.external': '外部',
|
'settings.skills.catalog.page.mode.external': '外部',
|
||||||
'settings.skills.catalog.page.title': 'Skills 目錄',
|
'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.section.sourceRepository': '來源儲存庫',
|
||||||
'settings.skills.catalog.page.field.selectSourcePlaceholder': '選擇來源',
|
'settings.skills.catalog.page.field.selectSourcePlaceholder': '選擇來源',
|
||||||
'settings.skills.catalog.page.actions.refreshTitle': '重新整理',
|
'settings.skills.catalog.page.actions.refreshTitle': '重新整理',
|
||||||
'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目錄',
|
'settings.skills.catalog.page.actions.removeCatalogTitle': '移除目錄',
|
||||||
'settings.skills.catalog.page.actions.addCatalog': '新增目錄',
|
'settings.skills.catalog.page.actions.addCatalog': '新增目錄',
|
||||||
'settings.skills.catalog.page.actions.removeCatalog': '移除目錄',
|
'settings.skills.catalog.page.actions.removeCatalog': '移除目錄',
|
||||||
'settings.skills.catalog.page.actions.loadMoreSkills': '載入更多 Skills',
|
|
||||||
'settings.skills.catalog.page.loading.catalog': '載入中...',
|
'settings.skills.catalog.page.loading.catalog': '載入中...',
|
||||||
'settings.skills.catalog.page.loading.skills': '正在載入 skills...',
|
'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.foundCount': '找到 {count} 個 skill(s)',
|
||||||
'settings.skills.catalog.page.error.catalogTitle': '目錄錯誤',
|
'settings.skills.catalog.page.error.catalogTitle': '目錄錯誤',
|
||||||
'settings.skills.catalog.page.empty.noSkillsTitle': '找不到 skills',
|
'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.installed': '已安裝({scope})',
|
||||||
'settings.skills.catalog.page.badge.notInstallable': '不可安裝',
|
'settings.skills.catalog.page.badge.notInstallable': '不可安裝',
|
||||||
'settings.skills.catalog.page.badge.unknown': '未知',
|
'settings.skills.catalog.page.badge.unknown': '未知',
|
||||||
'settings.skills.catalog.page.byOwnerPrefix': '作者',
|
|
||||||
'settings.skills.catalog.page.removeDialog.title': '移除目錄',
|
'settings.skills.catalog.page.removeDialog.title': '移除目錄',
|
||||||
'settings.skills.catalog.page.removeDialog.description': '確定要移除此目錄嗎?',
|
'settings.skills.catalog.page.removeDialog.description': '確定要移除此目錄嗎?',
|
||||||
'settings.openchamber.passkeys.title': 'Passkeys',
|
'settings.openchamber.passkeys.title': 'Passkeys',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user