Compare commits

..

3 Commits

Author SHA1 Message Date
Brendan Allan 366614537e fix(app): redirect invalid server routes 2026-07-29 07:12:13 +00:00
Brendan Allan 154d1e7f70 fix(app): fill invalid route state 2026-07-29 07:10:14 +00:00
Brendan Allan 8be2027a2a fix(app): stabilize server routes and selects 2026-07-29 06:55:12 +00:00
115 changed files with 548 additions and 1668 deletions
+31 -35
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.18.10",
"version": "1.18.9",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -96,7 +96,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.18.10",
"version": "1.18.9",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -144,7 +144,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.18.10",
"version": "1.18.9",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -158,7 +158,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.18.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -339,7 +339,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.1",
"gitlab-ai-provider": "6.12.0",
"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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -449,7 +449,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.10",
"version": "1.18.9",
"dependencies": {
"effect": "catalog:",
},
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.10",
"version": "1.18.9",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -493,7 +493,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.18.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -559,7 +559,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.18.10",
"version": "1.18.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -632,7 +632,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.1",
"gitlab-ai-provider": "6.12.0",
"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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -781,7 +781,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.18.10",
"version": "1.18.9",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -796,7 +796,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.18.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -888,7 +888,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.18.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -976,7 +976,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.18.10",
"version": "1.18.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1002,7 +1002,6 @@
"remend": "catalog:",
"shiki": "catalog:",
"solid-list": "catalog:",
"solid-sonner": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
@@ -1028,7 +1027,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.10",
"version": "1.18.9",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1147,7 +1146,6 @@
"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",
@@ -3857,7 +3855,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"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=="],
"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=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -5129,8 +5127,6 @@
"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=="],
+2 -6
View File
@@ -288,12 +288,8 @@ new sst.cloudflare.x.SolidStart("Console", {
server: {
placement: { region: "aws:us-east-2" },
transform: {
worker: (args) => {
args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [
...(flags ?? []),
"global_fetch_strictly_public",
])
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
worker: {
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
},
},
},
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-AD5kt1o035hdvYPyrvjiz2mcsXFJ7UJVEmbE9AJFDFs=",
"aarch64-linux": "sha256-f1gVXUpIRWC/I8dxIigdGGvPsju04MXVjZhtq5EYL64=",
"aarch64-darwin": "sha256-c+JRpjs2gsgQ2czyM6bTbYEdMbX/erOSQdX6TdrI59k=",
"x86_64-darwin": "sha256-iB7l4PtQQ9Mx/XAdtyayiLe6pZKhuXB9lpmrbTr+uWc="
"x86_64-linux": "sha256-/ZtluLolVq1ZFAYgbDScze9GZR0NF9baRQXvfEoOnkI=",
"aarch64-linux": "sha256-kdh7Dpira7cx9lvtozFsjLrDqMBrUMcgxkhfsqrutws=",
"aarch64-darwin": "sha256-chPeMCX+8hvOQ32hTANIn7FmONsSv9t3dKTWTaHerjs=",
"x86_64-darwin": "sha256-rMc0dDZB+YbXrjYwXTIt7QvHUxSp//wDJQ5hUdR+LYM="
}
}
-1
View File
@@ -90,7 +90,6 @@
"@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.10",
"version": "1.18.9",
"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 --conditions=solid --only-failures --preload ./happydom.ts ./src",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",
+28 -9
View File
@@ -42,7 +42,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
@@ -63,7 +63,13 @@ import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import {
legacySessionHref,
legacySessionServer,
parseServerKey,
retainServerKey,
sessionHref,
} from "./utils/session-route"
import { createSessionLineage } from "@/pages/session/session-lineage"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
@@ -111,16 +117,22 @@ const SessionRoute = () => {
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string; id: string }>()
const global = useGlobal()
const initial = parseServerKey(params.serverKey)
const key = createMemo<ServerConnection.Key | undefined>(
(previous) => retainServerKey(previous, params.serverKey),
initial,
)
const conn = createMemo(() => {
const key = requireServerKey(params.serverKey)
return global.servers.list().find((item) => ServerConnection.key(item) === key)
const current = key()
if (!current) return undefined
return global.servers.list().find((item) => ServerConnection.key(item) === current)
})
return (
// Owns the server-identity remount. Session changes must NOT remount this
// subtree (SessionRouteErrorBoundary resets and createSessionLineage
// re-resolves reactively instead); both rely on this key for server changes.
<Show when={requireServerKey(params.serverKey)} keyed>
<Show when={key()} keyed fallback={<Navigate href="/" />}>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
@@ -135,16 +147,23 @@ const TargetSessionRoute = () => (
)
function LegacyTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<TargetServerRoute>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
<LegacyTargetSessionRedirect />
</SessionRouteErrorBoundary>
<LegacyTargetSessionRouteContent />
</TargetServerRoute>
)
}
function LegacyTargetSessionRouteContent() {
const params = useParams<{ id: string }>()
const serverSDK = useServerSDK()
return (
<SessionRouteErrorBoundary sessionID={params.id} serverKey={ServerConnection.key(serverSDK().server)}>
<LegacyTargetSessionRedirect />
</SessionRouteErrorBoundary>
)
}
function LegacyTargetSessionRedirect() {
const params = useParams<{ id: string }>()
const navigate = useNavigate()
@@ -88,7 +88,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
class="w-full"
placement="right-start"
gutter={6}
delay="intent"
openDelay={0}
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
value={
<ModelTooltip
@@ -460,7 +460,7 @@ function ModelSelectorPopoverV2View(props: {
class="w-full"
placement="right-start"
gutter={6}
delay="intent"
openDelay={0}
value={
<ModelTooltip
model={item}
@@ -368,7 +368,6 @@ 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",
@@ -47,7 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
</span>
}
placement="top"
{...(!props.newLayoutDesigns ? { openDelay: 800 } : {})}
openDelay={800}
>
<div
classList={{
@@ -52,7 +52,12 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<For each={props.comments ?? []}>
{(item) => (
<div class="relative group shrink-0">
<TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
<TooltipV2
value={item.comment}
placement="top"
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<CommentCardV2
comment={item.comment ?? ""}
path={item.path}
@@ -9,86 +9,53 @@
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: 6px;
gap: 13.5px;
}
[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: -3.75px;
left: -6.75px;
width: 1.5px;
height: 12px;
border-radius: 9999px;
background: var(--tab-separator);
background: var(--v2-background-bg-layer-02);
}
[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before {
display: none;
}
[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 {
[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));
display: none;
}
[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"]:not([data-editing="true"]) [data-slot="tab-close"]::before {
display: block;
right: 0;
}
[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"]: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"]: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] px-1.5 [container-type:inline-size]"
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)]"
classList={{ invisible: props.hidden }}
data-active={props.active}
data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined}
data-pressed={props.pressed}
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">
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<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-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"
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)]"
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">
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2
size="small"
variant="ghost-muted"
+21 -35
View File
@@ -338,22 +338,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
keybind: "mod+shift+t",
onSelect: () => tabsStoreActions.reopenClosedTab(),
},
{
id: `tab.prev`,
category: "tab",
title: "",
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
hidden: true,
onSelect: tabs.previous,
},
{
id: `tab.next`,
category: "tab",
title: "",
keybind: `mod+option+ArrowRight,ctrl+tab`,
hidden: true,
onSelect: tabs.next,
},
].filter((v) => v !== undefined)
})
@@ -411,25 +395,27 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<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 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>
<div class="flex-1" />
<TitlebarV2Right state={v2RightState()} />
</div>
+4 -2
View File
@@ -18,7 +18,7 @@ import { createPathHelpers } from "./file/path"
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
import { parseServerKey } from "@/utils/session-route"
import { type DraftTab, useTabs } from "./tabs"
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
@@ -138,10 +138,12 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
}
if (parts[0] === "server" && parts[2] === "session" && parts[3]) {
const server = parseServerKey(parts[1])
if (!server) return { type: "home" }
return {
type: "session",
sessionId: parts[3],
server: requireServerKey(parts[1]),
server,
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { type DraftTab, useTabs } from "./tabs"
import { requireServerKey } from "@/utils/session-route"
import { parseServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
type NotificationBase = {
@@ -125,7 +125,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
const activeServer = createMemo(() => {
if (params.serverKey) return requireServerKey(params.serverKey)
if (params.serverKey) return parseServerKey(params.serverKey) ?? server.key
if (search.draftId) {
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
if (draft) return draft.server
+2 -2
View File
@@ -11,7 +11,7 @@ import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route"
import { parseServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
import { normalizePermissionRequest } from "./global-sync/utils"
import {
@@ -71,7 +71,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
})
const activeServer = createMemo(() => {
if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey)
if (params.serverKey && settings.general.newLayoutDesigns()) return parseServerKey(params.serverKey) ?? server.key
return activeDraft()?.server ?? server.key
})
+1 -3
View File
@@ -2,7 +2,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams, useSearchParams } from "@solidjs/router"
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
import { requireServerKey } from "@/utils/session-route"
import { ServerConnection } from "./server"
import { useServerSDK } from "./server-sdk"
import { useSettings } from "./settings"
@@ -100,8 +99,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const owner = getOwner()
const serverKey = () =>
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
const serverKey = () => ServerConnection.key(serverSDK().server)
const scope = (): PromptScope =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: PromptScope) => {
@@ -1,43 +0,0 @@
import { describe, expect, test } from "bun:test"
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
function history(): TabHistory {
return { stack: [], index: -1 }
}
describe("tab history", () => {
test("moves backward and forward through selected tabs", () => {
const selected = ["a", "b", "c"].reduce(rememberTab, history())
const available = new Set(selected.stack)
const previous = previousTab(selected, available)
expect(previous?.key).toBe("b")
const first = previousTab(previous!.state, available)
expect(first?.key).toBe("a")
const next = nextTab(first!.state, available)
expect(next?.key).toBe("b")
})
test("replaces forward history after a new selection", () => {
const selected = ["a", "b", "c"].reduce(rememberTab, history())
const previous = previousTab(selected, new Set(selected.stack))
const next = rememberTab(previous!.state, "d")
expect(next).toEqual({ stack: ["a", "b", "d"], index: 2 })
expect(nextTab(next, new Set(next.stack))).toBeUndefined()
})
test("skips tabs that are no longer open", () => {
const selected = ["a", "b", "c"].reduce(rememberTab, history())
expect(previousTab(selected, new Set(["a", "c"]))?.key).toBe("a")
})
test("skips a repeated current tab after closing the previous selection", () => {
const selected = ["a", "b", "c", "b"].reduce(rememberTab, history())
expect(previousTab(selected, new Set(["a", "b"]))?.key).toBe("a")
})
})
-28
View File
@@ -1,28 +0,0 @@
const MAX_TAB_HISTORY = 100
export type TabHistory = {
stack: string[]
index: number
}
export function rememberTab(state: TabHistory, key: string): TabHistory {
if (state.stack[state.index] === key) return state
const stack = state.stack.slice(0, state.index + 1).concat(key).slice(-MAX_TAB_HISTORY)
return { stack, index: stack.length - 1 }
}
export function previousTab(state: TabHistory, available: Set<string>) {
return move(state, -1, available)
}
export function nextTab(state: TabHistory, available: Set<string>) {
return move(state, 1, available)
}
function move(state: TabHistory, offset: -1 | 1, available: Set<string>) {
const current = state.stack[state.index]
for (let index = state.index + offset; index >= 0 && index < state.stack.length; index += offset) {
const key = state.stack[index]
if (key && key !== current && available.has(key)) return { state: { ...state, index }, key }
}
}
-23
View File
@@ -1,23 +0,0 @@
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,7 +3,6 @@ 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
@@ -12,23 +11,6 @@ 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) => {
+7 -18
View File
@@ -12,8 +12,6 @@ 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"
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
export type SessionTab = {
type: "session"
@@ -61,7 +59,13 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const [store, setStore, _, ready] = persisted(
{
...Persist.window("tabs"),
migrate: (value: unknown) => migrateTabs(value, fallback),
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 }
})
},
},
createStore<Tab[]>([]),
)
@@ -75,7 +79,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const memory = createTabMemory(getOwner())
const closing = new Set<string>()
let history: TabHistory = { stack: [], index: -1 }
let recentWrite = 0
let recentValue: string | undefined
@@ -147,21 +150,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const navigateTab = (tab: Tab) => {
const href = tabHref(tab)
history = rememberTab(history, tabKey(tab))
setRecentKey(tabKey(tab))
navigate(href)
}
const moveHistory = (direction: "previous" | "next") => {
const available = new Set(store.map(tabKey))
const result = direction === "previous" ? previousTab(history, available) : nextTab(history, available)
if (!result) return
const tab = store.find((item) => tabKey(item) === result.key)
if (!tab) return
history = result.state
navigateTab(tab)
}
const removeTab = (index: number) => {
const tab = store[index]
if (!tab) return
@@ -367,11 +359,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
select: navigateTab,
remember(tab: Tab) {
const key = tabKey(tab)
history = rememberTab(history, key)
if (recentKey() !== key) setRecentKey(key)
},
previous: () => moveHistory("previous"),
next: () => moveHistory("next"),
toggleHome(input: { home: boolean; current?: Tab }) {
if (input.home) {
const tab = store.find((tab) => tabKey(tab) === recentKey())
-11
View File
@@ -2,17 +2,6 @@ import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu"
describe("desktop menu", () => {
test("navigates between tabs", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && (item.label === "Previous Tab" || item.label === "Next Tab"),
)
expect(items).toEqual([
{ type: "item", label: "Previous Tab", command: "tab.prev", accelerator: { macos: "Option+Up" } },
{ type: "item", label: "Next Tab", command: "tab.next", accelerator: { macos: "Option+Down" } },
])
})
test("exports logs through the desktop command registry", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.label === "Export Logs...",
+2 -2
View File
@@ -168,8 +168,8 @@ export const DESKTOP_MENU: DesktopMenu[] = [
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
{ type: "separator" },
{ type: "item", label: "Previous Tab", command: "tab.prev", accelerator: { macos: "Option+Up" } },
{ type: "item", label: "Next Tab", command: "tab.next", accelerator: { macos: "Option+Down" } },
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
{ type: "separator" },
{
type: "item",
-1
View File
@@ -277,7 +277,6 @@ 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,7 +277,6 @@ 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,7 +298,6 @@ 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,7 +296,6 @@ 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,7 +282,6 @@ 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,7 +301,6 @@ 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,7 +297,6 @@ 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,7 +277,6 @@ 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,7 +276,6 @@ 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,7 +264,6 @@ 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,7 +288,6 @@ 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,7 +278,6 @@ 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,7 +297,6 @@ 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,7 +297,6 @@ 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,7 +302,6 @@ 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,7 +299,6 @@ 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,7 +316,6 @@ 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,7 +297,6 @@ 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": "傳送提示前請先選擇代理程式和模型。",
+5 -4
View File
@@ -33,7 +33,8 @@ 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 { dismissToast, setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { toaster } from "@opencode-ai/ui/toast"
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { useServerSDK } from "@/context/server-sdk"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { clearWorkspaceTerminals } from "@/context/terminal"
@@ -383,7 +384,7 @@ export default function LegacyLayout(props: ParentProps) {
const dismissSessionAlert = (sessionKey: string) => {
const toastId = toastBySession.get(sessionKey)
if (toastId === undefined) return
dismissToast(toastId)
toaster.dismiss(toastId)
toastBySession.delete(sessionKey)
alertedAtBySession.delete(sessionKey)
}
@@ -1458,7 +1459,7 @@ export default function LegacyLayout(props: ParentProps) {
title: language.t("workspace.resetting.title"),
description: language.t("workspace.resetting.description"),
})
const dismiss = () => dismissToast(progress)
const dismiss = () => toaster.dismiss(progress)
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
@@ -2449,7 +2450,7 @@ function UpdateAvailableToast(props: {
onCleanup(() => {
if (toastId === undefined) return
dismissToast(toastId)
toaster.dismiss(toastId)
})
return null
@@ -143,7 +143,7 @@ function ProviderTip() {
<TooltipV2
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
placement="top"
delay="intent"
openDelay={1000}
value={language.t("common.dismiss")}
>
<button
+7 -5
View File
@@ -99,7 +99,7 @@ import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { legacySessionHref, sessionHref } from "@/utils/session-route"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
import { createSessionLineage } from "./session/session-lineage"
@@ -158,6 +158,7 @@ export function SessionPage() {
// workspace-scoped state (terminal, directory providers) lives below.
export function TargetSessionRouteContent() {
const params = useParams<{ serverKey: string; id: string }>()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
return (
@@ -165,7 +166,7 @@ export function TargetSessionRouteContent() {
// when session content falls back to the route error boundary.
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<TargetSessionSettingsCommand />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={ServerConnection.key(serverSDK().server)} padded>
<ResolvedTargetSessionRoute />
</SessionRouteErrorBoundary>
</TargetServerScopedProviders>
@@ -244,10 +245,11 @@ function SessionErrorFallback(props: { error: unknown; sessionID?: string; serve
}
function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
const params = useParams<{ id: string }>()
const serverSDK = useServerSDK()
const tabs = useTabs()
const sync = useServerSync()
const serverKey = createMemo(() => requireServerKey(params.serverKey))
const serverKey = createMemo(() => ServerConnection.key(serverSDK().server))
const current = createSessionLineage(
() => params.id,
() => sync().session.lineage,
@@ -2161,7 +2163,7 @@ export default function Page() {
if (!id) return
navigate(
params.serverKey
? sessionHref(requireServerKey(params.serverKey), id)
? sessionHref(ServerConnection.key(serverSDK().server), id)
: legacySessionHref(sdk().directory, id),
)
},
@@ -63,10 +63,11 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk"
import { ServerConnection } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { legacySessionHref, sessionHref } from "@/utils/session-route"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
@@ -793,7 +794,9 @@ export function MessageTimeline(props: {
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
const href = (id: string) =>
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
params.serverKey
? sessionHref(ServerConnection.key(serverSDK().server), id)
: legacySessionHref(sdk().directory, id)
if (parentID) {
navigate(href(parentID))
return
@@ -803,7 +806,7 @@ export function MessageTimeline(props: {
return
}
if (params.serverKey) {
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
tabs.newDraft({ server: ServerConnection.key(serverSDK().server), directory: sdk().directory })
return
}
navigate(`/${params.dir}/session`)
@@ -907,7 +910,9 @@ export function MessageTimeline(props: {
const id = parentID()
if (!id) return
navigate(
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
params.serverKey
? sessionHref(ServerConnection.key(serverSDK().server), id)
: legacySessionHref(sdk().directory, id),
)
}
+17 -4
View File
@@ -1,6 +1,13 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { legacySessionHref, legacySessionServer, requireServerKey, rootSession, sessionHref } from "./session-route"
import {
legacySessionHref,
legacySessionServer,
parseServerKey,
retainServerKey,
rootSession,
sessionHref,
} from "./session-route"
describe("session routes", () => {
test("uses the unique persisted server for a legacy session route", () => {
@@ -31,11 +38,17 @@ describe("session routes", () => {
const href = sessionHref(server, "session-1")
expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/session-1")
expect(requireServerKey(href.split("/")[2])).toBe(server)
expect(parseServerKey(href.split("/")[2])).toBe(server)
})
test("rejects malformed server keys", () => {
expect(() => requireServerKey("not-base64")).toThrow("Invalid server route")
test("returns undefined for malformed server routes", () => {
expect(parseServerKey("not-base64")).toBeUndefined()
})
test("retains the current server while a route is being removed", () => {
const server = ServerConnection.Key.make("https://example.com:4096")
expect(retainServerKey(server, undefined)).toBe(server)
expect(retainServerKey(server, "not-base64")).toBeUndefined()
})
test("builds the legacy directory-keyed route", () => {
+7 -2
View File
@@ -10,12 +10,17 @@ export function legacySessionHref(directory: string, sessionID: string) {
return `/${base64Encode(directory)}/session/${sessionID}`
}
export function requireServerKey(segment: string | undefined) {
export function parseServerKey(segment: string | undefined) {
const key = decode64(segment)
if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route")
if (!key || base64Encode(key) !== segment) return undefined
return ServerConnection.Key.make(key)
}
export function retainServerKey(previous: ServerConnection.Key | undefined, segment: string | undefined) {
if (segment === undefined) return previous
return parseServerKey(segment)
}
export function legacySessionServer(
tabs: readonly { type: "session"; server: ServerConnection.Key; sessionId: string }[],
sessionID: string,
+2 -15
View File
@@ -1,12 +1,6 @@
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
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"
import { Toast, showToast as showLegacyToast, type ToastOptions, type ToastVariant } from "@opencode-ai/ui/toast"
import { ToastV2, showToastV2 } from "@opencode-ai/ui/v2/toast-v2"
let v2 = false
@@ -33,13 +27,6 @@ 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,10 +1,7 @@
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 () => {
@@ -88,88 +85,6 @@ 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")
}
+1 -39
View File
@@ -1,46 +1,8 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { 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.10",
"version": "1.18.9",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,4 +1,4 @@
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai"
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.18.10",
"version": "1.18.9",
"$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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"type": "module",
"license": "MIT",
"scripts": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.10",
"version": "1.18.9",
"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.1",
"gitlab-ai-provider": "6.12.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
+4 -10
View File
@@ -15,11 +15,6 @@ import { httpClient } from "./effect/app-node-platform"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
const InterleavedField = Schema.Union([
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]),
Schema.String,
])
const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
const CostTier = Schema.Struct({
@@ -76,10 +71,9 @@ export const Model = Schema.Struct({
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
interleaved: Schema.optional(
Schema.Union([
Schema.Boolean,
InterleavedField,
Schema.Literal(true),
Schema.Struct({
field: InterleavedField,
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
}),
]),
),
@@ -157,10 +151,10 @@ const layer = Layer.effect(
),
)
const source = Flag.OPENCODE_MODELS_URL || "https://models.opencode.ai"
const source = Flag.OPENCODE_MODELS_URL || "https://models.dev"
const filepath = path.join(
Global.Path.cache,
source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`,
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
const ttl = Duration.minutes(5)
const lockKey = `models-dev:${filepath}`
+2 -8
View File
@@ -5,11 +5,6 @@ import { PositiveInt } from "../../schema"
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
const InterleavedField = Schema.Union([
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]),
Schema.String,
])
export const Model = Schema.Struct({
id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
@@ -21,10 +16,9 @@ export const Model = Schema.Struct({
tool_call: Schema.optional(Schema.Boolean),
interleaved: Schema.optional(
Schema.Union([
Schema.Boolean,
InterleavedField,
Schema.Literal(true),
Schema.Struct({
field: InterleavedField,
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
}),
]),
),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.18.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.18.10",
"version": "1.18.9",
"$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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"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.opencode.ai/api.json"
const MODELS_DEV_URL = "https://models.dev/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.10",
"version": "1.18.9",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -120,7 +120,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.12.1",
"gitlab-ai-provider": "6.12.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
-2
View File
@@ -13,7 +13,6 @@ 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"
@@ -71,7 +70,6 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
}),
CopilotAuthPlugin,
ModalPlugin,
GitlabAuthPlugin,
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
@@ -1,17 +0,0 @@
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(() => ({}))
},
},
}
}
@@ -1,133 +0,0 @@
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"
+3 -8
View File
@@ -976,15 +976,10 @@ const ProviderModalities = Schema.Struct({
pdf: Schema.Boolean,
})
const ProviderInterleavedField = Schema.Union([
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]),
Schema.String,
])
const ProviderInterleaved = Schema.Union([
Schema.Boolean,
Schema.Struct({
field: ProviderInterleavedField,
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
}),
])
@@ -1248,7 +1243,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
video: model.modalities?.output?.includes("video") ?? false,
pdf: model.modalities?.output?.includes("pdf") ?? false,
},
interleaved: typeof model.interleaved === "string" ? { field: model.interleaved } : (model.interleaved ?? false),
interleaved: model.interleaved ?? false,
},
release_date: model.release_date ?? "",
variants: {},
@@ -1480,7 +1475,7 @@ const layer = Layer.effect(
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
},
interleaved:
(typeof model.interleaved === "string" ? { field: model.interleaved } : model.interleaved) ??
model.interleaved ??
existingModel?.capabilities.interleaved ??
(!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.includes("deepseek")
? { field: "reasoning_content" }
@@ -1,38 +0,0 @@
import { expect, test } from "bun:test"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
test("does not reconnect an SSE stream after a JSON-RPC error response", async () => {
let requests = 0
const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), {
fetch: async () => {
requests += 1
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n"))
controller.enqueue(
new TextEncoder().encode(
'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n',
),
)
controller.close()
},
}),
{ status: 200, headers: { "content-type": "text/event-stream" } },
)
},
reconnectionOptions: {
initialReconnectionDelay: 1,
maxReconnectionDelay: 1,
reconnectionDelayGrowFactor: 1,
maxRetries: 2,
},
})
await transport.start()
await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 })
await Bun.sleep(25)
await transport.close()
expect(requests).toBe(1)
})
@@ -1,198 +0,0 @@
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({})
})
@@ -252,8 +252,6 @@ it.instance(
const provider = providers[ProviderV2.ID.make("custom-provider")]
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" })
expect(provider.models["deepseek-text"].capabilities.interleaved).toEqual({ field: "reasoning_text" })
expect(provider.models["custom-reasoning"].capabilities.interleaved).toEqual({ field: "vendor_reasoning" })
expect(provider.models["custom-model"].capabilities.interleaved).toBe(false)
expect(
providers[ProviderV2.ID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved,
@@ -269,8 +267,6 @@ it.instance(
models: {
"deepseek-r1": { name: "DeepSeek R1" },
"deepseek-details": { name: "DeepSeek Details", interleaved: { field: "reasoning_details" } },
"deepseek-text": { name: "DeepSeek Text", interleaved: "reasoning_text" },
"custom-reasoning": { name: "Custom Reasoning", interleaved: { field: "vendor_reasoning" } },
"custom-model": { name: "Custom Model" },
},
options: { apiKey: "custom-key" },
@@ -1466,7 +1462,6 @@ test("models.dev normalization fills required response fields", () => {
id: "gpt-5.4",
name: "GPT-5.4",
family: "gpt",
interleaved: "reasoning_text",
cost: { input: 2.5, output: 15 },
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
},
@@ -1479,7 +1474,6 @@ test("models.dev normalization fills required response fields", () => {
expect(model.capabilities.reasoning).toBe(false)
expect(model.capabilities.attachment).toBe(false)
expect(model.capabilities.toolcall).toBe(true)
expect(model.capabilities.interleaved).toEqual({ field: "reasoning_text" })
expect(model.release_date).toBe("")
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.18.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"type": "module",
"license": "MIT",
"scripts": {
+3 -7
View File
@@ -1768,13 +1768,9 @@ export type ProviderConfig = {
temperature?: boolean
tool_call?: boolean
interleaved?:
| boolean
| "reasoning"
| "reasoning_content"
| "reasoning_text"
| string
| true
| {
field: "reasoning" | "reasoning_content" | "reasoning_text" | string
field: "reasoning" | "reasoning_content" | "reasoning_details"
}
cost?: {
input: number
@@ -2061,7 +2057,7 @@ export type Model = {
interleaved:
| boolean
| {
field: "reasoning" | "reasoning_content" | "reasoning_text" | string
field: "reasoning" | "reasoning_content" | "reasoning_details"
}
}
cost: {
+6 -26
View File
@@ -20820,28 +20820,15 @@
"interleaved": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"enum": ["reasoning", "reasoning_content", "reasoning_text"]
},
{
"type": "string"
"type": "boolean",
"enum": [true]
},
{
"type": "object",
"properties": {
"field": {
"anyOf": [
{
"type": "string",
"enum": ["reasoning", "reasoning_content", "reasoning_text"]
},
{
"type": "string"
}
]
"type": "string",
"enum": ["reasoning", "reasoning_content", "reasoning_details"]
}
},
"required": ["field"],
@@ -21654,15 +21641,8 @@
"type": "object",
"properties": {
"field": {
"anyOf": [
{
"type": "string",
"enum": ["reasoning", "reasoning_content", "reasoning_text"]
},
{
"type": "string"
}
]
"type": "string",
"enum": ["reasoning", "reasoning_content", "reasoning_details"]
}
},
"required": ["field"],
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.18.10",
"version": "1.18.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.18.10",
"version": "1.18.9",
"private": true,
"type": "module",
"license": "MIT",
@@ -32,6 +32,7 @@ export function CommentCardV2(props: {
return (
<TooltipV2
placement="top"
openDelay={1000}
value={props.title ?? props.comment}
disabled={!props.tooltip || !truncated()}
class={props.wide ? "w-full" : undefined}
@@ -1,44 +0,0 @@
@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,7 +74,6 @@ export type PromptInputV2AttachmentConfig = {
directory: () => string
isDialogActive: () => boolean
warn: () => void
duplicate: () => void
onError: (error: unknown) => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
@@ -95,7 +94,7 @@ export function createPromptInputV2Attachments(
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
}
const add = async (file: File, toast = true, target = capture(), clipboard = false) => {
const add = async (file: File, toast = true, target = capture()) => {
if (!target) return false
const mime = await attachmentMime(file)
if (!mime) {
@@ -104,28 +103,11 @@ 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,
sourcePath: input.getPathForFile?.(file) || undefined,
mime,
dataUrl: url,
}
@@ -159,7 +141,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, true))) return
if (file && (await add(file, true, target))) return
}
if (!plainText) return
const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText
@@ -21,7 +21,6 @@ import type {
PromptInputV2Suggestion,
} from "./types"
import type { PromptInputV2Interaction, PromptInputV2SelectControl } from "./interaction"
import "./attachments.css"
export type {
PromptInputV2Attachment,
@@ -108,7 +107,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 overflow-clip rounded-xl bg-v2-background-bg-base"
class="group/prompt-input relative min-h-[96px] w-full 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",
@@ -379,7 +378,7 @@ export function PromptInputV2Attachments(props: {
}) {
return (
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
<div data-component="prompt-input-v2-attachments" data-slot="prompt-attachments" class="relative">
<div 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"
@@ -387,7 +386,12 @@ export function PromptInputV2Attachments(props: {
<For each={props.comments ?? []}>
{(comment) => (
<div class="relative group shrink-0">
<TooltipV2 value={comment.comment} placement="top" contentClass="max-w-[300px] break-words">
<TooltipV2
value={comment.comment}
placement="top"
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<CommentCardV2
comment={comment.comment ?? ""}
path={comment.path}
@@ -440,14 +444,8 @@ export function PromptInputV2Attachments(props: {
)}
</For>
</div>
<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 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>
</Show>
)
@@ -63,7 +63,7 @@
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 16px 12px 8px;
padding: 12px 16px 8px 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 4px;
padding: 0 8px 8px;
flex-shrink: 0;
min-width: 0;
box-sizing: border-box;
@@ -109,71 +109,10 @@
[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: 4px 8px 12px;
padding: 0 8px 12px;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__thumb {
@@ -305,7 +244,7 @@
}
[data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"] {
background-color: var(--v2-background-bg-layer-02);
background-color: var(--v2-overlay-simple-overlay-pressed);
}
[data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] {
@@ -165,13 +165,11 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
}
const prev = () => {
if (!canCycle()) return
return props.files[(fileIndex() - 1 + props.files.length) % props.files.length]
return props.files[fileIndex() - 1]
}
const next = () => {
if (!canCycle()) return
return props.files[(fileIndex() + 1) % props.files.length]
return props.files[fileIndex() + 1]
}
const canCycle = () => props.files.length > 0
@@ -185,15 +183,15 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
props.onSelectFile(file)
}
// Keep the advertised arrow keys working while the
// The prev/next tooltips advertise < and >; keep the 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 !== "ArrowLeft" && event.key !== "ArrowRight") return
if (event.key !== "<" && event.key !== ">") 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 === "ArrowLeft" ? prev() : next()
const file = event.key === "<" ? prev() : next()
if (!file) return
event.preventDefault()
cycle(file)
@@ -216,11 +214,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
</Show>
<div class="flex items-center">
<TooltipV2
openDelay={2000}
inactive={!prev()}
value={
<>
{i18n.t("ui.sessionReviewV2.previousFile")}
<KeybindV2 keys={[""]} variant="neutral" />
<KeybindV2 keys={["<"]} variant="neutral" />
</>
}
>
@@ -235,11 +234,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
/>
</TooltipV2>
<TooltipV2
openDelay={2000}
inactive={!next()}
value={
<>
{i18n.t("ui.sessionReviewV2.nextFile")}
<KeybindV2 keys={[""]} variant="neutral" />
<KeybindV2 keys={[">"]} variant="neutral" />
</>
}
>
@@ -268,12 +268,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
>
<TooltipV2 value={i18n.t("ui.sessionReviewV2.showAllLines")}>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
<SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
<Icon name="expand" />
</SegmentedControlItemV2>
</TooltipV2>
<TooltipV2 value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<Icon name="collapse" />
</SegmentedControlItemV2>
@@ -289,12 +289,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
aria-label={i18n.t("ui.sessionReviewV2.diffView")}
>
<TooltipV2 value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<Icon name="unified" />
</SegmentedControlItemV2>
</TooltipV2>
<TooltipV2 value={i18n.t("ui.sessionReviewV2.splitDiff")}>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
<SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
<Icon name="split" />
</SegmentedControlItemV2>
@@ -331,7 +331,6 @@ 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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"private": true,
"type": "module",
"license": "MIT",
@@ -1,8 +1,8 @@
import { query } from "@solidjs/router"
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 const modelCatalogSourceUrl = "https://models.dev/catalog.json"
export const modelCatalogPricingUrl = "https://models.dev/api.json"
export const modelCatalogLabSourceUrl = "https://models.dev/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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"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.10",
"version": "1.18.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -5
View File
@@ -104,6 +104,7 @@ const appGlobalBindingCommands = [
] as const
const appBindingCommands = [
"command.palette.show",
"model.list",
"model.cycle_recent",
"model.cycle_recent_reverse",
@@ -962,11 +963,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
commands: appCommands(),
}))
useBindings(() => ({
enabled: () => dialog.stack.length === 0,
bindings: tuiConfig.keybinds.get(COMMAND_PALETTE_COMMAND),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
+4 -34
View File
@@ -5,13 +5,7 @@ import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onCleanup } from "solid-js"
import { TuiKeybind } from "../src/config/keybind"
import {
COMMAND_PALETTE_COMMAND,
getOpencodeModeStack,
OPENCODE_BASE_MODE,
OpencodeKeymapProvider,
registerOpencodeKeymap,
} from "../src/keymap"
import { getOpencodeModeStack, OPENCODE_BASE_MODE, OpencodeKeymapProvider, registerOpencodeKeymap } from "../src/keymap"
function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) {
const keybinds = TuiKeybind.parse(input)
@@ -79,14 +73,12 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const offGlobal = keymap.registerLayer({
commands: [
{ name: COMMAND_PALETTE_COMMAND, run() {} },
{ name: "session.list", run() {} },
{ name: "session.new", run() {} },
{ name: "session.page.up", run() {} },
{ name: "session.first", run() {} },
],
bindings: config.keybinds.gather("test.global", [
COMMAND_PALETTE_COMMAND,
"session.list",
"session.new",
"session.page.up",
@@ -103,14 +95,7 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
Array.from(
keymap.getCommandBindings({
visibility: "active",
commands: [
COMMAND_PALETTE_COMMAND,
"session.list",
"session.new",
"session.page.up",
"session.first",
"model.list",
],
commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
}),
([command, bindings]) => [command, bindings.length],
),
@@ -140,24 +125,9 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
const app = await testRender(() => <Harness />)
try {
expect(counts).toEqual({
base: {
[COMMAND_PALETTE_COMMAND]: 1,
"session.list": 1,
"session.new": 1,
"session.page.up": 2,
"session.first": 2,
"model.list": 1,
},
question: {
[COMMAND_PALETTE_COMMAND]: 1,
"session.list": 1,
"session.new": 1,
"session.page.up": 2,
"session.first": 2,
"model.list": 0,
},
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
autocomplete: {
[COMMAND_PALETTE_COMMAND]: 1,
"session.list": 1,
"session.new": 1,
"session.page.up": 2,
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.18.10",
"version": "1.18.9",
"type": "module",
"license": "MIT",
"repository": {
@@ -104,7 +104,6 @@
"remend": "catalog:",
"shiki": "catalog:",
"solid-list": "catalog:",
"solid-sonner": "catalog:",
"strip-ansi": "7.1.2"
},
"peerDependencies": {

Some files were not shown because too many files have changed in this diff Show More