Compare commits

...

17 Commits

Author SHA1 Message Date
Jack 6cc915bcf7 fix(console): recover delayed Go subscriptions 2026-07-30 09:32:06 +00:00
opencode-agent[bot] c08350b83e chore: update nix node_modules hashes 2026-07-30 09:11:45 +00:00
Aarav Sareen c5cf416633 feat(app): fix composer attachment fades (#38547) 2026-07-30 17:07:35 +08:00
Aarav Sareen 3dbce3dade feat(app): adds fades to file tree (#38549) 2026-07-30 17:07:06 +08:00
opencode-agent[bot] a277557a5f chore: generate 2026-07-30 09:05:12 +00:00
Aarav Sareen 005bf088c9 feat(app): fix file tree button bg (#38629) 2026-07-30 17:03:23 +08:00
Aarav Sareen eca5e68a5e feat(app): disallow duplicate attachments (#39464) 2026-07-30 17:02:15 +08:00
Aarav Sareen 5b11635d97 feat(app): always show new session button (#39520)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-30 16:58:38 +08:00
Aarav Sareen a9eda2e99e feat(app): migrate to solid-sonner (#39519)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-30 08:57:19 +00:00
Aarav Sareen 73009f0b09 feat(app): update arrow buttons in review tab top bar (#39469)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-30 16:56:16 +08:00
Frank f720490219 fix(console): add public fetch compatibility flag 2026-07-30 00:40:52 -04:00
Deven Navani 341c64cc97 feat: discover Modal models (#39066) 2026-07-29 23:06:19 -05:00
opencode-agent[bot] 1e17856ba4 chore: update nix node_modules hashes 2026-07-29 18:06:26 +00:00
Lakshya Agarwal a6f7fe739c docs(ecosystem): add opencode-tavily plugin (#38709) 2026-07-29 12:52:12 -05:00
Vladimir Glafirov 02f50a6e80 chore: bump gitlab-ai-provider to 6.12.1 (#39490) 2026-07-29 12:45:40 -05:00
opencode-agent[bot] 7565e03536 chore: generate 2026-07-29 08:36:03 +00:00
usrnk1 035a5a1a67 feat(desktop): refine tab states (#39472) 2026-07-29 08:34:37 +00:00
50 changed files with 1351 additions and 403 deletions
+7 -3
View File
@@ -339,7 +339,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.0",
"gitlab-ai-provider": "6.12.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -632,7 +632,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.0",
"gitlab-ai-provider": "6.12.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -1002,6 +1002,7 @@
"remend": "catalog:",
"shiki": "catalog:",
"solid-list": "catalog:",
"solid-sonner": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
@@ -1146,6 +1147,7 @@
"shiki": "4.2.0",
"solid-js": "1.9.10",
"solid-list": "0.3.0",
"solid-sonner": "0.3.1",
"sst": "4.13.1",
"tailwindcss": "4.1.11",
"typescript": "5.8.2",
@@ -3855,7 +3857,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-uGan7MQaNfmV5SH9wiMJkZ7D5QxM4KVPRcjIHtPCM5icZjgXl5mrHMjr3/e7TAHa9OjEJKT0a2FxzPgwRtN3PQ=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -5127,6 +5129,8 @@
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
"solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="],
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
+6 -2
View File
@@ -288,8 +288,12 @@ new sst.cloudflare.x.SolidStart("Console", {
server: {
placement: { region: "aws:us-east-2" },
transform: {
worker: {
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
worker: (args) => {
args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [
...(flags ?? []),
"global_fetch_strictly_public",
])
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
},
},
},
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-/ZtluLolVq1ZFAYgbDScze9GZR0NF9baRQXvfEoOnkI=",
"aarch64-linux": "sha256-kdh7Dpira7cx9lvtozFsjLrDqMBrUMcgxkhfsqrutws=",
"aarch64-darwin": "sha256-chPeMCX+8hvOQ32hTANIn7FmONsSv9t3dKTWTaHerjs=",
"x86_64-darwin": "sha256-rMc0dDZB+YbXrjYwXTIt7QvHUxSp//wDJQ5hUdR+LYM="
"x86_64-linux": "sha256-fK6zAHJC3ut/KUdfqLPxVMH7Z1yv65YZ7qcHF45kPas=",
"aarch64-linux": "sha256-CSWZY/vVUCKzlwfisQQHqHjawUwL0BRc8Y9o2y/QZYA=",
"aarch64-darwin": "sha256-IyFm5NbnU63BaOO/F4/v1exz3VbvkY96yjg9iun+O9Q=",
"x86_64-darwin": "sha256-XnXFoUxqXmEx7AKx5YhaJuLY2NYMtI0ICafaenU5L6k="
}
}
+1
View File
@@ -90,6 +90,7 @@
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12"
}
+2 -2
View File
@@ -19,9 +19,9 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit && bun run test:browser",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",
@@ -368,6 +368,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
}),
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
onError: (error) =>
showToast({
variant: "error",
@@ -9,53 +9,86 @@
justify-content: center;
}
[data-titlebar-tab] {
--tab-base: var(--v2-background-bg-deep);
--tab-overlay: transparent;
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not(
[data-dragging="true"]
):not([data-editing="true"]) {
--tab-base: var(--v2-background-bg-layer-02);
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
}
[data-titlebar-tab]:is([data-state="pressed"], [data-dragging="true"], [data-editing="true"]) {
--tab-base: var(--v2-background-bg-layer-02);
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] {
display: none;
}
[data-titlebar-tab-list] {
gap: 13.5px;
gap: 6px;
}
[data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-03);
position: relative;
}
[data-color-scheme="dark"] [data-titlebar-tab-slot] {
--tab-separator: var(--v2-background-bg-layer-02);
}
[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before {
content: "";
position: absolute;
top: 8px;
left: -6.75px;
left: -3.75px;
width: 1.5px;
height: 12px;
border-radius: 9999px;
background: var(--v2-background-bg-layer-02);
background: var(--tab-separator);
}
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
display: none;
}
[data-titlebar-tab] [data-slot="tab-close"]::before {
content: "";
position: absolute;
top: 0;
height: 28px;
width: 16px;
pointer-events: none;
background: linear-gradient(to right, transparent, var(--tab-bg));
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before,
[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before {
display: none;
}
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
display: block;
right: 0;
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 4px;
-webkit-mask-image: linear-gradient(
to right,
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
mask-image: linear-gradient(
to right,
black 0,
black calc(100% - var(--tab-title-fade-offset) - 16px),
transparent calc(100% - var(--tab-title-fade-offset)),
transparent 100%
);
}
[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before,
[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"])
[data-slot="tab-close"]::before {
right: 100%;
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] {
--tab-title-fade-offset: 24px;
}
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
@@ -180,11 +180,11 @@ export function TabNavItem(props: {
data-slot="titlebar-tab-item"
data-title-overflow={titleOverflowing()}
data-editing={editing()}
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]"
classList={{ invisible: props.hidden }}
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
data-state={props.active || props.pressed ? "pressed" : undefined}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
@@ -276,7 +276,7 @@ export function TabNavItem(props: {
/>
</a>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<div data-slot="tab-close">
<IconButtonV2
size="small"
variant="ghost-muted"
@@ -334,8 +334,8 @@ export function DraftTabItem(props: {
data-slot="titlebar-tab-item"
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-state={props.active || props.pressed ? "pressed" : undefined}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
@@ -381,7 +381,7 @@ export function DraftTabItem(props: {
{props.title}
</span>
</a>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<div data-slot="tab-close">
<IconButtonV2
size="small"
variant="ghost-muted"
+19 -21
View File
@@ -395,27 +395,25 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Show when={!creating()}>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
</Show>
<TooltipV2
placement="bottom"
value={
<>
{language.t("command.session.new")}
<KeybindV2 keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</TooltipV2>
<div class="flex-1" />
<TitlebarV2Right state={v2RightState()} />
</div>
+1
View File
@@ -277,6 +277,7 @@ export const dict = {
"prompt.action.send": "إرسال",
"prompt.action.stop": "توقف",
"prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم",
"prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل",
"prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.",
"prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
"prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.",
+1
View File
@@ -277,6 +277,7 @@ export const dict = {
"prompt.action.send": "Enviar",
"prompt.action.stop": "Parar",
"prompt.toast.pasteUnsupported.title": "Anexo não suportado",
"prompt.toast.attachmentDuplicate.title": "Este arquivo já foi enviado",
"prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.",
"prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
"prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.",
+1
View File
@@ -298,6 +298,7 @@ export const dict = {
"prompt.action.stop": "Zaustavi",
"prompt.toast.pasteUnsupported.title": "Nepodržan prilog",
"prompt.toast.attachmentDuplicate.title": "Ova datoteka je već učitana",
"prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.",
"prompt.toast.modelAgentRequired.title": "Odaberi agenta i model",
"prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.",
+1
View File
@@ -296,6 +296,7 @@ export const dict = {
"prompt.action.stop": "Stop",
"prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning",
"prompt.toast.attachmentDuplicate.title": "Denne fil er allerede uploadet",
"prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.",
"prompt.toast.modelAgentRequired.title": "Vælg en agent og model",
"prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.",
+1
View File
@@ -282,6 +282,7 @@ export const dict = {
"prompt.action.send": "Senden",
"prompt.action.stop": "Stopp",
"prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang",
"prompt.toast.attachmentDuplicate.title": "Diese Datei wurde bereits hochgeladen",
"prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.",
"prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell",
"prompt.toast.modelAgentRequired.description":
+1
View File
@@ -301,6 +301,7 @@ export const dict = {
"prompt.toast.pasteUnsupported.title": "Unsupported attachment",
"prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.",
"prompt.toast.attachmentDuplicate.title": "This file has already been uploaded",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
+1
View File
@@ -297,6 +297,7 @@ export const dict = {
"prompt.action.stop": "Detener",
"prompt.toast.pasteUnsupported.title": "Adjunto no compatible",
"prompt.toast.attachmentDuplicate.title": "Este archivo ya se ha subido",
"prompt.toast.pasteUnsupported.description": "Solo se pueden adjuntar imágenes, PDFs o archivos de texto aquí.",
"prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo",
"prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.",
+1
View File
@@ -277,6 +277,7 @@ export const dict = {
"prompt.action.send": "Envoyer",
"prompt.action.stop": "Arrêter",
"prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge",
"prompt.toast.attachmentDuplicate.title": "Ce fichier a déjà été téléversé",
"prompt.toast.pasteUnsupported.description":
"Seules les images, les PDF ou les fichiers texte peuvent être joints ici.",
"prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle",
+1
View File
@@ -276,6 +276,7 @@ export const dict = {
"prompt.action.send": "送信",
"prompt.action.stop": "停止",
"prompt.toast.pasteUnsupported.title": "サポートされていない添付ファイル",
"prompt.toast.attachmentDuplicate.title": "このファイルはすでにアップロードされています",
"prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。",
"prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択",
"prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。",
+1
View File
@@ -264,6 +264,7 @@ export const dict = {
"prompt.action.send": "전송",
"prompt.action.stop": "중지",
"prompt.toast.pasteUnsupported.title": "지원되지 않는 첨부 파일",
"prompt.toast.attachmentDuplicate.title": "이 파일은 이미 업로드되었습니다",
"prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.",
"prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택",
"prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.",
+1
View File
@@ -288,6 +288,7 @@ export const dict = {
"prompt.action.stop": "Stopp",
"prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg",
"prompt.toast.attachmentDuplicate.title": "Denne filen er allerede lastet opp",
"prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.",
"prompt.toast.modelAgentRequired.title": "Velg en agent og modell",
"prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.",
+1
View File
@@ -278,6 +278,7 @@ export const dict = {
"prompt.action.send": "Wyślij",
"prompt.action.stop": "Zatrzymaj",
"prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik",
"prompt.toast.attachmentDuplicate.title": "Ten plik został już przesłany",
"prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.",
"prompt.toast.modelAgentRequired.title": "Wybierz agenta i model",
"prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.",
+1
View File
@@ -297,6 +297,7 @@ export const dict = {
"prompt.action.stop": "Остановить",
"prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение",
"prompt.toast.attachmentDuplicate.title": "Этот файл уже загружен",
"prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.",
"prompt.toast.modelAgentRequired.title": "Выберите агента и модель",
"prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.",
+1
View File
@@ -297,6 +297,7 @@ export const dict = {
"prompt.action.stop": "หยุด",
"prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ",
"prompt.toast.attachmentDuplicate.title": "ไฟล์นี้ถูกอัปโหลดแล้ว",
"prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ, PDF หรือไฟล์ข้อความเท่านั้น",
"prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล",
"prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์",
+1
View File
@@ -302,6 +302,7 @@ export const dict = {
"prompt.action.stop": "Durdur",
"prompt.toast.pasteUnsupported.title": "Desteklenmeyen ek",
"prompt.toast.attachmentDuplicate.title": "Bu dosya zaten yüklendi",
"prompt.toast.pasteUnsupported.description": "Buraya yalnızca resimler, PDF'ler veya metin dosyaları eklenebilir.",
"prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin",
"prompt.toast.modelAgentRequired.description": "Komut göndermeden önce bir ajan ve model seçin.",
+1
View File
@@ -299,6 +299,7 @@ export const dict = {
"prompt.action.stop": "Зупинити",
"prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення",
"prompt.toast.attachmentDuplicate.title": "Цей файл уже завантажено",
"prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.",
"prompt.toast.modelAgentRequired.title": "Виберіть агента та модель",
"prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.",
+1
View File
@@ -316,6 +316,7 @@ export const dict = {
"prompt.action.send": "发送",
"prompt.action.stop": "停止",
"prompt.toast.pasteUnsupported.title": "不支持的附件",
"prompt.toast.attachmentDuplicate.title": "此文件已上传",
"prompt.toast.pasteUnsupported.description": "此处仅能附加图片、PDF 或文本文件。",
"prompt.toast.modelAgentRequired.title": "请选择智能体和模型",
"prompt.toast.modelAgentRequired.description": "发送提示前请先选择智能体和模型。",
+1
View File
@@ -297,6 +297,7 @@ export const dict = {
"prompt.action.stop": "停止",
"prompt.toast.pasteUnsupported.title": "不支援的附件",
"prompt.toast.attachmentDuplicate.title": "此檔案已上傳",
"prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。",
"prompt.toast.modelAgentRequired.title": "請選擇代理程式和模型",
"prompt.toast.modelAgentRequired.description": "傳送提示前請先選擇代理程式和模型。",
+4 -5
View File
@@ -33,8 +33,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers"
import { toaster } from "@opencode-ai/ui/toast"
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { dismissToast, setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { useServerSDK } from "@/context/server-sdk"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { clearWorkspaceTerminals } from "@/context/terminal"
@@ -384,7 +383,7 @@ export default function LegacyLayout(props: ParentProps) {
const dismissSessionAlert = (sessionKey: string) => {
const toastId = toastBySession.get(sessionKey)
if (toastId === undefined) return
toaster.dismiss(toastId)
dismissToast(toastId)
toastBySession.delete(sessionKey)
alertedAtBySession.delete(sessionKey)
}
@@ -1459,7 +1458,7 @@ export default function LegacyLayout(props: ParentProps) {
title: language.t("workspace.resetting.title"),
description: language.t("workspace.resetting.description"),
})
const dismiss = () => toaster.dismiss(progress)
const dismiss = () => dismissToast(progress)
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
@@ -2450,7 +2449,7 @@ function UpdateAvailableToast(props: {
onCleanup(() => {
if (toastId === undefined) return
toaster.dismiss(toastId)
dismissToast(toastId)
})
return null
+15 -2
View File
@@ -1,6 +1,12 @@
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import { Toast, showToast as showLegacyToast, type ToastOptions, type ToastVariant } from "@opencode-ai/ui/toast"
import { ToastV2, showToastV2 } from "@opencode-ai/ui/v2/toast-v2"
import {
Toast,
showToast as showLegacyToast,
toaster as legacyToaster,
type ToastOptions,
type ToastVariant,
} from "@opencode-ai/ui/toast"
import { ToastV2, showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2"
let v2 = false
@@ -27,6 +33,13 @@ export function showToast(options: ToastOptions | string) {
})
}
// v1 and v2 ids come from separate registries, so dismissal has to use the same
// implementation that issued the id.
export function dismissToast(toastId: number) {
if (!v2) return legacyToaster.dismiss(toastId)
return toasterV2.dismiss(toastId)
}
function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastVariant | undefined) {
const name = icon ?? (variant === "success" ? "check" : undefined)
if (!name) return
@@ -1,7 +1,10 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments"
import { createPromptState } from "@/context/prompt"
import { createPromptInputV2Attachments } from "../../session-ui/src/v2/components/prompt-input/attachments"
import type { PromptInputV2Prompt } from "../../session-ui/src/v2/components/prompt-input/types"
describe("prompt attachment session ownership", () => {
test("adds an asynchronously read image to the session where the read started", async () => {
@@ -85,6 +88,88 @@ describe("prompt attachment session ownership", () => {
})
})
test("rejects a duplicate native clipboard attachment in the V2 prompt store", async () => {
await createRoot(async (dispose) => {
const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt })
const duplicate = Promise.withResolvers<void>()
const files = [
new File(["hello"], "clipboard-1.txt", { type: "text/plain" }),
new File(["hello"], "clipboard-2.txt", { type: "text/plain" }),
]
const attachments = createPromptInputV2Attachments({
capture: () => ({
current: () => state.prompt,
cursor: () => 0,
set: (prompt) => setState("prompt", prompt),
}),
editor: () => document.createElement("div"),
focusEditor: () => undefined,
addPart: () => false,
setDraggingType: () => undefined,
directory: () => "/",
isDialogActive: () => false,
warn: () => undefined,
duplicate: duplicate.resolve,
onError: () => undefined,
readClipboardImage: async () => files.shift() ?? null,
})
const event = {
clipboardData: { items: [], getData: () => "" },
preventDefault: () => undefined,
stopPropagation: () => undefined,
} as unknown as ClipboardEvent
await attachments.handlePaste(event)
await attachments.handlePaste(event)
await duplicate.promise
expect(state.prompt).toHaveLength(1)
dispose()
})
})
test("rejects desktop duplicates and keeps changed files in the V2 prompt store", async () => {
await createRoot(async (dispose) => {
const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt })
const duplicates: string[] = []
const attachments = createPromptInputV2Attachments({
capture: () => ({
current: () => state.prompt,
cursor: () => 0,
set: (prompt) => setState("prompt", prompt),
}),
editor: () => document.createElement("div"),
focusEditor: () => undefined,
addPart: () => false,
setDraggingType: () => undefined,
directory: () => "/",
isDialogActive: () => false,
warn: () => undefined,
duplicate: () => duplicates.push("duplicate"),
onError: () => undefined,
getPathForFile: (file) => (file.name === "browser.txt" ? "" : `/tmp/${file.name}`),
})
const first = new File(["first"], "a.txt", { type: "text/plain" })
const second = new File(["second"], "b.txt", { type: "text/plain" })
await attachments.addAttachments([first, second])
await attachments.addAttachments([first, second])
expect(state.prompt).toHaveLength(2)
expect(duplicates).toEqual(["duplicate", "duplicate"])
await attachments.addAttachments([new File(["edited"], "a.txt", { type: "text/plain" })])
expect(state.prompt).toHaveLength(3)
await attachments.addAttachments([
new File(["same"], "browser.txt", { type: "text/plain" }),
new File(["same"], "browser.txt", { type: "text/plain" }),
])
expect(state.prompt).toHaveLength(4)
expect(duplicates).toHaveLength(3)
dispose()
})
})
function images(prompt: ReturnType<typeof createPromptState>) {
return prompt.current().filter((part) => part.type === "image")
}
+39 -1
View File
@@ -1,8 +1,46 @@
import { describe, expect, test } from "bun:test"
import { beforeEach, describe, expect, test } from "bun:test"
import { createSignal, type JSX } from "solid-js"
import { showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2"
describe("showToastV2", () => {
// The toast registry is module state, so each test starts from an empty stack.
beforeEach(() => {
toasterV2.dismiss()
})
test("coalesces exact active content", () => {
const first = showToastV2({ title: "Repeated error", description: "Try again" })
const second = showToastV2({ title: "Repeated error", description: "Try again" })
const different = showToastV2({ title: "Repeated error", description: "A different error" })
expect(second).toBe(first)
expect(different).not.toBe(first)
toasterV2.dismiss(first)
toasterV2.dismiss(different)
})
test("allows dismissed content to appear again", () => {
const first = showToastV2("Dismiss and retry")
toasterV2.dismiss(first)
const second = showToastV2("Dismiss and retry")
expect(second).not.toBe(first)
toasterV2.dismiss(second)
})
test("recreates matching content when it is not the topmost toast", () => {
const first = showToastV2("First toast")
const topmost = showToastV2("Topmost toast")
const repeated = showToastV2("First toast")
expect(repeated).not.toBe(first)
toasterV2.dismiss(topmost)
toasterV2.dismiss(repeated)
})
test("creates no reactive computations at call time", () => {
const [tick, setTick] = createSignal(0)
let reads = 0
@@ -0,0 +1,40 @@
import type { Stripe } from "stripe"
export function liteCheckoutSubscriptionToProvision(
type: "checkout.session.completed" | "checkout.session.async_payment_succeeded",
session: Pick<Stripe.Checkout.Session, "mode" | "payment_status" | "subscription">,
) {
if (session.mode !== "subscription") return
if (typeof session.subscription !== "string") return
if (type === "checkout.session.completed" && session.payment_status === "unpaid") return
return session.subscription
}
export function liteSubscriptionProvisioningData(
subscription: Pick<
Stripe.Subscription,
"id" | "status" | "customer" | "default_payment_method" | "metadata"
>,
) {
if (subscription.metadata.type !== "lite") return
if (subscription.status !== "active" && subscription.status !== "trialing") return
if (typeof subscription.customer !== "string") throw new Error("Customer ID not found")
const workspaceID = subscription.metadata.workspaceID
const userID = subscription.metadata.userID
if (!workspaceID) throw new Error("Workspace ID not found")
if (!userID) throw new Error("User ID not found")
return {
subscriptionID: subscription.id,
customerID: subscription.customer,
workspaceID,
userID,
userEmail: subscription.metadata.userEmail || undefined,
coupon: subscription.metadata.coupon || undefined,
paymentMethodID:
typeof subscription.default_payment_method === "string"
? subscription.default_payment_method
: subscription.default_payment_method?.id,
}
}
+101 -79
View File
@@ -10,6 +10,10 @@ import { Resource } from "@opencode-ai/console-resource"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { BlackData } from "@opencode-ai/console-core/black.js"
import { Referral } from "@opencode-ai/console-core/referral.js"
import {
liteCheckoutSubscriptionToProvision,
liteSubscriptionProvisioningData,
} from "./lite-subscription.js"
export async function POST(input: APIEvent) {
const body = await Billing.stripe().webhooks.constructEventAsync(
@@ -20,6 +24,10 @@ export async function POST(input: APIEvent) {
console.log(body.type, JSON.stringify(body, null, 2))
return (async () => {
if (body.type === "checkout.session.completed" || body.type === "checkout.session.async_payment_succeeded") {
const subscriptionID = liteCheckoutSubscriptionToProvision(body.type, body.data.object)
if (subscriptionID) await provisionLiteSubscription(subscriptionID)
}
if (body.type === "customer.updated") {
// check default payment method changed
const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {}
@@ -105,85 +113,6 @@ export async function POST(input: APIEvent) {
})
})
}
if (body.type === "customer.subscription.created") {
const type = body.data.object.metadata?.type
if (type === "lite") {
const workspaceID = body.data.object.metadata?.workspaceID
const userID = body.data.object.metadata?.userID
const userEmail = body.data.object.metadata?.userEmail
const coupon = body.data.object.metadata?.coupon
const customerID = body.data.object.customer as string
const invoiceID = body.data.object.latest_invoice as string
const subscriptionID = body.data.object.id as string
const paymentMethodID = body.data.object.default_payment_method as string
if (!workspaceID) throw new Error("Workspace ID not found")
if (!userID) throw new Error("User ID not found")
if (!customerID) throw new Error("Customer ID not found")
if (!invoiceID) throw new Error("Invoice ID not found")
if (!subscriptionID) throw new Error("Subscription ID not found")
if (!paymentMethodID) throw new Error("Payment method ID not found")
// get payment method for the payment intent
const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID)
await Actor.provide("system", { workspaceID }, async () => {
// look up current billing
const billing = await Billing.get()
if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`)
if (billing.customerID && billing.customerID !== customerID) throw new Error("Customer ID mismatch")
// set customer metadata
if (!billing?.customerID) {
await Billing.stripe().customers.update(customerID, {
metadata: {
workspaceID,
},
})
}
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
customerID,
liteSubscriptionID: subscriptionID,
lite: {},
paymentMethodID: paymentMethod.id,
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
paymentMethodType: paymentMethod.type,
})
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.insert(LiteTable).values({
workspaceID,
id: Identifier.create("lite"),
userID: userID,
})
if (userEmail) {
if (coupon === LiteData.firstMonth50Coupon) {
await Billing.redeemCoupon(userEmail, "GO1MONTH50")
} else if (coupon === LiteData.firstMonth100Coupon) {
await Billing.redeemCoupon(userEmail, "GOFREEMONTH")
} else if (coupon === LiteData.threeMonths100Coupon) {
await Billing.redeemCoupon(userEmail, "GO3MONTHS100")
} else if (coupon === LiteData.sixMonths100Coupon) {
await Billing.redeemCoupon(userEmail, "GO6MONTHS100")
} else if (coupon === LiteData.twelveMonths100Coupon) {
await Billing.redeemCoupon(userEmail, "GO12MONTHS100")
}
}
})
await Referral.completeFromLiteSubscription({
workspaceID,
userID,
}).catch((error) => {
console.error("Referral sync failed", error)
})
})
}
}
if (body.type === "customer.subscription.updated" && body.data.object.status === "incomplete_expired") {
const subscriptionID = body.data.object.id
if (!subscriptionID) throw new Error("Subscription ID not found")
@@ -221,6 +150,10 @@ export async function POST(input: APIEvent) {
if (!invoiceID) throw new Error("Invoice ID not found")
if (!subscriptionID) throw new Error("Subscription ID not found")
if (body.data.object.billing_reason === "subscription_create" && productID === LiteData.productID()) {
await provisionLiteSubscription(subscriptionID)
}
// get coupon id from subscription
const invoice = await Billing.stripe().invoices.retrieve(invoiceID, {
expand: ["discounts", "payments"],
@@ -378,3 +311,92 @@ export async function POST(input: APIEvent) {
return Response.json({ message: error.message }, { status: 500 })
})
}
async function provisionLiteSubscription(subscriptionID: string) {
const stripe = Billing.stripe()
const subscription = liteSubscriptionProvisioningData(await stripe.subscriptions.retrieve(subscriptionID))
if (!subscription) return
await Actor.provide("system", { workspaceID: subscription.workspaceID }, async () => {
const billing = await Billing.get()
if (!billing) throw new Error(`Workspace with ID ${subscription.workspaceID} not found`)
if (billing.customerID && billing.customerID !== subscription.customerID) throw new Error("Customer ID mismatch")
if (billing.liteSubscriptionID === subscription.subscriptionID) return
const paymentMethod = subscription.paymentMethodID
? await stripe.paymentMethods.retrieve(subscription.paymentMethodID)
: undefined
if (!billing.customerID) {
await stripe.customers.update(subscription.customerID, {
metadata: {
workspaceID: subscription.workspaceID,
},
})
}
const provisioned = await Database.transaction(async (tx) => {
const current = await tx
.select({
customerID: BillingTable.customerID,
liteSubscriptionID: BillingTable.liteSubscriptionID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, subscription.workspaceID))
.for("update")
.then((rows) => rows[0])
if (!current) throw new Error(`Workspace with ID ${subscription.workspaceID} not found`)
if (current.customerID && current.customerID !== subscription.customerID) throw new Error("Customer ID mismatch")
if (current.liteSubscriptionID === subscription.subscriptionID) return false
if (current.liteSubscriptionID) throw new Error("Workspace already has another Go subscription")
await tx
.update(BillingTable)
.set({
customerID: subscription.customerID,
liteSubscriptionID: subscription.subscriptionID,
lite: {},
...(paymentMethod
? {
paymentMethodID: paymentMethod.id,
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
paymentMethodType: paymentMethod.type,
}
: {}),
})
.where(eq(BillingTable.workspaceID, subscription.workspaceID))
await tx
.insert(LiteTable)
.ignore()
.values({
workspaceID: subscription.workspaceID,
id: Identifier.create("lite"),
userID: subscription.userID,
})
if (subscription.userEmail) {
if (subscription.coupon === LiteData.firstMonth50Coupon) {
await Billing.redeemCoupon(subscription.userEmail, "GO1MONTH50")
} else if (subscription.coupon === LiteData.firstMonth100Coupon) {
await Billing.redeemCoupon(subscription.userEmail, "GOFREEMONTH")
} else if (subscription.coupon === LiteData.threeMonths100Coupon) {
await Billing.redeemCoupon(subscription.userEmail, "GO3MONTHS100")
} else if (subscription.coupon === LiteData.sixMonths100Coupon) {
await Billing.redeemCoupon(subscription.userEmail, "GO6MONTHS100")
} else if (subscription.coupon === LiteData.twelveMonths100Coupon) {
await Billing.redeemCoupon(subscription.userEmail, "GO12MONTHS100")
}
}
return true
})
if (!provisioned) return
await Referral.completeFromLiteSubscription({
workspaceID: subscription.workspaceID,
userID: subscription.userID,
}).catch((error) => {
console.error("Referral sync failed", error)
})
})
}
@@ -0,0 +1,66 @@
import { expect, test } from "bun:test"
import {
liteCheckoutSubscriptionToProvision,
liteSubscriptionProvisioningData,
} from "../src/routes/stripe/lite-subscription"
test("waits for an unpaid Checkout Session's asynchronous payment", () => {
const session = {
mode: "subscription" as const,
payment_status: "unpaid" as const,
subscription: "sub_go",
}
expect(liteCheckoutSubscriptionToProvision("checkout.session.completed", session)).toBeUndefined()
expect(liteCheckoutSubscriptionToProvision("checkout.session.async_payment_succeeded", session)).toBe("sub_go")
})
test("provisions a paid Checkout Session immediately", () => {
expect(
liteCheckoutSubscriptionToProvision("checkout.session.completed", {
mode: "subscription",
payment_status: "paid",
subscription: "sub_go",
}),
).toBe("sub_go")
})
test("provisions an active Go subscription without a default payment method", () => {
expect(
liteSubscriptionProvisioningData({
id: "sub_go",
status: "active",
customer: "cus_go",
default_payment_method: null,
metadata: {
type: "lite",
workspaceID: "wrk_go",
userID: "usr_go",
},
}),
).toEqual({
subscriptionID: "sub_go",
customerID: "cus_go",
workspaceID: "wrk_go",
userID: "usr_go",
userEmail: undefined,
coupon: undefined,
paymentMethodID: undefined,
})
})
test("waits for an asynchronous Go subscription to become active", () => {
expect(
liteSubscriptionProvisioningData({
id: "sub_go",
status: "incomplete",
customer: "cus_go",
default_payment_method: null,
metadata: {
type: "lite",
workspaceID: "wrk_go",
userID: "usr_go",
},
}),
).toBeUndefined()
})
+1 -1
View File
@@ -108,7 +108,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.0",
"gitlab-ai-provider": "6.12.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
+1 -1
View File
@@ -120,7 +120,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.0",
"gitlab-ai-provider": "6.12.1",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
+2
View File
@@ -13,6 +13,7 @@ import { CodexAuthPlugin } from "./openai/codex"
import { Session } from "@/session/session"
import { NamedError } from "@opencode-ai/core/util/error"
import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { ModalPlugin } from "./modal/modal"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
@@ -70,6 +71,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
}),
CopilotAuthPlugin,
ModalPlugin,
GitlabAuthPlugin,
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
@@ -0,0 +1,17 @@
import type { Hooks } from "@opencode-ai/plugin"
import { ModalModels } from "./models"
export async function ModalPlugin(): Promise<Hooks> {
return {
provider: {
id: "modal",
async models(provider, ctx) {
const apiKey = ctx.auth?.type === "api" ? ctx.auth.key : process.env.MODAL_PROXY_TOKEN
const baseURL = Object.values(provider.models)[0]?.api.url
if (!apiKey || !baseURL) return {}
return ModalModels.get(baseURL, apiKey, provider.models).catch(() => ({}))
},
},
}
}
@@ -0,0 +1,133 @@
import type { Model } from "@opencode-ai/sdk/v2"
import { Schema } from "effect"
const reasoningOption = Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.NullOr(Schema.String)),
})
const response = Schema.Struct({
data: Schema.Array(
Schema.Struct({
id: Schema.String,
base_model_id: Schema.optional(Schema.String),
hugging_face_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
input_modalities: Schema.optional(Schema.Array(Schema.String)),
output_modalities: Schema.optional(Schema.Array(Schema.String)),
context_length: Schema.optional(Schema.Number),
max_output_length: Schema.optional(Schema.Number),
pricing: Schema.optional(
Schema.Struct({
prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
}),
),
supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)),
supported_features: Schema.optional(Schema.Array(Schema.String)),
reasoning_options: Schema.optional(Schema.Array(reasoningOption)),
interleaved: Schema.optional(
Schema.Union([
Schema.Boolean,
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
}),
]),
),
}),
),
})
const decode = Schema.decodeUnknownSync(response)
function price(value: string | number | undefined, fallback: number) {
if (value === undefined) return fallback
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed * 1_000_000 : fallback
}
export async function get(baseURL: string, apiKey: string, existing: Record<string, Model>) {
const data = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(3_000),
}).then(async (res) => {
if (!res.ok) throw new Error(`Failed to fetch Modal models: ${res.status}`)
return decode(await res.json())
})
return Object.fromEntries(
data.data.map((item) => {
const template = existing[item.base_model_id ?? item.hugging_face_id ?? item.id]
const model: Model = {
id: item.id,
providerID: "modal",
name: item.name ?? template?.name ?? item.id,
family: template?.family,
api: {
id: item.id,
url: baseURL,
npm: template?.api.npm ?? "@ai-sdk/openai-compatible",
},
status: template?.status ?? "active",
headers: { ...template?.headers },
options: { ...template?.options },
cost: {
input: price(item.pricing?.prompt, template?.cost.input ?? 0),
output: price(item.pricing?.completion, template?.cost.output ?? 0),
cache: {
read: price(item.pricing?.input_cache_read, template?.cost.cache.read ?? 0),
write: template?.cost.cache.write ?? 0,
},
},
limit: {
context: item.context_length ?? template?.limit.context ?? 0,
input: template?.limit.input,
output: item.max_output_length ?? template?.limit.output ?? 0,
},
capabilities: {
temperature:
item.supported_sampling_parameters?.includes("temperature") ?? template?.capabilities.temperature ?? false,
reasoning: item.supported_features?.includes("reasoning") ?? template?.capabilities.reasoning ?? false,
attachment:
item.input_modalities?.some((modality) => modality !== "text") ??
template?.capabilities.attachment ??
false,
toolcall: item.supported_features?.includes("tools") ?? template?.capabilities.toolcall ?? true,
input: {
text: item.input_modalities?.includes("text") ?? template?.capabilities.input.text ?? true,
audio: item.input_modalities?.includes("audio") ?? template?.capabilities.input.audio ?? false,
image: item.input_modalities?.includes("image") ?? template?.capabilities.input.image ?? false,
video: item.input_modalities?.includes("video") ?? template?.capabilities.input.video ?? false,
pdf: item.input_modalities?.includes("pdf") ?? template?.capabilities.input.pdf ?? false,
},
output: {
text: item.output_modalities?.includes("text") ?? template?.capabilities.output.text ?? true,
audio: item.output_modalities?.includes("audio") ?? template?.capabilities.output.audio ?? false,
image: item.output_modalities?.includes("image") ?? template?.capabilities.output.image ?? false,
video: item.output_modalities?.includes("video") ?? template?.capabilities.output.video ?? false,
pdf: item.output_modalities?.includes("pdf") ?? template?.capabilities.output.pdf ?? false,
},
interleaved: item.interleaved ?? template?.capabilities.interleaved ?? false,
},
release_date: template?.release_date ?? "",
}
model.variants =
item.reasoning_options === undefined
? template?.variants
: Object.fromEntries(
item.reasoning_options.flatMap((option) =>
option.values.map((value) => {
const effort = value ?? "none"
return [effort, { reasoningEffort: effort }]
}),
),
)
return [item.id, model]
}),
)
}
export * as ModalModels from "./models"
@@ -0,0 +1,198 @@
import { expect, test } from "bun:test"
import type { Model, Provider } from "@opencode-ai/sdk/v2"
import { ModalPlugin } from "@/plugin/modal/modal"
const BASE_MODEL_ID = "thinkingmachines/Inkling-NVFP4"
const RUNTIME_MODEL_ID = "workspace--inkling.us-west.modal.direct"
const FALLBACK_RUNTIME_MODEL_ID = "workspace--inkling-fallback.us-west.modal.direct"
function makeProvider(baseURL: string): Provider {
const template: Model = {
id: BASE_MODEL_ID,
providerID: "modal",
name: "Inkling",
family: "ling",
api: {
id: BASE_MODEL_ID,
url: baseURL,
npm: "@ai-sdk/openai-compatible",
},
status: "active",
headers: {},
options: {},
cost: {
input: 1,
output: 4,
cache: {
read: 0.2,
write: 0,
},
},
limit: {
context: 128_000,
output: 8_192,
},
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: {
text: true,
audio: true,
image: true,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: {
field: "reasoning_content",
},
},
release_date: "2026-07-15",
variants: {
fallback: {
reasoningEffort: "fallback",
},
},
}
return {
id: "modal",
name: "Modal",
source: "api",
env: ["MODAL_PROXY_TOKEN"],
options: {},
models: {
[template.id]: template,
},
}
}
test("discovers Modal workspace models", async () => {
const requests: Array<{ authorization: string | null; path: string }> = []
using server = Bun.serve({
port: 0,
fetch(request) {
requests.push({
authorization: request.headers.get("authorization"),
path: new URL(request.url).pathname,
})
return Response.json({
data: [
{
id: RUNTIME_MODEL_ID,
base_model_id: BASE_MODEL_ID,
name: "Thinking Machines: Inkling",
input_modalities: ["text", "image", "audio"],
output_modalities: ["text"],
context_length: 1_048_576,
max_output_length: 262_144,
pricing: {
prompt: "0.0000012",
completion: "0.000005",
input_cache_read: "0.00000027",
},
supported_sampling_parameters: ["temperature"],
supported_features: ["tools", "reasoning"],
reasoning_options: [
{
type: "effort",
values: ["none", "low", "medium", "high", "xhigh", "max"],
},
],
interleaved: {
field: "reasoning_content",
},
},
{
id: FALLBACK_RUNTIME_MODEL_ID,
base_model_id: BASE_MODEL_ID,
},
],
})
},
})
const provider = makeProvider(`${server.url}v1`)
const plugin = await ModalPlugin()
const models = await plugin.provider!.models!(provider, {
auth: {
type: "api",
key: "test-token",
},
})
const model = models[RUNTIME_MODEL_ID]
expect(requests).toEqual([
{
authorization: "Bearer test-token",
path: "/v1/models",
},
])
expect(Object.keys(models)).toEqual([RUNTIME_MODEL_ID, FALLBACK_RUNTIME_MODEL_ID])
expect(model.api).toEqual({
id: RUNTIME_MODEL_ID,
url: `${server.url}v1`,
npm: "@ai-sdk/openai-compatible",
})
expect(model.family).toBe("ling")
expect(model.capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(model.capabilities.input).toEqual({
text: true,
audio: true,
image: true,
video: false,
pdf: false,
})
expect(model.cost).toEqual({
input: 1.2,
output: 5,
cache: {
read: 0.27,
write: 0,
},
})
expect(model.limit).toEqual({
context: 1_048_576,
output: 262_144,
})
expect(model.variants).toEqual({
none: { reasoningEffort: "none" },
low: { reasoningEffort: "low" },
medium: { reasoningEffort: "medium" },
high: { reasoningEffort: "high" },
xhigh: { reasoningEffort: "xhigh" },
max: { reasoningEffort: "max" },
})
expect(models[FALLBACK_RUNTIME_MODEL_ID].variants).toEqual({
fallback: {
reasoningEffort: "fallback",
},
})
})
test("hides Modal models when discovery fails", async () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response(null, { status: 503 })
},
})
const plugin = await ModalPlugin()
const models = await plugin.provider!.models!(makeProvider(`${server.url}v1`), {
auth: {
type: "api",
key: "test-token",
},
})
expect(models).toEqual({})
})
@@ -0,0 +1,44 @@
@keyframes prompt-input-v2-attachments-fade-left {
from {
visibility: hidden;
}
to {
visibility: visible;
}
}
@keyframes prompt-input-v2-attachments-fade-right {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
[data-component="prompt-input-v2-attachments"] {
timeline-scope: --prompt-input-v2-attachments-scroll;
[data-slot^="prompt-attachments-fade-"] {
visibility: hidden;
}
}
@supports (animation-timeline: --prompt-input-v2-attachments-scroll) and
(timeline-scope: --prompt-input-v2-attachments-scroll) {
[data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-scroll"] {
scroll-timeline: --prompt-input-v2-attachments-scroll x;
}
[data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-fade-left"] {
animation: prompt-input-v2-attachments-fade-left linear both;
animation-timeline: --prompt-input-v2-attachments-scroll;
animation-range: 0 0.1px;
}
[data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-fade-right"] {
animation: prompt-input-v2-attachments-fade-right linear both;
animation-timeline: --prompt-input-v2-attachments-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}
@@ -74,6 +74,7 @@ export type PromptInputV2AttachmentConfig = {
directory: () => string
isDialogActive: () => boolean
warn: () => void
duplicate: () => void
onError: (error: unknown) => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
@@ -94,7 +95,7 @@ export function createPromptInputV2Attachments(
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
}
const add = async (file: File, toast = true, target = capture()) => {
const add = async (file: File, toast = true, target = capture(), clipboard = false) => {
if (!target) return false
const mime = await attachmentMime(file)
if (!mime) {
@@ -103,11 +104,28 @@ export function createPromptInputV2Attachments(
}
const url = await dataUrl(file, mime)
if (!url) return false
const sourcePath = input.getPathForFile?.(file) || undefined
// Native clipboard images arrive with a fresh timestamped filename on every paste, so identical
// clipboard content is matched on bytes alone.
const duplicate = target.prompt
.current()
.some(
(part) =>
part.type === "image" &&
part.dataUrl === url &&
(sourcePath
? part.sourcePath === sourcePath
: !part.sourcePath && (clipboard || part.filename === file.name)),
)
if (duplicate) {
input.duplicate()
return true
}
const attachment: PromptInputV2Attachment = {
type: "image",
id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2),
filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined,
sourcePath,
mime,
dataUrl: url,
}
@@ -141,7 +159,7 @@ export function createPromptInputV2Attachments(
const plainText = clipboardData.getData("text/plain") ?? ""
if (input.readClipboardImage && !plainText) {
const file = await input.readClipboardImage()
if (file && (await add(file, true, target))) return
if (file && (await add(file, true, target, true))) return
}
if (!plainText) return
const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText
@@ -21,6 +21,7 @@ import type {
PromptInputV2Suggestion,
} from "./types"
import type { PromptInputV2Interaction, PromptInputV2SelectControl } from "./interaction"
import "./attachments.css"
export type {
PromptInputV2Attachment,
@@ -107,7 +108,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
<form
data-component="prompt-input-v2"
data-dock-border-underlay={props.borderUnderlay ? "v2" : undefined}
class="group/prompt-input relative min-h-[96px] w-full rounded-xl bg-v2-background-bg-base"
class="group/prompt-input relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
classList={{
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
@@ -378,7 +379,7 @@ export function PromptInputV2Attachments(props: {
}) {
return (
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
<div data-slot="prompt-attachments" class="relative">
<div data-component="prompt-input-v2-attachments" data-slot="prompt-attachments" class="relative">
<div
data-slot="prompt-attachments-scroll"
class="flex flex-nowrap gap-2 overflow-x-auto no-scrollbar px-2 pt-2 pb-1"
@@ -444,8 +445,14 @@ export function PromptInputV2Attachments(props: {
)}
</For>
</div>
<div class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]" />
<div class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]" />
<div
data-slot="prompt-attachments-fade-left"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
/>
<div
data-slot="prompt-attachments-fade-right"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
/>
</div>
</Show>
)
@@ -63,7 +63,7 @@
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 16px 8px 8px;
padding: 12px 16px 12px 8px;
flex-shrink: 0;
}
@@ -91,7 +91,7 @@
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-filter"] {
padding: 0 8px 8px;
padding: 0 8px 4px;
flex-shrink: 0;
min-width: 0;
box-sizing: border-box;
@@ -109,10 +109,71 @@
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] {
flex: 1;
min-height: 0;
timeline-scope: --session-review-v2-sidebar-tree-scroll;
}
@keyframes session-review-v2-sidebar-tree-fade-top {
from {
visibility: hidden;
}
to {
visibility: visible;
}
}
@keyframes session-review-v2-sidebar-tree-fade-bottom {
from {
visibility: visible;
}
to {
visibility: hidden;
}
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before,
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after {
content: "";
position: absolute;
inset-inline: 0;
z-index: 10;
height: 16px;
pointer-events: none;
visibility: hidden;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before {
top: 0;
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after {
bottom: 0;
background: linear-gradient(to top, var(--v2-background-bg-base), transparent);
}
@supports (animation-timeline: --session-review-v2-sidebar-tree-scroll) and
(timeline-scope: --session-review-v2-sidebar-tree-scroll) {
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-tree"]
.scroll-view__viewport {
scroll-timeline: --session-review-v2-sidebar-tree-scroll y;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before {
animation: session-review-v2-sidebar-tree-fade-top linear both;
animation-timeline: --session-review-v2-sidebar-tree-scroll;
animation-range: 0 0.1px;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after {
animation: session-review-v2-sidebar-tree-fade-bottom linear both;
animation-timeline: --session-review-v2-sidebar-tree-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport {
padding: 0 8px 12px;
padding: 4px 8px 12px;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__thumb {
@@ -244,7 +305,7 @@
}
[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"] {
background-color: var(--v2-overlay-simple-overlay-pressed);
background-color: var(--v2-background-bg-layer-02);
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] {
@@ -165,11 +165,13 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
}
const prev = () => {
return props.files[fileIndex() - 1]
if (!canCycle()) return
return props.files[(fileIndex() - 1 + props.files.length) % props.files.length]
}
const next = () => {
return props.files[fileIndex() + 1]
if (!canCycle()) return
return props.files[(fileIndex() + 1) % props.files.length]
}
const canCycle = () => props.files.length > 0
@@ -183,15 +185,15 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
props.onSelectFile(file)
}
// The prev/next tooltips advertise < and >; keep the keys working while the
// Keep the advertised arrow keys working while the
// pane is mounted, but never while typing in an input or comment editor.
makeEventListener(document, "keydown", (event) => {
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return
if (event.key !== "<" && event.key !== ">") return
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
const target = event.target
if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return
if (!props.hasDiffs || !canCycle()) return
const file = event.key === "<" ? prev() : next()
const file = event.key === "ArrowLeft" ? prev() : next()
if (!file) return
event.preventDefault()
cycle(file)
@@ -219,7 +221,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
value={
<>
{i18n.t("ui.sessionReviewV2.previousFile")}
<KeybindV2 keys={["<"]} variant="neutral" />
<KeybindV2 keys={[""]} variant="neutral" />
</>
}
>
@@ -239,7 +241,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
value={
<>
{i18n.t("ui.sessionReviewV2.nextFile")}
<KeybindV2 keys={[">"]} variant="neutral" />
<KeybindV2 keys={[""]} variant="neutral" />
</>
}
>
@@ -331,6 +333,7 @@ export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled?
class="session-review-v2-sidebar-toggle"
aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")}
aria-expanded={props.opened}
data-expanded={props.opened ? "" : undefined}
disabled={props.disabled}
onClick={props.onToggle}
icon={<Icon name="filetree" />}
+1
View File
@@ -104,6 +104,7 @@
"remend": "catalog:",
"shiki": "catalog:",
"solid-list": "catalog:",
"solid-sonner": "catalog:",
"strip-ansi": "7.1.2"
},
"peerDependencies": {
+173 -163
View File
@@ -1,58 +1,182 @@
[data-component="toast-v2-region"] {
position: fixed;
bottom: 48px;
right: 32px;
.toast-v2-region {
z-index: 1000;
display: flex;
flex-direction: column;
gap: 12px;
max-width: min(320px, calc(100vw - 64px));
max-height: none;
width: 100%;
overflow: visible;
pointer-events: none;
[data-slot="toast-v2-list"] {
display: flex;
flex-direction: column;
gap: 12px;
list-style: none;
margin: 0;
padding: 0;
max-height: none;
overflow-y: visible;
overflow-x: visible;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
user-select: none;
-webkit-user-select: none;
}
[data-component="toast-v2"] {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
.toast-v2 {
display: grid;
grid-template-columns: minmax(0, 1fr) 20px;
column-gap: 12px;
row-gap: 12px;
width: 320px;
max-width: 100%;
padding: 12px;
max-height: min(420px, calc(100dvh - 96px));
overflow: hidden;
pointer-events: auto;
transition: transform 140ms ease-out;
box-sizing: border-box;
outline: none;
user-select: none;
-webkit-user-select: none;
border-radius: 8px;
color: var(--v2-text-text-base);
background: var(--v2-background-bg-layer-01);
box-shadow: var(--v2-elevation-floating);
transition:
transform 280ms cubic-bezier(0.2, 0, 0, 1),
opacity 160ms ease-out,
height 280ms cubic-bezier(0.2, 0, 0, 1),
box-shadow 160ms ease-out;
&[data-opened] {
animation: toastV2PopIn 140ms ease-out;
&:has(> [data-icon]) {
grid-template-columns: 16px minmax(0, 1fr) 20px;
}
&[data-closed] {
animation: toastV2PopOut 100ms ease-in forwards;
&:focus-visible {
box-shadow:
var(--v2-elevation-floating),
0 0 0 2px var(--v2-border-border-focus);
}
&[data-expanded="true"] {
overflow: visible;
}
&[data-expanded="false"][data-front="false"] > * {
opacity: 0;
}
&[data-expanded="false"][data-front="false"] {
--y: translateY(calc(-1 * var(--toasts-before) * 9.5px)) scale(calc(1 - var(--toasts-before) * 0.05));
}
> [data-icon] {
grid-column: 1;
grid-row: 1;
align-self: start;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 20px;
color: var(--v2-icon-icon-base);
}
> [data-content] {
grid-column: 1;
grid-row: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
overflow: hidden;
}
&:has(> [data-icon]) > [data-content] {
grid-column: 2;
}
> [data-close-button] {
grid-column: 2;
grid-row: 1;
position: static;
align-self: start;
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
transform: none;
background: transparent;
color: var(--v2-icon-icon-muted);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
&:has(> [data-icon]) > [data-close-button] {
grid-column: 3;
}
> [data-close-button]:hover {
background: var(--v2-overlay-simple-overlay-hover);
color: var(--v2-icon-icon-base);
}
> [data-close-button]:active {
background: var(--v2-overlay-simple-overlay-pressed);
}
> [data-close-button]:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
> [data-icon] > *,
> [data-icon] svg,
> [data-close-button] svg {
width: 16px;
height: 16px;
display: block;
flex-shrink: 0;
}
[data-title],
[data-slot="toast-v2-title"] {
color: var(--v2-text-text-base);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
margin: 0;
}
[data-description],
[data-slot="toast-v2-description"] {
color: var(--v2-text-text-muted);
text-wrap-style: pretty;
overflow-wrap: anywhere;
word-break: break-word;
font-size: 13px;
font-style: normal;
font-weight: 440;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
margin: 0;
}
[data-slot="toast-v2-actions"] {
grid-column: 1;
grid-row: 2;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&:has(> [data-icon]) [data-slot="toast-v2-actions"] {
grid-column: 2;
}
[data-slot="toast-v2-actions"] [data-component="button-v2"] {
min-height: 24px;
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
}
[data-slot="toast-v2-header"] {
@@ -69,34 +193,7 @@
justify-content: center;
width: 16px;
height: 20px;
min-width: 16px;
min-height: 20px;
color: var(--v2-icon-icon-base);
[data-component="icon"] {
color: var(--v2-icon-icon-base);
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
}
> * {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
svg {
width: 16px;
height: 16px;
display: block;
flex-shrink: 0;
}
}
[data-slot="toast-v2-content"] {
@@ -104,112 +201,25 @@
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
min-width: 0;
overflow: hidden;
}
}
[data-slot="toast-v2-title"] {
color: var(--v2-text-text-base);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
margin: 0;
}
[data-slot="toast-v2-description"] {
color: var(--v2-text-text-muted);
text-wrap-style: pretty;
overflow-wrap: anywhere;
word-break: break-word;
font-size: 13px;
font-style: normal;
font-weight: 440;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
margin: 0;
}
[data-slot="toast-v2-actions"] {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-left: 28px;
}
[data-slot="toast-v2-actions"] [data-component="button-v2"] {
min-height: 24px;
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
}
[data-slot="toast-v2-close-button"] {
flex-shrink: 0;
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-icon-icon-muted);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--v2-overlay-simple-overlay-hover);
color: var(--v2-icon-icon-base);
}
&:active {
background: var(--v2-overlay-simple-overlay-pressed);
}
&:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
svg {
width: 16px;
height: 16px;
display: block;
}
@media (prefers-reduced-motion: reduce) {
.toast-v2,
.toast-v2 > * {
animation: none !important;
transition: none !important;
}
}
@keyframes toastV2PopIn {
from {
opacity: 0.8;
transform: translateY(6px);
@media (max-width: 600px) {
.toast-v2-region {
max-width: calc(100vw - 32px);
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes toastV2PopOut {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(6px);
opacity: 0.8;
.toast-v2 {
width: 100%;
}
}
@@ -19,7 +19,7 @@ Use brief titles/descriptions; limit actions to 1-2.
- Toasts render in a portal and auto-dismiss unless persistent.
### Accessibility
- TODO: confirm aria-live behavior from Kobalte Toast.
- The region announces additions through an ARIA live region and expands with Alt+T.
### Theming/tokens
- Uses \`data-component="toast-v2"\` and slot data attributes.
@@ -57,10 +57,10 @@ export const AllExamples = {
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.5554 10.4453V13.5564H11.7777H4.22211C3.23989 13.5564 2.44434 13.5564 2.44434 13.5564V10.4453"
stroke="var(--icon-icon-base)"
stroke="currentColor"
/>
<path d="M4.88867 6L7.99978 9.11111L11.1109 6" stroke="var(--icon-icon-base)" />
<path d="M8 9.11198V2.44531" stroke="var(--icon-icon-base)" />
<path d="M4.88867 6L7.99978 9.11111L11.1109 6" stroke="currentColor" />
<path d="M8 9.11198V2.44531" stroke="currentColor" />
</svg>
),
actions: [
+204 -73
View File
@@ -1,40 +1,78 @@
import { Toast as Kobalte, toaster } from "@kobalte/core/toast"
import type { ToastRootProps, ToastCloseButtonProps, ToastTitleProps, ToastDescriptionProps } from "@kobalte/core/toast"
import { Toaster, toast, type ToasterProps } from "solid-sonner"
import type { ComponentProps, JSX } from "solid-js"
import { Show, children } from "solid-js"
import { createContext, onCleanup, onMount, splitProps, useContext } from "solid-js"
import { Portal } from "solid-js/web"
import { ButtonV2 } from "./button-v2"
import "./button-v2.css"
import "./toast-v2.css"
export interface ToastV2RegionProps extends ComponentProps<typeof Kobalte.Region> {}
export interface ToastV2RegionProps extends ToasterProps {}
function ToastV2Region(props: ToastV2RegionProps) {
const [local, rest] = splitProps(props, ["class", "className", "style", "toastOptions", "swipeDirections"])
onMount(() => {
const sync = () => {
document.querySelectorAll<HTMLElement>(".toast-v2-region .toast-v2").forEach((element) => {
const hidden = element.dataset.visible === "false"
element.inert = hidden
element.tabIndex = hidden ? -1 : 0
})
}
let connected = false
const connect = () => {
const regions = document.querySelectorAll(".toast-v2-region")
if (!regions.length) return
observer.disconnect()
regions.forEach((region) => {
observer.observe(region, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["data-visible"],
})
})
connected = true
sync()
}
const observer = new MutationObserver(() => {
if (!connected) connect()
else sync()
})
observer.observe(document.body, { subtree: true, childList: true })
queueMicrotask(connect)
onCleanup(() => observer.disconnect())
})
return (
<Portal>
<Kobalte.Region data-component="toast-v2-region" {...props}>
<Kobalte.List data-slot="toast-v2-list" />
</Kobalte.Region>
<Toaster
position="bottom-right"
offset={{ right: 32, bottom: 48 }}
mobileOffset={16}
gap={12}
duration={5000}
swipeDirections={["bottom"]}
className={["toast-v2-region", local.className, local.class].filter(Boolean).join(" ")}
style={{ "--width": "320px", ...local.style } as JSX.CSSProperties}
toastOptions={{
...local.toastOptions,
unstyled: true,
closeButton: true,
closeButtonAriaLabel: local.toastOptions?.closeButtonAriaLabel ?? "Dismiss",
}}
{...rest}
/>
</Portal>
)
}
export interface ToastV2RootComponentProps extends ToastRootProps {
class?: string
classList?: ComponentProps<"li">["classList"]
const ToastV2Context = createContext<number>()
export interface ToastV2RootComponentProps {
toastId: number
children?: JSX.Element
}
function ToastV2Root(props: ToastV2RootComponentProps) {
return (
<Kobalte
data-component="toast-v2"
classList={{
...props.classList,
[props.class ?? ""]: !!props.class,
}}
{...props}
/>
)
return <ToastV2Context.Provider value={props.toastId}>{props.children}</ToastV2Context.Provider>
}
function ToastV2Icon(props: ComponentProps<"div">) {
@@ -45,26 +83,43 @@ function ToastV2Content(props: ComponentProps<"div">) {
return <div data-slot="toast-v2-content" {...props} />
}
function ToastV2Title(props: ToastTitleProps & ComponentProps<"div">) {
return <Kobalte.Title data-slot="toast-v2-title" {...props} />
function ToastV2Title(props: ComponentProps<"div">) {
return <div data-slot="toast-v2-title" {...props} />
}
function ToastV2Description(props: ToastDescriptionProps & ComponentProps<"div">) {
return <Kobalte.Description data-slot="toast-v2-description" {...props} />
function ToastV2Description(props: ComponentProps<"div">) {
return <div data-slot="toast-v2-description" {...props} />
}
function ToastV2Actions(props: ComponentProps<"div">) {
return <div data-slot="toast-v2-actions" {...props} />
}
function ToastV2CloseButton(props: ToastCloseButtonProps & ComponentProps<"button">) {
function ToastV2CloseButton(props: ComponentProps<"button">) {
const toastId = useContext(ToastV2Context)
const [local, rest] = splitProps(props, ["children", "onClick"])
return (
<Kobalte.CloseButton data-slot="toast-v2-close-button" aria-label="Dismiss" {...props}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M4.25 11.75L11.75 4.25" stroke="currentColor" />
<path d="M11.75 11.75L4.25 4.25" stroke="currentColor" />
</svg>
</Kobalte.CloseButton>
<button
type="button"
data-slot="toast-v2-close-button"
aria-label="Dismiss"
{...rest}
onClick={(event) => {
if (typeof local.onClick === "function") local.onClick(event)
if (!event.defaultPrevented && toastId !== undefined) toasterV2.dismiss(toastId)
}}
>
{local.children ?? <CloseIcon />}
</button>
)
}
function CloseIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M4.25 11.75L11.75 4.25" stroke="currentColor" />
<path d="M11.75 11.75L4.25 4.25" stroke="currentColor" />
</svg>
)
}
@@ -78,7 +133,29 @@ export const ToastV2 = Object.assign(ToastV2Root, {
CloseButton: ToastV2CloseButton,
})
export { toaster as toasterV2 }
let toastV2Id = 0
export const toasterV2 = {
show(render: (props: { toastId: number }) => JSX.Element, options?: { duration?: number; persistent?: boolean }) {
const toastId = --toastV2Id
toast.custom((id) => render({ toastId: Number(id) }), {
id: toastId,
className: "toast-v2",
duration: options?.persistent ? Number.POSITIVE_INFINITY : options?.duration,
unstyled: true,
})
return toastId
},
dismiss(toastId?: number) {
if (toastId === undefined) {
activeToastV2ByKey.clear()
activeToastV2ById.clear()
} else {
releaseToastV2(activeToastV2ById.get(toastId))
}
return toast.dismiss(toastId)
},
}
export interface ToastV2Action {
label: string
@@ -90,53 +167,107 @@ export interface ToastV2Options {
title?: string
description?: string
icon?: JSX.Element
variant?: "default" | "success" | "error" | "loading"
duration?: number
persistent?: boolean
actions?: ToastV2Action[]
}
interface ActiveToastV2 {
id: number
key: string
options: ToastV2Options
actions?: JSX.Element
}
const activeToastV2ByKey = new Map<string, ActiveToastV2>()
const activeToastV2ById = new Map<number, ActiveToastV2>()
export function showToastV2(options: ToastV2Options | string) {
const opts = typeof options === "string" ? { description: options } : options
return toaster.show((props) => {
const resolvedIcon = children(() => opts.icon)
return (
<ToastV2 toastId={props.toastId} duration={opts.duration} persistent={opts.persistent}>
<div data-slot="toast-v2-header">
<Show when={resolvedIcon()}>
<ToastV2.Icon>{resolvedIcon()}</ToastV2.Icon>
</Show>
<ToastV2.Content>
<Show when={opts.title}>
<ToastV2.Title>{opts.title}</ToastV2.Title>
</Show>
<Show when={opts.description}>
<ToastV2.Description>{opts.description}</ToastV2.Description>
</Show>
</ToastV2.Content>
<ToastV2.CloseButton />
</div>
<Show when={opts.actions?.length}>
<ToastV2.Actions>
{opts.actions!.map((action) => (
<ButtonV2
variant={action.variant === "secondary" ? "ghost" : "neutral"}
size="small"
data-action-variant={action.variant ?? "primary"}
onClick={() => {
if (typeof action.onClick === "function") {
action.onClick()
}
toaster.dismiss(props.toastId)
}}
>
{action.label}
</ButtonV2>
))}
</ToastV2.Actions>
</Show>
</ToastV2>
)
const opts: ToastV2Options = typeof options === "string" ? { description: options } : options
const key = JSON.stringify({
title: opts.title,
description: opts.description,
variant: opts.variant,
duration: opts.duration,
persistent: opts.persistent,
actions: opts.actions?.map((action) => [action.label, action.variant]),
})
const active = activeToastV2ByKey.get(key)
const toasts = toast.getToasts()
if (active && toasts.at(-1)?.id === active.id) {
active.options = opts
publishToastV2(active)
pulseToastV2(active.id)
return active.id
}
if (active && toasts.some((item) => item.id === active.id)) toasterV2.dismiss(active.id)
releaseToastV2(active)
const entry: ActiveToastV2 = { id: --toastV2Id, key, options: opts }
entry.actions = createToastV2Actions(entry)
activeToastV2ByKey.set(key, entry)
activeToastV2ById.set(entry.id, entry)
publishToastV2(entry)
return entry.id
}
function publishToastV2(entry: ActiveToastV2) {
const release = () => releaseToastV2(entry)
toast(entry.options.title ?? "", {
id: entry.id,
description: entry.options.description,
icon: entry.options.icon,
action: entry.actions,
closeButton: true,
duration: entry.options.persistent ? Number.POSITIVE_INFINITY : entry.options.duration,
className: `toast-v2 toast-v2--${entry.options.variant ?? "default"}`,
testId: `toast-v2-${entry.id}`,
unstyled: true,
onDismiss: release,
onAutoClose: release,
})
}
function createToastV2Actions(entry: ActiveToastV2) {
if (!entry.options.actions?.length) return undefined
return (
<ToastV2.Actions>
{entry.options.actions.map((action, index) => (
<button
type="button"
data-component="button-v2"
data-variant={action.variant === "secondary" ? "ghost" : "neutral"}
data-size="small"
data-action-variant={action.variant ?? "primary"}
onClick={() => {
const onClick = entry.options.actions?.[index]?.onClick
if (typeof onClick === "function") onClick()
toasterV2.dismiss(entry.id)
}}
>
{action.label}
</button>
))}
</ToastV2.Actions>
)
}
function pulseToastV2(toastId: number) {
if (typeof document === "undefined" || typeof requestAnimationFrame === "undefined") return
requestAnimationFrame(() => {
const element = document.querySelector<HTMLElement>(`[data-testid="toast-v2-${toastId}"]`)
if (!element || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
element.animate([{ scale: 1 }, { scale: 1.025 }, { scale: 1 }], { duration: 160, easing: "ease-out" })
})
}
function releaseToastV2(entry: ActiveToastV2 | undefined) {
if (!entry) return
if (activeToastV2ByKey.get(entry.key) === entry) activeToastV2ByKey.delete(entry.key)
if (activeToastV2ById.get(entry.id) === entry) activeToastV2ById.delete(entry.id)
}
export interface ToastV2PromiseOptions<T, U = unknown> {
@@ -54,6 +54,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
| [opencode-firecrawl](https://github.com/firecrawl/opencode-firecrawl) | Web scraping, crawling, and search via the Firecrawl CLI |
| [opencode-jfrog-plugin](https://github.com/jfrog/opencode-jfrog-plugin) | JFrog Plugin for seamless integration of Opencode users to JFrog platform |
| [opencode-goal-plugin](https://github.com/willytop8/OpenCode-goal-plugin) | Session-scoped `/goal` workflow that keeps objectives in context and auto-continues until complete |
| [opencode-tavily](https://github.com/tavily-ai/opencode-tavily) | Web search, extraction, crawling, and deep research via the Tavily CLI |
---