wip: new release modal
- highlight key updates or new features - needs some transition love - all copy including text and video placeholder
This commit is contained in:
BIN
packages/app/public/release/release-example.mp4
Executable file
BIN
packages/app/public/release/release-example.mp4
Executable file
Binary file not shown.
BIN
packages/app/public/release/release-share.png
Normal file
BIN
packages/app/public/release/release-share.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
185
packages/app/src/components/dialog-release-notes.tsx
Normal file
185
packages/app/src/components/dialog-release-notes.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
packages/app/src/components/release-notes-handler.tsx
Normal file
31
packages/app/src/components/release-notes-handler.tsx
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -55,3 +55,30 @@
|
|||||||
scrollbar-width: thin !important;
|
scrollbar-width: thin !important;
|
||||||
scrollbar-color: var(--border-weak-base) transparent !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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
53
packages/app/src/lib/release-notes.ts
Normal file
53
packages/app/src/lib/release-notes.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ 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"
|
||||||
@@ -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(() => {
|
createEffect(() => {
|
||||||
const project = currentProject()
|
const project = currentProject()
|
||||||
if (!project) return
|
if (!project) return
|
||||||
@@ -2791,6 +2797,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<Toast.Region />
|
<Toast.Region />
|
||||||
|
<ReleaseNotesHandler />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user