f542c02490
The rectification session rendered its own textarea (no length ceiling, no count), its own rAF scroll follow with a sticky-scroll helper, and its own jump chip styled differently from the main chat's inline Tailwind button, while the main chat re-ran scrollTo on every streamed token and added a smooth scroll on settle. ChatComposer now accepts a controlled value so the rectification surface reuses it without touching the main draft store; useConversationScrollAnchor owns the follow through a ResizeObserver, one frame per content change, for both surfaces; a single JumpToLatestButton replaces both chips; the 720px transcript override is gone. DESIGN.md records the shared composer, the jump control, the reading width and the two shadow tokens. BUG-477 BUG-478 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
146 lines
8.7 KiB
TypeScript
146 lines
8.7 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import { noticeTone } from "../src/lib/chat-notice.ts";
|
|
import { nextAnchorState } from "../src/hooks/use-conversation-scroll-anchor.ts";
|
|
import { homeSurface } from "./home-surface.ts";
|
|
|
|
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
|
const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8");
|
|
const anchorSource = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8");
|
|
const globalsSource = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
|
|
|
function sourceBetween(source: string, startMarker: string, endMarker: string) {
|
|
const start = source.indexOf(startMarker);
|
|
const end = source.indexOf(endMarker, start);
|
|
assert.notEqual(start, -1);
|
|
assert.notEqual(end, -1);
|
|
return source.slice(start, end);
|
|
}
|
|
|
|
test("routes composer notices to the user instead of discarding them", () => {
|
|
// Given: the page no longer keeps the notice in write-only state.
|
|
assert.doesNotMatch(pageSource, /const \[, setComposerNotice\] = useState/);
|
|
assert.match(pageSource, /import \{ showChatNotice as setComposerNotice \} from "@\/lib\/chat-notice"/);
|
|
|
|
// Then: every notice reaches the mounted sonner toaster.
|
|
assert.match(noticeSource, /import \{ toast \} from "sonner"/);
|
|
assert.match(noticeSource, /toast\.success\(message, options\)/);
|
|
assert.match(noticeSource, /toast\.error\(message, options\)/);
|
|
assert.match(noticeSource, /toast\(message, options\)/);
|
|
});
|
|
|
|
test("clearing a notice dismisses instead of showing an empty toast", () => {
|
|
const clearBranch = sourceBetween(noticeSource, "if (!message.trim())", "if (lastNotice === message && !action) return;");
|
|
|
|
assert.match(clearBranch, /toast\.dismiss\(chatNoticeToastId\)/);
|
|
assert.doesNotMatch(clearBranch, /toast\(|toast\.success|toast\.error/);
|
|
assert.match(pageSource, /setComposerNotice\(""\)/);
|
|
});
|
|
|
|
test("keeps the recovery poll from stacking repeated notices", () => {
|
|
// Given: the recovery loop repeats the same message every 1750ms.
|
|
assert.match(pageSource, /timer = window\.setTimeout\(\(\) => void poll\(\), 1_750\)/);
|
|
|
|
// Then: a stable toast id plus last-message dedupe replaces instead of accumulating.
|
|
assert.match(noticeSource, /export const chatNoticeToastId = "chat-notice"/);
|
|
assert.match(noticeSource, /if \(lastNotice === message && !action\) return;/);
|
|
});
|
|
|
|
test("assigns notice severity by message intent", () => {
|
|
assert.equal(noticeTone("网络已断开,回答仍在后台生成;联网后会自动恢复。"), "info");
|
|
assert.equal(noticeTone("回答仍在后台生成,正在自动恢复。"), "info");
|
|
assert.equal(noticeTone("正在确认本次咨询请求是否已开始…"), "info");
|
|
assert.equal(noticeTone("此前选择的模型已下线,已切换为默认模型。"), "info");
|
|
assert.equal(noticeTone("回答已取消;问题仍保留在聊天记录中,可重新发送。"), "info");
|
|
assert.equal(noticeTone("回答已恢复。"), "success");
|
|
assert.equal(noticeTone("已归档,可在左侧归档中恢复。"), "success");
|
|
assert.equal(noticeTone("已停止回答,现有内容已保留,本次点数已退回。"), "success");
|
|
assert.equal(noticeTone("这段对话已写满,开个新对话继续吧"), "error");
|
|
assert.equal(noticeTone("该对话不存在或已被删除"), "error");
|
|
assert.equal(noticeTone("删除失败:网络异常"), "error");
|
|
assert.equal(noticeTone("重命名同步失败"), "error");
|
|
assert.equal(noticeTone("模型服务暂时不可用,当前无法发送问题。"), "error");
|
|
assert.equal(noticeTone("后台未找到本次咨询请求,已停止恢复,请重新发送。"), "error");
|
|
assert.equal(noticeTone("未能存入历史"), "error");
|
|
assert.equal(noticeTone("保存失败,请重试"), "error");
|
|
});
|
|
|
|
test("anchors the streaming scroll instead of following every token", () => {
|
|
// Former lock: a page-level effect keyed on `activeStreamingText` that called
|
|
// `container.scrollTo` after an `anchored` guard. That effect ran once per token and added
|
|
// a second smooth scroll on settle; the follow now lives in the hook, driven by a
|
|
// ResizeObserver over the scroller's children, one frame per change (BUG-478).
|
|
assert.doesNotMatch(pageSource, /container\.scrollTo\(/);
|
|
const follow = sourceBetween(anchorSource, "const follow = () => {", "};");
|
|
assert.match(follow, /if \(!anchoredRef\.current\) return/);
|
|
const scrollGuard = follow.indexOf("if (!anchoredRef.current) return");
|
|
const scrollCall = follow.indexOf("element.scrollTop = element.scrollHeight");
|
|
assert.ok(scrollGuard >= 0 && scrollGuard < scrollCall);
|
|
assert.match(anchorSource, /new ResizeObserver\(requestFollow\)/);
|
|
assert.match(anchorSource, /frame = window\.requestAnimationFrame\(follow\)/);
|
|
assert.match(anchorSource, /observe\(element, \{ childList: true \}\)/);
|
|
});
|
|
|
|
test("scrolls to the newest turn on intentional jumps", () => {
|
|
// Given: switching sessions resets the anchor through the hook reset key.
|
|
assert.match(pageSource, /useConversationScrollAnchor\(\n\s*conversation,\n\s*!rectificationSurfaceOpen && !starterHomeVisible,\n\s*activeSessionId,\n\s*\)/);
|
|
assert.match(anchorSource, /const anchored = anchor\.key === resetKey \? anchor\.anchored : true/);
|
|
|
|
// And: sending a question re-anchors before the optimistic turn renders.
|
|
const sendBlock = sourceBetween(homeSurface, " updateSession(sessionId, () => userSession);", " setDraft(\"\");");
|
|
assert.match(sendBlock, /conversationAnchor\.anchorToLatest\(\)/);
|
|
});
|
|
|
|
test("offers an accessible jump-to-latest control while reading history", () => {
|
|
// Former locks pinned the inline Tailwind button in page.tsx (`min-h-11`, `focus-visible:ring-3`,
|
|
// `shadow-md`, `pointer-events-auto`, `absolute inset-x-0 bottom-full`). That button was one of
|
|
// two jump controls with two styles, and `shadow-md` bypassed the §7 shadow token (BUG-478).
|
|
// Both surfaces now render `JumpToLatestButton`; its semantics live in the component and CSS.
|
|
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ")}");
|
|
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
|
|
const buttonRule = globalsSource.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
|
|
|
|
assert.match(pageSource, /const jumpToLatestVisible = !rectificationSurfaceOpen[\s\S]*?&& !conversationAnchor\.anchored/);
|
|
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
|
|
assert.match(jumpSource, /type="button"/);
|
|
assert.match(jumpSource, /aria-label="跳到最新"/);
|
|
assert.match(globalsSource, /\.jump-to-latest__button:focus-visible \{[^}]*outline: 3px solid/);
|
|
assert.match(buttonRule, /min-height: 44px/);
|
|
assert.match(buttonRule, /min-width: 44px/);
|
|
assert.match(buttonRule, /box-shadow: var\(--shadow-elevated\)/);
|
|
});
|
|
|
|
test("rests the jump-to-latest control on the composer instead of the padded scroller", () => {
|
|
// Given: the scroll container reserves composer-sized padding, which clamps any sticky child
|
|
// of it that far above the composer, on top of the transcript the reader is still reading.
|
|
assert.match(globalsSource, /\.conversation \{[^}]*padding-bottom: var\(--composer-reserve\)/);
|
|
assert.match(globalsSource, /\.composer-wrap \{[^}]*position: sticky/);
|
|
|
|
// Then: the control hangs off the composer's own top edge, whatever that reserve is.
|
|
const composerWrap = sourceBetween(pageSource, "className={`composer-wrap ", "<ChatComposer");
|
|
assert.match(composerWrap, /<JumpToLatestButton/);
|
|
const wrapRule = globalsSource.match(/\.jump-to-latest \{[^}]*\}/)?.[0] ?? "";
|
|
assert.match(wrapRule, /position: absolute/);
|
|
assert.match(wrapRule, /bottom: 100%/);
|
|
assert.doesNotMatch(wrapRule, /sticky/);
|
|
});
|
|
|
|
test("keeps the scroll listener passive and reduced-motion aware", () => {
|
|
assert.match(anchorSource, /addEventListener\("scroll", onScroll, \{ passive: true \}\)/);
|
|
assert.match(anchorSource, /frame = window\.requestAnimationFrame\(measure\)/);
|
|
assert.match(anchorSource, /window\.matchMedia\("\(prefers-reduced-motion: reduce\)"\)\.matches/);
|
|
assert.match(anchorSource, /behavior: reduceMotion \? "auto" : "smooth"/);
|
|
});
|
|
|
|
test("re-anchors once the reader returns to the newest turn", () => {
|
|
// Given: the reader is following the stream and scrolls up mid-answer.
|
|
assert.equal(nextAnchorState(true, 0, false), true);
|
|
assert.equal(nextAnchorState(true, 600, true), false);
|
|
|
|
// Then: growing content alone never re-anchors, but scrolling back does.
|
|
assert.equal(nextAnchorState(false, 600, false), false);
|
|
assert.equal(nextAnchorState(false, 40, false), true);
|
|
});
|