Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
629a9f5
cmux-next: make What's New links readable and usable
teamleaderleo Oct 16, 2026
c17fa45
Merge origin/feat-cmux-next into feat/whats-new-readable-links
teamleaderleo Oct 19, 2026
119aab8
chore(next): PR autofix at c17fa45be63f
Oct 19, 2026
a06cbec
Merge remote-tracking branch 'origin/feat-cmux-next' into HEAD
teamleaderleo Oct 19, 2026
385d539
What's New: open release-note links through LinkRouter's cmux browser…
teamleaderleo Oct 19, 2026
4e9da45
Merge remote-tracking branch 'origin/feat-cmux-next' into feat/whats-…
teamleaderleo Oct 19, 2026
8b1242e
test(next): What's New Digest keeps each change's docs link visible
teamleaderleo Oct 19, 2026
90e812b
fix(next): What's New links follow the link setting; stacked designs …
teamleaderleo Oct 19, 2026
7c03fb8
Merge commit 'c700b6ada4cf33d8869f8b12a962a353d0ff54f8' into feat/wha…
teamleaderleo Oct 20, 2026
b82674a
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
a1c8c76
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
ba4ec67
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
fd08dbc
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
beff1e9
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
41000de
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
f574955
Merge feat-cmux-next into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
152e538
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
3b13ce1
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
235a2f2
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
406f1dc
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
cb14a19
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
9a2e818
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
c367b12
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
1703f2e
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
2648924
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
e64077e
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
6f112a4
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
ad08ff1
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
7e3671c
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
0ebebaa
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
62980d5
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
4a76864
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
b7deee2
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
b540753
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 20, 2026
b4e81f9
Merge branch 'feat-cmux-next' into feat/whats-new-readable-links
teamleaderleo Oct 21, 2026
62c60f7
Merge feat-cmux-next into feat/whats-new-readable-links
teamleaderleo Oct 21, 2026
16cb7b0
test(changelog): Ledger rows keep their Try it / Learn more inline (f…
teamleaderleo Oct 21, 2026
f831d7f
fix(changelog): Ledger rows keep their Try it / Learn more inline
teamleaderleo Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion webviews/src/pages/changelog/DigestPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// category column and a small thumbnail where a change has media.
import type { Strings } from "../shared/i18n";
import { formatReleaseDate, releaseView, type ReleaseItem } from "./model";
import { categoryLabel, ChangesFold, Media, type PartsContext } from "./ReleaseParts";
import { categoryLabel, ChangesFold, Media, RowActions, type PartsContext } from "./ReleaseParts";
import type { ChangelogSnapshot, ChangelogStore } from "./store";
import type { IndexEntry } from "./types";

Expand Down Expand Up @@ -71,6 +71,7 @@ function DigestRelease({
<div>
<span className="cl-row-title">{item.title}</span>
{item.summary && <span className="cl-row-summary">{item.summary.split(/\n{2,}/)[0]}</span>}
<RowActions item={item} ctx={ctx} />
</div>
{item.media ? <Media media={item.media} className="cld-thumb" /> : <span />}
</div>
Expand Down
37 changes: 31 additions & 6 deletions webviews/src/pages/changelog/ReleaseParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,36 @@ export function ItemActions({ item, ctx }: { item: ReleaseItem; ctx: PartsContex
</button>
)}
{item.docs && (
<a className="cl-link" href={item.docs} rel="noreferrer">
<a
className="cl-link"
href={item.docs}
rel="noreferrer"
aria-label={`${ctx.strings.t("changelog.page.learnMore")}: ${item.title}`}
>
{ctx.strings.t("changelog.page.learnMore")}
</a>
)}
</div>
);
}

/** Try it and Learn more for one grouped change, kept with its title and summary. */
export function RowActions({ item, ctx }: { item: ReleaseItem; ctx: PartsContext }) {
if (!item.action && !item.docs) return null;
return (
<div className="cl-row-actions">
{item.action && (
<button type="button" className="cl-row-action" onClick={() => ctx.onTry(item.action!.id)}>
{item.action.title || ctx.strings.t("changelog.page.tryIt")}
</button>
)}
{item.docs && (
<a
className="cl-row-link"
href={item.docs}
rel="noreferrer"
aria-label={`${ctx.strings.t("changelog.page.learnMore")}: ${item.title}`}
>
{ctx.strings.t("changelog.page.learnMore")}
</a>
)}
Expand Down Expand Up @@ -127,11 +156,7 @@ function Group({ category, items, ctx }: { category: WhatsNewCategory; items: Re
{item.summary && <span className="cl-row-summary"> {item.summary.split(/\n{2,}/)[0]}</span>}
{ctx.rowMedia && item.media && <Media media={item.media} className="cl-row-media" />}
</span>
{item.action && (
<button type="button" className="cl-row-action" onClick={() => ctx.onTry(item.action!.id)}>
{item.action.title || ctx.strings.t("changelog.page.tryIt")}
</button>
)}
<RowActions item={item} ctx={ctx} />
</li>
))}
</ul>
Expand Down
20 changes: 13 additions & 7 deletions webviews/src/pages/changelog/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ html[data-cmux-page="changelog"] body {
.cl-button:hover { background: var(--page-selection); }
.cl-link { color: var(--page-text-2); text-decoration: none; font-size: var(--text-body); }
.cl-link:hover { color: var(--page-text); text-decoration: underline; text-underline-offset: 3px; }
:is(.cl-button, .cl-link, .cl-row-action, .cl-changes summary, .clr-version, .cl-pr, .cl-more, .cl-reel):focus-visible {
:is(.cl-button, .cl-link, .cl-row-action, .cl-row-link, .cl-changes summary, .clr-version, .cl-pr, .cl-more, .cl-reel):focus-visible {
outline: 2px solid var(--cmux-accent, currentColor);
outline-offset: 2px;
}
Expand All @@ -89,20 +89,23 @@ html[data-cmux-page="changelog"] body {
.cl-row-text { flex: 1 1 auto; min-width: 0; }
.cl-row-title { font-weight: 600; }
.cl-row-summary { color: var(--page-text-2); }
/* In a one-line row, Try it and Learn more show on hover or keyboard focus, beside the paragraph.
Layouts that stack a change in a column (Digest, Reader) show them under the summary. */
.cl-row-actions { display: flex; align-items: baseline; gap: 10px; flex: none; margin-left: auto; opacity: 0; }
.cl-group li:hover .cl-row-actions,
.cl-row-actions:focus-within { opacity: 1; }
.cl-row-action {
margin-left: auto;
flex: none;
border: 0;
background: transparent;
padding: 0 2px;
color: var(--page-text-3);
font-size: var(--text-detail);
cursor: default;
opacity: 0;
}
.cl-group li:hover .cl-row-action,
.cl-row-action:focus-visible { opacity: 1; }
.cl-row-action:hover { color: var(--page-text); }
.cl-row-action:hover,
.cl-row-link:hover { color: var(--page-text); }
.cl-row-link { color: var(--page-text-2); font-size: var(--text-detail); text-decoration: none; white-space: nowrap; }
.cl-row-link:hover { text-decoration: underline; text-underline-offset: 3px; }

.cl-changes { margin-top: 18px; color: var(--page-text-2); }
.cl-changes summary {
Expand Down Expand Up @@ -257,6 +260,7 @@ html[data-cmux-page="changelog"] body {
.cll .cl-group ul { list-style: disc outside; padding-left: 20px; }
.cll .cl-group li { display: list-item; padding: 3px 0; }
.cll .cl-row-text { display: inline; }
.cll .cl-row-actions { display: inline-flex; margin-left: 8px; }
.cll-body .cl-builds { margin-top: 14px; }
/* The release title (its curated headline) and, when it already names the lead change, that
change as the first row's bold title above its picture. */
Expand Down Expand Up @@ -320,6 +324,8 @@ html[data-cmux-page="changelog"] body {
.cld-item .cl-row-title,
.cld-item .cl-row-summary { display: block; white-space: normal; }
.cld-item .cl-row-summary { font-size: var(--text-detail); line-height: var(--text-detail--line-height); margin-top: 2px; }
.cld-item .cl-row-actions,
.clr-groups .cl-row-actions { margin: 4px 0 0; opacity: 1; }
.cld-thumb { margin: 0; width: 76px; border-radius: 5px; }

@media (prefers-reduced-motion: reduce) {
Expand Down
191 changes: 191 additions & 0 deletions webviews/test/changelog-links.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { afterAll, beforeAll, expect, test } from "bun:test";
import { JSDOM } from "jsdom";
import { act } from "react";

const scope = globalThis as Record<string, unknown>;
const keys = ["window", "document", "navigator", "HTMLElement", "IS_REACT_ACT_ENVIRONMENT"] as const;
const saved = keys.map((key) => [key, key in scope, scope[key]] as const);
let host: HTMLElement;

beforeAll(() => {
const { window } = new JSDOM("<!doctype html><div id=root></div>");
Object.assign(scope, {
window,
document: window.document,
navigator: window.navigator,
HTMLElement: window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
});
host = window.document.getElementById("root")!;
});

afterAll(() => {
for (const [key, had, value] of saved) {
if (had) scope[key] = value;
else delete scope[key];
}
});

test("grouped What's New entries keep readable summaries and expose their docs links", async () => {
const { createRoot } = await import("react-dom/client");
const { Groups } = await import("../src/pages/changelog/ReleaseParts");
const { releaseView } = await import("../src/pages/changelog/model");
const notes = {
build: "1",
shortVersion: "1.0.0-nightly.1",
date: "2026-10-16",
highlights: [],
changes: [],
whatsNew: {
version: "1.0.0-nightly.1",
channel: "nightly" as const,
date: "2026-10-16",
headline: { en: "A clearer update" },
entries: [
{
id: "groups",
category: "new" as const,
title: { en: "Workspace groups" },
summary: { en: "Keep related workspaces together so the sidebar is easier to scan." },
docs: "https://cmux.com/docs/sidebar",
},
],
},
};
const strings = {
language: "en",
t: (key: string) =>
({
"changelog.group.new": "New",
"changelog.page.learnMore": "Learn more",
"changelog.page.tryIt": "Try it",
})[key] ?? key,
format: (key: string, ...args: string[]) => `${key} ${args.join(" ")}`,
};
const root = createRoot(host);
const view = releaseView(notes, "en");
act(() => root.render(<Groups view={view} ctx={{ strings, onTry: () => undefined }} />));

const row = host.querySelector(".cl-group li")!;
expect(row.querySelector(".cl-row-title")?.textContent).toBe("Workspace groups");
expect(row.querySelector(".cl-row-summary")?.textContent).toContain("easier to scan");
const link = row.querySelector<HTMLAnchorElement>(".cl-row-link")!;
expect(link.href).toBe("https://cmux.com/docs/sidebar");
expect(link.textContent).toBe("Learn more");
expect(link.getAttribute("aria-label")).toBe("Learn more: Workspace groups");
act(() => root.unmount());
});

test("the Digest design shows each change's docs link under its summary", async () => {
const { createRoot } = await import("react-dom/client");
const { DigestPage } = await import("../src/pages/changelog/DigestPage");
const notes = {
build: "1",
shortVersion: "1.0.0-nightly.1",
date: "2026-10-16",
highlights: [],
changes: [],
whatsNew: {
version: "1.0.0-nightly.1",
channel: "nightly" as const,
date: "2026-10-16",
headline: { en: "A clearer update" },
entries: [
{
id: "groups",
category: "new" as const,
title: { en: "Workspace groups" },
summary: { en: "Keep related workspaces together so the sidebar is easier to scan." },
docs: "https://cmux.com/docs/sidebar",
},
],
},
};
const strings = {
language: "en",
t: (key: string) =>
({
"changelog.group.new": "New",
"changelog.page.learnMore": "Learn more",
"changelog.page.title": "What's New",
})[key] ?? key,
format: (key: string, ...args: string[]) => `${key} ${args.join(" ")}`,
};
const snapshot = {
builds: [{ build: "1", shortVersion: "1.0.0-nightly.1", date: "2026-10-16", highlights: 1 }],
loading: false,
missing: false,
inSpan: [],
loaded: { "1": notes },
unavailable: {},
};
const store = { tryIt: async () => undefined, ensure: async () => undefined };
const root = createRoot(host);
act(() =>
root.render(
<DigestPage
store={store as unknown as Parameters<typeof DigestPage>[0]["store"]}
strings={strings as unknown as Parameters<typeof DigestPage>[0]["strings"]}
s={snapshot as unknown as Parameters<typeof DigestPage>[0]["s"]}
/>,
),
);

const item = host.querySelector(".cld-item")!;
expect(item.querySelector(".cl-row-summary")?.textContent).toContain("easier to scan");
const link = item.querySelector<HTMLAnchorElement>(".cl-row-link")!;
expect(link.href).toBe("https://cmux.com/docs/sidebar");
expect(link.getAttribute("aria-label")).toBe("Learn more: Workspace groups");
// Digest stacks each change in a column, so its actions stay visible instead of revealing on hover.
const style = host.ownerDocument.createElement("style");
style.textContent = await Bun.file(new URL("../src/pages/changelog/styles.css", import.meta.url)).text();
host.ownerDocument.head.append(style);
const actions = item.querySelector<HTMLElement>(".cl-row-actions")!;
expect(host.ownerDocument.defaultView!.getComputedStyle(actions).opacity).toBe("1");
style.remove();
act(() => root.unmount());
});

test("the Ledger design keeps a change's actions on its row, not on a blank line under it", async () => {
const { createRoot } = await import("react-dom/client");
const { Groups } = await import("../src/pages/changelog/ReleaseParts");
const { releaseView } = await import("../src/pages/changelog/model");
const entry = {
id: "groups",
category: "new" as const,
title: { en: "Workspace groups" },
summary: { en: "Keep related workspaces together." },
docs: "https://cmux.com/docs/sidebar",
};
const notes = {
build: "1",
shortVersion: "1.0.0-nightly.1",
date: "2026-10-16",
highlights: [],
changes: [],
whatsNew: {
version: "1.0.0-nightly.1",
channel: "nightly" as const,
date: "2026-10-16",
headline: { en: "x" },
entries: [entry],
},
};
const strings = { language: "en", t: (key: string) => key, format: (key: string) => key };
const style = host.ownerDocument.createElement("style");
style.textContent = await Bun.file(new URL("../src/pages/changelog/styles.css", import.meta.url)).text();
host.ownerDocument.head.append(style);
const root = createRoot(host);
act(() =>
root.render(
<div className="cll">
<Groups view={releaseView(notes, "en")} ctx={{ strings, onTry: () => undefined }} />
</div>,
),
);
// Ledger rows are inline text in a bullet list; a block-level actions box would add an empty line under each.
const actions = host.querySelector<HTMLElement>(".cl-group li .cl-row-actions")!;
expect(host.ownerDocument.defaultView!.getComputedStyle(actions).display).toBe("inline-flex");
style.remove();
act(() => root.unmount());
});