feat(studio): switch a release's notes between Chinese and English

This commit is contained in:
esengine committed 2026-10-14 02:28:39 -07:00
1 parent 574f69bdde
commit 5436dc9ae1
13 files changed
+233 -18

No files matched your search

+1
View File
@@ -456,6 +456,7 @@ export const ACTIONS: UIAction[] = [
{ id: "versions.notes", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-retry", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "versions.notes-close", kind: "view", target: "none", proof: "interaction" },
{ id: "versions.notes-lang", kind: "view", target: "none", proof: "interaction" },
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "hooks.recipe", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
+1
View File
@@ -839,6 +839,7 @@ export const EN: Record<string, string> = {
"更新内容": "What's new",
"收起更新内容": "Hide what's new",
"{v} 的更新内容": "What's new in {v}",
"更新内容的语言": "Language of the notes",
"正在读取更新内容…": "Loading what's new…",
"更新内容读取失败": "What's new could not be read",
"在 GitHub 查看": "View on GitHub",
@@ -2,6 +2,7 @@
.vrow .vn { grid-column: 2; grid-row: 1; justify-self: end; text-decoration: none }
.vnotes { grid-column: 1 / -1; margin: 0 0 8px; padding: 10px 12px; border-left: 2px solid var(--hair); font-size: var(--fs-ui); overflow-wrap: anywhere }
.vnotes .md-noimg { color: var(--muted) }
.vnotes-lang { display: flex; margin-bottom: 8px }
@container versions (max-width: 600px) {
.vrow .vn { grid-column: 1 / -1; grid-row: auto; justify-self: start; margin-top: 4px }
@media (hover: hover) {
@@ -178,3 +178,28 @@ describe("version notes on the version panel", () => {
expect(screen.getByRole("button", { name: "固定在这里" })).toBeTruthy();
});
});
describe("notes that carry two languages", () => {
const both = "## 新增\n\n- 中文条目\n\n---\n\n<!-- lang:en -->\n\n## New\n\n- English item\n";
it("show the window's language first and switch to the other on request", async () => {
render(<Versions port={portWith(doc("2.21.0", both))} />);
await screen.findByText("2.19.0");
await userEvent.click(screen.getAllByRole("button", { name: "更新内容" })[0]);
expect(await screen.findByText("中文条目")).toBeTruthy();
expect(screen.queryByText("English item")).toBeNull();
expect(screen.queryByText(/lang:en/)).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "English" }));
expect(await screen.findByText("English item")).toBeTruthy();
expect(screen.queryByText("中文条目")).toBeNull();
expect(screen.getByRole("button", { name: "English" }).getAttribute("aria-pressed")).toBe("true");
});
it("show no switch for notes in one language", async () => {
render(<Versions port={portWith(doc("2.21.0", "## 新增\n\n- 只有中文\n"))} />);
await screen.findByText("2.19.0");
await userEvent.click(screen.getAllByRole("button", { name: "更新内容" })[0]);
expect(await screen.findByText("只有中文")).toBeTruthy();
expect(screen.queryByRole("button", { name: "English" })).toBeNull();
});
});
+20 -2
View File
@@ -1,10 +1,11 @@
import { Fragment, useCallback, useEffect, useState, type KeyboardEvent } from "react";
import { bytes } from "../i18n/format";
import { t } from "../i18n";
import { current, t } from "../i18n";
import type { UpdateProgress, VersionHub, VersionNotes } from "../port/port";
import { reason } from "../i18n/kernel";
import { HttpError } from "../port/http_error";
import { LazyMarkdown } from "./LazyMarkdown";
import { NOTES_LANGUAGE_NAMES, splitNotes } from "./notesLanguages";
import { deltaSkippedCopy, failureCopy, releasePage } from "./versionFailure";
// A shell that declared no install: a build run from source, not a failure.
@@ -404,12 +405,29 @@ export function Versions({ port }: { port: Port }) {
);
}
// The notes in the language the window draws in, with a switch to the other one
// when the document carries both.
function NotesBody({ markdown }: { markdown: string }) {
const { first, english } = splitNotes(markdown);
const [shown, setShown] = useState<"first" | "english">(english !== null && current() === "en" ? "english" : "first");
if (english === null) return <LazyMarkdown text={first} images={false} />;
return (
<>
<div className="vnotes-lang seg" role="group" aria-label={t("更新内容的语言")}>
<button data-action="versions.notes-lang" data-value="first" aria-pressed={shown === "first"} onClick={() => setShown("first")}>{NOTES_LANGUAGE_NAMES.first}</button>
<button data-action="versions.notes-lang" data-value="english" aria-pressed={shown === "english"} onClick={() => setShown("english")}>{NOTES_LANGUAGE_NAMES.english}</button>
</div>
<LazyMarkdown text={shown === "english" ? english : first} images={false} />
</>
);
}
function NotesPanel({ id, version, state, retry, onKeyDown }: { id: string; version: string; state: NotesState | undefined; retry: () => void; onKeyDown: (e: KeyboardEvent<HTMLElement>) => void }) {
const loading = !state || state.phase === "loading";
return (
<section id={id} className="vnotes" data-action="versions.notes-close" onKeyDown={onKeyDown} aria-label={t("{v} 的更新内容", { v: version })} aria-busy={loading}>
{loading && <p className="acct-note">{t("正在读取更新内容…")}</p>}
{state?.phase === "ok" && <LazyMarkdown text={state.markdown} images={false} />}
{state?.phase === "ok" && <NotesBody markdown={state.markdown} />}
{state?.phase === "err" && (
<div className="find" data-lvl="warn" role="alert">
<span className="t">{t("更新内容读取失败")}</span>
@@ -0,0 +1,21 @@
// A release's notes may carry a second language in the same document, after
// this line. The marker is invisible on the GitHub release page, where both
// languages read one after the other; the Versions panel shows one at a time.
export const NOTES_ENGLISH_MARK = "<!-- lang:en -->";
export interface NotesLanguages {
first: string;
english: string | null;
}
const rule = /\n+---\s*$/;
export function splitNotes(markdown: string): NotesLanguages {
const at = markdown.indexOf(NOTES_ENGLISH_MARK);
if (at < 0) return { first: markdown, english: null };
const english = markdown.slice(at + NOTES_ENGLISH_MARK.length).trim();
return { first: markdown.slice(0, at).replace(rule, "").trimEnd(), english: english || null };
}
// Language names are written in their own language and are not translated.
export const NOTES_LANGUAGE_NAMES = { first: "中文", english: "English" } as const;
+3 -2
View File
@@ -135,7 +135,7 @@ Verify a published package with `npm view reasonix dist.attestations`: the attes
## 6. Release note format
File: `release-notes/studio/X.Y.Z.md`, without the tag's `v`. Language: Chinese. Enforced by `release-note` and `doc-prose`.
File: `release-notes/studio/X.Y.Z.md`, without the tag's `v`. Language: Chinese, then English (R6). Enforced by `release-note` and `doc-prose`.
| ID | Rule |
| --- | --- |
@@ -144,6 +144,7 @@ File: `release-notes/studio/X.Y.Z.md`, without the tag's `v`. Language: Chinese.
| R3 | One change is one list item within 200 display columns. |
| R4 | Every item names its issue (`#123`), pull request or commit. |
| R5 | Describe what the user observes. The explanation belongs in the linked commit. |
| R6 | The same notes follow in English after a `---` line and the `<!-- lang:en -->` marker, under `## New`, `## Changed`, `## Fixed`, `## Removed`, `## Upgrade notes`, with the same items and references; R1 to R5 apply to each language. |
The published body is rendered, not copied:
@@ -156,7 +157,7 @@ The published body is rendered, not copied:
The repository owner and any login in `RELEASE_CREDIT_EXCLUDE` (comma separated) are treated like bots: never named in an item or in the list.
A `## 贡献者` list of the credited authors closes the notes. A `#N` counts only at a line start or after whitespace, `(`, `(`, `、`, `,` or `,`, and never in code, an HTML comment or a link target. Write colours such as `#333` in a code span.
A `## 贡献者` list of the credited authors closes the Chinese part, and a `## Contributors` list closes the English part. A `#N` counts only at a line start or after whitespace, `(`, `(`, `、`, `,` or `,`, and never in code, an HTML comment or a link target. Write colours such as `#333` in a code span.
A lookup GitHub refuses, or cannot answer after retries, fails `publish` with a `release_credits.*` code. Preview with `node scripts/studio-release-notes.mjs release-notes/studio/X.Y.Z.md /tmp/notes.md`; it reads `GH_TOKEN`, else `gh auth token`.
+65
View File
@@ -58,3 +58,68 @@
- 子代理状态帧在客户端落后时不再丢失 (#12888)
- 文件预览会先检查目标是否允许访问,并先经过 PreToolUse 钩子 (#12115)
- 记忆保存失败时会撤回,迁移过的记忆再次修改时会修复索引 (#12938, #12939, #12940)
---
<!-- lang:en -->
This release adds double-click rename for sessions, starting without any model service, a low-disk-space warning and maps and dashboards in extension surfaces, and tidies the sidebar and conversation display.
## New
- Double-click a session row in the sidebar to rename it in place; the rename dialog can generate a title on request (#12977, #11591)
- Reasonix starts without any model service configured and opens the connection guide directly (#12966)
- A warning appears when the disk holding a project or session is nearly full; on by default, it only warns and never deletes files (#12960)
- The terminal UI reports working, waiting-for-you and finished states to the terminal (#12976)
- A finished session can stay in the Running list for a chosen time; off by default (#12957)
- The sidebar marks sessions stored by 1.x, and the terminal resume list marks sessions you have not viewed (#12405, #12587)
- A skill invoked with / shows as a compact skill block, and the JSON input of tool calls is syntax-coloured (#12972, #12969)
- Files can be staged and unstaged from the changes list (#12923)
- The application icon shows the count of unread finished tasks, and a new flat icon replaces the old one (#12922, #12897)
- New "Clear blue" and "Tea green" colour schemes; theme packs can declare density and corner radius (#12932, #12513)
- The icon rail has a New session button; clicking the folder mark in the title bar opens the project folder (#12891, #12857, #12288)
- File links in a conversation open in the workbench, and the file menu can add a file or folder to the conversation (#12910, #12877)
- Remote machines are gathered into one collapsible row, and the levels of the session tree are easier to tell apart (#12878, #12905)
- Esc must be pressed a second time to stop a running turn (#12859)
- Extension surfaces support style fields, editable drafts, map-versus-dashboard views and non-blocking actions (#12830, #12854, #12876, #12896)
- Installed packages can be searched by capability, show their contribution types, copy their invocation, and the install preview shows the resolved commit (#12065, #12117, #12070, #12075, #12098)
- You can view a sub-agent's skill declarations and search an MCP server's tool list (#12102, #12085)
- WSL distributions are discovered and one of them is probed (#12595)
- The phone page can be added to the home screen; paired phones survive a restart, with a switch in settings (#12591, #12440)
- Linux arm64 .deb and .rpm packages are published (#12914)
- Local stdio MCP servers receive the working directory and the host's read-only state (#12967)
- The tool catalogue refreshes automatically when an MCP server adds or removes tools at runtime and sends a notification (#12138)
- The host's check of changed turns can be turned off (#12860)
- Right-to-left text blocks are displayed in their own direction (#12118)
- The workbench browser-tab menu gains "Close browser tabs to the right", reachable by keyboard (#12557)
## Changed
- The Versions page keeps only the latest few earlier versions and folds the rest behind a button (#12982)
- Session lists use single-line ellipsis everywhere, and bulk selection moved to the "This machine" more menu (#12935)
- The desktop sidebar uses divider bars for its actions, and the brand mark is the official wordmark throughout (#12918, #12936)
- Computer use now runs on the CUA Driver (#12937)
- The "Execution" block in a conversation is a faint single line, and its header sticks to the top when expanded (#12954, #12944)
- The session title in the title bar sits next to the project name, and the branch button's hover shape matches its neighbours (#12955, #12971)
- The model list keeps only the settings page's own scrollbar, and the display-alias input follows the theme (#12979)
- Installed skills are grouped by source (#12329)
## Fixed
- Folded user messages no longer vanish entirely when compaction cannot get a summary (#12963)
- The context-usage figure now uses the same number the compaction trigger uses, instead of reading lower than the provider reports (#12991, #12988)
- On Windows, clicking a task-completed notification brings the window to the front, and the click path is logged (#12907, #12956)
- Closing a browser tab no longer reads content the kernel never sends (#12886)
- A question you have started answering no longer times out and is skipped (#12884)
- A turn in progress is kept when the runtime is rebuilt or a turn-end event is lost (#11735)
- Saving settings now reports success, not applied, and failure separately, and a failure can be retried (#12350)
- The undo entry is restored from the backend, and an expired-undo state is kept (#11589)
- Unsaved file drafts in the workbench are kept (#12898)
- Context usage refreshes immediately after switching model settings (#12871)
- Cost is shown in the wallet's currency, and the folder picker can list hidden folders (#12874)
- On Windows, when the Git Bash probe hangs it is retried once, and the fallback shell path is shown correctly (#12887)
- When only /skill-name is sent, history records exactly what you typed (#12890)
- Duplicate MCP tool names are refused, and the original identifier stays the same on every path (#12889, #12893, #12892)
- Sub-agent status frames are no longer lost when the client falls behind (#12888)
- File preview checks that the target may be accessed, and passes through the PreToolUse hook first (#12115)
- A failed memory save is rolled back, and a migrated memory repairs its index when changed again (#12938, #12939, #12940)
+2
View File
@@ -0,0 +1,2 @@
- 版本页的更新内容可以在中文和英文之间切换,默认跟随界面语言;之后每个版本的发布说明都附带英文。关联 #13081。
- The Versions page can switch a release's notes between Chinese and English, following the interface language by default; every release's notes now carry English. Refs #13081.
+12 -3
View File
@@ -17,11 +17,20 @@ import {
unresolvedError,
} from "./release-credits.mjs";
// A second language rides in the same document after this line; the Versions
// panel and the GitHub page both read it (desktop/frontend-next notesLanguages.ts).
const ENGLISH_MARK = "<!-- lang:en -->";
export function renderStudioNotes(markdown, credits) {
const body = annotateMarkdown(markdown, (ref) => creditSuffix(credits.get(ref))).trimEnd();
const contributors = contributorLogins(markdownRefs(markdown), credits);
if (!contributors.length) return `${body}\n`;
return `${body}\n\n## 贡献者\n\n感谢本版本的贡献者:${contributors.map((login) => `@${login}`).join("、")}\n`;
const logins = contributorLogins(markdownRefs(markdown), credits).map((login) => `@${login}`);
if (!logins.length) return `${body}\n`;
const chinese = `## 贡献者\n\n感谢本版本的贡献者:${logins.join("、")}`;
const at = body.indexOf(ENGLISH_MARK);
if (at < 0) return `${body}\n\n${chinese}\n`;
const first = body.slice(0, at).replace(/\n+---\s*$/, "").trimEnd();
const english = `## Contributors\n\nThanks to this release's contributors: ${logins.join(", ")}`;
return `${first}\n\n${chinese}\n\n---\n\n${body.slice(at)}\n\n${english}\n`;
}
async function main() {
+27
View File
@@ -88,3 +88,30 @@ test("an item whose only author is excluded renders plain and lists no contribut
]);
assert.equal(renderStudioNotes("摘要。\n\n## 修复\n\n- 一 #1\n- 二 #2\n", credits), "摘要。\n\n## 修复\n\n- 一 #1\n- 二 #2 fixed in #9\n");
});
test("notes with an English part credit contributors in each language and keep the marker between them", async () => {
const both = `本版修复一件事。
## 修复
- 会话不再丢失(#20)
---
<!-- lang:en -->
This release fixes one thing.
## Fixed
- Sessions are no longer lost (#20)
`;
const { credits } = await resolveCredits(markdownRefs(both), async (ref) => authors[ref]);
const rendered = renderStudioNotes(both, credits);
const [first, english] = rendered.split("<!-- lang:en -->");
assert.match(first, /会话不再丢失(#20 by @alice)/);
assert.match(first, /## 贡献者\n\n感谢本版本的贡献者:@alice\n\n---\n\n$/);
assert.match(english, /Sessions are no longer lost \(#20 by @alice\)/);
assert.match(english, /## Contributors\n\nThanks to this release's contributors: @alice\n$/);
assert.equal(rendered.split("## 贡献者").length, 2);
});
+32 -11
View File
@@ -69,9 +69,11 @@ var translatedDocs = []string{
var (
docStatuses = []string{"active", "deprecated"}
releaseNoteGroups = []string{"新增", "变更", "修复", "移除", "升级须知"}
isoDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
listItem = regexp.MustCompile(`^\s*(?:[-*+]|\d+\.)\s+`)
changeReference = regexp.MustCompile(`#\d+|\b[0-9a-f]{7,40}\b|https?://`)
// A second language follows releaseNoteEnglishMark in the same file and is held to the same shape under these headings.
releaseNoteEnglishGroups = []string{"New", "Changed", "Fixed", "Removed", "Upgrade notes"}
isoDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
listItem = regexp.MustCompile(`^\s*(?:[-*+]|\d+\.)\s+`)
changeReference = regexp.MustCompile(`#\d+|\b[0-9a-f]{7,40}\b|https?://`)
)
func classifyDoc(rel string) (docClass, bool) {
@@ -272,22 +274,41 @@ func blockKind(b proseBlock) string {
return "paragraph"
}
const releaseNoteEnglishMark = "<!-- lang:en -->"
// checkReleaseNote holds a version's notes to the published shape: one summary
// line, changes grouped under the fixed headings, one short referenced line per
// change. The reasoning behind a change lives in its commit.
// change. The reasoning behind a change lives in its commit. A file may carry
// an English part after releaseNoteEnglishMark; each language is held to the
// same shape under its own headings.
func checkReleaseNote(rel string, lines []string) []Finding {
for i, raw := range lines {
if strings.TrimSpace(raw) != releaseNoteEnglishMark {
continue
}
first := lines[:i]
for len(first) > 0 && slices.Contains([]string{"", "---"}, strings.TrimSpace(first[len(first)-1])) {
first = first[:len(first)-1]
}
out := checkReleaseNoteBody(rel, first, 0, releaseNoteGroups)
return append(out, checkReleaseNoteBody(rel, lines[i+1:], i+1, releaseNoteEnglishGroups)...)
}
return checkReleaseNoteBody(rel, lines, 0, releaseNoteGroups)
}
func checkReleaseNoteBody(rel string, lines []string, offset int, groups []string) []Finding {
var out []Finding
paragraphs := 0
for i, raw := range lines {
t := strings.TrimSpace(raw)
switch {
case strings.HasPrefix(t, "## "):
if !slices.Contains(releaseNoteGroups, strings.TrimSpace(t[3:])) {
out = append(out, Finding{rel, i + 1, ruleReleaseNote,
fmt.Sprintf("heading %q is not one of %v", t, releaseNoteGroups), 1})
if !slices.Contains(groups, strings.TrimSpace(t[3:])) {
out = append(out, Finding{rel, offset + i + 1, ruleReleaseNote,
fmt.Sprintf("heading %q is not one of %v", t, groups), 1})
}
case strings.HasPrefix(t, "#"):
out = append(out, Finding{rel, i + 1, ruleReleaseNote, "only ## group headings; a change is one list item, not a section", 1})
out = append(out, Finding{rel, offset + i + 1, ruleReleaseNote, "only ## group headings; a change is one list item, not a section", 1})
}
}
for _, b := range proseBlocks(lines) {
@@ -295,17 +316,17 @@ func checkReleaseNote(rel string, lines []string) []Finding {
case !b.item:
paragraphs++
if paragraphs > 1 {
out = append(out, Finding{rel, b.line, ruleReleaseNote, "only the first line may be a summary paragraph; state changes as list items", 1})
out = append(out, Finding{rel, offset + b.line, ruleReleaseNote, "only the first line may be a summary paragraph; state changes as list items", 1})
}
case !changeReference.MatchString(b.text):
out = append(out, Finding{rel, b.line, ruleReleaseNote, "a change names its issue, pull request or commit", 1})
out = append(out, Finding{rel, offset + b.line, ruleReleaseNote, "a change names its issue, pull request or commit", 1})
}
limit := docProseWidth
if b.item {
limit = releaseNoteItemWidth
}
if b.width > limit {
out = append(out, Finding{rel, b.line, ruleDocProse,
out = append(out, Finding{rel, offset + b.line, ruleDocProse,
fmt.Sprintf("%s is %d columns, over %d", blockKind(b), b.width, limit),
(b.width - limit + proseWeightUnit - 1) / proseWeightUnit})
}
+23
View File
@@ -196,6 +196,29 @@ func TestAReleaseNoteIsGroupedReferencedAndShort(t *testing.T) {
}
}
func TestReleaseNoteHoldsEachLanguageToTheSameShape(t *testing.T) {
ok := strings.Join([]string{
"一句话概括本版。", "", "## 新增", "", "- 做了一件事 (#1)", "", "---", "", "<!-- lang:en -->", "",
"One-line summary.", "", "## New", "", "- Did one thing (#1)",
}, "\n")
if got := checkReleaseNote("release-notes/studio/9.9.9.md", strings.Split(ok, "\n")); len(got) != 0 {
t.Fatalf("a well-formed bilingual note has findings: %+v", got)
}
bad := strings.Join([]string{
"概括。", "", "## 新增", "", "- 做了一件事 (#1)", "", "<!-- lang:en -->", "",
"Summary.", "", "Second paragraph.", "", "## Highlights", "", "- No reference",
}, "\n")
got := checkReleaseNote("release-notes/studio/9.9.9.md", strings.Split(bad, "\n"))
if len(got) != 3 {
t.Fatalf("findings = %+v, want a second paragraph, an unknown English group and an unreferenced item", got)
}
for _, f := range got {
if f.Line < 9 {
t.Fatalf("a finding in the English part points at line %d, inside the first language", f.Line)
}
}
}
func TestClassifyDocSkipsMigrationBackup(t *testing.T) {
if _, ok := classifyDoc(".migration-backup/legacy-fact.md"); ok {
t.Fatal("a store-owned memory backup is not an authored doc")