From 5cb0cc60ce7c06f42b10d757c4266c7483917978 Mon Sep 17 00:00:00 2001 From: Petr Date: Mon, 24 Aug 2026 00:13:29 +0200 Subject: [PATCH 1/2] feat(ui): render the Changelog page as a typed release timeline The Changelog page dumped each version's raw bullet strings into a card: no structure, no colour, a wall of text. The CLI renderer already exploits the changelog authoring contract (commands/changelog.py) -- typed prefixes, PR decorations, backtick spans, headline-first summaries -- and the web page now does the same: - notes collapse to their first sentence behind a typed badge (New / Fix / Change / BREAKING / ... in the CLI's own colour semantics), expandable per note or via an expand-all toggle, mirroring the CLI's default-summary + --full behaviour - (#NNN) prefix decorations become GitHub PR links - inline backticks render as code chips, matching the inline-code treatment used elsewhere in the UI - versions sit on a vertical timeline rail; the running version (from the shared /version query cache) gets a glowing node and a 'running now' pill, the newest entry a 'latest' pill Parsing lives in changelogNotes.ts with a vitest suite; the prefix list and sentence-boundary rules are ports of _PREFIX_RE and headline() and must stay in sync with them. --- web/frontend/src/changelogNotes.test.ts | 79 ++++++++++ web/frontend/src/changelogNotes.ts | 141 +++++++++++++++++ web/frontend/src/pages/Changelog.tsx | 198 ++++++++++++++++++++++-- 3 files changed, 402 insertions(+), 16 deletions(-) create mode 100644 web/frontend/src/changelogNotes.test.ts create mode 100644 web/frontend/src/changelogNotes.ts diff --git a/web/frontend/src/changelogNotes.test.ts b/web/frontend/src/changelogNotes.test.ts new file mode 100644 index 00000000..3c315a94 --- /dev/null +++ b/web/frontend/src/changelogNotes.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { parseNote, splitHeadline, toRuns } from "./changelogNotes"; + +describe("parseNote", () => { + it("extracts label, tone and PR numbers from a decorated prefix", () => { + const n = parseNote("New (#658, #664): a Ctrl+K command palette. It jumps to any page."); + expect(n.label).toBe("New"); + expect(n.tone).toBe("green"); + expect(n.prs).toEqual([658, 664]); + expect(n.headline).toBe("a Ctrl+K command palette."); + expect(n.rest).toBe("It jumps to any page."); + }); + + it("recognises prefixes case-insensitively and non-PR decorations", () => { + const n = parseNote("BREAKING (sec-20 follow-up): tokens rotate."); + expect(n.label).toBe("BREAKING"); + expect(n.tone).toBe("red"); + expect(n.prs).toEqual([]); + }); + + it("prefers the longest prefix alternative", () => { + expect(parseNote("Plugin docs: refreshed.").label).toBe("Plugin docs"); + expect(parseNote("Plugin docs: refreshed.").tone).toBe("dim"); + }); + + it("leaves unprefixed notes label-less with the full text as body", () => { + const n = parseNote("Just a plain remark. With detail."); + expect(n.label).toBeNull(); + expect(n.headline).toBe("Just a plain remark."); + expect(n.rest).toBe("With detail."); + }); + + it("maps every documented prefix to its CLI tone", () => { + expect(parseNote("Fix: x.").tone).toBe("amber"); + expect(parseNote("Change: x.").tone).toBe("blue"); + expect(parseNote("UX: x.").tone).toBe("magenta"); + expect(parseNote("Note: x.").tone).toBe("cyan"); + expect(parseNote("Security: x.").tone).toBe("red"); + expect(parseNote("Internal: x.").tone).toBe("dim"); + }); +}); + +describe("splitHeadline", () => { + it("does not break inside version numbers", () => { + const { headline } = splitHeadline("since 0.57.0 the flow works. Detail here."); + expect(headline).toBe("since 0.57.0 the flow works."); + }); + + it("does not break after e.g.", () => { + const { headline, rest } = splitHeadline("some flags, e.g. `--json`, help. More."); + expect(headline).toBe("some flags, e.g. `--json`, help."); + expect(rest).toBe("More."); + }); + + it("returns the whole text when there is a single sentence", () => { + const { headline, rest } = splitHeadline("One sentence only."); + expect(headline).toBe("One sentence only."); + expect(rest).toBe(""); + }); + + it("treats ! and ? as sentence ends even after a digit", () => { + const { headline } = splitHeadline("exit code 5! And more."); + expect(headline).toBe("exit code 5!"); + }); +}); + +describe("toRuns", () => { + it("splits backtick spans into code runs", () => { + expect(toRuns("run `kbagent serve --ui` today")).toEqual([ + { code: false, text: "run " }, + { code: true, text: "kbagent serve --ui" }, + { code: false, text: " today" }, + ]); + }); + + it("passes through text without backticks", () => { + expect(toRuns("plain")).toEqual([{ code: false, text: "plain" }]); + }); +}); diff --git a/web/frontend/src/changelogNotes.ts b/web/frontend/src/changelogNotes.ts new file mode 100644 index 00000000..e92edf57 --- /dev/null +++ b/web/frontend/src/changelogNotes.ts @@ -0,0 +1,141 @@ +/** + * Client-side parser for changelog notes. + * ======================================= + * + * `GET /changelog` returns the raw bullet strings from `changelog.py`, which + * follow the authoring contract the CLI renderer already exploits + * (`commands/changelog.py`): a leading `Prefix (#PR):` tag, a self-contained + * first sentence, and inline backtick spans. This module ports that parsing + * so the web page can render the same semantics -- typed badges, PR links, + * headline-first collapsing -- instead of dumping the raw text. + * + * Keep the prefix list and sentence rules in sync with + * `commands/changelog.py` (`_PREFIX_RE`) and `changelog.py` (`headline()`). + */ + +/** Visual grouping for a note type; maps 1:1 to the CLI's Rich styles. */ +export type NoteTone = "red" | "green" | "amber" | "blue" | "magenta" | "cyan" | "dim"; + +export interface ParsedNote { + /** Canonical prefix label, e.g. "New", "Fix", "BREAKING". Null when the note has no recognised prefix. */ + label: string | null; + tone: NoteTone; + /** PR numbers pulled from the prefix decoration, e.g. "(#658, #664)" -> [658, 664]. */ + prs: number[]; + /** First sentence of the body (prefix stripped) -- the collapsed view. */ + headline: string; + /** Body text after the headline; empty string when the headline is the whole note. */ + rest: string; +} + +/** One piece of a rendered text run: plain text or an inline code span. */ +export interface TextRun { + code: boolean; + text: string; +} + +// Longest-alternative-first, mirroring _PREFIX_RE in commands/changelog.py. +const PREFIX_RE = new RegExp( + "^(Plugin docs|Review fixes|Observability|Breaking|Security|Closed|Tests|" + + "Internal|Change|Note|Fix|New|UX|E2E|Why)" + + "(\\s*\\(([^)]*)\\))?" + // optional "(#274)" / "(sec-20 follow-up)" decoration + ":\\s+", + "i", +); + +const TONES: Record = { + breaking: "red", + security: "red", + new: "green", + fix: "amber", + change: "blue", + closed: "blue", + ux: "magenta", + note: "cyan", + tests: "dim", + "plugin docs": "dim", + internal: "dim", + observability: "dim", + e2e: "dim", + "review fixes": "dim", + why: "dim", +}; + +/** Render the label the way the changelog writes it: BREAKING stays shouted, the rest title-case. */ +const LABELS: Record = { + breaking: "BREAKING", + security: "Security", + new: "New", + fix: "Fix", + change: "Change", + closed: "Closed", + ux: "UX", + note: "Note", + tests: "Tests", + "plugin docs": "Plugin docs", + internal: "Internal", + observability: "Observability", + e2e: "E2E", + "review fixes": "Review fixes", + why: "Why", +}; + +// Abbreviations whose trailing period is not a sentence end; subset of +// _HEADLINE_ABBREVIATIONS in changelog.py that actually occurs in notes. +const ABBREVIATIONS = new Set(["e.g", "i.e", "etc", "vs", "cf", "incl", "resp"]); + +/** + * First-sentence split, porting `headline()`'s rules: a `.`/`!`/`?` followed + * by whitespace ends the sentence, unless the period sits inside a version + * number (digit before it) or terminates a known abbreviation. + */ +export function splitHeadline(text: string): { headline: string; rest: string } { + const boundary = /[.!?](?=\s)/g; + let m: RegExpExecArray | null = boundary.exec(text); + while (m !== null) { + const dot = m.index; + const before = dot > 0 ? text[dot - 1] : ""; + const isDigitPeriod = text[dot] === "." && before >= "0" && before <= "9"; + const tokenMatch = /[\w.]+$/.exec(text.slice(0, dot)); + const token = tokenMatch ? tokenMatch[0].replace(/\.+$/, "").toLowerCase() : ""; + if (!isDigitPeriod && !ABBREVIATIONS.has(token)) { + return { + headline: text.slice(0, dot + 1), + rest: text.slice(dot + 1).trim(), + }; + } + m = boundary.exec(text); + } + return { headline: text, rest: "" }; +} + +export function parseNote(note: string): ParsedNote { + const m = PREFIX_RE.exec(note); + let label: string | null = null; + let tone: NoteTone = "dim"; + let prs: number[] = []; + let body = note; + if (m) { + const key = m[1].toLowerCase(); + label = LABELS[key] ?? m[1]; + tone = TONES[key] ?? "dim"; + prs = [...(m[3] ?? "").matchAll(/#(\d+)/g)].map((g) => Number(g[1])); + body = note.slice(m[0].length); + } + const { headline, rest } = splitHeadline(body); + return { label, tone, prs, headline, rest }; +} + +/** Split text into plain/inline-code runs on backtick spans (`` `like this` ``). */ +export function toRuns(text: string): TextRun[] { + const runs: TextRun[] = []; + for (const part of text.split(/(`[^`\n]+`)/)) { + if (!part) continue; + if (part.startsWith("`") && part.endsWith("`") && part.length >= 2) { + runs.push({ code: true, text: part.slice(1, -1) }); + } else { + runs.push({ code: false, text: part }); + } + } + return runs; +} diff --git a/web/frontend/src/pages/Changelog.tsx b/web/frontend/src/pages/Changelog.tsx index 540460d0..a9d82909 100644 --- a/web/frontend/src/pages/Changelog.tsx +++ b/web/frontend/src/pages/Changelog.tsx @@ -1,35 +1,201 @@ +/** + * Changelog page -- the release rail. + * + * Renders `GET /changelog` the way the CLI renders `kbagent changelog`, but + * readable: each version is a node on a vertical timeline, every note is + * collapsed to its first sentence behind a typed badge (New / Fix / BREAKING + * ... in the CLI's own colours), PR references become GitHub links, and the + * running version is highlighted on the rail. Parsing lives in + * `../changelogNotes.ts`, shared semantics with `commands/changelog.py`. + */ import { useQuery } from "@tanstack/react-query"; +import { ChevronRight } from "lucide-react"; +import { useState } from "react"; import { api } from "../api/client"; +import { type NoteTone, type TextRun, parseNote, toRuns } from "../changelogNotes"; import { ErrorBox, Loading, PageTitle } from "../components/Empty"; interface ChangelogResp { entries: Array<{ version: string; highlights: string[] }>; } +interface VersionResp { + kbagent: { version: string }; +} + +const BADGE_TONES: Record = { + red: "border-red-300 text-red-700 dark:border-red-700/50 dark:text-red-400", + green: "border-keboola/40 text-keboola-600 dark:text-keboola", + amber: "border-neon-amber/50 text-amber-700 dark:border-neon-amber/40 dark:text-neon-amber", + blue: "border-sky-300 text-sky-700 dark:border-sky-700/50 dark:text-sky-400", + magenta: "border-fuchsia-300 text-fuchsia-700 dark:border-fuchsia-700/50 dark:text-fuchsia-400", + cyan: "border-cyan-300 text-cyan-700 dark:border-cyan-700/50 dark:text-cyan-400", + dim: "border-zinc-300 text-zinc-500 dark:border-zinc-700 dark:text-zinc-500", +}; + +/** Inline text with `code` spans rendered like the rest of the UI's inline code. */ +function Runs({ runs }: { runs: TextRun[] }) { + return ( + <> + {runs.map((r, i) => + r.code ? ( + + {r.text} + + ) : ( + {r.text} + ), + )} + + ); +} + +function Note({ note, expanded, onToggle }: { note: string; expanded: boolean; onToggle: () => void }) { + const p = parseNote(note); + const hasDetail = p.rest.length > 0; + return ( +
  • + + {hasDetail && expanded ? ( +

    + +

    + ) : null} +
  • + ); +} + export function ChangelogPage() { const q = useQuery({ queryKey: ["changelog"], queryFn: () => api.get("/changelog"), }); + // Same key + staleTime as the status bar, so this shares the cache. + const versionQ = useQuery({ + queryKey: ["version"], + queryFn: () => api.get("/version"), + staleTime: 5 * 60_000, + }); + const running = versionQ.data?.kbagent.version; + + const [expandAll, setExpandAll] = useState(false); + // Per-note overrides on top of the global toggle; cleared when it flips. + const [overrides, setOverrides] = useState>({}); + const isExpanded = (key: string) => overrides[key] ?? expandAll; + const toggleNote = (key: string) => + setOverrides((o) => ({ ...o, [key]: !(o[key] ?? expandAll) })); + const toggleAll = () => { + setExpandAll((v) => !v); + setOverrides({}); + }; + + const entries = q.data?.entries ?? []; + return ( -
    - +
    + 0 ? ( + + ) : undefined + } + /> {q.isLoading ? : null} {q.error ? : null} -
    - {(q.data?.entries ?? []).map((e) => ( -
    -

    v{e.version}

    -
      - {e.highlights.map((h, i) => ( -
    • - • {h} -
    • - ))} -
    -
    - ))} -
    + +
      + {entries.map((e, idx) => { + const isRunning = e.version === running; + return ( +
    1. + {/* Rail node: filled + glowing for the running version. */} + +
      +

      + v{e.version} +

      + {isRunning ? running now : null} + {idx === 0 && !isRunning ? ( + latest + ) : null} + + {e.highlights.length} {e.highlights.length === 1 ? "change" : "changes"} + +
      +
        + {e.highlights.map((h, i) => { + const key = `${e.version}:${i}`; + return ( + toggleNote(key)} + /> + ); + })} +
      +
    2. + ); + })} +
    ); } From 230dcc212e5cea1429c2659045213d62fbf4d224 Mon Sep 17 00:00:00 2001 From: Petr Date: Mon, 24 Aug 2026 00:21:13 +0200 Subject: [PATCH 2/2] fix(ui): make the web headline split an exact port of headline() The abbreviation set dropped `no`, `al` and `inc` from _HEADLINE_ABBREVIATIONS and invented `incl` and `resp`, so a note carrying any of them split into a different first sentence on the Changelog page than under `kbagent changelog`. The trailing-token regex diverged the same way: `[\w.]+$` captures digits and underscores where _TRAILING_TOKEN is letters-only, so "kbagent_no." resolved to the whole token instead of "no" and missed the abbreviation lookup. Both are now exact ports, covered by a table test over every member of the Python set. All twelve cases were A/B'd against the real `headline()` and match. --- web/frontend/src/changelogNotes.test.ts | 21 +++++++++++++++++++++ web/frontend/src/changelogNotes.ts | 15 +++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/web/frontend/src/changelogNotes.test.ts b/web/frontend/src/changelogNotes.test.ts index 3c315a94..df864e92 100644 --- a/web/frontend/src/changelogNotes.test.ts +++ b/web/frontend/src/changelogNotes.test.ts @@ -62,6 +62,27 @@ describe("splitHeadline", () => { const { headline } = splitHeadline("exit code 5! And more."); expect(headline).toBe("exit code 5!"); }); + + // The abbreviation set is an exact port of _HEADLINE_ABBREVIATIONS in + // changelog.py; every member must suppress the split here too, or a note + // reads differently on the page than under `kbagent changelog`. + it.each(["e.g", "i.e", "vs", "etc", "cf", "no", "al", "inc"])( + "does not break after the %s. abbreviation", + (abbr) => { + const { headline } = splitHeadline(`before ${abbr}. after. Second sentence.`); + expect(headline).toBe(`before ${abbr}. after.`); + }, + ); + + it("breaks after an abbreviation-like word outside the ported set", () => { + const { headline } = splitHeadline("shipped incl. extras. More."); + expect(headline).toBe("shipped incl."); + }); + + it("resolves the trailing token to its final word, digits and underscores aside", () => { + const { headline } = splitHeadline("the flag kbagent_no. after. More."); + expect(headline).toBe("the flag kbagent_no. after."); + }); }); describe("toRuns", () => { diff --git a/web/frontend/src/changelogNotes.ts b/web/frontend/src/changelogNotes.ts index e92edf57..8e26db34 100644 --- a/web/frontend/src/changelogNotes.ts +++ b/web/frontend/src/changelogNotes.ts @@ -80,9 +80,16 @@ const LABELS: Record = { why: "Why", }; -// Abbreviations whose trailing period is not a sentence end; subset of -// _HEADLINE_ABBREVIATIONS in changelog.py that actually occurs in notes. -const ABBREVIATIONS = new Set(["e.g", "i.e", "etc", "vs", "cf", "incl", "resp"]); +// Abbreviations whose trailing period is not a sentence end. Exact port of +// _HEADLINE_ABBREVIATIONS in changelog.py -- a note must split the same way +// here and in `kbagent changelog`, so this set carries no additions of its own. +const ABBREVIATIONS = new Set(["e.g", "i.e", "vs", "etc", "cf", "no", "al", "inc"]); + +// Final alphabetic token (internal dots included) before a period, tested +// against ABBREVIATIONS. Port of _TRAILING_TOKEN: letters only, so a token +// carrying digits or underscores resolves to its trailing word exactly as it +// does in Python ("kbagent_no." -> "no"). +const TRAILING_TOKEN = /[A-Za-z][A-Za-z.]*$/; /** * First-sentence split, porting `headline()`'s rules: a `.`/`!`/`?` followed @@ -96,7 +103,7 @@ export function splitHeadline(text: string): { headline: string; rest: string } const dot = m.index; const before = dot > 0 ? text[dot - 1] : ""; const isDigitPeriod = text[dot] === "." && before >= "0" && before <= "9"; - const tokenMatch = /[\w.]+$/.exec(text.slice(0, dot)); + const tokenMatch = TRAILING_TOKEN.exec(text.slice(0, dot)); const token = tokenMatch ? tokenMatch[0].replace(/\.+$/, "").toLowerCase() : ""; if (!isDigitPeriod && !ABBREVIATIONS.has(token)) { return {