adamelmore 3 недель назад
Родитель
Сommit
8b6484ac1a

BIN
packages/app/public/release/release-example.mp4


BIN
packages/app/public/release/release-share.png


+ 4 - 3
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>

+ 23 - 187
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>
       )}

+ 0 - 31
packages/app/src/components/release-notes-handler.tsx

@@ -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 - 0
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,
+    }
+  },
+})

+ 0 - 53
packages/app/src/lib/release-notes.ts

@@ -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)
-}

+ 0 - 2
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>
   )
 }