Replace the native link prompt with an inline link popover #298
- MA
## Integration and validation
Replace the native prompt with the inline link editor and preview, retaining collaboration-relative targets and Escape focus restoration. Keep stricter checks for newly introduced URLs across human, Planner, MCP and creation paths; existing stored URLs remain editable. Unsafe pasted links unwrap locally before publication.
Clean rebase onto current main. Reviewed SelectionBubble and the extracted research selection helper before renewing their exact pins. Formatting/lint/design checks, types, 267 focused URL/editor/storage/MCP tests and all 28 toolbar/child-document browser tests pass locally. Final required CI running.
<details> <summary>Original proposal and screenshots</summary>
## Why
Adding or editing a link used the browser's native `window.prompt` and `window.alert`. The prompt didn't match the rest of Chopin, didn't prefill the existing URL, offered no way to open the link, and the only way to remove a link was to submit an empty prompt. Clicking a link in the document showed nothing.
Separately, selecting text and pressing Escape left the formatting toolbar floating with nothing selected, and focus dropped to `<body>`.
## What changed
- **Link editor popover** (`packages/editor/src/toolbar/link.tsx`). Opens from the toolbar's link button or ⌘K, start-aligned under the text, on the shared popover surface (`rounded-lg bg-page ring-hairline shadow-overlay`). - One URL field: prefilled, focused, text selected. - An inline error in destructive ink replaces the alert. - Actions: **Add link** for a new link; **Remove** (ghost, left) and **Save** (primary, right) for an existing one. - Keys: Enter saves. Escape closes, puts the selection back and returns focus to the editor. Tab cycles inside the popover. - While the field has focus, the text being linked keeps a brand-wash highlight. - **Collaboration-safe target.** The text being linked is kept as Yjs relative positions, the same approach the research composer uses (`toolbar/position.ts`). If another writer types earlier in the paragraph while the field is open, the link still lands on the right text. If the text itself is replaced, nothing is linked and the popover says "Someone else changed this text. Select it again to add the link." Existing links are tracked by node key. - **Link preview.** In an editable document, moving the caret into a link shows the URL plus **Open** and **Edit**. Readers get the preview on click, with only **Open**. ⌘↵ opens the link under the caret, and the Open tooltip shows that shortcut. ⌘/Ctrl-click and middle-click still open the link straight away. This replaces `SafeClickableLinkPlugin`. - **URL rules** (`url.ts`, `checkUrl`): - Control and invisible characters are refused before anything else. - Values that name another host without a scheme (`//host`, `\\host`, `/\host`, any backslash) are refused. - Bare domains, `host:port` and `localhost:port` become `https://…`; bare addresses become `mailto:…`. - Repository paths such as `README.md` stay relative. - **Escape with the selection toolbar showing** collapses the selection to its focus point, keeps focus in the editor and hides the toolbar. If the block-type menu is open, Escape closes that first.
## Security: link validation gap on the server (already present before this PR)
The dialect validator (`packages/dialect/src/validate.ts`) accepted any link with no scheme as a repository path. That let two kinds of value through:
- **Hidden characters.** A leading U+0001 before `javascript:`, NUL- or zero-width-split schemes, and URLs with zero-width or bidi marks inside them. Browsers strip some of these while parsing, so U+0001 followed by `javascript:` resolves to a `javascript:` URL. Lexical rendered it as `about:blank`, so nothing ran, but the stored value was junk. - **Protocol-relative values.** `//evil.com`, `\\evil.com` and `/\evil.com`. Browsers resolve these to another host.
The same gap reached the server through Planner and MCP edits and Lexical exports. The dialect now exports one shared rule: `HIDDEN_URL_CHARACTERS` (control, format and separator characters) and `leavesRepository`. The browser uses the same rule.
**Existing documents keep working.** Whole-document validation (`validate`/`assert`) is unchanged. It runs when a room opens, restores from its checkpoint and journal, rebuilds or compacts, and on every batch, so tightening it would have locked people out of documents stored with such a link, or forced an epoch rebuild on every edit. The stricter rules live in `disguisedUrl` / `assertIntroducedUrls(before, after)` instead. These judge only URLs that are not already in the previous version, refusing: - hidden characters; - surrounding whitespace, or a scheme hidden by whitespace (` javascript:`); - scheme-less links that name another host.
Each refusal reports the structural path, e.g. `root > paragraph[1] > link[0]`. The check runs on: - human CRDT batches (`room.apply`) - Planner edits and MCP `update_document`, both through `edit.replace` / `edit.apply` against the stored document - new documents only: `Service.initial` and MCP `create_document`'s `prepare`
**Judged by URL, not position.** A stored link that breaks the new rules may stay, move, or be duplicated elsewhere in the same document. Only a URL the document did not already contain is refused.
**Pasted links.** A new local link (paste or import) whose address fails the client's `checkUrl` is unwrapped to its text before it syncs, so pasting HTML with `//cdn…`, `http:` or a zero-width character never sends a batch that the server refuses and that would reset the room. Links arriving from collaborators or undo history are left for the server to judge, so stored links are never stripped on open.
Tests: - `room.test.ts`: a stored backslash link restores and accepts a human edit; a new `//evil.com` or zero-width link is refused through `room.apply`. - `edit.test.ts`: Planner edits around a stored backslash link. - `update-lifecycle.test.ts`: MCP update of a stored document containing `docs\notes.md` succeeds. - `creation.test.ts`: `Service.initial` refuses `//evil.com` and a zero-width link. - Dialect tests: moving and duplicating a stored bad URL, whitespace cases, the path. - `link-guard.test.ts` and a paste e2e.
## Screenshots
**Before:** the native `window.prompt("Link URL")` dialog, and `window.alert("Only https:, mailto:, relative paths are allowed here.")` on a bad value. Headless Chromium can't capture these.
Before, Escape left the toolbar orphaned and focus on `<body>`: 
After, Escape collapses the selection and the toolbar goes away: 
After, desktop: add, inline validation, caret-on-link preview, edit (prefilled), all start-aligned: 
After, another writer types before the target (the link still lands on "Zeb"), and the target text is replaced (nothing is linked; the popover explains): 
After, reader preview (Open only): 
After, phone (390×844): add, validation, preview, edit: 
## Testing
- `bun test` in `packages/editor`: 465 pass, 0 fail. - `url.test.ts`: normalisation, hidden characters (U+0001 before `javascript:`, NUL- and ZWSP-split schemes, BOM, bidi), protocol-relative and backslash values, `host:port`. - `link.test.ts`: finding, retargeting and unwrapping links. - `bun test` in `packages/dialect`: 129 pass. They cover links a change introduces (hidden characters, protocol-relative, backslash) and a stored link being left alone. - `bun test` in `apps/server`: 2332 pass, including a stored-legacy-link restore and edit in `room.test.ts` and Planner edits around one in `edit.test.ts`. - `bun run types`: pass. `bun scripts/check-design-record.ts`: pass. - `bun run ci`: format, lint and tokens pass. The design-contract check fails only on the review hash for `bubble.tsx` (see below). - E2E (`e2e/toolbar.e2e.ts`; local E2E is suspended, so CI's browser job covers these): - Escape releases a selection. - Add, inline refusal, bare domain, Tab cycling, preview, ⌘↵ opening a popup, prefilled edit, Escape, ⌘K edit, remove. - Two clients: a remote insert before the target, and a remote replacement of the target. - Reader preview opening a `noopener` popup. - Pasting HTML with refused links leaves their text and keeps only the acceptable link. - `e2e/research-child-surface.e2e.ts` now opens links through the preview's Open button. - Checked by hand in Chromium against a fake-GitHub server, with two clients, on desktop and phone.
## Needs a design-contract review
- File: `packages/editor/src/toolbar/bubble.tsx` - Exception: `scripts/design-contract/exceptions/dynamic-editor.json` (`sourceHash` `b55b2087…`) - New hash: `6cd7ba34cbb5a5e127d4f170c7b3db4af7af0559fe79dd898167c0c76dda994a` - Why it's safe: the class expressions are unchanged. The edit swaps the prompt for a command dispatch, adds the Escape handler and makes the link button's label dynamic. No new style or class logic.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
</details>
- MA
**Review:** Changes needed
The design is right and the flow works in a real browser (desktop and 390px). Two correctness issues and a few smaller ones need fixing first. Tested on port 8922 with headless Chromium; the 49 toolbar unit tests pass.
**Must fix**
1. **URL check accepts control characters (`url.ts` `checkUrl`/`acceptable`).** `\u0001javascript:alert(1)` has no scheme as far as the `^[a-z]` test can tell, so it passes as a "repository path". Browsers strip leading C0 controls when parsing a URL (`new URL("\u0001javascript:alert(1)", base).href` is `javascript:alert(1)`). In the browser it was accepted and Lexical rendered `href="about:blank"`, so it did not run, but the link was lost and the stored value is junk. Zero-width spaces, NUL inside the scheme and `https://example.com` also pass. Fix: reject `/[\u0000-\u001f\u007f- ]/` up front, not just `\s`. `javascript:`, `data:`, `vbscript:`, `file:`, uppercase and tab-split variants are all refused correctly. 2. **Protocol-relative links are accepted as "paths in this repository".** `//evil.com`, `\\evil.com` and `/\evil.com` pass, and browsers resolve them to `https://evil.com`. The server validator has the same gap, but the new message promises a repo path. Fix: for relative values, refuse a leading `//` or any `\`. 3. **A remote edit while the editor is open can link the wrong text.** Repro: select `Zeb`, press ⌘K, and have another client type 9 characters earlier in the same paragraph. On Enter the link landed on `D B`, because `finish()` restores a cloned selection by node key and offset, which a remote insert invalidates. On another run the popover closed outright when the remote edit arrived. Existing links are safe because they use `NodeKey`. Fix for the add case: capture the range with the Yjs relative-position or Lexical-mapped approach the comment code uses. At minimum, re-read the selection from the live DOM range (`state.range`, which is already live) when applying, and cancel the editor if the target is gone. Don't silently apply to a different range.
**Should fix**
4. **"Open" has no keyboard path.** Tab from the editor inserts a tab character (it split my link) and never reaches the preview buttons. ⌘K only reaches Edit. Keyboard users cannot follow a link at all, and the old plugin was mouse-only too, but the preview now makes this visible. Suggest ⌘Enter (or Alt+Enter) while the caret is in a link to open it, and show the hint in the preview. Mention it in the button `title`. 2. **Tab leaves the editor popover.** In `link.tsx` the Link URL field Tab goes to Add link, then out of the popover to the page (sidebar). The popover stays open until a pointerdown. Trap Tab/Shift+Tab inside the form, or close on `focusout` that leaves it with focus not returned to the editor. 3. **`example.com:8080/x` and `localhost:3000` are refused.** The scheme regex `^[a-z][a-z0-9+.-]*:` matches `example.com:`. Check the HOST pattern first. Minor.
**Alignment recommendation**
In code the editor uses `"centre"` (`link.tsx` `usePlacement(..., "centre")`) and the preview uses `"start"`. Pick **start-aligned (left edge of the link)** for both. The preview is a continuation of the link text, and left-aligned is stable when a link wraps across lines. The editor is wider (288px) and centred on a short selection clamps to the viewport edge, as the screenshot shows (it sits flush at x=1 on `the docs`). Edit-from-preview then lands exactly where the preview was, which removes a jump.
**Checked and fine**
- Open uses `window.open(href, "_blank", "noopener,noreferrer")` in both the preview and the click handler. - `SafeClickableLinkPlugin` removal loses nothing. The new handler in `link.tsx` keeps: left/middle only, `download` links skipped, `preventDefault` always, no follow while a range is selected, and noopener/noreferrer on every open path. Modified or middle click and links in nested editors still open at once in a new tab. Read-only documents show a preview with Open only (no Edit). - Focus restoration: Escape, Add, Save, Remove all return focus to the editor with the selection back. Enter on an invalid value keeps focus in the field with `aria-invalid` and `role="alert"`. - Remote edits with an existing link are safe, because it is keyed by node and saves to the right link. - Phone 390x844: preview is 374px wide, the editor is 288px, both inside the 16px gutters and below the link, with no overflow. Touch targets are btn-sm (about 32px), which is acceptable. - Collision with comment markers: not exercised (no marker on the test link). Both surfaces are fixed `z-50` over content, so they can cover a marker below the link while open. Low risk.
**Click-in-editable tradeoff**
Acceptable. A first click now places the caret and shows the preview, and a second click on Open follows the link: one extra click. This matches Notion and Linear, and it fixes accidental navigation away from an editing session. Modified and middle clicks still open directly, which keeps the fast path. Add ⌘Enter (item 4) so it is not a regression for keyboard use.
- MA
Thanks for the review. All six items are addressed in 5e5d80b9.
**Must fix** 1. **Hidden characters.** `checkUrl` and `acceptable` now refuse control, format and separator characters (`[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]`, exported from the dialect as `HIDDEN_URL_CHARACTERS`) before anything else. Only ordinary leading and trailing whitespace is trimmed. Unit tests cover a U+0001-prefixed `javascript:`, NUL- and ZWSP-split schemes, a ZWSP inside a host, a BOM, and a bidi override. 2. **Protocol-relative and backslash values.** A value with no scheme that starts with `//` or contains any `\` is refused (`leavesRepository`). I fixed the same gap in the server-side validator (`packages/dialect/src/validate.ts`): it now refuses those links and hidden characters, and refuses hidden characters in image URLs. Validator tests build MDAST trees directly, since markdown parsing never produces these values. The PR body calls this out as a gap that already existed. 3. **Remote edits.** The add-link target is captured as Yjs relative positions (a shared `toolbar/position.ts`; the research composer's capture now uses it too). It is re-resolved when the link is applied. If the resolved text no longer matches what was selected, nothing is linked and the popover says "Someone else changed this text. Select it again to add the link." with a Close button. The popover also follows remote edits now. A new two-client e2e covers both cases: an insert before the target, and the target being replaced. I couldn't reproduce the popover closing outright on a remote edit: in my two-client runs it stays open. Existing links still go by node key; if that link was deleted, Escape just returns focus to the editor.
**Should fix** 4. **⌘↵ / Ctrl+Enter** opens the link under the caret. The Open button's tooltip is "Open in a new tab (⌘↵)", or "Open in a new tab" for readers. Edit's tooltip shows ⌘K. Covered in e2e. Tab inserting a tab character is left to #292. 5. **Tab** cycles between the field and the actions inside the editor popover (covered in e2e). An outside pointerdown still closes it. 6. **`example.com:8080/x` and `localhost:3000`** are now matched as hosts before the scheme test and become `https://…`. Unit-tested.
**Alignment:** both popovers now start at the left edge of the link's first line.
CI: e2e passed (335 tests, including the new two-client test). The container build passed. The validation job fails only on the `bubble.tsx` design-contract review hash, unchanged and listed in the PR body.
- MA
Follow-up in 7eb26638: the stricter URL rules no longer lock out documents that were stored before them.
Whole-document validation runs on open, restore (checkpoint and journal), rebuild, compaction and every human batch. Tightening it would have stopped a stored document with a backslash or hidden-character link from opening, or forced an epoch rebuild on every edit. So `validate`/`assert` are back to their earlier link rules, and the new rules live in `assertIntroducedUrls(before, after)`. That function only judges URLs that were not already in the previous version. It runs on human batches (`room.apply`), Planner and MCP edits (`edit.apply`, `edit.replace`), and new documents (`Service.initial` and MCP `canonical`, so MCP create and research publication are covered).
Tests: in `room.test.ts`, a stored document with `[the notes](docs\\notes.md)` restores from its checkpoint and accepts a human edit, while a new `//evil.com` link is refused. In `edit.test.ts`, the Planner can edit around that stored link but cannot add a new bad one. The dialect tests cover the same cases. The PR body's security section describes this behaviour.
CI: e2e 335 passed and the container build passed. Validation fails only on the `bubble.tsx` review hash, as before.
- MA
**Review (validation change):** Approve once #1 is fixed. #2 and #3 are worth doing in this PR. The rest is optional.
Scope: 5e5d80b9 and 7eb26638 only. I ran the dialect, `url.test.ts`, `edit.test.ts`, `room.test.ts` and `src/mcp` suites at 7eb26638 and they pass. I also ran some probes in a scratch worktree, which are not committed.
**1. Bug: MCP `update_document` locks out the stored documents this change is meant to protect.** `prepareUpdate` (`mcp/update.ts:66`) calls `canonical()`, which now runs `assertIntroducedUrls([], tree.children)`. That treats every URL in the submitted full document as new. A coding agent that sends back a stored document unchanged except for one new paragraph is refused. I confirmed this with `prepareUpdate({... plan: "# T\n\nRead [n](docs\\\\notes.md).\n\nAdded.\n"})`, which returns `bad-link`. The update path already reaches `edit.replace` (`service.ts:1607`), and that compares against the stored document correctly. Fix: keep the `[]` check for create only, either in `prepareCreate` or behind a flag on `canonical`, and drop it from update. Add a test for an MCP update to a stored document.
**2. Gap: a leading space before `javascript:` is still accepted.** `" javascript:alert(1)"` contains no `\p{Cc}`, fails the scheme regex, and so is treated as a relative path. `leavesRepository` returns false for it. A crafted client batch therefore commits it: I projected `[click](< javascript:alert(1)>)`. The WHATWG URL parser strips leading and trailing C0 characters and spaces, so a browser resolves this as `javascript:`. This is the same class of problem `HIDDEN_URL_CHARACTERS` is meant to close. Lexical's `LinkNode` sanitising `href` to `about:blank` saves the editor, and `follow()` prefers `anchor.href`. Every other consumer of the stored MDX gets the raw value. The bug predates this PR, but this PR is the place to fix it. Suggest refusing leading and trailing whitespace, or any ASCII whitespace, in introduced URLs. The client already refuses `\s`.
**3. Pasted links can now reset the whole room.** A rejected human batch calls `rejectBatch`, which runs `room.rebuild`, moves everyone to a new epoch and loses everyone's undo and cursors. As far as I can see, the client has no guard on links that arrive by paste: Lexical `importDOM` keeps the raw `href`, and `checkUrl` only runs in the popover. Pasting HTML that contains `href="//cdn…"` or a zero-width character now resets everyone. Before this change it was accepted. Pasting `javascript:` hrefs already caused this, so it is not a new class of bug, but protocol-relative hrefs are far more common on real web pages. Consider a client-side link transform that drops a link failing `acceptable()` and keeps its text, or rewrites `//host` to `https://host`. Then a normal paste never makes a batch the server refuses.
**4. "Introduced" detection: no real bypass found.** - The comparison is an exact-string set built from the whole previous tree, so changing case, adding characters or using a different encoding all count as introduced. - Reference links, definitions and HTML are in `FORBIDDEN_NODES`. Autolinks parse to `link` and are checked. - No component attribute carries a URL, and splitting text across nodes doesn't affect `node.url`. - I checked the room path against Lexical by changing an existing `LinkNode`'s URL in a peer. `\u0001…`, `java\nscript:`, `java\tscript:`, `//evil.com` and `/\evil.com` are all refused, and `parse(project())` keeps them as links, so nothing slips through the serializer.
**Reusing a stored bad URL** is allowed: a writer can copy it onto new link text anywhere in the same document. That is acceptable, because the target already lives in this document and is visible to the same writers. It should be stated as intended in the doc comment, and a test should pin it down (move or duplicate). One small thing: `known` merges link and image URLs, so a stored image URL can be reused as a link. Keying the set by `type:url` costs nothing.
**5. Write paths covered.** - Covered: `room.apply`, `edit.apply` and `edit.replace` (Planner tools, decision prose, the heading tool, MCP update), `Service.initial` (MCP create via `hosted.ts`, research child publication), and MCP `canonical`. - Research sources are already restricted to `https:` and normalised by `publicUrl` in `jobs/research-workspace.ts`, so they cannot trip the new rules. - Restore, rebuild, compaction (`replace(project())`), the empty `room.create()` and the comments preview add no new content, so skipping them is correct. - Server mutations (questionnaires, decisions, expiry) carry no link nodes.
**6. Client and server rules match.** `checkUrl` and `acceptable` use the same `HIDDEN_URL_CHARACTERS` and `leavesRepository`, and both check hidden characters before the scheme. The client is stricter in that it rejects spaces. The server should match that (see #2).
**7. Performance is fine.** `room.apply` already parses `project()` twice per batch. The added cost is two linear walks and a `Set` over a source capped at `MAX_SOURCE_BYTES`, which is small next to the export and parse. Nit: `project(target)` is computed twice when `open > 0`, which is not new.
**8. Tests.** The room test (stored document restores, takes an edit, and refuses a new `//evil.com`) and the Planner test both test the behaviour that matters. Missing: - MCP update of a stored document (it would have caught #1) - move or duplicate of a stored bad URL (the intended behaviour) - a hidden character through `room.apply`, not only the dialect unit test - `Service.initial` refusing a bad URL - the leading-space case
Nit: issues use `path: "root"` instead of the structural path `validate` produces.
- MA
Fixes for "Review (validation change)" are in 4a6f3284.
1. **MCP update bug.** `canonical()` no longer runs the empty-before URL check. It now runs only in `prepare` (create_document). `update_document` goes through `edit.replace`, which judges against the stored document. New test in `update-lifecycle.test.ts`: an MCP update of a stored document containing `docs\\notes.md` goes through `prepareUpdate` and the hosted adapter, and returns `updated` with the link kept. 2. **Whitespace.** `disguisedUrl` (dialect, used by `assertIntroducedUrls`) refuses leading or trailing whitespace, and a scheme that only appears once whitespace is removed (`" javascript:alert(1)"`, `"java script:…"`). The client already trims ASCII whitespace and refuses spaces. Tested in the dialect tests. 3. **Pasted links.** A Lexical node transform (`toolbar/link-guard.ts`) unwraps any link that is new in a local update and fails `checkUrl`. A bare domain gets normalised instead. Updates tagged `collaboration` or `historic` are skipped, so stored links are never stripped on open. Covered by `link-guard.test.ts` (`//cdn…`, zero-width, `http:` and `javascript:` unwrapped; collaboration links left alone) and a paste e2e (HTML with `//cdn…` and a zero-width href gives one link, text kept, source written). 4. **Tests.** - Moving and duplicating a stored bad URL is allowed (dialect test; the PR body notes that the rule is judged by URL, not position). - A zero-width link through `room.apply` is refused (`room.test.ts`). - `Service.initial` refuses `//evil.com` and a zero-width link (`creation.test.ts`). - Nit: issues now carry the structural path, e.g. `root > paragraph[1] > link[0]`.
The PR body's security section is updated.
Local: `bun test` passes in apps/server (2332), dialect (129) and editor (465); `bun run types` passes. CI: e2e 335 passed plus 1 flaky (`responsive-selection.e2e.ts` keyboard navigation, which I don't think this change touches); the paste e2e passed. Container build passed. Validation fails only on the `bubble.tsx` review hash.
lavaman131referenced 587b62771288last month on GitHub
MaggieAppletonhead-ref-force-pushed 256df39285d4last month on GitHub
MaggieAppletonhead-ref-force-pushed 9fa20777ee5blast month on GitHub
- MA
CI is still failing in `Format, lint, types, tests` on the recorded head. The first meaningful failure is `bun scripts/check-design-contract.ts`, which flags stale reviewed dynamic-owner exceptions in `scripts/design-contract/exceptions/dynamic-editor.json` for `packages/editor/src/toolbar/bubble.tsx` and `packages/editor/src/widgets/research.tsx`.
I did not queue a guarded `[ci-fix]` commit because this runner does not have Bun installed, so I could not run the required reproduction and verification commands (`bun install --frozen-lockfile`, `bun run fix`, `bun run ci`). Static hash probes from this head point to `bubble.tsx` = `6cd7ba34cbb5a5e127d4f170c7b3db4af7af0559fe79dd898167c0c76dda994a` and `research.tsx` = `569956811559ebe0b6865d2522d030d853260fcd49a7154acc0b24f44ee7d7d2`.
Next action for a human: on a machine with Bun 1.4.2, run those commands on `9fa20777ee5b36eeb7c325d26fa9ba09fb87d285`, renew the exact reviewed hashes that `check-design-contract.ts` reports if the data flow is still safe, and rerun CI.
`babysit-head:9fa20777ee5b36eeb7c325d26fa9ba09fb87d285`
> Generated by [PR CI fixer](https://github.com/githubnext/chopin/actions/runs/37618989481) · gpt54 · 72.4 AIC · ⌖ 4.97 AIC · ⊞ 16.4K · [◷](https://github.com/search?q=repo%3Agithubnext%2Fchopin+%22gh-aw-workflow-call-id%3A+githubnext%2Fchopin%2Fpr-ci-fix%22&type=issues)
<!-- gh-aw-agentic-workflow: PR CI fixer, engine: codex, model: gpt-5.4, id: 37618989481, workflow_id: pr-ci-fix, run: https://github.com/githubnext/chopin/actions/runs/37618989481 --> <!-- gh-aw-workflow-call-id: githubnext/chopin/pr-ci-fix -->
MaggieAppletonhead-ref-force-pushed 0bc0278c8b70last month on GitHub
MaggieAppletonhead-ref-force-pushed b2a9e72fa897last month on GitHub
MaggieAppletonhead-ref-force-pushed 6613c6cf12b6last month on GitHub
- MA
CI babysitter rechecked the current failed `ci` run for `6613c6cf12b6c139aa8cb37b47dbd4d144162383`. The head still matches the recorded candidate, and the failure still points at stale reviewed design-contract hashes rather than a product-behavior regression: the checked-in exception for `packages/editor/src/toolbar/bubble.tsx` is still `b55b2087...`, while the file on this head hashes to `6cd7ba34cbb5a5e127d4f170c7b3db4af7af0559fe79dd898167c0c76dda994a`; a static probe also shows `packages/editor/src/widgets/research.tsx` hashing to `569956811559ebe0b6865d2522d030d853260fcd49a7154acc0b24f44ee7d7d2` on this head.
I did not queue a guarded `[ci-fix]` commit from this runner because `bun` and `mise` are not installed here, so I could not run the required local reproduction and verification commands (`bun install --frozen-lockfile`, `bun run fix`, `bun run ci`). Next action for a human: on a machine with Bun 1.4.2 available, rerun those commands on this head, inspect the exact `check-design-contract.ts` report, renew only the reviewed exception hashes that are still safe, and rerun CI.
`<!-- pr-ci-fix:6613c6cf12b6c139aa8cb37b47dbd4d144162383 -->`
> Generated by [PR CI fixer](https://github.com/githubnext/chopin/actions/runs/37627276943) · gpt54 · 52.4 AIC · ⌖ 4.51 AIC · ⊞ 16.4K · [◷](https://github.com/search?q=repo%3Agithubnext%2Fchopin+%22gh-aw-workflow-call-id%3A+githubnext%2Fchopin%2Fpr-ci-fix%22&type=issues)
<!-- gh-aw-agentic-workflow: PR CI fixer, engine: codex, model: gpt-5.4, id: 37627276943, workflow_id: pr-ci-fix, run: https://github.com/githubnext/chopin/actions/runs/37627276943 --> <!-- gh-aw-workflow-call-id: githubnext/chopin/pr-ci-fix -->
MaggieAppletonhead-ref-force-pushed 6f254bc2e621last month on GitHub
MaggieAppletonhead-ref-force-pushed 909890b3a28elast month on GitHub
MaggieAppletonmerged d232d5351c3flast month on GitHub
MaggieAppletonhead-ref-deletedlast month on GitHub
MaggieAppletonreferenced this in Restyle the selection toolbar and place it above the selection #349last month on GitHub
MaggieAppletonreferenced this in Add global keyboard shortcuts with hints and a shortcuts sheet #351last month on GitHub