feat(desktop): open What's new in beta from the command palette and Settings (#57940)

This commit is contained in:
Kit Langton
2026-10-22 13:37:28 -07:00
committed by GitHub
Unverified
parent 5a023ed662
commit 548ea5deb1
9 changed files with 201 additions and 28 deletions
+7 -2
View File
@@ -55,9 +55,14 @@ export default defineConfig(async ({ command }) => {
const channel = resolveChannel()
const nodePtyPkg = nodePtyPackage()
// Beta builds carry their commit and recent history for the updater's "What's new in beta"; other channels none.
// Beta builds carry their commit and recent history for the updater's "What's new in beta"; the dev server carries
// them without asking GitHub, so the page and command can be tried; other channels none.
const betaNotes =
channel === "beta" ? await BetaNotes.enrich(BetaNotes.embed(), { token: process.env.GITHUB_TOKEN }) : undefined
channel === "beta"
? await BetaNotes.enrich(BetaNotes.embed(), { token: process.env.GITHUB_TOKEN })
: command === "serve"
? BetaNotes.embed()
: undefined
return {
main: {
@@ -1,8 +1,10 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Option } from "effect"
import { onMount } from "solid-js"
import { createSignal, onMount, Show } from "solid-js"
import { SettingsPanel } from "../../../stories/mocks/gui-extensions/settings-page"
import { DialogBetaNotes } from "./beta-dialog"
import { BetaNotesView } from "./beta-page"
import { sliceBetaNotes, type EmbeddedNotes } from "./beta-notes"
type Author = { name: string; login?: string; avatar?: string }
@@ -128,8 +130,10 @@ const embedded = (picked: typeof commits): EmbeddedNotes => ({
const offline = (picked: typeof commits): typeof commits =>
picked.map(([type, summary, pr, author]) => [type, summary, pr, { name: author.name }])
// View all opens the What's new page in place of the story, as the app opens it in Settings.
function Fixture(props: { picked: typeof commits; previous?: string; previousCommit?: string }) {
const dialog = useDialog()
const [page, setPage] = createSignal(false)
const notes = Option.getOrThrow(sliceBetaNotes(embedded(props.picked), props.previousCommit ?? sha(999)))
const open = () =>
@@ -139,12 +143,22 @@ function Fixture(props: { picked: typeof commits; previous?: string; previousCom
previous={props.previous ?? "0.0.0-beta-21180"}
dialog={{ close: () => dialog.close() }}
open={(href) => window.open(href, "_blank")}
viewAll={() => {
dialog.close()
setPage(true)
}}
/>
))
onMount(open)
return <Button onClick={open}>Open What's new in beta</Button>
return (
<Show when={page()} fallback={<Button onClick={open}>Open What's new in beta</Button>}>
<SettingsPanel>
<BetaNotesView embedded={embedded(props.picked)} open={(href) => window.open(href, "_blank")} />
</SettingsPanel>
</Show>
)
}
export default {
@@ -163,7 +177,7 @@ export default {
/** A typical update: a few features and fixes. */
export const Typical = { render: () => <Fixture picked={commits.slice(0, 8)} /> }
/** More than ten changes: the rest are linked on GitHub. */
/** More than ten changes: View all opens the What's new page. */
export const Many = {
render: () => <Fixture picked={commits} previous="0.0.0-beta-21012" />,
}
@@ -176,7 +190,16 @@ export const FixesOnly = {
render: () => <Fixture picked={commits.filter(([type]) => type === "fix").slice(0, 4)} />,
}
/** The previous build is older than the embedded history: the newest changes stand in, and the rest are linked. */
/** The previous build is older than the embedded history: the newest changes stand in, and View all shows the rest. */
export const PreviousBuildUnknown = {
render: () => <Fixture picked={commits} previous="0.0.0-beta-20500" previousCommit={sha(5000)} />,
}
/** Settings > What's new in beta: every change the build carries, from ⌘K, Settings or the dialog's View all. */
export const Page = {
render: () => (
<SettingsPanel>
<BetaNotesView embedded={embedded(commits)} open={(href) => window.open(href, "_blank")} />
</SettingsPanel>
),
}
@@ -1,4 +1,4 @@
import { createSignal, For, onMount, Show } from "solid-js"
import { createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Option } from "effect"
import { Button } from "@opencode/ui/button"
@@ -38,18 +38,23 @@ export function showBetaNotes(
previous={input.previous}
dialog={dialog}
open={(href) => ctx.system.openExternal(href)}
viewAll={() => {
dialog.close()
ctx.layout.settings("whats-new")
}}
/>
),
{ replace: true },
)
}
/** What's new in beta: the listed features and fixes, and a link to the whole range when some are not listed. */
/** What's new in beta: the listed features and fixes, and the What's new page when some are not listed. */
export function DialogBetaNotes(props: {
notes: BetaNotes
previous: string
dialog: DialogHandle
open: (href: string) => void
viewAll: () => void
}) {
const ctx = useExtension<typeof definition>()
const [fade, setFade] = createSignal({ top: 0, bottom: 0 })
@@ -93,11 +98,9 @@ export function DialogBetaNotes(props: {
</DialogBody>
<DialogFooter>
<Show when={props.notes.more}>
{(href) => (
<Button type="button" variant="ghost" class="-ms-2.5 me-auto" onClick={() => props.open(href())}>
{ctx.t("beta.viewAll")}
</Button>
)}
<Button type="button" variant="ghost" class="-ms-2.5 me-auto" onClick={() => props.viewAll()}>
{ctx.t("beta.viewAll")}
</Button>
</Show>
<Button type="button" variant="contrast" autofocus onClick={() => props.dialog.close()}>
{ctx.t("beta.done")}
@@ -112,7 +115,7 @@ const FADE = 24
// Every entry starts on the heading's edge; a visual sits small at the row's end, so rows keep one rhythm with or
// without one.
function Section(props: { title: string; notes: readonly BetaNote[]; open: (href: string) => void }) {
export function Section(props: { title: string; notes: readonly BetaNote[]; open: (href: string) => void }) {
return (
<Show when={props.notes.length > 0}>
<section class="flex flex-col gap-3">
@@ -142,7 +145,15 @@ function Visual(props: { visual: NonNullable<BetaNote["visual"]>; label?: string
const [color, setColor] = createSignal<string>()
let frame!: HTMLSpanElement
onMount(() => setColor(getComputedStyle(frame).color))
// The theme lives on the root element's attributes; read the colour again when they change.
onMount(() => {
const read = () => setColor(getComputedStyle(frame).color)
const observer = new MutationObserver(read)
read()
observer.observe(document.documentElement, { attributes: true })
onCleanup(() => observer.disconnect())
})
return (
<span ref={frame} class="block h-9 w-[120px] shrink-0 rounded-[6px] bg-current/[0.05] text-v2-icon-icon-base">
@@ -1,6 +1,14 @@
import { expect, test } from "bun:test"
import { Option } from "effect"
import { betaBuild, decodeEmbeddedNotes, sliceBetaNotes, visualSource, type EmbeddedNotes } from "./beta-notes"
import {
allBetaNotes,
betaBuild,
betaHistory,
decodeEmbeddedNotes,
sliceBetaNotes,
visualSource,
type EmbeddedNotes,
} from "./beta-notes"
const sha = (n: number) => n.toString(16).padStart(4, "0").padEnd(40, "a")
@@ -21,8 +29,6 @@ function embedded(input: { notes: (n: number) => "feat" | "fix" | "chore" }): Em
}
}
const compare = (from: number) => `https://github.com/anomalyco/opencode/compare/${sha(from)}...${sha(30)}`
test("lists the notes after the previous commit, features first, humanized, with pull request links", () => {
const notes = sliceBetaNotes(
{
@@ -59,16 +65,16 @@ test("lists the notes after the previous commit, features first, humanized, with
{ sha: short(28), type: "perf", summary: "Cache the model list" },
],
total: 3,
more: undefined,
more: false,
})
})
test.each([
// previous commit, note types, [features, fixes] listed, total, more
["found, all listed", sha(20), (n: number) => (n % 3 === 0 ? "feat" : "chore"), [4, 0], 4, undefined],
["found, more than 10", sha(5), (n: number) => (n % 2 ? "fix" : "feat"), [5, 5], 25, compare(5)],
["found, mostly features", sha(10), (n: number) => (n < 28 ? "feat" : "fix"), [7, 3], 20, compare(10)],
["missing from the history", sha(99), (n: number) => (n % 2 ? "fix" : "feat"), [5, 5], 10, compare(99)],
["found, all listed", sha(20), (n: number) => (n % 3 === 0 ? "feat" : "chore"), [4, 0], 4, false],
["found, more than 10", sha(5), (n: number) => (n % 2 ? "fix" : "feat"), [5, 5], 25, true],
["found, mostly features", sha(10), (n: number) => (n < 28 ? "feat" : "fix"), [7, 3], 20, true],
["missing from the history", sha(99), (n: number) => (n % 2 ? "fix" : "feat"), [5, 5], 10, true],
] as const)("previous commit %s", (_, previous, notes, shown, total, more) => {
const sliced = Option.getOrThrow(sliceBetaNotes(embedded({ notes }), previous))
@@ -115,3 +121,15 @@ test("sources a visual with the page's colour, and its still copy for reduced mo
)
expect(source(true)).toBe('<svg viewBox="0 0 240 72"><style>svg{color:rgb(1, 2, 3)}</style><rect/></svg>')
})
test("lists every change the build carries for the What's new page, and links its history", () => {
const value = embedded({ notes: (n) => (["feat", "fix", "chore"] as const)[n % 3]! })
const notes = allBetaNotes(value)
expect(notes.added.map((note) => note.summary)).toEqual(
[30, 27, 24, 21, 18, 15, 12, 9, 6, 3].map((n) => `Change ${n}`),
)
expect(notes.fixed).toHaveLength(10)
expect([notes.total, notes.more]).toEqual([20, false])
expect(betaHistory(value)).toBe(`https://github.com/anomalyco/opencode/commits/${sha(30)}`)
})
@@ -56,14 +56,14 @@ export type BetaNotes = {
readonly fixed: readonly BetaNote[]
/** How many changes the header counts: every one since the previous build, or the listed ones when that is unknown. */
readonly total: number
/** The GitHub compare page of the whole range, when some changes are not listed. */
readonly more?: string
/** Some changes since the previous build are not listed. */
readonly more: boolean
}
/**
* The changes since the build at `previous`: the notes of the commits newer than it, features first, then fixes, at
* most 10 in all and at least half of them features when there are enough. When `previous` is older than the embedded
* history (or was force-pushed away), the newest notes stand in, with the rest linked. None without a previous commit,
* history (or was force-pushed away), the newest notes stand in, with the rest on the What's new page. None without a previous commit,
* or without a change.
*/
export function sliceBetaNotes(embedded: EmbeddedNotes, previous: string | undefined): Option.Option<BetaNotes> {
@@ -86,10 +86,23 @@ export function sliceBetaNotes(embedded: EmbeddedNotes, previous: string | undef
added: added.map(item),
fixed: fixed.map(item),
total: cut === -1 ? listed : notes.length,
more: cut === -1 || listed < notes.length ? `${REPOSITORY}/compare/${previous}...${embedded.sha}` : undefined,
more: cut === -1 || listed < notes.length,
})
}
/** Every change the build carries, features first, then fixes, newest first: the What's new page. */
export function allBetaNotes(embedded: EmbeddedNotes): BetaNotes {
return {
added: embedded.notes.filter((note) => note.type === "feat").map(item),
fixed: embedded.notes.filter((note) => note.type !== "feat").map(item),
total: embedded.notes.length,
more: false,
}
}
/** The build's commit history on GitHub. */
export const betaHistory = (embedded: EmbeddedNotes) => `${REPOSITORY}/commits/${embedded.sha}`
/** The beta run number of a version such as `0.0.0-beta-21180`, or the version itself. */
export function betaBuild(version: string) {
return version.match(/^v?0\.0\.0-(?:beta|next)-(\d+(?:\.\d+)?)$/)?.[1] ?? version
@@ -0,0 +1,58 @@
import { Show } from "solid-js"
import { Option } from "effect"
import { Button } from "@opencode/ui/button"
import { useExtension } from "../sdk"
import { Section } from "./beta-dialog"
import { allBetaNotes, betaHistory, decodeEmbeddedNotes, type EmbeddedNotes } from "./beta-notes"
import type definition from "./index"
/** Settings > What's new in beta: every change this build carries, as the dialog shows them. */
export default function BetaNotesPage() {
const ctx = useExtension<typeof definition>()
return (
<BetaNotesView
embedded={Option.getOrUndefined(decodeEmbeddedNotes(import.meta.env.OPENCODE_BETA_NOTES))}
open={(href) => ctx.system.openExternal(href)}
/>
)
}
export function BetaNotesView(props: { embedded: EmbeddedNotes | undefined; open: (href: string) => void }) {
const ctx = useExtension<typeof definition>()
return (
<Show when={props.embedded}>
{(embedded) => {
const notes = allBetaNotes(embedded())
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{ctx.t("beta.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{ctx.t("beta.page.description")}</span>
</div>
<div class="settings-tab-header-actions">
<Button
type="button"
variant="ghost"
class="-me-2.5"
onClick={() => props.open(betaHistory(embedded()))}
>
{ctx.t("beta.github")}
</Button>
</div>
</div>
</div>
<div class="settings-tab-body settings-tab-body--sectioned">
<Section title={ctx.t("beta.added")} notes={notes.added} open={props.open} />
<Section title={ctx.t("beta.fixed")} notes={notes.fixed} open={props.open} />
</div>
</>
)
}}
</Show>
)
}
@@ -34,6 +34,8 @@ export default {
"beta.since.other": "Since build {{build}} · {{count}} changes",
"beta.added": "New",
"beta.fixed": "Fixes",
"beta.viewAll": "View all on GitHub",
"beta.viewAll": "View all",
"beta.page.description": "Every change in this build.",
"beta.github": "History on GitHub",
"beta.done": "Got it",
}
@@ -6,6 +6,7 @@ import type definition from "./index"
const setup: Setup<typeof definition> = (ctx) => {
whatsNew(ctx)
betaNotes(ctx)
if (!ctx.desktop) return
const updater = ctx.uses.updater
@@ -86,6 +87,41 @@ const setup: Setup<typeof definition> = (ctx) => {
createKeyed(updater, (client) => void client.on("check", () => act("check")))
}
/**
* What's new in beta as a settings page and a command, so it is a keystroke away after its dialog closes. Only builds
* that embed the notes have it: beta builds and the desktop dev server.
*/
function betaNotes(ctx: SetupContext<typeof definition>) {
if (!import.meta.env.OPENCODE_BETA_NOTES) return
const Page = lazy(() => import("./beta-page"))
ctx.add(SettingsPage, {
id: "whats-new",
icon: "flask",
get title() {
return ctx.t("beta.title")
},
get entries() {
return [{ id: "whats-new", title: ctx.t("beta.title"), keywords: "changelog release notes beta" }]
},
render: () => (
<Suspense>
<Page />
</Suspense>
),
})
ctx.add(Command, {
id: "whats-new",
get title() {
return ctx.t("beta.title")
},
keywords: "changelog release notes beta",
run: () => ctx.layout.settings("whats-new"),
})
}
/**
* What's New after an update, on every platform: the release highlights since the version last seen, or on beta the
* features and fixes of the beta builds since then. The first run and a disabled What's New only remember the version.
@@ -0,0 +1,7 @@
import type { JSX } from "solid-js"
// Extension settings pages render inside the app's settings panel, which brings these styles and caps the width.
import "@/settings/settings.css"
export function SettingsPanel(props: { children: JSX.Element }) {
return <div class="settings-panel h-screen max-w-[800px] bg-v2-background-bg-base">{props.children}</div>
}