From 6989634a5b43ff2392749aeed3d79a3f066f2450 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 7 May 2026 15:23:14 +0800 Subject: [PATCH 01/18] feat(ui): add subtle toast variant and onDismiss hook The subtle variant uses surface-raised + fg-strong + border-weak for non-urgent informational notices, distinct from the default deep tone reserved for action receipts and errors. pre-line whitespace preserves multi-line content. The onDismiss option fires once when the toast root unmounts via solid-js onCleanup, covering close button, action click, and programmatic dismiss without leaking Kobalte lifecycle internals to consumers. --- packages/ui/src/components/toast.css | 10 ++++ packages/ui/src/components/toast.tsx | 89 ++++++++++++++++------------ 2 files changed, 60 insertions(+), 39 deletions(-) diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css index c245eca20..6e144bdd9 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 599cf2a9e..44f985c6e 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,59 @@ export interface ToastOptions { duration?: number persistent?: boolean actions?: ToastAction[] + onDismiss?: () => void } 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) => { + let dismissed = false + if (opts.onDismiss) { + onCleanup(() => { + if (dismissed) return + dismissed = true + opts.onDismiss?.() + }) + } + return ( + + + - - - {opts.actions!.map((action) => ( - - ))} - - - - - - )) + + + {opts.title} + + + {opts.description} + + + + {opts.actions!.map((action) => ( + + ))} + + + + + + ) + }) } export interface ToastPromiseOptions { From 5e8bbb9e2d5e753e02181637c55171af8274b93d Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 7 May 2026 15:25:55 +0800 Subject: [PATCH 02/18] feat(app): replace release notes dialog with subtle toast Reshape parser to ReleaseSummary { tag, description, localeUsed }; drop the structured-highlights branch (Array.isArray(value.highlights), the value.releases wrapper accessor, parseHighlight/parseMedia/Highlight.media, MAX_STRUCTURED_HIGHLIGHTS) which has never been used in any release. Switch the controller from dialog.show to showToast with the new subtle variant. markSeen moves from show-time to dismiss-time via onDismiss so close button, action click, and programmatic dismiss all converge on one hook. For locale fallback, ReleaseSummary carries the locale the parser ended up using; when the user-locale section is missing the toast title and action copy fall back together with the description, avoiding mixed- language output. The localized copy lives in TOAST_COPY since @solid-primitives/i18n has no per-call locale override. When the current platform version is not in the changelog (dev builds, custom tags) sliceHighlights returns empty rather than slicing from index 0 and surfacing every release. Multi-version merges keep the newest segment description-only (its tag is in the title) and prefix subsequent segments with their tag. Cap stays at 5 versions; older skipped releases remain accessible via the GitHub link. --- packages/app/src/context/highlights.test.ts | 145 +++---------- packages/app/src/context/highlights.tsx | 212 +++++++------------- 2 files changed, 99 insertions(+), 258 deletions(-) diff --git a/packages/app/src/context/highlights.test.ts b/packages/app/src/context/highlights.test.ts index e076c44cf..f711a8efb 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,30 @@ 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("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 +270,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 e04b542ab..b0a3ea887 100644 --- a/packages/app/src/context/highlights.tsx +++ b/packages/app/src/context/highlights.tsx @@ -1,28 +1,31 @@ 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 +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 +47,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 +102,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 +122,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 +143,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 +167,45 @@ function formatReleaseNoticeDescription(notice: ParsedNotice) { return notice.text } -function releaseTitle(tag: string, locale: ReleaseLocale) { - return `${locale === "zh" ? "爪印" : "PawWork"} ${tag}` -} - -function parseRelease(value: unknown, locale: ReleaseLocale): ParsedRelease | undefined { +function parseRelease(value: unknown, locale: ReleaseLocale): ReleaseSummary | 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 - return { tag, highlights: [], source: "release-body" } -} + const parsed = parseReleaseBodyNotice(body, locale) + if (!parsed) return -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, + description: formatReleaseNoticeDescription(parsed.notice), + localeUsed: parsed.localeUsed, } +} - if (!isRecord(value)) return - if (!Array.isArray(value.releases)) return - - return value.releases +function parseChangelog(value: unknown, locale: ReleaseLocale): ReleaseSummary[] | undefined { + if (!Array.isArray(value)) return + return value .map((release) => parseRelease(release, locale)) - .filter((release): release is ParsedRelease => release !== undefined) + .filter((release): release is ReleaseSummary => release !== undefined) } -function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; previous?: string }) { +function sliceHighlights(input: { releases: ReleaseSummary[]; current?: string; previous?: string }) { 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 startIndex = current + ? input.releases.findIndex((r) => normalizeVersion(r.tag) === current) + : 0 + if (startIndex === -1) return [] - const seen = new Set() - return highlights.filter((highlight) => { - const key = dedupeKey(highlight) - if (seen.has(key)) return false - seen.add(key) - return true - }) -} + const endIndex = previous + ? input.releases.findIndex((r, i) => i >= startIndex && normalizeVersion(r.tag) === previous) + : input.releases.length -function dedupeKey(highlight: Highlight) { - return [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join("\n") + const sliced = input.releases.slice(startIndex, endIndex === -1 ? undefined : endIndex) + return sliced.slice(0, MAX_RELEASE_VERSION_PAGES) } export function loadReleaseHighlights( @@ -312,13 +219,20 @@ export function loadReleaseHighlights( return sliceHighlights({ releases, current, previous }) } +function buildToastDescription(summaries: ReleaseSummary[]) { + if (summaries.length === 1) return summaries[0].description + return [ + summaries[0].description, + ...summaries.slice(1).map((s) => `${s.tag}\n${s.description}`), + ].join("\n\n") +} + export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({ name: "Highlights", gate: false, 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 +273,39 @@ 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 } timer = setTimeout(() => { timer = undefined - markSeen() - dialog.show(() => ) + const newest = summaries[0] + const copy = TOAST_COPY[newest.localeUsed] + const url = `https://github.com/Astro-Han/pawwork/releases/tag/${newest.tag}` + + showToast({ + title: copy.title(newest.tag), + description: buildToastDescription(summaries), + icon: "bullet-list", + variant: "subtle", + persistent: true, + actions: [ + { + label: copy.viewFull, + onClick: () => platform.openLink(url), + }, + ], + onDismiss: markSeen, + }) }, 500) }) .catch(() => undefined) From d8788a6377ec89af2b580359338e417bcecf27f4 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 7 May 2026 15:26:10 +0800 Subject: [PATCH 03/18] chore(app): remove DialogReleaseNotes The release-notes dialog is fully replaced by the subtle toast in highlights.tsx. Nothing else imports DialogReleaseNotes or the file itself. --- .../src/components/dialog-release-notes.tsx | 160 ------------------ 1 file changed, 160 deletions(-) delete mode 100644 packages/app/src/components/dialog-release-notes.tsx 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 26bed49b9..000000000 --- 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 - ) : ( -
- )} -
-
- ) -} From c2b68405ad86cd469c0994a567c3cfd4f06ec1b4 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 7 May 2026 15:27:57 +0800 Subject: [PATCH 04/18] feat(app): rename release notes i18n keys to toast namespace The dialog.releaseNotes.* keys (getStarted, next, hideFuture, media.alt) served the deleted DialogReleaseNotes component and have no consumers left. Replace with toast.releaseNotes.{title,action.viewFull} for future consumers that want locale-following copy. Today's release-notes toast goes through TOAST_COPY in highlights.tsx so it can honor the parser's effective locale on fallback; the i18n strings happen to match the en/zh values in TOAST_COPY. --- packages/app/src/i18n/en.ts | 6 ++---- packages/app/src/i18n/zh.ts | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index b8d9039f7..41e5b6268 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -391,10 +391,8 @@ 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", + "toast.releaseNotes.title": "Updated to {{version}}", + "toast.releaseNotes.action.viewFull": "Full release notes →", "context.breakdown.title": "Context Breakdown", "context.breakdown.note": 'Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.', diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index c8d5d5e08..0e4d9ada3 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -1016,10 +1016,8 @@ 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.releaseNotes.title": "已更新到 {{version}}", + "toast.releaseNotes.action.viewFull": "查看完整发布说明 →", "toast.project.reloadFailed.title": "无法重新加载 {{project}}", "error.server.invalidConfiguration": "配置无效", "common.moreCountSuffix": " (还有 {{count}} 个)", From 48ccb809c4fdb2d4113a64f4aae773dfafb936cb Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 7 May 2026 15:32:32 +0800 Subject: [PATCH 05/18] refactor(app): drop unused release notes toast i18n keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toast.releaseNotes.* keys added in the previous commit were never read: TOAST_COPY in highlights.tsx owns the title and action strings because the parsed body's locale (not the user's UI locale) drives the copy when GitHub releases lack the user's locale section — the @solid-primitives/i18n helper has no per-call locale override, so a small inline copy table is the cleanest path. Document the rationale inline so future maintainers don't restore the keys reflexively. Also add a one-line comment on toast.tsx onCleanup explaining that it covers all dismiss paths (close button, action click, swipe, programmatic), which is the contract consumers rely on. --- packages/app/src/context/highlights.tsx | 5 +++++ packages/app/src/i18n/en.ts | 3 --- packages/app/src/i18n/zh.ts | 2 -- packages/ui/src/components/toast.tsx | 4 ++++ 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx index b0a3ea887..b7eb33be1 100644 --- a/packages/app/src/context/highlights.tsx +++ b/packages/app/src/context/highlights.tsx @@ -22,6 +22,11 @@ export type ReleaseSummary = { localeUsed: ReleaseLocale } +// 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: "查看完整发布说明 →" }, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 41e5b6268..19364b835 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -391,9 +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.", - "toast.releaseNotes.title": "Updated to {{version}}", - "toast.releaseNotes.action.viewFull": "Full release notes →", - "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 0e4d9ada3..76ab43c7f 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -1016,8 +1016,6 @@ export const dict = { "workspace.reset.archived.many": "将归档 {{count}} 个会话。", "workspace.reset.note": "这将把工作区重置为与默认分支一致。", "common.open": "打开", - "toast.releaseNotes.title": "已更新到 {{version}}", - "toast.releaseNotes.action.viewFull": "查看完整发布说明 →", "toast.project.reloadFailed.title": "无法重新加载 {{project}}", "error.server.invalidConfiguration": "配置无效", "common.moreCountSuffix": " (还有 {{count}} 个)", diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx index 44f985c6e..0b1e77dc9 100644 --- a/packages/ui/src/components/toast.tsx +++ b/packages/ui/src/components/toast.tsx @@ -119,6 +119,10 @@ export interface ToastOptions { export function showToast(options: ToastOptions | string) { const opts = typeof options === "string" ? { description: options } : options return toaster.show((props) => { + // onCleanup runs when the toast root unmounts (close button, action click, + // swipe, programmatic toaster.dismiss) — covering all dismiss paths in one + // hook. The dismissed guard is defense against future Kobalte upgrades + // re-invoking this render closure. let dismissed = false if (opts.onDismiss) { onCleanup(() => { From 27e6601661d9f43cc3d1e912dbaa13662052d57b Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 7 May 2026 15:43:46 +0800 Subject: [PATCH 06/18] test(app): e2e coverage for release notes toast Six Playwright tests cover the user-visible toast path: subtle variant appears with title/description/icon/action when the stored version is older than current; close button writes markSeen to localStorage; releaseNotes=false suppresses the toast; multi-version skipped releases merge with version labels; locale fallback keeps title and action in sync with the parsed body locale (en when zh section is missing); and zh title/action render when a zh release section is present. Mocks the GitHub Releases API via page.route and seeds highlights.v1 plus pawwork.global.dat:language via addInitScript so the controller's update path runs deterministically without network. --- .../release-notes/release-notes-toast.spec.ts | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 packages/app/e2e/release-notes/release-notes-toast.spec.ts 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 000000000..5438b8091 --- /dev/null +++ b/packages/app/e2e/release-notes/release-notes-toast.spec.ts @@ -0,0 +1,213 @@ +import { test, expect, settingsKey } from "../fixtures" + +const RELEASES_URL_PATTERN = "**/api.github.com/repos/Astro-Han/pawwork/releases**" +const HIGHLIGHTS_KEY = "highlights.v1" + +const singleReleasePayload = [ + { + 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 multiVersionPayload = [ + { + 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 localizedPayload = [ + { + tag_name: "v2026.5.7", + body: [ + "## App Update Notice", + "", + "- English bullet only", + "", + "## 中文版本", + "", + "### 主要更新", + "", + "- 中文要点 A", + "- 中文要点 B", + ].join("\n"), + }, +] + +const toastSelector = '[data-component="toast"][data-variant="subtle"]' +const toastTitleSelector = `${toastSelector} [data-slot="toast-title"]` +const toastDescriptionSelector = `${toastSelector} [data-slot="toast-description"]` +const toastActionSelector = `${toastSelector} [data-slot="toast-action"]` +const toastCloseButtonSelector = `${toastSelector} [data-slot="toast-close-button"]` +const toastIconSelector = `${toastSelector} [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(singleReleasePayload), + }) + }) + + await page.addInitScript((key) => { + localStorage.setItem(key, JSON.stringify({ version: "2026.5.6" })) + }, HIGHLIGHTS_KEY) + + await gotoSession() + + await expect(page.locator(toastSelector)).toBeVisible({ timeout: 10_000 }) + await expect(page.locator(toastTitleSelector)).toHaveText("Updated to v2026.5.7") + await expect(page.locator(toastDescriptionSelector)).toContainText("Important refresh for this release.") + await expect(page.locator(toastDescriptionSelector)).toContainText("• Added subtle toast variant") + await expect(page.locator(toastDescriptionSelector)).toContainText("• Replaced release notes dialog") + await expect(page.locator(toastActionSelector)).toHaveText("Full release notes →") + await expect(page.locator(toastIconSelector)).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(singleReleasePayload), + }) + }) + + await page.addInitScript((key) => { + localStorage.setItem(key, JSON.stringify({ version: "2026.5.6" })) + }, HIGHLIGHTS_KEY) + + await gotoSession() + await expect(page.locator(toastSelector)).toBeVisible({ timeout: 10_000 }) + + await page.locator(toastCloseButtonSelector).click() + await expect(page.locator(toastSelector)).toBeHidden() + + const stored = await page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? JSON.parse(raw) : null + }, HIGHLIGHTS_KEY) + expect(stored?.version).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(singleReleasePayload), + }) + }) + + 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() + + await page.waitForTimeout(1500) + await expect(page.locator(toastSelector)).toHaveCount(0) + + const stored = await page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? JSON.parse(raw) : null + }, HIGHLIGHTS_KEY) + expect(stored?.version).toBe("2026.5.7") + }) + + 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(multiVersionPayload), + }) + }) + + await page.addInitScript((key) => { + localStorage.setItem(key, JSON.stringify({ version: "2026.5.5" })) + }, HIGHLIGHTS_KEY) + + await gotoSession() + + await expect(page.locator(toastSelector)).toBeVisible({ timeout: 10_000 }) + await expect(page.locator(toastTitleSelector)).toHaveText("Updated to v2026.5.7") + const description = page.locator(toastDescriptionSelector) + 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("pawwork.global.dat:language", JSON.stringify({ locale: "zh" })) + }, HIGHLIGHTS_KEY) + + await gotoSession() + + await expect(page.locator(toastSelector)).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(toastTitleSelector)).toHaveText("Updated to v2026.5.7") + await expect(page.locator(toastActionSelector)).toHaveText("Full release notes →") + await expect(page.locator(toastDescriptionSelector)).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(localizedPayload), + }) + }) + + await page.addInitScript((highlightsKey) => { + localStorage.setItem(highlightsKey, JSON.stringify({ version: "2026.5.6" })) + localStorage.setItem("pawwork.global.dat:language", JSON.stringify({ locale: "zh" })) + }, HIGHLIGHTS_KEY) + + await gotoSession() + + await expect(page.locator(toastSelector)).toBeVisible({ timeout: 10_000 }) + await expect(page.locator(toastTitleSelector)).toHaveText("已更新到 v2026.5.7") + await expect(page.locator(toastActionSelector)).toHaveText("查看完整发布说明 →") + await expect(page.locator(toastDescriptionSelector)).toContainText("• 中文要点 A") + await expect(page.locator(toastDescriptionSelector)).toContainText("• 中文要点 B") + }) +}) From dfebfcb7f9069c42b971a508877b9cc7635f6670 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 7 May 2026 17:00:48 +0800 Subject: [PATCH 07/18] fix(app): only mark release seen on explicit user dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onCleanup also fires on ambient unmount (parent owner teardown / app exit), so wiring `onDismiss: markSeen` directly silently marked unseen releases as seen on app close. Spec #486 requires "quitting unread re-shows on next launch", so onDismiss must distinguish user-initiated dismissal from teardown. Add a userDismissed flag set only by close-button click and action-button click handlers. onCleanup gates the onDismiss callback behind that flag — ambient unmounts no longer fire onDismiss. Update inline comment to reflect the dual unmount semantics; document the 500ms toast defer rationale in highlights.tsx. Reported by CodeRabbit AI on PR #488. --- packages/app/src/context/highlights.tsx | 3 +++ packages/ui/src/components/toast.tsx | 25 +++++++++++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx index b7eb33be1..90bf0d8ed 100644 --- a/packages/app/src/context/highlights.tsx +++ b/packages/app/src/context/highlights.tsx @@ -291,6 +291,9 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple 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 const newest = summaries[0] diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx index 0b1e77dc9..a74451221 100644 --- a/packages/ui/src/components/toast.tsx +++ b/packages/ui/src/components/toast.tsx @@ -119,15 +119,23 @@ export interface ToastOptions { export function showToast(options: ToastOptions | string) { const opts = typeof options === "string" ? { description: options } : options return toaster.show((props) => { - // onCleanup runs when the toast root unmounts (close button, action click, - // swipe, programmatic toaster.dismiss) — covering all dismiss paths in one - // hook. The dismissed guard is defense against future Kobalte upgrades - // re-invoking this render closure. - let dismissed = false + // onCleanup runs when the toast root unmounts. That covers explicit dismiss + // paths (close button, action click, swipe, programmatic toaster.dismiss) + // AND ambient unmounts (parent owner teardown, e.g. app exit). 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. + // 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 (dismissed) return - dismissed = true + if (fired) return + fired = true + if (!userDismissed) return opts.onDismiss?.() }) } @@ -154,6 +162,7 @@ export function showToast(options: ToastOptions | string) {