wip: highlights

This commit is contained in:
adamelmore
2026-01-26 15:34:59 -06:00
parent 53ac394c68
commit ccc7aa49c3
5 changed files with 82 additions and 29 deletions

View File

@@ -2,11 +2,11 @@ import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSettings } from "@/context/settings"
export type Highlight = { export type Highlight = {
title: string title: string
description: string description: string
tag?: string
media?: { media?: {
type: "image" | "video" type: "image" | "video"
src: string src: string
@@ -16,6 +16,7 @@ export type Highlight = {
export function DialogReleaseNotes(props: { highlights: Highlight[] }) { export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
const dialog = useDialog() const dialog = useDialog()
const settings = useSettings()
const [index, setIndex] = createSignal(0) const [index, setIndex] = createSignal(0)
const total = () => props.highlights.length const total = () => props.highlights.length
@@ -34,9 +35,20 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
dialog.close() dialog.close()
} }
function handleDisable() {
settings.general.setReleaseNotes(false)
handleClose()
}
let focusTrap: HTMLDivElement | undefined let focusTrap: HTMLDivElement | undefined
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault()
handleClose()
return
}
if (!paged()) return if (!paged()) return
if (e.key === "ArrowLeft" && !isFirst()) { if (e.key === "ArrowLeft" && !isFirst()) {
e.preventDefault() e.preventDefault()
@@ -50,8 +62,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
onMount(() => { onMount(() => {
focusTrap?.focus() focusTrap?.focus()
if (!paged()) return
document.addEventListener("keydown", handleKeyDown) document.addEventListener("keydown", handleKeyDown)
onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
}) })
@@ -72,14 +82,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
<div class="flex flex-col gap-2 pt-22"> <div class="flex flex-col gap-2 pt-22">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h1 class="text-16-medium text-text-strong">{feature()?.title ?? ""}</h1> <h1 class="text-16-medium text-text-strong">{feature()?.title ?? ""}</h1>
{feature()?.tag && (
<span
class="text-12-medium text-text-weak px-1.5 py-0.5 bg-surface-base rounded-sm border border-border-weak-base"
style={{ "border-width": "0.5px" }}
>
{feature()!.tag}
</span>
)}
</div> </div>
<p class="text-14-regular text-text-base">{feature()?.description ?? ""}</p> <p class="text-14-regular text-text-base">{feature()?.description ?? ""}</p>
</div> </div>
@@ -89,7 +91,7 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
{/* Bottom section - buttons and indicators (fixed position) */} {/* Bottom section - buttons and indicators (fixed position) */}
<div class="flex flex-col gap-12"> <div class="flex flex-col gap-12">
<div class="flex items-center gap-3"> <div class="flex flex-col items-start gap-3">
{isLast() ? ( {isLast() ? (
<Button variant="primary" size="large" onClick={handleClose}> <Button variant="primary" size="large" onClick={handleClose}>
Get started Get started
@@ -99,6 +101,10 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
Next Next
</Button> </Button>
)} )}
<Button variant="ghost" size="small" onClick={handleDisable}>
Don't show these in the future
</Button>
</div> </div>
{paged() && ( {paged() && (

View File

@@ -214,6 +214,23 @@ export const SettingsGeneral: Component = () => {
</div> </div>
</div> </div>
{/* Updates Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
>
<Switch
checked={settings.general.releaseNotes()}
onChange={(checked) => settings.general.setReleaseNotes(checked)}
/>
</SettingsRow>
</div>
</div>
{/* Sound effects Section */} {/* Sound effects Section */}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3> <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>

View File

@@ -3,10 +3,11 @@ import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes" import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes"
const CHANGELOG_URL = "https://dev.opencode.ai/changelog.json" const CHANGELOG_URL = "https://opencode.ai/changelog.json"
type Store = { type Store = {
version?: string version?: string
@@ -18,7 +19,7 @@ type ParsedRelease = {
} }
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null return typeof value === "object" && value !== null && !Array.isArray(value)
} }
function getText(value: unknown): string | undefined { function getText(value: unknown): string | undefined {
@@ -40,14 +41,14 @@ function normalizeVersion(value: string | undefined) {
function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined { function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined {
if (!isRecord(value)) return if (!isRecord(value)) return
const type = getText(value.type)?.toLowerCase() const type = getText(value.type)?.toLowerCase()
const src = getText(value.src) const src = getText(value.src) ?? getText(value.url)
if (!src) return if (!src) return
if (type !== "image" && type !== "video") return if (type !== "image" && type !== "video") return
return { type, src, alt } return { type, src, alt }
} }
function parseHighlight(value: unknown, tag: string | undefined): Highlight | undefined { function parseHighlight(value: unknown): Highlight | undefined {
if (!isRecord(value)) return if (!isRecord(value)) return
const title = getText(value.title) const title = getText(value.title)
@@ -57,7 +58,7 @@ function parseHighlight(value: unknown, tag: string | undefined): Highlight | un
if (!description) return if (!description) return
const media = parseMedia(value.media, title) const media = parseMedia(value.media, title)
return { title, description, tag, media } return { title, description, media }
} }
function parseRelease(value: unknown): ParsedRelease | undefined { function parseRelease(value: unknown): ParsedRelease | undefined {
@@ -70,11 +71,18 @@ function parseRelease(value: unknown): ParsedRelease | undefined {
const highlights = value.highlights.flatMap((group) => { const highlights = value.highlights.flatMap((group) => {
if (!isRecord(group)) return [] if (!isRecord(group)) return []
if (!Array.isArray(group.items)) return []
const source = getText(group.source) const source = getText(group.source)
return group.items if (!source) return []
.map((item) => parseHighlight(item, source)) if (!source.toLowerCase().includes("desktop")) return []
.filter((item): item is Highlight => item !== undefined)
if (Array.isArray(group.items)) {
return group.items.map((item) => parseHighlight(item)).filter((item): item is Highlight => item !== undefined)
}
const item = parseHighlight(group)
if (!item) return []
return [item]
}) })
return { tag, highlights } return { tag, highlights }
@@ -108,10 +116,17 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p
return index === -1 ? releases.length : index return index === -1 ? releases.length : index
})() })()
return releases const highlights = releases.slice(start, end).flatMap((release) => release.highlights)
.slice(start, end) const seen = new Set<string>()
.flatMap((release) => release.highlights) const unique = highlights.filter((highlight) => {
.slice(0, 3) const key = [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join(
"\n",
)
if (seen.has(key)) return false
seen.add(key)
return true
})
return unique.slice(0, 3)
} }
export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({ export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({
@@ -120,6 +135,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
init: () => { init: () => {
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const settings = useSettings()
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined })) const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
const [from, setFrom] = createSignal<string | undefined>(undefined) const [from, setFrom] = createSignal<string | undefined>(undefined)
@@ -135,6 +151,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
createEffect(() => { createEffect(() => {
if (state.started) return if (state.started) return
if (!ready()) return if (!ready()) return
if (!settings.ready()) return
if (!platform.version) return if (!platform.version) return
state.started = true state.started = true
@@ -149,6 +166,11 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
setFrom(previous) setFrom(previous)
setTo(platform.version) setTo(platform.version)
if (!settings.general.releaseNotes()) {
markSeen()
return
}
const fetcher = platform.fetch ?? fetch const fetcher = platform.fetch ?? fetch
const controller = new AbortController() const controller = new AbortController()
onCleanup(() => { onCleanup(() => {
@@ -182,10 +204,8 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
} }
const timer = setTimeout(() => { const timer = setTimeout(() => {
dialog.show( markSeen()
() => <DialogReleaseNotes highlights={highlights} />, dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
() => markSeen(),
)
}, 500) }, 500)
setTimer(timer) setTimer(timer)
}) })

View File

@@ -18,6 +18,7 @@ export interface SoundSettings {
export interface Settings { export interface Settings {
general: { general: {
autoSave: boolean autoSave: boolean
releaseNotes: boolean
} }
appearance: { appearance: {
fontSize: number fontSize: number
@@ -34,6 +35,7 @@ export interface Settings {
const defaultSettings: Settings = { const defaultSettings: Settings = {
general: { general: {
autoSave: true, autoSave: true,
releaseNotes: true,
}, },
appearance: { appearance: {
fontSize: 14, fontSize: 14,
@@ -97,6 +99,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setAutoSave(value: boolean) { setAutoSave(value: boolean) {
setStore("general", "autoSave", value) setStore("general", "autoSave", value)
}, },
releaseNotes: createMemo(() => store.general?.releaseNotes ?? defaultSettings.general.releaseNotes),
setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value)
},
}, },
appearance: { appearance: {
fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize), fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize),

View File

@@ -525,6 +525,7 @@ export const dict = {
"settings.general.section.appearance": "Appearance", "settings.general.section.appearance": "Appearance",
"settings.general.section.notifications": "System notifications", "settings.general.section.notifications": "System notifications",
"settings.general.section.updates": "Updates",
"settings.general.section.sounds": "Sound effects", "settings.general.section.sounds": "Sound effects",
"settings.general.row.language.title": "Language", "settings.general.row.language.title": "Language",
@@ -535,6 +536,9 @@ export const dict = {
"settings.general.row.theme.description": "Customise how OpenCode is themed.", "settings.general.row.theme.description": "Customise how OpenCode is themed.",
"settings.general.row.font.title": "Font", "settings.general.row.font.title": "Font",
"settings.general.row.font.description": "Customise the mono font used in code blocks", "settings.general.row.font.description": "Customise the mono font used in code blocks",
"settings.general.row.releaseNotes.title": "Release notes",
"settings.general.row.releaseNotes.description": "Show What's New popups after updates",
"font.option.ibmPlexMono": "IBM Plex Mono", "font.option.ibmPlexMono": "IBM Plex Mono",
"font.option.cascadiaCode": "Cascadia Code", "font.option.cascadiaCode": "Cascadia Code",
"font.option.firaCode": "Fira Code", "font.option.firaCode": "Fira Code",