wip: highlights
This commit is contained in:
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB |
@@ -26,11 +26,10 @@ import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { CommandProvider } from "@/context/command"
|
import { CommandProvider } from "@/context/command"
|
||||||
import { LanguageProvider, useLanguage } from "@/context/language"
|
import { LanguageProvider, useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { Logo } from "@opencode-ai/ui/logo"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
|
||||||
import { Suspense } from "solid-js"
|
import { Suspense } from "solid-js"
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const Home = lazy(() => import("@/pages/home"))
|
||||||
@@ -119,7 +118,9 @@ export function AppInterface(props: { defaultUrl?: string }) {
|
|||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<ModelsProvider>
|
<ModelsProvider>
|
||||||
<CommandProvider>
|
<CommandProvider>
|
||||||
<Layout>{props.children}</Layout>
|
<HighlightsProvider>
|
||||||
|
<Layout>{props.children}</Layout>
|
||||||
|
</HighlightsProvider>
|
||||||
</CommandProvider>
|
</CommandProvider>
|
||||||
</ModelsProvider>
|
</ModelsProvider>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
|
|||||||
@@ -2,110 +2,8 @@ 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 { markReleaseNotesSeen } from "@/lib/release-notes"
|
|
||||||
|
|
||||||
const CHANGELOG_URL = "https://opencode.ai/changelog.json"
|
export type 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 (!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 {
|
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
tag?: string
|
tag?: string
|
||||||
@@ -116,74 +14,30 @@ export interface ReleaseFeature {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReleaseNote {
|
export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
||||||
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 }) {
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const [note, setNote] = createSignal(props.release ?? CURRENT_RELEASE)
|
|
||||||
const [index, setIndex] = createSignal(0)
|
const [index, setIndex] = createSignal(0)
|
||||||
|
|
||||||
const feature = () => note().features[index()] ?? note().features[0] ?? CURRENT_RELEASE.features[0]!
|
const total = () => props.highlights.length
|
||||||
const total = () => note().features.length
|
const last = () => Math.max(0, total() - 1)
|
||||||
|
const feature = () => props.highlights[index()] ?? props.highlights[last()]
|
||||||
const isFirst = () => index() === 0
|
const isFirst = () => index() === 0
|
||||||
const isLast = () => index() === total() - 1
|
const isLast = () => index() >= last()
|
||||||
|
const paged = () => total() > 1
|
||||||
|
|
||||||
function handleNext() {
|
function handleNext() {
|
||||||
if (!isLast()) setIndex(index() + 1)
|
if (isLast()) return
|
||||||
}
|
setIndex(index() + 1)
|
||||||
|
|
||||||
function handleBack() {
|
|
||||||
if (!isFirst()) setIndex(index() - 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
markReleaseNotesSeen()
|
|
||||||
dialog.close()
|
dialog.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
let focusTrap: HTMLDivElement | undefined
|
let focusTrap: HTMLDivElement | undefined
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (!paged()) return
|
||||||
if (e.key === "ArrowLeft" && !isFirst()) {
|
if (e.key === "ArrowLeft" && !isFirst()) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIndex(index() - 1)
|
setIndex(index() - 1)
|
||||||
@@ -196,28 +50,10 @@ export function DialogReleaseNotes(props: { release?: ReleaseNote }) {
|
|||||||
|
|
||||||
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))
|
||||||
|
|
||||||
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
|
// 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) */}
|
{/* Top section - feature content (fixed position from top) */}
|
||||||
<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 && (
|
{feature()?.tag && (
|
||||||
<span
|
<span
|
||||||
class="text-12-medium text-text-weak px-1.5 py-0.5 bg-surface-base rounded-sm border border-border-weak-base"
|
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" }}
|
style={{ "border-width": "0.5px" }}
|
||||||
>
|
>
|
||||||
{feature().tag}
|
{feature()!.tag}
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
{/* Spacer to push buttons to bottom */}
|
{/* Spacer to push buttons to bottom */}
|
||||||
@@ -265,9 +101,9 @@ export function DialogReleaseNotes(props: { release?: ReleaseNote }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{total() > 1 && (
|
{paged() && (
|
||||||
<div class="flex items-center gap-1.5 -my-2.5">
|
<div class="flex items-center gap-1.5 -my-2.5">
|
||||||
{note().features.map((_, i) => (
|
{props.highlights.map((_, i) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Media content (edge to edge) */}
|
{/* 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">
|
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
||||||
{feature().media!.type === "image" ? (
|
{feature()!.media!.type === "image" ? (
|
||||||
<img
|
<img
|
||||||
src={feature().media!.src}
|
src={feature()!.media!.src}
|
||||||
alt={feature().media!.alt ?? "Release preview"}
|
alt={feature()!.media!.alt ?? feature()?.title ?? "Release preview"}
|
||||||
class="w-full h-full object-cover"
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
200
packages/app/src/context/highlights.tsx
Normal file
200
packages/app/src/context/highlights.tsx
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -68,7 +68,6 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
|||||||
import { navStart } from "@/utils/perf"
|
import { navStart } from "@/utils/perf"
|
||||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||||
import { ReleaseNotesHandler } from "@/components/release-notes-handler"
|
|
||||||
import { Titlebar } from "@/components/titlebar"
|
import { Titlebar } from "@/components/titlebar"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { useLanguage, type Locale } from "@/context/language"
|
import { useLanguage, type Locale } from "@/context/language"
|
||||||
@@ -2797,7 +2796,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<Toast.Region />
|
<Toast.Region />
|
||||||
<ReleaseNotesHandler />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user