From 152acbef8dfb9252e1ac1a0a20b9b06fd716e217 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 2 Sep 2026 04:01:41 +0000 Subject: [PATCH] docs(chat): record task 3 in progress and re-lock the starter-home scroll guard on the hook Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- PROGRESS-chat-streaming-ux-20260901.md | 61 +++++++++++++++++------- frontend/tests/starter-questions.test.ts | 18 +++---- 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/PROGRESS-chat-streaming-ux-20260901.md b/PROGRESS-chat-streaming-ux-20260901.md index 96b4e1fc..6152d54a 100644 --- a/PROGRESS-chat-streaming-ux-20260901.md +++ b/PROGRESS-chat-streaming-ux-20260901.md @@ -2,7 +2,9 @@ 工作树:`.worktrees/streaming-ux-20260901` 分支:`codex/streaming-ux-20260901` -基线:`origin/staging` @ `fbb80fa3`(任务书写 `80e77361`,开工时远端已多一条任务书本身的 docs commit)。 +基线:任务 0–2 时为 `origin/staging` @ `fbb80fa3`;2026-09-02 远端前进到 `058e5db9`(`bf6989ec` 第三批拆页、`124d3990` 引擎卫生、`058e5db9` Home lint 修复)后本分支 **rebase 到其上**,任务 3 在该基线上做。 + +**BUG 编号顺延**:staging 的 lint 修复占用了 BUG-470,本分支四条整体后移(470→471、471→472、472→473、473→474),`docs/BUG_HISTORY.md`、本文件、四个测试文件里的注释与四个 commit message 一并改(用 `git filter-branch --tree-filter/--msg-filter` 在原提交上逐个改,再 rebase,只在 `BUG_HISTORY.md` 冲突一次)。任务书 `TASK-chat-streaming-ux-20260901.md` 未动。 未改 `.gitea/workflows/**`。未合并 staging,未碰 main。未手写 `useCallback` / `useMemo`(`rectification-agentic-chat.tsx` 既有的 `useCallback` 原样保留)。未动服务端事件语义;校正 `thinking.delta` 在 `stream-mapping.ts` 公开边界的丢弃原样保留。`page.tsx` 一行未动。 @@ -11,8 +13,8 @@ | 0 · 流式按帧合并 + 匀速吐字 + Markdown 前缀记忆 | 完成 | `dd4e3b37` | BUG-473 | | 1 · 结算不闪、时间线开合过渡、流式可折叠、queued 行 | 完成 | `69d19e0f` | BUG-474、BUG-475 | | 2 · 校正接入同一套时间线、删三条死路径、移除 thinking-orbs | 完成 | `cc17d971` | BUG-476 | -| 3 · 校正复用 ChatComposer / 统一滚动跟随 / JumpToLatest / 900px | **未做**,见 `BLOCKED.md` | — | BUG-476/475 未占号 | -| 4 · DESIGN.md | 与 0/1/2 对应的条目已改;与 3 对应的条目随 3 延后 | 本文件同 commit | — | +| 3 · 校正复用 ChatComposer / 统一滚动跟随 / JumpToLatest / 900px | 完成(第三批合入后解除阻塞) | `b72c1efc` | BUG-477、BUG-478 | +| 4 · DESIGN.md | 与 0/1/2 对应的条目随各任务改;与 3 对应的四条随任务 3 同 commit | — | — | ## 任务 0 · 流式渲染 @@ -46,15 +48,23 @@ Performance 面板录制(3k 字回答无 >50ms 长任务)**未做**:无登 - `chat-message-row.tsx` 只剩 timeline 一条主路径 + `AgentActivityStatus` 兜底(无 trace 分支、无 orb);live 标记全部 `InlineSpinner`;`.conversation.is-rectification .agent-thinking-step/.agent-thinking-marker` 14px 覆写删除。 - 校正的「已用时 N 秒」计时(`ActivityElapsed`)随 trace 面板一起退出——时间线本来就没有这个元素,两面一致优先。 -## 任务 3 · 未做 +## 任务 3 · 校正复用 ChatComposer、统一滚动跟随与「跳到最新」 -`git log origin/staging -- frontend/src/app/page.tsx` 最新 `551d6317`(第二批),第三批未合入。按红线 7 登记 `BLOCKED.md`,未占 BUG-476/475。 +开工时 `git log origin/staging -- frontend/src/app/page.tsx` 最新为 `551d6317`,第三批未合入,先按红线 7 登记 `BLOCKED.md`;远端合入 `bf6989ec`/`058e5db9` 后 rebase,阻塞解除,`BLOCKED.md` 该条改为已完成。 + +改动:`src/hooks/use-conversation-scroll-anchor.ts`、`src/components/jump-to-latest-button.tsx`(新)、`src/components/chat-composer.tsx`、`src/app/page.tsx`、`src/components/rectification-agentic-chat.tsx`、`src/app/globals.css`;删除 `src/lib/rectification-sticky-scroll.ts`。 + +- **ChatComposer**:新增可选 `value`(受控)与 `remainingId`。`const storeDraft = useComposerDraft(); const draft = value ?? storeDraft;`——store 订阅仍在组件内,主会话行为不变;校正面传 `value={draft}`,`grep composer-draft` 在校正文件里为 0(契约锁死)。校正面 `maxLength=500`(`RECTIFICATION_COMPOSER_MAX_LENGTH`,与主对话同上限),footer 放 `CharacterRemaining`(id 用 `useId`);三段 placeholder、Enter/Shift+Enter/`isComposing`、「停止回答」文案与 `stopRun` 原样。 +- **滚动跟随只留一份**:`useConversationScrollAnchor` 增加 follow:`ResizeObserver` 观察滚动容器的子元素(`MutationObserver childList` 跟踪子元素增删并重新观察),anchored 时下一帧 `scrollTop = scrollHeight`;`resetKey` 变化时先落底一次;`anchored` 经 `anchoredRef` 在 effect 里同步(不在 render 写 ref,过 `react-hooks` 规则)。`page.tsx` 那个按 `activeStreamingText` 触发、结算时再补 smooth 的 effect 删除;hook 调用签名 `(conversation, !rectificationSurfaceOpen && !starterHomeVisible, activeSessionId)` 不变,starter home 不滚动的性质由 `active=false` 承担。校正面 `useConversationScrollAnchor(conversation, true, caseId)`,删除 `followTailRef` / `scrollFrameRef` / `updateFollowState` / `scrollToLatest` / `followLatestContent` / `showJumpToLatest` / `choiceCardsOpen` 与两个 `useLayoutEffect`;`rectification-sticky-scroll.ts` 删除。选择卡 360px 带宽的特例随之退出:卡片本来就在 transcript 里,看着它的人离底 ≤96px,本就 anchored。 +- **JumpToLatestButton**:`.jump-to-latest`(absolute、`bottom: 100%`、`pointer-events: none`)+ `.jump-to-latest__button`(44px、`--shadow-elevated`、focus ring、hover 暖卡片色),两面共用,文案统一「跳到最新」带 `ArrowDown`;`page.tsx` 内联 Tailwind 版(`shadow-md`)与 `.rectification-jump-latest` 三条规则删除。 +- **宽度**:`.rectification-workspace__chat .message-list { max-width: 720px }` 删除,两面同 `.message-list` 的 900px;`--rectification-jump-clearance` 底部留白保留。 +- 手工项(两面各做一次「流式中上滚→停跟随→出现按钮→点回底并恢复跟随」)**未做**,无登录态无 Chrome;行为由 `chat-notice-and-scroll-contract`、`rectification-agentic-entry`、`rectification-answer-choice` 的 hook/组件锁与 `nextAnchorState` 纯函数断言代替。 ## 任务 4 · DESIGN.md 已改:§3 思考正文 13px 写成显式例外;§5 Message 正文 16px → 17px(≤767px 时 16px,与 `globals.css` 一致)、Motion 改为"只有 GSAP 一份 160ms、结算不重放"、新增「Streaming states」七态表(含 failed 不用消息内红字、pacing 公式、reduced-motion 行为、校正只差不显示思考正文);§6 表新增 Thinking collapse 180ms / Timeline label swap 120ms / Text reveal;§9 等待表把时间线 live 步写成两面共用,注明 live 标记只有 `InlineSpinner`、orb 已移除。 -随任务 3 延后:§4 阅读宽度(720 vs 900)、§5 composer 共用一句、§5 Jump to latest 条目、§7 `--shadow-soft`。 +随任务 3 已改:§4 阅读宽度写明两面同 900px;§5 Input and composer 加「One composer」条;§5 新增「Jump to latest」条目;§7 写明 `--shadow-soft` 的实际归属(叙事卡、会话内 composer 字段、starter 主题卡 focus、auth 模式 tab)与「浮动控件一律 `--shadow-elevated`、不得用工具类阴影」。 ## 被触碰的既有断言(红线 3,每处源码上方均有原值与错因注释) @@ -69,27 +79,42 @@ Performance 面板录制(3k 字回答无 >50ms 长任务)**未做**:无登 | `tests/rectification-agentic-entry.test.ts` | 读取 `completed-activity-receipt.tsx` 并锁其 `
`/不 open/「本轮完成 · N 个步骤」;`activityStatus /activityTrace/` | 改为锁 `chat /rectificationTimelineRows/`、`chat /completedReceipt/`、`activityStatus` 不含 `activityTrace` | 锁的是校正专属第二折叠块与 trace 面板(BUG-476) | | `tests/rectification-activity-receipt.test.ts` | `deltaBranch` 内 `state: raw.trim() ? "streaming" : "thinking"`、`text: raw,`;`message.state === "settled" && message.failed` | 状态派生改锁 frame flush 段(`state: text.trim() ? …`、`text,`、`frames.setAnswer(raw)`);失败行改锁 `&& !message.failed` 且无 `rectification-activity-failure` | 前者锁的是每 chunk 一次 setMessages(BUG-473),后者锁的是校正专属红字失败行(BUG-476)。**注**:这个文件在任务 0 的聚焦跑里漏跑了,任务 2 跑全量前才发现,改在 `cc17d971` | +**任务 3 触碰的断言**(同样每处有原值与错因注释): + +| 文件 | 原断言 | 现在 | 理由 | +| --- | --- | --- | --- | +| `tests/character-remaining-contract.test.ts` | `aria-describedby={showRemaining ? composerRemainingId : undefined}` | `remainingId = composerRemainingId` 默认 + `aria-describedby={showRemaining ? remainingId : undefined}` | composer 现为两面共用,校正传自己的计数 id(BUG-477) | +| `tests/composer-isolation-contract.test.ts` | `const draft = useComposerDraft()` | `const storeDraft = useComposerDraft(); const draft = value ?? storeDraft;` + 校正传 `value={draft}` 且不 import `composer-draft` | 受控 `value` 是任务书要求,隔离性质加强而非减弱(BUG-477) | +| `tests/chat-navigation-a11y-contract.test.ts` | 内联按钮的 `min-h-11` / `focus-visible:ring-3` / `pointer-events-auto` / ``,语义改锁组件源码 + `.jump-to-latest__button` 的 44px / focus-visible / pointer-events | 锁的是内联 Tailwind 按钮本身(BUG-478) | +| `tests/chat-notice-and-scroll-contract.test.ts` | 三处:page effect 内 `anchored` 守卫先于 `container.scrollTo`;jump control 的 Tailwind 类;`absolute inset-x-0 bottom-full` | follow 在 hook 内、`anchoredRef` 守卫先于 `scrollTop` 赋值、`page.tsx` 无 `scrollTo`、`ResizeObserver`/`MutationObserver` 存在;按钮改锁组件与 CSS;定位改锁 `.jump-to-latest { position: absolute; bottom: 100% }` | 锁的是每 token 一次 `scrollTo` 的 effect 与内联按钮(BUG-478) | +| `tests/chat-stream-layout.test.ts` | composerWrap 含 `pointer-events-none absolute inset-x-0 bottom-full` | 含 ``、这些符号不再出现、`.jump-to-latest` 居中;`stopLabel="停止回答"`、`onStop={stopRun}`、`composer-stop` 在 `chat-composer.tsx`;`` 与 `conversationAnchor.anchored` | 模块已删除(BUG-478) | + 新增测试:`tests/stream-frame-buffer.test.ts`(7)、`tests/chat-markdown-split.test.ts`(4)、`tests/chat-stream-settle-contract.test.ts`(4)、`tests/rectification-timeline-adapter.test.ts`(6)、`home-streaming-render-split` +1。 ## 命令输出 -`./node_modules/.bin/tsc --noEmit` → **0**(任务 0/1/2 各跑一次,均无诊断)。 +`./node_modules/.bin/tsc --noEmit` → **0**(任务 0/1/2 各一次;rebase 后一次;任务 3 后一次,均无诊断)。 -`./node_modules/.bin/eslint` 触及文件 → **0 error**。`use-consultation-run.ts` 有 9 条既有 `no-unused-vars` warning(未使用的导入/`startNewChat`),本轮只加了一行 import,与 `origin/staging` 一致;`stream-frame-buffer.ts` 一条 `dirty` 未用已在本 commit 清掉。 +`./node_modules/.bin/eslint` 触及文件 → **0 error**。warning 只落在 `page.tsx`(43 条,与 `origin/staging` 同文件同数)与 `use-consultation-run.ts`(9 条既有 `no-unused-vars`,本轮只加一行 import);本轮新建的六个文件 0 warning。 -`./node_modules/.bin/tsx --test tests/*.test.ts`: +`./node_modules/.bin/tsx --test tests/*.test.ts`(基线在干净的 `origin/staging` @ `058e5db9` 独立 worktree 里 `npm ci` 后跑): -| | 基线(开工时 `fbb80fa3`) | 现在 | -| --- | ---: | ---: | -| tests | 2460 | 2482 | -| pass | 2426 | 2448 | -| fail | 24 | 24 | -| skipped | 10 | 10 | +| | 旧基线 `fbb80fa3` | 新基线 `058e5db9` | 现在(rebase + 任务 3) | +| --- | ---: | ---: | ---: | +| tests | 2460 | 2461 | 2482 | +| pass | 2426 | 2427 | 2448 | +| fail | 24 | 24 | 24 | +| skipped | 10 | 10 | 10 | -24 条失败逐条比对**完全同一集合**(`comm` 差集为空):全部是本机无 Docker / PostgreSQL / 部署夹具的数据库、Better Auth、staging workflow YAML、live sync 类(admin code functions、Owner recovery、local PostgreSQL、redeem security、personal report job/sections RLS、migration runner、v9 migration 等),与上一轮 PROGRESS 记录的环境缺口同类。10 条 skipped 同基线。无产品逻辑新增失败。 +24 条失败与新基线逐条比对**完全同一集合**(`comm` 差集为空):全部是本机无 Docker / PostgreSQL / 部署夹具的数据库、Better Auth、staging workflow YAML、live sync 类(admin code functions、Owner recovery、local PostgreSQL、redeem security、personal report job/sections RLS、migration runner、v9 migration 等)。10 条 skipped 同基线。无产品逻辑新增失败。任务 3 第一次全量跑曾多出 1 条 `starter-questions` 的源码锁失败,按红线 3 改锁 hook 后(见上表)复跑归零。 -`./node_modules/.bin/next build` → **0**。`✓ Compiled successfully in 21.7s`、`Finished TypeScript in 58s`、`Generating static pages (62/62)`,`/` 仍是 `○ (Static)`。5 条既有 `skill-package-registry.ts` filesystem-tracing warning,未失败。 +rebase 后、任务 3 前的聚焦 14 套件 117/117;任务 3 后的聚焦 17 套件 177/177。 + +`./node_modules/.bin/next build` → **0**。`✓ Compiled successfully in 22.6s`、`Finished TypeScript in 28.4s`、`Generating static pages (62/62)`,`/` 仍是 `○ (Static)`。5 条既有 `skill-package-registry.ts` filesystem-tracing warning,未失败。 ## 未做与原因 -见 `BLOCKED.md` 顶部三条:任务 3 等第三批拆页;浏览器级手工验收(Performance 录制、结算录屏、两面并排截图、流式期间折叠)无登录态无 Chrome;`latestEntryMounts` 探针无 DOM 测试库不可驱动。 +见 `BLOCKED.md` 顶部:任务 3 已解除并完成;仍未做的是浏览器级手工验收(Performance 录制、结算录屏、两面并排截图、流式期间折叠、两面滚动跟随实机)——无登录态无 Chrome;`latestEntryMounts` 探针无 DOM 测试库不可驱动。 diff --git a/frontend/tests/starter-questions.test.ts b/frontend/tests/starter-questions.test.ts index 68245e47..aff178f9 100644 --- a/frontend/tests/starter-questions.test.ts +++ b/frontend/tests/starter-questions.test.ts @@ -206,15 +206,15 @@ test("removes the user-facing admin button from the chat page", () => { }); test("keeps the empty starter home at the top instead of auto-scrolling", () => { - const autoScrollEffect = sourceBetween( - pageSource, - "useEffect(() => {\n if (starterHomeVisible) return;", - "profileComplete, starterHomeVisible]);", - ); - - assert.match(autoScrollEffect, /if \(starterHomeVisible\) return/); - assert.match(autoScrollEffect, /const container = conversation\.current/); - assert.match(autoScrollEffect, /container\.scrollTo\(\{ top: container\.scrollHeight/); + // Former lock: a page-level effect starting `if (starterHomeVisible) return;` that called + // `container.scrollTo`. The follow now lives in useConversationScrollAnchor (BUG-478); the page + // switches it off for the starter home through the hook's `active` argument instead. + assert.match(pageSource, /useConversationScrollAnchor\(\n\s*conversation,\n\s*!rectificationSurfaceOpen && !starterHomeVisible,/); + const anchorSource = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8"); + const followEffect = anchorSource.slice(anchorSource.indexOf("// Follow: while anchored")); + assert.match(followEffect, /if \(!active \|\| !element\) return;/); + assert.ok(followEffect.indexOf("if (!active || !element) return;") < followEffect.indexOf("element.scrollTop = element.scrollHeight")); + assert.doesNotMatch(pageSource, /container\.scrollTo\(/); assert.match(pageSource, /ref=\{conversation\} className=\{`conversation/); assert.doesNotMatch(pageSource, /conversationEnd|scrollIntoView/); });