Skip to content

The update button opens a window with the patch notes, Cancel and Install; built once in core (#28) - #30

Merged
Maxaubert merged 5 commits into
mainfrom
feat/28-update-window
Sep 20, 2026
Merged

Maxaubert merged 5 commits into
mainfrom
feat/28-update-window

Conversation

@Maxaubert

@Maxaubert Maxaubert commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

Closes #28. Stacked on #29 (feat/27-verbs-edges), which is this PR's base: merge #29 first, then retarget this one to main.

Owner, 2026-09-19: "when you click the Update badge, it opens like a pop window, which shows the change log or like patch notes for the new update, and then you can choose cancel or install." And: "I would want to see how the Update banner looks in both apps, so if you could enable it and make like a fake update." Decision: "yes keep the core".

What changed

The update chip opens a window; it no longer installs on a click. The window says Update to <version>, a quiet You have <current>, the notes as a scrollable list, then Cancel and Install (Install is the primary and holds the focus). It closes on Cancel, Escape, a press outside, and Install; progress then shows in the chip.

Built once, in core/ (core 0.2.0 -> 0.3.0), so Prism gets the identical thing. This WIDENS the core from "the terminal" to "what the two apps share"; core/README.md (contract) and CLAUDE.md say so, dated, with the owner's words.

  • core/shared/updateTypes.ts: UpdateInfo { version, url, notes, mock? }.
  • core/shared/releaseNotes.ts: a GitHub release body -> a capped list of PLAIN strings. Drops the by @user in <url> tail, keeps the PR number as text ((#30), taken from the url when the title lacks it), drops New Contributors, Full Changelog, headings, rules, HTML (comments, tags, script/style blocks, tags hidden inside tags), markdown link targets, emphasis marks, control characters and the bidi overrides (U+202E). 20 entries, 160 chars each, + N more, only the first 20,000 chars of a body are read, and a missing / null / non-string / boilerplate-only body is one line. 37 unit tests, including two that a backtracking regex would hang on.
  • core/main/updatePreview.ts: the --preview-update fake. No electron, injectable clock.
  • core/renderer/lib/updateFlow.ts (pure reducer, 20 tests) + useUpdateFlow.ts (the hook: the host hands in its bridge and its install guard).
  • core/renderer/components/UpdateChip.tsx + UpdateDialog.tsx: props only, --p-* tokens, the dialog on the opaque --p-side-flat, modelled on this app's Dialog.tsx.

The notes are never rendered. Text off the network in a window that can reach the bridge: parsed to strings, printed as React text nodes. No markdown renderer, no dangerouslySetInnerHTML, no <a>.

--preview-update, in the packaged app too and under --e2e: main offers a fake update (next minor, e.g. 0.5.0 from 0.4.0; a realistic generated body) and the real watcher never starts. Install runs about three seconds of fake progress, then the chip says Preview only: nothing was installed and returns to idle. The dialog also says it is a preview. A second launch with the flag previews in the already-running app (only when no real update is on offer). An unpackaged (dev) build previews unasked; that replaces the old inert mock chip. Under --e2e without the flag: no chip, no network, as before.

To try it on the installed app after this ships: "%LOCALAPPDATA%\Programs\PrismTerminal\PrismTerminal.exe" --preview-update.

Three things found on the way, all fixed here:

  1. The chip DID change width. "One shape, never changes width" was the design, but the pill is sized by its label. Measured with the fix removed: 104.8px idle, 54.8px at "7%", 96.0px at "Installing…". Every label is now always laid out in one grid cell and only the current one is visible. The e2e samples the width and the left edge every 25 ms through a whole install.
  2. White "35%" on pale blue on a light theme. One ink (--p-on-accent) for the whole label. I saw it in the e2e's screenshot; no assertion caught it. The fill is now a second copy of the chip in the accent's ink, clipped to the percentage, over a copy in --p-text. Measured in the e2e now.
  3. Install would have killed a working agent without asking. Main pre-answers the close question for an install (closeAgreed) and its comment said the page settles that first. That was true of Prism's page and never of this app's. App now has an install guard: while an agent is WORKING (holdsWindowClose, the window's own rule) it asks "Stop the agent and install the update?" / "Install and restart" before anything downloads. A preview asks nothing, since it quits nothing. Also: a download that fails now says so under the chip; it used to fall back to "Update" silently.

This app's wiring: latestUpdate() returns the body as notes (capped), TitleBar takes the core chip, the dialog is mounted once in App, the app's old UpdateChip.tsx is deleted. App version 0.3.0 -> 0.4.0.

Independent review (2026-09-20), four things found and fixed on this branch

  1. A close question opened UNDER the update window. The app's chords still work over the window, so Ctrl+W on a tab that hosts an agent (or Alt+F4) mounted the close question beneath it (same z-index, earlier in the document) with the focus on "Close tab" where nobody could see it: Enter, aimed at Install, ended the agent. App now puts the update window away whenever a question appears. updateGuard holds it (5 new checks), and with the fix taken out the new check fails. core/README.md carries the rule for Prism's half. (3020ea4)
  2. Ctrl+Tab did two things at once with the window up: the app stepped the tab strip and the window's Tab trap moved the focus. The trap now takes a plain Tab only. (3020ea4)
  3. The parser cut real titles. The author tail accepted any word after "in", so "Mention people by @handle in comments" became "Mention people". It must be a url now. A hand-written paragraph wrapped at a column also came out as one bullet per line; consecutive plain lines are one entry. 2 new unit tests. Both apps' newest real release bodies parse as expected. (69dd09f)
  4. Escape sometimes did nothing in a dialog (found as a flake in my own new check, about one run in four, then traced with a key and mutation log). Dialog keyed its keydown effect on an inline onCancel, so the listener was re-registered on every render; when another window keydown listener sets state during the same Escape, React flushes the effect between the two listeners and the question never hears the key. Both dialogs now register once and read onCancel from a ref. updateGuard ran 10 of 10 afterwards. (1c15125)

Checked and left alone: the parser's worst case on a 20,000 character hostile body is about 165 ms (timed on 17 adversarial shapes, none hangs); every --p-* token and class the two core components use exists in Prism as well; core-v0.3.0-rc.1 holds exactly the core of 27a2df6 (same tree); the diff holds source, docs and the lockfile only; no em-dashes. Known and not changed: an offer that arrives while the window is open replaces its contents (only reachable when a second-launch preview is followed by a real 4-hourly check).

After the review: typecheck clean, lint 0 errors and the same 8 warnings, unit 51 files and 687 tests, and every e2e scenario except the two dictation ones, by name: spawn 7, cwdLabel 3, indicator 9, theme 8, newTabFolder 6, restore 4, handoff 3, lastTab 7, closeAsk 8, links 6, dropAndMenu 5, options 10, edges 35, updateWindow 52, updateGuard 23, updateNotes 9, updateQuiet 6, exitClosesTab 3, promptLayout 2, all passing. The packaged build was not rebuilt after the review.

Core release candidate

core-v0.3.0-rc.2 (commit dd5f51d, cut after the review and holding the core as it is on this branch now; core-v0.3.0-rc.1, commit 2ccdc26, is the core before the review and should not be pinned), split from this branch with git subtree split --prefix=core, tag only; core-dist was not touched and the local split branch was deleted. I type-checked the rc's update files against Prism-work's own TypeScript 5.9.3 and @types/react 19.2.17 (read-only, nothing installed there): clean.

For Prism's half, a host needs: notes from its own update check, wantsPreview / previewUpdate / runPreviewInstall in main, useUpdateFlow(window.prism, guard), <UpdateChip> in the bar and one <UpdateDialog> at the root. Prism's preload already spells onUpdate / onUpdateProgress / installUpdate the same way. closeQuestionTitle gained an 'install' target (additive).

Tests

  • Typecheck clean. Lint 0 errors, 8 warnings (the same 8 as the base).
  • Unit: 51 files, 685 tests, all passing (base: 48 files, 612).
  • E2E on the built app, all passing: updateWindow 52, updateGuard 18, updateNotes 9, updateQuiet 6, options 10, spawn 7, theme 8, closeAsk 8, edges 35, handoff 3, lastTab 7, restore 4.
  • E2E against the PACKAGED build (npm run package, then PT_E2E_PACKAGED=1, driving dist/win-unpacked; no installer was run): updateWindow 52, updateGuard 18, updateNotes 9, updateQuiet 2, all passing. That is the proof the flag works in the packaged app.
  • No scenario with "dictation" in its name was run (another suite was running on the machine).

What the new scenarios prove:

  • updateWindow: chip in the title bar under --preview-update; a click opens the window and installs nothing; title, "You have", 7 entries as plain text; no by @, no url, no boilerplate, no <a>, only plain elements in the notes; opaque surface; Cancel|Install with Install focused; Escape, Cancel and an outside press each close it; Install runs downloading -> installing -> idle with a rising percentage; the preview line; width and left edge identical across all ~130 samples; afterwards 0 release checks, 0 installs, no prismterminal-update-* folder in temp, no process naming one, the line leaves by itself and the app and its shell are still alive; then the same on a light theme, including the label ink.
  • updateNotes: a hostile 30-line body (<img onerror>, <script>, <iframe>, <a href>, a markdown link) reaches the real page: 0 anchors / images / scripts / frames, the handler never ran, 20 shown and "+ 10 more", the list scrolls, and at the 560x400 minimum window the whole dialog still fits with its buttons.
  • updateGuard: a real-shaped offer whose url the installer refuses before sending a byte (PT_E2E_UPDATE_OFFER, reachable only under --e2e): empty notes say "No notes were published with this release."; with an agent working, Install asks in its own words; Cancel starts nothing; the go-ahead attempts the install, it fails, the chip says so and returns to idle; and closing the window afterwards STILL asks (the pre-answer was reset).
  • updateQuiet: no flag, no chip, watcher off; then a SECOND launch with --preview-update exits and the running app shows the preview chip, opens no tab for it, and still asks GitHub nothing.
  • Mutation check: with the label sizers removed, the width assertion fails (54.8 / 61.2 / 61.0 / 96.0 / 104.8 px), so the test is sensitive to the thing it guards.

Screenshots I opened (.e2e-shots/)

  • update-dialog-dark.png, update-dialog-light.png: the window centred, title, "You have 0.4.0", a bordered list under "WHAT IS NEW" with accent bullets, the preview line, Cancel and Install. Reads well on both; matches the close question's box, scrim and buttons.
  • update-chip-dark/light.png, update-progress-dark/light.png, update-notice-dark.png: the chip left of the cog; at 35% the filled part is the accent with its ink and the rest is tinted with the theme's text on it (after fix 2); the line "Preview only: nothing was installed" hangs under the chip, right-aligned to it, over the tab strip. In the first notice shot the fill was caught draining backwards over "Update 0.5.0" (a 300 ms transition); the return to idle no longer transitions.
  • update-dialog-empty.png, update-agent-question.png, update-dialog-small.png, update-dialog-long.png, settings-general.png (no chip without the flag; the Version hint fits on one line).

For the owner

  1. Install focused by default. Enter installs, as Enter confirms in every other dialog here. If you would rather an update never start from a stray Enter, moving the focus to Cancel is one line in UpdateDialog.tsx.
  2. Where the preview line shows. A small line hung under the chip (8 s, or click it away), so the chip itself never changes width. The same place now reports a failed download. Say if you would rather have it inside a reopened window.
  3. Caps: 20 entries then "+ N more", 160 characters per entry. Both are constants in releaseNotes.ts.
  4. The install question is new behaviour in this app (fix 3). It follows the window's rule: only an agent that is mid-answer holds it; idle agents come back at the next launch.
  5. Dev builds now preview where they used to show an inert chip.

Not verified

  • A REAL update end to end (a real release, a real download, the installer hand-off): unchanged code, but not exercised; nothing was downloaded or installed, by the rules of this run.
  • Installing the build (CLAUDE.md's last step) was skipped: no installer was run and no installed app was touched. The packaged build was driven from dist/win-unpacked instead.
  • The second-launch preview path is covered for the unpackaged build only (the packaged run of updateQuiet was 2 checks, before that check was added, and the check skips itself under PT_E2E_PACKAGED).
  • Prism's half (adopting core-v0.3.0-rc.2) is not in this PR.

🤖 Generated with Claude Code

@Maxaubert
Maxaubert changed the base branch from feat/27-verbs-edges to main September 20, 2026 11:20
Maxaubert and others added 5 commits September 20, 2026 13:20
…nd Install; built once in core (#28)

The chip used to download and quit on a click. It now opens a window that
lists what changed, as PLAIN TEXT parsed from the release body (never HTML,
never markdown, never a link), with Cancel and Install. Built in core/ so
both apps show the same thing (owner: "yes keep the core"), which widens the
core from "the terminal" to "what the two apps share"; core 0.2.0 -> 0.3.0.

- core: updateTypes, releaseNotes (parser), updatePreview (--preview-update
  fake: no network, no file, no process, no quit), updateFlow + useUpdateFlow,
  UpdateChip + UpdateDialog (props only).
- The chip never changes width: every label is laid out in one cell. It did
  change before (104.8 / 54.8 / 96.0 px, measured).
- The label ink follows what is behind it (a clipped second copy), after the
  e2e screenshot showed white "35%" on pale blue on a light theme.
- Install asks first while an agent is working; nothing in this app did.
  A failed download now says so under the chip.
- e2e: updateWindow, updateGuard, updateNotes, updateQuiet. App 0.4.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ing app (#28)

The app is single-instance and usually already running when somebody tries
the flag, so that launch hands its command line over and ends. updateQuiet
now proves the running app honours it, opens no tab for it, and still never
asks GitHub.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… Tab trap leaves Ctrl+Tab alone (#28)

Review of #28. The app's chords still work over the update window, so
Ctrl+W on a tab that hosts an agent (or Alt+F4) raised the close question
UNDER it: same z-index, earlier in the document, with the focus on
"Close tab" where nobody could see it. Enter, aimed at Install, ended the
agent. App now cancels the update window whenever a question appears, and
updateGuard holds it (mutation-checked: with the effect off the new check
fails). The dialog's Tab trap also handled Ctrl+Tab, which the app's own
capture listener had already used to step the tab strip: one press did
both. core/README.md gains the host rule for Prism's half.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pped prose is one entry (#28)

Review of #28. The author tail took ANY word after "in", so a title such as
"Mention people by @handle in comments" was cut down to "Mention people".
What follows "in" must now be a url, which is what generate-notes writes;
a bare "by @user" at the end still goes. And a hand-written paragraph
wrapped at a column came out as one bullet per line; consecutive plain
lines are one entry now, ended by a blank line, a heading or a bullet.
Both apps' newest real release bodies were parsed by hand as a check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nger re-registers on every render (#28)

Found as a flake in the new updateGuard check (about one run in four):
Escape did nothing, with the question on screen and focused. Dialog keyed
its keydown effect on an inline onCancel, so the listener was removed and
re-added on every render of the app. When another window keydown listener
sets state during the same Escape, React flushes that effect between the
two listeners: the old one is removed before its turn and the new one is
not called for an event already in dispatch. Both dialogs now register
once and read the latest onCancel from a ref; Dialog refocuses its primary
when the question changes under it, which the re-running effect used to do
by accident. updateGuard: 10 of 10 afterwards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Maxaubert
Maxaubert force-pushed the feat/28-update-window branch from 1c15125 to 06725ce Compare September 20, 2026 11:20
@Maxaubert
Maxaubert merged commit 137ebde into main Sep 20, 2026
2 checks passed
@Maxaubert
Maxaubert deleted the feat/28-update-window branch September 20, 2026 11:22
github-actions Bot pushed a commit that referenced this pull request Sep 20, 2026
…tall; built once in core (#28) (#30)

* feat(update): the update chip opens a window with the notes, Cancel and Install; built once in core (#28)

The chip used to download and quit on a click. It now opens a window that
lists what changed, as PLAIN TEXT parsed from the release body (never HTML,
never markdown, never a link), with Cancel and Install. Built in core/ so
both apps show the same thing (owner: "yes keep the core"), which widens the
core from "the terminal" to "what the two apps share"; core 0.2.0 -> 0.3.0.

- core: updateTypes, releaseNotes (parser), updatePreview (--preview-update
  fake: no network, no file, no process, no quit), updateFlow + useUpdateFlow,
  UpdateChip + UpdateDialog (props only).
- The chip never changes width: every label is laid out in one cell. It did
  change before (104.8 / 54.8 / 96.0 px, measured).
- The label ink follows what is behind it (a clipped second copy), after the
  e2e screenshot showed white "35%" on pale blue on a light theme.
- Install asks first while an agent is working; nothing in this app did.
  A failed download now says so under the chip.
- e2e: updateWindow, updateGuard, updateNotes, updateQuiet. App 0.4.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(e2e): a second launch with --preview-update previews in the running app (#28)

The app is single-instance and usually already running when somebody tries
the flag, so that launch hands its command line over and ends. updateQuiet
now proves the running app honours it, opens no tab for it, and still never
asks GitHub.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(update): the update window gives way to a close question, and its Tab trap leaves Ctrl+Tab alone (#28)

Review of #28. The app's chords still work over the update window, so
Ctrl+W on a tab that hosts an agent (or Alt+F4) raised the close question
UNDER it: same z-index, earlier in the document, with the focus on
"Close tab" where nobody could see it. Enter, aimed at Install, ended the
agent. App now cancels the update window whenever a question appears, and
updateGuard holds it (mutation-checked: with the effect off the new check
fails). The dialog's Tab trap also handled Ctrl+Tab, which the app's own
capture listener had already used to step the tab strip: one press did
both. core/README.md gains the host rule for Prism's half.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(core): a title that names a handle is not an author tail, and wrapped prose is one entry (#28)

Review of #28. The author tail took ANY word after "in", so a title such as
"Mention people by @handle in comments" was cut down to "Mention people".
What follows "in" must now be a url, which is what generate-notes writes;
a bare "by @user" at the end still goes. And a hand-written paragraph
wrapped at a column came out as one bullet per line; consecutive plain
lines are one entry now, ended by a blank line, a heading or a bullet.
Both apps' newest real release bodies were parsed by hand as a check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(dialog): Escape is heard once and for all: the key listener no longer re-registers on every render (#28)

Found as a flake in the new updateGuard check (about one run in four):
Escape did nothing, with the question on screen and focused. Dialog keyed
its keydown effect on an inline onCancel, so the listener was removed and
re-added on every render of the app. When another window keydown listener
sets state during the same Escape, React flushes that effect between the
two listeners: the old one is removed before its turn and the new one is
not called for an event already in dispatch. Both dialogs now register
once and read the latest onCancel from a ref; Dialog refocuses its primary
when the question changes under it, which the re-running effect used to do
by accident. updateGuard: 10 of 10 afterwards.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Maxaubert added a commit that referenced this pull request Sep 25, 2026
…2e harness (#71) (#72)

Settings: the colour editor saves the whole setup into Custom and is a theme
pick (#8, #29); ThemeSwitchAsk registers once (#22); a hex field commits only
a changed draft (#26); the dictation key is never a key that types or a chord
the terminal owns, and a press elsewhere ends the capture (#27); the editor
takes the focus, traps Tab and hears Escape, the pencil is its own button
(#28); a finished download reads fresh state before picking the model (#30).

Release and CI: release.yml tags the built commit (#11); core-release runs
from main only (#34), waits for terminal-gate by name and its success (#38),
and opens an issue when a core change cannot be released; core-version also
requires the core version above the base's (#39).

Harness: a time limit per scenario and closeApp (#35), profiles removed after
each scenario (#36), the poll's first verdict awaited instead of 6 s (#37).

Core 0.15.3, app 0.18.3. New reviewSettings e2e and unit tests.


Claude-Session: https://claude.ai/code/session_01LJbePcRzre7AusNzPNS2Bk

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
github-actions Bot pushed a commit that referenced this pull request Sep 25, 2026
…2e harness (#71) (#72)

Settings: the colour editor saves the whole setup into Custom and is a theme
pick (#8, #29); ThemeSwitchAsk registers once (#22); a hex field commits only
a changed draft (#26); the dictation key is never a key that types or a chord
the terminal owns, and a press elsewhere ends the capture (#27); the editor
takes the focus, traps Tab and hears Escape, the pencil is its own button
(#28); a finished download reads fresh state before picking the model (#30).

Release and CI: release.yml tags the built commit (#11); core-release runs
from main only (#34), waits for terminal-gate by name and its success (#38),
and opens an issue when a core change cannot be released; core-version also
requires the core version above the base's (#39).

Harness: a time limit per scenario and closeApp (#35), profiles removed after
each scenario (#36), the poll's first verdict awaited instead of 6 s (#37).

Core 0.15.3, app 0.18.3. New reviewSettings e2e and unit tests.


Claude-Session: https://claude.ai/code/session_01LJbePcRzre7AusNzPNS2Bk

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The update button opens a window with the patch notes, Cancel and Install; shared by both apps

1 participant