Skip to content

feat(webui): give the streaming cursor a square-wave rhythm - #122

Merged
fengzhi09 merged 1 commit into
mainfrom
feat/streaming-cursor-rhythm
Sep 30, 2026
Merged

fengzhi09 merged 1 commit into
mainfrom
feat/streaming-cursor-rhythm

Conversation

@fengzhi09

Copy link
Copy Markdown
Collaborator

背景

61 号工单最后一条差距。桌面版没有文本光标(用活动指示代替),但工单 46 已拍板保留 ▍ 并单独打磨节奏——所以本 PR 不是删掉它,而是给它一个有节奏的动效。

现状

chat.tsx:706-708 用 Tailwind 通用 animate-pulse:

pulse 2s cubic-bezier(0.4,0,0.6,1) infinite
@keyframes pulse { 50% { opacity: .5 } }

三个问题:无瞬时边沿、无熄灭态(最低只到 0.5)、**2s 周期读作「加载」**且与骨架屏语义重复。

新动效

.stream-cursor { animation: stream-cursor-blink 1.1s steps(1, end) infinite; }

方波式(亮/灭),60% 亮 / 40% 暗,1.1s 周期。

为什么 1.1s 而不是照抄短语轮换的 3.5s:3.5 / 1.1 = 3.18,两者不锁相——否则两个周期动效会周期性重合,肉眼能看出规律。

两处有意偏离:

  • 暗档取 0.2 而非 0——该字符贴在正文末尾、没有自己的格子,灭到 0 会像段落掉了最后一个字符
  • 否决「token 到达时亮起」——CSS 动画随元素创建启动,而 React 复用该 span(无 key、className 是静态字面量)。要按 token 重挂载就会在走到暗档前重启,恰好在节奏最该被看见时变常亮,还每 token 多一次 DOM 抖动,零收益

因此没有动 SSE 通路、没有加 key、没有改渲染条件——产品代码只有 1 行 JSX + 一段 CSS。

reduced-motion

共用块内加 .stream-cursor { animation: none }(紧邻 .message-animate-in,同形)。

不靠 catch-all——它只强制 animation-duration,从不碰 animation-name,所以对 animation 不够。不需要 !important(无第二张表竞争,已被测试钉住)。

关闭后光标落回实心、静止、可见——测试额外断言两条规则体都不含 opacity,保证「禁用动效」不等于「看不见」。

流式无副作用

纯 CSS,零 JS / 零 state / 零 effect,不进渲染路径。span 无 key、className 静态 → 节点复用 → 相位自由运行,不会逐 token 重启。回合结束仍随 streaming=false 消失,逻辑零改动。

变异验证 10/10 全红,逐字节还原

第一轮抓到一处真漏洞:改关键帧名最初全绿——keyframesBody() 用的是 indexOf 子串匹配,stream-cursor-blink-typo 也命中。已补「animation 名必须在全部已加载样式表里精确解析到唯一一个 @keyframes 声明」,重跑后该变异与整块删除均转红。

这是「测试是装饰品」的又一个小实例——自己写的测试自己也骗过了。

门禁

门禁 退出码 结果
pnpm webapp:typecheck 0 —
pnpm typecheck 0 —
pnpm test:webapp 0 1790/1790
pnpm test:webui 0 2137 pass / 0 fail / 2 skipped
pnpm check:source 0 4891 files
check-docs-alignment.mjs 0 —
test-tmp-leak verify-registry 0 169 entries

SPEC 闸门:产品代码 +71 / −1(globals.css +70 含 48 行解释注释、chat.tsx +1/−1),新增 API/组件 0。

边界:真机验证做不到

本分支在独立 worktree,dev server 不加载它。未重启任何实例、未跑 build/install。三条只能源侧 + 测试侧担保,需合入后由质检在真机确认:

  1. 1.1s 方波的真实观感——手感判据只能人眼看
  2. computed style 为 animation-name: stream-cursor-blink / steps(1, end)
  3. 开「减弱动态效果」后 animation-name: none 且光标仍可见

顺带记录一个未修的边角

空文本帧线上是 ● ▍,解码器 ^[●•]\s+(.*)$ 的贪婪匹配会吃掉全部空格 → 该帧 streaming 为 false。无可观察后果,本单未修,已在测试注释写明。

🤖 Generated with DeepSeek Harness

…rity 61, G6)

The trailing cursor on a streaming assistant block ran on Tailwind's stock
animate-pulse - a 2s symmetric eased breath between opacity 1 and 0.5, with
no instant edge and no off state. That reads as "loading", which is what
the transcript skeleton already says.

Replace it with .stream-cursor: a two-level square wave, 1.1s, 60% lit,
steps(1, end), dark floor 0.2 rather than 0 so the caret never looks like a
dropped character at the end of a prose run. The 1.1s period is not a ratio
of the 3.5s phrase rotation, so the two rhythms do not lock into one slow
compound beat. The beat is deliberately free-running: the span is reconciled
as the same element all turn, so keying it to token arrival would need a
per-token remount that restarts the animation before it reaches its dark
phase.

Reduced motion gets an explicit rule in the shared globals.css block (the
duration catch-all never touches animation-name), and the caret stays lit and
still rather than vanishing.

Pinned by webapp/test/stream-cursor.test.ts (16 cases), verified by
mutation: renaming or deleting the keyframes, easing the wave, dimming to
zero, locking the period, restoring animate-pulse, and rendering the cursor
unconditionally each turn the suite red.

Docs: docs/webui.md + docs/webui.zh-CN.md, same PR.
@fengzhi09
fengzhi09 merged commit a510ae6 into main Sep 30, 2026
8 checks passed
@fengzhi09
fengzhi09 deleted the feat/streaming-cursor-rhythm branch September 30, 2026 10:37
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