Skip to content
Merged
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./desktop-api": "./src/desktop-api.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
Expand Down
9 changes: 8 additions & 1 deletion packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { buildDesktopContext, type DesktopContext } from "./utils/desktop-context"
import { buildDesktopContext, desktopWindowTitle, type DesktopContext } from "./utils/desktop-context"
import { useCheckServerHealth } from "./utils/server-health"

const HomeRoute = lazy(() => import("@/pages/home"))
Expand Down Expand Up @@ -178,6 +178,13 @@ function DesktopContextRouteBridge() {
})
}

createEffect(() => {
if (typeof document !== "object") return
const pathname = location.pathname
if (isSessionRoute(pathname)) return
document.title = desktopWindowTitle(language.locale())
})
Comment thread
Astro-Han marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

createEffect(() => {
if (!window.api?.setDesktopContext) return
if (isSessionRoute(location.pathname)) {
Expand Down
86 changes: 79 additions & 7 deletions packages/app/src/context/highlights.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,85 @@ describe("loadReleaseHighlights (GitHub Releases API)", () => {
body: "## Downloads\n\n- [macOS](https://example.com/app.dmg)\n\n## App Update Notice\n\nFixed first-message crash\n",
},
]
const highlights = loadReleaseHighlights(payload, "0.2.3", "0.2.2")
const highlights = loadReleaseHighlights(payload, "0.2.3", "0.2.2", "en")
expect(highlights).toHaveLength(1)
expect(highlights[0]).toMatchObject({
title: "PawWork v0.2.3",
description: "Fixed first-message crash",
})
})

test("prefers the Chinese update notice for zh locale", () => {
const payload = [
{
tag_name: "v0.2.10",
body: [
"## App Update Notice",
"",
"- Fixed first-message crash",
"",
"## 中文版本",
"",
"### 主要更新",
"",
"- 修复首条消息崩溃",
"- 调整更新提示",
].join("\n"),
},
]
const highlights = loadReleaseHighlights(payload, "0.2.10", "0.2.9", "zh")
expect(highlights).toHaveLength(1)
expect(highlights[0]).toMatchObject({
title: "爪印 v0.2.10",
description: "修复首条消息崩溃",
})
})

test("falls back to bullets directly under 中文版本 when 主要更新 is absent", () => {
const payload = [
{
tag_name: "v0.2.10",
body: ["## App Update Notice", "", "- Fixed first-message crash", "", "## 中文版本", "", "- 修复首条消息崩溃", "- 调整更新提示"].join("\n"),
},
]
const highlights = loadReleaseHighlights(payload, "0.2.10", "0.2.9", "zh")
expect(highlights).toHaveLength(1)
expect(highlights[0]).toMatchObject({
title: "爪印 v0.2.10",
description: "修复首条消息崩溃",
})
})

test("falls back to the English update notice when Chinese summary is missing", () => {
const payload = [
{
tag_name: "v0.2.10",
body: "## App Update Notice\n\n- Fixed first-message crash\n",
},
]
const highlights = loadReleaseHighlights(payload, "0.2.10", "0.2.9", "zh")
expect(highlights).toHaveLength(1)
expect(highlights[0]).toMatchObject({
title: "爪印 v0.2.10",
description: "Fixed first-message crash",
})
})

test("skips markdown headings and strips bullet markers inside the app update notice section", () => {
const payload = [
{
tag_name: "v0.3.0",
body: "## Downloads\n\n- [macOS](https://example.com/app.dmg)\n\n## App Update Notice\n\n### Desktop\n\n- Added dark theme\n- Fixed dock icon\n\n## Verification\n\n- CI passed\n",
},
]
const highlights = loadReleaseHighlights(payload, "0.3.0", "0.2.3")
const highlights = loadReleaseHighlights(payload, "0.3.0", "0.2.3", "en")
expect(highlights[0].description).toBe("Added dark theme")
})

test("truncates long summaries with an ellipsis", () => {
const long = "a".repeat(300)
const payload = [{ tag_name: "v1.0.0", body: `## App Update Notice\n\n${long}` }]
const highlights = loadReleaseHighlights(payload, "1.0.0", "0.9.0")
const highlights = loadReleaseHighlights(payload, "1.0.0", "0.9.0", "en")
expect(highlights[0].description.endsWith("…")).toBe(true)
expect(highlights[0].description.length).toBe(201)
})
Expand All @@ -44,7 +100,7 @@ describe("loadReleaseHighlights (GitHub Releases API)", () => {
body: "## Downloads\n\n- [macOS Apple Silicon](https://github.com/Astro-Han/pawwork/releases/download/v0.2.6/pawwork-mac-arm64.dmg)\n\n## Highlights\n\n- Maintenance fixes\n",
},
]
expect(loadReleaseHighlights(payload, "0.2.6", "0.2.5")).toHaveLength(0)
expect(loadReleaseHighlights(payload, "0.2.6", "0.2.5", "en")).toHaveLength(0)
})

test("stops app update notice parsing at empty same-level headings", () => {
Expand All @@ -54,14 +110,14 @@ describe("loadReleaseHighlights (GitHub Releases API)", () => {
body: "## App Update Notice\n\n- Fixed update notices\n\n##\n\n- [macOS](https://example.com/app.dmg)\n",
},
]
const highlights = loadReleaseHighlights(payload, "0.2.6", "0.2.5")
const highlights = loadReleaseHighlights(payload, "0.2.6", "0.2.5", "en")
expect(highlights).toHaveLength(1)
expect(highlights[0].description).toBe("Fixed update notices")
})

test("returns no highlights when the body is empty or only headings", () => {
const payload = [{ tag_name: "v0.2.4", body: "# Title only\n\n## Heading only\n" }]
expect(loadReleaseHighlights(payload, "0.2.4", "0.2.3")).toHaveLength(0)
expect(loadReleaseHighlights(payload, "0.2.4", "0.2.3", "en")).toHaveLength(0)
})

test("keeps backward compatibility with the structured highlights schema", () => {
Expand All @@ -76,8 +132,24 @@ describe("loadReleaseHighlights (GitHub Releases API)", () => {
],
},
]
const highlights = loadReleaseHighlights(payload, "0.2.5", "0.2.4")
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")
})
})
55 changes: 42 additions & 13 deletions packages/app/src/context/highlights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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 { type Locale, useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { persisted } from "@/utils/persist"
Expand All @@ -18,6 +19,8 @@ type ParsedRelease = {
highlights: Highlight[]
}

type ReleaseLocale = Locale

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
Expand Down Expand Up @@ -61,9 +64,9 @@ function parseHighlight(value: unknown): Highlight | undefined {
return { title, description, media }
}

function findAppUpdateNotice(body: string): string | undefined {
function findHeadingSection(body: string, matcher: RegExp): string | undefined {
const lines = body.split(/\r?\n/)
const start = lines.findIndex((line) => /^#{2,6}\s+App Update Notice\s*$/i.test(line.trim()))
const start = lines.findIndex((line) => matcher.test(line.trim()))
if (start === -1) return

const headingLevel = lines[start].trim().match(/^#+/)?.[0].length ?? 2
Expand All @@ -75,8 +78,17 @@ function findAppUpdateNotice(body: string): string | undefined {
return (end === -1 ? section : section.slice(0, end)).join("\n")
}

function summarizeAppUpdateNotice(body: string): string | undefined {
const notice = findAppUpdateNotice(body)
function findAppUpdateNotice(body: string) {
return findHeadingSection(body, /^#{2,6}\s+App Update Notice\s*$/i)
}

function findChineseUpdateNotice(body: string) {
const chinese = findHeadingSection(body, /^#{2,6}\s+中文版本\s*$/)
if (!chinese) return
return findHeadingSection(chinese, /^#{3,6}\s+主要更新\s*$/) ?? chinese
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function summarizeNotice(notice: string | undefined): string | undefined {
if (!notice) return

const lines = notice
Expand All @@ -88,7 +100,19 @@ function summarizeAppUpdateNotice(body: string): string | undefined {
return first.length > 200 ? first.slice(0, 200).trimEnd() + "…" : first
}

function parseRelease(value: unknown): ParsedRelease | undefined {
function summarizeReleaseBody(body: string, locale: ReleaseLocale) {
if (locale === "zh") {
const chinese = summarizeNotice(findChineseUpdateNotice(body))
if (chinese) return chinese
}
return summarizeNotice(findAppUpdateNotice(body))
}

function releaseTitle(tag: string, locale: ReleaseLocale) {
return `${locale === "zh" ? "爪印" : "PawWork"} ${tag}`
}

function parseRelease(value: unknown, locale: ReleaseLocale): ParsedRelease | undefined {
if (!isRecord(value)) return
const tag = getText(value.tag) ?? getText(value.tag_name) ?? getText(value.name)

Expand All @@ -114,27 +138,31 @@ function parseRelease(value: unknown): ParsedRelease | undefined {

const body = getText(value.body)
if (tag && body) {
const summary = summarizeAppUpdateNotice(body)
const summary = summarizeReleaseBody(body, locale)
if (summary) {
return {
tag,
highlights: [{ title: `PawWork ${tag}`, description: summary }],
highlights: [{ title: releaseTitle(tag, locale), description: summary }],
}
}
}

return { tag, highlights: [] }
}

function parseChangelog(value: unknown): ParsedRelease[] | undefined {
function parseChangelog(value: unknown, locale: ReleaseLocale): ParsedRelease[] | undefined {
if (Array.isArray(value)) {
return value.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined)
return value
.map((release) => parseRelease(release, locale))
.filter((release): release is ParsedRelease => release !== undefined)
}

if (!isRecord(value)) return
if (!Array.isArray(value.releases)) return

return value.releases.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined)
return value.releases
.map((release) => parseRelease(release, locale))
.filter((release): release is ParsedRelease => release !== undefined)
}

function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; previous?: string }) {
Expand Down Expand Up @@ -169,8 +197,8 @@ function dedupeKey(highlight: Highlight) {
return [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join("\n")
}

export function loadReleaseHighlights(value: unknown, current?: string, previous?: string) {
const releases = parseChangelog(value)
export function loadReleaseHighlights(value: unknown, current?: string, previous?: string, locale: ReleaseLocale = "en") {
const releases = parseChangelog(value, locale)
if (!releases?.length) return []
return sliceHighlights({ releases, current, previous })
}
Expand All @@ -179,6 +207,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
name: "Highlights",
gate: false,
init: () => {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const settings = useSettings()
Expand Down Expand Up @@ -228,7 +257,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
})
.then((json) => {
if (!json) return
const highlights = loadReleaseHighlights(json, platform.version, previous)
const highlights = loadReleaseHighlights(json, platform.version, previous, language.locale())
if (controller.signal.aborted) return

if (highlights.length === 0) {
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/desktop-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { buildDesktopContext, desktopWindowTitle, type DesktopContext } from "./utils/desktop-context"
export type { ReportProblemInput, ReportProblemResult, UpdateInfo } from "./context/platform"
28 changes: 28 additions & 0 deletions packages/app/src/i18n/zh-branding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { dict as zh } from "./zh"

describe("zh branding copy", () => {
test("uses Chinese product naming on key user-facing surfaces", () => {
expect(zh["dialog.model.unpaid.freeModels.title"]).toBe("爪印内置免费模型")
expect(zh["session.new.subtitle"]).toBe("爪印可以帮你处理文件、分析信息、撰写内容并完成各类任务。")
expect(zh["sidebar.gettingStarted.line1"]).toBe("爪印内置免费模型,你可以立即开始使用。")
expect(zh["app.name.desktop"]).toBe("爪印")
expect(zh["toast.update.description"]).toBe("爪印有新版本 ({{version}}) 可安装。")
expect(zh["error.page.report.prefix"]).toBe("请将此错误报告给开发团队")
})

test("removes standalone PawWork from curated Chinese UI strings", () => {
const curatedKeys = [
"dialog.model.unpaid.freeModels.title",
"session.new.subtitle",
"sidebar.gettingStarted.line1",
"app.name.desktop",
"toast.update.description",
"error.page.report.prefix",
] as const

for (const key of curatedKeys) {
expect(zh[key]).not.toContain("PawWork")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
})
Loading
Loading