diff options
| author | adamelmore <[email protected]> | 2026-01-26 07:17:46 -0600 |
|---|---|---|
| committer | adamelmore <[email protected]> | 2026-01-26 15:36:56 -0600 |
| commit | 8b6484ac1a4ff3cfc2c98c38eaeb6e5bf0b4fab4 (patch) | |
| tree | 25f99116f5d56e6c9d512b79253d2cb57ade2dee | |
| parent | c1e840b9b229889c1a85b479b3cd601d6f313c87 (diff) | |
| download | opencode-8b6484ac1a4ff3cfc2c98c38eaeb6e5bf0b4fab4.tar.gz opencode-8b6484ac1a4ff3cfc2c98c38eaeb6e5bf0b4fab4.zip | |
wip: highlights
| -rwxr-xr-x | packages/app/public/release/release-example.mp4 | bin | 2308074 -> 0 bytes | |||
| -rw-r--r-- | packages/app/public/release/release-share.png | bin | 44076 -> 0 bytes | |||
| -rw-r--r-- | packages/app/src/app.tsx | 7 | ||||
| -rw-r--r-- | packages/app/src/components/dialog-release-notes.tsx | 210 | ||||
| -rw-r--r-- | packages/app/src/components/release-notes-handler.tsx | 31 | ||||
| -rw-r--r-- | packages/app/src/context/highlights.tsx | 200 | ||||
| -rw-r--r-- | packages/app/src/lib/release-notes.ts | 53 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 2 |
8 files changed, 227 insertions, 276 deletions
diff --git a/packages/app/public/release/release-example.mp4 b/packages/app/public/release/release-example.mp4 Binary files differdeleted file mode 100755 index 6cb4dd585..000000000 --- a/packages/app/public/release/release-example.mp4 +++ /dev/null diff --git a/packages/app/public/release/release-share.png b/packages/app/public/release/release-share.png Binary files differdeleted file mode 100644 index e4b99d2db..000000000 --- a/packages/app/public/release/release-share.png +++ /dev/null diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 0980bedb9..ba0d1e7aa 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -26,11 +26,10 @@ import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" import { LanguageProvider, useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import { Logo } from "@opencode-ai/ui/logo" +import { HighlightsProvider } from "@/context/highlights" import Layout from "@/pages/layout" import DirectoryLayout from "@/pages/directory-layout" import { ErrorPage } from "./pages/error" -import { iife } from "@opencode-ai/util/iife" import { Suspense } from "solid-js" const Home = lazy(() => import("@/pages/home")) @@ -119,7 +118,9 @@ export function AppInterface(props: { defaultUrl?: string }) { <NotificationProvider> <ModelsProvider> <CommandProvider> - <Layout>{props.children}</Layout> + <HighlightsProvider> + <Layout>{props.children}</Layout> + </HighlightsProvider> </CommandProvider> </ModelsProvider> </NotificationProvider> diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx index d6375dbbc..c62cbc188 100644 --- a/packages/app/src/components/dialog-release-notes.tsx +++ b/packages/app/src/components/dialog-release-notes.tsx @@ -2,110 +2,8 @@ import { createSignal, createEffect, onMount, onCleanup } from "solid-js" import { Dialog } from "@opencode-ai/ui/dialog" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { markReleaseNotesSeen } from "@/lib/release-notes" -const CHANGELOG_URL = "https://opencode.ai/changelog.json" - -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 (!Array.isArray(value)) return - const parts = value.map((item) => (typeof item === "string" ? item.trim() : "")).filter((item) => item.length > 0) - if (parts.length === 0) return - return parts.join(" ") -} - -function normalizeRemoteUrl(url: string): string { - if (url.startsWith("https://") || url.startsWith("http://")) return url - if (url.startsWith("/")) return `https://opencode.ai${url}` - return `https://opencode.ai/${url}` -} - -function parseMedia(value: unknown): ReleaseFeature["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: normalizeRemoteUrl(src), - alt: getText(value.alt), - } -} - -function parseFeature(value: unknown): ReleaseFeature | undefined { - if (!isRecord(value)) return - - const title = getText(value.title) ?? getText(value.name) ?? getText(value.heading) - const description = getText(value.description) ?? getText(value.body) ?? getText(value.text) - - if (!title) return - if (!description) return - - const tag = getText(value.tag) ?? getText(value.label) ?? "New" - - const media = (() => { - const parsed = parseMedia(value.media) - if (parsed) return parsed - - const alt = getText(value.alt) - const image = getText(value.image) - if (image) return { type: "image" as const, src: normalizeRemoteUrl(image), alt } - - const video = getText(value.video) - if (video) return { type: "video" as const, src: normalizeRemoteUrl(video), alt } - })() - - return { title, description, tag, media } -} - -function parseChangelog(value: unknown): ReleaseNote | undefined { - const releases = (() => { - if (Array.isArray(value)) return value - if (!isRecord(value)) return - if (Array.isArray(value.releases)) return value.releases - if (Array.isArray(value.versions)) return value.versions - if (Array.isArray(value.changelog)) return value.changelog - })() - - if (!releases) { - if (!isRecord(value)) return - if (!Array.isArray(value.highlights)) return - const features = value.highlights.map(parseFeature).filter((item): item is ReleaseFeature => item !== undefined) - if (features.length === 0) return - return { version: CURRENT_RELEASE.version, features: features.slice(0, 3) } - } - - const version = (() => { - const head = releases[0] - if (!isRecord(head)) return - return getText(head.version) ?? getText(head.tag_name) ?? getText(head.tag) ?? getText(head.name) - })() - - const features = releases - .flatMap((item) => { - if (!isRecord(item)) return [] - const highlights = item.highlights - if (!Array.isArray(highlights)) return [] - return highlights.map(parseFeature).filter((feature): feature is ReleaseFeature => feature !== undefined) - }) - .slice(0, 3) - - if (features.length === 0) return - return { version: version ?? CURRENT_RELEASE.version, features } -} - -export interface ReleaseFeature { +export type Highlight = { title: string description: string tag?: string @@ -116,74 +14,30 @@ export interface ReleaseFeature { } } -export interface ReleaseNote { - version: string - features: ReleaseFeature[] -} - -// Current release notes - update this with each release -export const CURRENT_RELEASE: ReleaseNote = { - version: "1.0.0", - features: [ - { - title: "Cleaner tab experience", - description: "Chat is now fixed to the side of your tabs, and review is now available as a dedicated tab. ", - tag: "New", - media: { - type: "video", - src: "/release/release-example.mp4", - alt: "Cleaner tab experience", - }, - }, - { - title: "Share with control", - description: "Keep your sessions private by default, or publish them to the web with a shareable URL.", - tag: "New", - media: { - type: "image", - src: "/release/release-share.png", - alt: "Share with control", - }, - }, - { - title: "Improved attachment management", - description: "Upload and manage attachments more easily, to help build and maintain context.", - tag: "New", - media: { - type: "video", - src: "/release/release-example.mp4", - alt: "Improved attachment management", - }, - }, - ], -} - -export function DialogReleaseNotes(props: { release?: ReleaseNote }) { +export function DialogReleaseNotes(props: { highlights: Highlight[] }) { const dialog = useDialog() - const [note, setNote] = createSignal(props.release ?? CURRENT_RELEASE) const [index, setIndex] = createSignal(0) - const feature = () => note().features[index()] ?? note().features[0] ?? CURRENT_RELEASE.features[0]! - const total = () => note().features.length + const total = () => props.highlights.length + const last = () => Math.max(0, total() - 1) + const feature = () => props.highlights[index()] ?? props.highlights[last()] const isFirst = () => index() === 0 - const isLast = () => index() === total() - 1 + const isLast = () => index() >= last() + const paged = () => total() > 1 function handleNext() { - if (!isLast()) setIndex(index() + 1) - } - - function handleBack() { - if (!isFirst()) setIndex(index() - 1) + if (isLast()) return + setIndex(index() + 1) } function handleClose() { - markReleaseNotesSeen() dialog.close() } let focusTrap: HTMLDivElement | undefined function handleKeyDown(e: KeyboardEvent) { + if (!paged()) return if (e.key === "ArrowLeft" && !isFirst()) { e.preventDefault() setIndex(index() - 1) @@ -196,28 +50,10 @@ export function DialogReleaseNotes(props: { release?: ReleaseNote }) { onMount(() => { focusTrap?.focus() + + if (!paged()) return document.addEventListener("keydown", handleKeyDown) onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) - - const controller = new AbortController() - fetch(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 parsed = parseChangelog(json) - if (!parsed) return - setNote({ - version: parsed.version, - features: parsed.features, - }) - setIndex(0) - }) - .catch(() => undefined) - - onCleanup(() => controller.abort()) }) // Refocus the trap when index changes to ensure escape always works @@ -235,17 +71,17 @@ export function DialogReleaseNotes(props: { release?: ReleaseNote }) { {/* Top section - feature content (fixed position from top) */} <div class="flex flex-col gap-2 pt-22"> <div class="flex items-center gap-2"> - <h1 class="text-16-medium text-text-strong">{feature().title}</h1> - {feature().tag && ( + <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} + {feature()!.tag} </span> )} </div> - <p class="text-14-regular text-text-base">{feature().description}</p> + <p class="text-14-regular text-text-base">{feature()?.description ?? ""}</p> </div> {/* Spacer to push buttons to bottom */} @@ -265,9 +101,9 @@ export function DialogReleaseNotes(props: { release?: ReleaseNote }) { )} </div> - {total() > 1 && ( + {paged() && ( <div class="flex items-center gap-1.5 -my-2.5"> - {note().features.map((_, i) => ( + {props.highlights.map((_, i) => ( <button type="button" class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200" @@ -292,16 +128,16 @@ export function DialogReleaseNotes(props: { release?: ReleaseNote }) { </div> {/* Right side - Media content (edge to edge) */} - {feature().media && ( + {feature()?.media && ( <div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl"> - {feature().media!.type === "image" ? ( + {feature()!.media!.type === "image" ? ( <img - src={feature().media!.src} - alt={feature().media!.alt ?? "Release preview"} + src={feature()!.media!.src} + alt={feature()!.media!.alt ?? feature()?.title ?? "Release preview"} class="w-full h-full object-cover" /> ) : ( - <video src={feature().media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" /> + <video src={feature()!.media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" /> )} </div> )} diff --git a/packages/app/src/components/release-notes-handler.tsx b/packages/app/src/components/release-notes-handler.tsx deleted file mode 100644 index 45237b577..000000000 --- a/packages/app/src/components/release-notes-handler.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { onMount } from "solid-js" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { DialogReleaseNotes } from "./dialog-release-notes" -import { shouldShowReleaseNotes, markReleaseNotesSeen } from "@/lib/release-notes" - -/** - * Component that handles showing release notes modal on app startup. - * Shows the modal if: - * - DEV_ALWAYS_SHOW_RELEASE_NOTES is true in lib/release-notes.ts - * - OR the user hasn't seen the current version's release notes yet - * - * To disable the dev mode behavior, set DEV_ALWAYS_SHOW_RELEASE_NOTES to false - * in packages/app/src/lib/release-notes.ts - */ -export function ReleaseNotesHandler() { - const dialog = useDialog() - - onMount(() => { - // Small delay to ensure app is fully loaded before showing modal - setTimeout(() => { - if (shouldShowReleaseNotes()) { - dialog.show( - () => <DialogReleaseNotes />, - () => markReleaseNotesSeen(), - ) - } - }, 500) - }) - - return null -} 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, + } + }, +}) diff --git a/packages/app/src/lib/release-notes.ts b/packages/app/src/lib/release-notes.ts deleted file mode 100644 index a28843acd..000000000 --- a/packages/app/src/lib/release-notes.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { CURRENT_RELEASE } from "@/components/dialog-release-notes" - -const STORAGE_KEY = "opencode:last-seen-version" - -// ============================================================================ -// DEV MODE: Set this to true to always show the release notes modal on startup -// Set to false for production behavior (only shows after updates) -// ============================================================================ -const DEV_ALWAYS_SHOW_RELEASE_NOTES = true - -/** - * Check if release notes should be shown - * Returns true if: - * - DEV_ALWAYS_SHOW_RELEASE_NOTES is true (for development) - * - OR the current version is newer than the last seen version - */ -export function shouldShowReleaseNotes(): boolean { - if (DEV_ALWAYS_SHOW_RELEASE_NOTES) { - console.log("[ReleaseNotes] DEV mode: always showing release notes") - return true - } - - const lastSeen = localStorage.getItem(STORAGE_KEY) - if (!lastSeen) { - // First time user - show release notes - return true - } - - // Compare versions - show if current is newer - return CURRENT_RELEASE.version !== lastSeen -} - -/** - * Mark the current release notes as seen - * Call this when the user closes the release notes modal - */ -export function markReleaseNotesSeen(): void { - localStorage.setItem(STORAGE_KEY, CURRENT_RELEASE.version) -} - -/** - * Get the current version - */ -export function getCurrentVersion(): string { - return CURRENT_RELEASE.version -} - -/** - * Reset the seen status (useful for testing) - */ -export function resetReleaseNotesSeen(): void { - localStorage.removeItem(STORAGE_KEY) -} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 601a24067..ae501a7b7 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -68,7 +68,6 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { navStart } from "@/utils/perf" import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogEditProject } from "@/components/dialog-edit-project" -import { ReleaseNotesHandler } from "@/components/release-notes-handler" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" @@ -2797,7 +2796,6 @@ export default function Layout(props: ParentProps) { </main> </div> <Toast.Region /> - <ReleaseNotesHandler /> </div> ) } |
