Compare commits

..

4 Commits

Author SHA1 Message Date
Brendonovich d0172c3cab fix(app): show SSH auth prompt only when required 2026-09-18 00:28:30 +00:00
Aiden Cline a2594ddefb fix(tui): sync model preferences across clients (#49611) 2026-09-17 14:54:34 -05:00
Aiden Cline 3555cef1c8 chore(client): regenerate policy types (#49613) 2026-09-17 14:38:09 -05:00
opencode 0fa881efd0 sync release versions for v2.0.7 2026-09-17 19:29:59 +00:00
8 changed files with 118 additions and 76 deletions
@@ -1,5 +1,18 @@
import { expect, story } from "../../storybook/playwright/story"
story("home connection progress stays in the server row", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--home-connecting")
await component.getByRole("button", { name: "Projects" }).click()
await expect(page.getByRole("status", { name: "Connecting over SSH…" })).toBeVisible()
await expect(page.locator('[data-action="home-server-authenticate"]')).toHaveCount(0)
})
story("home authentication action appears when required", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--authentication-required")
await component.getByRole("button", { name: "Projects" }).click()
await expect(page.getByRole("button", { name: "Authenticate", exact: true })).toBeVisible()
})
story("settings menu reconnect retains its prompt handler across server updates", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--settings-reconnect")
await component.getByRole("button", { name: "More options" }).click()
+2 -8
View File
@@ -11,7 +11,6 @@ import { ProjectAvatar } from "@opencode/ui/project-avatar"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Button } from "@opencode/ui/button"
import { Spinner } from "@opencode/ui/spinner"
import { Menu } from "@opencode/ui/menu"
import { Tooltip } from "@opencode/ui/tooltip"
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/shell/state/layout"
@@ -256,7 +255,7 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
collapsed={collapsed()}
health={props.serverHealth(item)}
/>
<Show when={authentication() || connecting()}>
<Show when={authentication()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<div class="px-1.5 py-1">
<Button
@@ -264,14 +263,9 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
class="w-full"
size="small"
variant="neutral"
disabled={connecting()}
aria-busy={!!connecting()}
onClick={() => props.onAuthenticateServer?.(item)}
>
<Show when={connecting()}>
<Spinner class="size-3.5" />
</Show>
{props.language.t(connecting() ? "ssh.stage.connecting" : "ssh.action.authenticate")}
{props.language.t("ssh.action.authenticate")}
</Button>
</div>
</Show>
@@ -14,6 +14,7 @@ import { SshConnectionPanel } from "./connection-panel"
import { SshServerSettings } from "./settings"
function Fixture(props: {
home?: boolean
session?: boolean
settings?: boolean
incompatible?: boolean
@@ -25,10 +26,15 @@ function Fixture(props: {
const state: { item?: SshItem; before?: SshItem; step: number; timer?: ReturnType<typeof setTimeout> } = {
step: 0,
item:
props.initial === "required"
props.initial === "required" || props.home
? {
config: { id: "story", target: "ssh linuxbook", name: "" },
stage: props.session || props.settings ? "disconnected" : "authentication",
stage:
props.session || props.settings
? "disconnected"
: props.initial === "connecting"
? "connecting"
: "authentication",
saved: true,
detail: "",
}
@@ -141,7 +147,7 @@ function Fixture(props: {
<AuthenticationSettings />
) : props.session ? (
<AuthenticationSession />
) : props.initial === "required" ? (
) : props.initial === "required" || props.home ? (
<AuthenticationHome />
) : (
<Open initial={props.initial} />
@@ -277,6 +283,7 @@ function Open(props: { initial?: string }) {
export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
export const AuthenticationRequired = { render: () => <Fixture initial="required" /> }
export const HomeConnecting = { render: () => <Fixture home initial="connecting" /> }
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
export const IncompatibleHost = { render: () => <Fixture incompatible /> }
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
@@ -2135,7 +2135,7 @@ export type ConfigEntry =
experimental?: {
portable_shell_scanner?: boolean
subagent_depth?: number
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
policies?: Array<{ action: "provider.use" | "permission"; resource: string; effect: "allow" | "deny" }>
}
}
}
+12 -49
View File
@@ -11,8 +11,10 @@ import { readJson, writeJsonAtomic } from "../util/persistence"
import {
createModelPreferenceRepository,
cycleModelVariant,
favoriteModels,
modelPreferenceKey,
normalizeModelVariant,
recentModels,
type ModelPreference,
type ModelPreferenceModel,
} from "../model-preference"
@@ -24,19 +26,6 @@ import { usePermission } from "./permission"
import { useLocation } from "./location"
import { parse } from "../util/model"
export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) {
const seen = new Set<string>()
return [model, ...recent]
.filter((item) => {
const key = modelPreferenceKey(item)
if (seen.has(key)) return false
seen.add(key)
return true
})
.slice(0, 10)
.map((item) => ({ providerID: item.providerID, modelID: item.modelID }))
}
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: () => {
@@ -167,37 +156,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const pendingSelectionCommits = new Map<string, { agentID: string; selection: string }>()
const selectionKey = (value: ModelSelection) =>
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
const saveState = {
pending: false,
}
function savePreferences() {
if (!preferences.ready) {
saveState.pending = true
return
}
saveState.pending = false
void repository
.patch({
recent: preferences.recent,
favorite: preferences.favorite,
variant: preferences.variant,
})
.catch(() => undefined)
}
repository
.load()
.then((value) => {
function applyPreferences(value: ModelPreference) {
batch(() => {
setPreferences("recent", value.recent)
setPreferences("favorite", value.favorite)
setPreferences("variant", value.variant)
})
.catch(() => {})
.finally(() => {
setPreferences("ready", true)
if (saveState.pending) savePreferences()
})
}
onCleanup(repository.subscribe(applyPreferences))
const configuredModel = createMemo(() => {
const entry = data.location.config
@@ -479,7 +448,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!next) return
if (!selectModel({ ...next })) return
setPreferences("recent", recentModels(next, preferences.recent))
savePreferences()
void repository.addRecent(next).catch(() => undefined)
},
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => {
@@ -487,7 +456,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!selectModel(model)) return
if (options?.recent) {
setPreferences("recent", recentModels(model, preferences.recent))
savePreferences()
void repository.addRecent(model).catch(() => undefined)
}
})
},
@@ -497,14 +466,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const exists = preferences.favorite.some(
(x) => x.providerID === model.providerID && x.modelID === model.modelID,
)
const next = exists
? preferences.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
: [model, ...preferences.favorite]
setPreferences(
"favorite",
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
)
savePreferences()
setPreferences("favorite", favoriteModels(model, preferences.favorite, !exists))
void repository.setFavorite(model, !exists).catch(() => undefined)
})
},
variant: {
@@ -527,7 +490,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
}
setPreferences("variant", modelPreferenceKey(m), value ?? "default")
savePreferences()
void repository.saveVariant(m, value).catch(() => undefined)
},
cycle() {
const variants = this.list()
+77 -12
View File
@@ -1,5 +1,8 @@
import { readJson, writeJsonAtomic } from "./util/persistence"
import { isRecord } from "./util/record"
import { Flock } from "@opencode/util/flock"
import { watch } from "node:fs"
import path from "node:path"
export type ModelPreferenceModel = {
providerID: string
@@ -43,6 +46,24 @@ export function modelPreferenceKey(model: ModelPreferenceModel) {
return `${model.providerID}/${model.modelID}`
}
export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) {
const seen = new Set<string>()
return [model, ...recent]
.filter((item) => {
const key = modelPreferenceKey(item)
if (seen.has(key)) return false
seen.add(key)
return true
})
.slice(0, 10)
.map((item) => ({ providerID: item.providerID, modelID: item.modelID }))
}
export function favoriteModels(model: ModelPreferenceModel, favorite: ModelPreferenceModel[], enabled: boolean) {
const current = favorite.filter((item) => modelPreferenceKey(item) !== modelPreferenceKey(model))
return enabled ? [model, ...current] : current
}
export function cycleModelVariant(current: string | undefined, variants: string[]) {
const named = variants.filter((variant) => variant !== "default")
if (named.length === 0) return undefined
@@ -80,32 +101,76 @@ function patch(value: Partial<ModelPreference>) {
}
export function createModelPreferenceRepository(filePath: string) {
const state = {
pending: Promise.resolve(),
}
let pending = Promise.resolve()
let revision = 0
let watcher: ReturnType<typeof watch> | undefined
let reload: ReturnType<typeof setTimeout> | undefined
const listeners = new Set<(value: ModelPreference) => void>()
const read = () =>
readJson<unknown>(filePath)
.then(decodeModelPreference)
.catch(() => decodeModelPreference(undefined))
function update(change: (current: ModelPreference) => Partial<ModelPreference>) {
const result = state.pending.then(async () => {
const current = await read()
const next = { ...current, ...patch(change(preference(current))) }
await writeJsonAtomic(filePath, next)
})
state.pending = result.catch(() => undefined)
const result = pending.then(() =>
Flock.withLock(
filePath,
async () => {
const current = await read()
await writeJsonAtomic(filePath, { ...current, ...patch(change(preference(current))) })
},
{ dir: path.join(path.dirname(filePath), "locks") },
),
)
pending = result.then(
() => undefined,
() => undefined,
)
return result
}
function load() {
return state.pending.then(read).then(preference)
return pending.then(read).then(preference)
}
async function refresh() {
const current = ++revision
const value = await load()
if (current !== revision) return
listeners.forEach((listener) => listener(value))
}
return {
load,
patch(value: Partial<ModelPreference>) {
return update(() => value)
addRecent(model: ModelPreferenceModel) {
return update((current) => ({ recent: recentModels(model, current.recent) }))
},
setFavorite(model: ModelPreferenceModel, enabled: boolean) {
return update((current) => ({ favorite: favoriteModels(model, current.favorite, enabled) }))
},
subscribe(listener: (value: ModelPreference) => void) {
listeners.add(listener)
void refresh()
if (!watcher) {
watcher = watch(path.dirname(filePath), (_event, filename) => {
const changed = filename?.toString()
const name = path.basename(filePath)
if (changed !== undefined && changed !== name && !changed.startsWith(name + ".")) return
clearTimeout(reload)
reload = setTimeout(() => void refresh(), 50)
})
watcher.on("error", () => {
watcher?.close()
watcher = undefined
})
}
return () => {
listeners.delete(listener)
if (listeners.size > 0) return
clearTimeout(reload)
watcher?.close()
watcher = undefined
}
},
async resolveVariant(model: ModelPreferenceModel) {
return normalizeModelVariant((await load()).variant[modelPreferenceKey(model)])
+1 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { recentModels } from "../../src/context/local"
import { recentModels } from "../../src/model-preference"
test("moves a model to the front, deduplicates, and limits recents", () => {
const recent = Array.from({ length: 12 }, (_, index) => ({
+2 -2
View File
@@ -19,7 +19,7 @@ test("repairs known model preferences and preserves unrelated fields", () => {
})
})
test("atomically serializes patches and variant updates", async () => {
test("atomically serializes model preference updates", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "model.json")
await Bun.write(file, JSON.stringify({ unrelated: "keep", favorite: [], variant: {} }))
@@ -28,7 +28,7 @@ test("atomically serializes patches and variant updates", async () => {
const anthropic = { providerID: "anthropic", modelID: "claude/sonnet" }
await Promise.all([
repository.patch({ recent: [openai] }),
repository.addRecent(openai),
repository.saveVariant(openai, "high"),
repository.saveVariant(anthropic, "low"),
])