Skip to content

test(desktop): move the composer slash, paste and Skill-draft journeys off Electron - #4786

Merged
Astro-Han merged 12 commits into
apache:mainfrom
Astro-Han:test/desktop-slash-command-menu-unit
Sep 4, 2026
Merged

test(desktop): move the composer slash, paste and Skill-draft journeys off Electron#4786
Astro-Han merged 12 commits into
apache:mainfrom
Astro-Han:test/desktop-slash-command-menu-unit

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 2 of #4761: move the composer's slash-command, paste and Skill-draft journeys out of Electron. Eleven E2E tests are deleted and replaced in this same commit range — nine as Storybook play stories in real Chromium, three as pure unit assertions — plus one supplementary component test the plan calls for, and one new Electron spec holding the two claims that turned out to have no home outside it.

The slash menu goes first because #2948 and #3289 were the only two flake reports before #4523 and both were slash-command-menu.spec.ts. Nothing it asserted needed a layout engine; it was a state machine on a deterministic fixture backend, paying Electron's price for it.

Each assertion landed at the tier that owns what it guards, not at the tier that is cheapest:

Was Now Why there
which commands a state offers already covered by desktop-slash-command.test.ts; only the rendering moved the rule is pure; the two groups and the /id token are not
whether a / addresses a command chat-input-behavior.test.ts (slashCommandQuery, skillMentionQuery) pure string rule, milliseconds instead of a browser
path separators (#3849), block-break line boundaries composer-slash-menu.stories.tsx patched Astryx useTriggerMenu; needs a live Selection across text nodes
same-content projection refresh (#2667) same file needs a portal whose identity survives a re-render, with the real ComposerMentionsProvider so invocableSkillListsEqual actually runs
paste undo granularity, menu suppression, draft sync composer-paste.stories.tsx needs the browser's own undo stack
staged Skills across a draft scope switch composer-skill-draft.stories.tsx needs redrawSkillTokens against a real contentEditable

Three things worth a reviewer's attention:

  • composer-directory-reference.spec.ts stays in Electron. Its claims are reload persistence, the real session store's cwd, and a persisted transcript that must not contain README.md / deep.txt / "status":"listed" — all squarely in tracking(desktop): move every test out of Electron E2E that does not need it #4761's "What stays in Electron". Its movable half (chip add/remove/duplicate/foreign-host) is already covered by composer-directories.test.ts. Moving it would delete coverage and add none.
  • No production extraction. I first lifted the slash-command assembly out of app-shell.tsx into desktop-slash-command.ts. It shrank app-shell (nonTriviaTokens 15601→15402) but added two new dependencies to the smaller module, which the per-file monotonic ratchet rejects — exactly what test(desktop): sink the /compact menu rule to a tested predicate #4762 documented when it left the catalog query in app-shell. Reverted; the stories assemble the list from the same three authorities, with a comment saying why.
  • Two production changes, both in packages/ui. prompt-anchor-rail.tsx gets READING_BAND_TOP_PERCENT, collapsing the two spellings of the reading band (a rootMargin string and a geometry fraction) onto one constant, so the new test asserts a contract about the band rather than copying the literal beside it. And transcript-scroll-authority.tsx stops pulling a reader back to the tail when they scroll during growth — a behaviour change, described under its own heading below.

Review found two claims the move had dropped, and both held up: the stories are handed hasSession, so Boolean(activeId) in app-shell is theirs to assume rather than check; and selecting /compact only writes an invocation, while what that draft does on submit — sessions.compact(), a status change, a cleared composer, and a command that must never reach the model — was covered by the deleted spec and by nothing after it. Setting hasSession: false, or making the compact branch unreachable, each leaves every slash story green and the whole 2157-test Desktop suite green.

Neither has a home outside Electron today. The routing is inline in app-shell.tsx, src/renderer has no test directory at all, and opening a seam there is what the architecture ratchet exists to refuse. So slash-command-compact.spec.ts keeps both, in one test named for them, red under both mutations. That is why this is eleven moved rather than twelve.

The supplementary component test is StreamingDeltasKeepThePromptRailObserver, written as a story in #4766 and removed. It counts IntersectionObserver constructions, so its probe must be installed before the rail's observer — a story can only observe after it has mounted, which would leave the rootMargin assertion permanently green. It is a node:test component test that owns the global instead.

fixture-thread-search moves off the warm fixture onto an ordinary withE2eWindow. Search reads the Host through the bridge and renders nothing, so it needed neither the warm window's compositor nor its between-test reset.

An earlier revision of this description credited that with retiring #4707. That was wrong, and review caught it: #4707 is pinned tail can load earlier history after its settle check, a different spec, already deleted by #4766. What is true is stronger — promptRailWorker now has exactly one consumer, native-transcript-perf.spec.ts, whose tests skip unless MAKA_TRANSCRIPT_PERF=1, so after this PR CI does not exercise the reused worker window at all. The mechanism still stays, for PR 6.

FIDELITY.md said the smoke mounts stories with autoplay disabled, so a play function was "a local review driver, not a CI test". That is false: the smoke waits on Storybook's storyFinished event, nothing passes embed, and #4766 landed 18 stories whose assertions are the coverage. Corrected, keeping the two limits that are real.

Refs #4761

Verification

Every moved assertion was mutation-tested — the defect it claims to guard was created in the production source and the replacement confirmed red, then reverted:

Defect introduced Turned red
drop the session gate in desktopSlashCommandAvailability before-any-session-exists
remove the /{command.id} token span in-an-active-session, +2
command onSelect writes /id without the trailing space picking-a-command-writes-its-invocation
getCharacterBeforeNode prefix → '' (pre-#3849 behaviour) path-separators-are-not-triggers
createsLineBoundary → always false a-block-break-is-a-line-boundary
invocableSkillListsEqualfalse survives-a-same-content-projection-refresh
paste falls back to the controlled write instead of execCommand paste-is-its-own-undo-step
remove the plainTextPasteInputActiveRef stopPropagation paste-synchronizes-the-draft-once, +2
remove the menuWasOpen Escape dispatch paste-closes-an-open-trigger-menu
redrawSkillTokensreturn false staged-skills-survive-a-draft-scope-switch
rail reads nextOrderedTurnIds instead of the stable ref prompt-rail-observer-identity
each of the three slashCommandQuery guards, removed one at a time chat-input-behavior

Two mutations passed at first and the tests were fixed, not the mutations. Breaking ChatView's turn-list memo left the observer test green, because PromptAnchorRail stabilizes orderedTurnIdsRef independently — two mechanisms in series, and the test now says so and asserts the composed outcome. And removing the triggerStart < nodeStart comparison did not break the path-separator story, because the prefix from getCharacterBeforeNode is what actually suppresses the trigger; the mutation was retargeted there.

Checks run locally from the worktree:

npm run format                                  clean
npm run lint                                    clean
npm --workspace @maka/ui run test:dist          371 pass / 0 fail
npm --workspace @maka/desktop run test:dist     2157 pass / 0 fail
npm --workspace @maka/desktop run typecheck     clean
npm run check:architecture                      Renderer architecture check passed
npm --workspace @maka/desktop run build-storybook && smoke:storybook
                                                289 stories, 315 theme renders, passed
npx playwright test --list                      78 tests in 35 files (was 89 in 37)
playwright e2e/slash-command-compact.spec.ts    1 passed, and red under both named mutations

The Electron E2E suite itself was not run locally. CI ran it: the first run failed on two specs this PR does not move, diagnosed below; the rerun was green.

The reader pulled back to the tail

Two Storybook stories from #4766ReaderScrolledUpIsNotPulledBack and DockAffordanceReturnsToTail — failed on CI here and once on this branch's own base commit on main, and pass locally every time. They were right, and what they caught is a product defect, so it is fixed here rather than recorded.

TranscriptScrollAuthority decided that a scroll event was content rather than the reader by asking whether the geometry had changed since the last event. During growth it always has. So a reader's own scroll arrived carrying a changed scrollHeight and was discarded along with it: the pin stayed set, and the next ResizeObserver delivery wrote the view straight back to the tail. In the product that is scroll up while an answer is streaming and get pulled back to the bottom — reachable on the ordinary path, needing nothing but a slow enough frame.

Geometry cannot decide it, because during growth both readings are true at once. What content can do to the offset is bounded, though: anchoring pushes it down by at most what was inserted above the reader, and a transcript that shrank clamps it up to the new end. A move outside that band has no explanation but the reader, and that is the rule now.

The case the old rule was written for is unchanged and still covered by its own test: growth that outran this authority's write moves the offset by nothing while the tail runs away, which is inside the band and so is not a reader.

Reproduced deterministically before the fix, by driving the story's own sequence in a real Chromium and changing the content's height in the same task as the reader's scroll write — the interleaving CI's slowness produces by itself:

frame 1 frame 2
before distance 537 distance 0 ← pulled back
after distance 537 distance 537

CPU throttling alone (8× and 20×, five runs each) never reproduced it, and neither did injecting continuous growth: the window is an interleaving, not a duration. a reader who scrolls up while the answer grows is still the reader in transcript-scroll-authority.test.ts pins it deterministically, and reverting the rule turns it red.

What the band tolerates, and where it is spent

The band comparison mixes number systems: CSSOM gives scrollTop as a double and scrollHeight / clientHeight as rounded integers, and native anchoring rounds the anchor's own positions separately again. So a step that is entirely the content still lands a pixel or two outside its own band. The first version of this rule guessed at that with a 2px tolerance plus a running total of the unexplained movement, and neither was falsifiable where it lived — packages/ui runs on a fake DOM of integers, so an ablation there passes whatever the numbers are.

Product/Transcript Scroll Rounding measures it in a real layout engine on fractional heights, through both paths that move the offset without the reader:

path misses its own band by
native anchoring compensating growth above the reader up to 2px
the browser clamping when the transcript ends before the offset never

The running total is retired: the error is bounded per event but one-directional across a stream, so accumulating it drifts across any threshold and hands a streaming reader back to the tail — the same defect arriving the other way round.

What replaces it is not a different number but a smaller scope. The slack is the rounding of the content reading, so it is spent only on events where the content moved. An event that finds the content unchanged has no rounded reading in it — the band is a point, the offset either moved or it did not — and a reader inching down a settled transcript is heard exactly. That is what a slack applied unconditionally would swallow, and it is why no accumulator is needed to buy it back.

CI wrote the last part of this. An intermediate revision cut the tolerance to 1px on the strength of the probe scroller alone; TailFollowsGrowthOutsideTurns failed, because a real transcript's steps miss by more than a synthetic one's. The story now holds both halves: shrink the constant and content is reported as gestures, spend it unconditionally and the settled-content reader goes unheard. Both mutations turn it red.

It ships in this PR rather than its own because it is what the stories this series depends on were failing on: a tier migration whose replacement stories are red is not a migration.

Two E2E flakes this run surfaced

Both are pre-existing and neither is reachable from this diff. The other production change here is READING_BAND_TOP_PERCENT, and it is bit-identical: 34 / 100 === 0.34 is true (both are 0.34000000000000002442), and the template string equals '0px 0px -66% 0px' exactly. Neither failing spec uses a fixture this PR touches.

new-task-reload.spec.ts:28 — fixed here. expect(getByText(/Fake backend received: create history/)) timed out at 10s. The failure snapshot carries the full #4573 signature: the draft create history still sitting in the composer, no Turn, and 发送 already enabled by the time the assertion gave up. A send that lands clears the draft, so the Enter was dropped by sendCurrent while admission was unresolved — it was not rendered slowly, and a longer timeout would only have made the red slower.

#4577 fenced the three specs that had failed by then and said the rest could adopt awaitSendReady when they failed. This one did, so it gets the fence and a 20s assertion. The roughly 40 other bare-Enter sends are deliberately left alone: a fence with no failure behind it is the same guess as a timeout with no failure behind it.

partial-history-notice.spec.ts:136 — recorded, not fixed. After clicking 返回最新消息, expect(notice).toHaveCount(0) passed and then [data-turn-id="turn-partial-history-8"] never appeared. The failure snapshot shows why, and it is not slowness: the notice is back on screen ("正在查看较早的消息" with its button), the transcript holds only turn-partial-history-1, and the rail still shows all eight ticks because it reads the prompt index rather than the resident range.

So the range did return to the tail — that is what unmounted the notice — and was then pushed back to the head, evicting turn 8. Widening the timeout cannot fix a settled wrong state.

The likely mechanism is the rail claim #4577 introduced: the test clicked turn 1's tick earlier in the run, and that hold re-aims at turn 1, which the shell answers by loading around turn 1 again. A viewport change from 1400×800 to 520×720 sits between the two. That is a hypothesis, not a diagnosis — separating it from the load-around path needs instrumentation this PR has no business adding, and the artifact from run 33881601168 has the trace for whoever picks it up.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code (Opus). Wrote the replacement stories and tests, ran the mutation matrix and the verification above, and drafted these commit messages. Tier placement per assertion, the decision to keep composer-directory-reference.spec.ts in Electron, and the revert of the production extraction were reviewed against #4761 before landing. Affected commits carry Generated-by: Claude Code; please retain the trailer on the squash commit.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

The render smoke waits on Storybook's `storyFinished` channel event, so
every `play` function executes and a failed assertion inside one fails the
lane. FIDELITY said the opposite — that the smoke mounts with autoplay
disabled — which is how apache#4766's assertion-bearing stories came to be read
as review-only scaffolding.

Say what the lane actually does, and keep the two limits that are real:
geometry and theme matrices still belong elsewhere because CI mounts once
at 1280 in light, and a probe that must be installed before mount belongs
in a test that owns the global, not in a story that can only observe after
it renders.

Generated-by: Claude Code
The transcript-wide IntersectionObserver must be built once, not once per
delta. Two mechanisms hold that in series — ChatView memoizes the rail's
turn list on persisted text, and PromptAnchorRail stabilizes
`orderedTurnIdsRef` on the turn ids — and neither is observable from a
story: the probe has to be installed before the rail's own observer, and a
story can only observe after it has mounted. So this is a component test
that owns the global, counting constructions across ten re-renders that
grow only the streaming tail.

Collapse the two spellings of the reading band onto
`READING_BAND_TOP_PERCENT`, so asserting the `rootMargin` string is a
contract about the band rather than a copy of the literal next to it.

Generated-by: Claude Code
apache#2948 and apache#3289 were the only two flake reports before apache#4523, and both
were this file. Nothing it asserts needs a layout engine.

Each of the six cases lands at the tier that owns what it guards:

- Which commands a state offers is pure, and
  `desktop-slash-command.test.ts` already covers it; only the rendering —
  two groups, 命令 then Skills, each row carrying its `/id` token — needs
  a browser.
- Whether a `/` addresses a command is `slashCommandQuery`, pure, so it
  becomes a unit test alongside `skillMentionQuery`.
- Path separators (apache#3849) and block-break line boundaries live in the
  patched Astryx `useTriggerMenu` and need a live Selection across text
  nodes; the same-content projection refresh (apache#2667) needs a portal whose
  identity survives a re-render. Those become stories, mounting the real
  Composer inside the real ComposerMentionsProvider over a scoped bridge
  so `invocableSkillListsEqual` is genuinely exercised.

`PathSeparatorsAreNotTriggers` opens the menu first, from the same editor
with the same synthetic input, so a menu that stopped opening at all
cannot pass it.

The command list is assembled in the story from the same three
authorities app-shell reads, rather than extracted into
`desktop-slash-command.ts`: the architecture ratchet counts dependencies
per file, so moving the catalog import into the smaller module books new
debt there instead of retiring any — the reason apache#4762 left the catalog
query where it was.

Generated-by: Claude Code
Six more cases whose subject is the browser, not the app: an undo
transaction, a paste that must not open a trigger menu, and a Skill token
redrawn after a draft scope switch. All of them run in the smoke's real
Chromium.

Undo is driven with `document.execCommand('insertText' | 'undo')` rather
than synthetic keys, because `userEvent` events are not trusted input and
the browser's undo stack ignores them — the same reason apache#3787 made the
paste path use `insertHTML` in the first place.

The deleted spec counted `sessionStorage` writes to prove a paste
synchronizes the draft once. The story spies on `draftPersistence.write`
instead: the seam `chat-composer-region.tsx` actually calls, one level
above the storage the counter was reading downstream of.

Generated-by: Claude Code
It was the last CI test sharing the warm prompt-rail window, and sharing
bought it nothing: search reads the Host through the bridge and renders
nothing, so it needs neither that window's compositor nor its between-test
reset. Moving it onto an ordinary per-test window retires the cross-test
readiness bleed behind apache#4707.

The warm fixture itself stays — `native-transcript-perf` still needs a
window whose compositor has settled.

Generated-by: Claude Code
@github-actions github-actions Bot added the effort/XL Under 2500 readable lines label Sep 4, 2026
@Astro-Han
Astro-Han marked this pull request as ready for review September 4, 2026 14:06
It failed on CI with `element(s) not found` after Enter, and the failure
snapshot carries the whole signature apache#4573 described: the draft still
sitting in the composer, no Turn, and 发送 already enabled by the time the
assertion gave up. A successful send clears the draft, so the keypress was
dropped by `sendCurrent` while admission was still unresolved — not
rendered slowly.

apache#4577 fenced the three specs that had failed by then and left the rest to
adopt `awaitSendReady` when they failed. This one just did.

Generated-by: Claude Code
The scroll authority decided "this was content, not the reader" from the
geometry having changed since the last event. During growth it always has,
so a reader's own scroll arrived carrying a changed `scrollHeight` and was
discarded along with it: the pin stayed set, and the next ResizeObserver
delivery wrote the view straight back to the tail. Scroll up while an
answer streams and the transcript pulls you back — the one moment a reader
most needs to be believed.

Geometry cannot decide it, because during growth both readings are true.
What content can do to the offset is bounded: anchoring pushes it down by
at most what was inserted above the reader, and a transcript that shrank
clamps it up to the new end. A move outside that band has no explanation
but the reader, so that is what decides now.

The case the old rule existed for is unchanged and still covered: growth
that outran this authority's own write moves the offset by nothing while
the tail runs away, which is inside the band and so is not a reader.

This is what made `ReaderScrolledUpIsNotPulledBack` and
`DockAffordanceReturnsToTail` fail on CI and never locally — CI is slow
enough for a resolution to land between the write and its event. Both are
about the reader being left alone, so both were telling the truth.

Generated-by: Claude Code
Review of the previous commit found the band right and the reading of it
wrong, in two ways that cancelled part of the fix and cost some of what
already worked.

Content can pull the offset up as well as push it down: anchoring answers
a removal above the reader — a tool block folding, a status line going —
by moving the offset up by exactly what went. The lower bound covered only
end-clamping, so a fold above a pinned reader read as a reader and quietly
stopped the transcript following its own answer.

And the slack was `PIN_THRESHOLD_PX`, which is a distance from the tail
and says nothing about how far an offset may miss its explanation. Ten
pixels of it, spent afresh on every event, swallowed every move smaller
than that: a reader crossing the transcript on a trackpad was never heard
at all, growth or no growth. What is left over is now kept between events
and measured against the sub-pixel rounding it actually is, so a run of
small moves adds up to the reader it plainly is.

Three cases cover the corrections: a slow reader, a fold above the reader,
and a scrollport that grew. Re-baselining every event turns the first red;
dropping the removal term turns the second red.

Generated-by: Claude Code

@jackwener jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at exact head 23b6b1a5779d4e2e81851ba6317a83e8a3789268. No P0 or P1. Three P2s and one P3, none blocking. Four reviews ran on this, each sealing before reading the others; a fifth is still replaying the named regressions against their historical implementations and I will post that separately.

The mutation table is real, and it is second-tier evidence

Every moved assertion having been shown to redden against an injected defect is more than most moves of this kind arrive with, and five independent seam mutations reproduce it — getCharacterBeforeNode → '', createsLineBoundary → false, invocableSkillListsEqual → false, a failing paste execCommand, and redrawSkillTokens → false each turn their story red.

That proves the assertions run. It does not prove they inherit what the deleted tests intercepted, and two cases below show the difference concretely.

[P2] Command availability never crosses the production AppShell seam

composer-slash-menu.stories.tsx:64-68 assembles the options itself and passes hasSession directly, so the real activeId → hasSession binding at app-shell.tsx:1326-1361 is never exercised.

Changing production hasSession: Boolean(activeId) to false leaves BeforeAnySessionExists, InAnActiveSession and PickingACommandWritesItsInvocation all green, along with 13/13 focused tests. The deleted E2E fails immediately on that mutation — four rows expected, two present. The ordinary consequence is an active Session silently losing /compact and /side, with no test able to see it.

Keep the pure predicate test, and add one story or test that reads the menu rows through a real AppShell active-Session state.

[P2] /compact coverage stops before execution

The deleted slash-command-menu.spec.ts:52-110 covered the click, the Host's sessions.compact(), the completion event, the composer clearing, the absence of a failure, a normal send afterwards, and /compact never reaching the model. PickingACommandWritesItsInvocation asserts that selecting it writes /compact and closes the menu.

Making the compact branch at app-shell.tsx:1958 unreachable leaves that story green — and the entire Desktop test:dist suite green at 2157/2157. With that mutation live, /compact falls through to an ordinary model send. The deleted E2E failed on it.

An AppShell/Host integration test that submits the invocation and asserts the compaction happened, the command did not reach the model, and the next send still works would close it.

[P2] The #4707 attribution

Moving fixture-thread-search onto an ordinary window does not retire #4707. That issue is following the tail does not ask for the history above it (transcript-scroll.spec.ts:712) — a pinned tail admitting an earlier-range load, in a spec already deleted by #4766, and the issue text separates it from the prompt-rail snapshot bleed.

What is true and worth claiming instead: promptRailWindow / promptRailWorker now have one consumer, native-transcript-perf.spec.ts, whose tests skip unless MAKA_TRANSCRIPT_PERF=1so after this PR CI no longer exercises the reused worker window at all.

[P3] The summary's production-change claim is now out of date

It says prompt-anchor-rail.tsx is the one production change. On this head there is a second, added after that sentence was written: transcript-scroll-authority.tsx, and it is a behaviour fix rather than a test move.

It is a good fix, and it deserves more visibility than a stale summary gives it. The old rule treated any geometry change as content moving rather than the reader — and during streaming the geometry always changes, so a reader who scrolled while an answer grew was discarded along with it, the pin held, and the next growth wrote the view back to the tail. The comment puts it exactly right: scrolling away from a streaming answer is the one moment a reader most needs to be believed.

The replacement bounds what content can account for — anchoring pushes the offset down by at most the growth in maxScroll, a shrunk transcript clamps it up by at most the loss — and treats anything outside that band as the reader's. The edges hold: tail growth gives topDelta = 0 inside the band; a reader scrolling up 500px against 200px of insertion gives −300, outside it. a reader who scrolls up while the answer grows is still the reader covers it.

Worth updating the summary, and worth considering whether a user-visible scroll fix belongs in a test-move PR at all.

Verified

The other ten mappings hold, including the pure slash and skill query rules, path separators, DOM block breaks, same-content projection identity, paste undo granularity, menu suppression, single draft write, and Skill draft scope restoration. 12 of 13 is accurate — six from slash-command-menu.spec.ts, five from composer-undo.spec.ts, one from composer-skill-invocation.spec.ts, with composer-directory-reference.spec.ts staying; the Playwright list moving 89 → 77 matches.

The reason given for keeping that spec also holds: add, duplicate, remove and foreign-host are all in composer-directories.test.ts as hook state, chip DOM stays covered by the E2E that remains, and reload, cwd and the transcript's must-not-contain assertions have nowhere else to go.

The one production change I reviewed in full — READING_BAND_TOP_PERCENT — is behaviour-preserving to the bit. 34/100 === 0.34 exactly, and 100 - 34 is exactly 66, so both the geometry seed and the rootMargin string are unchanged. The comment's reason for whole percent is also correct rather than stylistic: storing 0.34 would render the margin as -65.99999999999999%.

test is green on this head and the merge tree against current main is clean. This is a test move, so the merge decision is a human's.

简体中文

23b6b1a5779d4e2e81851ba6317a83e8a3789268 上批准。没有 P0/P1。 三条 P2、一条 P3,均不阻塞。四次评审在这个 head 上进行,各自封存后才互看;第五席仍在把那些具名回归回放到它们的历史实现上,结果我会另行发布。

变异表格是真的,但它是第二层证据

「每一条被搬移的断言都被证明会对着注入的缺陷变红」——这比同类搬移通常带来的东西多得多,而且有五处独立的接缝变异复现了它:getCharacterBeforeNode → ''createsLineBoundary → falseinvocableSkillListsEqual → false、失败的 paste execCommandredrawSkillTokens → false,各自让对应 story 变红。

这证明了断言在跑。它不证明它们继承了被删测试所拦截的东西,下面两条把这个区别摆成了具体的。

[P2] 命令可用性从未穿过生产 AppShell 接缝

composer-slash-menu.stories.tsx:64-68 自己组装选项并直接传入 hasSession,所以 app-shell.tsx:1326-1361 那个真实的 activeId → hasSession 绑定从未被演练。

把生产的 hasSession: Boolean(activeId) 改成 false,BeforeAnySessionExistsInAnActiveSessionPickingACommandWritesItsInvocation 全部保持绿,13/13 focused 测试也全绿。而被删的 E2E 会立刻在这个变异上失败——应有四行,实际只有两行。普通后果是:一个 active Session 静默失去 /compact/side,而没有任何测试看得见。

保留纯 predicate 单测,再加一条通过真实 AppShell active-Session 状态读取菜单行的 story 或测试。

[P2] /compact 的覆盖停在执行之前

被删的 slash-command-menu.spec.ts:52-110 覆盖了点击、Host 的 sessions.compact()、完成事件、composer 清空、没有失败、之后的一次正常发送,以及 /compact 从不抵达模型。而 PickingACommandWritesItsInvocation 断言的是「选中它会写入 /compact 并关闭菜单」。

app-shell.tsx:1958 的 compact 分支变为不可达,这个 story 仍然绿——而且整个 Desktop test:dist 套件也绿,2157/2157。 在那个变异下,/compact 会落入一次普通的模型发送。被删的 E2E 会失败。

补一条 AppShell/Host 集成测试:提交该 invocation 后断言 compaction 真的发生、命令没有抵达模型、下一次正常发送仍然可用。

[P2] 关于 #4707 的归因

fixture-thread-search 挪到普通窗口并没有让 #4707 退休。那个 issue 是 following the tail does not ask for the history above it(transcript-scroll.spec.ts:712)——钉住尾部却接纳了一次更早范围的加载,而那个 spec 已经在 #4766 里被删掉了,并且 issue 正文把它与 prompt-rail 快照串扰分开了。

真正为真、也更值得写的是:promptRailWindow / promptRailWorker 现在只剩一个消费者 native-transcript-perf.spec.ts,而它的测试除非 MAKA_TRANSCRIPT_PERF=1 否则跳过——所以这个 PR 之后,CI 根本不再演练那个被复用的 worker 窗口。

[P3] 摘要里关于生产改动的那句话已经过期

它说 prompt-anchor-rail.tsx 是唯一的生产改动。在这个 head 上还有第二处,是在那句话写下之后加进来的:transcript-scroll-authority.tsx,而且它是一次行为修复,不是测试搬移。

这是个好修复,而且它值得比一句过期摘要更多的能见度。 旧规则把任何几何变化都当作「内容在动」而非「读者在动」——而在流式输出期间几何始终在变,于是一个在回答生长时滚动了的读者,就连同那次变化一起被丢弃了:pin 保持不变,下一次生长把视图写回尾部。注释说得非常准确:从一个正在流式输出的回答旁滚开,恰恰是读者最需要被相信的那一刻。

新的写法给「内容能解释的位移」划了一条带——anchoring 最多把偏移下推 maxScroll 的增量,收缩的 transcript 最多把它上钳所减少的量——带外的位移就是读者的。边界是成立的:尾部生长给出 topDelta = 0,落在带内;读者在 200px 插入之下向上滚 500px 给出 −300,落在带外。新测试 a reader who scrolls up while the answer grows is still the reader 覆盖了它。

建议更新摘要,也值得想一想:一个用户可见的滚动修复,是否该出现在一个测试搬移 PR 里。

已验证

其余十条映射成立,包括纯 slash/skill 查询规则、路径分隔符、DOM 块边界、同内容 projection identity、paste 撤销粒度、菜单抑制、单次 draft 写入,以及 Skill draft 作用域恢复。12 of 13 是准确的——slash-command-menu.spec.ts 六条、composer-undo.spec.ts 五条、composer-skill-invocation.spec.ts 一条,composer-directory-reference.spec.ts 留下;Playwright 列表 89 → 77 与之吻合。

保留那个 spec 所给的理由也成立:add、duplicate、remove 与 foreign-host 都以 hook 状态存在于 composer-directories.test.ts,chip DOM 仍由留下的 E2E 覆盖,而 reload、cwd 以及 transcript 的「必须不包含」断言无处可去。

我完整评审的那一处生产改动——READING_BAND_TOP_PERCENT——逐位保持行为不变:34/100 精确等于 0.34,100 - 34 精确等于 66,所以几何种子与 rootMargin 字符串都没有变化。注释给出的「用整数百分比」的理由也是实证而非风格:存成 0.34 会让 margin 渲染成 -65.99999999999999%

这个 head 上 test 为绿,与当前 main 的合并树干净。这是一次测试搬移,合并与否由人决定。


Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.

@jackwener

Copy link
Copy Markdown
Member

Scope note on the approval above: it landed on 2338ebd1ce4654133eda85c73d142a3033088dec, while the review it describes was bound to 23b6b1a5. The head moved while I was writing. I have checked the increment, and the approval stands — but two of the changes are worth reading, because the second fixes an edge I had reasoned about and got wrong.

The increment touches only transcript-scroll-authority.tsx and its tests. Everything else in my comment — the twelve mappings, 12 of 13, the #4707 attribution, READING_BAND_TOP_PERCENT — is byte-identical on this head.

The band itself is unchanged. What changed is how the band's edge is measured, in two ways:

A dedicated rounding tolerance. GEOMETRY_ROUNDING_PX = 2 replaces the use of PIN_THRESHOLD_PX for the comparison. That is a correction of meaning, not a number tweak: the pin threshold is a distance from the tail and says nothing about arithmetic error, while the real slack here is that scrollHeight and clientHeight are integers and scrollTop is not. Borrowing an unrelated constant would have made a 10px reader gesture indistinguishable from sub-pixel rounding.

The unexplained residue now accumulates across events, rather than each event being judged alone:

unexplained += topDelta - Math.min(explainedHigh, Math.max(explainedLow, topDelta));
const readerMoved = Math.abs(unexplained) > GEOMETRY_ROUNDING_PX;

This closes a hole I did not find. When I checked the previous version I reasoned through tail growth and through a reader scrolling 500px against 200px of insertion, and both classified correctly — but I never considered a reader moving in steps each smaller than the tolerance. Under per-event judgement every one of those steps is discardable as content, and the reader is never believed no matter how far they travel. a slow reader is a reader, however small each step is is exactly that case, and it is the strongest of the three new tests. The other two cover content leaving from above the reader and a viewport that grew.

So the fix is better than the version I reviewed, and the reason I can say that is that the author found the case my edge-checking missed.

简体中文

关于上面那次批准的范围说明:它落在了 2338ebd1ce4654133eda85c73d142a3033088dec 上,而它所描述的评审绑定的是 23b6b1a5 我写评论期间 head 移动了。增量我已经核过,批准依然成立——但其中两处改动值得一读,因为第二处修掉的正是一个我推理过、而且推错了的边界。

增量只触及 transcript-scroll-authority.tsx 及其测试。我评论里的其余内容——十二条映射、12 of 13#4707 归因、READING_BAND_TOP_PERCENT——在这个 head 上逐字节相同。

带本身没有变。 变的是如何度量带的边缘,有两处:

一个专用的舍入容差。 GEOMETRY_ROUNDING_PX = 2 取代了原先在比较中使用的 PIN_THRESHOLD_PX这是语义上的纠正,不是调数字:pin 阈值是「距尾部的距离」,它对算术误差没有任何说法;而这里真正的松弛来自 scrollHeightclientHeight 是整数、scrollTop 不是。 借用一个不相干的常量,会让一次 10px 的读者手势与亚像素舍入无法区分。

未被解释的残差现在跨事件累积,而不再是每个事件单独判断:

unexplained += topDelta - Math.min(explainedHigh, Math.max(explainedLow, topDelta));
const readerMoved = Math.abs(unexplained) > GEOMETRY_ROUNDING_PX;

这补上了一个我没有找到的洞。 我检查上一个版本时,推了尾部生长、也推了「读者在 200px 插入之下向上滚 500px」,两者分类都正确——但我从未考虑过一个每一步都小于容差的读者。 在逐事件判断下,那样的每一步都可以被当作内容而丢弃,于是无论他滚了多远,都不会被相信。 a slow reader is a reader, however small each step is 正是这个用例,也是三条新测试里最有分量的一条。 另外两条覆盖内容从读者上方离开,以及视口变大的情形。

所以这个修复比我评审过的那一版更好;而我之所以能这么说,是因为作者找到了我边界检查漏掉的那个用例。


Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.

Ablation: removing it fails nothing, and it cannot — the offset is never
past the end it is read from, so `maxScroll - lastScrollTop` is always the
looser of the two and the minimum was never taking it. What content did to
the offset is one number, and the band is between nothing and that.

Generated-by: Claude Code
Review of the move found the leaf assertions carried over and the wiring
under them left behind, in two places, and it was right on both.

The stories are handed `hasSession`, so `Boolean(activeId)` in app-shell is
theirs to assume. Setting it to `false` in production leaves every slash
story green while an active Session silently loses `/compact` and `/side`.

And selecting `/compact` only writes an invocation into the draft. What
that draft does when submitted — `sessions.compact()`, a status change, a
composer that clears, and above all a command that never reaches the model
— was covered by the deleted spec and by nothing after it. Making the
branch unreachable leaves the story green and the whole 2157-test Desktop
suite green; the deleted E2E failed on it.

Neither has a home outside Electron today: the routing is inline in
app-shell, `src/renderer` has no test directory, and opening a seam there
is what the architecture ratchet exists to refuse. So they stay, in one
test named for the two claims it holds. 11 of 13 moved, not 12.

Both mutations confirmed red against it.

Generated-by: Claude Code
@Astro-Han

Copy link
Copy Markdown
Contributor Author

Three P2s all held up, and two of them were real coverage losses rather than description problems. Fixed at 94e86beac6.

[P2] The AppShell seam and [P2] /compact execution. Both correct, and they are the same defect: the leaf assertions moved and the wiring under them did not. I reproduced both mutations — hasSession: false and an unreachable compact branch each leave every slash story green and the whole 2157-test Desktop suite green.

Neither has a home outside Electron today. The /compact routing is inline at app-shell.tsx:1958, src/renderer has no test directory at all, and opening a seam there is exactly what the architecture ratchet refuses — the same wall that sent the slash-command extraction back earlier in this PR. So rather than adding a renderer test layer inside a test-move PR, both claims go back into Electron as one spec, slash-command-compact.spec.ts: it seeds a Session through the real shell, asserts the command group has four rows (that is the activeId → hasSession binding), then picks /compact, submits, and asserts the compaction happened, the composer cleared, no failure banner, a normal send still works, and /compact never reached the model.

Verified: passes in 9.0s, and red under both of your mutations — hasSession: false fails on toHaveCount(4), the dead compact branch fails on Fake backend received: /compact having count 1. The move is eleven of thirteen now, not twelve, and the description says so.

[P2] The #4707 attribution. Wrong, as you say — #4707 is pinned tail can load earlier history after its settle check, already deleted by #4766. I have replaced the claim with the one you offered instead, which is both true and stronger: promptRailWorker now has exactly one consumer, and its tests skip unless MAKA_TRANSCRIPT_PERF=1, so after this PR CI does not exercise the reused worker window at all.

[P3] The stale summary. Already corrected before your scope note landed; the description now names both production changes and gives the scroll fix its own section.

On whether a user-visible scroll fix belongs in a test-move PR: a fair question, and I do not think the answer is obvious. It is here because the two stories it fixes are the ones this series depends on — a tier migration whose replacement stories are red is not a migration — but it is a core-authority behaviour change with its own blast radius, and splitting it out so it can be reviewed, bisected and reverted on its own is a defensible call. Happy to split if a maintainer prefers that.

One correction to your increment note, since it credits me with something that was not mine: a slow reader is a reader, however small each step is came out of a second review pass, not from my own edge-checking. It found that the first version borrowed PIN_THRESHOLD_PX for the comparison and re-baselined every event, which swallowed every move smaller than the slack — so the original bug survived at low scroll speeds and a new regression appeared where there had been none. The dedicated tolerance and the accumulator are that finding, not a refinement I arrived at.

The rule that tells a reader's scroll from content moving under them
carried a 2px tolerance and a running total of what the content could
not explain. Neither was measured. `packages/ui` runs on a fake DOM of
integers, so no test there could reach either one, and an ablation
passed whatever the numbers were.

Measured in a real layout engine, on fractional heights, through both
paths that move the offset without the reader: native anchoring misses
its own band by exactly one pixel on about a quarter of the frames it
compensates, and clamping never misses. So the tolerance is one pixel
rather than two, and the running total has to go — the error is bounded
per event but one-directional across a stream, so accumulating it turns
a pixel of arithmetic into a drift that crosses any threshold and hands
a streaming reader back to the tail.

The story holds the measurement rather than the conclusion: shrink the
constant and the misses are reported as gestures, widen it and a two
pixel gesture goes unheard.

Generated-by: Claude Code
@Astro-Han

Copy link
Copy Markdown
Contributor Author

On splitting the TranscriptScrollAuthority fix into its own PR — I offered that earlier and left it open. Closing it: it should stay here.

The two stories this series moves work onto, ReaderScrolledUpIsNotPulledBack and DockAffordanceReturnsToTail, are exactly the ones that were failing. Landing the migration without the fix means landing a tier migration whose replacement coverage is red, and then explaining that state in a follow-up. Removing a mechanism and installing what replaces it is one change; the same applies to moving a claim into a story and making that story true.

The split I would accept is a merge constraint — some part having to land on its own schedule or through a different path. There isn't one here: both halves go to the same branch at the same time, and neither is independently mergeable in a state anyone would want.

What did change since the approval is the shape of the fix itself, and it is worth a second look because it is smaller now, not larger. The band comparison mixes CSSOM's two number systems, and the first version guessed at that with a 2px tolerance plus a running total of unexplained movement. Neither was falsifiable in packages/ui, which runs on a fake DOM of integers. Measured in a real layout engine on fractional heights (Product/Transcript Scroll Rounding, added in c47e415), native anchoring misses its own band by exactly 1px on 11 of 40 growth frames, and clamping never misses. So the tolerance is 1, and the running total is gone — the error is bounded per event but one-directional across a stream, so accumulating it would drift across any threshold and pull a streaming reader back to the tail, which is the same defect arriving the other way round.

The constant is now pinned from both sides: at 0 the misses are read as reader gestures, at 2 a deliberate 2px gesture goes unheard. Both mutations turn the story red.

CI caught the tolerance being cut to one pixel: a real transcript's
content steps miss their own band by more than the probe scroller's do,
so growth was read as the reader and the view stopped following the
tail mid-answer.

Two pixels is what the band actually needs, and the reason it looked
too wide is that it was being spent everywhere. An event that finds the
content unchanged has no rounded reading in it — the band is a point,
and the offset either moved or it did not. So the slack belongs to
events the content moved, and a reader inching down a settled
transcript is heard exactly rather than swallowed by arithmetic that
did not happen to them.

Generated-by: Claude Code
@jackwener

Copy link
Copy Markdown
Member

Follow-up to the approval above: the historical replay came back clean. I said I would post it either way.

The stories were applied unchanged on top of the commits that produced the behaviours they name, and both selected regressions turn them red deterministically:

Regression Result
#3849 — a path separator triggering the menu replacement fails
#2667 — same-content projection refresh replacement fails

No P0 or P1 from that pass. The three P2s and one P3 in the approval stand as written, and the approval does not need revisiting.

Worth stating why this pass was run separately at all. The PR's own mutation table — a defect injected into production, the replacement confirmed red — establishes that the assertions execute and are wired to the code they name. It cannot establish that they catch what the deleted tests caught, because the injected defect is chosen by the same person who wrote the replacement. Replaying against the implementation that actually shipped the regression removes that degree of freedom, and here it agrees with the table rather than contradicting it.

That agreement is the useful outcome: for these two, the move preserved interception rather than merely preserving green.

简体中文

对上面那次批准的跟进:历史回放的结果是干净的。 我说过无论结果如何都会发。

这些 story 被原样叠加到了产生它们所命名行为的那些提交之上,两个被选中的回归都确定性地让它们变红:

回归 结果
#3849——路径分隔符触发了菜单 替代测试失败
#2667——同内容 projection 刷新 替代测试失败

这一轮没有 P0/P1。 批准中的三条 P2 与一条 P3 按原文成立,批准本身无需重新考虑。

值得说明为什么要单独跑这一轮。PR 自带的变异表格——在生产代码里注入缺陷、确认替代测试变红——只能确立「这些断言会执行,并且确实连着它们所命名的代码」。 它无法确立「它们能抓住被删测试所抓住的东西」,因为那个被注入的缺陷,是由写这些替代测试的同一个人挑选的。 回放到真正发布过该回归的那个实现上,消除了这一个自由度——而在这里,它与表格是一致的,不是相反。

这种一致本身就是有用的结果:对这两条而言,这次搬移保住的是拦截力,而不只是绿色。


Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.

@Astro-Han
Astro-Han merged commit 7f7843e into apache:main Sep 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Under 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants