Compare commits

...
Author SHA1 Message Date
Brendonovich 506c30e590 fix(app): refine background surfaces 2026-09-09 06:55:57 +00:00
Brendonovich 45fd44db01 feat(app): add custom backgrounds 2026-09-09 06:42:56 +00:00
11 changed files with 418 additions and 1 deletions
@@ -0,0 +1,79 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const draftID = "draft_background_image"
const directory = "/tmp/background-image"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const image = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
)
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_background_image",
worktree: directory,
vcs: "git",
name: "background-image",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem("opencode-theme-id", "oc-2")
localStorage.setItem("opencode-color-scheme", "dark")
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
})
test("selects, restores, and removes a background image", async ({ page }) => {
const providerTip = page.locator('[data-component="new-session-tip"][data-kind="provider"]')
await expect(providerTip).toBeVisible()
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeFocused()
await settings.getByRole("tab", { name: "Appearance", exact: true }).click()
const chooser = page.waitForEvent("filechooser")
await settings.getByRole("button", { name: "Choose image", exact: true }).click()
await (await chooser).setFiles({ name: "background.png", mimeType: "image/png", buffer: image })
await expect(settings.getByRole("button", { name: "Remove", exact: true })).toBeVisible()
const shell = page.locator('[data-component="app-shell"]')
await expect(shell).toHaveAttribute("data-background-image", "")
await expect(shell).toHaveCSS("background-image", /blob:/)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="new-session"][data-background-surface="canvas"]')).toBeVisible()
await expect(providerTip).toBeHidden()
await page.reload()
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
await expect(shell).toHaveAttribute("data-background-image", "")
await page.keyboard.press("Control+,")
await expect(settings).toBeFocused()
await settings.getByRole("tab", { name: "Appearance", exact: true }).click()
await settings.getByRole("button", { name: "Remove", exact: true }).click()
await expect(settings.getByRole("button", { name: "Remove", exact: true })).toBeHidden()
await expect(shell).not.toHaveAttribute("data-background-image", "")
})
@@ -132,6 +132,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
</Show>
<form
data-component="composer"
data-background-surface="composer"
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
classList={{
+1
View File
@@ -19,6 +19,7 @@ export function Home() {
const scroll = createHomeScrollController(sessions.data.groups)
return (
<div
data-background-surface="panel"
class={`
mx-2 mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
+32
View File
@@ -3,6 +3,38 @@
@import "@opencode/ui/styles/tokens";
@import "tw-animate-css";
[data-component="app-shell"][data-background-image] {
background-image: linear-gradient(rgb(255 255 255 / 28%), rgb(255 255 255 / 28%)), var(--app-background-image);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
[data-color-scheme="dark"] [data-component="app-shell"][data-background-image] {
background-image: linear-gradient(rgb(0 0 0 / 28%), rgb(0 0 0 / 28%)), var(--app-background-image);
}
[data-component="app-shell"][data-background-image] .bg-v2-background-bg-deep {
background-color: transparent;
}
[data-component="app-shell"][data-background-image] [data-background-surface="panel"] {
background-color: color-mix(in srgb, var(--v2-background-bg-base) 72%, transparent);
}
[data-component="app-shell"][data-background-image] [data-background-surface="canvas"] {
background-color: transparent;
}
[data-component="app-shell"][data-background-image] [data-background-surface="composer"] {
background-color: color-mix(in srgb, var(--v2-background-bg-base) 55%, transparent);
backdrop-filter: blur(12px);
}
[data-component="app-shell"][data-background-image] [data-component="new-session-tip"][data-kind="provider"] {
display: none;
}
@font-face {
font-family: "JetBrainsMono Nerd Font Mono";
src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2");
+2
View File
@@ -55,6 +55,7 @@ export function NewSessionView(props: {
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div
data-component="new-session"
data-background-surface="canvas"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<ComposerDropzone
@@ -194,6 +195,7 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
<div
ref={setRef}
data-component="new-session-tip"
data-kind={displayed()}
data-visible={tip() !== undefined}
class="group/new-session-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 }}
+7
View File
@@ -1028,6 +1028,13 @@ export const dict = {
"settings.appearance.row.tabs.vertical": "Vertical",
"settings.appearance.row.projectName.title": "Show project names",
"settings.appearance.row.projectName.description": "Show project names in vertical tabs and the mobile tab drawer",
"settings.appearance.row.backgroundImage.title": "Background image",
"settings.appearance.row.backgroundImage.description": "Choose an image for the app background.",
"settings.appearance.row.backgroundImage.choose": "Choose image",
"settings.appearance.row.backgroundImage.remove": "Remove",
"settings.appearance.row.backgroundImage.pickerTitle": "Choose a background image",
"settings.appearance.row.backgroundImage.error.unsupported": "Choose a PNG, JPEG, GIF, WebP, AVIF, or BMP image.",
"settings.appearance.row.backgroundImage.error.too-large": "Background images must be 20 MB or smaller.",
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
"settings.shortcuts.description": "Customize shortcuts for common actions",
"settings.servers.description": "Manage server connections",
@@ -1,4 +1,6 @@
import { Component } from "solid-js"
import { Component, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { Select } from "@opencode/ui/select"
import { TextInput } from "@opencode/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
@@ -6,6 +8,9 @@ import { ExternalLink } from "@/runtime/platform/external-link"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
import { createAppearanceSettingsController, type AppearanceSettingsController } from "@/settings/general/controllers"
import { useSettings } from "@/settings/model"
import { BackgroundImageSelectionError } from "@/settings/appearance/background-image"
import { showToast } from "@/shell/notifications/toast"
import "@/settings/settings.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
@@ -64,6 +69,26 @@ const FontSetting: Component<{
export const SettingsAppearance: Component = () => {
const language = useLanguage()
const appearance = createAppearanceSettingsController()
const settings = useSettings()
const [state, setState] = createStore({ backgroundBusy: false })
const backgroundAction = async (action: () => Promise<void>) => {
if (state.backgroundBusy) return
setState("backgroundBusy", true)
await action().catch((error: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description:
error instanceof BackgroundImageSelectionError
? language.t(`settings.appearance.row.backgroundImage.error.${error.reason}`)
: error instanceof Error
? error.message
: String(error),
})
})
setState("backgroundBusy", false)
}
return (
<>
@@ -121,6 +146,42 @@ export const SettingsAppearance: Component = () => {
/>
</SettingsRow>
<Show when={settings.appearance.backgroundImage.available}>
<SettingsRow
title={language.t("settings.appearance.row.backgroundImage.title")}
description={language.t("settings.appearance.row.backgroundImage.description")}
>
<div class="flex items-center gap-2">
<Button
data-action="settings-background-image"
size="normal"
variant="neutral"
disabled={state.backgroundBusy}
onClick={() =>
void backgroundAction(() =>
settings.appearance.backgroundImage.select(
language.t("settings.appearance.row.backgroundImage.pickerTitle"),
),
)
}
>
{language.t("settings.appearance.row.backgroundImage.choose")}
</Button>
<Show when={settings.appearance.backgroundImage.active()}>
<Button
data-action="settings-background-image-remove"
size="normal"
variant="ghost"
disabled={state.backgroundBusy}
onClick={() => void backgroundAction(() => settings.appearance.backgroundImage.clear())}
>
{language.t("settings.appearance.row.backgroundImage.remove")}
</Button>
</Show>
</div>
</SettingsRow>
</Show>
<FontSetting kind="ui" fonts={appearance.fonts} />
<FontSetting kind="code" fonts={appearance.fonts} />
<FontSetting kind="terminal" fonts={appearance.fonts} />
@@ -0,0 +1,109 @@
import { describe, expect, test } from "bun:test"
import { createDraftStore, type DraftStore } from "@/runtime/persistence/drafts"
import type { Platform } from "@/runtime/platform/platform"
import { createBackgroundImageSettings } from "./background-image"
function setup(file?: File, initial?: string) {
let value = initial ?? null
const written: unknown[] = []
const draftStore: DraftStore = {
getItem: async () => value,
setItem: async (_key, next) => {
value = next
},
removeItem: async () => {
value = null
},
putBlob: async () => ({ id: "blob-id", url: "blob:background" }),
setDocument: async (_key, document) => {
written.push(document)
value = JSON.stringify(document)
},
}
const platform: Platform = {
platform: "web",
draftStore,
openExternal() {},
restart: async () => {},
notify: async () => {},
openAttachmentPickerDialog: async (_options, onFile) => {
if (file) await onFile(file)
},
}
const background = createBackgroundImageSettings(platform, false)
return { background, written, value: () => value }
}
describe("background image settings", () => {
test("reloads an image through the document and blob store", async () => {
const documents = new Map<string, string>()
const blobs = new Map<string, Blob>()
const draftStore = createDraftStore({
get: async (key) => documents.get(key) ?? null,
set: async (key, value) => {
documents.set(key, value)
return []
},
remove: async (key) => {
documents.delete(key)
},
putBlob: async (blob) => {
const id = crypto.randomUUID()
blobs.set(id, blob)
return id
},
getBlob: async (id) => blobs.get(id) ?? null,
})
const file = new File([new Uint8Array([1, 2, 3])], "background.png", { type: "image/png" })
const value: Platform = {
platform: "web",
draftStore,
openExternal() {},
restart: async () => {},
notify: async () => {},
openAttachmentPickerDialog: async (_options, onFile) => {
await onFile(file)
},
}
const first = createBackgroundImageSettings(value, false)
await first.ready
await first.select("Choose a background image")
const second = createBackgroundImageSettings(value, false)
await second.ready
expect(second.active()).toBe(true)
expect(second.url()).toStartWith("blob:")
})
test("loads, replaces, and clears a persisted image", async () => {
const current = JSON.stringify({ image: { mime: "image/png", blob: { id: "old", url: "blob:old" } } })
const { background, written, value } = setup(new File([new Uint8Array([1, 2, 3])], "new.webp"), current)
await background.ready
expect(background.active()).toBe(true)
expect(background.url()).toBe("blob:old")
await background.select("Choose a background image")
expect(background.url()).toBe("blob:background")
expect(written).toEqual([{ image: { mime: "image/webp", blob: { id: "blob-id", url: "blob:background" } } }])
await background.clear()
expect(background.active()).toBe(false)
expect(value()).toBeNull()
})
test("rejects unsupported files", async () => {
const { background } = setup(new File(["<svg />"], "background.svg", { type: "image/svg+xml" }))
await background.ready
expect(background.select("Choose a background image")).rejects.toMatchObject({
reason: "unsupported",
})
})
test("rejects files larger than 20 MB", async () => {
const { background } = setup(new File([new Uint8Array(20 * 1024 * 1024 + 1)], "background.png"))
await background.ready
expect(background.select("Choose a background image")).rejects.toMatchObject({
reason: "too-large",
})
})
})
@@ -0,0 +1,115 @@
import { Option, Schema } from "effect"
import { getOwner, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { Platform } from "@/runtime/platform/platform"
const key = "opencode.global.dat:appearance.background-image"
const maxBytes = 20 * 1024 * 1024
const mime = new Map([
["avif", "image/avif"],
["bmp", "image/bmp"],
["gif", "image/gif"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
["png", "image/png"],
["webp", "image/webp"],
])
const accepted = new Set(mime.values())
const documentSchema = Schema.Struct({
image: Schema.optional(
Schema.Struct({
mime: Schema.String,
blob: Schema.Struct({ id: Schema.String, url: Schema.optional(Schema.String) }),
}),
),
})
const decode = Schema.decodeUnknownOption(Schema.fromJsonString(documentSchema))
export class BackgroundImageSelectionError extends Error {
constructor(readonly reason: "unsupported" | "too-large") {
super(reason)
this.name = "BackgroundImageSelectionError"
}
}
export function createBackgroundImageSettings(platform: Platform, sync = true) {
const [state, setState] = createStore<{
image: { mime: string; blob: { id: string; url: string } } | undefined
}>({ image: undefined })
const channel = sync && typeof BroadcastChannel !== "undefined" ? new BroadcastChannel(key) : undefined
let revision = 0
const load = async () => {
const current = ++revision
const raw = await platform.draftStore?.getItem(key)
if (current !== revision || !raw) {
if (current === revision) setState("image", undefined)
return
}
const parsed = decode(raw)
const image = Option.isSome(parsed) ? parsed.value.image : undefined
if (current !== revision) return
setState(
"image",
image?.blob.url?.startsWith("blob:") && accepted.has(image.mime)
? { mime: image.mime, blob: { id: image.blob.id, url: image.blob.url } }
: undefined,
)
}
channel?.addEventListener("message", () => void load().catch(() => undefined))
if (getOwner()) onCleanup(() => channel?.close())
const ready = load().catch(() => undefined)
return {
ready,
available: !!platform.draftStore,
active: () => !!state.image,
url: () => state.image?.blob.url,
async select(title: string) {
const file = await pick(platform, title)
if (!file) return
const type = file.type.toLowerCase()
const extension = file.name.split(".").at(-1)?.toLowerCase()
const contentType = accepted.has(type) ? type : extension ? mime.get(extension) : undefined
if (!contentType) throw new BackgroundImageSelectionError("unsupported")
if (file.size > maxBytes) throw new BackgroundImageSelectionError("too-large")
const store = platform.draftStore
if (!store) return
revision++
const blob = await store.putBlob(file)
await store.setDocument(key, { image: { mime: contentType, blob } })
revision++
setState("image", { mime: contentType, blob })
channel?.postMessage(null)
},
async clear() {
revision++
await platform.draftStore?.removeItem(key)
revision++
setState("image", undefined)
channel?.postMessage(null)
},
}
}
async function pick(platform: Platform, title: string) {
if (platform.openAttachmentPickerDialog) {
let selected: File | undefined
await platform.openAttachmentPickerDialog(
{ title, extensions: [...mime.keys()], accept: [...accepted] },
async (file) => {
selected ??= file
},
)
return selected
}
return new Promise<File | undefined>((resolve) => {
const input = document.createElement("input")
input.type = "file"
input.accept = [...accepted].join(",")
input.addEventListener("change", () => resolve(input.files?.[0]), { once: true })
input.addEventListener("cancel", () => resolve(undefined), { once: true })
input.click()
})
}
+5
View File
@@ -6,6 +6,8 @@ import { timelinePresets, type TimelineCategory, type TimelineDetail } from "@op
import { persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
import { usePlatform } from "@/runtime/platform/platform"
import { createBackgroundImageSettings } from "@/settings/appearance/background-image"
export type Settings = typeof settingsSchema.Type
export type WorkspaceDefaultDestination = Settings["workspaces"]["defaultDestination"]
@@ -277,7 +279,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
name: "Settings",
gate: false,
init: () => {
const platform = usePlatform()
const [store, setStore, , ready] = persisted({ key: "settings.v3" }, settingsPersistence, defaultSettings)
const backgroundImage = createBackgroundImageSettings(platform)
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
@@ -375,6 +379,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
customAgents: showCustomAgents,
},
appearance: {
backgroundImage,
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
setFontSize(value: number) {
setStore("appearance", "fontSize", value)
+5
View File
@@ -40,8 +40,13 @@ export default function Layout(props: ParentProps) {
return (
<TitlebarRightProvider>
<div
data-component="app-shell"
data-background-image={preferences.appearance.backgroundImage.active() ? "" : undefined}
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
"--app-background-image": preferences.appearance.backgroundImage.url()
? `url("${preferences.appearance.backgroundImage.url()}")`
: undefined,
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
"--shell-top-inset": bottomTitlebar()
? "max(0px, calc(8px - env(safe-area-inset-top, 0px)))"