Compare commits

...
20 Commits
Author SHA1 Message Date
opencode 8c38d260eb sync release versions for v1.18.10 2026-07-30 14:43:50 +00:00
a4f25a94b4 fix: use OpenCode model catalog URL (#39672)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-07-30 10:12:08 -04:00
opencode-agent[bot] ff0382e971 chore: generate 2026-07-30 09:31:27 +00:00
OpeOginniandGitHub ac5380614f fix(app): repair malformed persisted tabs (#39645) 2026-07-30 17:29:40 +08:00
opencode-agent[bot] c08350b83e chore: update nix node_modules hashes 2026-07-30 09:11:45 +00:00
Aarav SareenandGitHub c5cf416633 feat(app): fix composer attachment fades (#38547) 2026-07-30 17:07:35 +08:00
Aarav SareenandGitHub 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 SareenandGitHub 005bf088c9 feat(app): fix file tree button bg (#38629) 2026-07-30 17:03:23 +08:00
Aarav SareenandGitHub eca5e68a5e feat(app): disallow duplicate attachments (#39464) 2026-07-30 17:02:15 +08:00
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
a9eda2e99e feat(app): migrate to solid-sonner (#39519)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-30 08:57:19 +00:00
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 NavaniandGitHub 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 AgarwalandGitHub a6f7fe739c docs(ecosystem): add opencode-tavily plugin (#38709) 2026-07-29 12:52:12 -05:00
Vladimir GlafirovandGitHub 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
usrnk1andGitHub 035a5a1a67 feat(desktop): refine tab states (#39472) 2026-07-29 08:34:37 +00:00
80 changed files with 1252 additions and 396 deletions
+35 -31
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -96,7 +96,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.18.9",
"version": "1.18.10",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -144,7 +144,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -158,7 +158,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -194,7 +194,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -221,7 +221,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -243,7 +243,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -267,7 +267,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -287,7 +287,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.18.9",
"version": "1.18.10",
"bin": {
"opencode": "./bin/opencode",
},
@@ -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",
@@ -381,7 +381,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -435,7 +435,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -449,7 +449,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"effect": "catalog:",
},
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -493,7 +493,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -509,7 +509,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -540,7 +540,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -559,7 +559,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.18.9",
"version": "1.18.10",
"bin": {
"opencode": "./bin/opencode",
},
@@ -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",
@@ -690,7 +690,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -766,7 +766,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -781,7 +781,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -796,7 +796,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
@@ -841,7 +841,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -854,7 +854,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -888,7 +888,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -907,7 +907,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -949,7 +949,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -976,7 +976,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1002,6 +1002,7 @@
"remend": "catalog:",
"shiki": "catalog:",
"solid-list": "catalog:",
"solid-sonner": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
@@ -1027,7 +1028,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -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"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.18.9",
"version": "1.18.10",
"description": "",
"type": "module",
"exports": {
@@ -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>
+23
View File
@@ -0,0 +1,23 @@
import type { ServerConnection } from "./server"
import type { Tab } from "./tabs"
export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] {
if (!Array.isArray(value)) return []
return value.flatMap<Tab>((tab) => {
if (!tab || typeof tab !== "object") return []
if ("server" in tab && typeof tab.server !== "string") return []
const server = ("server" in tab ? tab.server : fallback) as ServerConnection.Key
if (tab.type === "session" && typeof tab.sessionId === "string") {
return [{ type: tab.type, server, sessionId: tab.sessionId }]
}
if (
tab.type === "draft" &&
typeof tab.draftID === "string" &&
typeof tab.directory === "string" &&
(tab.worktree === undefined || typeof tab.worktree === "string")
) {
return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }]
}
return []
})
}
+18
View File
@@ -3,6 +3,7 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import type { SessionTab, Tab } from "./tabs"
import { migrateTabs } from "./tab-migration"
import type { ServerConnection } from "./server"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
@@ -11,6 +12,23 @@ function sessionTab(sessionId: string): SessionTab {
return { type: "session", server, sessionId }
}
describe("tab migration", () => {
test("drops null and malformed persisted tabs", () => {
expect(
migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], server),
).toEqual([sessionTab("a")])
})
test("adds the fallback server to valid legacy tabs", () => {
expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([sessionTab("a")])
})
test("replaces invalid top-level persisted data", () => {
expect(migrateTabs(null, server)).toEqual([])
expect(migrateTabs({}, server)).toEqual([])
})
})
describe("tab memory", () => {
test("keeps state until its tab is removed", () => {
createRoot((dispose) => {
+2 -7
View File
@@ -12,6 +12,7 @@ import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
import { migrateTabs } from "./tab-migration"
export type SessionTab = {
type: "session"
@@ -59,13 +60,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const [store, setStore, _, ready] = persisted(
{
...Persist.window("tabs"),
migrate: (value: unknown) => {
if (!Array.isArray(value)) return value
return value.map((tab) => {
if (!tab || typeof tab !== "object" || "server" in tab) return tab
return { ...tab, server: fallback }
})
},
migrate: (value: unknown) => migrateTabs(value, fallback),
},
createStore<Tab[]>([]),
)
+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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.18.9",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,4 +1,4 @@
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai"
export const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.18.9",
"version": "1.18.10",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.18.9",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.18.9",
"version": "1.18.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.18.9",
"version": "1.18.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.18.9",
"version": "1.18.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.18.9",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"scripts": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.9",
"version": "1.18.10",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -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",
+2 -2
View File
@@ -151,10 +151,10 @@ const layer = Layer.effect(
),
)
const source = Flag.OPENCODE_MODELS_URL || "https://models.dev"
const source = Flag.OPENCODE_MODELS_URL || "https://models.opencode.ai"
const filepath = path.join(
Global.Path.cache,
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
const ttl = Duration.minutes(5)
const lockKey = `models-dev:${filepath}`
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.18.9",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.9",
"version": "1.18.10",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.9",
"version": "1.18.10",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.18.9",
"version": "1.18.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.18.9",
"version": "1.18.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.9",
"version": "1.18.10",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.9",
"version": "1.18.10",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"
import * as path from "node:path"
const RECORDINGS_DIR = path.resolve(import.meta.dir, "..", "test", "fixtures", "recordings")
const MODELS_DEV_URL = "https://models.dev/api.json"
const MODELS_DEV_URL = "https://models.opencode.ai/api.json"
type JsonRecord = Record<string, unknown>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.9",
"version": "1.18.10",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -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({})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.18.9",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.18.9",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.18.9",
"version": "1.18.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.18.9",
"version": "1.18.10",
"private": true,
"type": "module",
"license": "MIT",
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.18.9",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
"version": "1.18.9",
"version": "1.18.10",
"private": true,
"type": "module",
"license": "MIT",
@@ -1,8 +1,8 @@
import { query } from "@solidjs/router"
export const modelCatalogSourceUrl = "https://models.dev/catalog.json"
export const modelCatalogPricingUrl = "https://models.dev/api.json"
export const modelCatalogLabSourceUrl = "https://models.dev/labs"
export const modelCatalogSourceUrl = "https://models.opencode.ai/catalog.json"
export const modelCatalogPricingUrl = "https://models.opencode.ai/api.json"
export const modelCatalogLabSourceUrl = "https://models.opencode.ai/labs"
export type ModelCatalogCost = {
input: number
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-core",
"version": "1.18.9",
"version": "1.18.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-server",
"version": "1.18.9",
"version": "1.18.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/tui",
"version": "1.18.9",
"version": "1.18.10",
"private": true,
"type": "module",
"license": "MIT",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.18.9",
"version": "1.18.10",
"type": "module",
"license": "MIT",
"repository": {
@@ -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> {
+1 -1
View File
@@ -45,7 +45,7 @@ function providerIconsPlugin() {
}
async function fetchProviderIcons() {
const url = process.env.OPENCODE_MODELS_URL || "https://models.dev"
const url = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai"
const providers = await fetch(`${url}/api.json`)
.then((res) => res.json())
.then((json) => Object.keys(json))
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.18.9",
"version": "1.18.10",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
@@ -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 |
---
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.18.9",
"version": "1.18.10",
"publisher": "sst-dev",
"repository": {
"type": "git",