Compare commits

..

10 Commits

Author SHA1 Message Date
Brendan Allan 9e432a6785 feat(desktop): add opt-in v2 sidecar (#39286) 2026-07-28 17:29:27 +08:00
Brendan Allan 1ead8d84a2 fix(app): localize home session suspense (#39285) 2026-07-28 16:28:59 +08:00
Brendan Allan 8c81f9a40b refactor(app): extract v2 settings controllers (#39228) 2026-07-28 16:12:59 +08:00
usrnk1 95636bd3ca feat(desktop): collapse model provider sections (#39283) 2026-07-28 07:35:57 +00:00
opencode-agent[bot] 7dae9a1083 chore: generate 2026-07-28 07:03:56 +00:00
Brendan Allan d46be565aa refactor(app): thin new session composition (#39227) 2026-07-28 15:02:26 +08:00
opencode 8021dbd80f sync release versions for v1.18.8 2026-07-28 06:07:51 +00:00
opencode-agent[bot] c8487bac54 chore: update nix node_modules hashes 2026-07-28 05:50:46 +00:00
Aiden Cline 484f00ebf4 fix(mcp): recover expired SDK sessions (#39265) 2026-07-28 00:35:19 -05:00
Aiden Cline c3be6c4965 fix(mcp): honor callback port in debug (#39259) 2026-07-28 00:00:38 -05:00
68 changed files with 1793 additions and 1137 deletions
-4
View File
@@ -219,7 +219,6 @@ jobs:
build-electron:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode'
continue-on-error: false
@@ -316,10 +315,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Build
run: bun run build
+30 -29
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -96,7 +96,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.18.7",
"version": "1.18.8",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -144,7 +144,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -158,7 +158,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.18.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"bin": {
"opencode": "./bin/opencode",
},
@@ -381,7 +381,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.18.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -449,7 +449,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"effect": "catalog:",
},
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -493,7 +493,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.18.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -559,7 +559,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.18.7",
"version": "1.18.8",
"bin": {
"opencode": "./bin/opencode",
},
@@ -691,7 +691,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -767,7 +767,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -782,7 +782,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -797,7 +797,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
@@ -842,7 +842,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -855,7 +855,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -889,7 +889,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -908,7 +908,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -950,7 +950,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -977,7 +977,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1028,7 +1028,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.7",
"version": "1.18.8",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1071,8 +1071,9 @@
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@modelcontextprotocol/client@2.0.0-beta.5": "patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-YH/hjSpK7t5RKE2ie6l+EDKaXKoqVXR11ihGXK957DQ=",
"aarch64-linux": "sha256-fJztjpptVyHStsCWxaaAaQnfxu4Uh2fv79qCag1pokg=",
"aarch64-darwin": "sha256-C9Mno6ts6f19iwPAiO9299CMpK7/dmSL5JS9JSTAmKw=",
"x86_64-darwin": "sha256-zcfjXO73bPK1JtcxekDNyGPwIkj3LMITzAB0hq2g/Ro="
"x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=",
"aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=",
"aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=",
"x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA="
}
}
+2 -1
View File
@@ -155,6 +155,7 @@
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@modelcontextprotocol/client@2.0.0-beta.5": "patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.18.7",
"version": "1.18.8",
"description": "",
"type": "module",
"exports": {
@@ -4,4 +4,3 @@ export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTabV2 } from "./session-sortable-tab-v2"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"
@@ -1,16 +0,0 @@
import type { JSX } from "solid-js"
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
export function NewSessionDesignView(props: { children: JSX.Element }) {
return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8">{props.children}</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,70 @@
import { onCleanup } from "solid-js"
export type ShellOption = {
path: string
name: string
acceptable: boolean
}
export type ShellSelectOption = {
id: string
value: string
name: string
terminalOnly: boolean
}
export function createShellOptions(input: { shells: ShellOption[]; current: string | undefined }) {
const counts = input.shells.reduce((result, shell) => {
result.set(shell.name, (result.get(shell.name) ?? 0) + 1)
return result
}, new Map<string, number>())
const options: ShellSelectOption[] = [
{ id: "auto", value: "", name: "", terminalOnly: false },
...input.shells.map((shell) => {
const ambiguous = (counts.get(shell.name) ?? 0) > 1
const name = ambiguous ? shell.path : shell.name
return {
id: shell.path,
value: ambiguous ? shell.path : shell.name,
name,
terminalOnly: !shell.acceptable,
}
}),
]
if (input.current && !options.some((option) => option.value === input.current)) {
options.push({ id: input.current, value: input.current, name: input.current, terminalOnly: false })
}
return options
}
export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) {
let cleanup: (() => void) | undefined
let timeout: ReturnType<typeof setTimeout> | undefined
let run = 0
const stop = () => {
run += 1
cleanup?.()
clearTimeout(timeout)
cleanup = undefined
timeout = undefined
}
const play = (id: string | undefined) => {
stop()
if (!id) return
const current = ++run
timeout = setTimeout(() => {
timeout = undefined
void player(id).then((next) => {
if (run === current) {
cleanup = next
return
}
next?.()
})
}, 100)
}
onCleanup(stop)
return { play, stop }
}
@@ -0,0 +1,53 @@
import { describe, expect, test, vi } from "bun:test"
import { createRoot } from "solid-js"
import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
describe("settings v2 controllers", () => {
test("normalizes shell names and preserves an unavailable configured shell", () => {
expect(
createShellOptions({
shells: [
{ path: "/bin/bash", name: "bash", acceptable: true },
{ path: "/opt/bash", name: "bash", acceptable: false },
{ path: "/bin/zsh", name: "zsh", acceptable: true },
],
current: "fish",
}),
).toEqual([
{ id: "auto", value: "", name: "", terminalOnly: false },
{ id: "/bin/bash", value: "/bin/bash", name: "/bin/bash", terminalOnly: false },
{ id: "/opt/bash", value: "/opt/bash", name: "/opt/bash", terminalOnly: true },
{ id: "/bin/zsh", value: "zsh", name: "zsh", terminalOnly: false },
{ id: "fish", value: "fish", name: "fish", terminalOnly: false },
])
})
test("debounces previews and stops owned audio on disposal", async () => {
vi.useFakeTimers()
try {
const played: string[] = []
const stopped: string[] = []
const owned = createRoot((dispose) => ({
dispose,
preview: createSoundPreviewController(async (id) => {
played.push(id ?? "")
return () => stopped.push(id ?? "")
}),
}))
owned.preview.play("first")
vi.advanceTimersByTime(99)
expect(played).toEqual([])
owned.preview.play("second")
vi.advanceTimersByTime(100)
await Promise.resolve()
expect(played).toEqual(["second"])
owned.dispose()
expect(stopped).toEqual(["second"])
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,173 @@
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { usePermission } from "@/context/permission"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import {
monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
const permission = usePermission()
const serverSync = useServerSync()
const directory = createMemo(() => {
const id = sessionID()
if (!id) return undefined
return serverSync().session.lineage.peek(id)?.session.directory
})
return {
accepting: createMemo(() => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return false
return permission.isAutoAccepting(id, dir)
}),
enabled: createMemo(() => !!directory()),
set: (checked: boolean) => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return
if (checked) return permission.enableAutoAccept(id, dir)
permission.disableAutoAccept(id, dir)
},
}
}
export function createShellSettingsController() {
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? []
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
)
const current = createMemo(() => serverSync().data.config.shell ?? "")
return {
shells: () => shells.latest,
current,
select: (value: string) => {
if (value === current()) return
void serverSync().updateConfig({ shell: value })
},
}
}
export function createAppearanceSettingsController() {
const settings = useSettings()
const theme = useTheme()
const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
onMount(() => void theme.loadThemes())
return {
scheme: {
current: theme.colorScheme,
select: (value: ColorScheme) => theme.setColorScheme(value),
},
theme: {
options: themes,
current: createMemo(() => themes().find((option) => option.id === theme.themeId())),
select: (option: { id: string } | null) => option && theme.setTheme(option.id),
},
fonts: {
ui: createMemo(() => ({
value: sansInput(settings.appearance.uiFont()),
family: sansFontFamily(settings.appearance.uiFont()),
placeholder: sansDefault,
})),
code: createMemo(() => ({
value: monoInput(settings.appearance.font()),
family: monoFontFamily(settings.appearance.font()),
placeholder: monoDefault,
})),
terminal: createMemo(() => ({
value: terminalInput(settings.appearance.terminalFont()),
family: terminalFontFamily(settings.appearance.terminalFont()),
placeholder: terminalDefault,
})),
setUI: (value: string) => settings.appearance.setUIFont(value),
setCode: (value: string) => settings.appearance.setFont(value),
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
},
}
}
const noneSound = { id: "none", label: "sound.option.none" } as const
export const soundOptions = [noneSound, ...SOUND_OPTIONS]
export type SoundSelectOption = (typeof soundOptions)[number]
export function createSoundSettingsController() {
const settings = useSettings()
const preview = createSoundPreviewController(playSoundById)
const channel = (
enabled: Accessor<boolean>,
current: Accessor<string>,
setEnabled: (value: boolean) => void,
set: (id: string) => void,
) => ({
current: createMemo(() =>
enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound,
),
highlight: (option: SoundSelectOption | undefined) => {
if (!option) return
preview.play(option.id === "none" ? undefined : option.id)
},
select: (option: SoundSelectOption | null) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
preview.stop()
return
}
setEnabled(true)
set(option.id)
preview.play(option.id)
},
})
return {
agent: channel(
settings.sounds.agentEnabled,
settings.sounds.agent,
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
),
permissions: channel(
settings.sounds.permissionsEnabled,
settings.sounds.permissions,
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
),
errors: channel(
settings.sounds.errorsEnabled,
settings.sounds.errors,
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
),
}
}
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
@@ -1,182 +1,297 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { Component, Show, createMemo, createResource } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useUpdaterAction } from "../updater-action"
import {
monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { useSettings } from "@/context/settings"
import { Link } from "../link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
import {
createAppearanceSettingsController,
createPermissionScopeController,
createShellOptions,
createShellSettingsController,
createSoundSettingsController,
soundOptions,
type AppearanceSettingsController,
type PermissionScopeController,
type ShellSettingsController,
type SoundSettingsController,
} from "./general-controllers"
import "./settings-v2.css"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const fontSettings = {
ui: {
action: "settings-ui-font",
title: "settings.general.row.uiFont.title",
description: "settings.general.row.uiFont.description",
font: "ui",
input: "setUI",
},
code: {
action: "settings-code-font",
title: "settings.general.row.font.title",
description: "settings.general.row.font.description",
font: "code",
input: "setCode",
},
terminal: {
action: "settings-terminal-font",
title: "settings.general.row.terminalFont.title",
description: "settings.general.row.terminalFont.description",
font: "terminal",
input: "setTerminal",
},
} as const
const soundSettings = {
agent: {
action: "settings-sounds-agent",
title: "settings.general.sounds.agent.title",
description: "settings.general.sounds.agent.description",
},
permissions: {
action: "settings-sounds-permissions",
title: "settings.general.sounds.permissions.title",
description: "settings.general.sounds.permissions.description",
},
errors: {
action: "settings-sounds-errors",
title: "settings.general.sounds.errors.title",
description: "settings.general.sounds.errors.description",
},
} as const
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
const language = useLanguage()
return (
<SettingsRowV2
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch
checked={props.controller.accepting()}
disabled={!props.controller.enabled()}
onChange={props.controller.set}
/>
</div>
</SettingsRowV2>
)
}
type ThemeOption = {
id: string
name: string
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const language = useLanguage()
const options = createMemo(() =>
createShellOptions({
shells: props.controller.shells(),
current: props.controller.current(),
}),
)
return (
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={options()}
current={options().find((option) => option.value === props.controller.current()) ?? options()[0]}
placement="bottom-end"
gutter={6}
value={(option) => option.id}
label={(option) => {
if (option.id === "auto") return language.t("settings.general.row.shell.autoDefault")
if (!option.terminalOnly) return option.name
return `${option.name} (${language.t("settings.general.row.shell.terminalOnly")})`
}}
onSelect={(option) => option && props.controller.select(option.value)}
/>
</SettingsRowV2>
)
}
type ShellOption = {
path: string
name: string
acceptable: boolean
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
const language = useLanguage()
return (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
data-action="settings-color-scheme"
options={schemeOptions}
current={schemeOptions.find((option) => option === props.controller.scheme.current())}
placement="bottom-end"
gutter={6}
label={(option) => {
if (option === "system") return language.t("theme.scheme.system")
if (option === "light") return language.t("theme.scheme.light")
return language.t("theme.scheme.dark")
}}
onSelect={(option) => option && props.controller.scheme.select(option)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</Link>
</>
}
>
<SelectV2
appearance="inline"
data-action="settings-theme"
options={props.controller.theme.options()}
current={props.controller.theme.current()}
placement="bottom-end"
gutter={6}
value={(option) => option.id}
label={(option) => option.name}
onSelect={props.controller.theme.select}
/>
</SettingsRowV2>
<FontSetting kind="ui" fonts={props.controller.fonts} />
<FontSetting kind="code" fonts={props.controller.fonts} />
<FontSetting kind="terminal" fonts={props.controller.fonts} />
</SettingsListV2>
</div>
)
}
type ShellSelectOption = {
id: string
value: string
label: string
const FontSetting: Component<{
kind: "ui" | "code" | "terminal"
fonts: AppearanceSettingsController["fonts"]
}> = (props) => {
const language = useLanguage()
const config = () => fontSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action={config().action}
type="text"
appearance="base"
value={props.fonts[config().font]().value}
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
placeholder={props.fonts[config().font]().placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t(config().title)}
style={{ "font-family": props.fonts[config().font]().family }}
/>
</div>
</SettingsRowV2>
)
}
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => {
demoSoundState.run += 1
if (demoSoundState.cleanup) {
demoSoundState.cleanup()
}
clearTimeout(demoSoundState.timeout)
demoSoundState.cleanup = undefined
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
const language = useLanguage()
return (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsListV2>
<SoundSetting kind="agent" channel={props.controller.agent} />
<SoundSetting kind="permissions" channel={props.controller.permissions} />
<SoundSetting kind="errors" channel={props.controller.errors} />
</SettingsListV2>
</div>
)
}
const playDemoSound = (id: string | undefined) => {
stopDemoSound()
if (!id) return
const SoundSetting: Component<{
kind: "agent" | "permissions" | "errors"
channel: SoundSettingsController["agent"]
}> = (props) => {
const language = useLanguage()
const config = () => soundSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<SelectV2
appearance="inline"
data-action={config().action}
options={soundOptions}
current={props.channel.current()}
value={(option) => option.id}
label={(option) => language.t(option.label)}
onHighlight={props.channel.highlight}
onSelect={props.channel.select}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
)
}
const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
}, 100)
const LanguageSetting = () => {
const language = useLanguage()
const options = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
label: language.label(locale),
})),
)
return (
<SettingsRowV2
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<SelectV2
appearance="inline"
data-action="settings-language"
options={options()}
placement="bottom-end"
gutter={6}
current={options().find((option) => option.value === language.locale())}
value={(option) => option.value}
label={(option) => option.label}
onSelect={(option) => option && language.setLocale(option.value)}
/>
</SettingsRowV2>
)
}
export const SettingsGeneralV2: Component<{
sessionID?: string
}> = (props) => {
const theme = useTheme()
const language = useLanguage()
const permission = usePermission()
const platform = usePlatform()
const dialog = useDialog()
const settings = useSettings()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
const dir = createMemo(() => {
if (!props.sessionID) return undefined
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
})
const accepting = createMemo(() => {
const value = dir()
if (!value || !props.sessionID) return false
return permission.isAutoAccepting(props.sessionID, value)
})
const toggleAccept = (checked: boolean) => {
const value = dir()
if (!value || !props.sessionID) return
if (checked) {
permission.enableAutoAccept(props.sessionID, value)
return
}
permission.disableAutoAccept(props.sessionID, value)
}
const permissionScope = createPermissionScopeController(() => props.sessionID)
const shell = createShellSettingsController()
const appearance = createAppearanceSettingsController()
const sounds = createSoundSettingsController()
const desktop = createMemo(() => platform.platform === "desktop")
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const [shells] = createResource(
async () => {
const sdk = serverSdk()
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => desktop() && "getPinchZoomEnabled" in platform,
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
{ initialValue: false },
)
onMount(() => {
void theme.loadThemes()
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = serverSync().data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
}
const options = [
autoOption,
...list.map((s) => {
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
const text = ambiguousName ? s.path : s.name
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
return {
id: s.path,
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
value: ambiguousName ? s.path : s.name,
label,
}
}),
]
if (current && !options.some((o) => o.value === current)) {
options.push({ id: current, value: current, label: current })
}
return options
})
const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked)
@@ -184,52 +299,6 @@ export const SettingsGeneralV2: Component<{
void update.catch(() => setPinchZoom(!checked))
}
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
{ value: "dark", label: language.t("theme.scheme.dark") },
])
const languageOptions = createMemo(() =>
language.locales.map((locale) => ({
value: locale,
label: language.label(locale),
})),
)
const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS]
const mono = () => monoInput(settings.appearance.font())
const sans = () => sansInput(settings.appearance.uiFont())
const terminal = () => terminalInput(settings.appearance.terminalFont())
const soundSelectProps = (
enabled: () => boolean,
current: () => string,
setEnabled: (value: boolean) => void,
set: (id: string) => void,
) => ({
options: soundOptions,
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
value: (o: (typeof soundOptions)[number]) => o.id,
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.id === "none" ? undefined : option.id)
},
onSelect: (option: (typeof soundOptions)[number] | null) => {
if (!option) return
if (option.id === "none") {
setEnabled(false)
stopDemoSound()
return
}
setEnabled(true)
set(option.id)
playDemoSound(option.id)
},
})
const InterfaceSection = () => (
<LayoutTransitionToggle
title={language.t("settings.general.row.newInterface.title")}
@@ -251,59 +320,18 @@ export const SettingsGeneralV2: Component<{
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description")}
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
onDismiss={settings.general.dismissNewInterfaceNotice}
onDismiss={() => settings.general.dismissNewInterfaceNotice()}
/>
)
const GeneralSection = () => (
<div class="settings-v2-section">
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<SelectV2
appearance="inline"
data-action="settings-language"
options={languageOptions()}
placement="bottom-end"
gutter={6}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
/>
</SettingsRowV2>
<LanguageSetting />
<SettingsRowV2
title={language.t("command.permissions.autoaccept.enable")}
description={language.t("toast.permissions.autoaccept.on.description")}
>
<div data-action="settings-auto-accept-permissions">
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
</div>
</SettingsRowV2>
<PermissionScopeSetting controller={permissionScope} />
<SettingsRowV2
title={language.t("settings.general.row.shell.title")}
description={language.t("settings.general.row.shell.description")}
>
<SelectV2
appearance="inline"
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync().updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>
<ShellSetting controller={shell} />
<SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -414,124 +442,6 @@ export const SettingsGeneralV2: Component<{
</div>
)
const AppearanceSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
placement="bottom-end"
gutter={6}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</Link>
</>
}
>
<SelectV2
appearance="inline"
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
placement="bottom-end"
gutter={6}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => {
if (!option) return
theme.setTheme(option.id)
}}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.uiFont.title")}
description={language.t("settings.general.row.uiFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-ui-font"
type="text"
appearance="base"
value={sans()}
onInput={(event) => settings.appearance.setUIFont(event.currentTarget.value)}
placeholder={sansDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.uiFont.title")}
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-code-font"
type="text"
appearance="base"
value={mono()}
onInput={(event) => settings.appearance.setFont(event.currentTarget.value)}
placeholder={monoDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.font.title")}
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.terminalFont.title")}
description={language.t("settings.general.row.terminalFont.description")}
>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action="settings-terminal-font"
type="text"
appearance="base"
value={terminal()}
onInput={(event) => settings.appearance.setTerminalFont(event.currentTarget.value)}
placeholder={terminalDefault}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.general.row.terminalFont.title")}
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
/>
</div>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const NotificationsSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
@@ -576,68 +486,6 @@ export const SettingsGeneralV2: Component<{
</div>
)
const SoundsSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agentEnabled(),
() => settings.sounds.agent(),
(value) => settings.sounds.setAgentEnabled(value),
(id) => settings.sounds.setAgent(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissionsEnabled(),
() => settings.sounds.permissions(),
(value) => settings.sounds.setPermissionsEnabled(value),
(id) => settings.sounds.setPermissions(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<SelectV2
appearance="inline"
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errorsEnabled(),
() => settings.sounds.errors(),
(value) => settings.sounds.setErrorsEnabled(value),
(id) => settings.sounds.setErrors(id),
)}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
</SettingsListV2>
</div>
)
const UpdatesSection = () => (
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
@@ -659,7 +507,7 @@ export const SettingsGeneralV2: Component<{
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={() => updater.run()}>
{language.t(updater.action().label)}
</ButtonV2>
</SettingsRowV2>
@@ -704,11 +552,11 @@ export const SettingsGeneralV2: Component<{
<GeneralSection />
<AppearanceSection />
<AppearanceSection controller={appearance} />
<NotificationsSection />
<SoundsSection />
<SoundsSection controller={sounds} />
<Show when={desktop()}>
<UpdatesSection />
@@ -5,9 +5,12 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { type Component, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { useServerSDK } from "@/context/server-sdk"
import { popularProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import "./settings-v2.css"
@@ -19,6 +22,11 @@ const PROVIDER_ICON_SIZE = 16
export const SettingsModelsV2: Component = () => {
const language = useLanguage()
const models = useModels()
const serverSdk = useServerSDK()
const [store, setStore] = persisted(
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
createStore({ collapsed: {} as Record<string, boolean> }),
)
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
@@ -94,41 +102,82 @@ export const SettingsModelsV2: Component = () => {
}
>
<For each={list.grouped.latest}>
{(group) => (
<div class="settings-v2-section" data-component="settings-models-provider">
<div class="settings-v2-models-group-header">
<ProviderIcon
id={group.category}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-models-provider-icon shrink-0"
/>
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
{(group) => {
const searching = () => list.filter().length > 0
const expanded = () => searching() || !store.collapsed[group.category]
return (
<div
class="settings-v2-section"
data-component="settings-models-provider"
data-expanded={expanded() ? "" : undefined}
>
<h3 class="settings-v2-models-group-header">
<button
type="button"
class="settings-v2-models-group-trigger"
aria-expanded={expanded()}
disabled={searching()}
onClick={() => setStore("collapsed", group.category, expanded())}
>
<span class="settings-v2-models-group-chevron">
<Show
when={expanded()}
fallback={
<svg width="5" height="6" viewBox="0 0 5 6" fill="none" aria-hidden="true">
<path
d="M0.75194 5.31663C0.41861 5.51103 0 5.27063 0 4.88473V0.500754C0 0.114854 0.41861 -0.125577 0.75194 0.0688635L4.5096 2.26084C4.8404 2.45378 4.8404 2.93168 4.5096 3.12462L0.75194 5.31663Z"
fill="currentColor"
/>
</svg>
}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z"
fill="currentColor"
/>
</svg>
</Show>
</span>
<span class="settings-v2-models-group-label">
<ProviderIcon
id={group.category}
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-models-provider-icon shrink-0"
/>
<span class="settings-v2-section-title">{group.items[0].provider.name}</span>
</span>
</button>
</h3>
<Show when={expanded()}>
<SettingsListV2>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<SettingsRowV2 title={item.name} description="">
<div>
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRowV2>
)
}}
</For>
</SettingsListV2>
</Show>
</div>
<SettingsListV2>
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<SettingsRowV2 title={item.name} description="">
<div>
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</SettingsRowV2>
)
}}
</For>
</SettingsListV2>
</div>
)}
)
}}
</For>
</Show>
</Show>
@@ -373,14 +373,64 @@
}
.settings-v2-models {
gap: 24px;
gap: 12px;
}
.settings-v2-models .settings-v2-section {
gap: 12px;
}
.settings-v2-models .settings-v2-section[data-expanded] {
padding-bottom: 12px;
}
.settings-v2-models-group-header {
display: flex;
align-items: center;
height: 28px;
}
.settings-v2-models-group-trigger {
display: flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 4px 8px 4px 4px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-text-text-base);
}
.settings-v2-models-group-trigger:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
@media (hover: hover) {
.settings-v2-models-group-trigger:not(:disabled):hover {
background-color: var(--v2-background-bg-layer-02);
}
}
.settings-v2-models-group-trigger:disabled {
cursor: default;
}
.settings-v2-models-group-chevron {
display: flex;
width: 20px;
height: 20px;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: var(--v2-icon-icon-muted);
}
.settings-v2-models-group-label {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 8px;
}
.settings-v2-models .settings-v2-section-title {
@@ -394,6 +444,10 @@
color: var(--v2-icon-icon-base);
}
.settings-v2-models [data-component="settings-v2-list"] {
padding-inline: 16px;
}
.settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] {
margin-top: 0;
}
@@ -1,5 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { type Accessor, createMemo, For, Show } from "solid-js"
import { type Accessor, createMemo, For, Show, Suspense } from "solid-js"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
@@ -39,7 +39,6 @@ function isBackgroundOpen(event: MouseEvent) {
export type HomeSessionsViewProps = {
language: ReturnType<typeof useLanguage>
groups: Accessor<HomeSessionGroup[]>
loading: Accessor<boolean>
showProjectName: Accessor<boolean>
server: Accessor<ServerConnection.Key>
canCreateSession: Accessor<boolean>
@@ -81,20 +80,22 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
>
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<HomeSessionSearch {...props} />
<Show when={props.groups().length > 0 && props.canCreateSession()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
<Suspense>
<Show when={props.groups().length > 0 && props.canCreateSession()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
</Suspense>
</div>
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
@@ -104,8 +105,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show
when={!props.loading()}
<Suspense
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={props.language.t("common.loading")} />
@@ -141,7 +141,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
</For>
</div>
</Show>
</Show>
</Suspense>
</div>
</section>
)
@@ -12,7 +12,6 @@ export function HomeSessions(props: {
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups}
loading={props.sessions.data.loading}
showProjectName={props.sessions.session.showProjectName}
server={props.sessions.session.server}
canCreateSession={props.sessions.session.canCreate}
+25 -266
View File
@@ -1,290 +1,49 @@
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useSearchParams } from "@solidjs/router"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { NewSessionDesignView } from "@/components/session"
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
import { StatusPopoverV2 } from "@/components/status-popover"
import {
PromptProjectAddButton,
PromptProjectSelector,
createPromptProjectController,
} from "@/components/prompt-project-selector"
import { useComments } from "@/context/comments"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
import { createPromptProjectController } from "@/components/prompt-project-selector"
import { useTitlebarRightMount } from "@/components/titlebar"
import { useCommand } from "@/context/command"
import { useProviders } from "@/hooks/use-providers"
import { useSettingsCommand } from "@/components/settings-dialog"
import { Persist, persisted } from "@/utils/persist"
import createPresence from "solid-presence"
import { useLocal } from "@/context/local"
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
import { useSettings } from "@/context/settings"
import { createEffect, createResource } from "solid-js"
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
import { useNewSessionCommands } from "./new-session/use-new-session-commands"
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
const providerTipExitDuration = 250
/**
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
* composer for a brand-new session — no terminal, review pane, file tree, or message
* timeline. Submitting promotes the draft into a real session (see prompt-input/submit).
*/
/** The draft-only V2 session page. Submitting promotes the draft into a real session. */
export default function NewSessionPage() {
const prompt = usePrompt()
const sdk = useSDK()
const sync = useSync()
const serverSync = useServerSync()
const comments = useComments()
const language = useLanguage()
const settings = useSettings()
const dialog = useDialog()
const command = useCommand()
const providers = useProviders(() => sdk().directory)
const openProviders = () => {
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
})
}
useSettingsCommand()
const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
const local = useLocal()
const model = createPromptModelSelection({ agent: local.agent.current })
useComposerCommands({ model })
const inputController = createPromptInputController({
sessionKey: route.sessionKey,
sessionID: () => route.params.id,
queryOptions: serverSync().queryOptions,
model,
})
const projectControls = createPromptProjectControls()
const [store, setStore] = createStore<{ worktree?: string }>({})
const rightMount = useTitlebarRightMount()
const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
const newSessionWorktree = createMemo(() => {
if (!showWorkspaceBar()) return "main"
if (store.worktree) return store.worktree
const project = sync().project
if (project && sdk().directory !== project.worktree) return sdk().directory
return "main"
const workspace = createNewSessionWorkspaceController()
const draft = createNewSessionDraftController({
worktree: workspace.selection.value,
resetWorktree: workspace.selection.reset,
})
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
const selectedBranch = createMemo(() => {
const worktree = newSessionWorktree()
if (worktree === "main" || worktree === "create") return localBranch()
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
const project = createPromptProjectController({
controls: draft.project.controls,
onDone: draft.input.restoreFocus,
})
const promptInputV2Controller = usePromptInputV2Controller({
get controls() {
return inputController()
useNewSessionCommands({
restoreFocus: draft.input.restoreFocus,
project: {
empty: project.empty,
open: () => project.setOpen(true),
},
get newSessionWorktree() {
return newSessionWorktree()
},
onNewSessionWorktreeReset: () => setStore("worktree", undefined),
onSubmit: () => comments.clear(),
})
const projectController = createPromptProjectController({
controls: projectControls,
onDone: promptInputV2Controller.restoreFocus,
})
command.register("new-session", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const { DialogSelectFile } = await import("@/components/dialog-select-file")
void dialog.show(() => <DialogSelectFile />)
},
},
{
id: "input.focus",
title: language.t("command.input.focus"),
category: language.t("command.category.view"),
keybind: "ctrl+l",
onSelect: () => promptInputV2Controller.restoreFocus(),
},
{
id: "project.select",
title: language.t("session.new.project.search"),
category: language.t("command.category.project"),
keybind: "mod+shift+o",
disabled: projectController.empty(),
onSelect: () => projectController.setOpen(true),
},
])
createEffect(() => {
if (!prompt.ready()) return
untrack(() => {
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
setSearchParams({ ...searchParams, prompt: undefined })
})
if (!draft.prompt.ready()) return
draft.input.restoreFocus()
})
createEffect(() => {
if (!prompt.ready()) return
promptInputV2Controller.restoreFocus()
})
const ready = Promise.resolve()
const [suspendUntilPromptReady] = createResource(
() => prompt.ready.promise ?? ready,
() => draft.prompt.readyPromise() ?? ready,
(promise) => promise.then(() => true),
)
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<Show when={rightMount()}>
{(mount) => (
<Portal mount={mount()}>
<Show when={settings.visibility.status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopoverV2 />
</Tooltip>
</Show>
</Portal>
)}
</Show>
<NewSessionStatus mount={rightMount} visible={settings.visibility.status} />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
<NewSessionDesignView>
<div class={NEW_SESSION_CONTENT_WIDTH}>
<div class="flex flex-col gap-8">
<PromptInputV2Composer controller={promptInputV2Controller} />
<Show when={projectController.empty()}>
<PromptProjectAddButton controller={projectController} />
</Show>
<Show when={projectController.selected()}>
<div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
<PromptProjectSelector controller={projectController} placement="bottom" />
<Show
when={showWorkspaceBar()}
fallback={<PromptGitStatus branch={selectedBranch()} noGit={sync().project?.vcs !== "git"} />}
>
<PromptWorkspaceSelector
value={newSessionWorktree()}
projectRoot={projectRoot()}
workspaces={sync().project?.sandboxes ?? []}
branch={selectedBranch()}
onChange={(value) =>
setStore(
"worktree",
value === "main" && sync().project?.worktree !== sdk().directory
? sync().project?.worktree
: value,
)
}
onDone={promptInputV2Controller.restoreFocus}
/>
</Show>
</div>
</Show>
</div>
{/*</Show>*/}
</div>
</NewSessionDesignView>
<ProviderTip
ready={() => serverSync().child(sdk().directory)[0].provider_ready}
connected={() => providers.paid().length > 0}
openProviders={openProviders}
/>
</div>
</div>
<NewSessionView input={draft.input} project={project} workspace={workspace} />
</div>
</div>
)
}
function ProviderTip(props: { ready: () => boolean; connected: () => boolean; openProviders: () => void }) {
const language = useLanguage()
const [persistedState, setPersistedState, , persistedReady] = persisted(
Persist.global("new-session.provider-tip"),
createStore({ dismissedAt: 0 }),
)
const visible = createMemo(
() =>
props.ready() &&
persistedReady() &&
!props.connected() &&
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
)
function dismiss() {
setPersistedState("dismissedAt", Date.now())
}
const [ref, setRef] = createSignal<HTMLDivElement>()
const presence = createPresence({
show: () => visible(),
element: () => ref() ?? null,
})
return (
<Show when={presence.present()}>
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
<div
ref={setRef}
data-component="provider-tip"
data-visible={visible()}
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
classList={{
"data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true,
}}
>
<button
type="button"
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
onClick={props.openProviders}
>
<span class="truncate">{language.t("home.providerTip")}</span>
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
</span>
</button>
<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"
openDelay={1000}
value={language.t("common.dismiss")}
>
<button
type="button"
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
aria-label={language.t("common.dismiss")}
onClick={dismiss}
>
<IconV2 name="xmark-small" />
</button>
</TooltipV2>
</div>
</div>
</Show>
)
}
@@ -0,0 +1,64 @@
import { useSearchParams } from "@solidjs/router"
import { createEffect, untrack } from "solid-js"
import { usePromptInputV2Controller } from "@/components/prompt-input-v2"
import { useComments } from "@/context/comments"
import { useLocal } from "@/context/local"
import { usePrompt } from "@/context/prompt"
import { useServerSync } from "@/context/server-sync"
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) {
const prompt = usePrompt()
const serverSync = useServerSync()
const comments = useComments()
const local = useLocal()
const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
const model = createPromptModelSelection({ agent: () => local.agent.current() })
useComposerCommands({ model })
const controls = createPromptInputController({
sessionKey: route.sessionKey,
sessionID: () => route.params.id,
queryOptions: serverSync().queryOptions,
model,
})
const projectControls = createPromptProjectControls()
const input = usePromptInputV2Controller({
get controls() {
return controls()
},
get newSessionWorktree() {
return workspace.worktree()
},
onNewSessionWorktreeReset: workspace.resetWorktree,
onSubmit: comments.clear,
})
createEffect(() => {
if (!prompt.ready()) return
untrack(() => {
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
setSearchParams({ ...searchParams, prompt: undefined })
})
})
return {
input,
prompt: {
ready: prompt.ready,
readyPromise: () => prompt.ready.promise,
},
project: {
controls: projectControls,
},
}
}
export type NewSessionDraftController = ReturnType<typeof createNewSessionDraftController>
@@ -0,0 +1,162 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
import { Show, createMemo, createSignal, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import createPresence from "solid-presence"
import { PromptInputV2Composer } from "@/components/prompt-input-v2"
import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
import {
PromptProjectAddButton,
PromptProjectSelector,
type PromptProjectController,
} from "@/components/prompt-project-selector"
import { StatusPopoverV2 } from "@/components/status-popover"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useServerSync } from "@/context/server-sync"
import { useProviders } from "@/hooks/use-providers"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { Persist, persisted } from "@/utils/persist"
import type { NewSessionDraftController } from "./new-session-draft-controller"
import type { NewSessionWorkspaceController } from "./new-session-workspace-controller"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
export function NewSessionView(props: {
input: NewSessionDraftController["input"]
project: PromptProjectController
workspace: NewSessionWorkspaceController
}) {
return (
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div
data-component="session-new-design"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-deep"
>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8 flex flex-col gap-8">
<PromptInputV2Composer controller={props.input} />
<Show when={props.project.empty()}>
<PromptProjectAddButton controller={props.project} />
</Show>
<Show when={props.project.selected()}>
<div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
<PromptProjectSelector controller={props.project} placement="bottom" />
<Show
when={props.workspace.bar.visible()}
fallback={
<PromptGitStatus branch={props.workspace.bar.branch()} noGit={!props.workspace.project.git()} />
}
>
<PromptWorkspaceSelector
value={props.workspace.selection.value()}
projectRoot={props.workspace.project.root()}
workspaces={props.workspace.project.workspaces()}
branch={props.workspace.bar.branch()}
onChange={props.workspace.selection.set}
onDone={props.input.restoreFocus}
/>
</Show>
</div>
</Show>
</div>
</div>
</div>
<ProviderTip />
</div>
</div>
)
}
export function NewSessionStatus(props: { mount: Accessor<HTMLElement | null>; visible: Accessor<boolean> }) {
const language = useLanguage()
return (
<Show when={props.mount()}>
{(mount) => (
<Portal mount={mount()}>
<Show when={props.visible()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopoverV2 />
</Tooltip>
</Show>
</Portal>
)}
</Show>
)
}
function ProviderTip() {
const language = useLanguage()
const dialog = useDialog()
const sdk = useSDK()
const serverSync = useServerSync()
const providers = useProviders(() => sdk().directory)
const [persistedState, setPersistedState, , persistedReady] = persisted(
Persist.global("new-session.provider-tip"),
createStore({ dismissedAt: 0 }),
)
const visible = createMemo(
() =>
serverSync().child(sdk().directory)[0].provider_ready &&
persistedReady() &&
providers.paid().length === 0 &&
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
)
const [ref, setRef] = createSignal<HTMLDivElement>()
const presence = createPresence({
show: visible,
element: () => ref() ?? null,
})
const openProviders = () => {
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
})
}
return (
<Show when={presence.present()}>
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
<div
ref={setRef}
data-component="provider-tip"
data-visible={visible()}
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
>
<button
type="button"
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
onClick={openProviders}
>
<span class="truncate">{language.t("home.providerTip")}</span>
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
</span>
</button>
<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"
openDelay={1000}
value={language.t("common.dismiss")}
>
<button
type="button"
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
aria-label={language.t("common.dismiss")}
onClick={() => setPersistedState("dismissedAt", Date.now())}
>
<IconV2 name="xmark-small" />
</button>
</TooltipV2>
</div>
</div>
</Show>
)
}
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import {
normalizeNewSessionWorktree,
resolveNewSessionBranch,
resolveNewSessionWorktree,
} from "./new-session-workspace-controller"
describe("new session workspace selection", () => {
test("uses main when the workspace bar is unavailable", () => {
expect(
resolveNewSessionWorktree({
enabled: false,
selected: "/project/feature",
directory: "/project/feature",
projectWorktree: "/project",
}),
).toBe("main")
})
test("derives an existing worktree from the current directory", () => {
expect(
resolveNewSessionWorktree({ enabled: true, directory: "/project/feature", projectWorktree: "/project" }),
).toBe("/project/feature")
expect(resolveNewSessionWorktree({ enabled: true, directory: "/project", projectWorktree: "/project" })).toBe(
"main",
)
})
test("normalizes main to the project root outside the main worktree", () => {
expect(normalizeNewSessionWorktree("main", "/project/feature", "/project")).toBe("/project")
expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main")
})
test("falls back to the local branch for main, create, and unknown worktrees", () => {
const branch = (worktree: string) => (worktree === "/project/feature" ? "feature" : undefined)
expect(resolveNewSessionBranch({ worktree: "main", local: "dev", worktreeBranch: branch })).toBe("dev")
expect(resolveNewSessionBranch({ worktree: "create", local: "dev", worktreeBranch: branch })).toBe("dev")
expect(resolveNewSessionBranch({ worktree: "/project/feature", local: "dev", worktreeBranch: branch })).toBe(
"feature",
)
expect(resolveNewSessionBranch({ worktree: "/missing", local: "dev", worktreeBranch: branch })).toBe("dev")
})
})
@@ -0,0 +1,77 @@
import { createMemo, createSignal } from "solid-js"
import { useSDK } from "@/context/sdk"
import { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync"
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export function resolveNewSessionWorktree(input: {
enabled: boolean
selected?: string
directory: string
projectWorktree?: string
}) {
if (!input.enabled) return "main"
if (input.selected) return input.selected
if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory
return "main"
}
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
if (value === "main" && projectWorktree !== directory) return projectWorktree
return value
}
export function resolveNewSessionBranch(input: {
worktree: string
local?: string
worktreeBranch: (worktree: string) => string | undefined
}) {
if (input.worktree === "main" || input.worktree === "create") return input.local
return input.worktreeBranch(input.worktree) ?? input.local
}
export function createNewSessionWorkspaceController() {
const sdk = useSDK()
const sync = useSync()
const serverSync = useServerSync()
const [worktree, setWorktree] = createSignal<string>()
const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
const value = createMemo(() =>
resolveNewSessionWorktree({
enabled: visible(),
selected: worktree(),
directory: sdk().directory,
projectWorktree: sync().project?.worktree,
}),
)
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
const branch = createMemo(() =>
resolveNewSessionBranch({
worktree: value(),
local: localBranch(),
worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
}),
)
return {
selection: {
value,
reset: () => setWorktree(),
set: (worktree: string) =>
setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)),
},
project: {
root: projectRoot,
workspaces: () => sync().project?.sandboxes ?? [],
git: () => sync().project?.vcs === "git",
},
bar: {
visible,
branch,
},
}
}
export type NewSessionWorkspaceController = ReturnType<typeof createNewSessionWorkspaceController>
@@ -0,0 +1,44 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSettingsCommand } from "@/components/settings-dialog"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
export function useNewSessionCommands(input: {
restoreFocus: () => void
project: {
empty: () => boolean
open: () => void
}
}) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
useSettingsCommand()
command.register("new-session", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const { DialogSelectFile } = await import("@/components/dialog-select-file")
void dialog.show(() => <DialogSelectFile />)
},
},
{
id: "input.focus",
title: language.t("command.input.focus"),
category: language.t("command.category.view"),
keybind: "ctrl+l",
onSelect: input.restoreFocus,
},
{
id: "project.select",
title: language.t("session.new.project.search"),
category: language.t("command.category.project"),
keybind: "mod+shift+o",
disabled: input.project.empty(),
onSelect: input.project.open,
},
])
}
@@ -1,64 +0,0 @@
import { expect, test } from "bun:test"
import { MetaProvider, Title } from "@solidjs/meta"
import { MemoryRouter, Route, createMemoryHistory, useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
import { createComponent, render } from "solid-js/web"
test("route cleanup cannot invalidate an owner list being disposed", async () => {
const host = document.createElement("div")
document.body.append(host)
const history = createMemoryHistory()
const RepoPage = () => {
const params = useParams<{ id?: string }>()
const title = createMemo(() => params.id ?? "")
const button = document.createElement("button")
button.textContent = "Back"
button.addEventListener("click", () => history.set({ value: "/", scroll: false, replace: false }))
return [
createComponent(Title, {
get children() {
return title()
},
}),
button,
]
}
const HomePage = () => {
const button = document.createElement("button")
button.textContent = "Go"
button.addEventListener("click", () => history.set({ value: "/project", scroll: false, replace: false }))
return button
}
const App = () =>
createComponent(MetaProvider, {
get children() {
return createComponent(MemoryRouter, {
history,
get children() {
return [
createComponent(Route, { path: "/", component: HomePage }),
createComponent(Route, { path: "/:id", component: RepoPage }),
]
},
})
},
})
const dispose = render(() => createComponent(App, {}), host)
const go = host.querySelector("button")
expect(go?.textContent).toBe("Go")
go?.click()
await new Promise((resolve) => setTimeout(resolve, 0))
const back = host.querySelector("button")
expect(back?.textContent).toBe("Back")
back?.click()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(host.querySelector("button")?.textContent).toBe("Go")
dispose()
host.remove()
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.18.7",
"version": "1.18.8",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.18.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.18.7",
"version": "1.18.8",
"$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.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.7",
"version": "1.18.8",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -56,3 +56,36 @@ test("keeps a hidden prod launcher for old Linux pins", async () => {
expect(desktop).toContain("StartupWMClass=ai.opencode.desktop")
expect(desktop).toContain("NoDisplay=true")
})
test("bundles the CLI outside the dev app archive", async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = "dev"
const module = await import("./electron-builder.config.ts?cli-resource")
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.files).toContain("!resources/opencode-cli*")
expect(config.extraResources).toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
for (const channel of ["beta", "prod"] as const) {
test(`does not bundle the CLI in ${channel} builds`, async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = channel
const module = await import(`./electron-builder.config.ts?no-cli-resource=${channel}`)
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.extraResources).not.toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
}
+10 -1
View File
@@ -55,8 +55,17 @@ const getBase = (appId: string): Configuration => ({
extraMetadata: {
desktopName: `${appId}.desktop`,
},
files: ["out/**/*", "resources/**/*"],
files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
extraResources: [
...(channel === "dev"
? [
{
from: "resources/",
to: "",
filter: ["opencode-cli*"],
},
]
: []),
{
from: "native/",
to: "native/",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.18.7",
"version": "1.18.8",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+2 -1
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { resolveChannel } from "./utils"
import { downloadCliToResources, resolveChannel } from "./utils"
const channel = resolveChannel()
await $`bun ./scripts/copy-icons.ts ${channel}`
await $`bun ./scripts/copy-metainfo.ts ${channel}`
await $`cd ../opencode && bun script/build-node.ts`
if (channel === "dev") await downloadCliToResources()
+2
View File
@@ -1,7 +1,9 @@
import { $ } from "bun"
import { downloadCliToResources } from "./utils"
await $`bun run install-electron`
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
await $`cd ../opencode && bun script/build-node.ts`
await downloadCliToResources()
+42 -22
View File
@@ -1,4 +1,9 @@
import { $ } from "bun"
import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
const CLI_VERSION = "0.0.0-next-16350"
export type Channel = "dev" | "beta" | "prod"
@@ -8,36 +13,42 @@ export function resolveChannel(): Channel {
return "dev"
}
export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; assetExt: string }> = [
export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [
{
rustTarget: "aarch64-apple-darwin",
ocBinary: "opencode-darwin-arm64",
assetExt: "zip",
package: "@opencode-ai/cli-darwin-arm64",
os: "darwin",
cpu: "arm64",
},
{
rustTarget: "x86_64-apple-darwin",
ocBinary: "opencode-darwin-x64-baseline",
assetExt: "zip",
package: "@opencode-ai/cli-darwin-x64-baseline",
os: "darwin",
cpu: "x64",
},
{
rustTarget: "aarch64-pc-windows-msvc",
ocBinary: "opencode-windows-arm64",
assetExt: "zip",
package: "@opencode-ai/cli-windows-arm64",
os: "win32",
cpu: "arm64",
},
{
rustTarget: "x86_64-pc-windows-msvc",
ocBinary: "opencode-windows-x64-baseline",
assetExt: "zip",
package: "@opencode-ai/cli-windows-x64-baseline",
os: "win32",
cpu: "x64",
},
{
rustTarget: "x86_64-unknown-linux-gnu",
ocBinary: "opencode-linux-x64-baseline",
assetExt: "tar.gz",
package: "@opencode-ai/cli-linux-x64-baseline",
os: "linux",
cpu: "x64",
},
{
rustTarget: "aarch64-unknown-linux-gnu",
ocBinary: "opencode-linux-arm64",
assetExt: "tar.gz",
package: "@opencode-ai/cli-linux-arm64",
os: "linux",
cpu: "arm64",
},
]
@@ -51,24 +62,33 @@ function nativeTarget() {
throw new Error(`Unsupported platform: ${platform}/${arch}`)
}
export function getCurrentSidecar(target = RUST_TARGET ?? nativeTarget()) {
const binaryConfig = SIDECAR_BINARIES.find((b) => b.rustTarget === target)
if (!binaryConfig) throw new Error(`Sidecar configuration not available for Rust target '${target}'`)
export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) {
const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target)
if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`)
return binaryConfig
}
export async function copyBinaryToSidecarFolder(source: string) {
const dir = `resources`
await $`mkdir -p ${dir}`
const dest = windowsify(`${dir}/opencode-cli`)
await $`cp ${source} ${dest}`
export async function downloadCliToResources() {
const cli = getCurrentCli()
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
const dest = windowsify("resources/opencode-cli")
try {
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await copyFile(
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"),
dest,
)
} finally {
await rm(directory, { recursive: true, force: true })
}
if (process.platform !== "win32") await chmod(dest, 0o755)
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
}
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
console.log(`Copied ${source} to ${dest}`)
console.log(`Copied ${cli.package} to ${dest}`)
}
export function windowsify(path: string) {
+125
View File
@@ -0,0 +1,125 @@
import { execFile } from "node:child_process"
import { existsSync } from "node:fs"
import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { promisify } from "node:util"
import { app } from "electron"
const execFileAsync = promisify(execFile)
const root = dirname(fileURLToPath(import.meta.url))
const stateHome = process.env.XDG_STATE_HOME
const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"]
type Logger = {
log(message: string, meta?: Record<string, unknown>): void
error(message: string, meta?: Record<string, unknown>): void
}
export async function startBackgroundCli(logger: Logger, shellStateHome?: string) {
const bundled = app.isPackaged
? join(process.resourcesPath, executableName())
: join(root, "../../resources", executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = await run(bundled, ["--version"], logger)
const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
const candidates = [
...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]),
].filter((candidate) => candidate === undefined || existsSync(candidate))
const discovered = await Promise.all(
candidates.map(async (candidate) => ({
stateHome: candidate,
url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })),
})),
)
const found = discovered.find((candidate) => candidate.url !== undefined)
logger.log("v2 CLI background instance checked", {
detected: Boolean(found),
...endpoint(found?.url),
})
const daemonStateHome = found?.stateHome ?? stateHome
const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome })
const password = await run(binary, ["service", "get", "password"], logger, {
redact: true,
stateHome: daemonStateHome,
})
logger.log("v2 CLI background service ready", {
existing: Boolean(found),
username: "opencode",
...endpoint(url),
})
return {
url,
username: "opencode",
password,
}
}
async function installCli(source: string, version: string, logger: Logger) {
const directory = join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-"))
const destination = join(directory, executableName())
if (existsSync(destination)) {
logger.log("v2 CLI staged executable reused", { path: destination, version })
return destination
}
const temp = destination + `.${process.pid}.tmp`
await mkdir(directory, { recursive: true })
await copyFile(source, temp)
if (process.platform !== "win32") await chmod(temp, 0o755)
await rename(temp, destination).catch(async (error) => {
await rm(temp, { force: true })
throw error
})
logger.log("v2 CLI executable staged", { source, path: destination, version })
return destination
}
async function run(
binary: string,
args: string[],
logger: Logger,
options: { redact?: boolean; stateHome?: string } = {},
) {
logger.log("v2 CLI command started", { binary, args })
const env = { ...process.env }
if (options.stateHome === undefined) delete env.XDG_STATE_HOME
else env.XDG_STATE_HOME = options.stateHome
return execFileAsync(binary, args, { env, windowsHide: true }).then(
(result) => {
const stdout = result.stdout.trim()
const stderr = result.stderr.trim()
logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr })
return stdout
},
(error: unknown) => {
const output = error as { stdout?: string; stderr?: string }
logger.error("v2 CLI command failed", {
args,
error: error instanceof Error ? error.message : String(error),
stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""),
stderr: output.stderr?.trim() ?? "",
})
throw error
},
)
}
function serviceUrl(status: string) {
if (URL.canParse(status)) return status
if (!status.startsWith("running ")) return
const url = status.slice("running ".length).trim()
return URL.canParse(url) ? url : undefined
}
function endpoint(url: string | undefined) {
if (!url || !URL.canParse(url)) return {}
const parsed = new URL(url)
return { url, hostname: parsed.hostname, port: parsed.port }
}
function executableName() {
return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli"
}
+48 -29
View File
@@ -48,6 +48,7 @@ import { registerWslIpcHandlers } from "./wsl/ipc"
import { spawnWslSidecar } from "./wsl/sidecar"
import { migrate } from "./migrate"
import { cleanupStoreFiles } from "./store-cleanup"
import { startBackgroundCli } from "./background-cli"
const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev",
@@ -60,6 +61,7 @@ const APP_IDS: Record<string, string> = {
prod: "ai.opencode.desktop",
}
const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
const SIDECAR_VERSION = process.env.OPENCODE_SIDECAR_V2 === "1" ? "v2" : "v1"
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
let logger: ReturnType<typeof initLogging>
@@ -198,7 +200,7 @@ const main = Effect.gen(function* () {
return
}
preferAppEnv(app.getPath("userData"))
const shellEnv = preferAppEnv(app.getPath("userData"))
app.on("second-instance", (_event: Event, argv: string[]) => {
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
@@ -312,39 +314,56 @@ const main = Effect.gen(function* () {
),
)
const port = yield* Effect.gen(function* () {
const fromEnv = process.env.OPENCODE_PORT
if (fromEnv) {
const parsed = Number.parseInt(fromEnv, 10)
if (!Number.isNaN(parsed)) return parsed
}
const res = yield* Deferred.make<number, unknown>()
const server = createServer()
server.on("error", (e) => Deferred.failSync(res, () => e))
server.listen(0, "127.0.0.1", () => {
const address = server.address()
if (typeof address !== "object" || !address) {
server.close()
Deferred.failSync(res, () => new Error("Failed to get port"))
return
}
const port = address.port
server.close(() => Effect.runSync(Deferred.succeed(res, port)))
})
return yield* Deferred.await(res)
})
const hostname = "127.0.0.1"
const url = `http://${hostname}:${port}`
const password = randomUUID()
const loadingTask = yield* Effect.gen(function* () {
logger.log("sidecar connection started", { url })
logger.log("sidecar connection started", { version: SIDECAR_VERSION })
ensureLoopbackNoProxy()
useEnvProxy()
if (SIDECAR_VERSION === "v2") {
logger.log("spawning v2 sidecar")
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME))
yield* Deferred.succeed(serverReady, {
url: sidecar.url,
username: sidecar.username,
password: sidecar.password,
})
if (process.platform === "win32") {
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
}
logger.log("loading task finished")
return
}
const port = yield* Effect.gen(function* () {
const fromEnv = process.env.OPENCODE_PORT
if (fromEnv) {
const parsed = Number.parseInt(fromEnv, 10)
if (!Number.isNaN(parsed)) return parsed
}
const res = yield* Deferred.make<number, unknown>()
const socket = createServer()
socket.on("error", (e) => Deferred.failSync(res, () => e))
socket.listen(0, "127.0.0.1", () => {
const address = socket.address()
if (typeof address !== "object" || !address) {
socket.close()
Deferred.failSync(res, () => new Error("Failed to get port"))
return
}
const port = address.port
socket.close(() => Effect.runSync(Deferred.succeed(res, port)))
})
return yield* Deferred.await(res)
})
const hostname = "127.0.0.1"
const url = `http://${hostname}:${port}`
const password = randomUUID()
logger.log("spawning sidecar", { url })
const { listener, health } = yield* Effect.promise(() =>
spawnLocalServer(hostname, port, password, {
+3 -1
View File
@@ -43,13 +43,15 @@ export function setDefaultServerUrl(url: string | null) {
export function preferAppEnv(userDataPath: string) {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null
Object.assign(process.env, {
...(shell ? loadShellEnv(shell, getLogger()) : null),
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
})
return shellEnv
}
export async function spawnLocalServer(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.18.7",
"version": "1.18.8",
"$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.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.7",
"version": "1.18.8",
"name": "opencode",
"type": "module",
"license": "MIT",
+1
View File
@@ -737,6 +737,7 @@ export const McpDebugCommand = effectCmd({
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
callbackPort: oauthConfig?.callbackPort,
redirectUri: oauthConfig?.redirectUri,
},
{
+38 -25
View File
@@ -1,44 +1,57 @@
import { Client, type Tool as MCPToolDef } from "@modelcontextprotocol/client"
import { Client, type CallToolResult, type Tool as MCPToolDef } from "@modelcontextprotocol/client"
import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai"
import { Effect } from "effect"
const DEFAULT_TIMEOUT = 30_000
export interface McpTool {
readonly def: MCPToolDef
readonly client: Client
readonly timeout?: number
}
export async function callTool(
tool: McpTool,
args: Record<string, unknown>,
signal?: AbortSignal,
): Promise<CallToolResult> {
const result = await tool.client.callTool(
{ name: tool.def.name, arguments: args },
{
resetTimeoutOnProgress: true,
signal,
timeout: tool.timeout,
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
onprogress: () => {},
},
)
if (result.isError)
throw new Error(
result.content
.flatMap((item) => (item.type === "text" ? [item.text] : []))
.filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error",
)
return result
}
export function defs(client: Client, timeout?: number) {
return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void))
}
export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: number): Tool {
export function convertTool(tool: McpTool): Tool {
const inputSchema: JSONSchema7 = {
...(mcpTool.inputSchema as JSONSchema7),
...(tool.def.inputSchema as JSONSchema7),
type: "object",
properties: (mcpTool.inputSchema.properties ?? {}) as JSONSchema7["properties"],
properties: (tool.def.inputSchema.properties ?? {}) as JSONSchema7["properties"],
additionalProperties: false,
}
return dynamicTool({
description: mcpTool.description ?? "",
description: tool.def.description ?? "",
inputSchema: jsonSchema(inputSchema),
execute: async (args: unknown, options) => {
const result = await client.callTool(
{
name: mcpTool.name,
arguments: (args || {}) as Record<string, unknown>,
},
{
resetTimeoutOnProgress: true,
signal: options.abortSignal,
timeout,
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
onprogress: () => {},
},
)
if (result.isError)
throw new Error(
result.content
.flatMap((item) => (item.type === "text" ? [item.text] : []))
.filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error",
)
const result = await callTool(tool, (args || {}) as Record<string, unknown>, options.abortSignal)
if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null)
return result
return {
+1 -6
View File
@@ -162,12 +162,7 @@ export interface ServerInstructions {
}
/** An MCP tool in its native shape; consumers adapt it to their own tool format. */
export interface McpTool {
/** Shared cached definition; consumers must copy rather than mutate it. */
readonly def: MCPToolDef
readonly client: MCPClient
readonly timeout?: number
}
export type McpTool = McpCatalog.McpTool
export interface Interface {
readonly status: () => Effect.Effect<Record<string, Status>>
+1 -1
View File
@@ -388,7 +388,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
if (flags.experimentalCodeMode) return tools
for (const [key, entry] of Object.entries(yield* mcp.tools())) {
const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout)
const item = McpCatalog.convertTool(entry)
const execute = item.execute
if (!execute) continue
+1 -21
View File
@@ -145,27 +145,7 @@ const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input:
)
const result: CallToolResult = yield* Effect.gen(function* () {
yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
// Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns.
return yield* Effect.promise(async () => {
const raw = await input.entry.tool.client.callTool(
{ name: input.entry.tool.def.name, arguments: input.args },
{
resetTimeoutOnProgress: true,
signal: input.ctx.abort,
timeout: input.entry.tool.timeout,
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
onprogress: () => {},
},
)
if (raw.isError)
throw new Error(
raw.content
.flatMap((item) => (item.type === "text" ? [item.text] : []))
.filter((text) => text.trim())
.join("\n\n") || "MCP tool returned an error",
)
return raw
})
return yield* Effect.promise(() => McpCatalog.callTool(input.entry.tool, input.args, input.ctx.abort))
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
@@ -1,8 +1,11 @@
import { Client, LATEST_PROTOCOL_VERSION, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
const posts: Array<{ method: string; session: string | null }> = []
const concurrent = process.env.MCP_RECOVERY_CONCURRENT === "1"
let initializeCount = 0
let pingCount = 0
let replacementStarted!: () => void
const replacement = new Promise<void>((resolve) => (replacementStarted = resolve))
const server = Bun.serve({
port: 0,
async fetch(request) {
@@ -15,6 +18,7 @@ const server = Bun.serve({
if (message.method === "initialize") {
initializeCount++
if (initializeCount === 2) replacementStarted()
return Response.json(
{
jsonrpc: "2.0",
@@ -32,7 +36,8 @@ const server = Bun.serve({
if (message.method === "notifications/initialized") return new Response(null, { status: 202 })
pingCount++
if (pingCount === 1) return new Response("Session not found", { status: 404 })
if (concurrent && pingCount === 2) await replacement
if (pingCount <= (concurrent ? 2 : 1)) return new Response("Session not found", { status: 404 })
return Response.json({ jsonrpc: "2.0", id: message.id, result: {} })
},
})
@@ -40,7 +45,8 @@ const client = new Client({ name: "test", version: "1" })
try {
await client.connect(new StreamableHTTPClientTransport(server.url))
await client.ping()
if (concurrent) await Promise.all([client.ping(), client.ping()])
else await client.ping()
process.stdout.write(JSON.stringify(posts))
} finally {
await client.close()
+42 -2
View File
@@ -28,7 +28,10 @@ describe("McpCatalog.convertTool", () => {
test("preserves content when structuredContent is also present", async () => {
const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }]
const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } }
const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content, structuredContent }))
const converted = McpCatalog.convertTool({
def: mcpTool(),
client: clientReturning({ content, structuredContent }),
})
const output = await converted.execute?.({}, options)
@@ -37,7 +40,10 @@ describe("McpCatalog.convertTool", () => {
test("falls back to structuredContent only when content is absent", async () => {
const structuredContent = { results: [{ title: "one" }] }
const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content: [], structuredContent }))
const converted = McpCatalog.convertTool({
def: mcpTool(),
client: clientReturning({ content: [], structuredContent }),
})
const output = await converted.execute?.({}, options)
@@ -48,6 +54,40 @@ describe("McpCatalog.convertTool", () => {
})
})
describe("McpCatalog.callTool", () => {
test("forwards the request options", async () => {
const controller = new AbortController()
let request: unknown
let options: unknown
const client = {
callTool: async (input: unknown, config: unknown) => {
request = input
options = config
return { content: [] }
},
} as unknown as Client
await McpCatalog.callTool({ def: mcpTool(), client, timeout: 123 }, { value: true }, controller.signal)
expect(request).toEqual({ name: "screenshot", arguments: { value: true } })
expect(options).toMatchObject({ resetTimeoutOnProgress: true, signal: controller.signal, timeout: 123 })
expect(typeof (options as { onprogress?: unknown }).onprogress).toBe("function")
})
test("throws text returned by an MCP tool error", async () => {
const client = clientReturning({
isError: true,
content: [
{ type: "image", data: "AAAA", mimeType: "image/png" },
{ type: "text", text: "first" },
{ type: "text", text: "second" },
],
})
await expect(McpCatalog.callTool({ def: mcpTool(), client }, {})).rejects.toThrow("first\n\nsecond")
})
})
test("preserves output schema validation across paginated tool discovery", async () => {
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
server.setRequestHandler("tools/list", ({ params }) =>
@@ -2,7 +2,7 @@ import path from "node:path"
import { describe, expect, test } from "bun:test"
describe("mcp session recovery", () => {
test.skip("reinitializes and retries once after a session-bound POST returns 404", async () => {
test("reinitializes and retries once after a session-bound POST returns 404", async () => {
const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../fixture/mcp-session-recovery.ts")], {
cwd: path.join(import.meta.dir, "../.."),
stdout: "pipe",
@@ -24,4 +24,24 @@ describe("mcp session recovery", () => {
{ method: "ping", session: "replacement" },
])
})
test("retries a concurrent stale response after recovery completes", async () => {
const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../fixture/mcp-session-recovery.ts")], {
cwd: path.join(import.meta.dir, "../.."),
env: { ...process.env, MCP_RECOVERY_CONCURRENT: "1" },
stdout: "pipe",
stderr: "pipe",
})
const [code, stdout, stderr] = await Promise.all([
child.exited,
Bun.readableStreamToText(child.stdout),
Bun.readableStreamToText(child.stderr),
])
expect(code, stderr).toBe(0)
const posts = JSON.parse(stdout) as Array<{ method: string; session: string | null }>
expect(posts.filter((post) => post.method === "initialize").map((post) => post.session)).toEqual([null, null])
expect(posts.filter((post) => post.method === "ping" && post.session === "expired")).toHaveLength(2)
expect(posts.filter((post) => post.method === "ping" && post.session === "replacement")).toHaveLength(2)
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.18.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.18.7",
"version": "1.18.8",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.18.7",
"version": "1.18.8",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.18.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"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-core",
"version": "1.18.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"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.7",
"version": "1.18.8",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.18.7",
"version": "1.18.8",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.18.7",
"version": "1.18.8",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
@@ -0,0 +1,214 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 1c43bac25a1037416fdf2ddfb4534ba0897a2e69..7e2108326f368ccbc48897195d57e370a4553855 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -3154,6 +3154,7 @@ var Client = class extends require_src.Protocol {
*/
async _connectPlainLegacy(transport, options) {
await super.connect(transport);
+ transport.onsessionexpired = () => this._legacyHandshake(transport, options);
if (transport.sessionId !== void 0) {
const negotiatedProtocolVersion = this._negotiatedProtocolVersion;
if (negotiatedProtocolVersion !== void 0) transport.setProtocolVersion?.(negotiatedProtocolVersion);
@@ -3170,6 +3171,7 @@ var Client = class extends require_src.Protocol {
* the handshake; its completion sets the negotiated (legacy) version.
*/
async _legacyHandshake(transport, options) {
+ transport.onsessionexpired = () => this._legacyHandshake(transport, options);
const legacyVersions = require_src.legacyProtocolVersions(this._supportedProtocolVersions);
try {
const offeredVersion = legacyVersions[0];
@@ -3208,6 +3210,7 @@ var Client = class extends require_src.Protocol {
await super.connect(transport);
const negotiatedProtocolVersion = this._negotiatedProtocolVersion;
if (negotiatedProtocolVersion !== void 0 && transport.setProtocolVersion) transport.setProtocolVersion(negotiatedProtocolVersion);
+ if (negotiatedProtocolVersion !== void 0 && !require_src.isModernProtocolVersion(negotiatedProtocolVersion)) transport.onsessionexpired = () => this._legacyHandshake(transport, options);
return;
}
this._resetConnectionState();
@@ -5211,10 +5214,32 @@ var StreamableHTTPClientTransport = class {
}
}
async send(message, options) {
- return this._send(message, options, false);
+ return this._send(message, options, false, 0, false);
+ }
+ async _recoverSession(expiredSessionId) {
+ if (this._sessionRecovery) return this._sessionRecovery;
+ if (!this.onsessionexpired) return false;
+ if (this._sessionId !== expiredSessionId) return true;
+ this._sessionId = void 0;
+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired()).then(() => true);
+ try {
+ return await this._sessionRecovery;
+ } catch (error) {
+ this._sessionId = void 0;
+ await this.close();
+ throw error;
+ } finally {
+ this._sessionRecovery = void 0;
+ }
}
- async _send(message, options, isAuthRetry, stepUpRetries = 0) {
+ async _send(message, options, isAuthRetry, stepUpRetries = 0, isSessionRetry = false) {
try {
+ const isHandshake = Array.isArray(message) ? message.some((m) => require_src.isInitializeRequest(m)) : require_src.isInitializeRequest(message);
+ const isInitialized = Array.isArray(message) ? message.some((m) => require_src.isInitializedNotification(m)) : require_src.isInitializedNotification(message);
+ if (this._sessionRecovery && !isHandshake && !isInitialized) {
+ await this._sessionRecovery;
+ options?.requestSignal?.throwIfAborted();
+ }
const { resumptionToken, onresumptiontoken } = options || {};
if (resumptionToken) {
this._startOrAuthSse({
@@ -5226,8 +5251,8 @@ var StreamableHTTPClientTransport = class {
}
const headers = await this._commonHeaders();
this._applyBodyDerivedHeaders(headers, message);
- const isHandshake = Array.isArray(message) ? message.some((m) => require_src.isInitializeRequest(m)) : require_src.isInitializeRequest(message);
if (isHandshake) headers.delete("mcp-session-id");
+ const requestSessionId = headers.get("mcp-session-id") || void 0;
if (options?.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) {
if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue;
headers.set(name, value);
@@ -5249,8 +5274,14 @@ var StreamableHTTPClientTransport = class {
signal
};
const response = await (this._fetch ?? fetch)(this._url, init);
- if (isHandshake && response.ok) this._sessionId = response.headers.get("mcp-session-id") || void 0;
+ if (isHandshake && response.ok && (requestSessionId === void 0 || this._sessionId === requestSessionId)) this._sessionId = response.headers.get("mcp-session-id") || void 0;
if (!response.ok) {
+ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitialized) {
+ if (await this._recoverSession(requestSessionId)) {
+ options?.requestSignal?.throwIfAborted();
+ return this._send(message, options, isAuthRetry, stepUpRetries, true);
+ }
+ }
if (response.status === 401 && this._authProvider) {
if (response.headers.has("www-authenticate")) {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
@@ -5264,7 +5295,7 @@ var StreamableHTTPClientTransport = class {
fetchFn: this._fetchWithInit
});
await response.text?.().catch(() => {});
- return this._send(message, options, true, stepUpRetries);
+ return this._send(message, options, true, stepUpRetries, isSessionRetry);
}
await response.text?.().catch(() => {});
if (isAuthRetry) throw new require_src.SdkHttpError(require_src.SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", {
@@ -5284,7 +5315,7 @@ var StreamableHTTPClientTransport = class {
statusText: response.statusText,
text
}, stepUpRetries) !== "AUTHORIZED") throw new UnauthorizedError();
- return this._send(message, options, isAuthRetry, stepUpRetries + 1);
+ return this._send(message, options, isAuthRetry, stepUpRetries + 1, isSessionRetry);
}
}
if (response.status === 400 && typeof text === "string" && this._isModernEnvelopedRequest(message)) try {
diff --git a/dist/index.mjs b/dist/index.mjs
index 77e2389913cb5c5c2b047f95d990ab2892bef923..4b5e4ff2869189d600ca644488a7749668c39747 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -3151,6 +3151,7 @@ var Client = class extends Protocol {
*/
async _connectPlainLegacy(transport, options) {
await super.connect(transport);
+ transport.onsessionexpired = () => this._legacyHandshake(transport, options);
if (transport.sessionId !== void 0) {
const negotiatedProtocolVersion = this._negotiatedProtocolVersion;
if (negotiatedProtocolVersion !== void 0) transport.setProtocolVersion?.(negotiatedProtocolVersion);
@@ -3167,6 +3168,7 @@ var Client = class extends Protocol {
* the handshake; its completion sets the negotiated (legacy) version.
*/
async _legacyHandshake(transport, options) {
+ transport.onsessionexpired = () => this._legacyHandshake(transport, options);
const legacyVersions = legacyProtocolVersions(this._supportedProtocolVersions);
try {
const offeredVersion = legacyVersions[0];
@@ -3205,6 +3207,7 @@ var Client = class extends Protocol {
await super.connect(transport);
const negotiatedProtocolVersion = this._negotiatedProtocolVersion;
if (negotiatedProtocolVersion !== void 0 && transport.setProtocolVersion) transport.setProtocolVersion(negotiatedProtocolVersion);
+ if (negotiatedProtocolVersion !== void 0 && !isModernProtocolVersion(negotiatedProtocolVersion)) transport.onsessionexpired = () => this._legacyHandshake(transport, options);
return;
}
this._resetConnectionState();
@@ -5208,10 +5211,32 @@ var StreamableHTTPClientTransport = class {
}
}
async send(message, options) {
- return this._send(message, options, false);
+ return this._send(message, options, false, 0, false);
+ }
+ async _recoverSession(expiredSessionId) {
+ if (this._sessionRecovery) return this._sessionRecovery;
+ if (!this.onsessionexpired) return false;
+ if (this._sessionId !== expiredSessionId) return true;
+ this._sessionId = void 0;
+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired()).then(() => true);
+ try {
+ return await this._sessionRecovery;
+ } catch (error) {
+ this._sessionId = void 0;
+ await this.close();
+ throw error;
+ } finally {
+ this._sessionRecovery = void 0;
+ }
}
- async _send(message, options, isAuthRetry, stepUpRetries = 0) {
+ async _send(message, options, isAuthRetry, stepUpRetries = 0, isSessionRetry = false) {
try {
+ const isHandshake = Array.isArray(message) ? message.some((m) => isInitializeRequest(m)) : isInitializeRequest(message);
+ const isInitialized = Array.isArray(message) ? message.some((m) => isInitializedNotification(m)) : isInitializedNotification(message);
+ if (this._sessionRecovery && !isHandshake && !isInitialized) {
+ await this._sessionRecovery;
+ options?.requestSignal?.throwIfAborted();
+ }
const { resumptionToken, onresumptiontoken } = options || {};
if (resumptionToken) {
this._startOrAuthSse({
@@ -5223,8 +5248,8 @@ var StreamableHTTPClientTransport = class {
}
const headers = await this._commonHeaders();
this._applyBodyDerivedHeaders(headers, message);
- const isHandshake = Array.isArray(message) ? message.some((m) => isInitializeRequest(m)) : isInitializeRequest(message);
if (isHandshake) headers.delete("mcp-session-id");
+ const requestSessionId = headers.get("mcp-session-id") || void 0;
if (options?.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) {
if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue;
headers.set(name, value);
@@ -5246,8 +5271,14 @@ var StreamableHTTPClientTransport = class {
signal
};
const response = await (this._fetch ?? fetch)(this._url, init);
- if (isHandshake && response.ok) this._sessionId = response.headers.get("mcp-session-id") || void 0;
+ if (isHandshake && response.ok && (requestSessionId === void 0 || this._sessionId === requestSessionId)) this._sessionId = response.headers.get("mcp-session-id") || void 0;
if (!response.ok) {
+ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitialized) {
+ if (await this._recoverSession(requestSessionId)) {
+ options?.requestSignal?.throwIfAborted();
+ return this._send(message, options, isAuthRetry, stepUpRetries, true);
+ }
+ }
if (response.status === 401 && this._authProvider) {
if (response.headers.has("www-authenticate")) {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
@@ -5261,7 +5292,7 @@ var StreamableHTTPClientTransport = class {
fetchFn: this._fetchWithInit
});
await response.text?.().catch(() => {});
- return this._send(message, options, true, stepUpRetries);
+ return this._send(message, options, true, stepUpRetries, isSessionRetry);
}
await response.text?.().catch(() => {});
if (isAuthRetry) throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", {
@@ -5281,7 +5312,7 @@ var StreamableHTTPClientTransport = class {
statusText: response.statusText,
text
}, stepUpRetries) !== "AUTHORIZED") throw new UnauthorizedError();
- return this._send(message, options, isAuthRetry, stepUpRetries + 1);
+ return this._send(message, options, isAuthRetry, stepUpRetries + 1, isSessionRetry);
}
}
if (response.status === 400 && typeof text === "string" && this._isModernEnvelopedRequest(message)) try {
+10 -146
View File
@@ -1,5 +1,11 @@
diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-6fcb6b48d6947d2c b/.bun-tag-6fcb6b48d6947d2c
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-b272f631c12927b0 b/.bun-tag-b272f631c12927b0
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/dist/dev.cjs b/dist/dev.cjs
index 7104749..dc3eac9 100644
index 7104749486e4361e8c4ee7836a8046582cec7aa1..0501eb1ec5d13b81ecb13a5ac1a82db42502b976 100644
--- a/dist/dev.cjs
+++ b/dist/dev.cjs
@@ -764,6 +764,8 @@ function runComputation(node, value, time) {
@@ -11,33 +17,8 @@ index 7104749..dc3eac9 100644
Transition.sources.add(node);
node.tValue = nextValue;
} else node.value = nextValue;
@@ -987,18 +989,21 @@ function cleanNode(node) {
}
}
if (node.tOwned) {
- for (i = node.tOwned.length - 1; i >= 0; i--) cleanNode(node.tOwned[i]);
+ const tOwned = node.tOwned;
delete node.tOwned;
+ for (i = tOwned.length - 1; i >= 0; i--) cleanNode(tOwned[i]);
}
if (Transition && Transition.running && node.pure) {
reset(node, true);
} else if (node.owned) {
- for (i = node.owned.length - 1; i >= 0; i--) cleanNode(node.owned[i]);
+ const owned = node.owned;
node.owned = null;
+ for (i = owned.length - 1; i >= 0; i--) cleanNode(owned[i]);
}
if (node.cleanups) {
- for (i = node.cleanups.length - 1; i >= 0; i--) node.cleanups[i]();
+ const cleanups = node.cleanups;
node.cleanups = null;
+ for (i = cleanups.length - 1; i >= 0; i--) cleanups[i]();
}
if (Transition && Transition.running) node.tState = 0;else node.state = 0;
delete node.sourceMap;
diff --git a/dist/dev.js b/dist/dev.js
index ea5e4bc..a2e2d59 100644
index ea5e4bc2fd4f0b3922a73d9134439529dc81339f..4b3ec07e624d20fdd23d6941a4fdde6d3a78cca3 100644
--- a/dist/dev.js
+++ b/dist/dev.js
@@ -762,6 +762,8 @@ function runComputation(node, value, time) {
@@ -49,75 +30,8 @@ index ea5e4bc..a2e2d59 100644
Transition.sources.add(node);
node.tValue = nextValue;
} else node.value = nextValue;
@@ -985,18 +987,21 @@ function cleanNode(node) {
}
}
if (node.tOwned) {
- for (i = node.tOwned.length - 1; i >= 0; i--) cleanNode(node.tOwned[i]);
+ const tOwned = node.tOwned;
delete node.tOwned;
+ for (i = tOwned.length - 1; i >= 0; i--) cleanNode(tOwned[i]);
}
if (Transition && Transition.running && node.pure) {
reset(node, true);
} else if (node.owned) {
- for (i = node.owned.length - 1; i >= 0; i--) cleanNode(node.owned[i]);
+ const owned = node.owned;
node.owned = null;
+ for (i = owned.length - 1; i >= 0; i--) cleanNode(owned[i]);
}
if (node.cleanups) {
- for (i = node.cleanups.length - 1; i >= 0; i--) node.cleanups[i]();
+ const cleanups = node.cleanups;
node.cleanups = null;
+ for (i = cleanups.length - 1; i >= 0; i--) cleanups[i]();
}
if (Transition && Transition.running) node.tState = 0;else node.state = 0;
delete node.sourceMap;
diff --git a/dist/server.cjs b/dist/server.cjs
index e715309..188ba81 100644
--- a/dist/server.cjs
+++ b/dist/server.cjs
@@ -127,12 +127,14 @@ function onCleanup(fn) {
}
function cleanNode(node) {
if (node.owned) {
- for (let i = 0; i < node.owned.length; i++) cleanNode(node.owned[i]);
+ const owned = node.owned;
node.owned = null;
+ for (let i = 0; i < owned.length; i++) cleanNode(owned[i]);
}
if (node.cleanups) {
- for (let i = 0; i < node.cleanups.length; i++) node.cleanups[i]();
+ const cleanups = node.cleanups;
node.cleanups = null;
+ for (let i = 0; i < cleanups.length; i++) cleanups[i]();
}
}
function catchError(fn, handler) {
diff --git a/dist/server.js b/dist/server.js
index d5f8803..320d9af 100644
--- a/dist/server.js
+++ b/dist/server.js
@@ -125,12 +125,14 @@ function onCleanup(fn) {
}
function cleanNode(node) {
if (node.owned) {
- for (let i = 0; i < node.owned.length; i++) cleanNode(node.owned[i]);
+ const owned = node.owned;
node.owned = null;
+ for (let i = 0; i < owned.length; i++) cleanNode(owned[i]);
}
if (node.cleanups) {
- for (let i = 0; i < node.cleanups.length; i++) node.cleanups[i]();
+ const cleanups = node.cleanups;
node.cleanups = null;
+ for (let i = 0; i < cleanups.length; i++) cleanups[i]();
}
}
function catchError(fn, handler) {
diff --git a/dist/solid.cjs b/dist/solid.cjs
index 7c133a2..5ef1501 100644
index 7c133a2b254678a84fd61d719fbeffad766e1331..2f68c99f2698210cc0bac62f074cc8cd3beb2881 100644
--- a/dist/solid.cjs
+++ b/dist/solid.cjs
@@ -717,6 +717,8 @@ function runComputation(node, value, time) {
@@ -129,33 +43,8 @@ index 7c133a2..5ef1501 100644
Transition.sources.add(node);
node.tValue = nextValue;
} else node.value = nextValue;
@@ -938,18 +940,21 @@ function cleanNode(node) {
}
}
if (node.tOwned) {
- for (i = node.tOwned.length - 1; i >= 0; i--) cleanNode(node.tOwned[i]);
+ const tOwned = node.tOwned;
delete node.tOwned;
+ for (i = tOwned.length - 1; i >= 0; i--) cleanNode(tOwned[i]);
}
if (Transition && Transition.running && node.pure) {
reset(node, true);
} else if (node.owned) {
- for (i = node.owned.length - 1; i >= 0; i--) cleanNode(node.owned[i]);
+ const owned = node.owned;
node.owned = null;
+ for (i = owned.length - 1; i >= 0; i--) cleanNode(owned[i]);
}
if (node.cleanups) {
- for (i = node.cleanups.length - 1; i >= 0; i--) node.cleanups[i]();
+ const cleanups = node.cleanups;
node.cleanups = null;
+ for (i = cleanups.length - 1; i >= 0; i--) cleanups[i]();
}
if (Transition && Transition.running) node.tState = 0;else node.state = 0;
}
diff --git a/dist/solid.js b/dist/solid.js
index 656fd26..6e0038c 100644
index 656fd26e7e5c794aa22df19c2377ff5c0591fc29..f08e9f5a7157c3506e5b6922fe2ef991335a80be 100644
--- a/dist/solid.js
+++ b/dist/solid.js
@@ -715,6 +715,8 @@ function runComputation(node, value, time) {
@@ -167,28 +56,3 @@ index 656fd26..6e0038c 100644
Transition.sources.add(node);
node.tValue = nextValue;
} else node.value = nextValue;
@@ -936,18 +938,21 @@ function cleanNode(node) {
}
}
if (node.tOwned) {
- for (i = node.tOwned.length - 1; i >= 0; i--) cleanNode(node.tOwned[i]);
+ const tOwned = node.tOwned;
delete node.tOwned;
+ for (i = tOwned.length - 1; i >= 0; i--) cleanNode(tOwned[i]);
}
if (Transition && Transition.running && node.pure) {
reset(node, true);
} else if (node.owned) {
- for (i = node.owned.length - 1; i >= 0; i--) cleanNode(node.owned[i]);
+ const owned = node.owned;
node.owned = null;
+ for (i = owned.length - 1; i >= 0; i--) cleanNode(owned[i]);
}
if (node.cleanups) {
- for (i = node.cleanups.length - 1; i >= 0; i--) node.cleanups[i]();
+ const cleanups = node.cleanups;
node.cleanups = null;
+ for (i = cleanups.length - 1; i >= 0; i--) cleanups[i]();
}
if (Transition && Transition.running) node.tState = 0;else node.state = 0;
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.18.7",
"version": "1.18.8",
"publisher": "sst-dev",
"repository": {
"type": "git",