Просмотр исходного кода

wip: new release modal

- highlight key updates or new features
- needs some transition love
- all copy including text and video placeholder
David Hill 2 месяцев назад
Родитель
Сommit
a77df3c174

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


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


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

+ 31 - 0
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
+}

+ 27 - 0
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;
+    }
+  }
+}

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

+ 7 - 0
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) {
         </main>
       </div>
       <Toast.Region />
+      <ReleaseNotesHandler />
     </div>
   )
 }