mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 06:02:05 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e432a6785 | |||
| 1ead8d84a2 | |||
| 8c81f9a40b | |||
| 95636bd3ca | |||
| 7dae9a1083 | |||
| d46be565aa | |||
| 8021dbd80f | |||
| c8487bac54 | |||
| 484f00ebf4 | |||
| c3be6c4965 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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}
|
||||
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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*"],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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, {
|
||||
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -737,6 +737,7 @@ export const McpDebugCommand = effectCmd({
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
callbackPort: oauthConfig?.callbackPort,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user