diff --git a/packages/app/public/release/release-example.mp4 b/packages/app/public/release/release-example.mp4 new file mode 100755 index 0000000000..6cb4dd5859 Binary files /dev/null and b/packages/app/public/release/release-example.mp4 differ diff --git a/packages/app/public/release/release-share.png b/packages/app/public/release/release-share.png new file mode 100644 index 0000000000..e4b99d2db7 Binary files /dev/null and b/packages/app/public/release/release-share.png differ diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx new file mode 100644 index 0000000000..d6375dbbc4 --- /dev/null +++ b/packages/app/src/components/dialog-release-notes.tsx @@ -0,0 +1,310 @@ +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 { + 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 + description: string + tag?: string + media?: { + type: "image" | "video" + src: string + alt?: string + } +} + +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 }) { + 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 isFirst = () => index() === 0 + const isLast = () => index() === total() - 1 + + function handleNext() { + if (!isLast()) setIndex(index() + 1) + } + + function handleBack() { + if (!isFirst()) setIndex(index() - 1) + } + + function handleClose() { + markReleaseNotesSeen() + dialog.close() + } + + let focusTrap: HTMLDivElement | undefined + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "ArrowLeft" && !isFirst()) { + e.preventDefault() + setIndex(index() - 1) + } + if (e.key === "ArrowRight" && !isLast()) { + e.preventDefault() + setIndex(index() + 1) + } + } + + onMount(() => { + focusTrap?.focus() + 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) : 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 + createEffect(() => { + index() // track index + focusTrap?.focus() + }) + + return ( + + {/* Hidden element to capture initial focus and handle escape */} +
+ {/* Left side - Text content */} +
+ {/* Top section - feature content (fixed position from top) */} +
+
+

{feature().title}

+ {feature().tag && ( + + {feature().tag} + + )} +
+

{feature().description}

+
+ + {/* Spacer to push buttons to bottom */} +
+ + {/* Bottom section - buttons and indicators (fixed position) */} +
+
+ {isLast() ? ( + + ) : ( + + )} +
+ + {total() > 1 && ( +
+ {note().features.map((_, i) => ( + + ))} +
+ )} +
+
+ + {/* Right side - Media content (edge to edge) */} + {feature().media && ( +
+ {feature().media!.type === "image" ? ( + {feature().media!.alt + ) : ( +
+ )} +
+ ) +} diff --git a/packages/app/src/components/release-notes-handler.tsx b/packages/app/src/components/release-notes-handler.tsx new file mode 100644 index 0000000000..45237b5779 --- /dev/null +++ b/packages/app/src/components/release-notes-handler.tsx @@ -0,0 +1,31 @@ +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( + () => , + () => markReleaseNotesSeen(), + ) + } + }, 500) + }) + + return null +} diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 3d7b9db7af..c0c7da8585 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -55,3 +55,30 @@ scrollbar-width: thin !important; scrollbar-color: var(--border-weak-base) transparent !important; } + +/* Wider dialog variant for release notes modal */ +[data-component="dialog"]:has(.dialog-release-notes) { + padding: 20px; + box-sizing: border-box; + + [data-slot="dialog-container"] { + width: min(100%, 720px); + height: min(100%, 400px); + margin-top: -80px; + + [data-slot="dialog-content"] { + min-height: auto; + overflow: hidden; + height: 100%; + border: none; + box-shadow: var(--shadow-lg-border-base); + } + + [data-slot="dialog-body"] { + overflow: hidden; + height: 100%; + display: flex; + flex-direction: row; + } + } +} diff --git a/packages/app/src/lib/release-notes.ts b/packages/app/src/lib/release-notes.ts new file mode 100644 index 0000000000..a28843acdf --- /dev/null +++ b/packages/app/src/lib/release-notes.ts @@ -0,0 +1,53 @@ +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 485deae99d..4a2531d0ce 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -68,6 +68,7 @@ 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" @@ -1412,6 +1413,11 @@ export default function Layout(props: ParentProps) { ), ) + createEffect(() => { + const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48 + document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) + }) + createEffect(() => { const project = currentProject() if (!project) return @@ -2623,6 +2629,7 @@ export default function Layout(props: ParentProps) { + ) } diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 03166285bc..ba2eae8eb6 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -105,6 +105,24 @@ color: var(--icon-active); } } + + > [data-component="icon-button"] { + background-color: transparent; + + &:hover:not(:disabled), + &:focus:not(:disabled), + &:active:not(:disabled) { + background-color: transparent; + } + + &:hover:not(:disabled) [data-slot="icon-svg"] { + color: var(--icon-hover); + } + + &:active:not(:disabled) [data-slot="icon-svg"] { + color: var(--icon-active); + } + } } [data-slot="list-scroll"] {