chore: remove beta what's new and visual svgs (#63325)

This commit is contained in:
Aiden Cline
2026-11-11 11:40:39 -06:00
committed by GitHub
Unverified
parent d0cffe13fb
commit 6eac53772b
22 changed files with 29 additions and 1938 deletions
-7
View File
@@ -17,13 +17,6 @@ Please provide a description of the issue, the changes you made to fix it, and w
### How did you verify your code works?
### Visual
_Optional, for user-facing changes: a small looping animated SVG (240×72) of the change, shown in the desktop app's "What's new in beta". Replace the block below or delete this section._
```svg
```
### Screenshots / recordings
_If this is a UI change, please include a screenshot or recording._
-9
View File
@@ -318,7 +318,6 @@ jobs:
if: needs.plan.outputs.desktop == 'true'
permissions:
contents: read
pull-requests: read # beta builds read who made each change and what it does for "What's new in beta"
strategy:
fail-fast: false
matrix:
@@ -346,13 +345,6 @@ jobs:
- name: Set up rpm
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends rpm
# The beta build embeds the subjects of its newest commits for "What's new in beta"
# (packages/desktop/script/beta-notes.ts). Commits without file contents are enough.
- name: Fetch beta history
if: needs.plan.outputs.channel == 'beta'
run: git fetch --no-tags --filter=blob:none --depth=300 origin "$SHA"
env:
SHA: ${{ needs.tag.outputs.sha }}
# Dev and beta trade larger packages for faster packaging. fpm sets XZ_OPT for the deb and rpm level, but xz
# still reads XZ_DEFAULTS, so it uses every CPU.
- name: Preview packaging settings
@@ -367,7 +359,6 @@ jobs:
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_ENVIRONMENT: ${{ needs.plan.outputs.channel == 'latest' && 'production' || needs.plan.outputs.channel }}
VITE_SENTRY_RELEASE: desktop@${{ needs.plan.outputs.version }}
GITHUB_TOKEN: ${{ github.token }} # read-only; beta notes read pull request authors and descriptions
- name: Pack
working-directory: packages/desktop
timeout-minutes: 60
-2
View File
@@ -44,8 +44,6 @@ Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes a
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
For user-facing `feat`, `fix` and `perf` PRs, add a small looping animated SVG of the change under `### Visual` in the PR body (a `svg` code block, 240×72, for example a dock growing out of the composer or spinners turning in step). Draw with `currentColor` and opacity so it works in light and dark, make the unanimated state a good still (it is shown under reduced motion), animate with CSS or SMIL only, and keep it under 8 KB with no scripts or external references. The desktop beta's "What's new" dialog shows it.
## Style Guide
### General Principles
-1
View File
@@ -371,7 +371,6 @@
"electron": "44.9.1",
"electron-builder": "26.15.7",
"electron-vite": "6.0.0-beta.1",
"htmlparser2": "8.0.2",
"magic-string": "0.30.21",
"solid-js": "catalog:",
"typescript": "catalog:",
+1 -5
View File
@@ -724,11 +724,7 @@ function Tile(props: { entry: LabEntry }) {
}
function isDialogEntry(entry: LabEntry) {
return (
entry.title.startsWith("App/Dialogs/") ||
entry.file.endsWith("/beta-dialog.stories.tsx") ||
entry.file.endsWith("/overlays/dialog/dialog.stories.tsx")
)
return entry.title.startsWith("App/Dialogs/") || entry.file.endsWith("/overlays/dialog/dialog.stories.tsx")
}
function tileSize(entry: LabEntry) {
-5
View File
@@ -41,11 +41,6 @@ the `beta` update channel. Beta publishes only the desktop app; the CLI and SDK
from `dev`. Install it from `https://opencode.ai/update/api/beta/desktop/opencode/<asset>`, for example
`opencode-desktop-mac-arm64.dmg` or `opencode-desktop-win-x64.exe`. Installed builds update in place on every platform.
Each beta build compiles in its commit and the `feat`, `fix` and `perf` commits among its newest 200
(`script/beta-notes.ts`, embedded by `electron.vite.config.ts`; run `bun script/beta-notes.ts` to preview them). After an
update, the beta app lists the ones after the commit of the build the user saw last in a "What's new in beta" dialog,
unless release notes are turned off in Settings.
## Startup benchmark
`perf/startup.bench.ts` times a launch with [strobe](https://github.com/anomalyco/strobe), from process spawn to what a
+1 -13
View File
@@ -1,6 +1,5 @@
import { defineConfig } from "electron-vite"
import type { Plugin } from "vite"
import { BetaNotes } from "./script/beta-notes"
import { pickerPlugin } from "./script/picker"
import { nodePtyPackage } from "./script/utils"
@@ -34,19 +33,10 @@ const initialChunk: Plugin = {
},
}
export default defineConfig(async ({ command }) => {
export default defineConfig(({ command }) => {
const channel = resolveChannel()
const nodePtyPkg = nodePtyPackage()
// 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 can be tried; other channels none.
const betaNotes =
channel === "beta"
? await BetaNotes.enrich(BetaNotes.embed(), { token: process.env.GITHUB_TOKEN })
: command === "serve"
? BetaNotes.embed()
: undefined
return {
main: {
resolve: {
@@ -118,8 +108,6 @@ export default defineConfig(async ({ command }) => {
define: {
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
"import.meta.env.OPENCODE_COMMIT": betaNotes ? JSON.stringify(betaNotes.sha) : "undefined",
"import.meta.env.OPENCODE_BETA_NOTES": betaNotes ? JSON.stringify(JSON.stringify(betaNotes)) : "undefined",
"import.meta.env.OPENCODE_TEST_ONBOARDING": JSON.stringify(
command === "serve" && process.env.OPENCODE_TEST_ONBOARDING === "1",
),
-1
View File
@@ -50,7 +50,6 @@
"electron": "44.9.1",
"electron-builder": "26.15.7",
"electron-vite": "6.0.0-beta.1",
"htmlparser2": "8.0.2",
"magic-string": "0.30.21",
"solid-js": "catalog:",
"typescript": "catalog:",
-298
View File
@@ -1,298 +0,0 @@
import { afterAll, expect, test } from "bun:test"
import { $ } from "bun"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { BetaNotes } from "./beta-notes"
test.each([
[
"feat(app): merge projects across servers in the location dialog",
{ type: "feat", scope: "app", summary: "merge projects across servers in the location dialog" },
],
[
"fix(desktop): disable animated wheel scrolling on linux (#57120)",
{ type: "fix", scope: "desktop", summary: "disable animated wheel scrolling on linux", pr: 57120 },
],
["perf: cache model list", { type: "perf", summary: "cache model list" }],
[
"Feat(core)!: drop legacy sessions",
{ type: "feat", scope: "core", summary: "drop legacy sessions", breaking: true },
],
[
"fix(app): keep (#1) in the middle of a summary",
{ type: "fix", scope: "app", summary: "keep (#1) in the middle of a summary" },
],
["chore(sdk): regenerate types", undefined],
["refactor(app): split dialog", undefined],
["docs: update guide", undefined],
["test(app): cover dialog", undefined],
["ci: cache bun", undefined],
["Merge branch 'dev' into beta", undefined],
["fix the thing without a type", undefined],
["fix(app): (#12)", undefined],
])("parses %p", (subject, expected) => {
expect(BetaNotes.parseSubject(subject)).toEqual(expected)
})
const repos: string[] = []
afterAll(() => Promise.all(repos.map((dir) => rm(dir, { recursive: true, force: true }))))
async function repo(subjects: string[]) {
const dir = await mkdtemp(path.join(tmpdir(), "beta-notes-"))
repos.push(dir)
await $`git init -q -b beta && git config user.email a@b.c && git config user.name Ada`.cwd(dir).quiet()
// One fast-import writes every commit, where a commit per process is slow on Windows.
const stream = subjects
.map((subject, i) =>
[
"commit refs/heads/beta",
`committer Ada <a@b.c> ${1_700_000_000 + i} +0000`,
`data ${Buffer.byteLength(subject)}`,
subject,
"",
].join("\n"),
)
.join("")
await $`git fast-import --quiet < ${new Response(stream)}`.cwd(dir).quiet()
await $`git reset -q --hard beta`.cwd(dir).quiet()
return { dir, shas: (await $`git rev-list --reverse HEAD`.cwd(dir).text()).trim().split("\n") }
}
test("embeds the build's commit, its history and the notes of its non-merge feat and fix commits", async () => {
const git = await repo([
"feat(app): old feature",
"fix(app): first fix (#10)",
"chore: bump",
"feat(desktop): new dialog",
])
await $`git checkout -q -b side HEAD~1 && git commit -q --allow-empty -m ${"fix(app): side fix"} && git checkout -q beta && git merge -q --no-ff side -m ${"fix(app): merge side"}`
.cwd(git.dir)
.quiet()
const head = (await $`git rev-parse HEAD`.cwd(git.dir).text()).trim()
const side = (await $`git rev-parse side`.cwd(git.dir).text()).trim()
const short = (sha: string) => sha.slice(0, 12)
const embedded = BetaNotes.embed({ head: "HEAD", cwd: git.dir })
expect(embedded.sha).toBe(head)
expect(embedded.commits).toHaveLength(6)
expect(embedded.commits[0]).toBe(short(head))
const author = { name: "Ada" }
expect(embedded.notes).toEqual([
{ type: "fix", scope: "app", summary: "side fix", sha: short(side), author },
{ type: "feat", scope: "desktop", summary: "new dialog", sha: short(git.shas[3]!), author },
{ type: "fix", scope: "app", summary: "first fix", pr: 10, sha: short(git.shas[1]!), author },
{ type: "feat", scope: "app", summary: "old feature", sha: short(git.shas[0]!), author },
])
})
test("embeds at most the newest 200 commits", async () => {
const git = await repo(Array.from({ length: 205 }, (_, i) => `fix(app): fix ${i}`))
const embedded = BetaNotes.embed({ head: "HEAD", cwd: git.dir })
expect(embedded.commits).toHaveLength(200)
expect(embedded.notes[0]?.summary).toBe("fix 204")
expect(embedded.notes.at(-1)?.summary).toBe("fix 5")
})
const TEMPLATE = `### Issue for this PR
Closes #1
### Type of change
- [x] Bug fix
### What does this PR do?
Keep the composer's last line visible. Shift+Enter now scrolls far enough to clear the [fade](https://example.com).
A third sentence is left out.
### How did you verify your code works?
Tests.`
test.each([
[
"the template's What does this PR do? section",
TEMPLATE,
"Keep the composer's last line visible. Shift+Enter now scrolls far enough to clear the fade.",
],
[
"a Summary list's first item",
"## Summary\n- Restore the **standard** border (`composer.tsx`).\n- Keep the row.",
"Restore the standard border.",
],
[
"the text before the first heading",
"Home showed a loading state.\n\n### Changes\n\n- Cache the list.",
"Home showed a loading state.",
],
[
"a Changes section when nothing comes first",
"### Why\n\nIt was slow.\n\n### Changes\n\nThe list stays cached.",
"The list stays cached.",
],
[
"nothing that introduces a code block",
"### What does this PR do?\n\nThe query was:\n\n```sql\nSELECT 1\n```",
undefined,
],
["nothing that repeats the subject", "Fix the thing.", undefined],
[
"past the template's filler lines",
"**Issue for this PR:** N/A\nCloses #\nType of change: Bug fix\n- [x] Bug fix\n<!-- note -->\n\nThe dialog opens at the top.",
"The dialog opens at the top.",
],
[
"the whole sentences that fit under the cap",
`Short first sentence. ${"Long ".repeat(50)}second.`,
"Short first sentence.",
],
[
"a long sentence cut at a word, with an ellipsis",
`Unifies the card surface, ${"and more words ".repeat(20)}end.`,
`Unifies the card surface, ${"and more words ".repeat(14).trim()}…`,
],
["nothing from an empty body", "", undefined],
])("describes %s", (_, body, expected) => {
expect(BetaNotes.describe(body, "fix the thing")).toBe(expected)
})
const notes = (): ReturnType<typeof BetaNotes.embed> => ({
sha: "a".repeat(40),
commits: ["aaaaaaaaaaaa", "bbbbbbbbbbbb"],
notes: [
{ type: "fix", summary: "keep the last line visible", pr: 10, sha: "aaaaaaaaaaaa", author: { name: "Ada" } },
{ type: "feat", summary: "local change", sha: "bbbbbbbbbbbb", author: { name: "Ada" } },
],
})
test("adds each pull request's author and description from one GitHub query", async () => {
const requests: RequestInit[] = []
const enriched = await BetaNotes.enrich(notes(), {
token: "token",
fetch: async (_, init) => {
requests.push(init)
return Response.json({
data: {
repository: {
pr10: {
number: 10,
body: TEMPLATE,
author: {
login: "ada",
name: "Ada Lovelace",
avatarUrl: "https://avatars.githubusercontent.com/u/1?s=48",
},
},
},
},
})
},
})
expect(requests).toHaveLength(1)
expect(enriched.notes).toEqual([
{
...notes().notes[0]!,
author: { name: "Ada Lovelace", login: "ada", avatar: "https://avatars.githubusercontent.com/u/1?s=48" },
description: "Keep the composer's last line visible. Shift+Enter now scrolls far enough to clear the fade.",
},
notes().notes[1],
])
})
test("keeps only GitHub's public avatars, and the login when there is no name", async () => {
const enriched = await BetaNotes.enrich(notes(), {
token: "token",
fetch: async () =>
Response.json({
data: {
repository: {
pr10: { number: 10, body: "", author: { login: "bot", avatarUrl: "http://example.com/a.png" } },
},
},
}),
})
expect(enriched.notes[0]?.author).toEqual({ name: "bot", login: "bot" })
})
test.each([
["no token", undefined, async () => Response.json({})],
["a failed request", "token", async () => new Response("rate limited", { status: 403 })],
["a network error", "token", async () => Promise.reject(new Error("offline"))],
["an unexpected response", "token", async () => Response.json({ errors: [{ message: "bad" }] })],
])("keeps the commit authors and subjects with %s", async (_, token, request) => {
expect(await BetaNotes.enrich(notes(), { token, fetch: request })).toEqual(notes())
})
const SVG = `<svg viewBox="0 0 240 72"><rect width="40" height="8" fill="currentColor"><animate attributeName="width" values="40;80;40" dur="3s" repeatCount="indefinite"/></rect></svg>`
const withVisual = (svg: string) =>
`### What does this PR do?\n\nIt grows.\n\n### Visual\n\n\`\`\`svg\n${svg}\n\`\`\`\n\n### Checklist`
test("rebuilds a visual with its namespace, and a still without its animation", () => {
expect(BetaNotes.visual(withVisual(SVG))).toEqual({
animated: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72"><rect width="40" height="8" fill="currentColor"><animate attributeName="width" values="40;80;40" dur="3s" repeatCount="indefinite"/></rect></svg>`,
still: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72"><rect width="40" height="8" fill="currentColor"/></svg>`,
})
})
test("drops scripts, foreign objects, images, handlers, outside links and SMIL that animates a link", () => {
const rebuilt = BetaNotes.visual(
withVisual(
`<svg onload="alert(1)"><!-- note --><script>alert(1)</script><foreignObject><div/></foreignObject><image href="https://example.com/a.png"/><use xlink:href="https://example.com/a.svg#x"/><use xlink:href="#x"/><rect onclick="x()"/><animate attributeName="href" values="https://example.com"/><set attributeName="xlink:href" to="https://example.com"/></svg>`,
),
)
expect(rebuilt?.animated).toBe(`<svg xmlns="http://www.w3.org/2000/svg"><use/><use href="#x"/><rect/></svg>`)
})
test("drops a still's keyframes and animations, even important or inline ones", () => {
const rebuilt = BetaNotes.visual(
withVisual(
`<svg><style><![CDATA[.a{fill:red;animation:spin 1s infinite!important}@keyframes spin{from{opacity:0}to{opacity:1}}@media (min-width:1px){.b{transition:opacity 1s}}]]></style><rect class="a" style="animation: spin 1s !important; stroke: blue"/></svg>`,
),
)
expect(rebuilt?.still).toBe(
`<svg xmlns="http://www.w3.org/2000/svg"><style>.a{fill:red}@media (min-width:1px){.b{}}</style><rect class="a" style="stroke: blue"/></svg>`,
)
expect(rebuilt?.animated).toContain("@keyframes spin")
})
test.each([
["no Visual section", "### What does this PR do?\n\n```svg\n<svg></svg>\n```"],
["a root that is not an svg", withVisual("<div><svg></svg></div>")],
["more than one svg root", withVisual("<svg></svg><svg></svg>")],
["a doctype", withVisual('<!DOCTYPE svg [<!ENTITY x "y">]><svg></svg>')],
["an @import", withVisual('<svg><style>@import "https://example.com/a.css";</style></svg>')],
["a CSS-escaped @import", withVisual('<svg><style>@\\69mport "https://example.com/a.css";</style></svg>')],
["a CSS-escaped url()", withVisual('<svg><rect style="fill:u\\72l(https://example.com/p)"/></svg>')],
["an outside url() in an attribute", withVisual('<svg><rect fill="url(https://example.com/p)"/></svg>')],
[
"an outside url() in an animation",
withVisual('<svg><animate attributeName="fill" to="url(https://example.com/p)"/></svg>'),
],
["an entity-encoded url()", withVisual('<svg><rect fill="&#117;rl(https://example.com/p)"/></svg>')],
["more than 8 KB", withVisual(`<svg>${'<rect width="1"/>'.repeat(600)}</svg>`)],
])("refuses a visual with %s", (_, body) => {
expect(BetaNotes.visual(body)).toBeUndefined()
})
test("keeps the visual's code out of the description", () => {
expect(BetaNotes.describe(`\`\`\`svg\n${SVG}\n\`\`\`\n\nIt grows.`, "grow")).toBe("It grows.")
})
-313
View File
@@ -1,313 +0,0 @@
// The user-facing changes compiled into a beta build: the build's commit, the newest commits of its history, and the
// feat, fix and perf commits among them, with who made each and what its pull request says it does when GitHub can be
// asked. electron.vite.config.ts embeds them in the renderer as import.meta.env.OPENCODE_BETA_NOTES; the updater's
// "What's new in beta" dialog lists the ones after the commit of the build the user saw last.
// Usage: GITHUB_TOKEN=... bun script/beta-notes.ts [head]
export * as BetaNotes from "./beta-notes"
import { execFileSync } from "node:child_process"
import { Option, Schema } from "effect"
import { BetaVisual } from "./beta-visual"
export type BetaNote = {
type: "feat" | "fix" | "perf"
scope?: string
summary: string
breaking?: true
pr?: number
sha: string
/** The commit's author, or the pull request's once GitHub has been asked. */
author?: { name: string; login?: string; avatar?: string }
/** A sentence or two from the pull request's description of what it does. */
description?: string
/** The pull request's "### Visual": a small looping SVG of the change, rebuilt, and its still for reduced motion. */
visual?: { animated: string; still: string }
}
/** How many commits of history a build carries. A previous build further back shows the newest notes instead. */
const LIMIT = 200
/** Commits are identified by this many hex digits, enough to be unique in this repository. */
const SHORT = 12
const KEPT = new Set(["feat", "fix", "perf"])
const FIELD = "\x1f"
const RECORD = "\x1e"
const REPOSITORY = { owner: "anomalyco", name: "opencode" }
/** Pull requests per GitHub query; the newest 200 commits take at most two. */
const BATCH = 100
/** A description longer than this reads as a wall of text in the dialog, so it is cut to fit. */
const DESCRIPTION = 240
/**
* Reads the commit `head` resolves to (GITHUB_SHA in CI) and its newest commits, newest first: every commit's short
* sha, merges included so a previous build at a merge is found, and the notes of the non-merge feat, fix and perf ones.
*/
export function embed(input: { head?: string; cwd?: string } = {}) {
// electron-vite loads its config, which calls this, in Node, so this cannot use Bun's shell.
const git = (...args: string[]) => execFileSync("git", args, { cwd: input.cwd, encoding: "utf8" })
const sha = git("rev-parse", input.head ?? process.env.GITHUB_SHA ?? "HEAD").trim()
const log = git(
"log",
"--topo-order",
`--max-count=${LIMIT}`,
`--format=%H${FIELD}%P${FIELD}%an${FIELD}%s${RECORD}`,
sha,
)
const records = log
.split(RECORD)
.map((record) => record.trim().split(FIELD))
.filter((fields) => fields.length === 4 && fields[0])
return {
sha,
commits: records.map(([commit]) => commit!.slice(0, SHORT)),
notes: records.flatMap(([commit, parents, author, subject]): BetaNote[] => {
// A merge commit repeats the commits it merges.
if (parents!.trim().split(/\s+/).length > 1) return []
const note = parseSubject(subject!)
if (!note) return []
return [{ ...note, sha: commit!.slice(0, SHORT), author: author!.trim() ? { name: author!.trim() } : undefined }]
}),
}
}
/**
* Adds each note's pull request author (name, login and public avatar) and description, read from GitHub with one
* GraphQL query per 100 pull requests. The token stays here: only public data reaches the build. Without a token, or
* when GitHub fails or is slow, the notes keep their commit author and subject: a build never fails for this.
*/
export async function enrich(
embedded: ReturnType<typeof embed>,
input: { token: string | undefined; fetch?: Request },
): Promise<ReturnType<typeof embed>> {
const numbers = [...new Set(embedded.notes.flatMap((note) => note.pr ?? []))]
const token = input.token
if (!token || numbers.length === 0) return embedded
const batches = Array.from({ length: Math.ceil(numbers.length / BATCH) }, (_, i) =>
numbers.slice(i * BATCH, (i + 1) * BATCH),
)
const found = new Map(
(await Promise.all(batches.map((batch) => pullRequests(batch, token, input.fetch ?? fetch)))).flat(),
)
return {
...embedded,
notes: embedded.notes.map((note) => {
const pr = note.pr === undefined ? undefined : found.get(note.pr)
if (!pr) return note
return {
...note,
author: pr.author ? author(pr.author) : note.author,
description: describe(pr.body, note.summary),
visual: visual(pr.body),
}
}),
}
}
/**
* The first sentence or two of what a pull request says it does, as plain text: from the PR template's "What does this
* PR do?" or a "Summary" section, else the text before the first heading, else a "Changes" or "Solution" section. The
* template's own lines (issue links, "N/A", the type-of-change checklist) never count. Text over the cap ends at its
* last whole sentence that fits, or is cut at a word with an ellipsis. None when it only repeats the subject.
*/
export function describe(body: string, summary: string) {
const heading = (line: string) => /^#{1,6}\s/.test(line) || /^\*\*[^*]+\*\*:?$/.test(line)
// Template headings stay, as they end the section before them.
const lines = body
.replace(/<!--[\s\S]*?-->|```[\s\S]*?```/g, "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => heading(line) || !BOILERPLATE.some((pattern) => pattern.test(line)))
const until = (rest: string[]) => {
const end = rest.findIndex(heading)
return end === -1 ? rest : rest.slice(0, end)
}
const section = (title: RegExp) => {
const start = lines.findIndex((line) => heading(line) && title.test(line))
return start === -1 ? [] : until(lines.slice(start + 1))
}
const words = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim()
return [section(/what does this pr do|summary/i), until(lines), section(/^\W*(what )?chang|solution/i)]
.map((candidate) => {
const paragraph = candidate
.join("\n")
.split(/\n\s*\n/)
// A list stands for its first item.
.map((block) => plain(block.split("\n").find((line) => /^([-*+]|\d+\.)\s/.test(line)) ?? block))
.find(
(text) => text && !/^(\[.\]|(closes|fixes|resolves|stacked on|depends on|n\/a|please provide)\b)/i.test(text),
)
// A sentence that introduces a code block or a list reads as cut off.
const [first, second] = (paragraph ?? "")
.split(/(?<=[.!?])\s+(?=[A-Z0-9"'`(])/)
.filter((sentence) => sentence && !sentence.endsWith(":"))
const text = first && second && `${first} ${second}`.length <= DESCRIPTION ? `${first} ${second}` : first
// Fragments of a list, or text that starts with code, don't read as a sentence on their own.
return text && /^[A-Z0-9"“]/.test(text) && words(text) !== words(summary) ? cap(text) : undefined
})
.find((text) => text !== undefined)
}
/**
* The ```svg block under a pull request's "### Visual" heading, rebuilt from an allowlist (beta-visual.ts) as an
* animated and a still copy. The dialog only ever shows it as an image, where scripts never run.
*/
export function visual(body: string) {
const lines = body.split(/\r?\n/)
const start = lines.findIndex((line) => /^#{1,6}\s+visual\b/i.test(line.trim()))
if (start === -1) return undefined
const rest = lines.slice(start + 1)
const end = rest.findIndex((line) => /^#{1,6}\s/.test(line.trim()))
const block = (end === -1 ? rest : rest.slice(0, end)).join("\n").match(/```svg[^\n]*\n([\s\S]*?)\n\s*```/)?.[1]
return block ? BetaVisual.sanitize(block) : undefined
}
/** Lines of the PR template that say nothing about the change, whether a heading, a label or a filler answer. */
const BOILERPLATE = [
/^\W*(issue for this pr|type of change|how did you verify|screenshots|checklist)\b/i,
/^[-*+]\s*\[[ x]\]/i,
/^\W*(n\/a|none|tbd)\W*$/i,
/^\W*((closes|fixes|resolves)\s*)?([\w.-]+\/[\w.-]+)?#\d*\W*$/i,
]
/** A sentence over the cap, cut at its last word that fits, with an ellipsis. */
function cap(text: string) {
if (text.length <= DESCRIPTION) return text
const cut = text.slice(0, DESCRIPTION - 1).replace(/\s+\S*$/, "")
return `${cut.replace(/[\s,;:—–-]+$/, "")}…`
}
/** Markdown as the plain text it reads as. */
function plain(markdown: string) {
return (
markdown
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
// Parenthesized file paths and identifiers are for reviewers.
.replace(/\s*\([^()]*`[^()]*\)/g, "")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/<[^>]+>/g, "")
.replace(/^\s*(?:[-*+]|\d+\.|>)\s+/gm, "")
.replace(/(\*\*|__|`)/g, "")
.replace(/\s+/g, " ")
.trim()
)
}
const Author = Schema.Struct({
login: Schema.String,
avatarUrl: Schema.optional(Schema.String),
name: Schema.optional(Schema.NullOr(Schema.String)),
})
const PullRequest = Schema.Struct({ number: Schema.Number, body: Schema.String, author: Schema.NullOr(Author) })
const Response = Schema.Struct({
data: Schema.Struct({ repository: Schema.Record(Schema.String, Schema.NullOr(PullRequest)) }),
})
const decodeResponse = Schema.decodeUnknownOption(Response)
type Request = (url: string, init: RequestInit) => Promise<globalThis.Response>
/** The pull requests GitHub returns for `numbers`; none when the request fails. Missing numbers are left out. */
async function pullRequests(numbers: number[], token: string, request: Request) {
const fields = numbers
.map(
(n) =>
`pr${n}: pullRequest(number: ${n}) { number body author { login avatarUrl(size: 48) ... on User { name } } }`,
)
.join("\n")
const query = `query { repository(owner: "${REPOSITORY.owner}", name: "${REPOSITORY.name}") { ${fields} } }`
const response = await request("https://api.github.com/graphql", {
method: "POST",
headers: { authorization: `bearer ${token}`, "content-type": "application/json", "user-agent": "opencode-desktop" },
body: JSON.stringify({ query }),
signal: AbortSignal.timeout(10_000),
})
.then((result) => (result.ok ? result.json() : undefined))
.catch(() => undefined)
return Option.match(decodeResponse(response), {
onNone: () => [],
onSome: (decoded) => Object.values(decoded.data.repository).flatMap((pr) => (pr ? [[pr.number, pr] as const] : [])),
})
}
/** A pull request author as the dialog shows them; only GitHub's public avatar host is kept. */
function author(input: typeof Author.Type): NonNullable<BetaNote["author"]> {
const avatar = input.avatarUrl?.startsWith("https://avatars.githubusercontent.com/") ? input.avatarUrl : undefined
return { name: input.name?.trim() || input.login, login: input.login, avatar }
}
/** Parses a conventional commit header `type(scope)!: summary (#123)`; anything else is not a note. */
export function parseSubject(subject: string) {
const match = subject.trim().match(/^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/)
const type = match?.[1]?.toLowerCase()
if (!match || !type || !isKept(type)) return undefined
const pr = match[4]!.match(/\s*\(#(\d+)\)\s*$/)
const summary = (pr ? match[4]!.slice(0, pr.index) : match[4]!).trim()
if (!summary) return undefined
const note: Omit<BetaNote, "sha"> = { type, summary }
const scope = match[2]?.trim()
if (scope) note.scope = scope
if (match[3]) note.breaking = true
if (pr) note.pr = Number(pr[1])
return note
}
function isKept(type: string): type is BetaNote["type"] {
return KEPT.has(type)
}
if (import.meta.main)
console.log(
JSON.stringify(await enrich(embed({ head: process.argv[2] }), { token: process.env.GITHUB_TOKEN }), null, 2),
)
-278
View File
@@ -1,278 +0,0 @@
// A pull request's "### Visual" SVG, rebuilt from an allowlist for the What's new in beta dialog, which shows it only
// as an <img>. The input is parsed as XML and serialized again from scratch: exactly one <svg> root, known shape,
// paint and animation elements and attributes only, references to the document itself only, and no CSS escapes.
export * as BetaVisual from "./beta-visual"
import { DomUtils, parseDocument } from "htmlparser2"
/** The most bytes a visual may take once rebuilt. */
const LIMIT = 8 * 1024
const ELEMENTS = new Set([
"svg",
"g",
"defs",
"symbol",
"use",
"rect",
"circle",
"ellipse",
"line",
"polyline",
"polygon",
"path",
"text",
"tspan",
"title",
"desc",
"clipPath",
"mask",
"linearGradient",
"radialGradient",
"stop",
"style",
"animate",
"animateTransform",
"animateMotion",
"set",
])
const SMIL = new Set(["animate", "animateTransform", "animateMotion", "set"])
const ATTRIBUTES = new Set([
"id",
"class",
"style",
"viewBox",
"preserveAspectRatio",
"width",
"height",
"x",
"y",
"x1",
"y1",
"x2",
"y2",
"cx",
"cy",
"r",
"rx",
"ry",
"fx",
"fy",
"d",
"points",
"pathLength",
"transform",
"transform-origin",
"href",
"color",
"fill",
"fill-opacity",
"fill-rule",
"stroke",
"stroke-width",
"stroke-opacity",
"stroke-linecap",
"stroke-linejoin",
"stroke-dasharray",
"stroke-dashoffset",
"stroke-miterlimit",
"opacity",
"visibility",
"display",
"vector-effect",
"paint-order",
"clip-path",
"clip-rule",
"clipPathUnits",
"mask",
"maskUnits",
"maskContentUnits",
"offset",
"stop-color",
"stop-opacity",
"gradientUnits",
"gradientTransform",
"spreadMethod",
"font-size",
"font-weight",
"font-family",
"letter-spacing",
"text-anchor",
"dominant-baseline",
"attributeName",
"attributeType",
"values",
"from",
"to",
"by",
"dur",
"begin",
"end",
"repeatCount",
"repeatDur",
"keyTimes",
"keySplines",
"keyPoints",
"calcMode",
"additive",
"accumulate",
"restart",
"type",
"rotate",
"path",
])
/** Where text may appear; elsewhere it is formatting whitespace. */
const TEXT = new Set(["text", "tspan", "title", "desc", "style"])
type Node = ReturnType<typeof parseDocument>["children"][number]
/**
* The visual rebuilt twice: `animated` as drawn, and `still` with its SMIL elements, keyframes and animation and
* transition declarations removed, for reduced motion. None when it is not one <svg> root, uses anything outside the
* allowlist that can reach outside the document (a CSS escape, `@import`, a `url()` or `href` that is not `#id`, a
* doctype), or is over 8 KB.
*/
export function sanitize(input: string) {
if (input.length > LIMIT * 4 || /<!(doctype|entity|element|attlist)/i.test(input)) return undefined
const document = parseDocument(input.replace(/^\s*<\?xml[^>]*\?>/, ""), {
xmlMode: true,
recognizeCDATA: true,
recognizeSelfClosing: true,
})
const roots = document.children.filter(
(node) => !DomUtils.isComment(node) && !(DomUtils.isText(node) && !node.data.trim()),
)
const root = roots[0]
if (roots.length !== 1 || !root || !DomUtils.isTag(root) || root.name !== "svg") return undefined
const animated = serialize(root, false)
const still = serialize(root, true)
if (animated === undefined || still === undefined || new TextEncoder().encode(animated).length > LIMIT)
return undefined
return { animated, still }
}
/** One element and its allowed children as markup; undefined anywhere means the whole visual is refused. */
function serialize(node: Node, still: boolean): string | undefined {
if (!DomUtils.isTag(node) || !ELEMENTS.has(node.name)) return ""
const name = node.name
if (SMIL.has(name) && (still || /href$/i.test(node.attribs.attributeName ?? ""))) return ""
const attributes = Object.entries(node.attribs).flatMap(([key, value]): [string, string | undefined][] => {
const attribute = key === "xlink:href" ? "href" : key
if (!ATTRIBUTES.has(attribute)) return []
if (attribute === "href" && !/^#[\w-]+$/.test(value)) return []
if (attribute === "style") return [[attribute, declarations(value, still)]]
return [[attribute, safe(value) ? value : undefined]]
})
// A CSS escape or an outside reference in any value refuses the whole visual, rather than drawing part of it.
if (attributes.some(([, value]) => value === undefined)) return undefined
const children = node.children.map((child) => {
if (DomUtils.isTag(child)) return serialize(child, still)
if (DomUtils.isCDATA(child) || DomUtils.isText(child)) return text(name, DomUtils.textContent(child), still)
return ""
})
if (children.includes(undefined)) return undefined
const open = [
name,
...(name === "svg" ? [`xmlns="http://www.w3.org/2000/svg"`] : []),
...attributes.map(([key, value]) => `${key}="${escape(value)}"`),
].join(" ")
const body = children.join("")
return body ? `<${open}>${body}</${name}>` : `<${open}/>`
}
/** An element's text, kept only where text means something; a stylesheet must pass `stylesheet`. */
function text(element: string, value: string, still: boolean) {
if (!TEXT.has(element)) return ""
if (element !== "style") return escape(value)
const css = stylesheet(value, still)
return css === undefined ? undefined : escape(css)
}
/** A stylesheet that only styles this document: rules, `@keyframes` and `@media`, no escapes or outside urls. */
function stylesheet(value: string, still: boolean) {
const css = value.replace(/\/\*[\s\S]*?\*\//g, "")
if (!safe(css) || /@(?!(-webkit-)?keyframes\b|media\b)/i.test(css)) return undefined
if (!still) return css.trim()
return withoutKeyframes(css)
.replace(/\{([^{}]*)\}/g, (_, body: string) => `{${declarations(body, true)}}`)
.trim()
}
/** A declaration list, without its animations and transitions for the still. */
function declarations(value: string, still: boolean) {
const css = value.replace(/\/\*[\s\S]*?\*\//g, "")
if (!safe(css)) return undefined
if (!still) return css.trim()
return css
.split(";")
.filter((declaration) => !/^\s*(-webkit-)?(animation|transition)(-[\w-]+)?\s*:/i.test(declaration))
.join(";")
.trim()
}
/** The stylesheet without its `@keyframes` blocks, braces balanced. */
function withoutKeyframes(css: string) {
const start = css.search(/@(-webkit-)?keyframes\b/i)
if (start === -1) return css
const open = css.indexOf("{", start)
if (open === -1) return css.slice(0, start)
const end = Array.from(css.slice(open)).reduce(
(state, char, index) => {
if (state.end !== -1) return state
const depth = state.depth + ({ "{": 1, "}": -1 }[char] ?? 0)
return { depth, end: depth === 0 ? open + index + 1 : -1 }
},
{ depth: 0, end: -1 },
).end
return css.slice(0, start) + (end === -1 ? "" : withoutKeyframes(css.slice(end)))
}
/** No CSS escapes, no `@import`, and every `url()` points into the document. */
function safe(value: string) {
return !value.includes("\\") && !/@import/i.test(value) && !/url\(\s*(?!['"]?#)/i.test(value)
}
function escape(value: string) {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
}
-6
View File
@@ -1,6 +0,0 @@
interface ImportMetaEnv {
/** The commit a beta desktop build was built from; undefined in every other build. */
readonly OPENCODE_COMMIT?: string
/** A beta desktop build's commit, recent history and notes as JSON (packages/desktop/script/beta-notes.ts). */
readonly OPENCODE_BETA_NOTES?: string
}
@@ -1,205 +0,0 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Option } from "effect"
import { createSignal, onMount, Show } from "solid-js"
import { SettingsPanel } from "../../../stories/mocks/gui-plugins/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 }
// Fictional people with drawn avatars, so the stories show no real names or photos.
const avatar = (hue: number) =>
`data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" fill="hsl(${hue} 45% 62%)"/><circle cx="24" cy="19" r="9" fill="hsl(${hue} 40% 88%)"/><rect x="9" y="32" width="30" height="20" rx="10" fill="hsl(${hue} 40% 88%)"/></svg>`,
)}`
const people = {
mira: { name: "Mira Okafor", login: "mira", avatar: avatar(18) },
theo: { name: "Theo Lindqvist", login: "theo", avatar: avatar(205) },
juno: { name: "Juno Park", login: "juno", avatar: avatar(140) },
ravi: { name: "ravi-c", login: "ravi-c" },
wren: { name: "Wren Abernathy", login: "wren", avatar: avatar(265) },
} satisfies Record<string, Author>
// Example visuals as a build embeds them from a pull request's "### Visual": animated, and the still for reduced motion.
const visuals = {
dock: {
animated:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="none"><style>.d{transform-box:fill-box;transform-origin:50% 100%;animation:g 4s cubic-bezier(.2,.8,.2,1) infinite}.c{animation:f 4s ease-out infinite}@keyframes g{0%,12%{transform:scaleY(.3);opacity:0}28%,72%{transform:none;opacity:1}88%,100%{transform:scaleY(.3);opacity:0}}@keyframes f{0%,26%{opacity:0}36%,70%{opacity:1}80%,100%{opacity:0}}</style><g class="d"><rect x="56" y="10" width="128" height="30" rx="6" fill="currentColor" fill-opacity=".08" stroke="currentColor" stroke-opacity=".35"/><g class="c" fill="currentColor"><rect x="64" y="17" width="64" height="4" rx="2" fill-opacity=".55"/><rect x="64" y="25" width="40" height="4" rx="2" fill-opacity=".25"/><rect x="140" y="27" width="18" height="8" rx="4" fill-opacity=".25"/><rect x="161" y="27" width="16" height="8" rx="4" fill-opacity=".6"/></g></g><rect x="48" y="40" width="144" height="24" rx="8" fill="currentColor" fill-opacity=".06" stroke="currentColor" stroke-opacity=".5"/><rect x="58" y="50" width="52" height="4" rx="2" fill="currentColor" fill-opacity=".35"/><circle cx="180" cy="52" r="6" fill="currentColor" fill-opacity=".7"/></svg>',
still:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="none"><style>.d{transform-box:fill-box;transform-origin:50% 100%}.c{}</style><g class="d"><rect x="56" y="10" width="128" height="30" rx="6" fill="currentColor" fill-opacity=".08" stroke="currentColor" stroke-opacity=".35"/><g class="c" fill="currentColor"><rect x="64" y="17" width="64" height="4" rx="2" fill-opacity=".55"/><rect x="64" y="25" width="40" height="4" rx="2" fill-opacity=".25"/><rect x="140" y="27" width="18" height="8" rx="4" fill-opacity=".25"/><rect x="161" y="27" width="16" height="8" rx="4" fill-opacity=".6"/></g></g><rect x="48" y="40" width="144" height="24" rx="8" fill="currentColor" fill-opacity=".06" stroke="currentColor" stroke-opacity=".5"/><rect x="58" y="50" width="52" height="4" rx="2" fill="currentColor" fill-opacity=".35"/><circle cx="180" cy="52" r="6" fill="currentColor" fill-opacity=".7"/></svg>',
},
spinners: {
animated:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><style>.s{transform-box:view-box;animation:r 1.6s linear infinite}.p{animation:p 1.6s ease-in-out infinite}@keyframes r{to{transform:rotate(360deg)}}@keyframes p{0%,100%{opacity:.08}50%{opacity:.3}}</style><path class="p" d="M44 36h152" stroke-width="1" stroke-dasharray="2 4"/><g stroke-opacity=".15"><circle cx="60" cy="36" r="11"/><circle cx="120" cy="36" r="11"/><circle cx="180" cy="36" r="11"/></g><g stroke-opacity=".8"><path class="s" style="transform-origin:60px 36px" d="M60 25a11 11 0 0 1 11 11"/><path class="s" style="transform-origin:120px 36px" d="M120 25a11 11 0 0 1 11 11"/><path class="s" style="transform-origin:180px 36px" d="M180 25a11 11 0 0 1 11 11"/></g></svg>',
still:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><style>.s{transform-box:view-box}.p{}</style><path class="p" d="M44 36h152" stroke-width="1" stroke-dasharray="2 4"/><g stroke-opacity=".15"><circle cx="60" cy="36" r="11"/><circle cx="120" cy="36" r="11"/><circle cx="180" cy="36" r="11"/></g><g stroke-opacity=".8"><path class="s" style="transform-origin:60px 36px" d="M60 25a11 11 0 0 1 11 11"/><path class="s" style="transform-origin:120px 36px" d="M120 25a11 11 0 0 1 11 11"/><path class="s" style="transform-origin:180px 36px" d="M180 25a11 11 0 0 1 11 11"/></g></svg>',
},
palette: {
animated:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="currentColor"><style>.t{transform-box:fill-box;transform-origin:0 50%;animation:t 4s steps(6,end) infinite}.h{animation:h 4s ease-out infinite}.o{animation:o 4s ease-out infinite}@keyframes t{0%,8%{transform:scaleX(0)}38%,84%{transform:none}96%,100%{transform:scaleX(0)}}@keyframes h{0%,40%{opacity:0}50%,84%{opacity:1}94%,100%{opacity:0}}@keyframes o{0%,40%{opacity:.45}50%,84%{opacity:1}94%,100%{opacity:.45}}</style><rect x="52" y="4" width="136" height="64" rx="7" fill-opacity=".06" stroke="currentColor" stroke-opacity=".35"/><rect class="t" x="62" y="13" width="44" height="5" rx="2.5" fill-opacity=".6"/><rect x="52" y="24" width="136" height="1" fill-opacity=".15"/><rect x="62" y="31" width="22" height="3" rx="1.5" fill-opacity=".25"/><rect class="h" x="56" y="38" width="128" height="12" rx="4" fill-opacity=".12"/><circle class="o" cx="66" cy="44" r="3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-opacity=".55"/><rect class="o" x="74" y="42" width="52" height="4" rx="2" fill-opacity=".55"/><rect x="62" y="56" width="8" height="4" rx="2" fill-opacity=".2"/><rect x="74" y="56" width="70" height="4" rx="2" fill-opacity=".2"/></svg>',
still:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="currentColor"><style>.t{transform-box:fill-box;transform-origin:0 50%}.h{}.o{}</style><rect x="52" y="4" width="136" height="64" rx="7" fill-opacity=".06" stroke="currentColor" stroke-opacity=".35"/><rect class="t" x="62" y="13" width="44" height="5" rx="2.5" fill-opacity=".6"/><rect x="52" y="24" width="136" height="1" fill-opacity=".15"/><rect x="62" y="31" width="22" height="3" rx="1.5" fill-opacity=".25"/><rect class="h" x="56" y="38" width="128" height="12" rx="4" fill-opacity=".12"/><circle class="o" cx="66" cy="44" r="3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-opacity=".55"/><rect class="o" x="74" y="42" width="52" height="4" rx="2" fill-opacity=".55"/><rect x="62" y="56" width="8" height="4" rx="2" fill-opacity=".2"/><rect x="74" y="56" width="70" height="4" rx="2" fill-opacity=".2"/></svg>',
},
sidebar: {
animated:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="currentColor"><style>.b{animation:b 4.5s cubic-bezier(.45,0,.2,1) infinite}.m{animation:m 4.5s cubic-bezier(.45,0,.2,1) infinite}@keyframes b{0%,15%{transform:translateX(-44px)}40%,70%{transform:none}95%,100%{transform:translateX(-44px)}}@keyframes m{0%,15%{transform:translateX(-44px)}40%,70%{transform:none}95%,100%{transform:translateX(-44px)}}</style><defs><clipPath id="w"><rect x="44" y="6" width="152" height="60" rx="6"/></clipPath></defs><rect x="44" y="6" width="152" height="60" rx="6" fill-opacity=".05" stroke="currentColor" stroke-opacity=".35"/><g clip-path="url(#w)"><g class="b"><rect x="44" y="6" width="44" height="60" fill-opacity=".1"/><rect x="52" y="16" width="26" height="4" rx="2" fill-opacity=".5"/><rect x="52" y="26" width="20" height="4" rx="2" fill-opacity=".25"/><rect x="52" y="36" width="24" height="4" rx="2" fill-opacity=".25"/></g><g class="m"><rect x="88" y="6" width="1" height="60" fill-opacity=".25"/><rect x="100" y="16" width="64" height="5" rx="2.5" fill-opacity=".55"/><rect x="100" y="27" width="84" height="4" rx="2" fill-opacity=".25"/><rect x="100" y="36" width="72" height="4" rx="2" fill-opacity=".25"/><rect x="100" y="45" width="80" height="4" rx="2" fill-opacity=".25"/></g></g></svg>',
still:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 72" fill="currentColor"><style>.b{}.m{}</style><defs><clipPath id="w"><rect x="44" y="6" width="152" height="60" rx="6"/></clipPath></defs><rect x="44" y="6" width="152" height="60" rx="6" fill-opacity=".05" stroke="currentColor" stroke-opacity=".35"/><g clip-path="url(#w)"><g class="b"><rect x="44" y="6" width="44" height="60" fill-opacity=".1"/><rect x="52" y="16" width="26" height="4" rx="2" fill-opacity=".5"/><rect x="52" y="26" width="20" height="4" rx="2" fill-opacity=".25"/><rect x="52" y="36" width="24" height="4" rx="2" fill-opacity=".25"/></g><g class="m"><rect x="88" y="6" width="1" height="60" fill-opacity=".25"/><rect x="100" y="16" width="64" height="5" rx="2.5" fill-opacity=".55"/><rect x="100" y="27" width="84" height="4" rx="2" fill-opacity=".25"/><rect x="100" y="36" width="72" height="4" rx="2" fill-opacity=".25"/><rect x="100" y="45" width="80" height="4" rx="2" fill-opacity=".25"/></g></g></svg>',
},
}
const commits: [
type: "feat" | "fix" | "perf",
summary: string,
pr: number | undefined,
author: Author,
description?: string,
visual?: { animated: string; still: string },
][] = [
[
"feat",
"grow questions and permissions out of the composer",
57103,
people.mira,
"Questions and permission requests grow out of the top of the composer, and fold back into it once answered.",
visuals.dock,
],
[
"fix",
"keep activity spinners in step",
57101,
people.theo,
"Every busy spinner on screen turns in step, wherever it appeared.",
visuals.spinners,
],
[
"feat",
"find settings from the command palette",
57094,
people.juno,
"Typing in the command palette also finds settings, in their own group.",
visuals.palette,
],
["fix", "open activity panel above the capsule", 57088, people.ravi],
[
"feat",
"glide the sidebar open and closed",
57090,
people.wren,
"The sidebar slides in and out, and the content beside it moves with its edge.",
visuals.sidebar,
],
["fix", "disable animated wheel scrolling on linux", 57080, people.wren],
["perf", "cache the model list between sessions", 57050, people.juno],
["feat", "merge projects across servers in the location dialog", 55838, people.theo],
["fix", "fit connect dialog steps to their content under a fixed top edge", 57006, people.wren],
["feat", "add an experimental session inbox to the sidebar", 55647, people.ravi],
["fix", "debounce compatibility skill reloads", 56260, people.theo],
["fix", "restore blue user bubbles with grey pending steers", 57001, people.mira],
["feat", "open images from their place in the message", 56896, people.juno],
["fix", "install each Windows channel into its own directory", 56904, people.wren],
["feat", "show MCP errors from a failed status link", undefined, people.ravi],
["fix", "open dialogs with their input focused", undefined, people.theo],
["fix", "default agent notifications to off", 56990, people.juno],
["feat", "add Focus and Pro interface picker to preferences", 56898, people.mira],
]
const sha = (n: number) => n.toString(16).padStart(4, "0").padEnd(40, "a")
// A build's embedded history: one commit per entry, newest first, with a chore between the notes.
const embedded = (picked: typeof commits): EmbeddedNotes => ({
sha: sha(1000),
commits: [sha(1000), ...picked.flatMap((_, i) => [sha(i * 2 + 1), sha(i * 2 + 2)]), sha(999)].map((value) =>
value.slice(0, 12),
),
notes: picked.map(([type, summary, pr, author, description, visual], i) => ({
type,
summary,
pr,
sha: sha(i * 2 + 1).slice(0, 12),
author,
description,
visual,
})),
})
// What a build without GitHub access embeds: commit authors without avatars, and no visuals.
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 = () =>
dialog.show(() => (
<DialogBetaNotes
notes={notes}
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 (
<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 {
title: "Plugins/Updater/What's new in beta",
id: "plugins-updater-beta-notes",
parameters: {
docs: {
description: {
component:
"The dialog a beta desktop build shows after an update: the features and fixes since the build the user last saw.",
},
},
},
}
/** A typical update: a few features and fixes. */
export const Typical = { render: () => <Fixture picked={commits.slice(0, 8)} /> }
/** 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" />,
}
/** Built without GitHub: commit authors as initials, and kind icons where the visuals would be. */
export const Offline = { render: () => <Fixture picked={offline(commits.slice(0, 8))} /> }
/** Only fixes, so the New section is not shown. */
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 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 Settings or the dialog's View all. */
export const Page = {
render: () => (
<SettingsPanel>
<BetaNotesView embedded={embedded(commits)} open={(href) => window.open(href, "_blank")} />
</SettingsPanel>
),
}
@@ -1,237 +0,0 @@
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"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { prefersReducedMotion } from "@opencode/ui/motion"
import { usePlugin, type DialogHandle, type SetupContext } from "@opencode/plugin/gui"
import {
betaBuild,
decodeEmbeddedNotes,
sliceBetaNotes,
visualSource,
type BetaNote,
type BetaNotes,
} from "./beta-notes"
import type definition from "./index"
/**
* The features and fixes this beta build carries since the commit of the build the user saw last; undefined when there
* are none. Everything comes from the build itself; only the links reach the network, when clicked.
*/
export function readBetaNotes(previousCommit: string | undefined) {
return Option.getOrUndefined(
Option.flatMap(decodeEmbeddedNotes(import.meta.env.OPENCODE_BETA_NOTES), (embedded) =>
sliceBetaNotes(embedded, previousCommit),
),
)
}
/** Opens What's new in beta above any open dialog. */
export function openBetaNotes(ctx: SetupContext<typeof definition>, notes: BetaNotes, previous: string) {
ctx.dialogs.open((dialog) => (
<DialogBetaNotes
notes={notes}
previous={previous}
dialog={dialog}
open={(href) => ctx.system.openExternal(href)}
viewAll={() => {
dialog.close()
ctx.layout.settings("whats-new")
}}
/>
))
}
/** 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 = usePlugin<typeof definition>()
const [fade, setFade] = createSignal({ top: 0, bottom: 0 })
let list!: HTMLDivElement
// The edges feather by as much as there is left to scroll, so they appear and leave without a jump.
const measure = () =>
setFade({
top: Math.min(FADE, list.scrollTop),
bottom: Math.min(FADE, list.scrollHeight - list.clientHeight - list.scrollTop),
})
onMount(() => {
measure()
createResizeObserver(list, measure)
})
return (
<Dialog fit containerClass="w-[min(calc(100vw-32px),520px)]!">
<DialogHeader>
<DialogTitleGroup
title={ctx.t("beta.title")}
description={ctx.plural("beta.since", props.notes.total, { build: betaBuild(props.previous) })}
/>
</DialogHeader>
<DialogBody class="min-h-0">
<div
ref={list}
class="flex max-h-[min(calc(100vh-220px),440px)] flex-col gap-6 overflow-y-auto px-4 pb-2"
style={{
"mask-image":
fade().top || fade().bottom
? `linear-gradient(to bottom, transparent, #000 ${fade().top}px, #000 calc(100% - ${fade().bottom}px), transparent)`
: undefined,
}}
onScroll={measure}
>
<Section title={ctx.t("beta.added")} notes={props.notes.added} open={props.open} />
<Section title={ctx.t("beta.fixed")} notes={props.notes.fixed} open={props.open} />
</div>
</DialogBody>
<DialogFooter>
<Show when={props.notes.more}>
<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")}
</Button>
</DialogFooter>
</Dialog>
)
}
/** How far the list's edges feather once it scrolls. */
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 }) {
return (
<Show when={props.notes.length > 0}>
<section class="flex flex-col gap-3">
<h2 class="m-0 text-12-medium leading-4 text-v2-text-text-muted">{props.title}</h2>
<ul class="m-0 flex list-none flex-col gap-4 p-0">
<For each={props.notes}>
{(note) => (
<li class="flex min-w-0 items-center gap-4">
<div class="flex min-w-0 flex-1 flex-col gap-1 [overflow-wrap:anywhere]">
<p class="m-0 text-[13px] leading-5 font-[500] tracking-[-0.04px] text-v2-text-text-base">
{note.summary}
</p>
<Meta note={note} open={props.open} />
</div>
<Show when={note.visual}>{(visual) => <Visual visual={visual()} label={note.description} />}</Show>
</li>
)}
</For>
</ul>
</section>
</Show>
)
}
/** What changed, drawn: the pull request's looping SVG at half size, only ever as an image, in the icon colour. */
export function Visual(props: { visual: NonNullable<BetaNote["visual"]>; label?: string }) {
const [color, setColor] = createSignal<string>()
let frame!: HTMLSpanElement
// 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">
<Show when={color()}>
{(value) => (
<img
src={visualSource(props.visual, { color: value(), still: prefersReducedMotion() })}
alt={props.label ?? ""}
width="120"
height="36"
draggable={false}
class="block size-full object-contain"
/>
)}
</Show>
</span>
)
}
/** Who made it and its pull request, quietly; `class` sets the type, the dialog's own scale by default. */
export function Meta(props: { note: BetaNote; open: (href: string) => void; class?: string }) {
return (
<Show when={props.note.author || props.note.pr}>
<div
class={`flex min-w-0 flex-wrap items-center gap-x-1.5 ${props.class ?? "text-[12px] leading-4 font-[400] text-v2-text-text-faint"}`}
>
<Show when={props.note.author}>
{(author) => (
<span class="flex min-w-0 items-center gap-1.5">
<Avatar name={author().name} src={author().avatar} />
{author().name}
</span>
)}
</Show>
<Show when={props.note.pr}>
{(pr) => (
<>
<Show when={props.note.author}>
<span aria-hidden="true">·</span>
</Show>
<a
href={pr().url}
class="rounded-[4px] tabular-nums text-v2-text-text-faint underline-offset-2 hover:text-v2-text-text-base hover:underline focus-visible:underline focus-visible:outline-none"
onClick={(event) => {
event.preventDefault()
props.open(pr().url)
}}
>
#{pr().number}
</a>
</>
)}
</Show>
</div>
</Show>
)
}
/** The author's initial, which the avatar fades in over once it loads; an avatar that fails leaves the initial. */
function Avatar(props: { name: string; src?: string }) {
const [loaded, setLoaded] = createSignal(false)
return (
<span
aria-hidden="true"
class="relative grid size-3.5 shrink-0 place-items-center overflow-hidden rounded-full bg-v2-background-bg-layer-02 text-[8px] leading-none font-[530] text-v2-text-text-muted uppercase after:absolute after:inset-0 after:rounded-full after:shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)]"
>
{Array.from(props.name.trim())[0]}
<Show when={props.src}>
{(src) => (
<img
src={src()}
alt=""
loading="lazy"
decoding="async"
draggable={false}
referrerPolicy="no-referrer"
class="absolute inset-0 size-full object-cover opacity-0 transition-opacity duration-200 ease-out"
classList={{ "opacity-100": loaded() }}
onLoad={() => setLoaded(true)}
/>
)}
</Show>
</span>
)
}
@@ -1,135 +0,0 @@
import { expect, test } from "bun:test"
import { Option } from "effect"
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")
const short = (n: number) => sha(n).slice(0, 12)
// A build at commit 30 whose history runs from 30 down to 1, with the note type `notes` gives each commit.
function embedded(input: { notes: (n: number) => "feat" | "fix" | "chore" }): EmbeddedNotes {
const commits = Array.from({ length: 30 }, (_, i) => 30 - i)
return {
sha: sha(30),
commits: commits.map(short),
notes: commits.flatMap((n) => {
const type = input.notes(n)
return type === "chore" ? [] : [{ type, summary: `change ${n}`, sha: short(n) }]
}),
}
}
test("lists the notes after the previous commit, features first, humanized, with pull request links", () => {
const notes = sliceBetaNotes(
{
sha: sha(30),
commits: [short(30), short(29), short(28), short(27), short(26)],
notes: [
{
type: "fix",
summary: "disable animated wheel scrolling on linux.",
pr: 57120,
sha: short(30),
author: { name: "Ada Lovelace", login: "ada", avatar: "https://avatars.githubusercontent.com/u/1?s=48" },
description: "Wheel scrolling on Linux no longer glides.",
},
{ type: "feat", summary: "merge projects across servers", sha: short(29) },
{ type: "perf", summary: "cache the model list", sha: short(28) },
{ type: "feat", summary: "already seen", sha: short(26) },
],
},
sha(27),
)
expect(Option.getOrThrow(notes)).toEqual({
added: [{ sha: short(29), type: "feat", summary: "Merge projects across servers" }],
fixed: [
{
sha: short(30),
type: "fix",
summary: "Disable animated wheel scrolling on linux",
description: "Wheel scrolling on Linux no longer glides.",
pr: { number: 57120, url: "https://github.com/anomalyco/opencode/pull/57120" },
author: { name: "Ada Lovelace", avatar: "https://avatars.githubusercontent.com/u/1?s=48" },
},
{ sha: short(28), type: "perf", summary: "Cache the model list" },
],
total: 3,
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, 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))
expect([sliced.added.length, sliced.fixed.length]).toEqual([...shown])
expect(sliced.total).toBe(total)
expect(sliced.more).toBe(more)
})
test.each([
["no previous commit", undefined, () => "feat" as const],
["the same build", sha(30), () => "feat" as const],
["only chores since the previous commit", sha(25), (n: number) => (n < 25 ? "feat" : "chore")],
] as const)("shows nothing for %s", (_, previous, notes) => {
expect(Option.isNone(sliceBetaNotes(embedded({ notes }), previous))).toBe(true)
})
test("decodes the embedded notes, and none from a build without them", () => {
const value = embedded({ notes: () => "fix" })
expect(Option.getOrThrow(decodeEmbeddedNotes(JSON.stringify(value)))).toEqual(value)
expect(Option.isNone(decodeEmbeddedNotes(undefined))).toBe(true)
})
test.each([
["0.0.0-beta-21180", "21180"],
["0.0.0-beta-21180.2", "21180.2"],
["0.0.0-next-900", "900"],
["1.2.3", "1.2.3"],
])("names build %s", (version, build) => {
expect(betaBuild(version)).toBe(build)
})
test("sources a visual with the page's colour, and its still copy for reduced motion", () => {
const visual = {
animated: '<svg viewBox="0 0 240 72"><rect><animate/></rect></svg>',
still: '<svg viewBox="0 0 240 72"><rect/></svg>',
}
const source = (still: boolean) =>
decodeURIComponent(visualSource(visual, { color: "rgb(1, 2, 3)", still }).replace("data:image/svg+xml,", ""))
expect(source(false)).toBe(
'<svg viewBox="0 0 240 72"><style>svg{color:rgb(1, 2, 3)}</style><rect><animate/></rect></svg>',
)
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)}`)
})
@@ -1,133 +0,0 @@
import { Option, Schema } from "effect"
const REPOSITORY = "https://github.com/anomalyco/opencode"
/** The dialog lists at most this many changes and links the rest. */
const SHOWN = 10
const Note = Schema.Struct({
type: Schema.Literals(["feat", "fix", "perf"]),
summary: Schema.String,
pr: Schema.optional(Schema.Number),
sha: Schema.String,
author: Schema.optional(
Schema.Struct({
name: Schema.String,
login: Schema.optional(Schema.String),
avatar: Schema.optional(Schema.String),
}),
),
description: Schema.optional(Schema.String),
visual: Schema.optional(Schema.Struct({ animated: Schema.String, still: Schema.String })),
})
/** What a beta build embeds (packages/desktop/script/beta-notes.ts), newest first. */
const Embedded = Schema.Struct({
/** The build's commit. */
sha: Schema.String,
/** The short shas of the build's newest commits, merges included. */
commits: Schema.Array(Schema.String),
/** The feat, fix and perf commits among them. */
notes: Schema.Array(Note),
})
export type EmbeddedNotes = typeof Embedded.Type
/** Decodes the notes a beta build embeds as JSON. Other builds embed none. */
export const decodeEmbeddedNotes = Schema.decodeUnknownOption(Schema.fromJsonString(Embedded))
export type BetaNote = {
readonly sha: string
readonly type: "feat" | "fix" | "perf"
readonly summary: string
/** A sentence or two from the pull request about what it does, read out for its visual. */
readonly description?: string
/** A small looping SVG of the change, from the pull request's "### Visual", sanitized at build time. */
readonly visual?: { readonly animated: string; readonly still: string }
readonly pr?: { readonly number: number; readonly url: string }
/** Who made it, with GitHub's public avatar when the build could ask GitHub. */
readonly author?: { readonly name: string; readonly avatar?: string }
}
export type BetaNotes = {
/** New features, newest first. */
readonly added: readonly BetaNote[]
/** Fixes and performance improvements, newest first. */
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
/** 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 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> {
if (!previous || previous === embedded.sha) return Option.none()
const cut = embedded.commits.findIndex((commit) => previous.startsWith(commit))
const position = new Map(embedded.commits.map((commit, index) => [commit, index]))
const notes = cut === -1 ? embedded.notes : embedded.notes.filter((note) => (position.get(note.sha) ?? cut) < cut)
if (notes.length === 0) return Option.none()
const features = notes.filter((note) => note.type === "feat")
const fixes = notes.filter((note) => note.type !== "feat")
// Features get at least half of the list when there are more fixes than fit, fixes whatever features leave.
const added = features.slice(0, Math.max(SHOWN / 2, SHOWN - fixes.length))
const fixed = fixes.slice(0, SHOWN - added.length)
const listed = added.length + fixed.length
return Option.some({
added: added.map(item),
fixed: fixed.map(item),
total: cut === -1 ? listed : notes.length,
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
}
/**
* A visual as an image source. An image never runs scripts or loads anything, and does not inherit the page's colour, so
* `color` sets its currentColor. Reduced motion uses the still copy the build made without the animations.
*/
export function visualSource(visual: NonNullable<BetaNote["visual"]>, input: { color: string; still: boolean }) {
const svg = input.still ? visual.still : visual.animated
return `data:image/svg+xml,${encodeURIComponent(svg.replace(/<svg\b[^>]*>/i, (open) => `${open}<style>svg{color:${input.color}}</style>`))}`
}
function item(note: typeof Note.Type): BetaNote {
const summary = note.summary.trim().replace(/\.$/, "")
return {
sha: note.sha,
type: note.type,
summary: summary.charAt(0).toUpperCase() + summary.slice(1),
description: note.description,
visual: note.visual,
pr: note.pr === undefined ? undefined : { number: note.pr, url: `${REPOSITORY}/pull/${note.pr}` },
author: note.author && { name: note.author.name, avatar: note.author.avatar },
}
}
@@ -1,140 +0,0 @@
import { createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Option } from "effect"
import { Button } from "@opencode/ui/button"
import { usePlugin } from "@opencode/plugin/gui"
import { Meta, Visual } from "./beta-dialog"
import { allBetaNotes, betaHistory, decodeEmbeddedNotes, type BetaNote, type EmbeddedNotes } from "./beta-notes"
import type definition from "./index"
/** Settings > What's new in beta: every change this build carries, in the host's settings list markup. */
export default function BetaNotesPage() {
const ctx = usePlugin<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 = usePlugin<typeof definition>()
// Below 640px a row's control slot wraps under its title, so narrow windows leave the visuals to the dialog.
const wide = createMediaQuery("(min-width: 640px)")
const watch = createWatch()
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 min-w-0 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-muted"
class="whitespace-nowrap"
onClick={() => props.open(betaHistory(embedded()))}
>
{ctx.t("beta.github")}
</Button>
</div>
</div>
</div>
<div class="settings-tab-body settings-tab-body--sectioned">
<Notes title={ctx.t("beta.added")} notes={notes.added} open={props.open} wide={wide()} watch={watch} />
<Notes title={ctx.t("beta.fixed")} notes={notes.fixed} open={props.open} wide={wide()} watch={watch} />
</div>
</>
)
}}
</Show>
)
}
type Watch = (element: Element, inView: (value: boolean) => void) => () => void
function Notes(props: {
title: string
notes: readonly BetaNote[]
open: (href: string) => void
wide: boolean
watch: Watch
}) {
return (
<Show when={props.notes.length > 0}>
<section class="settings-section" aria-label={props.title}>
<h3 class="settings-section-title">{props.title}</h3>
<div data-component="settings-list" data-variant="catalog">
<For each={props.notes}>
{(note) => (
<div data-component="settings-row">
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title" class="[overflow-wrap:anywhere]">
{note.summary}
</div>
<Show when={note.author || note.pr}>
<div data-slot="settings-row-description">
<Meta note={note} open={props.open} class="" />
</div>
</Show>
</div>
<Show when={props.wide && note.visual}>
{(visual) => <RowVisual visual={visual()} label={note.description} watch={props.watch} />}
</Show>
</div>
)}
</For>
</div>
</section>
</Show>
)
}
/** A row's visual in its control slot, mounted only while the row is near the viewport so off-screen loops stop. */
function RowVisual(props: { visual: NonNullable<BetaNote["visual"]>; label?: string; watch: Watch }) {
const [visible, setVisible] = createSignal(false)
let slot!: HTMLDivElement
onMount(() => onCleanup(props.watch(slot, setVisible)))
return (
// Inline, so the host's unlayered control-slot width cannot collapse the space before the visual mounts.
<div ref={slot} data-slot="settings-row-control" style={{ width: "120px", height: "36px" }}>
<Show when={visible()}>
<Visual visual={props.visual} label={props.label} />
</Show>
</div>
)
}
/** One IntersectionObserver for every row on the page; each watched element hears when it nears or leaves the viewport. */
function createWatch(): Watch {
const rows = new Map<Element, (value: boolean) => void>()
const observer = new IntersectionObserver(
(entries) => entries.forEach((entry) => rows.get(entry.target)?.(entry.isIntersecting)),
{ rootMargin: "200px 0px" },
)
onCleanup(() => observer.disconnect())
return (element, inView) => {
rows.set(element, inView)
observer.observe(element)
return () => {
rows.delete(element)
observer.unobserve(element)
}
}
}
@@ -37,13 +37,4 @@ export default {
"releaseNotes.action.next": "Next",
"releaseNotes.action.hideFuture": "Don't show these in the future",
"releaseNotes.media.alt": "Release preview",
"beta.title": "What's new",
"beta.since.one": "Since build {{build}} · {{count}} change",
"beta.since.other": "Since build {{build}} · {{count}} changes",
"beta.added": "New",
"beta.fixed": "Fixes",
"beta.viewAll": "View all",
"beta.page.description": "Every change in this build.",
"beta.github": "History on GitHub",
"beta.done": "Got it",
}
+2 -3
View File
@@ -6,7 +6,7 @@ import en from "./i18n/en"
const ReleaseNotes = Schema.Struct({ enabled: Schema.Boolean })
const Seen = Schema.Struct({ version: Schema.optional(Schema.String), commit: Schema.optional(Schema.String) })
const Seen = Schema.Struct({ version: Schema.optional(Schema.String) })
export default Plugin.define({
stores: {
@@ -23,8 +23,7 @@ export default Plugin.define({
},
},
),
// The version whose What's New was last shown or skipped, and on beta its commit; stored before under the app's
// own key.
// The version whose What's New was last shown or skipped; stored before under the app's own key.
seen: Store.global(Seen, {}, "highlights.v1"),
},
i18n: builtinCatalog({
+5 -54
View File
@@ -24,7 +24,6 @@ import type definition from "./index"
const setup: Plugin.Setup<typeof definition> = (ctx) => {
whatsNew(ctx)
betaNotes(ctx)
if (!ctx.desktop) return webUpdates(ctx)
const updater = ctx.main(Updater)
@@ -125,35 +124,8 @@ const setup: Plugin.Setup<typeof definition> = (ctx) => {
}
/**
* What's new in beta as a settings page, to come back to 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: "outline-list-changelog",
group: "about",
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>
),
})
}
/**
* 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.
* What's New after an update, on every platform: the release highlights since the version last seen. The first run
* and a disabled What's New only remember the version.
*/
function whatsNew(ctx: SetupContext<typeof definition>) {
const seen = ctx.stores.seen
@@ -161,10 +133,7 @@ function whatsNew(ctx: SetupContext<typeof definition>) {
const start = initWhatsNew({
version: ctx.app.version,
commit: import.meta.env.OPENCODE_COMMIT,
channel: ctx.app.channel === "beta" ? "beta" : "stable",
previousVersion: seen.value.version,
previousCommit: seen.value.commit,
enabled: ctx.stores.releaseNotes.value.enabled,
})
@@ -182,9 +151,6 @@ function whatsNew(ctx: SetupContext<typeof definition>) {
if (effect.type === "mark-seen")
return seen.update((draft) => {
draft.version = effect.version
// Beta remembers the build's commit even when nothing shows, so the next update lists what came after it.
if (effect.commit) draft.commit = effect.commit
})
if (effect.type === "abort-load") {
@@ -194,7 +160,7 @@ function whatsNew(ctx: SetupContext<typeof definition>) {
return
}
if (effect.type === "open") return void open(effect.content, effect.previousVersion)
if (effect.type === "open") return void open(effect.content)
timer = setTimeout(() => dispatch({ type: "load.timed-out" }), effect.timeoutMs)
void load(effect).then(
@@ -204,13 +170,6 @@ function whatsNew(ctx: SetupContext<typeof definition>) {
}
const load = async (effect: Extract<WhatsNewEffect, { type: "load" }>): Promise<WhatsNewContent | undefined> => {
if (effect.channel === "beta") {
const { readBetaNotes } = await import("./beta-dialog")
const notes = readBetaNotes(effect.previousCommit)
return notes && { channel: "beta", notes }
}
const { loadWhatsNewHighlights } = await import("./whats-new")
const highlights = await loadWhatsNewHighlights({
@@ -219,19 +178,11 @@ function whatsNew(ctx: SetupContext<typeof definition>) {
signal: controller.signal,
})
return { channel: "stable", highlights }
return { highlights }
}
// The chunk loaded with the notes, so this resolves at once.
const open = async (content: WhatsNewContent, previousVersion: string) => {
if (content.channel === "beta") {
const { openBetaNotes } = await import("./beta-dialog")
if (!ctx.signal.aborted) openBetaNotes(ctx, content.notes, previousVersion)
return
}
const open = async (content: WhatsNewContent) => {
const { openWhatsNew } = await import("./whats-new")
if (!ctx.signal.aborted) openWhatsNew(ctx, content.highlights)
@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test"
import type { BetaNotes } from "./beta-notes"
import {
initWhatsNew,
transitionWhatsNew,
@@ -12,40 +11,17 @@ import {
const upgraded: WhatsNewInit = {
version: "1.10.0",
commit: undefined,
channel: "stable",
previousVersion: "1.9.1",
previousCommit: undefined,
enabled: true,
}
const beta: WhatsNewInit = {
...upgraded,
version: "1.10.0-beta.2",
commit: "bbb",
channel: "beta",
previousVersion: "1.10.0-beta.1",
previousCommit: "aaa",
}
const highlights: WhatsNewContent = {
channel: "stable",
highlights: [{ title: "Split panes", description: "Work side by side." }],
}
const notes: WhatsNewContent = {
channel: "beta",
notes: {
added: [],
fixed: [{ sha: "bbb", type: "fix", summary: "Fixed a crash" }],
total: 1,
more: false,
} satisfies BetaNotes,
}
const loading = (input: WhatsNewInit) => initWhatsNew(input).state
const markSeen: WhatsNewEffect = { type: "mark-seen", version: "1.10.0", commit: undefined }
const markSeen: WhatsNewEffect = { type: "mark-seen", version: "1.10.0" }
describe("whats new", () => {
test("turning release notes off while they load remembers the release and never opens the dialog", () => {
@@ -66,19 +42,13 @@ describe("whats new", () => {
expect(transitionWhatsNew(timedOut.state, { type: "loaded", content: highlights }).effects).toEqual([])
})
test("both channels remember the release as they open its notes, and neither does on dispose", () => {
test("remembers the release as it opens its notes, and not on dispose", () => {
expect(transitionWhatsNew(loading(upgraded), { type: "loaded", content: highlights }).effects).toEqual([
{ type: "abort-load" },
markSeen,
{ type: "open", content: highlights, previousVersion: "1.9.1" },
])
expect(transitionWhatsNew(loading(beta), { type: "loaded", content: notes }).effects).toEqual([
{ type: "abort-load" },
{ type: "mark-seen", version: "1.10.0-beta.2", commit: "bbb" },
{ type: "open", content: notes, previousVersion: "1.10.0-beta.1" },
{ type: "open", content: highlights },
])
expect(transitionWhatsNew(loading(upgraded), { type: "disposed" }).effects).toEqual([{ type: "abort-load" }])
expect(transitionWhatsNew(loading(beta), { type: "disposed" }).effects).toEqual([{ type: "abort-load" }])
})
test.each([
@@ -86,11 +56,6 @@ describe("whats new", () => {
{ name: "the version seen", input: { ...upgraded, previousVersion: "1.10.0" }, effects: [] },
{ name: "the first run", input: { ...upgraded, previousVersion: undefined }, effects: [markSeen] },
{ name: "release notes off", input: { ...upgraded, enabled: false }, effects: [markSeen] },
{
name: "release notes off on beta",
input: { ...beta, enabled: false },
effects: [{ type: "mark-seen", version: "1.10.0-beta.2", commit: "bbb" }],
},
])("$name loads nothing", (row) => {
const start = initWhatsNew(row.input)
@@ -98,27 +63,18 @@ describe("whats new", () => {
expect(whatsNewLoading(start.state)).toBe(false)
})
test("an upgrade loads the channel's notes with a bounded timeout", () => {
const start = initWhatsNew(beta)
test("an upgrade loads the highlights with a bounded timeout", () => {
const start = initWhatsNew(upgraded)
expect(start).toEqual({
state: { status: "loading", version: "1.10.0-beta.2", commit: "bbb", previousVersion: "1.10.0-beta.1" },
effects: [
{
type: "load",
channel: "beta",
previousVersion: "1.10.0-beta.1",
previousCommit: "aaa",
version: "1.10.0-beta.2",
timeoutMs: WHATS_NEW_TIMEOUT_MS,
},
],
state: { status: "loading", version: "1.10.0" },
effects: [{ type: "load", previousVersion: "1.9.1", version: "1.10.0", timeoutMs: WHATS_NEW_TIMEOUT_MS }],
})
expect(whatsNewLoading(start.state)).toBe(true)
})
test("nothing to show remembers the release; a failed load does not", () => {
const empty = [undefined, { channel: "stable" as const, highlights: [] }]
const empty = [undefined, { highlights: [] }]
empty.forEach((content) =>
expect(transitionWhatsNew(loading(upgraded), { type: "loaded", content }).effects).toEqual([
@@ -1,33 +1,22 @@
import type { BetaNotes } from "./beta-notes"
import type { Highlight } from "./whats-new"
/** Bounds the dialog's chunk and the changelog request, so a stalled connection never opens a dialog mid-session. */
export const WHATS_NEW_TIMEOUT_MS = 15_000
/** What the dialog shows: on beta the notes the build embeds, elsewhere the changelog's highlights. */
export type WhatsNewContent =
| { readonly channel: "beta"; readonly notes: BetaNotes }
| { readonly channel: "stable"; readonly highlights: readonly Highlight[] }
/** What the dialog shows: the changelog's highlights. */
export type WhatsNewContent = { readonly highlights: readonly Highlight[] }
export type WhatsNewInit = {
readonly version: string | undefined
readonly commit: string | undefined
readonly channel: WhatsNewContent["channel"]
readonly previousVersion: string | undefined
readonly previousCommit: string | undefined
/** Release notes are on in Settings. */
readonly enabled: boolean
}
/** What's New after an update; it only moves forward, so results match on the phase. */
export type WhatsNewState =
/** The dialog's chunk, and on stable the changelog, load for the release `version`. */
| {
readonly status: "loading"
readonly version: string
readonly commit: string | undefined
readonly previousVersion: string
}
/** The dialog's chunk and the changelog load for the release `version`. */
| { readonly status: "loading"; readonly version: string }
/** Nothing more happens this start: the dialog opened, there was nothing to show, or the load failed or ended. */
| { readonly status: "done" }
@@ -43,17 +32,15 @@ export type WhatsNewEvent =
export type WhatsNewEffect =
| {
readonly type: "load"
readonly channel: WhatsNewContent["channel"]
readonly previousVersion: string
readonly previousCommit: string | undefined
readonly version: string
readonly timeoutMs: number
}
/** Ends the load's timeout and request. */
| { readonly type: "abort-load" }
/** Remembers the release, so the next start shows nothing. */
| { readonly type: "mark-seen"; readonly version: string; readonly commit: string | undefined }
| { readonly type: "open"; readonly content: WhatsNewContent; readonly previousVersion: string }
| { readonly type: "mark-seen"; readonly version: string }
| { readonly type: "open"; readonly content: WhatsNewContent }
export type WhatsNewTransition = { readonly state: WhatsNewState; readonly effects: readonly WhatsNewEffect[] }
@@ -63,24 +50,21 @@ const NONE: readonly WhatsNewEffect[] = []
/**
* Decides at startup: an unchanged version shows nothing; the first run and disabled release notes only remember the
* version; otherwise the channel's notes load.
* version; otherwise the highlights load.
*/
export function initWhatsNew(input: WhatsNewInit): WhatsNewTransition {
const version = input.version
if (!version || input.previousVersion === version) return { state: DONE, effects: NONE }
if (!input.previousVersion || !input.enabled)
return { state: DONE, effects: [{ type: "mark-seen", version, commit: input.commit }] }
if (!input.previousVersion || !input.enabled) return { state: DONE, effects: [{ type: "mark-seen", version }] }
return {
state: { status: "loading", version, commit: input.commit, previousVersion: input.previousVersion },
state: { status: "loading", version },
effects: [
{
type: "load",
channel: input.channel,
previousVersion: input.previousVersion,
previousCommit: input.previousCommit,
version,
timeoutMs: WHATS_NEW_TIMEOUT_MS,
},
@@ -95,19 +79,15 @@ export function transitionWhatsNew(state: WhatsNewState, event: WhatsNewEvent):
if (event.type === "load.failed" || event.type === "load.timed-out" || event.type === "disposed")
return { state: DONE, effects: [{ type: "abort-load" }] }
const seen: readonly WhatsNewEffect[] = [
{ type: "abort-load" },
{ type: "mark-seen", version: state.version, commit: state.commit },
]
const seen: readonly WhatsNewEffect[] = [{ type: "abort-load" }, { type: "mark-seen", version: state.version }]
const content = event.type === "loaded" ? event.content : undefined
if (!content || (content.channel === "stable" && content.highlights.length === 0))
return { state: DONE, effects: seen }
if (!content || content.highlights.length === 0) return { state: DONE, effects: seen }
return {
state: DONE,
effects: [...seen, { type: "open", content, previousVersion: state.previousVersion }],
effects: [...seen, { type: "open", content }],
}
}