Skip to content

refactor(ui): let Astryx clickAction own the in-flight button state - #2089

Merged
jackwener merged 2 commits into
mainfrom
pr-astryx-click-action-1
Aug 4, 2026
Merged

refactor(ui): let Astryx clickAction own the in-flight button state#2089
jackwener merged 2 commits into
mainfrom
pr-astryx-click-action-1

Conversation

@jackwener

Copy link
Copy Markdown
Member

Task #137 —【Astryx 落地 ①】clickAction/changeAction 乐观异步替换手写 busy 态.

问题

改造点上的按钮用改自己的名字来汇报进度(导出中… / 测试中… / 删除中…),旁边再手写一整套:isDisableddata-pendingaria-busy、以及一个 mountedRef 守卫防止结束时 setState-after-unmount。

标签互换是这几个信号里最弱的一个:读屏必须重新读一遍按钮才会发现;文字在点击瞬间重排;而且它完全不防同 tick 双击——恰恰是 export / save / delete 这类 fire-once 操作最需要防的。

做法

Astryx 的 clickAction / changeAction 原生带全套契约(Button.tsx:617-670Switch.tsx:431-433,490-500 已核实):

  • actionInFlightRef 同 tick 去重 —— 官方注释写明 isPendinguseOptimistic 都做不到
  • 自动 aria-busy
  • spinner 延迟显示,快操作不闪
  • live-region "Loading" 播报
  • 执行中自动禁用(isInterruptible 可豁免)
  • Switch 额外给乐观值,action reject 时自动回滚

明确没有动的东西

所有跨控件的锁原样保留 —— 它们和 clickAction 是互补的两层,不是替代关系:

保留 它负责的事
configBusy 导出/导入互斥,一次只跑一个配置操作
detailActionBusy 一个连接的 test/update/delete/save 互相串行
pendingSkillAction 告诉下方 skill library 哪一行在忙(喂 8 个 prop)
voice busy 冻结保存按钮旁边的 3 个输入框和取消
runArtifactAction 的 ref 全 pane 一次一个 artifact 操作,含预览卡片里的「在 Finder 中打开」

按规格要求,optimistic-settings-draft-controller.tsuse-action-guard.ts 一行未动 —— 它们管的是跨渲染的 save ticket / last-write-wins,不是单控件反馈。

两处偏离规格,请 review 时确认

  1. mcp-page.tsx:719,772isLoading 而不是 clickAction 这两个是 type="submit",装在 <form onSubmit> 里。换成 clickAction 就得把 form 的 onSubmit 一起端掉,输入框里按 Enter 提交会失效——这是真实回归。isLoading 是 submit 按钮能用的那一半契约:spinner + aria-busy + 播报都在,只是不接管点击。

  2. artifact-pane.tsx 的 4 个工具栏按钮不再彼此禁用。 规格说「pendingArtifactAction 整套 state 可删」,我照做了:state 删掉,ref 留下继续串行。代价是——action A 跑的时候,B 按钮不再显示为禁用,点它是静默 no-op(ref 挡掉了,不会并发)。考虑到 saveAs 开的是窗口级 modal 原生对话框、其余操作都是亚秒级,我判断可以接受;但如果你要「兄弟按钮可见地禁用」,把 state 加回来只喂 isDisabled 也行,说一声我改。

顺带清理

删掉 6 个 locale 模块里 7 组孤儿 copy key(zh + en 都删):exporting/importingtesting/updating/deleting/importingopening/saving/copying/deletingrefreshingsaving/importingrecognitionConnectionSaving。每个都 grep 确认过全仓无引用。

验证

步骤 结果
npm run build
npm run typecheck
npm run format:check
check-dead-css.mjs --check ✅ no dead classes or tokens
test:checks(console / a11y / copy) ✅ 全 clean
workspace 测试 · desktop ✅ passed
workspace 测试 · ui ✅ passed

命令均未接 | tail 管道,退出码真实。

storagecli 两个 workspace 失败,但在本 commit 的 parent 上 stash 后重跑失败完全一致(Node v25 会打 node:sqlite ExperimentalWarning,这些测试断言 stderr 为空)。与本 PR 无关,本 PR 一行都没碰这两个包。

同一模式还没改的地方(不在本次规格里,留作后续)

data-settings-page 的 opening/copying/clearing 三连、artifact-pane 的列表重试按钮、general-settings:403web-search-settings:300bot-chat-detail:241,246about-settings:183password-input:118memory-settings:344,360,400permission-center:520claude-subscription-card:380bot-wechat-login:188provider-oauth-section:275mcp-page:326,615plan-reminder-panel:575skills-panel:454,571,572。要不要单开一个 PR 扫干净,你定。

@maka-审美专家 请 review。

Fourteen buttons and three switches reported their own work by renaming
themselves — 导出中… / 测试中… / 删除中… — and hand-rolled the rest of the
affordance beside it: an isDisabled flag, a data-pending attribute, an
aria-busy attribute, and a mounted-ref guard so the settle didn't setState
after unmount.

A label swap is the weakest of those signals. A screen reader has to
re-read the button to notice it, the text reflows mid-click, and none of
it dedupes the same-tick second click that a fire-once action (export,
save, delete) most needs to survive.

Astryx's clickAction/changeAction already carry the whole contract: a ref
guard that dedupes within the tick (which neither isPending nor
useOptimistic does), automatic aria-busy, a spinner whose reveal is
delayed so a fast action doesn't flash, a live-region "Loading"
announcement, and disable-while-pending. Switch adds an optimistic value
that reverts if the action rejects.

What did NOT move: every cross-control lock stays exactly as it was.
configBusy still means "one config operation at a time", detailActionBusy
still serializes a connection's actions, pendingSkillAction still tells
the skill library which row is busy, and voice's `busy` still freezes the
three fields beside its save button. clickAction speaks for one control;
those speak for a group, and the two are complementary. Per the same
rule, optimistic-settings-draft-controller and use-action-guard are
untouched — they hold cross-render save tickets and last-write-wins,
which is not something a button can know.

mcp-page keeps type="submit" and its form onSubmit so Enter in a field
still saves; it takes isLoading, which is the half of the contract that
applies to a submit button.

Removes seven now-orphaned copy keys across six locale modules
(exporting/importing, testing/updating/deleting/importing,
opening/saving/copying/deleting, refreshing, saving/importing,
recognitionConnectionSaving) in both zh and en.

Verified: build, typecheck, format:check, check-dead-css, test:checks,
and the desktop + ui workspace suites. storage and cli fail identically
on a clean checkout of this commit's parent (Node v25 prints a node:sqlite
ExperimentalWarning that those tests assert stderr against) — unrelated
to this change, which touches neither package.
Review caught a real regression, and the CI e2e failure was it, not a
flake: the three delete buttons converted to clickAction never showed
their confirm dialog.

clickAction runs its handler inside `startTransition(async …)`. React
holds state commits made during an async transition until the action
settles. `toast.confirm` is React state — it needs four sequential
commits to resolve its promise (mounting → rAF → open → user choice →
rAF → resolve). So the dialog could not render until the action settled,
and the action was awaiting the dialog. Deadlock; the alertdialog never
appeared and the button sat spinning.

Reproduced independently before fixing, with a minimal probe that skips
the CDP short-viewport step (that step is what makes providers.spec
unusable locally): on the previous commit the probe fails with
"element(s) not found" on the alertdialog, on this one it passes.

The three confirm-gated buttons go back to onClick and take `isLoading`
for the affordance. That keeps this PR's actual goal — the label stays
删除 instead of renaming itself to 删除中… — while staying out of the
transition. isLoading counts into the button's disabled state, so the
spinner, aria-busy, and the disable all still come from the component.

Everything else keeps clickAction. Audited every converted handler for a
reachable confirm, following the callbacks out of the components:
openInFinder / saveAs / copyText, runTest / refreshModels, connect,
export / import, voice save, refreshSkills / openSkillsFolder, and the
three Switches' onUpdate are all confirm-free.

Also applies the review's consistency nit: export / import / save now
pass through an arrow like refreshModels already did, so no handler can
silently receive the click event as its first argument.

Rule this establishes, for the rest of the Astryx migration: never await
UI driven by same-tree React state from inside clickAction/changeAction.
A confirm-gated action belongs on onClick + isLoading.

Verified after rebasing onto main: build, typecheck, format:check,
check-dead-css, test:checks, desktop + ui workspace suites, and the
delete-confirm probe. providers.spec fails locally at an unrelated step —
a network-error toast covers the delete button in the 500px viewport
because the sandbox is offline — identically on clean origin/main, same
test, same step, same locator. storage and cli remain the known Node v25
node:sqlite ExperimentalWarning failures.
@jackwener
jackwener force-pushed the pr-astryx-click-action-1 branch from ca32265 to 6baed50 Compare August 4, 2026 06:18
@jackwener

Copy link
Copy Markdown
Member Author

Review by maka-审美专家 — 通过(第二轮):3 个 confirm 门控按钮回 onClick + isLoading 的修复复验通过——本地重建后 delete-confirm probe 由挂转过,CI e2e_shard(1/2)(首轮抓到死锁的关卡)全绿。首轮意见全部落实:范围重审、箭头包装 nit、规则入册。合入。

@jackwener
jackwener merged commit 58f8bb4 into main Aug 4, 2026
11 checks passed
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.

1 participant