summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context
diff options
context:
space:
mode:
authoradamelmore <[email protected]>2026-01-26 07:17:46 -0600
committeradamelmore <[email protected]>2026-01-26 15:36:56 -0600
commit8b6484ac1a4ff3cfc2c98c38eaeb6e5bf0b4fab4 (patch)
tree25f99116f5d56e6c9d512b79253d2cb57ade2dee /packages/app/src/context
parentc1e840b9b229889c1a85b479b3cd601d6f313c87 (diff)
downloadopencode-8b6484ac1a4ff3cfc2c98c38eaeb6e5bf0b4fab4.tar.gz
opencode-8b6484ac1a4ff3cfc2c98c38eaeb6e5bf0b4fab4.zip
wip: highlights
Diffstat (limited to 'packages/app/src/context')
-rw-r--r--packages/app/src/context/highlights.tsx200
1 files changed, 200 insertions, 0 deletions
diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx
new file mode 100644
index 000000000..4c2e8c838
--- /dev/null
+++ b/packages/app/src/context/highlights.tsx
@@ -0,0 +1,200 @@
+import { createEffect, createSignal, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { usePlatform } from "@/context/platform"
+import { persisted } from "@/utils/persist"
+import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes"
+
+const CHANGELOG_URL = "https://opencode.ai/changelog.json"
+
+type Store = {
+ version?: string
+}
+
+type ParsedRelease = {
+ tag?: string
+ highlights: Highlight[]
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null
+}
+
+function getText(value: unknown): string | undefined {
+ if (typeof value === "string") {
+ const text = value.trim()
+ return text.length > 0 ? text : undefined
+ }
+
+ if (typeof value === "number") return String(value)
+ return
+}
+
+function normalizeVersion(value: string | undefined) {
+ const text = value?.trim()
+ if (!text) return
+ return text.startsWith("v") || text.startsWith("V") ? text.slice(1) : text
+}
+
+function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined {
+ if (!isRecord(value)) return
+ const type = getText(value.type)?.toLowerCase()
+ const src = getText(value.src)
+ if (!src) return
+ if (type !== "image" && type !== "video") return
+
+ return { type, src, alt }
+}
+
+function parseHighlight(value: unknown, tag: string | undefined): Highlight | undefined {
+ if (!isRecord(value)) return
+
+ const title = getText(value.title)
+ if (!title) return
+
+ const description = getText(value.description) ?? getText(value.shortDescription)
+ if (!description) return
+
+ const media = parseMedia(value.media, title)
+ return { title, description, tag, media }
+}
+
+function parseRelease(value: unknown): ParsedRelease | undefined {
+ if (!isRecord(value)) return
+ const tag = getText(value.tag) ?? getText(value.tag_name) ?? getText(value.name)
+
+ if (!Array.isArray(value.highlights)) {
+ return { tag, highlights: [] }
+ }
+
+ const highlights = value.highlights.flatMap((group) => {
+ if (!isRecord(group)) return []
+ if (!Array.isArray(group.items)) return []
+ const source = getText(group.source)
+ return group.items
+ .map((item) => parseHighlight(item, source))
+ .filter((item): item is Highlight => item !== undefined)
+ })
+
+ return { tag, highlights }
+}
+
+function parseChangelog(value: unknown): ParsedRelease[] | undefined {
+ if (!isRecord(value)) return
+ if (!Array.isArray(value.releases)) return
+
+ return value.releases.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined)
+}
+
+function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; previous?: string }) {
+ const current = normalizeVersion(input.current)
+ const previous = normalizeVersion(input.previous)
+ const releases = input.releases
+
+ const start = (() => {
+ if (!current) return 0
+ const index = releases.findIndex((release) => normalizeVersion(release.tag) === current)
+ return index === -1 ? 0 : index
+ })()
+
+ const end = (() => {
+ if (!previous) return releases.length
+ const index = releases.findIndex((release, i) => i >= start && normalizeVersion(release.tag) === previous)
+ return index === -1 ? releases.length : index
+ })()
+
+ return releases
+ .slice(start, end)
+ .flatMap((release) => release.highlights)
+ .slice(0, 3)
+}
+
+export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({
+ name: "Highlights",
+ gate: false,
+ init: () => {
+ const platform = usePlatform()
+ const dialog = useDialog()
+ const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
+
+ const [from, setFrom] = createSignal<string | undefined>(undefined)
+ const [to, setTo] = createSignal<string | undefined>(undefined)
+ const [timer, setTimer] = createSignal<ReturnType<typeof setTimeout> | undefined>(undefined)
+ const state = { started: false }
+
+ const markSeen = () => {
+ if (!platform.version) return
+ setStore("version", platform.version)
+ }
+
+ createEffect(() => {
+ if (state.started) return
+ if (!ready()) return
+ if (!platform.version) return
+ state.started = true
+
+ const previous = store.version
+ if (!previous) {
+ setStore("version", platform.version)
+ return
+ }
+
+ if (previous === platform.version) return
+
+ setFrom(previous)
+ setTo(platform.version)
+
+ const fetcher = platform.fetch ?? fetch
+ const controller = new AbortController()
+ onCleanup(() => {
+ controller.abort()
+ const id = timer()
+ if (id === undefined) return
+ clearTimeout(id)
+ })
+
+ fetcher(CHANGELOG_URL, {
+ signal: controller.signal,
+ headers: { Accept: "application/json" },
+ })
+ .then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
+ .then((json) => {
+ if (!json) return
+ const releases = parseChangelog(json)
+ if (!releases) return
+ const highlights = sliceHighlights({
+ releases,
+ current: platform.version,
+ previous,
+ })
+
+ if (controller.signal.aborted) return
+
+ if (highlights.length === 0) {
+ markSeen()
+ return
+ }
+
+ const timer = setTimeout(() => {
+ dialog.show(
+ () => <DialogReleaseNotes highlights={highlights} />,
+ () => markSeen(),
+ )
+ }, 500)
+ setTimer(timer)
+ })
+ .catch(() => undefined)
+ })
+
+ return {
+ ready,
+ from,
+ to,
+ get last() {
+ return store.version
+ },
+ markSeen,
+ }
+ },
+})