diff --git a/packages/app/e2e/release-notes/release-notes-toast.spec.ts b/packages/app/e2e/release-notes/release-notes-toast.spec.ts new file mode 100644 index 00000000..33771c3c --- /dev/null +++ b/packages/app/e2e/release-notes/release-notes-toast.spec.ts @@ -0,0 +1,399 @@ +import { test, expect, settingsKey } from "../fixtures" + +const RELEASES_URL_PATTERN = "**/api.github.com/repos/Astro-Han/pawwork/releases**" +const HIGHLIGHTS_KEY = "highlights.v1" +const LANGUAGE_KEY = "pawwork.global.dat:language" + +const SINGLE_RELEASE_PAYLOAD = [ + { + tag_name: "v2026.5.7", + body: [ + "## App Update Notice", + "", + "Important refresh for this release.", + "", + "- Added subtle toast variant", + "- Replaced release notes dialog", + ].join("\n"), + }, +] + +const MULTI_VERSION_PAYLOAD = [ + { + tag_name: "v2026.5.7", + body: "## App Update Notice\n\n- Newest highlight A\n- Newest highlight B\n", + }, + { + tag_name: "v2026.5.6", + body: "## App Update Notice\n\n- Older highlight C\n", + }, +] + +const LOCALIZED_PAYLOAD = [ + { + tag_name: "v2026.5.7", + body: [ + "## App Update Notice", + "", + "- English bullet only", + "", + "## 中文版本", + "", + "### 主要更新", + "", + "- 中文要点 A", + "- 中文要点 B", + ].join("\n"), + }, +] + +const TOAST_SELECTOR = '[data-component="toast"][data-variant="subtle"]' +const TOAST_TITLE_SELECTOR = `${TOAST_SELECTOR} [data-slot="toast-title"]` +const TOAST_DESCRIPTION_SELECTOR = `${TOAST_SELECTOR} [data-slot="toast-description"]` +const TOAST_ACTION_SELECTOR = `${TOAST_SELECTOR} [data-slot="toast-action"]` +const TOAST_CLOSE_BUTTON_SELECTOR = `${TOAST_SELECTOR} [data-slot="toast-close-button"]` +const TOAST_ICON_SELECTOR = `${TOAST_SELECTOR} [data-slot="toast-icon"]` + +test.describe("release notes toast", () => { + test("@smoke shows subtle toast when stored version is older than current", async ({ page, gotoSession }) => { + await page.route(RELEASES_URL_PATTERN, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(SINGLE_RELEASE_PAYLOAD), + }) + }) + + await page.addInitScript((key) => { + localStorage.setItem(key, JSON.stringify({ version: "2026.5.6" })) + }, HIGHLIGHTS_KEY) + + await gotoSession() + + await expect(page.locator(TOAST_SELECTOR)).toBeVisible({ timeout: 10_000 }) + await expect(page.locator(TOAST_TITLE_SELECTOR)).toHaveText("Updated to v2026.5.7") + await expect(page.locator(TOAST_DESCRIPTION_SELECTOR)).toContainText("Important refresh for this release.") + await expect(page.locator(TOAST_DESCRIPTION_SELECTOR)).toContainText("• Added subtle toast variant") + await expect(page.locator(TOAST_DESCRIPTION_SELECTOR)).toContainText("• Replaced release notes dialog") + await expect(page.locator(TOAST_ACTION_SELECTOR)).toHaveText("Full release notes →") + await expect(page.locator(TOAST_ICON_SELECTOR)).toBeVisible() + }) + + test("clicking the close button marks the current version as seen", async ({ page, gotoSession }) => { + await page.route(RELEASES_URL_PATTERN, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(SINGLE_RELEASE_PAYLOAD), + }) + }) + + await page.addInitScript((key) => { + localStorage.setItem(key, JSON.stringify({ version: "2026.5.6" })) + }, HIGHLIGHTS_KEY) + + await gotoSession() + await expect(page.locator(TOAST_SELECTOR)).toBeVisible({ timeout: 10_000 }) + + await page.locator(TOAST_CLOSE_BUTTON_SELECTOR).click() + await expect(page.locator(TOAST_SELECTOR)).toBeHidden() + + await expect + .poll(() => + page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? (JSON.parse(raw)?.version ?? null) : null + }, HIGHLIGHTS_KEY), + ) + .toBe("2026.5.7") + }) + + test("clicking the action opens the release URL and marks the current version as seen", async ({ + page, + gotoSession, + }) => { + await page.route(RELEASES_URL_PATTERN, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(SINGLE_RELEASE_PAYLOAD), + }) + }) + + await page.addInitScript((key) => { + localStorage.setItem(key, JSON.stringify({ version: "2026.5.6" })) + // Capture window.open calls so we can assert the action opened the + // expected release URL. platform.openLink in the web shell calls + // window.open(url, "_blank"); stubbing it avoids opening a real popup. + const captured: string[] = [] + ;(window as unknown as { __OPENED_LINKS: string[] }).__OPENED_LINKS = captured + window.open = ((url?: string | URL) => { + if (typeof url === "string") captured.push(url) + else if (url) captured.push(url.toString()) + return null + }) as typeof window.open + }, HIGHLIGHTS_KEY) + + await gotoSession() + await expect(page.locator(TOAST_SELECTOR)).toBeVisible({ timeout: 10_000 }) + + await page.locator(TOAST_ACTION_SELECTOR).click() + await expect(page.locator(TOAST_SELECTOR)).toBeHidden() + + await expect + .poll(() => + page.evaluate(() => (window as unknown as { __OPENED_LINKS: string[] }).__OPENED_LINKS), + ) + .toContain("https://github.com/Astro-Han/pawwork/releases/tag/v2026.5.7") + + await expect + .poll(() => + page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? (JSON.parse(raw)?.version ?? null) : null + }, HIGHLIGHTS_KEY), + ) + .toBe("2026.5.7") + }) + + test("pressing Escape marks the current version as seen", async ({ page, gotoSession }) => { + await page.route(RELEASES_URL_PATTERN, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(SINGLE_RELEASE_PAYLOAD), + }) + }) + + await page.addInitScript((key) => { + localStorage.setItem(key, JSON.stringify({ version: "2026.5.6" })) + }, HIGHLIGHTS_KEY) + + await gotoSession() + await expect(page.locator(TOAST_SELECTOR)).toBeVisible({ timeout: 10_000 }) + + // Escape key dismisses via Kobalte's onEscapeKeyDown → close() path, + // which bypasses CloseButton's onClick. This guards the same code path + // used by swipe-to-dismiss on touch devices: both must mark the version + // as seen via the onSwipeEnd / onEscapeKeyDown handlers we add to the + // Toast root, otherwise the toast re-shows on next launch. + await page.locator(TOAST_SELECTOR).focus() + await page.keyboard.press("Escape") + await expect(page.locator(TOAST_SELECTOR)).toBeHidden() + + await expect + .poll(() => + page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? (JSON.parse(raw)?.version ?? null) : null + }, HIGHLIGHTS_KEY), + ) + .toBe("2026.5.7") + }) + + test("releaseNotes=false suppresses the toast", async ({ page, gotoSession }) => { + await page.route(RELEASES_URL_PATTERN, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(SINGLE_RELEASE_PAYLOAD), + }) + }) + + await page.addInitScript( + ([highlightsKey, settingsStorageKey]) => { + localStorage.setItem(highlightsKey, JSON.stringify({ version: "2026.5.6" })) + const existing = localStorage.getItem(settingsStorageKey) + const parsed = existing ? JSON.parse(existing) : {} + parsed.general = { ...(parsed.general ?? {}), releaseNotes: false } + localStorage.setItem(settingsStorageKey, JSON.stringify(parsed)) + }, + [HIGHLIGHTS_KEY, settingsKey], + ) + + await gotoSession() + + // releaseNotes=false short-circuits start() to call markSeen() synchronously, + // so polling localStorage is the deterministic readiness signal that the + // controller has run. Once the version advances, we know the toast was + // suppressed (not merely "not yet rendered"). + await expect + .poll(() => + page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? (JSON.parse(raw)?.version ?? null) : null + }, HIGHLIGHTS_KEY), + ) + .toBe("2026.5.7") + await expect(page.locator(TOAST_SELECTOR)).toHaveCount(0) + }) + + test("merges multiple skipped versions into one description", async ({ page, gotoSession }) => { + await page.route(RELEASES_URL_PATTERN, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(MULTI_VERSION_PAYLOAD), + }) + }) + + await page.addInitScript((key) => { + localStorage.setItem(key, JSON.stringify({ version: "2026.5.5" })) + }, HIGHLIGHTS_KEY) + + await gotoSession() + + await expect(page.locator(TOAST_SELECTOR)).toBeVisible({ timeout: 10_000 }) + await expect(page.locator(TOAST_TITLE_SELECTOR)).toHaveText("Updated to v2026.5.7") + const description = page.locator(TOAST_DESCRIPTION_SELECTOR) + await expect(description).toContainText("• Newest highlight A") + await expect(description).toContainText("• Newest highlight B") + await expect(description).toContainText("v2026.5.6") + await expect(description).toContainText("• Older highlight C") + }) + + test("falls back to English when zh release section is missing", async ({ page, gotoSession }) => { + await page.route(RELEASES_URL_PATTERN, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + tag_name: "v2026.5.7", + body: "## App Update Notice\n\n- English fallback bullet\n", + }, + ]), + }) + }) + + await page.addInitScript((highlightsKey) => { + localStorage.setItem(highlightsKey, JSON.stringify({ version: "2026.5.6" })) + localStorage.setItem(LANGUAGE_KEY, JSON.stringify({ locale: "zh" })) + }, HIGHLIGHTS_KEY) + + await gotoSession() + + await expect(page.locator(TOAST_SELECTOR)).toBeVisible({ timeout: 10_000 }) + // Title and action must follow the parsed body's locale (en) — never mix with zh UI locale. + await expect(page.locator(TOAST_TITLE_SELECTOR)).toHaveText("Updated to v2026.5.7") + await expect(page.locator(TOAST_ACTION_SELECTOR)).toHaveText("Full release notes →") + await expect(page.locator(TOAST_DESCRIPTION_SELECTOR)).toContainText("• English fallback bullet") + }) + + test("uses zh title and action when zh release section is present", async ({ page, gotoSession }) => { + await page.route(RELEASES_URL_PATTERN, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(LOCALIZED_PAYLOAD), + }) + }) + + await page.addInitScript((highlightsKey) => { + localStorage.setItem(highlightsKey, JSON.stringify({ version: "2026.5.6" })) + localStorage.setItem(LANGUAGE_KEY, JSON.stringify({ locale: "zh" })) + }, HIGHLIGHTS_KEY) + + await gotoSession() + + await expect(page.locator(TOAST_SELECTOR)).toBeVisible({ timeout: 10_000 }) + await expect(page.locator(TOAST_TITLE_SELECTOR)).toHaveText("已更新到 v2026.5.7") + await expect(page.locator(TOAST_ACTION_SELECTOR)).toHaveText("查看完整发布说明 →") + await expect(page.locator(TOAST_DESCRIPTION_SELECTOR)).toContainText("• 中文要点 A") + await expect(page.locator(TOAST_DESCRIPTION_SELECTOR)).toContainText("• 中文要点 B") + }) + + test("title and link anchor on the app's current version even when its release lacks a notice in the resolved locale", async ({ + page, + gotoSession, + }) => { + await page.route(RELEASES_URL_PATTERN, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + tag_name: "v2026.5.7", + // newest release: zh-only notice, no English App Update Notice + body: ["## 中文版本", "", "### 主要更新", "", "- 仅中文要点"].join("\n"), + }, + { + tag_name: "v2026.5.6", + // older skipped release: en-only notice, no 中文版本 + body: "## App Update Notice\n\n- older en-only bullet\n", + }, + ]), + }) + }) + + await page.addInitScript((highlightsKey) => { + localStorage.setItem(highlightsKey, JSON.stringify({ version: "2026.5.5" })) + localStorage.setItem(LANGUAGE_KEY, JSON.stringify({ locale: "zh" })) + }, HIGHLIGHTS_KEY) + + await gotoSession() + + await expect(page.locator(TOAST_SELECTOR)).toBeVisible({ timeout: 10_000 }) + // First-pass zh resolves to mixed (v2026.5.7 zh + v2026.5.6 en fallback), + // so we re-resolve the whole window in English. The English window does + // not contain v2026.5.7 (no App Update Notice there), but the title and + // link must still anchor on the app's current version, not summaries[0]. + await expect(page.locator(TOAST_TITLE_SELECTOR)).toHaveText("Updated to v2026.5.7") + await expect(page.locator(TOAST_ACTION_SELECTOR)).toHaveText("Full release notes →") + // Description's first segment must carry the v2026.5.6 tag, otherwise + // the older release's bullet would read as if it described the current + // version (the title says v2026.5.7 but summaries[0] here is v2026.5.6 + // because the English fallback dropped v2026.5.7). + await expect(page.locator(TOAST_DESCRIPTION_SELECTOR)).toContainText("v2026.5.6") + await expect(page.locator(TOAST_DESCRIPTION_SELECTOR)).toContainText("• older en-only bullet") + }) + + test("falls back to English across the whole window when an older skipped release has no zh section", async ({ + page, + gotoSession, + }) => { + await page.route(RELEASES_URL_PATTERN, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + tag_name: "v2026.5.7", + body: [ + "## App Update Notice", + "", + "- newest en bullet", + "", + "## 中文版本", + "", + "### 主要更新", + "", + "- 最新中文 bullet", + ].join("\n"), + }, + { + tag_name: "v2026.5.6", + body: "## App Update Notice\n\n- older en only\n", + }, + ]), + }) + }) + + await page.addInitScript((highlightsKey) => { + localStorage.setItem(highlightsKey, JSON.stringify({ version: "2026.5.5" })) + localStorage.setItem(LANGUAGE_KEY, JSON.stringify({ locale: "zh" })) + }, HIGHLIGHTS_KEY) + + await gotoSession() + + await expect(page.locator(TOAST_SELECTOR)).toBeVisible({ timeout: 10_000 }) + // The newest release has a zh section but the older skipped release does + // not. Spec #486 forbids mixing languages, so the entire toast — title, + // action, and every segment — must be English. + await expect(page.locator(TOAST_TITLE_SELECTOR)).toHaveText("Updated to v2026.5.7") + await expect(page.locator(TOAST_ACTION_SELECTOR)).toHaveText("Full release notes →") + await expect(page.locator(TOAST_DESCRIPTION_SELECTOR)).toContainText("• newest en bullet") + await expect(page.locator(TOAST_DESCRIPTION_SELECTOR)).toContainText("• older en only") + await expect(page.locator(TOAST_DESCRIPTION_SELECTOR)).not.toContainText("中文") + }) +}) diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx deleted file mode 100644 index 26bed49b..00000000 --- a/packages/app/src/components/dialog-release-notes.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { createEffect, createSignal } 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 { useLanguage } from "@/context/language" -import { useSettings } from "@/context/settings" - -export type Highlight = { - title: string - description: string - media?: { - type: "image" | "video" - src: string - alt?: string - } -} - -export function DialogReleaseNotes(props: { highlights: Highlight[] }) { - const dialog = useDialog() - const language = useLanguage() - const settings = useSettings() - const [index, setIndex] = createSignal(0) - let descriptionRef: HTMLParagraphElement | undefined - - const total = () => props.highlights.length - const last = () => Math.max(0, total() - 1) - const feature = () => props.highlights[index()] ?? props.highlights[last()] - const isFirst = () => index() === 0 - const isLast = () => index() >= last() - const paged = () => total() > 1 - - createEffect(() => { - // Reset scroll position when page changes - index() - queueMicrotask(() => { - if (descriptionRef) descriptionRef.scrollTop = 0 - }) - }) - - function handleNext() { - if (isLast()) return - setIndex(index() + 1) - } - - function handleClose() { - dialog.close() - } - - function handleDisable() { - settings.general.setReleaseNotes(false) - handleClose() - } - - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - e.preventDefault() - handleClose() - return - } - - if (!paged()) return - if (e.key === "ArrowLeft" && !isFirst()) { - e.preventDefault() - setIndex(index() - 1) - } - if (e.key === "ArrowRight" && !isLast()) { - e.preventDefault() - setIndex(index() + 1) - } - } - - return ( - -
- {/* Left side - Text content */} -
- {/* Top section - feature content (fixed position from top) */} -
-
-

- {feature()?.title ?? ""} -

-
-

- {feature()?.description ?? ""} -

-
- - {/* Bottom section - buttons and indicators (fixed position) */} -
-
- {isLast() ? ( - - ) : ( - - )} - - -
- - {paged() && ( -
- {props.highlights.map((_, i) => ( - - ))} -
- )} -
-
- - {/* Right side - Media content (edge to edge) */} - {feature()?.media && ( -
- {feature()!.media!.type === "image" ? ( - {feature()!.media!.alt - ) : ( -
- )} -
-
- ) -} diff --git a/packages/app/src/context/highlights.test.ts b/packages/app/src/context/highlights.test.ts index e076c44c..dbcea963 100644 --- a/packages/app/src/context/highlights.test.ts +++ b/packages/app/src/context/highlights.test.ts @@ -13,8 +13,9 @@ describe("loadReleaseHighlights (GitHub Releases API)", () => { const highlights = loadReleaseHighlights(payload, "0.2.3", "0.2.2", "en") expect(highlights).toHaveLength(1) expect(highlights[0]).toMatchObject({ - title: "PawWork v0.2.3", + tag: "v0.2.3", description: "Fixed first-message crash", + localeUsed: "en", }) }) @@ -39,8 +40,9 @@ describe("loadReleaseHighlights (GitHub Releases API)", () => { const highlights = loadReleaseHighlights(payload, "0.2.10", "0.2.9", "zh") expect(highlights).toHaveLength(1) expect(highlights[0]).toMatchObject({ - title: "爪印 v0.2.10", + tag: "v0.2.10", description: "• 修复首条消息崩溃\n• 调整更新提示", + localeUsed: "zh", }) }) @@ -63,8 +65,9 @@ describe("loadReleaseHighlights (GitHub Releases API)", () => { const highlights = loadReleaseHighlights(payload, "0.2.10", "0.2.9", "zh") expect(highlights).toHaveLength(1) expect(highlights[0]).toMatchObject({ - title: "爪印 v0.2.10", + tag: "v0.2.10", description: "• 修复首条消息崩溃\n• 调整更新提示", + localeUsed: "zh", }) }) @@ -78,8 +81,9 @@ describe("loadReleaseHighlights (GitHub Releases API)", () => { const highlights = loadReleaseHighlights(payload, "0.2.10", "0.2.9", "zh") expect(highlights).toHaveLength(1) expect(highlights[0]).toMatchObject({ - title: "爪印 v0.2.10", + tag: "v0.2.10", description: "• Fixed first-message crash", + localeUsed: "en", }) }) @@ -191,15 +195,87 @@ describe("loadReleaseHighlights (GitHub Releases API)", () => { const highlights = loadReleaseHighlights(payload, "1.0.6", "1.0.0", "en") expect(highlights).toHaveLength(5) - expect(highlights.map((highlight) => highlight.title)).toEqual([ - "PawWork v1.0.6", - "PawWork v1.0.5", - "PawWork v1.0.4", - "PawWork v1.0.3", - "PawWork v1.0.2", + expect(highlights.map((highlight) => highlight.tag)).toEqual([ + "v1.0.6", + "v1.0.5", + "v1.0.4", + "v1.0.3", + "v1.0.2", ]) }) + test("returns up to 5 skipped versions newest-first", () => { + const payload = Array.from({ length: 7 }, (_, i) => { + const patch = 7 - i + return { tag_name: `v1.0.${patch}`, body: `## App Update Notice\n\n- item ${patch}\n` } + }) + const result = loadReleaseHighlights(payload, "1.0.7", "1.0.0", "en") + expect(result).toHaveLength(5) + expect(result.map((r) => r.tag)).toEqual(["v1.0.7", "v1.0.6", "v1.0.5", "v1.0.4", "v1.0.3"]) + }) + + test("re-resolves the whole window in English when any merged segment lacks the user's locale", () => { + const payload = [ + { + tag_name: "v2026.5.7", + body: [ + "## App Update Notice", + "", + "- newest en bullet", + "", + "## 中文版本", + "", + "### 主要更新", + "", + "- 最新中文 bullet", + ].join("\n"), + }, + { + tag_name: "v2026.5.6", + body: "## App Update Notice\n\n- older en only\n", + }, + ] + const highlights = loadReleaseHighlights(payload, "2026.5.7", "2026.5.5", "zh") + expect(highlights).toHaveLength(2) + expect(highlights.every((h) => h.localeUsed === "en")).toBe(true) + expect(highlights[0].description).toBe("• newest en bullet") + expect(highlights[1].description).toBe("• older en only") + }) + + test("keeps zh locale when every selected release has a Chinese section", () => { + const payload = [ + { + tag_name: "v2026.5.7", + body: ["## App Update Notice", "", "- en a", "", "## 中文版本", "", "- zh a"].join("\n"), + }, + { + tag_name: "v2026.5.6", + body: ["## App Update Notice", "", "- en b", "", "## 中文版本", "", "- zh b"].join("\n"), + }, + ] + const highlights = loadReleaseHighlights(payload, "2026.5.7", "2026.5.5", "zh") + expect(highlights).toHaveLength(2) + expect(highlights.every((h) => h.localeUsed === "zh")).toBe(true) + expect(highlights[0].description).toBe("• zh a") + expect(highlights[1].description).toBe("• zh b") + }) + + test("respects previous boundary when an intermediate release lacks an update notice", () => { + const payload = [ + { tag_name: "v1.0.5", body: "## App Update Notice\n\n- v1.0.5 item\n" }, + { tag_name: "v1.0.4", body: "## Downloads\n\n- [macOS](https://example.com/app.dmg)\n" }, + { tag_name: "v1.0.3", body: "## App Update Notice\n\n- v1.0.3 item\n" }, + { tag_name: "v1.0.2", body: "## App Update Notice\n\n- v1.0.2 item\n" }, + ] + const highlights = loadReleaseHighlights(payload, "1.0.5", "1.0.4", "en") + expect(highlights.map((h) => h.tag)).toEqual(["v1.0.5"]) + }) + + test("returns empty when current version is not in changelog", () => { + const payload = [{ tag_name: "v1.0.0", body: "## App Update Notice\n\n- item\n" }] + expect(loadReleaseHighlights(payload, "9.9.9", "1.0.0", "en")).toEqual([]) + }) + test("does not cap bullets inside a single version page", () => { const payload = [ { @@ -251,112 +327,6 @@ describe("loadReleaseHighlights (GitHub Releases API)", () => { expect(loadReleaseHighlights(payload, "0.2.4", "0.2.3", "en")).toHaveLength(0) }) - test("keeps backward compatibility with the structured highlights schema", () => { - const payload = [ - { - tag: "v0.2.5", - highlights: [ - { - source: "desktop", - items: [{ title: "Card Title", description: "Card Description" }], - }, - ], - }, - ] - const highlights = loadReleaseHighlights(payload, "0.2.5", "0.2.4", "zh") - expect(highlights).toHaveLength(1) - expect(highlights[0]).toMatchObject({ title: "Card Title", description: "Card Description" }) - }) - - test("does not rewrite structured highlight titles for zh locale", () => { - const payload = [ - { - tag: "v0.2.5", - highlights: [ - { - source: "desktop", - items: [{ title: "PawWork card", description: "Card Description" }], - }, - ], - }, - ] - const highlights = loadReleaseHighlights(payload, "0.2.5", "0.2.4", "zh") - expect(highlights[0]?.title).toBe("PawWork card") - }) - - test("keeps structured highlight items as separate pages", () => { - const payload = [ - { - tag: "v0.2.5", - highlights: [ - { - source: "desktop", - items: [ - { title: "Card A", description: "Description A" }, - { title: "Card B", description: "Description B" }, - ], - }, - ], - }, - ] - - const highlights = loadReleaseHighlights(payload, "0.2.5", "0.2.4", "en") - - expect(highlights).toHaveLength(2) - expect(highlights.map((highlight) => highlight.title)).toEqual(["Card A", "Card B"]) - }) - - test("keeps structured highlight cap independent from release-body version cap", () => { - const payload = [ - { - tag: "v0.2.5", - highlights: [ - { - source: "desktop", - items: Array.from({ length: 6 }, (_, index) => ({ - title: `Card ${index + 1}`, - description: `Description ${index + 1}`, - })), - }, - ], - }, - ] - - const highlights = loadReleaseHighlights(payload, "0.2.5", "0.2.4", "en") - - expect(highlights).toHaveLength(6) - expect(highlights.map((highlight) => highlight.title)).toEqual([ - "Card 1", - "Card 2", - "Card 3", - "Card 4", - "Card 5", - "Card 6", - ]) - }) - - test("keeps structured highlight limit at fifteen cards", () => { - const payload = [ - { - tag: "v0.2.5", - highlights: [ - { - source: "desktop", - items: Array.from({ length: 16 }, (_, index) => ({ - title: `Card ${index + 1}`, - description: `Description ${index + 1}`, - })), - }, - ], - }, - ] - - const highlights = loadReleaseHighlights(payload, "0.2.5", "0.2.4", "en") - - expect(highlights).toHaveLength(15) - expect(highlights.at(-1)?.title).toBe("Card 15") - }) - test("preserves intro prose before bullets in mixed content notices", () => { const payload = [ { diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx index e04b542a..9dc8db27 100644 --- a/packages/app/src/context/highlights.tsx +++ b/packages/app/src/context/highlights.tsx @@ -1,28 +1,36 @@ import { createEffect, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import { useDialog } from "@opencode-ai/ui/context/dialog" +import { showToast } from "@opencode-ai/ui/toast" import { type Locale, useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { persisted } from "@/utils/persist" -import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes" const CHANGELOG_URL = "https://api.github.com/repos/Astro-Han/pawwork/releases" const MAX_RELEASE_VERSION_PAGES = 5 -const MAX_STRUCTURED_HIGHLIGHTS = 15 type Store = { version?: string } -type ParsedRelease = { - tag?: string - highlights: Highlight[] - source: "release-body" | "structured" +type ReleaseLocale = Locale + +export type ReleaseSummary = { + tag: string + description: string + localeUsed: ReleaseLocale } -type ReleaseLocale = Locale +// Locale-aware copy lives here, not in i18n/{en,zh}.ts, because the toast +// title and action must follow the *parsed body's* locale, not the user's +// UI locale, when the GitHub release lacks the user's locale section +// (spec: title and description must never mix languages). @solid-primitives/i18n +// has no per-call locale override, so a small inline table is the cleanest path. +const TOAST_COPY: Record string; viewFull: string }> = { + en: { title: (v) => `Updated to ${v}`, viewFull: "Full release notes →" }, + zh: { title: (v) => `已更新到 ${v}`, viewFull: "查看完整发布说明 →" }, +} function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -44,29 +52,6 @@ function normalizeVersion(value: string | undefined) { return text.startsWith("v") || text.startsWith("V") ? text.slice(1) : text } -function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined { - if (!isRecord(value)) return - const type = getText(value.type)?.toLowerCase() - const src = getText(value.src) ?? getText(value.url) - if (!src) return - if (type !== "image" && type !== "video") return - - return { type, src, alt } -} - -function parseHighlight(value: unknown): Highlight | undefined { - if (!isRecord(value)) return - - const title = getText(value.title) - if (!title) return - - const description = getText(value.description) ?? getText(value.shortDescription) - if (!description) return - - const media = parseMedia(value.media, title) - return { title, description, media } -} - function findHeadingSection(body: string, matcher: RegExp): string | undefined { const lines = body.split(/\r?\n/) const start = lines.findIndex((line) => matcher.test(line.trim())) @@ -122,7 +107,6 @@ function parseNoticeContent(notice: string | undefined): ParsedNotice | undefine for (const line of lines) { if (line.length === 0) { - // Empty line acts as a paragraph break: flush any active bullet if (currentBullet) { bullets.push(trimNoticeItem(currentBullet)) currentBullet = undefined @@ -143,7 +127,6 @@ function parseNoticeContent(notice: string | undefined): ParsedNotice | undefine } else if (!hasSeenBullet) { prose.push(line) } - // trailing prose after last bullet is ignored (intentionally) } if (currentBullet) bullets.push(trimNoticeItem(currentBullet)) @@ -165,12 +148,16 @@ function parseNoticeContent(notice: string | undefined): ParsedNotice | undefine } } -function parseReleaseBodyNotice(body: string, locale: ReleaseLocale): ParsedNotice | undefined { +function parseReleaseBodyNotice( + body: string, + locale: ReleaseLocale, +): { notice: ParsedNotice; localeUsed: ReleaseLocale } | undefined { if (locale === "zh") { const chinese = parseNoticeContent(findChineseUpdateNotice(body)) - if (chinese) return chinese + if (chinese) return { notice: chinese, localeUsed: "zh" } } - return parseNoticeContent(findAppUpdateNotice(body)) + const english = parseNoticeContent(findAppUpdateNotice(body)) + return english ? { notice: english, localeUsed: "en" } : undefined } function formatReleaseNoticeDescription(notice: ParsedNotice) { @@ -185,120 +172,63 @@ function formatReleaseNoticeDescription(notice: ParsedNotice) { return notice.text } -function releaseTitle(tag: string, locale: ReleaseLocale) { - return `${locale === "zh" ? "爪印" : "PawWork"} ${tag}` -} +// Carries the raw tag for every release in the GitHub Releases response, even +// when the body has no app-facing notice. Window-slicing must run on the full +// tag list — not the filtered summary list — otherwise a release whose body +// lacks an "App Update Notice" section silently drops out of the array, and +// `previous` may not be found, so the slice spills into older versions the +// user has already seen. +type RawRelease = { tag: string; summary: ReleaseSummary | undefined } -function parseRelease(value: unknown, locale: ReleaseLocale): ParsedRelease | undefined { +function parseRawRelease(value: unknown, locale: ReleaseLocale): RawRelease | undefined { if (!isRecord(value)) return const tag = getText(value.tag) ?? getText(value.tag_name) ?? getText(value.name) - - if (Array.isArray(value.highlights)) { - const highlights = value.highlights.flatMap((group) => { - if (!isRecord(group)) return [] - - const source = getText(group.source) - if (!source) return [] - if (!source.toLowerCase().includes("desktop")) return [] - - if (Array.isArray(group.items)) { - return group.items.map((item) => parseHighlight(item)).filter((item): item is Highlight => item !== undefined) - } - - const item = parseHighlight(group) - if (!item) return [] - return [item] - }) - - return { tag, highlights, source: "structured" } - } + if (!tag) return const body = getText(value.body) - if (tag && body) { - const notice = parseReleaseBodyNotice(body, locale) - if (notice) { - return { - tag, - source: "release-body", - highlights: [ - { - title: releaseTitle(tag, locale), - description: formatReleaseNoticeDescription(notice), - }, - ], - } - } - } + if (!body) return { tag, summary: undefined } - return { tag, highlights: [], source: "release-body" } -} + const parsed = parseReleaseBodyNotice(body, locale) + if (!parsed) return { tag, summary: undefined } -function parseChangelog(value: unknown, locale: ReleaseLocale): ParsedRelease[] | undefined { - if (Array.isArray(value)) { - return value - .map((release) => parseRelease(release, locale)) - .filter((release): release is ParsedRelease => release !== undefined) + return { + tag, + summary: { + tag, + description: formatReleaseNoticeDescription(parsed.notice), + localeUsed: parsed.localeUsed, + }, } +} - if (!isRecord(value)) return - if (!Array.isArray(value.releases)) return - - return value.releases - .map((release) => parseRelease(release, locale)) - .filter((release): release is ParsedRelease => release !== undefined) +function parseChangelog(value: unknown, locale: ReleaseLocale): RawRelease[] | undefined { + if (!Array.isArray(value)) return + return value + .map((release) => parseRawRelease(release, locale)) + .filter((release): release is RawRelease => release !== undefined) } -function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; previous?: string }) { +function sliceHighlights(input: { + releases: RawRelease[] + current?: string + previous?: string +}): ReleaseSummary[] { const current = normalizeVersion(input.current) const previous = normalizeVersion(input.previous) - const releases = input.releases - - const start = (() => { - if (!current) return 0 - const index = releases.findIndex((release) => normalizeVersion(release.tag) === current) - return index === -1 ? 0 : index - })() - - const end = (() => { - if (!previous) return releases.length - const index = releases.findIndex((release, i) => i >= start && normalizeVersion(release.tag) === previous) - return index === -1 ? releases.length : index - })() - - const selected = releases.slice(start, end) - const highlights: Highlight[] = [] - let releaseBodyPages = 0 - let structuredHighlights = 0 - - for (const release of selected) { - if (release.source === "release-body") { - if (release.highlights.length === 0) continue - if (releaseBodyPages >= MAX_RELEASE_VERSION_PAGES) continue - - highlights.push(...release.highlights) - releaseBodyPages += 1 - continue - } - - const remaining = MAX_STRUCTURED_HIGHLIGHTS - structuredHighlights - if (remaining <= 0) continue - - const next = release.highlights.slice(0, remaining) - highlights.push(...next) - structuredHighlights += next.length - } - - const seen = new Set() - return highlights.filter((highlight) => { - const key = dedupeKey(highlight) - if (seen.has(key)) return false - seen.add(key) - return true - }) -} - -function dedupeKey(highlight: Highlight) { - return [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join("\n") + const startIndex = current + ? input.releases.findIndex((r) => normalizeVersion(r.tag) === current) + : 0 + if (startIndex === -1) return [] + + const endIndex = previous + ? input.releases.findIndex((r, i) => i >= startIndex && normalizeVersion(r.tag) === previous) + : input.releases.length + + const sliced = input.releases.slice(startIndex, endIndex === -1 ? undefined : endIndex) + return sliced + .map((r) => r.summary) + .filter((s): s is ReleaseSummary => s !== undefined) + .slice(0, MAX_RELEASE_VERSION_PAGES) } export function loadReleaseHighlights( @@ -306,10 +236,39 @@ export function loadReleaseHighlights( current?: string, previous?: string, locale: ReleaseLocale = "en", -) { - const releases = parseChangelog(value, locale) - if (!releases?.length) return [] - return sliceHighlights({ releases, current, previous }) +): ReleaseSummary[] { + const tryLocale = (lc: ReleaseLocale): ReleaseSummary[] => { + const releases = parseChangelog(value, lc) + if (!releases?.length) return [] + return sliceHighlights({ releases, current, previous }) + } + + const summaries = tryLocale(locale) + if (summaries.length === 0) return summaries + // Spec #486: title and description must never mix languages. If any + // selected release fell back to a different locale (e.g. user is zh, + // newest has a zh section but an older skipped release does not), + // re-resolve the entire window in English so every segment — and the + // toast title and action — share a single locale. + if (summaries.some((s) => s.localeUsed !== locale)) { + return tryLocale("en") + } + return summaries +} + +function buildToastDescription(summaries: ReleaseSummary[], currentTag: string) { + // First segment omits its tag only when it matches the toast title's + // version. When summaries[0] is an older release (e.g. zh-locale fallback + // dropped a zh-only newest release that has no English notice), the first + // segment must carry its tag too, otherwise it reads as if the older + // release's bullets describe the current version. + return summaries + .map((s, i) => + i === 0 && normalizeVersion(s.tag) === normalizeVersion(currentTag) + ? s.description + : `${s.tag}\n${s.description}`, + ) + .join("\n\n") } export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({ @@ -318,7 +277,6 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple init: () => { const language = useLanguage() const platform = usePlatform() - const dialog = useDialog() const settings = useSettings() const [store, setStore, _, ready] = persisted("highlights.v1", createStore({ version: undefined })) @@ -359,25 +317,48 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple }) .then((response) => { if (response.ok) return response.json() as Promise - // GitHub returns 403 (rate limit) or 304 (etag-hit) under normal load; - // keep the failure visible in devtools instead of silently dropping it. console.warn("[highlights] changelog fetch failed", response.status) return undefined }) .then((json) => { if (!json) return - const highlights = loadReleaseHighlights(json, platform.version, previous, language.locale()) + const summaries = loadReleaseHighlights(json, platform.version, previous, language.locale()) if (controller.signal.aborted) return - if (highlights.length === 0) { + if (summaries.length === 0) { markSeen() return } + // Defer 500ms so the toast region has mounted and the splash has + // settled before the toast slides in — avoids a visual collision + // on first launch after an update. timer = setTimeout(() => { timer = undefined - markSeen() - dialog.show(() => ) + // Title and link always anchor on the app's current version, + // not summaries[0].tag. The two diverge when the current + // release has no notice in the resolved locale (e.g. zh user, + // newest release has only a 中文版本 section, an older skipped + // release has only English; English rebuild drops the newest + // release and summaries[0] becomes the older one). + const currentTag = `v${platform.version}` + const copy = TOAST_COPY[summaries[0].localeUsed] + const url = `https://github.com/Astro-Han/pawwork/releases/tag/${currentTag}` + + showToast({ + title: copy.title(currentTag), + description: buildToastDescription(summaries, currentTag), + icon: "bullet-list", + variant: "subtle", + persistent: true, + actions: [ + { + label: copy.viewFull, + onClick: () => platform.openLink(url), + }, + ], + onDismiss: markSeen, + }) }, 500) }) .catch(() => undefined) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index b8d9039f..19364b83 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -391,11 +391,6 @@ export const dict = { "dialog.project.edit.icon.readFailed.title": "Failed to read image", "dialog.project.edit.icon.readFailed.description": "The selected file could not be loaded. Please try another image.", - "dialog.releaseNotes.action.getStarted": "Get started", - "dialog.releaseNotes.action.next": "Next", - "dialog.releaseNotes.action.hideFuture": "Don't show these in the future", - "dialog.releaseNotes.media.alt": "Release preview", - "context.breakdown.title": "Context Breakdown", "context.breakdown.note": 'Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.', "context.breakdown.system": "System", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index c8d5d5e0..76ab43c7 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -1016,10 +1016,6 @@ export const dict = { "workspace.reset.archived.many": "将归档 {{count}} 个会话。", "workspace.reset.note": "这将把工作区重置为与默认分支一致。", "common.open": "打开", - "dialog.releaseNotes.action.getStarted": "开始", - "dialog.releaseNotes.action.next": "下一步", - "dialog.releaseNotes.action.hideFuture": "不再显示", - "dialog.releaseNotes.media.alt": "发布预览", "toast.project.reloadFailed.title": "无法重新加载 {{project}}", "error.server.invalidConfiguration": "配置无效", "common.moreCountSuffix": " (还有 {{count}} 个)", diff --git a/packages/opencode/test/config/e2e-smoke-tagging.test.ts b/packages/opencode/test/config/e2e-smoke-tagging.test.ts index 391e0f29..fe1cf9f9 100644 --- a/packages/opencode/test/config/e2e-smoke-tagging.test.ts +++ b/packages/opencode/test/config/e2e-smoke-tagging.test.ts @@ -15,6 +15,7 @@ const expectedSmokeTests = [ "packages/app/e2e/files/file-tree.spec.ts:@smoke review tab no longer renders the legacy file-tree sub-panel", "packages/app/e2e/prompt/first-message-reply.spec.ts:@smoke first replied message in a new session renders without page errors", "packages/app/e2e/prompt/prompt.spec.ts:@smoke can send a prompt and receive a reply", + "packages/app/e2e/release-notes/release-notes-toast.spec.ts:@smoke shows subtle toast when stored version is older than current", "packages/app/e2e/settings/settings.spec.ts:@smoke PawWork settings opens as a full-pane surface, not a dialog", "packages/app/e2e/settings/settings.spec.ts:@smoke new installs start with the PawWork theme", "packages/app/e2e/settings/settings.spec.ts:@smoke settings dialog opens, switches tabs, closes", diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css index c245eca2..6e144bdd 100644 --- a/packages/ui/src/components/toast.css +++ b/packages/ui/src/components/toast.css @@ -85,6 +85,16 @@ /* border-color: var(--fg-base); */ /* } */ + &[data-variant="subtle"] { + background: var(--surface-raised); + color: var(--fg-strong); + border-color: var(--border-weak); + + [data-slot="toast-description"] { + white-space: pre-line; + } + } + [data-slot="toast-icon"] { flex-shrink: 0; display: flex; diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx index 599cf2a9..d0a49eb1 100644 --- a/packages/ui/src/components/toast.tsx +++ b/packages/ui/src/components/toast.tsx @@ -1,7 +1,7 @@ import { Toast as Kobalte, toaster } from "@kobalte/core/toast" import type { ToastRootProps, ToastCloseButtonProps, ToastTitleProps, ToastDescriptionProps } from "@kobalte/core/toast" import type { ComponentProps, JSX } from "solid-js" -import { Show } from "solid-js" +import { onCleanup, Show } from "solid-js" import { Portal } from "solid-js/web" import { useI18n } from "../context/i18n" import { Icon, type IconProps } from "./icon" @@ -98,7 +98,7 @@ export const Toast = Object.assign(ToastRoot, { export { toaster } -export type ToastVariant = "default" | "success" | "error" | "loading" +export type ToastVariant = "default" | "success" | "error" | "loading" | "subtle" export interface ToastAction { label: string @@ -113,48 +113,96 @@ export interface ToastOptions { duration?: number persistent?: boolean actions?: ToastAction[] + onDismiss?: () => void } +// Module-scope set used by dismissToast() to flag a toast id as a +// user-equivalent dismiss before toaster.dismiss runs. The render closure's +// onCleanup consults this set so external programmatic dismissals fire +// onDismiss the same as a CloseButton click. Raw toaster.dismiss(id) (used +// by promise-toast's internal lifecycle, etc.) stays as ambient teardown. +const userDismissedToastIds = new Set() + export function showToast(options: ToastOptions | string) { const opts = typeof options === "string" ? { description: options } : options - return toaster.show((props) => ( - - - - - - - {opts.title} - - - {opts.description} + return toaster.show((props) => { + // onCleanup runs when the toast root unmounts. That covers explicit dismiss + // paths (close button, action click, swipe, escape, dismissToast wrapper) + // AND ambient unmounts (parent owner teardown, e.g. app exit, raw + // toaster.dismiss). For callers with "user-acknowledged" semantics (e.g. + // markSeen on release notes), we gate onDismiss behind a flag set only by + // user-driven dismiss handlers. Kobalte's swipe-end and escape paths call + // close() directly without going through CloseButton's onClick, so they + // need their own handlers — see below. The fired guard is defense + // against future Kobalte upgrades re-invoking this render closure. + let userDismissed = false + let fired = false + const markUserDismissed = () => { + userDismissed = true + } + if (opts.onDismiss) { + onCleanup(() => { + if (fired) return + fired = true + const flaggedExternally = userDismissedToastIds.delete(props.toastId) + if (!userDismissed && !flaggedExternally) return + opts.onDismiss?.() + }) + } + return ( + + + - - - {opts.actions!.map((action) => ( - - ))} - - - - - - )) + + + {opts.title} + + + {opts.description} + + + + {opts.actions!.map((action) => ( + + ))} + + + + + + ) + }) +} + +// Programmatic dismiss that counts as a user-equivalent dismiss for +// onDismiss callbacks (e.g. release-notes markSeen). External callers +// should use this in preference to raw toaster.dismiss(id) when their +// caller intent is "the user is done with this toast". +export function dismissToast(toastId: number) { + userDismissedToastIds.add(toastId) + toaster.dismiss(toastId) } export interface ToastPromiseOptions {