From 95a022d7252e95f4d91359cd09810ae9546a35be Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 18:50:35 -0700 Subject: [PATCH] Stop stamping changelog watermark without showing notes (CL-5475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenTUI path computed whatsNewMarkdown and stamped lastChangelogVersion as a side effect, but never rendered the notes — upgrades were permanently marked seen without display. Stamp first-install only; upgrade stamps only when notesShown is true (currently false until a surface returns). --- src/changelog/index.test.ts | 40 ++++++++++++++++++++++++++++++++++++- src/changelog/index.ts | 23 ++++++++++++++++++++- src/config/settings.ts | 3 ++- src/tui/runner.ts | 37 +++++++++++++++------------------- 4 files changed, 79 insertions(+), 24 deletions(-) diff --git a/src/changelog/index.test.ts b/src/changelog/index.test.ts index 58ed74d9f..9c8c6cbfa 100644 --- a/src/changelog/index.test.ts +++ b/src/changelog/index.test.ts @@ -12,6 +12,7 @@ import { parseChangelogText, parseVersionString, resolveChangelogPath, + stampVersionAfterStartup, } from "./index.js"; const SAMPLE = `# Changelog @@ -96,7 +97,7 @@ describe("decideStartupChangelog", () => { expect(d.kind).toBe("first_install"); }); - test("upgrade shows notes and stamps package version", () => { + test("upgrade yields notes with stampVersion for when shown", () => { const d = decideStartupChangelog({ entries, lastChangelogVersion: "0.2.85", @@ -122,6 +123,43 @@ describe("decideStartupChangelog", () => { }); }); +describe("stampVersionAfterStartup", () => { + const entries = parseChangelogText(SAMPLE); + + test("first_install always stamps (quiet, no history dump)", () => { + const d = decideStartupChangelog({ + entries, + lastChangelogVersion: undefined, + packageVersion: "0.2.86", + }); + expect(stampVersionAfterStartup(d, false)).toBe("0.2.86"); + expect(stampVersionAfterStartup(d, true)).toBe("0.2.86"); + }); + + test("upgrade stamps only when notes were shown (CL-5475)", () => { + const d = decideStartupChangelog({ + entries, + lastChangelogVersion: "0.2.85", + packageVersion: "0.2.86", + }); + expect(d.kind).toBe("upgrade"); + // Dead surface / OpenTUI gap: do not consume notes without display. + expect(stampVersionAfterStartup(d, false)).toBeNull(); + // When a surface restores and actually shows markdown, stamp. + expect(stampVersionAfterStartup(d, true)).toBe("0.2.86"); + }); + + test("current never stamps", () => { + const d = decideStartupChangelog({ + entries, + lastChangelogVersion: "0.2.86", + packageVersion: "0.2.86", + }); + expect(stampVersionAfterStartup(d, false)).toBeNull(); + expect(stampVersionAfterStartup(d, true)).toBeNull(); + }); +}); + describe("formatStartupChangelog", () => { test("caps entry count and marks truncated", () => { const entries = parseChangelogText(SAMPLE); diff --git a/src/changelog/index.ts b/src/changelog/index.ts index 61e0a498d..16030d408 100644 --- a/src/changelog/index.ts +++ b/src/changelog/index.ts @@ -163,8 +163,11 @@ export type ChangelogDisplayDecision = /** * Decide what to show on interactive start. * - Missing/empty/malformed watermark → first install: stamp package version, no history dump. - * - New versioned sections after watermark → upgrade notes + stamp package version. + * - New versioned sections after watermark → upgrade notes (stamp only once actually shown). * - Otherwise quiet. + * + * Persistence of the watermark is separate: see {@link stampVersionAfterStartup}. + * Callers must not stamp upgrade notes unless they rendered them (CL-5475). */ export function decideStartupChangelog(input: { entries: ChangelogEntry[]; @@ -201,6 +204,24 @@ export function decideStartupChangelog(input: { }; } +/** + * Version to persist as `lastChangelogVersion` after this interactive start, or + * `null` to leave the watermark alone. + * + * - first_install: always stamp (quiet; never dump history on later launches). + * - upgrade: stamp only when `notesShown` is true. A dead surface must not + * consume notes by stamping without display (CL-5475). + * - current: no write. + */ +export function stampVersionAfterStartup( + decision: ChangelogDisplayDecision, + notesShown: boolean, +): string | null { + if (decision.kind === "first_install") return decision.stampVersion; + if (decision.kind === "upgrade" && notesShown) return decision.stampVersion; + return null; +} + /** * Resolve CHANGELOG.md for runtime: package root (dev / npm), then next to the * executable (binary install), then cwd. diff --git a/src/config/settings.ts b/src/config/settings.ts index 8f0df7a35..3ffff889b 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -105,7 +105,8 @@ export type Settings = { // shown. Controls whether subsequent launches show "Welcome to" vs "Welcome back". onboarded?: boolean; // Last package version whose release notes were shown (or stamped on first - // interactive install). Drives the one-shot post-upgrade notes banner. + // interactive install). Upgrade stamps only after notes are actually shown + // so a missing surface cannot silently swallow them (CL-5475). lastChangelogVersion?: string; // Controls the context-compaction strategy used when the context window fills. // "llm" (default) generates a structured handoff summary via LLM call. diff --git a/src/tui/runner.ts b/src/tui/runner.ts index fe8d3ad08..bc6bc6f4b 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -109,7 +109,10 @@ import { captureSlashCommand } from "../telemetry/product-events.js"; import { getTelemetry, liveTelemetry, setTelemetry } from "../telemetry/singleton.js"; import { createTelemetryToggleHandler } from "../telemetry/toggle.js"; -import { loadStartupChangelogMarkdown } from "../changelog/index.js"; +import { + loadStartupChangelogMarkdown, + stampVersionAfterStartup, +} from "../changelog/index.js"; import { scheduleUpgradeNotice } from "../upgrade/index.js"; import pkg from "../../package.json" with { type: "json" }; import { seedPricingMetadataFromCache } from "../cost/pricing-metadata.js"; @@ -1831,30 +1834,22 @@ export async function runTUI(initialConfig: Config): Promise { }); } - // Post-upgrade release notes: one-shot banner on a fresh interactive session. - // Resume skips the banner and does not stamp, so the next fresh session still - // surfaces notes. First install stamps without dumping history. + // Post-upgrade release notes watermark policy (CL-5475): + // - first_install: stamp quietly so later launches do not dump history. + // - upgrade: stamp only when notes were actually shown. The former Ink + // whats-new banner is gone on the OpenTUI path, so notesShown is false + // until a surface is restored — never silently consume upgrade notes. + // - resume / current: leave the watermark alone. const changelogDecision = loadStartupChangelogMarkdown({ lastChangelogVersion: globalSettingsForOnboarding?.lastChangelogVersion, packageVersion: typeof pkg.version === "string" ? pkg.version : "0.0.0", }); - let whatsNewMarkdown: string | undefined; - if (changelogDecision.kind === "upgrade" && !resumeSkipInitialTask) { - whatsNewMarkdown = changelogDecision.markdown; - void markLastChangelogVersion(trueGlobalSettingsPath, changelogDecision.stampVersion).catch( - () => { - // Best-effort watermark; worst case notes reappear next launch. - }, - ); - } else if (changelogDecision.kind === "first_install") { - void markLastChangelogVersion(trueGlobalSettingsPath, changelogDecision.stampVersion).catch( - () => { - // Best-effort watermark. - }, - ); - } else if (changelogDecision.kind === "upgrade" && resumeSkipInitialTask) { - // Resume with pending notes: leave watermark alone so a future fresh - // session can show them. + const notesShown = false; + const stampVersion = stampVersionAfterStartup(changelogDecision, notesShown); + if (stampVersion !== null) { + void markLastChangelogVersion(trueGlobalSettingsPath, stampVersion).catch(() => { + // Best-effort watermark. + }); } const commandContext: CommandContext = {