diff --git a/packages/app/public/release/release-example.mp4 b/packages/app/public/release/release-example.mp4 new file mode 100755 index 000000000..6cb4dd585 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 000000000..e4b99d2db 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 000000000..c5a1e15c1 --- /dev/null +++ b/packages/app/src/components/dialog-release-notes.tsx @@ -0,0 +1,185 @@ +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" + +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 release = props.release ?? CURRENT_RELEASE + const [index, setIndex] = createSignal(0) + + const feature = () => release.features[index()] + const total = release.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)) + }) + + // 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 && ( +
+ {release.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 000000000..45237b577 --- /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 3d7b9db7a..c0c7da858 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 000000000..a28843acd --- /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 b13cb1ac3..601a24067 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" @@ -1443,6 +1444,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 @@ -2791,6 +2797,7 @@ export default function Layout(props: ParentProps) { + ) }