summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rwxr-xr-xpackages/app/public/release/release-example.mp4bin0 -> 2308074 bytes
-rw-r--r--packages/app/public/release/release-share.pngbin0 -> 44076 bytes
-rw-r--r--packages/app/src/components/dialog-release-notes.tsx185
-rw-r--r--packages/app/src/components/release-notes-handler.tsx31
-rw-r--r--packages/app/src/index.css27
-rw-r--r--packages/app/src/lib/release-notes.ts53
-rw-r--r--packages/app/src/pages/layout.tsx7
7 files changed, 303 insertions, 0 deletions
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
--- /dev/null
+++ b/packages/app/public/release/release-example.mp4
Binary files 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
--- /dev/null
+++ b/packages/app/public/release/release-share.png
Binary files 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 (
+ <Dialog class="dialog-release-notes">
+ {/* Hidden element to capture initial focus and handle escape */}
+ <div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
+ {/* Left side - Text content */}
+ <div class="flex flex-col flex-1 min-w-0 p-8">
+ {/* 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 && (
+ <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}
+ </span>
+ )}
+ </div>
+ <p class="text-14-regular text-text-base">{feature().description}</p>
+ </div>
+
+ {/* Spacer to push buttons to bottom */}
+ <div class="flex-1" />
+
+ {/* Bottom section - buttons and indicators (fixed position) */}
+ <div class="flex flex-col gap-12">
+ <div class="flex items-center gap-3">
+ {isLast() ? (
+ <Button variant="primary" size="large" onClick={handleClose}>
+ Get started
+ </Button>
+ ) : (
+ <Button variant="secondary" size="large" onClick={handleNext}>
+ Next
+ </Button>
+ )}
+ </div>
+
+ {total > 1 && (
+ <div class="flex items-center gap-1.5 -my-2.5">
+ {release.features.map((_, i) => (
+ <button
+ type="button"
+ class="w-8 h-6 flex items-center cursor-pointer bg-transparent border-none p-0"
+ onClick={() => setIndex(i)}
+ >
+ <div
+ class="w-full h-0.5 rounded-[1px] transition-colors"
+ classList={{
+ "bg-icon-strong-base": i === index(),
+ "bg-icon-weak-base": i !== index(),
+ }}
+ />
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* Right side - Media content (edge to edge) */}
+ {feature().media && (
+ <div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
+ {feature().media!.type === "image" ? (
+ <img
+ src={feature().media!.src}
+ alt={feature().media!.alt ?? "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" />
+ )}
+ </div>
+ )}
+ </Dialog>
+ )
+}
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(
+ () => <DialogReleaseNotes />,
+ () => 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"
@@ -1444,6 +1445,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) {
</main>
</div>
<Toast.Region />
+ <ReleaseNotesHandler />
</div>
)
}