Compare commits

...

33 Commits

Author SHA1 Message Date
opencode-agent[bot] 7c66fa3051 Apply PR #38403: fix(ui): standardize v2 tooltip delay
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Waiting to run
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Waiting to run
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Waiting to run
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Waiting to run
nix-hashes / update-hashes (push) Blocked by required conditions
publish / version (push) Waiting to run
publish / build-cli (push) Blocked by required conditions
publish / sign-cli-windows (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Blocked by required conditions
publish / publish (push) Blocked by required conditions
2026-07-29 05:13:51 +00:00
opencode-agent[bot] 9098cd57bd Apply PR #37927: fix(app): navigate tabs by selection history 2026-07-29 05:12:32 +00:00
opencode-agent[bot] 356392835b Apply PR #29948: fix(tui): keep command palette available in questions 2026-07-29 05:11:24 +00:00
opencode-agent[bot] 8cbea4fbb7 chore: generate 2026-07-29 01:26:18 +00:00
Brendan Allan f256a4c538 fix(app): preserve agent picker for existing users (#39300) 2026-07-29 01:24:31 +00:00
opencode a45c2b917e sync release versions for v1.18.9 2026-07-28 18:14:59 +00:00
opencode-agent[bot] f28d72d15e chore: update nix node_modules hashes 2026-07-28 17:12:42 +00:00
opencode-agent[bot] d8a9d8a76d chore: generate 2026-07-28 16:55:30 +00:00
Aiden Cline 982a9044c5 fix(mcp): restore legacy SDK compatibility (#39373) 2026-07-28 11:53:35 -05:00
opencode-agent[bot] 7edefb3347 chore(mcp): upgrade client to 2.0.0 (#39369)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-28 11:37:58 -05:00
usrnk1 017a5977d2 feat(desktop): remove v2 vertical menu borders (#39317) 2026-07-28 19:50:58 +08:00
opencode-agent[bot] 7336cc5a7b chore: update nix node_modules hashes 2026-07-28 11:21:16 +00:00
opencode-agent[bot] e1587588c4 chore: generate 2026-07-28 11:09:22 +00:00
opencode-agent[bot] be93cec9ba fix(app): guard reentrant Solid cleanup (#39261)
Co-authored-by: Jack <jack@anoma.ly>
2026-07-28 19:07:19 +08:00
Robin Andrew 7c898249a2 fix(desktop): patch @dnd-kit/solid to preserve core scroll plugins (#38119)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-28 19:05:57 +08:00
Brendan Allan 9e432a6785 feat(desktop): add opt-in v2 sidecar (#39286) 2026-07-28 17:29:27 +08:00
Brendan Allan 1ead8d84a2 fix(app): localize home session suspense (#39285) 2026-07-28 16:28:59 +08:00
Brendan Allan 8c81f9a40b refactor(app): extract v2 settings controllers (#39228) 2026-07-28 16:12:59 +08:00
usrnk1 95636bd3ca feat(desktop): collapse model provider sections (#39283) 2026-07-28 07:35:57 +00:00
opencode-agent[bot] 7dae9a1083 chore: generate 2026-07-28 07:03:56 +00:00
Brendan Allan d46be565aa refactor(app): thin new session composition (#39227) 2026-07-28 15:02:26 +08:00
opencode 8021dbd80f sync release versions for v1.18.8
deploy / deploy (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
containers / build (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
2026-07-28 06:07:51 +00:00
opencode-agent[bot] c8487bac54 chore: update nix node_modules hashes 2026-07-28 05:50:46 +00:00
Aiden Cline 484f00ebf4 fix(mcp): recover expired SDK sessions (#39265) 2026-07-28 00:35:19 -05:00
Aiden Cline c3be6c4965 fix(mcp): honor callback port in debug (#39259) 2026-07-28 00:00:38 -05:00
opencode-agent[bot] 172d08cb98 chore: update nix node_modules hashes 2026-07-28 04:34:03 +00:00
opencode-agent[bot] 5e39051183 fix(provider): omit deprecated Gemini sampling defaults (#38924)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-07-27 23:21:00 -05:00
opencode-agent[bot] d50b9e8f9a chore: generate 2026-07-28 04:20:55 +00:00
Aiden Cline 921b1c6a34 feat(mcp): upgrade client SDK to v2 (#39247)
Co-authored-by: Matt Carey <mcarey@cloudflare.com>
2026-07-27 23:19:33 -05:00
Luke Parker f77f5a343e fix(ui): standardize tooltip delay 2026-07-27 13:24:01 +08:00
LukeParkerDev 985ee1e2ec fix(app): navigate tabs by selection history 2026-07-20 23:15:53 +10:00
Test 83bee1e776 Merge remote-tracking branch 'origin/dev' into beta-sync-29948
# Conflicts:
#	packages/tui/test/keymap.test.tsx
2026-06-25 07:02:20 +00:00
opencode-agent[bot] 124714ca3a fix(tui): keep command palette available in questions 2026-05-29 23:19:01 +00:00
84 changed files with 2057 additions and 927 deletions
-4
View File
@@ -219,7 +219,6 @@ jobs:
build-electron:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode'
continue-on-error: false
@@ -316,10 +315,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Build
run: bun run build
+29 -28
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -96,7 +96,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.18.7",
"version": "1.18.9",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -144,7 +144,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -158,7 +158,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -194,7 +194,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -221,7 +221,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -243,7 +243,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -267,7 +267,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -287,7 +287,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.18.7",
"version": "1.18.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -381,7 +381,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -435,7 +435,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -449,7 +449,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"effect": "catalog:",
},
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -493,7 +493,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -509,7 +509,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -540,7 +540,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -559,7 +559,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.18.7",
"version": "1.18.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -690,7 +690,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -766,7 +766,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -781,7 +781,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -796,7 +796,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
@@ -841,7 +841,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -854,7 +854,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -888,7 +888,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -907,7 +907,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -949,7 +949,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -976,7 +976,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1027,7 +1027,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1082,6 +1082,7 @@
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
},
"overrides": {
"@opentui/core": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-szhy0258K6IMWi3WRrAzRfhEEIDkGTS04KQwQw+DXyI=",
"aarch64-linux": "sha256-eVqUhXGKgx7rmRzpdr9G59Xrxvj2J0Dhx4Q7QRBHKiQ=",
"aarch64-darwin": "sha256-r/xAkD/yktfxOVTAArWYb0ZMWe/hEqh80dkYsigRkm8=",
"x86_64-darwin": "sha256-xEKSQZM4gYoz9RoaHEvuxrT8AFXHx8hMD1oKGi8lc3Y="
"x86_64-linux": "sha256-/ZtluLolVq1ZFAYgbDScze9GZR0NF9baRQXvfEoOnkI=",
"aarch64-linux": "sha256-kdh7Dpira7cx9lvtozFsjLrDqMBrUMcgxkhfsqrutws=",
"aarch64-darwin": "sha256-chPeMCX+8hvOQ32hTANIn7FmONsSv9t3dKTWTaHerjs=",
"x86_64-darwin": "sha256-rMc0dDZB+YbXrjYwXTIt7QvHUxSp//wDJQ5hUdR+LYM="
}
}
+1
View File
@@ -143,6 +143,7 @@
"@types/node": "catalog:"
},
"patchedDependencies": {
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.18.7",
"version": "1.18.9",
"description": "",
"type": "module",
"exports": {
@@ -88,7 +88,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
delay="intent"
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
value={
<ModelTooltip
@@ -460,7 +460,7 @@ function ModelSelectorPopoverV2View(props: {
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
delay="intent"
value={
<ModelTooltip
model={item}
@@ -47,7 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
</span>
}
placement="top"
openDelay={800}
{...(!props.newLayoutDesigns ? { openDelay: 800 } : {})}
>
<div
classList={{
@@ -52,12 +52,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<For each={props.comments ?? []}>
{(item) => (
<div class="relative group shrink-0">
<TooltipV2
value={item.comment}
placement="top"
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
<CommentCardV2
comment={item.comment ?? ""}
path={item.path}
@@ -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;
}
+16
View File
@@ -338,6 +338,22 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
keybind: "mod+shift+t",
onSelect: () => tabsStoreActions.reopenClosedTab(),
},
{
id: "tab.prev",
category: "tab",
title: "",
keybind: "mod+option+ArrowLeft,ctrl+shift+tab",
hidden: true,
onSelect: tabs.previous,
},
{
id: "tab.next",
category: "tab",
title: "",
keybind: "mod+option+ArrowRight,ctrl+tab",
hidden: true,
onSelect: tabs.next,
},
].filter((v) => v !== undefined)
})
+17
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import {
hasExistingWebState,
initialAgentVisibility,
isAppUpgrade,
layoutTransitionState,
maximumSunsetTimeout,
@@ -11,6 +12,22 @@ import {
shouldEnableNewLayout,
} from "./settings"
describe("agent visibility", () => {
test("shows the picker for existing profiles and hides it for first-time installs", () => {
expect(initialAgentVisibility(undefined, true)).toBe(true)
expect(initialAgentVisibility(undefined, false)).toBe(false)
})
test("shows the picker when updating from a recent release", () => {
expect(initialAgentVisibility(undefined, false, "1.18.8")).toBe(true)
})
test("preserves the preference after initialization", () => {
expect(initialAgentVisibility(true, true, "1.18.8")).toBeUndefined()
expect(initialAgentVisibility(true, false)).toBeUndefined()
})
})
describe("layout transition", () => {
test("blank profiles default to the new layout", () => {
expect(newLayoutDesignsDefault).toBe(true)
+19 -3
View File
@@ -1,5 +1,5 @@
import { createStore, reconcile } from "solid-js/store"
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist"
import { usePlatform } from "@/context/platform"
@@ -36,6 +36,7 @@ export interface Settings {
mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean
layoutTransitionEligible?: boolean
agentVisibilityInitialized?: boolean
newInterfaceNoticeDismissed?: boolean
shouldDisplayTabsToast?: boolean
}
@@ -93,6 +94,11 @@ export function hasExistingWebState(settings: Promise<string> | string | null, p
return settings !== null || previousVersion !== undefined
}
export function initialAgentVisibility(initialized: boolean | undefined, existing: boolean, previousVersion?: string) {
if (initialized === true) return
return existing || previousVersion !== undefined
}
export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) {
if (!current) return false
const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff)
@@ -271,6 +277,14 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
)
})
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
const initializeAgentVisibility = (existing: boolean) => {
const initial = initialAgentVisibility(store.general?.agentVisibilityInitialized, existing, launchState.previous)
if (initial === undefined) return
batch(() => {
setStore("general", "showCustomAgents", initial)
setStore("general", "agentVisibilityInitialized", true)
})
}
if (sunset && !oldInterfaceRetired()) {
const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -299,8 +313,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (!ready() || !launchState.classified || platform.platform !== "web") return
if (layoutTransitionClassified()) return
setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous))
const existing = hasExistingWebState(settingsInit, launchState.previous)
if (!layoutTransitionClassified()) setStore("general", "layoutTransitionEligible", existing)
initializeAgentVisibility(existing)
})
createEffect(() => {
@@ -426,6 +441,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
if (typeof current === "boolean") return
setStore("general", "layoutTransitionEligible", eligible)
},
initializeAgentVisibility,
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
dismissNewInterfaceNotice() {
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
function history(): TabHistory {
return { stack: [], index: -1 }
}
describe("tab history", () => {
test("moves backward and forward through selected tabs", () => {
const selected = ["a", "b", "c"].reduce(rememberTab, history())
const available = new Set(selected.stack)
const previous = previousTab(selected, available)
expect(previous?.key).toBe("b")
const first = previousTab(previous!.state, available)
expect(first?.key).toBe("a")
const next = nextTab(first!.state, available)
expect(next?.key).toBe("b")
})
test("replaces forward history after a new selection", () => {
const selected = ["a", "b", "c"].reduce(rememberTab, history())
const previous = previousTab(selected, new Set(selected.stack))
const next = rememberTab(previous!.state, "d")
expect(next).toEqual({ stack: ["a", "b", "d"], index: 2 })
expect(nextTab(next, new Set(next.stack))).toBeUndefined()
})
test("skips tabs that are no longer open", () => {
const selected = ["a", "b", "c"].reduce(rememberTab, history())
expect(previousTab(selected, new Set(["a", "c"]))?.key).toBe("a")
})
test("skips a repeated current tab after closing the previous selection", () => {
const selected = ["a", "b", "c", "b"].reduce(rememberTab, history())
expect(previousTab(selected, new Set(["a", "b"]))?.key).toBe("a")
})
})
+28
View File
@@ -0,0 +1,28 @@
const MAX_TAB_HISTORY = 100
export type TabHistory = {
stack: string[]
index: number
}
export function rememberTab(state: TabHistory, key: string): TabHistory {
if (state.stack[state.index] === key) return state
const stack = state.stack.slice(0, state.index + 1).concat(key).slice(-MAX_TAB_HISTORY)
return { stack, index: stack.length - 1 }
}
export function previousTab(state: TabHistory, available: Set<string>) {
return move(state, -1, available)
}
export function nextTab(state: TabHistory, available: Set<string>) {
return move(state, 1, available)
}
function move(state: TabHistory, offset: -1 | 1, available: Set<string>) {
const current = state.stack[state.index]
for (let index = state.index + offset; index >= 0 && index < state.stack.length; index += offset) {
const key = state.stack[index]
if (key && key !== current && available.has(key)) return { state: { ...state, index }, key }
}
}
+16
View File
@@ -12,6 +12,7 @@ import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
export type SessionTab = {
type: "session"
@@ -79,6 +80,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const memory = createTabMemory(getOwner())
const closing = new Set<string>()
let history: TabHistory = { stack: [], index: -1 }
let recentWrite = 0
let recentValue: string | undefined
@@ -150,10 +152,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const navigateTab = (tab: Tab) => {
const href = tabHref(tab)
history = rememberTab(history, tabKey(tab))
setRecentKey(tabKey(tab))
navigate(href)
}
const moveHistory = (direction: "previous" | "next") => {
const available = new Set(store.map(tabKey))
const result = direction === "previous" ? previousTab(history, available) : nextTab(history, available)
if (!result) return
const tab = store.find((item) => tabKey(item) === result.key)
if (!tab) return
history = result.state
navigateTab(tab)
}
const removeTab = (index: number) => {
const tab = store[index]
if (!tab) return
@@ -359,8 +372,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
select: navigateTab,
remember(tab: Tab) {
const key = tabKey(tab)
history = rememberTab(history, key)
if (recentKey() !== key) setRecentKey(key)
},
previous: () => moveHistory("previous"),
next: () => moveHistory("next"),
toggleHome(input: { home: boolean; current?: Tab }) {
if (input.home) {
const tab = store.find((tab) => tabKey(tab) === recentKey())
+11
View File
@@ -2,6 +2,17 @@ import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu"
describe("desktop menu", () => {
test("navigates between tabs", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && (item.label === "Previous Tab" || item.label === "Next Tab"),
)
expect(items).toEqual([
{ type: "item", label: "Previous Tab", command: "tab.prev", accelerator: { macos: "Option+Up" } },
{ type: "item", label: "Next Tab", command: "tab.next", accelerator: { macos: "Option+Down" } },
])
})
test("exports logs through the desktop command registry", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.label === "Export Logs...",
+2 -2
View File
@@ -168,8 +168,8 @@ export const DESKTOP_MENU: DesktopMenu[] = [
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
{ type: "separator" },
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
{ type: "item", label: "Previous Tab", command: "tab.prev", accelerator: { macos: "Option+Up" } },
{ type: "item", label: "Next Tab", command: "tab.next", accelerator: { macos: "Option+Down" } },
{ type: "separator" },
{
type: "item",
@@ -482,7 +482,6 @@ function HomeProjectRow(
class="pr-16 disabled:opacity-60"
classList={{
"bg-v2-background-bg-layer-01 text-v2-text-text-base": sortable.isDragSource(),
"[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]": sortable.isDragSource(),
}}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
@@ -590,9 +589,9 @@ function HomeProjectNavButton(props: JSX.ButtonHTMLAttributes<HTMLButtonElement>
class={`
flex h-7 min-w-0 w-full shrink-0 cursor-default items-center gap-2 rounded-[6px] bg-transparent px-1.5 text-left
text-v2-text-text-muted [font-weight:440] transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base
data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base
data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03
data-[selected]:hover:bg-v2-background-bg-layer-03
focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:outline-none
focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]
${local.class ?? ""}
@@ -1,5 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { type Accessor, createMemo, For, Show } from "solid-js"
import { type Accessor, createMemo, For, Show, Suspense } from "solid-js"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
@@ -39,7 +39,6 @@ function isBackgroundOpen(event: MouseEvent) {
export type HomeSessionsViewProps = {
language: ReturnType<typeof useLanguage>
groups: Accessor<HomeSessionGroup[]>
loading: Accessor<boolean>
showProjectName: Accessor<boolean>
server: Accessor<ServerConnection.Key>
canCreateSession: Accessor<boolean>
@@ -81,20 +80,22 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
>
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<HomeSessionSearch {...props} />
<Show when={props.groups().length > 0 && props.canCreateSession()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
<Suspense>
<Show when={props.groups().length > 0 && props.canCreateSession()}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
</Suspense>
</div>
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
@@ -104,8 +105,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show
when={!props.loading()}
<Suspense
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={props.language.t("common.loading")} />
@@ -141,7 +141,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
</For>
</div>
</Show>
</Show>
</Suspense>
</div>
</section>
)
@@ -12,7 +12,6 @@ export function HomeSessions(props: {
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups}
loading={props.sessions.data.loading}
showProjectName={props.sessions.session.showProjectName}
server={props.sessions.session.server}
canCreateSession={props.sessions.session.canCreate}
+25 -266
View File
@@ -1,290 +1,49 @@
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useSearchParams } from "@solidjs/router"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { NewSessionDesignView } from "@/components/session"
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
import { StatusPopoverV2 } from "@/components/status-popover"
import {
PromptProjectAddButton,
PromptProjectSelector,
createPromptProjectController,
} from "@/components/prompt-project-selector"
import { useComments } from "@/context/comments"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
import { createPromptProjectController } from "@/components/prompt-project-selector"
import { useTitlebarRightMount } from "@/components/titlebar"
import { useCommand } from "@/context/command"
import { useProviders } from "@/hooks/use-providers"
import { useSettingsCommand } from "@/components/settings-dialog"
import { Persist, persisted } from "@/utils/persist"
import createPresence from "solid-presence"
import { useLocal } from "@/context/local"
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
import { useSettings } from "@/context/settings"
import { createEffect, createResource } from "solid-js"
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
import { useNewSessionCommands } from "./new-session/use-new-session-commands"
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
const providerTipExitDuration = 250
/**
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
* composer for a brand-new session — no terminal, review pane, file tree, or message
* timeline. Submitting promotes the draft into a real session (see prompt-input/submit).
*/
/** The draft-only V2 session page. Submitting promotes the draft into a real session. */
export default function NewSessionPage() {
const prompt = usePrompt()
const sdk = useSDK()
const sync = useSync()
const serverSync = useServerSync()
const comments = useComments()
const language = useLanguage()
const settings = useSettings()
const dialog = useDialog()
const command = useCommand()
const providers = useProviders(() => sdk().directory)
const openProviders = () => {
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
})
}
useSettingsCommand()
const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
const local = useLocal()
const model = createPromptModelSelection({ agent: local.agent.current })
useComposerCommands({ model })
const inputController = createPromptInputController({
sessionKey: route.sessionKey,
sessionID: () => route.params.id,
queryOptions: serverSync().queryOptions,
model,
})
const projectControls = createPromptProjectControls()
const [store, setStore] = createStore<{ worktree?: string }>({})
const rightMount = useTitlebarRightMount()
const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
const newSessionWorktree = createMemo(() => {
if (!showWorkspaceBar()) return "main"
if (store.worktree) return store.worktree
const project = sync().project
if (project && sdk().directory !== project.worktree) return sdk().directory
return "main"
const workspace = createNewSessionWorkspaceController()
const draft = createNewSessionDraftController({
worktree: workspace.selection.value,
resetWorktree: workspace.selection.reset,
})
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
const selectedBranch = createMemo(() => {
const worktree = newSessionWorktree()
if (worktree === "main" || worktree === "create") return localBranch()
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
const project = createPromptProjectController({
controls: draft.project.controls,
onDone: draft.input.restoreFocus,
})
const promptInputV2Controller = usePromptInputV2Controller({
get controls() {
return inputController()
useNewSessionCommands({
restoreFocus: draft.input.restoreFocus,
project: {
empty: project.empty,
open: () => project.setOpen(true),
},
get newSessionWorktree() {
return newSessionWorktree()
},
onNewSessionWorktreeReset: () => setStore("worktree", undefined),
onSubmit: () => comments.clear(),
})
const projectController = createPromptProjectController({
controls: projectControls,
onDone: promptInputV2Controller.restoreFocus,
})
command.register("new-session", () => [
{
id: "command.palette",
title: language.t("command.palette"),
hidden: true,
onSelect: async () => {
const { DialogSelectFile } = await import("@/components/dialog-select-file")
void dialog.show(() => <DialogSelectFile />)
},
},
{
id: "input.focus",
title: language.t("command.input.focus"),
category: language.t("command.category.view"),
keybind: "ctrl+l",
onSelect: () => promptInputV2Controller.restoreFocus(),
},
{
id: "project.select",
title: language.t("session.new.project.search"),
category: language.t("command.category.project"),
keybind: "mod+shift+o",
disabled: projectController.empty(),
onSelect: () => projectController.setOpen(true),
},
])
createEffect(() => {
if (!prompt.ready()) return
untrack(() => {
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
setSearchParams({ ...searchParams, prompt: undefined })
})
if (!draft.prompt.ready()) return
draft.input.restoreFocus()
})
createEffect(() => {
if (!prompt.ready()) return
promptInputV2Controller.restoreFocus()
})
const ready = Promise.resolve()
const [suspendUntilPromptReady] = createResource(
() => prompt.ready.promise ?? ready,
() => draft.prompt.readyPromise() ?? ready,
(promise) => promise.then(() => true),
)
return (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<Show when={rightMount()}>
{(mount) => (
<Portal mount={mount()}>
<Show when={settings.visibility.status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopoverV2 />
</Tooltip>
</Show>
</Portal>
)}
</Show>
<NewSessionStatus mount={rightMount} visible={settings.visibility.status} />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
<NewSessionDesignView>
<div class={NEW_SESSION_CONTENT_WIDTH}>
<div class="flex flex-col gap-8">
<PromptInputV2Composer controller={promptInputV2Controller} />
<Show when={projectController.empty()}>
<PromptProjectAddButton controller={projectController} />
</Show>
<Show when={projectController.selected()}>
<div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
<PromptProjectSelector controller={projectController} placement="bottom" />
<Show
when={showWorkspaceBar()}
fallback={<PromptGitStatus branch={selectedBranch()} noGit={sync().project?.vcs !== "git"} />}
>
<PromptWorkspaceSelector
value={newSessionWorktree()}
projectRoot={projectRoot()}
workspaces={sync().project?.sandboxes ?? []}
branch={selectedBranch()}
onChange={(value) =>
setStore(
"worktree",
value === "main" && sync().project?.worktree !== sdk().directory
? sync().project?.worktree
: value,
)
}
onDone={promptInputV2Controller.restoreFocus}
/>
</Show>
</div>
</Show>
</div>
{/*</Show>*/}
</div>
</NewSessionDesignView>
<ProviderTip
ready={() => serverSync().child(sdk().directory)[0].provider_ready}
connected={() => providers.paid().length > 0}
openProviders={openProviders}
/>
</div>
</div>
<NewSessionView input={draft.input} project={project} workspace={workspace} />
</div>
</div>
)
}
function ProviderTip(props: { ready: () => boolean; connected: () => boolean; openProviders: () => void }) {
const language = useLanguage()
const [persistedState, setPersistedState, , persistedReady] = persisted(
Persist.global("new-session.provider-tip"),
createStore({ dismissedAt: 0 }),
)
const visible = createMemo(
() =>
props.ready() &&
persistedReady() &&
!props.connected() &&
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
)
function dismiss() {
setPersistedState("dismissedAt", Date.now())
}
const [ref, setRef] = createSignal<HTMLDivElement>()
const presence = createPresence({
show: () => visible(),
element: () => ref() ?? null,
})
return (
<Show when={presence.present()}>
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
<div
ref={setRef}
data-component="provider-tip"
data-visible={visible()}
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
classList={{
"data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true,
}}
>
<button
type="button"
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
onClick={props.openProviders}
>
<span class="truncate">{language.t("home.providerTip")}</span>
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
</span>
</button>
<TooltipV2
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
placement="top"
openDelay={1000}
value={language.t("common.dismiss")}
>
<button
type="button"
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
aria-label={language.t("common.dismiss")}
onClick={dismiss}
>
<IconV2 name="xmark-small" />
</button>
</TooltipV2>
</div>
</div>
</Show>
)
}
@@ -0,0 +1,64 @@
import { useSearchParams } from "@solidjs/router"
import { createEffect, untrack } from "solid-js"
import { usePromptInputV2Controller } from "@/components/prompt-input-v2"
import { useComments } from "@/context/comments"
import { useLocal } from "@/context/local"
import { usePrompt } from "@/context/prompt"
import { useServerSync } from "@/context/server-sync"
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) {
const prompt = usePrompt()
const serverSync = useServerSync()
const comments = useComments()
const local = useLocal()
const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
const model = createPromptModelSelection({ agent: () => local.agent.current() })
useComposerCommands({ model })
const controls = createPromptInputController({
sessionKey: route.sessionKey,
sessionID: () => route.params.id,
queryOptions: serverSync().queryOptions,
model,
})
const projectControls = createPromptProjectControls()
const input = usePromptInputV2Controller({
get controls() {
return controls()
},
get newSessionWorktree() {
return workspace.worktree()
},
onNewSessionWorktreeReset: workspace.resetWorktree,
onSubmit: comments.clear,
})
createEffect(() => {
if (!prompt.ready()) return
untrack(() => {
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
setSearchParams({ ...searchParams, prompt: undefined })
})
})
return {
input,
prompt: {
ready: prompt.ready,
readyPromise: () => prompt.ready.promise,
},
project: {
controls: projectControls,
},
}
}
export type NewSessionDraftController = ReturnType<typeof createNewSessionDraftController>
@@ -0,0 +1,161 @@
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"
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,
},
])
}
@@ -0,0 +1,64 @@
import { expect, test } from "bun:test"
import { MetaProvider, Title } from "@solidjs/meta"
import { MemoryRouter, Route, createMemoryHistory, useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
import { createComponent, render } from "solid-js/web"
test("route cleanup cannot invalidate an owner list being disposed", async () => {
const host = document.createElement("div")
document.body.append(host)
const history = createMemoryHistory()
const RepoPage = () => {
const params = useParams<{ id?: string }>()
const title = createMemo(() => params.id ?? "")
const button = document.createElement("button")
button.textContent = "Back"
button.addEventListener("click", () => history.set({ value: "/", scroll: false, replace: false }))
return [
createComponent(Title, {
get children() {
return title()
},
}),
button,
]
}
const HomePage = () => {
const button = document.createElement("button")
button.textContent = "Go"
button.addEventListener("click", () => history.set({ value: "/project", scroll: false, replace: false }))
return button
}
const App = () =>
createComponent(MetaProvider, {
get children() {
return createComponent(MemoryRouter, {
history,
get children() {
return [
createComponent(Route, { path: "/", component: HomePage }),
createComponent(Route, { path: "/:id", component: RepoPage }),
]
},
})
},
})
const dispose = render(() => createComponent(App, {}), host)
const go = host.querySelector("button")
expect(go?.textContent).toBe("Go")
go?.click()
await new Promise((resolve) => setTimeout(resolve, 0))
const back = host.querySelector("button")
expect(back?.textContent).toBe("Back")
back?.click()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(host.querySelector("button")?.textContent).toBe("Go")
dispose()
host.remove()
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.18.7",
"version": "1.18.9",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.18.7",
"version": "1.18.9",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.18.7",
"version": "1.18.9",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.18.7",
"version": "1.18.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.18.7",
"version": "1.18.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.18.7",
"version": "1.18.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.18.7",
"version": "1.18.9",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.7",
"version": "1.18.9",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -56,3 +56,36 @@ test("keeps a hidden prod launcher for old Linux pins", async () => {
expect(desktop).toContain("StartupWMClass=ai.opencode.desktop")
expect(desktop).toContain("NoDisplay=true")
})
test("bundles the CLI outside the dev app archive", async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = "dev"
const module = await import("./electron-builder.config.ts?cli-resource")
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.files).toContain("!resources/opencode-cli*")
expect(config.extraResources).toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
for (const channel of ["beta", "prod"] as const) {
test(`does not bundle the CLI in ${channel} builds`, async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = channel
const module = await import(`./electron-builder.config.ts?no-cli-resource=${channel}`)
const config = module.default as Configuration
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.extraResources).not.toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
})
})
}
+10 -1
View File
@@ -55,8 +55,17 @@ const getBase = (appId: string): Configuration => ({
extraMetadata: {
desktopName: `${appId}.desktop`,
},
files: ["out/**/*", "resources/**/*"],
files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
extraResources: [
...(channel === "dev"
? [
{
from: "resources/",
to: "",
filter: ["opencode-cli*"],
},
]
: []),
{
from: "native/",
to: "native/",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.18.7",
"version": "1.18.9",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+2 -1
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { resolveChannel } from "./utils"
import { downloadCliToResources, resolveChannel } from "./utils"
const channel = resolveChannel()
await $`bun ./scripts/copy-icons.ts ${channel}`
await $`bun ./scripts/copy-metainfo.ts ${channel}`
await $`cd ../opencode && bun script/build-node.ts`
if (channel === "dev") await downloadCliToResources()
+2
View File
@@ -1,7 +1,9 @@
import { $ } from "bun"
import { downloadCliToResources } from "./utils"
await $`bun run install-electron`
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
await $`cd ../opencode && bun script/build-node.ts`
await downloadCliToResources()
+42 -22
View File
@@ -1,4 +1,9 @@
import { $ } from "bun"
import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
const CLI_VERSION = "0.0.0-next-16350"
export type Channel = "dev" | "beta" | "prod"
@@ -8,36 +13,42 @@ export function resolveChannel(): Channel {
return "dev"
}
export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; assetExt: string }> = [
export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [
{
rustTarget: "aarch64-apple-darwin",
ocBinary: "opencode-darwin-arm64",
assetExt: "zip",
package: "@opencode-ai/cli-darwin-arm64",
os: "darwin",
cpu: "arm64",
},
{
rustTarget: "x86_64-apple-darwin",
ocBinary: "opencode-darwin-x64-baseline",
assetExt: "zip",
package: "@opencode-ai/cli-darwin-x64-baseline",
os: "darwin",
cpu: "x64",
},
{
rustTarget: "aarch64-pc-windows-msvc",
ocBinary: "opencode-windows-arm64",
assetExt: "zip",
package: "@opencode-ai/cli-windows-arm64",
os: "win32",
cpu: "arm64",
},
{
rustTarget: "x86_64-pc-windows-msvc",
ocBinary: "opencode-windows-x64-baseline",
assetExt: "zip",
package: "@opencode-ai/cli-windows-x64-baseline",
os: "win32",
cpu: "x64",
},
{
rustTarget: "x86_64-unknown-linux-gnu",
ocBinary: "opencode-linux-x64-baseline",
assetExt: "tar.gz",
package: "@opencode-ai/cli-linux-x64-baseline",
os: "linux",
cpu: "x64",
},
{
rustTarget: "aarch64-unknown-linux-gnu",
ocBinary: "opencode-linux-arm64",
assetExt: "tar.gz",
package: "@opencode-ai/cli-linux-arm64",
os: "linux",
cpu: "arm64",
},
]
@@ -51,24 +62,33 @@ function nativeTarget() {
throw new Error(`Unsupported platform: ${platform}/${arch}`)
}
export function getCurrentSidecar(target = RUST_TARGET ?? nativeTarget()) {
const binaryConfig = SIDECAR_BINARIES.find((b) => b.rustTarget === target)
if (!binaryConfig) throw new Error(`Sidecar configuration not available for Rust target '${target}'`)
export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) {
const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target)
if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`)
return binaryConfig
}
export async function copyBinaryToSidecarFolder(source: string) {
const dir = `resources`
await $`mkdir -p ${dir}`
const dest = windowsify(`${dir}/opencode-cli`)
await $`cp ${source} ${dest}`
export async function downloadCliToResources() {
const cli = getCurrentCli()
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
const dest = windowsify("resources/opencode-cli")
try {
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await copyFile(
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"),
dest,
)
} finally {
await rm(directory, { recursive: true, force: true })
}
if (process.platform !== "win32") await chmod(dest, 0o755)
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
}
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
console.log(`Copied ${source} to ${dest}`)
console.log(`Copied ${cli.package} to ${dest}`)
}
export function windowsify(path: string) {
+125
View File
@@ -0,0 +1,125 @@
import { execFile } from "node:child_process"
import { existsSync } from "node:fs"
import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { promisify } from "node:util"
import { app } from "electron"
const execFileAsync = promisify(execFile)
const root = dirname(fileURLToPath(import.meta.url))
const stateHome = process.env.XDG_STATE_HOME
const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"]
type Logger = {
log(message: string, meta?: Record<string, unknown>): void
error(message: string, meta?: Record<string, unknown>): void
}
export async function startBackgroundCli(logger: Logger, shellStateHome?: string) {
const bundled = app.isPackaged
? join(process.resourcesPath, executableName())
: join(root, "../../resources", executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = await run(bundled, ["--version"], logger)
const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
const candidates = [
...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]),
].filter((candidate) => candidate === undefined || existsSync(candidate))
const discovered = await Promise.all(
candidates.map(async (candidate) => ({
stateHome: candidate,
url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })),
})),
)
const found = discovered.find((candidate) => candidate.url !== undefined)
logger.log("v2 CLI background instance checked", {
detected: Boolean(found),
...endpoint(found?.url),
})
const daemonStateHome = found?.stateHome ?? stateHome
const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome })
const password = await run(binary, ["service", "get", "password"], logger, {
redact: true,
stateHome: daemonStateHome,
})
logger.log("v2 CLI background service ready", {
existing: Boolean(found),
username: "opencode",
...endpoint(url),
})
return {
url,
username: "opencode",
password,
}
}
async function installCli(source: string, version: string, logger: Logger) {
const directory = join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-"))
const destination = join(directory, executableName())
if (existsSync(destination)) {
logger.log("v2 CLI staged executable reused", { path: destination, version })
return destination
}
const temp = destination + `.${process.pid}.tmp`
await mkdir(directory, { recursive: true })
await copyFile(source, temp)
if (process.platform !== "win32") await chmod(temp, 0o755)
await rename(temp, destination).catch(async (error) => {
await rm(temp, { force: true })
throw error
})
logger.log("v2 CLI executable staged", { source, path: destination, version })
return destination
}
async function run(
binary: string,
args: string[],
logger: Logger,
options: { redact?: boolean; stateHome?: string } = {},
) {
logger.log("v2 CLI command started", { binary, args })
const env = { ...process.env }
if (options.stateHome === undefined) delete env.XDG_STATE_HOME
else env.XDG_STATE_HOME = options.stateHome
return execFileAsync(binary, args, { env, windowsHide: true }).then(
(result) => {
const stdout = result.stdout.trim()
const stderr = result.stderr.trim()
logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr })
return stdout
},
(error: unknown) => {
const output = error as { stdout?: string; stderr?: string }
logger.error("v2 CLI command failed", {
args,
error: error instanceof Error ? error.message : String(error),
stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""),
stderr: output.stderr?.trim() ?? "",
})
throw error
},
)
}
function serviceUrl(status: string) {
if (URL.canParse(status)) return status
if (!status.startsWith("running ")) return
const url = status.slice("running ".length).trim()
return URL.canParse(url) ? url : undefined
}
function endpoint(url: string | undefined) {
if (!url || !URL.canParse(url)) return {}
const parsed = new URL(url)
return { url, hostname: parsed.hostname, port: parsed.port }
}
function executableName() {
return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli"
}
+48 -29
View File
@@ -48,6 +48,7 @@ import { registerWslIpcHandlers } from "./wsl/ipc"
import { spawnWslSidecar } from "./wsl/sidecar"
import { migrate } from "./migrate"
import { cleanupStoreFiles } from "./store-cleanup"
import { startBackgroundCli } from "./background-cli"
const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev",
@@ -60,6 +61,7 @@ const APP_IDS: Record<string, string> = {
prod: "ai.opencode.desktop",
}
const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
const SIDECAR_VERSION = process.env.OPENCODE_SIDECAR_V2 === "1" ? "v2" : "v1"
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
let logger: ReturnType<typeof initLogging>
@@ -198,7 +200,7 @@ const main = Effect.gen(function* () {
return
}
preferAppEnv(app.getPath("userData"))
const shellEnv = preferAppEnv(app.getPath("userData"))
app.on("second-instance", (_event: Event, argv: string[]) => {
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
@@ -312,39 +314,56 @@ const main = Effect.gen(function* () {
),
)
const port = yield* Effect.gen(function* () {
const fromEnv = process.env.OPENCODE_PORT
if (fromEnv) {
const parsed = Number.parseInt(fromEnv, 10)
if (!Number.isNaN(parsed)) return parsed
}
const res = yield* Deferred.make<number, unknown>()
const server = createServer()
server.on("error", (e) => Deferred.failSync(res, () => e))
server.listen(0, "127.0.0.1", () => {
const address = server.address()
if (typeof address !== "object" || !address) {
server.close()
Deferred.failSync(res, () => new Error("Failed to get port"))
return
}
const port = address.port
server.close(() => Effect.runSync(Deferred.succeed(res, port)))
})
return yield* Deferred.await(res)
})
const hostname = "127.0.0.1"
const url = `http://${hostname}:${port}`
const password = randomUUID()
const loadingTask = yield* Effect.gen(function* () {
logger.log("sidecar connection started", { url })
logger.log("sidecar connection started", { version: SIDECAR_VERSION })
ensureLoopbackNoProxy()
useEnvProxy()
if (SIDECAR_VERSION === "v2") {
logger.log("spawning v2 sidecar")
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME))
yield* Deferred.succeed(serverReady, {
url: sidecar.url,
username: sidecar.username,
password: sidecar.password,
})
if (process.platform === "win32") {
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
}
logger.log("loading task finished")
return
}
const port = yield* Effect.gen(function* () {
const fromEnv = process.env.OPENCODE_PORT
if (fromEnv) {
const parsed = Number.parseInt(fromEnv, 10)
if (!Number.isNaN(parsed)) return parsed
}
const res = yield* Deferred.make<number, unknown>()
const socket = createServer()
socket.on("error", (e) => Deferred.failSync(res, () => e))
socket.listen(0, "127.0.0.1", () => {
const address = socket.address()
if (typeof address !== "object" || !address) {
socket.close()
Deferred.failSync(res, () => new Error("Failed to get port"))
return
}
const port = address.port
socket.close(() => Effect.runSync(Deferred.succeed(res, port)))
})
return yield* Deferred.await(res)
})
const hostname = "127.0.0.1"
const url = `http://${hostname}:${port}`
const password = randomUUID()
logger.log("spawning sidecar", { url })
const { listener, health } = yield* Effect.promise(() =>
spawnLocalServer(hostname, port, password, {
+3 -1
View File
@@ -43,13 +43,15 @@ export function setDefaultServerUrl(url: string | null) {
export function preferAppEnv(userDataPath: string) {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null
Object.assign(process.env, {
...(shell ? loadShellEnv(shell, getLogger()) : null),
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
})
return shellEnv
}
export async function spawnLocalServer(
@@ -17,6 +17,7 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
)
const existingInstall = await window.api.isOldLayoutEligible()
settings.general.setOldLayoutEligible(existingInstall)
settings.general.initializeAgentVisibility(existingInstall)
if (!server.isLocal()) return
const pending = await window.api.isFirstLaunchOnboardingPending()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.7",
"version": "1.18.9",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.7",
"version": "1.18.9",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.18.7",
"version": "1.18.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.18.7",
"version": "1.18.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.7",
"version": "1.18.9",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.7",
"version": "1.18.9",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.7",
"version": "1.18.9",
"name": "opencode",
"type": "module",
"license": "MIT",
+17 -6
View File
@@ -516,12 +516,20 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re
return msgs
}
const GEMINI_MODELS_WITH_SAMPLING_DEFAULTS = [
/gemini-2[.-]5(?:[.-]|$)/,
/gemini-3-(?:flash|pro)(?:[.-]|$)/,
/gemini-3[.-]1(?:[.-]|$)/,
/gemini-3[.-]5-flash(?!-lite)(?:[.-]|$)/,
]
export function temperature(model: Provider.Model) {
const id = model.id.toLowerCase()
const id = model.api.id.toLowerCase()
if (id.includes("north-mini-code")) return 1.0
if (id.includes("qwen")) return 0.55
if (id.includes("claude")) return undefined
if (id.includes("gemini")) return 1.0
if (id.includes("gemini"))
return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 1.0 : undefined
if (id.includes("glm-4.6")) return 1.0
if (id.includes("glm-4.7")) return 1.0
if (id.includes("minimax-m2")) return 1.0
@@ -536,21 +544,24 @@ export function temperature(model: Provider.Model) {
}
export function topP(model: Provider.Model) {
const id = model.id.toLowerCase()
const id = model.api.id.toLowerCase()
if (id.includes("qwen")) return 1
if (["minimax-m2", "gemini", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) {
if (id.includes("gemini"))
return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 0.95 : undefined
if (["minimax-m2", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) {
return 0.95
}
return undefined
}
export function topK(model: Provider.Model) {
const id = model.id.toLowerCase()
const id = model.api.id.toLowerCase()
if (id.includes("minimax-m2")) {
if (["m2.", "m25", "m21"].some((s) => id.includes(s))) return 40
return 20
}
if (id.includes("gemini")) return 64
if (id.includes("gemini"))
return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 64 : undefined
return undefined
}
@@ -3186,7 +3186,69 @@ describe("ProviderTransform.message - cache control on gateway", () => {
describe("ProviderTransform.temperature - Cohere North", () => {
test("defaults north-mini-code models to 1.0", () => {
expect(ProviderTransform.temperature({ id: "cohere/North-Mini-Code-1-0-latest" } as any)).toBe(1.0)
expect(
ProviderTransform.temperature({
id: "cohere/North-Mini-Code-1-0-latest",
api: { id: "North-Mini-Code-1-0-latest" },
} as any),
).toBe(1.0)
})
})
describe("ProviderTransform sampling defaults - Gemini", () => {
const model = (id: string) =>
({
id: `google/${id}`,
api: { id },
}) as any
const alias = (id: string, apiID: string) =>
({
id,
api: { id: apiID },
}) as any
test.each([
"gemini-3.5-flash-lite",
"gemini-3-5-flash-lite",
"gemini-3.6-flash",
"gemini-3-6-flash",
"gemini-4-pro",
"gemini-future",
])("omits deprecated sampling controls for %s", (id) => {
expect(ProviderTransform.temperature(model(id))).toBeUndefined()
expect(ProviderTransform.topP(model(id))).toBeUndefined()
expect(ProviderTransform.topK(model(id))).toBeUndefined()
})
test.each([
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-2.5-flash-lite",
"gemini-2-5-flash-lite",
"gemini-3-flash-preview",
"gemini-3-pro-image",
"gemini-3.1-flash-lite",
"gemini-3.1-pro-preview",
"gemini-3-1-pro-preview",
"gemini-3.5-flash",
"gemini-3-5-flash",
])("preserves sampling defaults for %s", (id) => {
expect(ProviderTransform.temperature(model(id))).toBe(1)
expect(ProviderTransform.topP(model(id))).toBe(0.95)
expect(ProviderTransform.topK(model(id))).toBe(64)
})
test("uses the API model ID for configured aliases", () => {
const deprecated = alias("google/gemini-3.5-flash", "google/gemini-3.6-flash")
expect(ProviderTransform.temperature(deprecated)).toBeUndefined()
expect(ProviderTransform.topP(deprecated)).toBeUndefined()
expect(ProviderTransform.topK(deprecated)).toBeUndefined()
const supported = alias("my-gemini", "google/gemini-2.5-flash")
expect(ProviderTransform.temperature(supported)).toBe(1)
expect(ProviderTransform.topP(supported)).toBe(0.95)
expect(ProviderTransform.topK(supported)).toBe(64)
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.18.7",
"version": "1.18.9",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.18.7",
"version": "1.18.9",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.18.7",
"version": "1.18.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.18.7",
"version": "1.18.9",
"private": true,
"type": "module",
"license": "MIT",
@@ -32,7 +32,6 @@ export function CommentCardV2(props: {
return (
<TooltipV2
placement="top"
openDelay={1000}
value={props.title ?? props.comment}
disabled={!props.tooltip || !truncated()}
class={props.wide ? "w-full" : undefined}
@@ -385,12 +385,7 @@ export function PromptInputV2Attachments(props: {
<For each={props.comments ?? []}>
{(comment) => (
<div class="relative group shrink-0">
<TooltipV2
value={comment.comment}
placement="top"
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<TooltipV2 value={comment.comment} placement="top" contentClass="max-w-[300px] break-words">
<CommentCardV2
comment={comment.comment ?? ""}
path={comment.path}
@@ -214,7 +214,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
</Show>
<div class="flex items-center">
<TooltipV2
openDelay={2000}
inactive={!prev()}
value={
<>
@@ -234,7 +233,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
/>
</TooltipV2>
<TooltipV2
openDelay={2000}
inactive={!next()}
value={
<>
@@ -268,12 +266,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
<TooltipV2 value={i18n.t("ui.sessionReviewV2.showAllLines")}>
<SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
<Icon name="expand" />
</SegmentedControlItemV2>
</TooltipV2>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<TooltipV2 value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
<Icon name="collapse" />
</SegmentedControlItemV2>
@@ -289,12 +287,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
aria-label={i18n.t("ui.sessionReviewV2.diffView")}
>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<TooltipV2 value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
<Icon name="unified" />
</SegmentedControlItemV2>
</TooltipV2>
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
<TooltipV2 value={i18n.t("ui.sessionReviewV2.splitDiff")}>
<SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
<Icon name="split" />
</SegmentedControlItemV2>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.18.7",
"version": "1.18.9",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
"version": "1.18.7",
"version": "1.18.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-core",
"version": "1.18.7",
"version": "1.18.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-server",
"version": "1.18.7",
"version": "1.18.9",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/tui",
"version": "1.18.7",
"version": "1.18.9",
"private": true,
"type": "module",
"license": "MIT",
+5 -1
View File
@@ -104,7 +104,6 @@ const appGlobalBindingCommands = [
] as const
const appBindingCommands = [
"command.palette.show",
"model.list",
"model.cycle_recent",
"model.cycle_recent_reverse",
@@ -963,6 +962,11 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
commands: appCommands(),
}))
useBindings(() => ({
enabled: () => dialog.stack.length === 0,
bindings: tuiConfig.keybinds.get(COMMAND_PALETTE_COMMAND),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
+34 -4
View File
@@ -5,7 +5,13 @@ import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onCleanup } from "solid-js"
import { TuiKeybind } from "../src/config/keybind"
import { getOpencodeModeStack, OPENCODE_BASE_MODE, OpencodeKeymapProvider, registerOpencodeKeymap } from "../src/keymap"
import {
COMMAND_PALETTE_COMMAND,
getOpencodeModeStack,
OPENCODE_BASE_MODE,
OpencodeKeymapProvider,
registerOpencodeKeymap,
} from "../src/keymap"
function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) {
const keybinds = TuiKeybind.parse(input)
@@ -73,12 +79,14 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const offGlobal = keymap.registerLayer({
commands: [
{ name: COMMAND_PALETTE_COMMAND, run() {} },
{ name: "session.list", run() {} },
{ name: "session.new", run() {} },
{ name: "session.page.up", run() {} },
{ name: "session.first", run() {} },
],
bindings: config.keybinds.gather("test.global", [
COMMAND_PALETTE_COMMAND,
"session.list",
"session.new",
"session.page.up",
@@ -95,7 +103,14 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
Array.from(
keymap.getCommandBindings({
visibility: "active",
commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
commands: [
COMMAND_PALETTE_COMMAND,
"session.list",
"session.new",
"session.page.up",
"session.first",
"model.list",
],
}),
([command, bindings]) => [command, bindings.length],
),
@@ -125,9 +140,24 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
const app = await testRender(() => <Harness />)
try {
expect(counts).toEqual({
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
base: {
[COMMAND_PALETTE_COMMAND]: 1,
"session.list": 1,
"session.new": 1,
"session.page.up": 2,
"session.first": 2,
"model.list": 1,
},
question: {
[COMMAND_PALETTE_COMMAND]: 1,
"session.list": 1,
"session.new": 1,
"session.page.up": 2,
"session.first": 2,
"model.list": 0,
},
autocomplete: {
[COMMAND_PALETTE_COMMAND]: 1,
"session.list": 1,
"session.new": 1,
"session.page.up": 2,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.18.7",
"version": "1.18.9",
"type": "module",
"license": "MIT",
"repository": {
@@ -199,7 +199,6 @@
width: 100%;
height: 28px;
border-radius: 4px;
border: 0.5px solid transparent;
box-sizing: border-box;
color: var(--v2-text-text-muted);
}
@@ -222,5 +221,4 @@
[data-slot="tabs-v2-trigger-wrapper"]:has([data-selected]) {
background-color: var(--v2-background-bg-layer-03);
color: var(--v2-text-text-base);
border: 0.5px solid var(--v2-border-border-muted);
}
@@ -0,0 +1,35 @@
const OPEN_DELAY = 1_000
// Kobalte warms every tooltip globally. Keep intent previews isolated so opening
// a model picker never inherits warm state from an unrelated tooltip.
let warm = false
let reset: ReturnType<typeof setTimeout> | undefined
export function openTooltipIntent(open: () => void) {
clearTimeout(reset)
reset = undefined
if (warm) {
open()
return
}
const timer = setTimeout(() => {
warm = true
open()
}, OPEN_DELAY)
return () => clearTimeout(timer)
}
export function closeTooltipIntent() {
clearTimeout(reset)
// Adjacent triggers enter before the next task; leaving the group does not.
reset = setTimeout(() => {
warm = false
reset = undefined
})
}
export function resetTooltipIntent() {
clearTimeout(reset)
reset = undefined
warm = false
}
+36 -6
View File
@@ -2,19 +2,22 @@ import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"
import { createEffect, Match, onCleanup, splitProps, Switch, type JSX } from "solid-js"
import type { ComponentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { closeTooltipIntent, openTooltipIntent, resetTooltipIntent } from "./tooltip-intent"
import "./tooltip-v2.css"
export interface TooltipV2Props extends ComponentProps<typeof KobalteTooltip> {
export interface TooltipV2Props extends Omit<ComponentProps<typeof KobalteTooltip>, "openDelay"> {
value: JSX.Element
class?: string
contentClass?: string
contentStyle?: JSX.CSSProperties
inactive?: boolean
delay?: "standard" | "intent"
forceOpen?: boolean
}
export function TooltipV2(props: TooltipV2Props) {
let ref: HTMLDivElement | undefined
let cancelIntent: (() => void) | undefined
const [state, setState] = createStore({
open: false,
block: false,
@@ -26,19 +29,37 @@ export function TooltipV2(props: TooltipV2Props) {
"contentClass",
"contentStyle",
"inactive",
"delay",
"forceOpen",
"ignoreSafeArea",
"value",
])
const close = () => setState("open", false)
const inside = () => {
const active = document.activeElement
if (!ref || !active) return false
return ref.contains(active)
}
const close = () => {
cancelIntent?.()
cancelIntent = undefined
if (local.delay === "intent") closeTooltipIntent()
setState("open", false)
}
const show = () => {
if (local.delay !== "intent" || inside()) {
setState("open", true)
return
}
if (cancelIntent) return
cancelIntent = openTooltipIntent(() => {
cancelIntent = undefined
setState("open", true)
})
}
const drop = (expand = state.expand) => {
if (expand) return
if (ref?.matches(":hover")) return
@@ -80,6 +101,11 @@ export function TooltipV2(props: TooltipV2Props) {
onCleanup(() => obs.disconnect())
})
onCleanup(() => {
cancelIntent?.()
if (local.delay === "intent") resetTooltipIntent()
})
let justClickedTrigger = false
return (
@@ -88,8 +114,8 @@ export function TooltipV2(props: TooltipV2Props) {
<Match when={true}>
<KobalteTooltip
gutter={4}
openDelay={400}
skipDelayDuration={300}
openDelay={local.delay === "intent" ? 0 : 400}
skipDelayDuration={local.delay === "intent" ? 0 : 300}
{...others}
closeDelay={0}
ignoreSafeArea={local.ignoreSafeArea ?? true}
@@ -101,7 +127,11 @@ export function TooltipV2(props: TooltipV2Props) {
justClickedTrigger = false
return
}
setState("open", open)
if (open) {
show()
return
}
close()
}}
>
<KobalteTooltip.Trigger
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.18.7",
"version": "1.18.9",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+40
View File
@@ -0,0 +1,40 @@
--- a/index.js
+++ b/index.js
@@ -2195,6 +2195,17 @@
sensors,
modifiers
}));
+ }
+ get plugins() {
+ return super.plugins;
+ }
+ set plugins(plugins) {
+ super.plugins = [
+ ScrollListener,
+ Scroller,
+ StyleInjector,
+ ...plugins
+ ];
}
};
var _element_dec, _handle_dec, _c, _init5, _handle, _element;
--- a/index.cjs
+++ b/index.cjs
@@ -2196,6 +2196,17 @@
sensors,
modifiers
}));
+ }
+ get plugins() {
+ return super.plugins;
+ }
+ set plugins(plugins) {
+ super.plugins = [
+ ScrollListener,
+ Scroller,
+ StyleInjector,
+ ...plugins
+ ];
}
};
var _element_dec, _handle_dec, _c, _init5, _handle, _element;
+146 -10
View File
@@ -1,11 +1,5 @@
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 7104749486e4361e8c4ee7836a8046582cec7aa1..0501eb1ec5d13b81ecb13a5ac1a82db42502b976 100644
index 7104749..dc3eac9 100644
--- a/dist/dev.cjs
+++ b/dist/dev.cjs
@@ -764,6 +764,8 @@ function runComputation(node, value, time) {
@@ -17,8 +11,33 @@ index 7104749486e4361e8c4ee7836a8046582cec7aa1..0501eb1ec5d13b81ecb13a5ac1a82db4
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 ea5e4bc2fd4f0b3922a73d9134439529dc81339f..4b3ec07e624d20fdd23d6941a4fdde6d3a78cca3 100644
index ea5e4bc..a2e2d59 100644
--- a/dist/dev.js
+++ b/dist/dev.js
@@ -762,6 +762,8 @@ function runComputation(node, value, time) {
@@ -30,8 +49,75 @@ index ea5e4bc2fd4f0b3922a73d9134439529dc81339f..4b3ec07e624d20fdd23d6941a4fdde6d
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 7c133a2b254678a84fd61d719fbeffad766e1331..2f68c99f2698210cc0bac62f074cc8cd3beb2881 100644
index 7c133a2..5ef1501 100644
--- a/dist/solid.cjs
+++ b/dist/solid.cjs
@@ -717,6 +717,8 @@ function runComputation(node, value, time) {
@@ -43,8 +129,33 @@ index 7c133a2b254678a84fd61d719fbeffad766e1331..2f68c99f2698210cc0bac62f074cc8cd
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 656fd26e7e5c794aa22df19c2377ff5c0591fc29..f08e9f5a7157c3506e5b6922fe2ef991335a80be 100644
index 656fd26..6e0038c 100644
--- a/dist/solid.js
+++ b/dist/solid.js
@@ -715,6 +715,8 @@ function runComputation(node, value, time) {
@@ -56,3 +167,28 @@ index 656fd26e7e5c794aa22df19c2377ff5c0591fc29..f08e9f5a7157c3506e5b6922fe2ef991
Transition.sources.add(node);
node.tValue = nextValue;
} else node.value = nextValue;
@@ -936,18 +938,21 @@ function cleanNode(node) {
}
}
if (node.tOwned) {
- for (i = node.tOwned.length - 1; i >= 0; i--) cleanNode(node.tOwned[i]);
+ const tOwned = node.tOwned;
delete node.tOwned;
+ for (i = tOwned.length - 1; i >= 0; i--) cleanNode(tOwned[i]);
}
if (Transition && Transition.running && node.pure) {
reset(node, true);
} else if (node.owned) {
- for (i = node.owned.length - 1; i >= 0; i--) cleanNode(node.owned[i]);
+ const owned = node.owned;
node.owned = null;
+ for (i = owned.length - 1; i >= 0; i--) cleanNode(owned[i]);
}
if (node.cleanups) {
- for (i = node.cleanups.length - 1; i >= 0; i--) node.cleanups[i]();
+ const cleanups = node.cleanups;
node.cleanups = null;
+ for (i = cleanups.length - 1; i >= 0; i--) cleanups[i]();
}
if (Transition && Transition.running) node.tState = 0;else node.state = 0;
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.18.7",
"version": "1.18.9",
"publisher": "sst-dev",
"repository": {
"type": "git",