Merge pull request #2910 from openchamber/feat/third-party-integrations-dashboard-6ead

This commit is contained in:
Serhii Dziupin
2026-08-14 19:53:13 +03:00
committed by GitHub
33 changed files with 1672 additions and 9 deletions
+2
View File
@@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
- **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.
## [1.18.4] - 2026-08-14
- **Chat:** new messages now remain at the end of the conversation instead of jumping before older messages after the message ID sequence rolls over; history loading, revert, and redo follow the same chronological order.
+1
View File
@@ -83,6 +83,7 @@ const MOBILE_SETTINGS_PAGES = [
'providers',
'usage',
'voice',
'integrations',
'about',
] as const;
@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<!-- Claude AI symbol (CC0, Wikimedia Commons File:Claude_AI_symbol.svg), monochrome for theme invert -->
<path fill="currentColor" d="m19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6.3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3.9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1.6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1.8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1.4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+24 -1
View File
@@ -30,6 +30,29 @@ function ensureSpriteOnce() {
spriteInjected = true
}
/**
* Append a single missing symbol. Needed when the sprite was injected before a
* newly generated icon landed (HMR / late sprite regenerate) a one-shot inject
* would otherwise leave `<use href="#oc-…"/>` pointing at nothing.
*/
function ensureSpriteSymbol(name: IconName) {
if (typeof document === "undefined") return
ensureSpriteOnce()
if (document.getElementById(`oc-${name}`)) return
const content = iconSpriteData[name]
if (typeof content !== "string") return
const sprite = document.getElementById(SPRITE_ID)
if (!sprite) return
const symbol = document.createElementNS("http://www.w3.org/2000/svg", "symbol")
symbol.id = `oc-${name}`
symbol.setAttribute("viewBox", "0 0 24 24")
symbol.innerHTML = content
sprite.appendChild(symbol)
}
export interface IconProps extends React.ComponentPropsWithoutRef<"svg"> {
name: IconName
}
@@ -38,7 +61,7 @@ export const Icon = React.memo(({ name, className, ...rest }: IconProps) => {
// Inline sprite injection during render must run before <use> tries
// to resolve the #oc-* reference during the same commit.
if (typeof document !== "undefined") {
ensureSpriteOnce()
ensureSpriteSymbol(name)
}
return (
+4 -1
View File
@@ -51,6 +51,7 @@ export const iconSpriteData = {
"checkbox-blank-circle-fill": `<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill="currentColor"/>`,
"checkbox-circle": `<path d="M4 12C4 7.58172 7.58172 4 12 4C16.4183 4 20 7.58172 20 12C20 16.4183 16.4183 20 12 20C7.58172 20 4 16.4183 4 12ZM12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM17.4571 9.45711L16.0429 8.04289L11 13.0858L8.20711 10.2929L6.79289 11.7071L11 15.9142L17.4571 9.45711Z" fill="currentColor"/>`,
"checkbox-multiple": `<path d="M6.99979 7V3C6.99979 2.44772 7.4475 2 7.99979 2H20.9998C21.5521 2 21.9998 2.44772 21.9998 3V16C21.9998 16.5523 21.5521 17 20.9998 17H17V20.9925C17 21.5489 16.551 22 15.9925 22H3.00728C2.45086 22 2 21.5511 2 20.9925L2.00276 8.00748C2.00288 7.45107 2.4518 7 3.01025 7H6.99979ZM8.99979 7H15.9927C16.549 7 17 7.44892 17 8.00748V15H19.9998V4H8.99979V7ZM15 9H4.00255L4.00021 20H15V9ZM8.50242 18L4.96689 14.4645L6.3811 13.0503L8.50242 15.1716L12.7451 10.9289L14.1593 12.3431L8.50242 18Z" fill="currentColor"/>`,
"claude-code": `<path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z" fill="currentColor"/>`,
"clipboard": `<path d="M7 4V2H17V4H20.0066C20.5552 4 21 4.44495 21 4.9934V21.0066C21 21.5552 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5551 3 21.0066V4.9934C3 4.44476 3.44495 4 3.9934 4H7ZM7 6H5V20H19V6H17V8H7V6ZM9 4V6H15V4H9Z" fill="currentColor"/>`,
"close": `<path d="M11.9997 10.5865L16.9495 5.63672L18.3637 7.05093L13.4139 12.0007L18.3637 16.9504L16.9495 18.3646L11.9997 13.4149L7.04996 18.3646L5.63574 16.9504L10.5855 12.0007L5.63574 7.05093L7.04996 5.63672L11.9997 10.5865Z" fill="currentColor"/>`,
"close-circle": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 10.5858L14.8284 7.75736L16.2426 9.17157L13.4142 12L16.2426 14.8284L14.8284 16.2426L12 13.4142L9.17157 16.2426L7.75736 14.8284L10.5858 12L7.75736 9.17157L9.17157 7.75736L12 10.5858Z" fill="currentColor"/>`,
@@ -62,11 +63,12 @@ export const iconSpriteData = {
"code-sslash": `<path d="M24 12L18.3431 17.6569L16.9289 16.2426L21.1716 12L16.9289 7.75736L18.3431 6.34315L24 12ZM2.82843 12L7.07107 16.2426L5.65685 17.6569L0 12L5.65685 6.34315L7.07107 7.75736L2.82843 12ZM9.78845 21H7.66009L14.2116 3H16.3399L9.78845 21Z" fill="currentColor"/>`,
"collapse-vertical": `<path d="M11.9995 13.4995 16.9492 18.4493 15.535 19.8635 12.9995 17.3279 12.9995 22.9995H10.9995L10.9995 17.3279 8.46643 19.861 7.05222 18.4468 11.9995 13.4995ZM10.9995.999512 10.9995 6.67035 8.46448 4.13535 7.05026 5.54956 12 10.4995 16.9497 5.54977 15.5355 4.13555 12.9995 6.67157V.999512L10.9995.999512Z" fill="currentColor"/>`,
"command": `<path d="M10 8H14V6.5C14 4.567 15.567 3 17.5 3C19.433 3 21 4.567 21 6.5C21 8.433 19.433 10 17.5 10H16V14H17.5C19.433 14 21 15.567 21 17.5C21 19.433 19.433 21 17.5 21C15.567 21 14 19.433 14 17.5V16H10V17.5C10 19.433 8.433 21 6.5 21C4.567 21 3 19.433 3 17.5C3 15.567 4.567 14 6.5 14H8V10H6.5C4.567 10 3 8.433 3 6.5C3 4.567 4.567 3 6.5 3C8.433 3 10 4.567 10 6.5V8ZM8 8V6.5C8 5.67157 7.32843 5 6.5 5C5.67157 5 5 5.67157 5 6.5C5 7.32843 5.67157 8 6.5 8H8ZM8 16H6.5C5.67157 16 5 16.6716 5 17.5C5 18.3284 5.67157 19 6.5 19C7.32843 19 8 18.3284 8 17.5V16ZM16 8H17.5C18.3284 8 19 7.32843 19 6.5C19 5.67157 18.3284 5 17.5 5C16.6716 5 16 5.67157 16 6.5V8ZM16 16V17.5C16 18.3284 16.6716 19 17.5 19C18.3284 19 19 18.3284 19 17.5C19 16.6716 18.3284 16 17.5 16H16ZM10 10V14H14V10H10Z" fill="currentColor"/>`,
"command-code": `<path fill="currentColor" d="M5.8 5.8h4.8v4.8h-4.8Z M13.4 5.8h4.8v4.8h-4.8Z M10.6 10.6h2.8v2.8h-2.8Z M5.8 13.4h4.8v4.8h-4.8Z M13.4 13.4h4.8v4.8h-4.8Z"/>`,
"compass-3": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM16.5 7.5L14 14L7.5 16.5L10 10L16.5 7.5ZM12 13C12.5523 13 13 12.5523 13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12C11 12.5523 11.4477 13 12 13Z" fill="currentColor"/>`,
"computer": `<path d="M4 16H20V5H4V16ZM13 18V20H17V22H7V20H11V18H2.9918C2.44405 18 2 17.5511 2 16.9925V4.00748C2 3.45107 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44892 22 4.00748V16.9925C22 17.5489 21.5447 18 21.0082 18H13Z" fill="currentColor"/>`,
"contract-up-down": `<path d="M5.79285 5.20718 12 11.4143 18.2071 5.20718 16.7928 3.79297 12 8.58586 7.20706 3.79297 5.79285 5.20718ZM18.2072 18.7928 12.0001 12.5857 5.793 18.7928 7.20721 20.207 12.0001 15.4141 16.793 20.207 18.2072 18.7928Z" fill="currentColor"/>`,
"corner-down-left": `<path d="M19.0001 13.9999L19.0002 5L17.0002 4.99997L17.0001 11.9999L6.8283 12L10.778 8.05024L9.36382 6.63603L2.99986 13L9.36382 19.364L10.778 17.9497L6.82826 14L19.0001 13.9999Z" fill="currentColor"/>`,
"cursor": `<path d="M15.3873 13.4975L17.9403 20.5117L13.2418 22.2218L10.6889 15.2076L6.79004 17.6529L8.4086 1.63318L19.9457 12.8646L15.3873 13.4975ZM15.3768 19.3163L12.6618 11.8568L15.6212 11.4459L9.98201 5.9561L9.19088 13.7863L11.7221 12.1988L14.4371 19.6583L15.3768 19.3163Z" fill="currentColor"/>`,
"cursor": `<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" fill="currentColor"/>`,
"database-2": `<path d="M5 12.5C5 12.8134 5.46101 13.3584 6.53047 13.8931C7.91405 14.5849 9.87677 15 12 15C14.1232 15 16.0859 14.5849 17.4695 13.8931C18.539 13.3584 19 12.8134 19 12.5V10.3287C17.35 11.3482 14.8273 12 12 12C9.17273 12 6.64996 11.3482 5 10.3287V12.5ZM19 15.3287C17.35 16.3482 14.8273 17 12 17C9.17273 17 6.64996 16.3482 5 15.3287V17.5C5 17.8134 5.46101 18.3584 6.53047 18.8931C7.91405 19.5849 9.87677 20 12 20C14.1232 20 16.0859 19.5849 17.4695 18.8931C18.539 18.3584 19 17.8134 19 17.5V15.3287ZM3 17.5V7.5C3 5.01472 7.02944 3 12 3C16.9706 3 21 5.01472 21 7.5V17.5C21 19.9853 16.9706 22 12 22C7.02944 22 3 19.9853 3 17.5ZM12 10C14.1232 10 16.0859 9.58492 17.4695 8.89313C18.539 8.3584 19 7.81342 19 7.5C19 7.18658 18.539 6.6416 17.4695 6.10687C16.0859 5.41508 14.1232 5 12 5C9.87677 5 7.91405 5.41508 6.53047 6.10687C5.46101 6.6416 5 7.18658 5 7.5C5 7.81342 5.46101 8.3584 6.53047 8.89313C7.91405 9.58492 9.87677 10 12 10Z" fill="currentColor"/>`,
"delete-bin": `<path d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM18 8H6V20H18V8ZM9 11H11V17H9V11ZM13 11H15V17H13V11ZM9 4V6H15V4H9Z" fill="currentColor"/>`,
"discord-fill": `<path d="M19.3034 5.33716C17.9344 4.71103 16.4805 4.2547 14.9629 4C14.7719 4.32899 14.5596 4.77471 14.411 5.12492C12.7969 4.89144 11.1944 4.89144 9.60255 5.12492C9.45397 4.77471 9.2311 4.32899 9.05068 4C7.52251 4.2547 6.06861 4.71103 4.70915 5.33716C1.96053 9.39111 1.21766 13.3495 1.5891 17.2549C3.41443 18.5815 5.17612 19.388 6.90701 19.9187C7.33151 19.3456 7.71356 18.73 8.04255 18.0827C7.41641 17.8492 6.82211 17.5627 6.24904 17.2231C6.39762 17.117 6.5462 17.0003 6.68416 16.8835C10.1438 18.4648 13.8911 18.4648 17.3082 16.8835C17.4568 17.0003 17.5948 17.117 17.7434 17.2231C17.1703 17.5627 16.576 17.8492 15.9499 18.0827C16.2789 18.73 16.6609 19.3456 17.0854 19.9187C18.8152 19.388 20.5875 18.5815 22.4033 17.2549C22.8596 12.7341 21.6806 8.80747 19.3034 5.33716ZM8.5201 14.8459C7.48007 14.8459 6.63107 13.9014 6.63107 12.7447C6.63107 11.5879 7.45884 10.6434 8.5201 10.6434C9.57071 10.6434 10.4303 11.5879 10.4091 12.7447C10.4091 13.9014 9.57071 14.8459 8.5201 14.8459ZM15.4936 14.8459C14.4535 14.8459 13.6034 13.9014 13.6034 12.7447C13.6034 11.5879 14.4323 10.6434 15.4936 10.6434C16.5442 10.6434 17.4038 11.5879 17.3825 12.7447C17.3825 13.9014 16.5548 14.8459 15.4936 14.8459Z" fill="currentColor"/>`,
@@ -225,6 +227,7 @@ export const iconSpriteData = {
"target": `<path d="M12 1.99999C12.5523 1.99999 13 2.4477 13 2.99999C12.9999 3.55224 12.5522 3.99999 12 3.99999C7.58172 3.99999 4 7.58171 4 12C4.00004 16.4182 7.58174 20 12 20C16.4182 20 19.9999 16.4182 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C21.9999 17.5228 17.5228 22 12 22C6.47717 22 2.00004 17.5228 2 12C2 6.47714 6.47715 1.99999 12 1.99999ZM12 5.99999C12.5523 5.99999 13 6.4477 13 6.99999C12.9999 7.55224 12.5522 7.99999 12 7.99999C9.79085 7.99999 7.99999 9.79085 7.99999 12C8.00004 14.2091 9.79088 16 12 16C14.2091 16 15.9999 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C17.9999 15.3137 15.3137 18 12 18C8.68631 18 6.00004 15.3137 6 12C6 8.68628 8.68629 5.99999 12 5.99999ZM17.6562 2.10057C18.0468 1.71005 18.6807 1.71005 19.0713 2.10057C19.4614 2.49105 19.4615 3.12419 19.0713 3.51463L18.3633 4.22069L18.3642 4.22167C17.9737 4.61219 17.9737 5.2452 18.3642 5.63573C18.7548 6.02612 19.3878 6.02621 19.7783 5.63573L20.4853 4.9287C20.8759 4.53839 21.5089 4.53826 21.8994 4.9287C22.2899 5.31915 22.2897 5.95222 21.8994 6.34276L19.7783 8.46483C19.5909 8.65223 19.3363 8.75671 19.0713 8.75682H16.6572L12.707 12.707C12.3165 13.0974 11.6834 13.0974 11.293 12.707C10.9025 12.3165 10.9026 11.6835 11.293 11.293L15.2422 7.34374V4.9287C15.2422 4.66356 15.3477 4.40916 15.5351 4.22167L17.6562 2.10057Z" fill="currentColor"/>`,
"target-fill": `<path d="M12 2C12.5523 2 13 2.44772 13 3C13 3.55228 12.5523 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 6C12.5523 6 13 6.44772 13 7C13 7.55228 12.5523 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12C6 8.68629 8.68629 6 12 6ZM18.5713 2.10059C18.8474 2.1006 19.0712 2.32449 19.0713 2.60059V4.42969C19.0716 4.70553 19.2954 4.92866 19.5713 4.92871H21.3994C21.6754 4.92871 21.8992 5.15275 21.8994 5.42871V6.34375L20.0107 8.23242C19.6358 8.60719 19.1268 8.81824 18.5967 8.81836H16.5967L12.707 12.707C12.3165 13.0974 11.6835 13.0975 11.293 12.707C10.9027 12.3165 10.9026 11.6834 11.293 11.293L15.1826 7.4043V5.4043C15.1826 4.87411 15.3928 4.36526 15.7676 3.99023L17.6572 2.10059H18.5713Z" fill="currentColor"/>`,
"task": `<path d="M19 4H5V20H19V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H19.9997C20.5519 2 20.9996 2.44772 20.9997 3L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11.2929 13.1213L15.5355 8.87868L16.9497 10.2929L11.2929 15.9497L7.40381 12.0607L8.81802 10.6464L11.2929 13.1213Z" fill="currentColor"/>`,
"telegram-fill": `<path d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM12.3584 9.38246C11.3857 9.78702 9.4418 10.6244 6.5266 11.8945C6.05321 12.0827 5.80524 12.2669 5.78266 12.4469C5.74451 12.7513 6.12561 12.8711 6.64458 13.0343C6.71517 13.0565 6.78832 13.0795 6.8633 13.1039C7.37388 13.2698 8.06071 13.464 8.41776 13.4717C8.74164 13.4787 9.10313 13.3452 9.50222 13.0711C12.226 11.2325 13.632 10.3032 13.7203 10.2832C13.7826 10.269 13.8689 10.2513 13.9273 10.3032C13.9858 10.3552 13.98 10.4536 13.9739 10.48C13.9361 10.641 12.4401 12.0318 11.666 12.7515C11.4351 12.9661 11.2101 13.1853 10.9833 13.4039C10.509 13.8611 10.1533 14.204 11.003 14.764C11.8644 15.3317 12.7323 15.8982 13.5724 16.4971C13.9867 16.7925 14.359 17.0579 14.8188 17.0156C15.0861 16.991 15.3621 16.7397 15.5022 15.9903C15.8335 14.2193 16.4847 10.3821 16.6352 8.80083C16.6484 8.6623 16.6318 8.485 16.6185 8.40717C16.6052 8.32934 16.5773 8.21844 16.4762 8.13635C16.3563 8.03913 16.1714 8.01863 16.0887 8.02009C15.7125 8.02672 15.1355 8.22737 12.3584 9.38246Z" fill="currentColor"/>`,
"terminal": `<path d="M10.9999 12L3.92886 19.0711L2.51465 17.6569L8.1715 12L2.51465 6.34317L3.92886 4.92896L10.9999 12ZM10.9999 19H20.9999V21H10.9999V19Z" fill="currentColor"/>`,
"terminal-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM12 15H18V17H12V15ZM8.66685 12L5.83842 9.17157L7.25264 7.75736L11.4953 12L7.25264 16.2426L5.83842 14.8284L8.66685 12Z" fill="currentColor"/>`,
"terminal-window": `<path d="M20 9V5H4V9H20ZM20 11H4V19H20V11ZM3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM5 12H8V17H5V12ZM5 6H7V8H5V6ZM9 6H11V8H9V6Z" fill="currentColor"/>`,
@@ -0,0 +1,74 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import type { IconName } from '@/components/icon/icons';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { cn } from '@/lib/utils';
type ComingSoonMessenger = {
id: 'discord' | 'telegram';
icon: IconName;
brandClassName: string;
nameKey: I18nKey;
descriptionKey: I18nKey;
};
const COMING_SOON_MESSENGERS: readonly ComingSoonMessenger[] = [
{
id: 'discord',
icon: 'discord-fill',
brandClassName: 'text-[#5865F2]',
nameKey: 'settings.integrations.messengers.discord.name',
descriptionKey: 'settings.integrations.messengers.discord.description',
},
{
id: 'telegram',
icon: 'telegram-fill',
brandClassName: 'text-[#2AABEE]',
nameKey: 'settings.integrations.messengers.telegram.name',
descriptionKey: 'settings.integrations.messengers.telegram.description',
},
] as const;
/**
* Non-interactive Discord/Telegram placeholders same card chrome as live
* integrations, greyed out, with a Coming soon badge and no expandable body.
*/
export const ComingSoonMessengersSection: React.FC = () => {
const { t } = useI18n();
return (
<SettingsSection
title={t('settings.integrations.messengers.title')}
info={t('settings.integrations.messengers.info')}
divider={false}
settingsItem="integrations.messengers"
contentClassName="space-y-3"
>
{COMING_SOON_MESSENGERS.map((messenger) => (
<div
key={messenger.id}
data-settings-item={`integrations.messengers.${messenger.id}`}
aria-disabled="true"
className={cn(
'flex min-w-0 items-center gap-3 rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-4 py-3',
'pointer-events-none opacity-60',
)}
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
<Icon name={messenger.icon} className={cn('size-5', messenger.brandClassName)} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">{t(messenger.nameKey)}</div>
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
{t(messenger.descriptionKey)}
</p>
</div>
<span className="max-w-36 shrink-0 truncate rounded-full bg-[var(--surface-muted)] px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{t('settings.common.state.comingSoon')}
</span>
</div>
))}
</SettingsSection>
);
};
@@ -0,0 +1,31 @@
import React from 'react';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { useI18n } from '@/lib/i18n';
import { ComingSoonMessengersSection } from './ComingSoonMessengersSection';
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
interface IntegrationsPageProps {
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
onOpenPluginManager: () => void;
}
export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
onOpenProviderSetup,
onOpenPluginManager,
}) => {
const { t } = useI18n();
return (
<SettingsPageLayout
title={t('settings.page.integrations.title')}
description={t('settings.page.integrations.description')}
showSaveStatus={false}
>
<ComingSoonMessengersSection />
<ThirdPartyIntegrationsSection
onOpenProviderSetup={onOpenProviderSetup}
onOpenPluginManager={onOpenPluginManager}
/>
</SettingsPageLayout>
);
};
@@ -0,0 +1,439 @@
import React from 'react';
import { useShallow } from 'zustand/react/shallow';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible';
import { useI18n } from '@/lib/i18n';
import { openExternalUrl } from '@/lib/url';
import { cn } from '@/lib/utils';
import {
usePluginsStore,
type PluginMutationResult,
} from '@/stores/usePluginsStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import {
getCatalogPluginPrimaryAction,
getCatalogPluginPresentation,
getCatalogPluginState,
getLatestNpmSpec,
THIRD_PARTY_PLUGINS,
type ThirdPartyPluginDefinition,
} from './thirdPartyPlugins';
type PendingAction = 'install' | 'update' | 'setup' | 'remove';
type RemoveTarget = ThirdPartyPluginDefinition | null;
interface ThirdPartyIntegrationsSectionProps {
onOpenProviderSetup: (providerId: string) => Promise<boolean>;
onOpenPluginManager: () => void;
}
const requiresRestart = (result: PluginMutationResult): boolean =>
result.restartDeferred === true
|| result.requiresManualRestart === true
|| result.reloadFailed === true;
export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSectionProps> = ({
onOpenProviderSetup,
onOpenPluginManager,
}) => {
const { t } = useI18n();
const {
entries,
registryInfo,
loadPlugins,
loadRegistryInfo,
createEntry,
updateEntry,
deleteEntry,
} = usePluginsStore(
useShallow((state) => ({
entries: state.entries,
registryInfo: state.registryInfo,
loadPlugins: state.loadPlugins,
loadRegistryInfo: state.loadRegistryInfo,
createEntry: state.createEntry,
updateEntry: state.updateEntry,
deleteEntry: state.deleteEntry,
})),
);
const [registryLoadFailed, setRegistryLoadFailed] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<{
pluginId: string;
action: PendingAction;
} | null>(null);
const [restartRequiredIds, setRestartRequiredIds] = React.useState<ReadonlySet<string>>(
() => new Set(),
);
const [providerUnavailableIds, setProviderUnavailableIds] = React.useState<ReadonlySet<string>>(
() => new Set(),
);
const [removeTarget, setRemoveTarget] = React.useState<RemoveTarget>(null);
const [openPluginIds, setOpenPluginIds] = React.useState<ReadonlySet<string>>(() => new Set());
const refresh = React.useCallback(async () => {
const pluginsLoaded = await loadPlugins({ force: true });
if (!pluginsLoaded) {
setRegistryLoadFailed(true);
return;
}
const latestEntries = usePluginsStore.getState().entries;
const specs = new Set(THIRD_PARTY_PLUGINS.map((plugin) => plugin.packageName));
for (const entry of latestEntries) {
if (THIRD_PARTY_PLUGINS.some((plugin) => entry.spec === plugin.packageName || entry.spec.startsWith(`${plugin.packageName}@`))) {
specs.add(entry.spec);
}
}
const registryLoaded = await loadRegistryInfo({ specs: [...specs], force: true });
setRegistryLoadFailed(!registryLoaded);
}, [loadPlugins, loadRegistryInfo]);
React.useEffect(() => {
void refresh();
}, [refresh]);
const pendingPluginRestartCount = usePendingOpenCodeRestartStore(
(state) => state.changes.filter((change) => change.scope === 'plugins').length,
);
const isApplyingRestart = usePendingOpenCodeRestartStore((state) => state.isApplying);
const previousPluginRestartCountRef = React.useRef(pendingPluginRestartCount);
// When deferred plugin restarts are applied (pending plugins scope clears), drop
// local restart/unavailable flags and reload so statuses update immediately.
React.useEffect(() => {
const previousCount = previousPluginRestartCountRef.current;
previousPluginRestartCountRef.current = pendingPluginRestartCount;
if (isApplyingRestart) {
return;
}
if (previousCount <= 0 || pendingPluginRestartCount > 0) {
return;
}
setRestartRequiredIds(new Set());
setProviderUnavailableIds(new Set());
void refresh();
}, [isApplyingRestart, pendingPluginRestartCount, refresh]);
const setRestartRequired = React.useCallback((pluginId: string, required: boolean) => {
setRestartRequiredIds((current) => {
const next = new Set(current);
if (required) next.add(pluginId);
else next.delete(pluginId);
return next;
});
}, []);
const setProviderUnavailable = React.useCallback((pluginId: string, unavailable: boolean) => {
setProviderUnavailableIds((current) => {
const next = new Set(current);
if (unavailable) next.add(pluginId);
else next.delete(pluginId);
return next;
});
}, []);
const runMutation = React.useCallback(async (
plugin: ThirdPartyPluginDefinition,
action: Exclude<PendingAction, 'setup'>,
run: () => Promise<PluginMutationResult>,
) => {
setPendingAction({ pluginId: plugin.id, action });
try {
const result = await run();
if (!result.ok) {
toast.error(t('settings.integrations.thirdParty.toast.actionFailed'));
return;
}
setProviderUnavailable(plugin.id, false);
const restartNeeded = requiresRestart(result);
setRestartRequired(plugin.id, restartNeeded);
const toastOptions = restartNeeded
? { description: t('settings.integrations.thirdParty.toast.restartRequired') }
: undefined;
if (action === 'install') {
toast.success(t('settings.integrations.thirdParty.toast.installed', { name: t(plugin.nameKey) }), toastOptions);
} else if (action === 'update') {
toast.success(t('settings.integrations.thirdParty.toast.updated', { name: t(plugin.nameKey) }), toastOptions);
} else {
toast.success(t('settings.integrations.thirdParty.toast.removed', { name: t(plugin.nameKey) }), toastOptions);
}
await refresh();
} finally {
setPendingAction(null);
}
}, [refresh, setProviderUnavailable, setRestartRequired, t]);
const handlePrimaryAction = React.useCallback(async (plugin: ThirdPartyPluginDefinition) => {
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
const action = getCatalogPluginPrimaryAction(state, plugin.packageName);
if (action === 'manage') {
onOpenPluginManager();
return;
}
if (action === 'setup') {
setPendingAction({ pluginId: plugin.id, action });
try {
const opened = await onOpenProviderSetup(plugin.providerId);
setProviderUnavailable(plugin.id, !opened);
if (!opened) {
toast.error(t('settings.integrations.thirdParty.toast.providerUnavailable'));
}
} finally {
setPendingAction(null);
}
return;
}
const latestSpec = getLatestNpmSpec(plugin.packageName, state.registry);
if (!latestSpec) {
setRegistryLoadFailed(true);
return;
}
if (action === 'install') {
await runMutation(plugin, 'install', () => createEntry({ spec: latestSpec, scope: 'user' }));
return;
}
if (state.userEntry) {
await runMutation(plugin, 'update', () => updateEntry(state.userEntry!.id, { spec: latestSpec }));
}
}, [createEntry, entries, onOpenPluginManager, onOpenProviderSetup, registryInfo, runMutation, setProviderUnavailable, t, updateEntry]);
const handleRemove = React.useCallback(async () => {
const plugin = removeTarget;
if (!plugin) return;
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
if (!state.userEntry || state.userEntryIsAmbiguous) {
setRemoveTarget(null);
onOpenPluginManager();
return;
}
setRemoveTarget(null);
await runMutation(plugin, 'remove', () => deleteEntry(state.userEntry!.id));
}, [deleteEntry, entries, onOpenPluginManager, registryInfo, removeTarget, runMutation]);
const setPluginOpen = React.useCallback((pluginId: string, open: boolean) => {
setOpenPluginIds((current) => {
const next = new Set(current);
if (open) next.add(pluginId);
else next.delete(pluginId);
return next;
});
}, []);
const renderPlugin = (plugin: ThirdPartyPluginDefinition) => {
const state = getCatalogPluginState(entries, plugin.packageName, registryInfo);
const primaryAction = getCatalogPluginPrimaryAction(state, plugin.packageName);
const latestSpec = getLatestNpmSpec(plugin.packageName, state.registry);
const isPending = pendingAction?.pluginId === plugin.id;
const isRestartRequired = restartRequiredIds.has(plugin.id);
const isProviderUnavailable = providerUnavailableIds.has(plugin.id);
const registryUnavailable = registryLoadFailed || state.registry?.kind === 'npm-network';
const actionDisabled = isPending
|| isRestartRequired
|| ((primaryAction === 'install' || primaryAction === 'update') && (registryUnavailable || !latestSpec));
const presentation = getCatalogPluginPresentation(state, {
registryUnavailable,
restartRequired: isRestartRequired,
providerUnavailable: isProviderUnavailable,
});
let status: string;
switch (presentation.status) {
case 'installed-version':
status = presentation.latestVersion
? t('settings.integrations.thirdParty.status.installedVersion', {
version: presentation.latestVersion,
})
: t('settings.integrations.thirdParty.status.installed');
break;
case 'update-available':
status = presentation.latestVersion
? t('settings.integrations.thirdParty.status.updateAvailable', {
version: presentation.latestVersion,
})
: t('settings.integrations.thirdParty.status.unpinned');
break;
case 'not-installed':
status = t('settings.integrations.thirdParty.status.notInstalled');
break;
case 'installed':
status = t('settings.integrations.thirdParty.status.installed');
break;
case 'unpinned':
status = t('settings.integrations.thirdParty.status.unpinned');
break;
case 'ambiguous':
status = t('settings.integrations.thirdParty.status.ambiguous');
break;
case 'restart-required':
status = t('settings.integrations.thirdParty.status.restartRequired');
break;
case 'registry-unavailable':
status = t('settings.integrations.thirdParty.status.registryUnavailable');
break;
case 'provider-unavailable':
status = t('settings.integrations.thirdParty.status.providerUnavailable');
break;
}
const statusClassName = presentation.status === 'installed-version'
? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
: presentation.status === 'update-available'
|| presentation.status === 'ambiguous'
|| presentation.status === 'restart-required'
|| presentation.status === 'registry-unavailable'
|| presentation.status === 'provider-unavailable'
? 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]'
: 'bg-[var(--surface-muted)] text-muted-foreground';
const primaryLabel = {
install: t('settings.integrations.thirdParty.actions.install'),
update: t('settings.integrations.thirdParty.actions.update'),
setup: t('settings.integrations.thirdParty.actions.setup'),
manage: t('settings.integrations.thirdParty.actions.managePlugins'),
}[primaryAction];
const open = openPluginIds.has(plugin.id);
return (
<Collapsible
key={plugin.id}
open={open}
onOpenChange={(nextOpen) => setPluginOpen(plugin.id, nextOpen)}
>
<div
data-settings-item={`integrations.third-party.${plugin.id}`}
className="overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]"
>
<button
type="button"
aria-expanded={open}
onClick={() => setPluginOpen(plugin.id, !open)}
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
<Icon name={plugin.icon} className={cn('size-5', plugin.brandClassName)} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">{t(plugin.nameKey)}</div>
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
{t(plugin.descriptionKey)}
</p>
</div>
<span
aria-live="polite"
className={cn(
'max-w-36 shrink-0 truncate rounded-full px-2 py-0.5 text-[10px] font-medium',
statusClassName,
)}
>
{status}
</span>
<Icon
name={open ? 'arrow-up-s' : 'arrow-down-s'}
className="size-4 shrink-0 text-muted-foreground"
/>
</button>
<CollapsibleContent className="border-t border-[var(--interactive-border)] px-4 py-4">
<div className="space-y-3">
{state.projectEntries.length > 0 ? (
<p className="text-xs text-muted-foreground">
{t('settings.integrations.thirdParty.status.projectInstalled')}
</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant={primaryAction === 'manage' ? 'outline' : 'default'}
onClick={() => void handlePrimaryAction(plugin)}
disabled={actionDisabled}
>
{isPending ? (
<Icon name="loader-4" className="size-3.5 animate-spin" />
) : primaryAction === 'setup' ? (
<Icon name="plug-2" className="size-3.5" />
) : null}
{primaryLabel}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => void openExternalUrl(plugin.homepage)}
>
<Icon name="external-link" className="size-3.5" />
{t('settings.integrations.thirdParty.actions.docs')}
</Button>
{state.userEntry && !state.userEntryIsAmbiguous ? (
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => setRemoveTarget(plugin)}
disabled={isPending}
>
<Icon name="delete-bin" className="size-3.5" />
{t('settings.integrations.thirdParty.actions.remove')}
</Button>
) : null}
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
};
return (
<>
<SettingsSection
title={t('settings.integrations.thirdParty.title')}
info={t('settings.integrations.thirdParty.info')}
settingsItem="integrations.third-party"
contentClassName="space-y-3"
>
{THIRD_PARTY_PLUGINS.map(renderPlugin)}
</SettingsSection>
<Dialog open={removeTarget !== null} onOpenChange={(open) => !open && setRemoveTarget(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('settings.integrations.thirdParty.dialog.remove.title')}</DialogTitle>
<DialogDescription>
{t('settings.integrations.thirdParty.dialog.remove.description', {
name: removeTarget ? t(removeTarget.nameKey) : '',
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" size="sm" variant="ghost" onClick={() => setRemoveTarget(null)}>
{t('settings.common.actions.cancel')}
</Button>
<Button type="button" size="sm" variant="destructive" onClick={() => void handleRemove()}>
{t('settings.integrations.thirdParty.actions.remove')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -0,0 +1,218 @@
import { describe, expect, test } from 'bun:test';
import type { PluginEntry, RegistryResult } from '@/stores/usePluginsStore';
import * as thirdPartyCatalog from './thirdPartyPlugins';
import {
getCatalogPluginState,
getCatalogPluginPrimaryAction,
getLatestNpmSpec,
specMatchesPackage,
} from './thirdPartyPlugins';
type CatalogPresentationStatus =
| 'not-installed'
| 'installed'
| 'installed-version'
| 'update-available'
| 'unpinned'
| 'ambiguous'
| 'restart-required'
| 'registry-unavailable'
| 'provider-unavailable';
type GetCatalogPluginPresentation = (
state: ReturnType<typeof getCatalogPluginState>,
options?: {
registryUnavailable?: boolean;
restartRequired?: boolean;
providerUnavailable?: boolean;
},
) => {
status: CatalogPresentationStatus;
latestVersion: string | null;
};
const getCatalogPluginPresentation = (
thirdPartyCatalog as unknown as {
getCatalogPluginPresentation?: GetCatalogPluginPresentation;
}
).getCatalogPluginPresentation;
const claudePackage = '@openchamber/opencode-claude';
const entry = (spec: string, scope: PluginEntry['scope'] = 'user'): PluginEntry => ({
id: `config:${scope}:${spec}`,
spec,
scope,
kind: 'config',
parsedKind: 'npm',
});
const registry = (spec: string, currentVersion: string | null, latestVersion = '0.7.0'): RegistryResult => ({
kind: 'npm-ok',
spec,
name: claudePackage,
currentVersion,
latestVersion,
versions: ['0.6.0', latestVersion],
hasUpdate: currentVersion !== null && currentVersion !== latestVersion,
});
describe('third-party plugin catalog helpers', () => {
test('derives compact-card status with explicit transient-state priority', () => {
expect(typeof getCatalogPluginPresentation).toBe('function');
if (!getCatalogPluginPresentation) return;
const notInstalled = getCatalogPluginState([], claudePackage, {});
expect(getCatalogPluginPresentation(notInstalled)).toEqual({
status: 'not-installed',
latestVersion: null,
});
const current = getCatalogPluginState(
[entry(`${claudePackage}@0.7.0`)],
claudePackage,
{ [`${claudePackage}@0.7.0`]: registry(`${claudePackage}@0.7.0`, '0.7.0') },
);
expect(getCatalogPluginPresentation(current)).toEqual({
status: 'installed-version',
latestVersion: '0.7.0',
});
const outdated = getCatalogPluginState(
[entry(`${claudePackage}@0.6.0`)],
claudePackage,
{ [`${claudePackage}@0.6.0`]: registry(`${claudePackage}@0.6.0`, '0.6.0') },
);
expect(getCatalogPluginPresentation(outdated)).toEqual({
status: 'update-available',
latestVersion: '0.7.0',
});
expect(getCatalogPluginPresentation(outdated, { registryUnavailable: true })).toEqual({
status: 'registry-unavailable',
latestVersion: '0.7.0',
});
expect(getCatalogPluginPresentation(outdated, { providerUnavailable: true })).toEqual({
status: 'provider-unavailable',
latestVersion: '0.7.0',
});
expect(getCatalogPluginPresentation(outdated, {
providerUnavailable: true,
restartRequired: true,
})).toEqual({
status: 'restart-required',
latestVersion: '0.7.0',
});
const ambiguous = getCatalogPluginState(
[entry(claudePackage), entry(`${claudePackage}@0.6.0`)],
claudePackage,
{},
);
expect(getCatalogPluginPresentation(ambiguous)).toEqual({
status: 'ambiguous',
latestVersion: null,
});
});
test('matches only a package or its versioned spec', () => {
expect(specMatchesPackage(claudePackage, claudePackage)).toBe(true);
expect(specMatchesPackage(`${claudePackage}@0.6.0`, claudePackage)).toBe(true);
expect(specMatchesPackage('@openchamber/opencode-claude-extra@0.6.0', claudePackage)).toBe(false);
});
test('points catalog plugins at the OpenChamber GitHub and npm packages', () => {
expect(thirdPartyCatalog.THIRD_PARTY_PLUGINS.map((plugin) => ({
id: plugin.id,
packageName: plugin.packageName,
homepage: plugin.homepage,
}))).toEqual([
{
id: 'opencode-claude',
packageName: '@openchamber/opencode-claude',
homepage: 'https://github.com/openchamber/opencode-claude',
},
{
id: 'opencode-commandcode',
packageName: '@openchamber/opencode-commandcode',
homepage: 'https://github.com/openchamber/opencode-commandcode',
},
{
id: 'opencode-cursor-oauth',
packageName: '@openchamber/opencode-cursor',
homepage: 'https://github.com/openchamber/opencode-cursor',
},
]);
});
test('uses the configured user entry and its registry result', () => {
const installed = entry(`${claudePackage}@0.6.0`);
const state = getCatalogPluginState(
[installed],
claudePackage,
{ [installed.spec]: registry(installed.spec, '0.6.0') },
);
expect(state.userEntry).toEqual(installed);
expect(state.userEntryIsAmbiguous).toBe(false);
expect(state.projectEntries).toEqual([]);
expect(state.registry).toEqual(registry(installed.spec, '0.6.0'));
});
test('does not choose an entry when multiple user specs would make a mutation ambiguous', () => {
const state = getCatalogPluginState(
[entry(claudePackage), entry(`${claudePackage}@0.6.0`), entry(claudePackage, 'project')],
claudePackage,
{},
);
expect(state.userEntry).toBeNull();
expect(state.userEntryIsAmbiguous).toBe(true);
expect(state.projectEntries).toHaveLength(1);
});
test('returns an exact latest spec only from a valid npm registry result', () => {
expect(getLatestNpmSpec(claudePackage, registry(claudePackage, null))).toBe(`${claudePackage}@0.7.0`);
expect(getLatestNpmSpec(claudePackage, {
kind: 'npm-network',
spec: claudePackage,
error: 'offline',
})).toBeNull();
});
test('chooses an update for a bare or outdated user-wide entry', () => {
const bare = getCatalogPluginState(
[entry(claudePackage)],
claudePackage,
{ [claudePackage]: registry(claudePackage, null) },
);
const outdated = getCatalogPluginState(
[entry(`${claudePackage}@0.6.0`)],
claudePackage,
{ [`${claudePackage}@0.6.0`]: registry(`${claudePackage}@0.6.0`, '0.6.0') },
);
expect(getCatalogPluginPrimaryAction(bare, claudePackage)).toBe('update');
expect(getCatalogPluginPrimaryAction(outdated, claudePackage)).toBe('update');
});
test('keeps setup as the primary action once the exact latest spec is installed', () => {
const installed = entry(`${claudePackage}@0.7.0`);
const state = getCatalogPluginState(
[installed],
claudePackage,
{ [installed.spec]: registry(installed.spec, '0.7.0') },
);
expect(getCatalogPluginPrimaryAction(state, claudePackage)).toBe('setup');
});
test('sends ambiguous entries to manual plugin management', () => {
const state = getCatalogPluginState(
[entry(claudePackage), entry(`${claudePackage}@0.6.0`)],
claudePackage,
{},
);
expect(getCatalogPluginPrimaryAction(state, claudePackage)).toBe('manage');
});
});
@@ -0,0 +1,166 @@
import type { IconName } from '@/components/icon/icons';
import type { I18nKey } from '@/lib/i18n';
import type { PluginEntry, RegistryResult } from '@/stores/usePluginsStore';
export interface ThirdPartyPluginDefinition {
id: string;
packageName: string;
providerId: string;
icon: IconName;
/** Brand mark tint (e.g. Claude orange); neutral marks use text-foreground. */
brandClassName: string;
nameKey: I18nKey;
descriptionKey: I18nKey;
homepage: string;
}
export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [
{
id: 'opencode-claude',
packageName: '@openchamber/opencode-claude',
providerId: 'claude-code',
icon: 'claude-code',
brandClassName: 'text-[#D97757]',
nameKey: 'settings.integrations.thirdParty.opencodeClaude.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description',
homepage: 'https://github.com/openchamber/opencode-claude',
},
{
id: 'opencode-commandcode',
packageName: '@openchamber/opencode-commandcode',
providerId: 'command-code',
icon: 'command-code',
brandClassName: 'text-foreground',
nameKey: 'settings.integrations.thirdParty.opencodeCommandcode.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeCommandcode.description',
homepage: 'https://github.com/openchamber/opencode-commandcode',
},
{
id: 'opencode-cursor-oauth',
packageName: '@openchamber/opencode-cursor',
providerId: 'cursor',
icon: 'cursor',
brandClassName: 'text-foreground',
nameKey: 'settings.integrations.thirdParty.opencodeCursorOauth.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeCursorOauth.description',
homepage: 'https://github.com/openchamber/opencode-cursor',
},
] as const;
export interface CatalogPluginState {
userEntry: PluginEntry | null;
userEntryIsAmbiguous: boolean;
projectEntries: PluginEntry[];
registry: RegistryResult | null;
}
export type CatalogPluginPrimaryAction = 'install' | 'update' | 'setup' | 'manage';
type CatalogPluginPresentationStatus =
| 'not-installed'
| 'installed'
| 'installed-version'
| 'update-available'
| 'unpinned'
| 'ambiguous'
| 'restart-required'
| 'registry-unavailable'
| 'provider-unavailable';
interface CatalogPluginPresentationOptions {
registryUnavailable?: boolean;
restartRequired?: boolean;
providerUnavailable?: boolean;
}
interface CatalogPluginPresentation {
status: CatalogPluginPresentationStatus;
latestVersion: string | null;
}
export const specMatchesPackage = (spec: string, packageName: string): boolean =>
spec === packageName || spec.startsWith(`${packageName}@`);
export function getCatalogPluginState(
entries: PluginEntry[],
packageName: string,
registryInfo: Record<string, RegistryResult>,
): CatalogPluginState {
const matchingEntries = entries.filter((entry) => specMatchesPackage(entry.spec, packageName));
const userEntries = matchingEntries.filter((entry) => entry.scope === 'user');
const projectEntries = matchingEntries.filter((entry) => entry.scope === 'project');
const userEntry = userEntries.length === 1 ? userEntries[0] : null;
const registry = registryInfo[userEntry?.spec ?? packageName] ?? registryInfo[packageName] ?? null;
return {
userEntry,
userEntryIsAmbiguous: userEntries.length > 1,
projectEntries,
registry,
};
}
export function getLatestNpmSpec(
packageName: string,
registry: RegistryResult | null | undefined,
): string | null {
if (registry?.kind !== 'npm-ok' || registry.name !== packageName || !registry.latestVersion) {
return null;
}
return `${packageName}@${registry.latestVersion}`;
}
export function getCatalogPluginPrimaryAction(
state: CatalogPluginState,
packageName: string,
): CatalogPluginPrimaryAction {
if (state.userEntryIsAmbiguous) {
return 'manage';
}
if (!state.userEntry) {
return 'install';
}
const latestSpec = getLatestNpmSpec(packageName, state.registry);
return latestSpec && latestSpec !== state.userEntry.spec ? 'update' : 'setup';
}
/**
* Converts catalog and temporary mutation state into the one compact-card
* status. Transient states intentionally outrank installed/version metadata.
*/
export function getCatalogPluginPresentation(
state: CatalogPluginState,
options: CatalogPluginPresentationOptions = {},
): CatalogPluginPresentation {
const latestVersion = state.registry?.kind === 'npm-ok'
? state.registry.latestVersion
: null;
if (state.userEntryIsAmbiguous) {
return { status: 'ambiguous', latestVersion };
}
if (options.restartRequired) {
return { status: 'restart-required', latestVersion };
}
if (options.providerUnavailable) {
return { status: 'provider-unavailable', latestVersion };
}
if (options.registryUnavailable) {
return { status: 'registry-unavailable', latestVersion };
}
if (!state.userEntry) {
return { status: 'not-installed', latestVersion };
}
if (state.registry?.kind === 'npm-ok' && state.registry.currentVersion === state.registry.latestVersion) {
return { status: 'installed-version', latestVersion };
}
if (state.registry?.kind === 'npm-ok' && state.registry.currentVersion === null) {
return { status: 'unpinned', latestVersion };
}
if (state.registry?.kind === 'npm-ok' && latestVersion) {
return { status: 'update-available', latestVersion };
}
return { status: 'installed', latestVersion };
}
@@ -1,6 +1,8 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useProviderLogo } from '@/hooks/useProviderLogo';
import { cn } from '@/lib/utils';
import { getProviderLogoFallbackIcon } from './providerLogoFallback';
interface ProviderLogoProps {
providerId: string;
@@ -16,6 +18,7 @@ export const ProviderLogo: React.FC<ProviderLogoProps> = ({
onError: externalOnError
}) => {
const { src, onError: handleInternalError, hasLogo } = useProviderLogo(providerId);
const fallbackIcon = getProviderLogoFallbackIcon(providerId);
const handleError = React.useCallback(() => {
handleInternalError();
@@ -23,7 +26,7 @@ export const ProviderLogo: React.FC<ProviderLogoProps> = ({
}, [handleInternalError, externalOnError]);
if (!hasLogo || !src) {
return null;
return fallbackIcon ? <Icon name={fallbackIcon} className={cn('text-muted-foreground', className)} /> : null;
}
return (
@@ -0,0 +1,13 @@
import { describe, expect, test } from 'bun:test';
import { getProviderLogoFallbackIcon } from './providerLogoFallback';
describe('provider logo fallbacks', () => {
test('uses a local terminal icon when Command Code has no resolved logo', () => {
expect(getProviderLogoFallbackIcon('command-code')).toBe('terminal-box');
});
test('does not replace providers with their own logo assets', () => {
expect(getProviderLogoFallbackIcon('claude-code')).toBeNull();
expect(getProviderLogoFallbackIcon('cursor')).toBeNull();
});
});
@@ -0,0 +1,5 @@
import type { IconName } from '@/components/icon/icons';
export function getProviderLogoFallbackIcon(providerId: string | null | undefined): IconName | null {
return providerId?.trim().toLowerCase() === 'command-code' ? 'terminal-box' : null;
}
@@ -34,6 +34,7 @@ import { MagicPromptsPage } from '@/components/sections/magic-prompts/MagicPromp
import { SnippetsSidebar } from '@/components/sections/snippets/SnippetsSidebar';
import { SnippetsPage } from '@/components/sections/snippets/SnippetsPage';
import { GitPage } from '@/components/sections/git-identities/GitPage';
import { IntegrationsPage } from '@/components/sections/integrations/IntegrationsPage';
import type { OpenChamberSection } from '@/components/sections/openchamber/types';
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
@@ -94,6 +95,7 @@ const pageOrder: SettingsPageSlug[] = [
'sessions',
'shortcuts',
'voice',
'integrations',
'usage',
'about',
// 'projects' group — Workspace
@@ -298,6 +300,24 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
setMobileStage(def.kind === 'split' ? 'page-sidebar' : 'page-content');
}, [isMobile, setSettingsPage]);
const openThirdPartyProviderSetup = React.useCallback(async (providerId: string): Promise<boolean> => {
const configStore = useConfigStore.getState();
await configStore.loadProviders({ source: 'settings:third-party-provider-setup' });
const providerAvailable = useConfigStore.getState().providers.some(
(provider) => provider.id === providerId,
);
if (!providerAvailable) {
return false;
}
configStore.setSelectedProvider(providerId);
openPage('providers');
if (isMobile) {
setMobileStage('page-content');
}
return true;
}, [isMobile, openPage]);
const activePageMeta = React.useMemo(() => {
return getSettingsPageMeta(settingsSlug);
}, [settingsSlug]);
@@ -343,6 +363,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return t('settings.page.skillsCatalog.title');
case 'git':
return t('settings.page.git.title');
case 'integrations':
return t('settings.page.integrations.title');
case 'appearance':
return t('settings.page.appearance.title');
case 'chat':
@@ -646,6 +668,13 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
return <SnippetsPage />;
case 'git':
return <GitPage />;
case 'integrations':
return (
<IntegrationsPage
onOpenProviderSetup={openThirdPartyProviderSetup}
onOpenPluginManager={() => openPage('plugins')}
/>
);
case 'general':
case 'appearance':
case 'chat':
@@ -661,7 +690,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
default:
return null;
}
}, [openChamberSectionBySlug, renderUnavailable, runtimeCtx, t]);
}, [openChamberSectionBySlug, openPage, openThirdPartyProviderSetup, renderUnavailable, runtimeCtx, t]);
// Mobile: if opened via deep-link / palette to a non-home page, jump into it once.
React.useEffect(() => {
@@ -1,3 +1,4 @@
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go Nutzungsverfolgung',
'settings.providers.page.openCodeGo.description': 'Verbinden Sie das OpenCode Go Dashboard, um rollierenden, wöchentlichen und monatlichen Verbrauch anzuzeigen.',
@@ -2124,4 +2125,5 @@ export const settingsDict = {
'settings.openchamber.visual.option.themeMode.light.description': 'Immer helles Erscheinungsbild verwenden',
'settings.openchamber.visual.option.themeMode.dark.description': 'Immer dunkles Erscheinungsbild verwenden',
'chat.message.userText.collapseAria': 'Benutzernachricht einklappen',
...thirdPartyIntegrationI18n.de,
};
@@ -1,3 +1,4 @@
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go usage tracking',
'settings.providers.page.openCodeGo.description': 'Connect the OpenCode Go dashboard to show rolling, weekly, and monthly quota.',
@@ -2123,4 +2124,5 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
...thirdPartyIntegrationI18n.en,
} as const;
@@ -1,3 +1,4 @@
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Seguimiento de uso de OpenCode Go',
'settings.providers.page.openCodeGo.description': 'Conecta el panel de OpenCode Go para ver las cuotas móvil, semanal y mensual.',
@@ -2133,4 +2134,5 @@ export const settingsDict = {
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
...thirdPartyIntegrationI18n.es,
} as const;
@@ -1,3 +1,4 @@
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Suivi de lutilisation dOpenCode Go',
'settings.providers.page.openCodeGo.description': 'Connectez le tableau de bord OpenCode Go pour afficher les quotas glissant, hebdomadaire et mensuel.',
@@ -2133,4 +2134,5 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
...thirdPartyIntegrationI18n.fr,
} as const;
@@ -1,3 +1,4 @@
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 使用量追跡',
'settings.providers.page.openCodeGo.description': 'OpenCode Go ダッシュボードを接続して、ローリング、週間、月間のクォータを表示します。',
@@ -2133,4 +2134,5 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'エージェントが応答している間にフォローアップメッセージで Enter を押したときの動作を選択します。',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー',
...thirdPartyIntegrationI18n.ja,
} as const;
@@ -1,3 +1,4 @@
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 사용량 추적',
'settings.providers.page.openCodeGo.description': 'OpenCode Go 대시보드를 연결하여 롤링, 주간 및 월간 할당량을 표시합니다.',
@@ -2133,4 +2134,5 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
...thirdPartyIntegrationI18n.ko,
} as const;
@@ -1,3 +1,4 @@
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Śledzenie użycia OpenCode Go',
'settings.providers.page.openCodeGo.description': 'Połącz panel OpenCode Go, aby wyświetlać limity kroczące, tygodniowe i miesięczne.',
@@ -2125,4 +2126,5 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
...thirdPartyIntegrationI18n.pl,
};
@@ -1,3 +1,4 @@
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Monitoramento de uso do OpenCode Go',
'settings.providers.page.openCodeGo.description': 'Conecte o painel do OpenCode Go para exibir as cotas móvel, semanal e mensal.',
@@ -2133,4 +2134,5 @@ export const settingsDict = {
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
...thirdPartyIntegrationI18n['pt-BR'],
} as const;
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'bun:test';
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW'] as const;
const requiredKeys = [
'settings.page.integrations.title',
'settings.page.integrations.description',
'settings.integrations.messengers.title',
'settings.integrations.messengers.discord.name',
'settings.integrations.messengers.telegram.name',
'settings.integrations.thirdParty.title',
'settings.integrations.thirdParty.actions.install',
'settings.integrations.thirdParty.actions.update',
'settings.integrations.thirdParty.actions.setup',
'settings.integrations.thirdParty.actions.remove',
'settings.integrations.thirdParty.status.notInstalled',
'settings.integrations.thirdParty.opencodeClaude.description',
'settings.integrations.thirdParty.opencodeCommandcode.description',
'settings.integrations.thirdParty.opencodeCursorOauth.description',
] as const;
describe('third-party integration translations', () => {
test('provides every required key in every supported locale', () => {
for (const locale of locales) {
for (const key of requiredKeys) {
expect(thirdPartyIntegrationI18n[locale][key]).toBeTruthy();
}
}
});
});
@@ -0,0 +1,465 @@
/** Third-party integration settings strings — merged into each locale's settings dictionary. */
export const thirdPartyIntegrationI18n = {
en: {
'settings.page.integrations.title': 'Integrations',
'settings.page.integrations.description': 'Add third-party subscriptions to use as OpenChamber providers.',
'settings.integrations.messengers.title': 'Messengers',
'settings.integrations.messengers.info': 'Chat with OpenChamber from Discord or Telegram. These bridges are not available yet.',
'settings.integrations.messengers.discord.name': 'Discord',
'settings.integrations.messengers.discord.description': 'Connect a Discord bot to chat with OpenChamber.',
'settings.integrations.messengers.telegram.name': 'Telegram',
'settings.integrations.messengers.telegram.description': 'Connect a Telegram bot to chat with OpenChamber.',
'settings.integrations.thirdParty.title': 'Third-party integrations',
'settings.integrations.thirdParty.info': 'Install a provider plugin, then set up your subscription so OpenChamber can use it.',
'settings.integrations.thirdParty.actions.install': 'Install',
'settings.integrations.thirdParty.actions.update': 'Update',
'settings.integrations.thirdParty.actions.setup': 'Set up',
'settings.integrations.thirdParty.actions.remove': 'Remove',
'settings.integrations.thirdParty.actions.docs': 'Docs',
'settings.integrations.thirdParty.actions.managePlugins': 'Manage plugins',
'settings.integrations.thirdParty.status.notInstalled': 'Not installed',
'settings.integrations.thirdParty.status.installed': 'Installed',
'settings.integrations.thirdParty.status.installedVersion': 'Installed {version}',
'settings.integrations.thirdParty.status.updateAvailable': 'Update available: {version}',
'settings.integrations.thirdParty.status.unpinned': 'Following the latest release',
'settings.integrations.thirdParty.status.projectInstalled': 'Also configured for this project',
'settings.integrations.thirdParty.status.ambiguous': 'Multiple user-wide plugin entries need manual management',
'settings.integrations.thirdParty.status.restartRequired': 'Restart OpenCode before setting up this provider.',
'settings.integrations.thirdParty.status.registryUnavailable': 'Could not check npm right now.',
'settings.integrations.thirdParty.status.providerUnavailable': 'The provider is not available yet. Restart OpenCode and try again.',
'settings.integrations.thirdParty.dialog.remove.title': 'Remove integration',
'settings.integrations.thirdParty.dialog.remove.description': 'Remove {name} from your user-wide OpenCode configuration? The provider will no longer load after OpenCode refreshes.',
'settings.integrations.thirdParty.toast.installed': '{name} installed',
'settings.integrations.thirdParty.toast.updated': '{name} updated',
'settings.integrations.thirdParty.toast.removed': '{name} removed',
'settings.integrations.thirdParty.toast.actionFailed': 'Could not update the integration',
'settings.integrations.thirdParty.toast.providerUnavailable': 'The provider could not be opened yet',
'settings.integrations.thirdParty.toast.restartRequired': 'Restart OpenCode for changes to take effect',
'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
'settings.integrations.thirdParty.opencodeClaude.description': 'Use your Claude Pro/Max plan — no API keys, no Claude apps.',
'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
'settings.integrations.thirdParty.opencodeCommandcode.description': '$1 Go Plan: unlimited Laguna S 2.1 + $40 DeepSeek V4 Pro. Sign in, no CLI.',
'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursors generous in-house model limits, now in OpenChamber.',
},
de: {
'settings.page.integrations.title': 'Integrationen',
'settings.page.integrations.description': 'Füge Drittanbieter-Abonnements hinzu, um sie als OpenChamber-Provider zu nutzen.',
'settings.integrations.messengers.title': 'Messenger',
'settings.integrations.messengers.info': 'Chatte mit OpenChamber über Discord oder Telegram. Diese Bridges sind noch nicht verfügbar.',
'settings.integrations.messengers.discord.name': 'Discord',
'settings.integrations.messengers.discord.description': 'Verbinde einen Discord-Bot, um mit OpenChamber zu chatten.',
'settings.integrations.messengers.telegram.name': 'Telegram',
'settings.integrations.messengers.telegram.description': 'Verbinde einen Telegram-Bot, um mit OpenChamber zu chatten.',
'settings.integrations.thirdParty.title': 'Drittanbieter-Integrationen',
'settings.integrations.thirdParty.info': 'Installiere ein Provider-Plugin und richte dein Abonnement ein, damit OpenChamber es nutzen kann.',
'settings.integrations.thirdParty.actions.install': 'Installieren',
'settings.integrations.thirdParty.actions.update': 'Aktualisieren',
'settings.integrations.thirdParty.actions.setup': 'Einrichten',
'settings.integrations.thirdParty.actions.remove': 'Entfernen',
'settings.integrations.thirdParty.actions.docs': 'Dokumentation',
'settings.integrations.thirdParty.actions.managePlugins': 'Plugins verwalten',
'settings.integrations.thirdParty.status.notInstalled': 'Nicht installiert',
'settings.integrations.thirdParty.status.installed': 'Installiert',
'settings.integrations.thirdParty.status.installedVersion': '{version} installiert',
'settings.integrations.thirdParty.status.updateAvailable': 'Aktualisierung verfügbar: {version}',
'settings.integrations.thirdParty.status.unpinned': 'Folgt der neuesten Version',
'settings.integrations.thirdParty.status.projectInstalled': 'Auch für dieses Projekt konfiguriert',
'settings.integrations.thirdParty.status.ambiguous': 'Mehrere benutzerweite Plugin-Einträge müssen manuell verwaltet werden',
'settings.integrations.thirdParty.status.restartRequired': 'Starte OpenCode neu, bevor du diesen Provider einrichtest.',
'settings.integrations.thirdParty.status.registryUnavailable': 'npm konnte gerade nicht geprüft werden.',
'settings.integrations.thirdParty.status.providerUnavailable': 'Der Provider ist noch nicht verfügbar. Starte OpenCode neu und versuche es erneut.',
'settings.integrations.thirdParty.dialog.remove.title': 'Integration entfernen',
'settings.integrations.thirdParty.dialog.remove.description': '{name} aus deiner benutzerweiten OpenCode-Konfiguration entfernen? Der Provider wird nach der Aktualisierung von OpenCode nicht mehr geladen.',
'settings.integrations.thirdParty.toast.installed': '{name} installiert',
'settings.integrations.thirdParty.toast.updated': '{name} aktualisiert',
'settings.integrations.thirdParty.toast.removed': '{name} entfernt',
'settings.integrations.thirdParty.toast.actionFailed': 'Die Integration konnte nicht aktualisiert werden',
'settings.integrations.thirdParty.toast.providerUnavailable': 'Der Provider konnte noch nicht geöffnet werden',
'settings.integrations.thirdParty.toast.restartRequired': 'Starte OpenCode neu, damit die Änderungen wirksam werden',
'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
'settings.integrations.thirdParty.opencodeClaude.description': 'Nutze deinen Claude-Pro/Max-Plan — ohne API-Keys, ohne Claude-Apps.',
'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go-Plan für 1 $: unbegrenztes Laguna S 2.1 + 40 $ DeepSeek V4 Pro. Anmelden, kein CLI.',
'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Die großzügigen Limits der Cursor-eigenen Modelle jetzt in OpenChamber.',
},
fr: {
'settings.page.integrations.title': 'Intégrations',
'settings.page.integrations.description': 'Ajoutez des abonnements tiers à utiliser comme fournisseurs OpenChamber.',
'settings.integrations.messengers.title': 'Messagers',
'settings.integrations.messengers.info': 'Discutez avec OpenChamber depuis Discord ou Telegram. Ces ponts ne sont pas encore disponibles.',
'settings.integrations.messengers.discord.name': 'Discord',
'settings.integrations.messengers.discord.description': 'Connectez un bot Discord pour discuter avec OpenChamber.',
'settings.integrations.messengers.telegram.name': 'Telegram',
'settings.integrations.messengers.telegram.description': 'Connectez un bot Telegram pour discuter avec OpenChamber.',
'settings.integrations.thirdParty.title': 'Intégrations tierces',
'settings.integrations.thirdParty.info': 'Installez un plugin de fournisseur, puis configurez votre abonnement pour quOpenChamber puisse lutiliser.',
'settings.integrations.thirdParty.actions.install': 'Installer',
'settings.integrations.thirdParty.actions.update': 'Mettre à jour',
'settings.integrations.thirdParty.actions.setup': 'Configurer',
'settings.integrations.thirdParty.actions.remove': 'Supprimer',
'settings.integrations.thirdParty.actions.docs': 'Documentation',
'settings.integrations.thirdParty.actions.managePlugins': 'Gérer les plugins',
'settings.integrations.thirdParty.status.notInstalled': 'Non installé',
'settings.integrations.thirdParty.status.installed': 'Installé',
'settings.integrations.thirdParty.status.installedVersion': '{version} installé',
'settings.integrations.thirdParty.status.updateAvailable': 'Mise à jour disponible : {version}',
'settings.integrations.thirdParty.status.unpinned': 'Suit la dernière version',
'settings.integrations.thirdParty.status.projectInstalled': 'Également configuré pour ce projet',
'settings.integrations.thirdParty.status.ambiguous': 'Plusieurs entrées de plugin globales doivent être gérées manuellement',
'settings.integrations.thirdParty.status.restartRequired': 'Redémarrez OpenCode avant de configurer ce fournisseur.',
'settings.integrations.thirdParty.status.registryUnavailable': 'Impossible de vérifier npm pour le moment.',
'settings.integrations.thirdParty.status.providerUnavailable': 'Le fournisseur nest pas encore disponible. Redémarrez OpenCode et réessayez.',
'settings.integrations.thirdParty.dialog.remove.title': 'Supprimer lintégration',
'settings.integrations.thirdParty.dialog.remove.description': 'Supprimer {name} de votre configuration OpenCode globale ? Le fournisseur ne sera plus chargé après lactualisation dOpenCode.',
'settings.integrations.thirdParty.toast.installed': '{name} installé',
'settings.integrations.thirdParty.toast.updated': '{name} mis à jour',
'settings.integrations.thirdParty.toast.removed': '{name} supprimé',
'settings.integrations.thirdParty.toast.actionFailed': 'Impossible de mettre à jour lintégration',
'settings.integrations.thirdParty.toast.providerUnavailable': 'Le fournisseur na pas encore pu être ouvert',
'settings.integrations.thirdParty.toast.restartRequired': 'Redémarrez OpenCode pour que les modifications prennent effet',
'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
'settings.integrations.thirdParty.opencodeClaude.description': 'Utilisez votre forfait Claude Pro/Max — sans clés API, sans apps Claude.',
'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan à 1 $ : Laguna S 2.1 illimité + 40 $ DeepSeek V4 Pro. Connectez-vous, sans CLI.',
'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Les généreuses limites des modèles internes Cursor, désormais dans OpenChamber.',
},
es: {
'settings.page.integrations.title': 'Integraciones',
'settings.page.integrations.description': 'Añade suscripciones de terceros para usarlas como proveedores de OpenChamber.',
'settings.integrations.messengers.title': 'Mensajeros',
'settings.integrations.messengers.info': 'Chatea con OpenChamber desde Discord o Telegram. Estos puentes aún no están disponibles.',
'settings.integrations.messengers.discord.name': 'Discord',
'settings.integrations.messengers.discord.description': 'Conecta un bot de Discord para chatear con OpenChamber.',
'settings.integrations.messengers.telegram.name': 'Telegram',
'settings.integrations.messengers.telegram.description': 'Conecta un bot de Telegram para chatear con OpenChamber.',
'settings.integrations.thirdParty.title': 'Integraciones de terceros',
'settings.integrations.thirdParty.info': 'Instala un plugin de proveedor y configura tu suscripción para que OpenChamber pueda usarla.',
'settings.integrations.thirdParty.actions.install': 'Instalar',
'settings.integrations.thirdParty.actions.update': 'Actualizar',
'settings.integrations.thirdParty.actions.setup': 'Configurar',
'settings.integrations.thirdParty.actions.remove': 'Quitar',
'settings.integrations.thirdParty.actions.docs': 'Documentación',
'settings.integrations.thirdParty.actions.managePlugins': 'Administrar plugins',
'settings.integrations.thirdParty.status.notInstalled': 'No instalado',
'settings.integrations.thirdParty.status.installed': 'Instalado',
'settings.integrations.thirdParty.status.installedVersion': '{version} instalado',
'settings.integrations.thirdParty.status.updateAvailable': 'Actualización disponible: {version}',
'settings.integrations.thirdParty.status.unpinned': 'Sigue la versión más reciente',
'settings.integrations.thirdParty.status.projectInstalled': 'También configurado para este proyecto',
'settings.integrations.thirdParty.status.ambiguous': 'Hay varias entradas de plugin globales que requieren gestión manual',
'settings.integrations.thirdParty.status.restartRequired': 'Reinicia OpenCode antes de configurar este proveedor.',
'settings.integrations.thirdParty.status.registryUnavailable': 'No se pudo comprobar npm ahora mismo.',
'settings.integrations.thirdParty.status.providerUnavailable': 'El proveedor todavía no está disponible. Reinicia OpenCode e inténtalo de nuevo.',
'settings.integrations.thirdParty.dialog.remove.title': 'Quitar integración',
'settings.integrations.thirdParty.dialog.remove.description': '¿Quitar {name} de tu configuración global de OpenCode? El proveedor dejará de cargarse después de actualizar OpenCode.',
'settings.integrations.thirdParty.toast.installed': '{name} instalado',
'settings.integrations.thirdParty.toast.updated': '{name} actualizado',
'settings.integrations.thirdParty.toast.removed': '{name} eliminado',
'settings.integrations.thirdParty.toast.actionFailed': 'No se pudo actualizar la integración',
'settings.integrations.thirdParty.toast.providerUnavailable': 'Todavía no se pudo abrir el proveedor',
'settings.integrations.thirdParty.toast.restartRequired': 'Reinicia OpenCode para que los cambios surtan efecto',
'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
'settings.integrations.thirdParty.opencodeClaude.description': 'Usa tu plan Claude Pro/Max: sin claves API ni apps de Claude.',
'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan por 1 $: Laguna S 2.1 ilimitado + 40 $ de DeepSeek V4 Pro. Entra, sin CLI.',
'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Los generosos límites de los modelos internos de Cursor, ahora en OpenChamber.',
},
ja: {
'settings.page.integrations.title': '連携',
'settings.page.integrations.description': 'サードパーティのサブスクリプションを追加して、OpenChamber のプロバイダーとして使います。',
'settings.integrations.messengers.title': 'メッセンジャー',
'settings.integrations.messengers.info': 'Discord または Telegram から OpenChamber とチャットできます。これらの連携はまだ利用できません。',
'settings.integrations.messengers.discord.name': 'Discord',
'settings.integrations.messengers.discord.description': 'Discord ボットを接続して OpenChamber とチャットします。',
'settings.integrations.messengers.telegram.name': 'Telegram',
'settings.integrations.messengers.telegram.description': 'Telegram ボットを接続して OpenChamber とチャットします。',
'settings.integrations.thirdParty.title': 'サードパーティー連携',
'settings.integrations.thirdParty.info': 'プロバイダープラグインをインストールし、サブスクリプションを設定して OpenChamber で使えるようにします。',
'settings.integrations.thirdParty.actions.install': 'インストール',
'settings.integrations.thirdParty.actions.update': '更新',
'settings.integrations.thirdParty.actions.setup': '設定',
'settings.integrations.thirdParty.actions.remove': '削除',
'settings.integrations.thirdParty.actions.docs': 'ドキュメント',
'settings.integrations.thirdParty.actions.managePlugins': 'プラグインを管理',
'settings.integrations.thirdParty.status.notInstalled': '未インストール',
'settings.integrations.thirdParty.status.installed': 'インストール済み',
'settings.integrations.thirdParty.status.installedVersion': '{version} をインストール済み',
'settings.integrations.thirdParty.status.updateAvailable': '更新があります: {version}',
'settings.integrations.thirdParty.status.unpinned': '最新リリースを追跡中',
'settings.integrations.thirdParty.status.projectInstalled': 'このプロジェクトにも設定済み',
'settings.integrations.thirdParty.status.ambiguous': '複数のユーザー全体プラグインエントリーは手動で管理する必要があります',
'settings.integrations.thirdParty.status.restartRequired': 'このプロバイダーを設定する前に OpenCode を再起動してください。',
'settings.integrations.thirdParty.status.registryUnavailable': '現在 npm を確認できません。',
'settings.integrations.thirdParty.status.providerUnavailable': 'プロバイダーはまだ利用できません。OpenCode を再起動して再試行してください。',
'settings.integrations.thirdParty.dialog.remove.title': '連携を削除',
'settings.integrations.thirdParty.dialog.remove.description': '{name} をユーザー全体の OpenCode 設定から削除しますか?OpenCode の更新後、このプロバイダーは読み込まれなくなります。',
'settings.integrations.thirdParty.toast.installed': '{name} をインストールしました',
'settings.integrations.thirdParty.toast.updated': '{name} を更新しました',
'settings.integrations.thirdParty.toast.removed': '{name} を削除しました',
'settings.integrations.thirdParty.toast.actionFailed': '連携を更新できませんでした',
'settings.integrations.thirdParty.toast.providerUnavailable': 'プロバイダーをまだ開けませんでした',
'settings.integrations.thirdParty.toast.restartRequired': '変更を反映するには OpenCode を再起動してください',
'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro/Max プランを利用 — API キーも Claude アプリも不要。',
'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
'settings.integrations.thirdParty.opencodeCommandcode.description': '1ドルの Go PlanLaguna S 2.1 無制限 + DeepSeek V4 Pro 40ドル分。ログインするだけで CLI 不要。',
'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 内蔵モデルの余裕ある制限が、OpenChamber で使えます。',
},
ko: {
'settings.page.integrations.title': '통합',
'settings.page.integrations.description': '타사 구독을 추가해 OpenChamber 프로바이더로 사용하세요.',
'settings.integrations.messengers.title': '메신저',
'settings.integrations.messengers.info': 'Discord 또는 Telegram에서 OpenChamber와 채팅하세요. 이 브리지는 아직 사용할 수 없습니다.',
'settings.integrations.messengers.discord.name': 'Discord',
'settings.integrations.messengers.discord.description': 'Discord 봇을 연결해 OpenChamber와 채팅하세요.',
'settings.integrations.messengers.telegram.name': 'Telegram',
'settings.integrations.messengers.telegram.description': 'Telegram 봇을 연결해 OpenChamber와 채팅하세요.',
'settings.integrations.thirdParty.title': '서드파티 통합',
'settings.integrations.thirdParty.info': '프로바이더 플러그인을 설치한 뒤 구독을 설정하면 OpenChamber에서 사용할 수 있습니다.',
'settings.integrations.thirdParty.actions.install': '설치',
'settings.integrations.thirdParty.actions.update': '업데이트',
'settings.integrations.thirdParty.actions.setup': '설정',
'settings.integrations.thirdParty.actions.remove': '제거',
'settings.integrations.thirdParty.actions.docs': '문서',
'settings.integrations.thirdParty.actions.managePlugins': '플러그인 관리',
'settings.integrations.thirdParty.status.notInstalled': '설치되지 않음',
'settings.integrations.thirdParty.status.installed': '설치됨',
'settings.integrations.thirdParty.status.installedVersion': '{version} 설치됨',
'settings.integrations.thirdParty.status.updateAvailable': '업데이트 가능: {version}',
'settings.integrations.thirdParty.status.unpinned': '최신 릴리스 추적 중',
'settings.integrations.thirdParty.status.projectInstalled': '이 프로젝트에도 구성됨',
'settings.integrations.thirdParty.status.ambiguous': '여러 사용자 전체 플러그인 항목은 수동으로 관리해야 합니다',
'settings.integrations.thirdParty.status.restartRequired': '이 프로바이더를 설정하기 전에 OpenCode를 다시 시작하세요.',
'settings.integrations.thirdParty.status.registryUnavailable': '지금은 npm을 확인할 수 없습니다.',
'settings.integrations.thirdParty.status.providerUnavailable': '프로바이더를 아직 사용할 수 없습니다. OpenCode를 다시 시작한 후 재시도하세요.',
'settings.integrations.thirdParty.dialog.remove.title': '통합 제거',
'settings.integrations.thirdParty.dialog.remove.description': '사용자 전체 OpenCode 구성에서 {name}을(를) 제거할까요? OpenCode가 새로 고쳐진 후 프로바이더가 더 이상 로드되지 않습니다.',
'settings.integrations.thirdParty.toast.installed': '{name} 설치됨',
'settings.integrations.thirdParty.toast.updated': '{name} 업데이트됨',
'settings.integrations.thirdParty.toast.removed': '{name} 제거됨',
'settings.integrations.thirdParty.toast.actionFailed': '통합을 업데이트하지 못했습니다',
'settings.integrations.thirdParty.toast.providerUnavailable': '프로바이더를 아직 열 수 없습니다',
'settings.integrations.thirdParty.toast.restartRequired': '변경 사항을 적용하려면 OpenCode를 다시 시작하세요',
'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro/Max 요금제를 사용하세요. API 키와 Claude 앱은 필요 없습니다.',
'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
'settings.integrations.thirdParty.opencodeCommandcode.description': '1달러 Go Plan: Laguna S 2.1 무제한 + DeepSeek V4 Pro 40달러. 로그인만 하면 되고 CLI는 필요 없습니다.',
'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 자체 모델의 넉넉한 한도를 이제 OpenChamber에서.',
},
pl: {
'settings.page.integrations.title': 'Integracje',
'settings.page.integrations.description': 'Dodaj subskrypcje zewnętrzne, aby używać ich jako dostawców OpenChamber.',
'settings.integrations.messengers.title': 'Komunikatory',
'settings.integrations.messengers.info': 'Czatuj z OpenChamber przez Discord lub Telegram. Te mosty nie są jeszcze dostępne.',
'settings.integrations.messengers.discord.name': 'Discord',
'settings.integrations.messengers.discord.description': 'Połącz bota Discord, aby czatować z OpenChamber.',
'settings.integrations.messengers.telegram.name': 'Telegram',
'settings.integrations.messengers.telegram.description': 'Połącz bota Telegram, aby czatować z OpenChamber.',
'settings.integrations.thirdParty.title': 'Integracje zewnętrzne',
'settings.integrations.thirdParty.info': 'Zainstaluj wtyczkę dostawcy, a następnie skonfiguruj subskrypcję, aby OpenChamber mógł z niej korzystać.',
'settings.integrations.thirdParty.actions.install': 'Zainstaluj',
'settings.integrations.thirdParty.actions.update': 'Aktualizuj',
'settings.integrations.thirdParty.actions.setup': 'Skonfiguruj',
'settings.integrations.thirdParty.actions.remove': 'Usuń',
'settings.integrations.thirdParty.actions.docs': 'Dokumentacja',
'settings.integrations.thirdParty.actions.managePlugins': 'Zarządzaj wtyczkami',
'settings.integrations.thirdParty.status.notInstalled': 'Nie zainstalowano',
'settings.integrations.thirdParty.status.installed': 'Zainstalowano',
'settings.integrations.thirdParty.status.installedVersion': 'Zainstalowano {version}',
'settings.integrations.thirdParty.status.updateAvailable': 'Dostępna aktualizacja: {version}',
'settings.integrations.thirdParty.status.unpinned': 'Śledzi najnowsze wydanie',
'settings.integrations.thirdParty.status.projectInstalled': 'Skonfigurowano także dla tego projektu',
'settings.integrations.thirdParty.status.ambiguous': 'Wiele globalnych wpisów wtyczki wymaga ręcznego zarządzania',
'settings.integrations.thirdParty.status.restartRequired': 'Uruchom ponownie OpenCode przed konfiguracją tego dostawcy.',
'settings.integrations.thirdParty.status.registryUnavailable': 'Nie można teraz sprawdzić npm.',
'settings.integrations.thirdParty.status.providerUnavailable': 'Dostawca nie jest jeszcze dostępny. Uruchom ponownie OpenCode i spróbuj ponownie.',
'settings.integrations.thirdParty.dialog.remove.title': 'Usuń integrację',
'settings.integrations.thirdParty.dialog.remove.description': 'Usunąć {name} z globalnej konfiguracji OpenCode? Dostawca przestanie być ładowany po odświeżeniu OpenCode.',
'settings.integrations.thirdParty.toast.installed': 'Zainstalowano {name}',
'settings.integrations.thirdParty.toast.updated': 'Zaktualizowano {name}',
'settings.integrations.thirdParty.toast.removed': 'Usunięto {name}',
'settings.integrations.thirdParty.toast.actionFailed': 'Nie udało się zaktualizować integracji',
'settings.integrations.thirdParty.toast.providerUnavailable': 'Nie można jeszcze otworzyć dostawcy',
'settings.integrations.thirdParty.toast.restartRequired': 'Uruchom ponownie OpenCode, aby zastosować zmiany',
'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
'settings.integrations.thirdParty.opencodeClaude.description': 'Korzystaj z planu Claude Pro/Max — bez kluczy API i aplikacji Claude.',
'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan za 1 $: nielimitowane Laguna S 2.1 + 40 $ DeepSeek V4 Pro. Zaloguj się, bez CLI.',
'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Hojne limity wewnętrznych modeli Cursor teraz w OpenChamber.',
},
'pt-BR': {
'settings.page.integrations.title': 'Integrações',
'settings.page.integrations.description': 'Adicione assinaturas de terceiros para usar como provedores do OpenChamber.',
'settings.integrations.messengers.title': 'Mensageiros',
'settings.integrations.messengers.info': 'Converse com o OpenChamber pelo Discord ou Telegram. Essas pontes ainda não estão disponíveis.',
'settings.integrations.messengers.discord.name': 'Discord',
'settings.integrations.messengers.discord.description': 'Conecte um bot do Discord para conversar com o OpenChamber.',
'settings.integrations.messengers.telegram.name': 'Telegram',
'settings.integrations.messengers.telegram.description': 'Conecte um bot do Telegram para conversar com o OpenChamber.',
'settings.integrations.thirdParty.title': 'Integrações de terceiros',
'settings.integrations.thirdParty.info': 'Instale um plugin de provedor e configure sua assinatura para o OpenChamber poder usá-la.',
'settings.integrations.thirdParty.actions.install': 'Instalar',
'settings.integrations.thirdParty.actions.update': 'Atualizar',
'settings.integrations.thirdParty.actions.setup': 'Configurar',
'settings.integrations.thirdParty.actions.remove': 'Remover',
'settings.integrations.thirdParty.actions.docs': 'Documentação',
'settings.integrations.thirdParty.actions.managePlugins': 'Gerenciar plugins',
'settings.integrations.thirdParty.status.notInstalled': 'Não instalado',
'settings.integrations.thirdParty.status.installed': 'Instalado',
'settings.integrations.thirdParty.status.installedVersion': '{version} instalado',
'settings.integrations.thirdParty.status.updateAvailable': 'Atualização disponível: {version}',
'settings.integrations.thirdParty.status.unpinned': 'Acompanha a versão mais recente',
'settings.integrations.thirdParty.status.projectInstalled': 'Também configurado para este projeto',
'settings.integrations.thirdParty.status.ambiguous': 'Várias entradas de plugin globais precisam de gerenciamento manual',
'settings.integrations.thirdParty.status.restartRequired': 'Reinicie o OpenCode antes de configurar este provedor.',
'settings.integrations.thirdParty.status.registryUnavailable': 'Não foi possível verificar o npm agora.',
'settings.integrations.thirdParty.status.providerUnavailable': 'O provedor ainda não está disponível. Reinicie o OpenCode e tente novamente.',
'settings.integrations.thirdParty.dialog.remove.title': 'Remover integração',
'settings.integrations.thirdParty.dialog.remove.description': 'Remover {name} da sua configuração global do OpenCode? O provedor deixará de ser carregado após a atualização do OpenCode.',
'settings.integrations.thirdParty.toast.installed': '{name} instalado',
'settings.integrations.thirdParty.toast.updated': '{name} atualizado',
'settings.integrations.thirdParty.toast.removed': '{name} removido',
'settings.integrations.thirdParty.toast.actionFailed': 'Não foi possível atualizar a integração',
'settings.integrations.thirdParty.toast.providerUnavailable': 'Ainda não foi possível abrir o provedor',
'settings.integrations.thirdParty.toast.restartRequired': 'Reinicie o OpenCode para que as alterações entrem em vigor',
'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
'settings.integrations.thirdParty.opencodeClaude.description': 'Use seu plano Claude Pro/Max — sem chaves de API nem apps da Claude.',
'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan por US$ 1: Laguna S 2.1 ilimitado + US$ 40 de DeepSeek V4 Pro. Entre, sem CLI.',
'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Os limites generosos dos modelos internos do Cursor, agora no OpenChamber.',
},
uk: {
'settings.page.integrations.title': 'Інтеграції',
'settings.page.integrations.description': 'Додайте сторонні підписки, щоб використовувати їх як провайдери OpenChamber.',
'settings.integrations.messengers.title': 'Месенджери',
'settings.integrations.messengers.info': 'Спілкуйтеся з OpenChamber у Discord або Telegram. Ці мости ще недоступні.',
'settings.integrations.messengers.discord.name': 'Discord',
'settings.integrations.messengers.discord.description': 'Підключіть бота Discord, щоб спілкуватися з OpenChamber.',
'settings.integrations.messengers.telegram.name': 'Telegram',
'settings.integrations.messengers.telegram.description': 'Підключіть бота Telegram, щоб спілкуватися з OpenChamber.',
'settings.integrations.thirdParty.title': 'Сторонні інтеграції',
'settings.integrations.thirdParty.info': 'Установіть плагін провайдера, а потім налаштуйте підписку, щоб OpenChamber міг її використовувати.',
'settings.integrations.thirdParty.actions.install': 'Встановити',
'settings.integrations.thirdParty.actions.update': 'Оновити',
'settings.integrations.thirdParty.actions.setup': 'Налаштувати',
'settings.integrations.thirdParty.actions.remove': 'Видалити',
'settings.integrations.thirdParty.actions.docs': 'Документація',
'settings.integrations.thirdParty.actions.managePlugins': 'Керувати плагінами',
'settings.integrations.thirdParty.status.notInstalled': 'Не встановлено',
'settings.integrations.thirdParty.status.installed': 'Встановлено',
'settings.integrations.thirdParty.status.installedVersion': 'Встановлено {version}',
'settings.integrations.thirdParty.status.updateAvailable': 'Доступне оновлення: {version}',
'settings.integrations.thirdParty.status.unpinned': 'Відстежує найновіший випуск',
'settings.integrations.thirdParty.status.projectInstalled': 'Також налаштовано для цього проєкту',
'settings.integrations.thirdParty.status.ambiguous': 'Кілька глобальних записів плагіна потребують ручного керування',
'settings.integrations.thirdParty.status.restartRequired': 'Перезапустіть OpenCode перед налаштуванням цього провайдера.',
'settings.integrations.thirdParty.status.registryUnavailable': 'Зараз не вдалося перевірити npm.',
'settings.integrations.thirdParty.status.providerUnavailable': 'Провайдер ще недоступний. Перезапустіть OpenCode й спробуйте знову.',
'settings.integrations.thirdParty.dialog.remove.title': 'Видалити інтеграцію',
'settings.integrations.thirdParty.dialog.remove.description': 'Видалити {name} з вашої глобальної конфігурації OpenCode? Після оновлення OpenCode провайдер більше не завантажуватиметься.',
'settings.integrations.thirdParty.toast.installed': '{name} встановлено',
'settings.integrations.thirdParty.toast.updated': '{name} оновлено',
'settings.integrations.thirdParty.toast.removed': '{name} видалено',
'settings.integrations.thirdParty.toast.actionFailed': 'Не вдалося оновити інтеграцію',
'settings.integrations.thirdParty.toast.providerUnavailable': 'Провайдера ще не вдалося відкрити',
'settings.integrations.thirdParty.toast.restartRequired': 'Перезапустіть OpenCode, щоб застосувати зміни',
'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro/Max за підпискою — без API-ключів і без додатків Claude.',
'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
'settings.integrations.thirdParty.opencodeCommandcode.description': 'Go Plan за $1: безліміт Laguna S 2.1 і $40 на DeepSeek V4 Pro. Вхід без CLI.',
'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Щедрі ліміти внутрішніх моделей Cursor — тепер в OpenChamber.',
},
'zh-CN': {
'settings.page.integrations.title': '集成',
'settings.page.integrations.description': '添加第三方订阅,将其用作 OpenChamber 提供商。',
'settings.integrations.messengers.title': '即时通讯',
'settings.integrations.messengers.info': '通过 Discord 或 Telegram 与 OpenChamber 聊天。这些桥接尚不可用。',
'settings.integrations.messengers.discord.name': 'Discord',
'settings.integrations.messengers.discord.description': '连接 Discord 机器人以与 OpenChamber 聊天。',
'settings.integrations.messengers.telegram.name': 'Telegram',
'settings.integrations.messengers.telegram.description': '连接 Telegram 机器人以与 OpenChamber 聊天。',
'settings.integrations.thirdParty.title': '第三方集成',
'settings.integrations.thirdParty.info': '安装提供商插件并设置订阅,以便 OpenChamber 可以使用它。',
'settings.integrations.thirdParty.actions.install': '安装',
'settings.integrations.thirdParty.actions.update': '更新',
'settings.integrations.thirdParty.actions.setup': '设置',
'settings.integrations.thirdParty.actions.remove': '移除',
'settings.integrations.thirdParty.actions.docs': '文档',
'settings.integrations.thirdParty.actions.managePlugins': '管理插件',
'settings.integrations.thirdParty.status.notInstalled': '未安装',
'settings.integrations.thirdParty.status.installed': '已安装',
'settings.integrations.thirdParty.status.installedVersion': '已安装 {version}',
'settings.integrations.thirdParty.status.updateAvailable': '有可用更新:{version}',
'settings.integrations.thirdParty.status.unpinned': '跟踪最新版本',
'settings.integrations.thirdParty.status.projectInstalled': '也已为此项目配置',
'settings.integrations.thirdParty.status.ambiguous': '多个用户级插件条目需要手动管理',
'settings.integrations.thirdParty.status.restartRequired': '请先重启 OpenCode,再设置此提供商。',
'settings.integrations.thirdParty.status.registryUnavailable': '当前无法检查 npm。',
'settings.integrations.thirdParty.status.providerUnavailable': '该提供商尚不可用。请重启 OpenCode 后重试。',
'settings.integrations.thirdParty.dialog.remove.title': '移除集成',
'settings.integrations.thirdParty.dialog.remove.description': '要从用户级 OpenCode 配置中移除 {name} 吗?OpenCode 刷新后将不再加载该提供商。',
'settings.integrations.thirdParty.toast.installed': '已安装 {name}',
'settings.integrations.thirdParty.toast.updated': '已更新 {name}',
'settings.integrations.thirdParty.toast.removed': '已移除 {name}',
'settings.integrations.thirdParty.toast.actionFailed': '无法更新集成',
'settings.integrations.thirdParty.toast.providerUnavailable': '暂时无法打开该提供商',
'settings.integrations.thirdParty.toast.restartRequired': '请重启 OpenCode 以使更改生效',
'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
'settings.integrations.thirdParty.opencodeClaude.description': '使用 Claude Pro/Max 套餐——无需 API 密钥,也无需 Claude 应用。',
'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
'settings.integrations.thirdParty.opencodeCommandcode.description': '1 美元 Go Plan:无限 Laguna S 2.1,另含 40 美元 DeepSeek V4 Pro。登录即可,无需 CLI。',
'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 内部模型的充足额度,现已可用于 OpenChamber。',
},
'zh-TW': {
'settings.page.integrations.title': '整合',
'settings.page.integrations.description': '新增第三方訂閱,將其用作 OpenChamber 供應商。',
'settings.integrations.messengers.title': '即時通訊',
'settings.integrations.messengers.info': '透過 Discord 或 Telegram 與 OpenChamber 聊天。這些橋接尚不可用。',
'settings.integrations.messengers.discord.name': 'Discord',
'settings.integrations.messengers.discord.description': '連接 Discord 機器人以與 OpenChamber 聊天。',
'settings.integrations.messengers.telegram.name': 'Telegram',
'settings.integrations.messengers.telegram.description': '連接 Telegram 機器人以與 OpenChamber 聊天。',
'settings.integrations.thirdParty.title': '第三方整合',
'settings.integrations.thirdParty.info': '安裝供應商外掛並設定訂閱,以便 OpenChamber 可以使用它。',
'settings.integrations.thirdParty.actions.install': '安裝',
'settings.integrations.thirdParty.actions.update': '更新',
'settings.integrations.thirdParty.actions.setup': '設定',
'settings.integrations.thirdParty.actions.remove': '移除',
'settings.integrations.thirdParty.actions.docs': '文件',
'settings.integrations.thirdParty.actions.managePlugins': '管理外掛',
'settings.integrations.thirdParty.status.notInstalled': '未安裝',
'settings.integrations.thirdParty.status.installed': '已安裝',
'settings.integrations.thirdParty.status.installedVersion': '已安裝 {version}',
'settings.integrations.thirdParty.status.updateAvailable': '有可用更新:{version}',
'settings.integrations.thirdParty.status.unpinned': '追蹤最新版本',
'settings.integrations.thirdParty.status.projectInstalled': '也已為此專案設定',
'settings.integrations.thirdParty.status.ambiguous': '多個使用者層級外掛項目需要手動管理',
'settings.integrations.thirdParty.status.restartRequired': '請先重新啟動 OpenCode,再設定此供應商。',
'settings.integrations.thirdParty.status.registryUnavailable': '目前無法檢查 npm。',
'settings.integrations.thirdParty.status.providerUnavailable': '供應商尚不可用。請重新啟動 OpenCode 後再試一次。',
'settings.integrations.thirdParty.dialog.remove.title': '移除整合',
'settings.integrations.thirdParty.dialog.remove.description': '要從使用者層級 OpenCode 設定中移除 {name} 嗎?OpenCode 重新整理後將不再載入此供應商。',
'settings.integrations.thirdParty.toast.installed': '已安裝 {name}',
'settings.integrations.thirdParty.toast.updated': '已更新 {name}',
'settings.integrations.thirdParty.toast.removed': '已移除 {name}',
'settings.integrations.thirdParty.toast.actionFailed': '無法更新整合',
'settings.integrations.thirdParty.toast.providerUnavailable': '暫時無法開啟供應商',
'settings.integrations.thirdParty.toast.restartRequired': '請重新啟動 OpenCode 以使變更生效',
'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code',
'settings.integrations.thirdParty.opencodeClaude.description': '使用 Claude Pro/Max 方案——無需 API 金鑰,也無需 Claude 應用程式。',
'settings.integrations.thirdParty.opencodeCommandcode.name': 'Command Code',
'settings.integrations.thirdParty.opencodeCommandcode.description': '1 美元 Go Plan:無限 Laguna S 2.1,另含 40 美元 DeepSeek V4 Pro。登入即可,無需 CLI。',
'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor',
'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 內部模型的充足額度,現已可用於 OpenChamber。',
},
} as const;
@@ -1,3 +1,4 @@
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'Відстеження використання OpenCode Go',
'settings.providers.page.openCodeGo.description': 'Підключіть панель OpenCode Go, щоб бачити ковзну, тижневу та місячну квоту.',
@@ -2133,4 +2134,5 @@ export const settingsDict = {
"settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.",
"settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer",
"settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue",
...thirdPartyIntegrationI18n.uk,
} as const;
@@ -1,3 +1,4 @@
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量跟踪',
'settings.providers.page.openCodeGo.description': '连接 OpenCode Go 控制面板以显示滚动、每周和每月配额。',
@@ -2133,4 +2134,5 @@ export const settingsDict = {
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
...thirdPartyIntegrationI18n['zh-CN'],
} as const;
@@ -1,4 +1,5 @@
export const settingsDict = {
import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n';
export const settingsDict = {
'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量追蹤',
'settings.providers.page.openCodeGo.description': '連接 OpenCode Go 控制面板以顯示滾動、每週和每月配額。',
'settings.providers.page.openCodeGo.workspaceId': '工作區 ID',
@@ -2133,4 +2134,5 @@
'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.',
'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer',
'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue',
...thirdPartyIntegrationI18n['zh-TW'],
} as const;
+6 -1
View File
@@ -25,7 +25,8 @@ export type SettingsPageSlug =
| 'notifications'
| 'voice'
| 'tunnel'
| 'about';
| 'about'
| 'integrations';
type SettingsPageGroup =
| 'general'
@@ -201,6 +202,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
{ slug: 'voice', title: 'Voice', group: 'general', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
{ slug: 'tunnel', title: 'External Tunnel', group: 'projects', kind: 'single', keywords: ['tunnel', 'external', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode },
{ slug: 'about', title: 'About', group: 'general', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile && !ctx.isVSCode },
{ slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger'] },
] as const;
const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record<SidebarSection, SettingsPageSlug> = {
@@ -286,6 +288,9 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
case 'git':
return 'git-branch';
case 'integrations':
return 'plug';
case 'usage':
return 'bar-chart-2';
case 'voice':
@@ -0,0 +1,52 @@
import { describe, expect, test } from 'bun:test';
import type { I18nKey } from '@/lib/i18n/store';
import { buildSettingsSearchResults } from './search';
const t = (key: I18nKey): string => key;
const runtimeCtx = {
isVSCode: false,
isWeb: true,
isDesktop: false,
isMobile: false,
isDesktopLocalOrigin: false,
isMac: false,
isWindows: false,
isLinux: false,
isWindowsArm64: false,
};
describe('settings search', () => {
test('finds the Claude Code third-party integration', () => {
const results = buildSettingsSearchResults({
query: 'claude',
runtimeCtx,
t,
getPageTitle: (page) => page,
});
expect(results.some((result) => result.id === 'integrations.third-party.opencode-claude')).toBe(true);
});
test('finds third-party integrations by OpenChamber npm package names', () => {
const results = buildSettingsSearchResults({
query: '@openchamber/opencode-cursor',
runtimeCtx,
t,
getPageTitle: (page) => page,
});
expect(results.some((result) => result.id === 'integrations.third-party.opencode-cursor-oauth')).toBe(true);
});
test('finds coming-soon messenger placeholders', () => {
const results = buildSettingsSearchResults({
query: 'discord',
runtimeCtx,
t,
getPageTitle: (page) => page,
});
expect(results.some((result) => result.id === 'integrations.messengers.discord')).toBe(true);
});
});
+48
View File
@@ -933,6 +933,54 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
keywords: ['background', 'push'],
isAvailable: (ctx) => ctx.isWeb && !ctx.isDesktop && !ctx.isVSCode,
},
{
id: 'integrations.messengers',
page: 'integrations',
titleKey: 'settings.integrations.messengers.title',
keywords: ['messenger', 'discord', 'telegram', 'bot', 'coming soon'],
},
{
id: 'integrations.messengers.discord',
page: 'integrations',
titleKey: 'settings.integrations.messengers.discord.name',
descriptionKey: 'settings.integrations.messengers.discord.description',
keywords: ['discord', 'bot', 'messenger', 'coming soon'],
},
{
id: 'integrations.messengers.telegram',
page: 'integrations',
titleKey: 'settings.integrations.messengers.telegram.name',
descriptionKey: 'settings.integrations.messengers.telegram.description',
keywords: ['telegram', 'bot', 'messenger', 'coming soon'],
},
{
id: 'integrations.third-party',
page: 'integrations',
titleKey: 'settings.integrations.thirdParty.title',
keywords: ['plugin', 'provider', 'oauth', 'install', 'update', 'remove'],
},
{
id: 'integrations.third-party.opencode-claude',
page: 'integrations',
titleKey: 'settings.integrations.thirdParty.opencodeClaude.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description',
keywords: ['claude', 'anthropic', 'claude code', 'pro', 'max', 'agent sdk', '@openchamber/opencode-claude'],
},
{
id: 'integrations.third-party.opencode-commandcode',
page: 'integrations',
titleKey: 'settings.integrations.thirdParty.opencodeCommandcode.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeCommandcode.description',
keywords: ['command code', 'commandcode', 'laguna', 'poolside', 'gateway', '@openchamber/opencode-commandcode'],
},
{
id: 'integrations.third-party.opencode-cursor-oauth',
page: 'integrations',
titleKey: 'settings.integrations.thirdParty.opencodeCursorOauth.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeCursorOauth.description',
keywords: ['cursor', 'oauth', 'subscription', 'openai compatible', '@openchamber/opencode-cursor'],
},
] as const;
interface BuildSettingsSearchResultsOptions {
+13 -1
View File
@@ -292,12 +292,24 @@ describe('usePluginsStore', () => {
test('loadRegistryInfo skips empty specs and clears loading flag', async () => {
usePluginsStore.setState({ isLoadingRegistry: true });
await usePluginsStore.getState().loadRegistryInfo({ specs: [] });
const result = await usePluginsStore.getState().loadRegistryInfo({ specs: [] });
expect(result).toBe(true);
expect(fetchCalls).toHaveLength(0);
expect(usePluginsStore.getState().isLoadingRegistry).toBe(false);
});
test('loadRegistryInfo reports a failed request without clearing prior registry data', async () => {
usePluginsStore.setState({ registryInfo: { [entry.spec]: registryOk } });
queueFetchResponses([jsonResponse({ error: 'registry unavailable' }, { status: 500 })]);
const result = await usePluginsStore.getState().loadRegistryInfo({ specs: [entry.spec] });
expect(result).toBe(false);
expect(usePluginsStore.getState().registryInfo).toEqual({ [entry.spec]: registryOk });
expect(usePluginsStore.getState().isLoadingRegistry).toBe(false);
});
test('loadPlugins success triggers registry load without blocking result', async () => {
queueFetchResponses([jsonResponse(pluginListPayload), jsonResponse({ results: [registryOk] })]);
+4 -2
View File
@@ -67,7 +67,7 @@ export interface PluginsStore {
setSelected: (id: string | null) => void;
setDraft: (draft: PluginDraft | null) => void;
loadPlugins: (options?: { force?: boolean }) => Promise<boolean>;
loadRegistryInfo: (opts?: { specs?: string[]; force?: boolean }) => Promise<void>;
loadRegistryInfo: (opts?: { specs?: string[]; force?: boolean }) => Promise<boolean>;
updateToLatest: (id: string) => Promise<PluginMutationResult>;
createEntry: (input: { spec: string; options?: Record<string, unknown>; scope: PluginScope }) => Promise<PluginMutationResult>;
updateEntry: (id: string, input: { spec?: string; options?: Record<string, unknown> }) => Promise<PluginMutationResult>;
@@ -207,7 +207,7 @@ export const usePluginsStore = create<PluginsStore>()(
const specs = dedupeSpecs(opts?.specs ?? get().entries.map((entry) => entry.spec));
if (specs.length === 0) {
set({ isLoadingRegistry: false });
return;
return true;
}
set({ isLoadingRegistry: true });
@@ -227,9 +227,11 @@ export const usePluginsStore = create<PluginsStore>()(
}
}
set({ registryInfo: nextRegistryInfo, isLoadingRegistry: false });
return true;
} catch (error) {
console.error('[PluginsStore] Failed to load plugin registry info:', error);
set({ isLoadingRegistry: false });
return false;
}
},
+15
View File
@@ -22,6 +22,21 @@ const customIconData = new Map([
"openchamber",
`<polygon points="12 2.5 3.5 7.4 3.5 17.2 12 22.1 20.5 17.2 20.5 7.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><polyline points="3.5 7.4 12 12.3 20.5 7.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><line x1="12" y1="12.3" x2="12" y2="22.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="m12 5.5 3.7 2.1L12 9.7 8.3 7.6 12 5.5Zm0 1.5-1 .6 1 .6 1-.6-1-.6Z" fill="currentColor" fill-rule="evenodd"/>`,
],
// Claude spark — official Anthropic mark (Simple Icons path), monochrome.
[
"claude-code",
`<path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z" fill="currentColor"/>`,
],
// Cursor two-cursor mark — official (Simple Icons path), monochrome.
[
"cursor",
`<path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23" fill="currentColor"/>`,
],
// Command Code — corner squares + center square (official logo geometry).
[
"command-code",
`<path fill="currentColor" d="M5.8 5.8h4.8v4.8h-4.8Z M13.4 5.8h4.8v4.8h-4.8Z M10.6 10.6h2.8v2.8h-2.8Z M5.8 13.4h4.8v4.8h-4.8Z M13.4 13.4h4.8v4.8h-4.8Z"/>`,
],
])
const source = readFileSync(remixPath, "utf-8")