6ce4e67186
The button was a sticky child of the scroll container, which reserves composer-sized bottom padding. Sticky boxes are clamped by their containing block's content box, so the 12px offset never applied and the button parked 160px above the composer, on top of the transcript. Anchor it to the composer wrapper's own top edge instead, so the offset no longer depends on the --composer-reserve constant. Co-authored-by: Cursor <cursoragent@cursor.com>
130 lines
7.2 KiB
TypeScript
130 lines
7.2 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";
|
|
|
|
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, \{ id: chatNoticeToastId \}\)/);
|
|
assert.match(noticeSource, /toast\.error\(message, \{ id: chatNoticeToastId \}\)/);
|
|
assert.match(noticeSource, /toast\(message, \{ id: chatNoticeToastId \}\)/);
|
|
});
|
|
|
|
test("clearing a notice dismisses instead of showing an empty toast", () => {
|
|
const clearBranch = sourceBetween(noticeSource, "if (!message.trim())", "if (lastNotice === message) 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\) 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");
|
|
});
|
|
|
|
test("anchors the streaming scroll instead of following every token", () => {
|
|
const autoScrollEffect = sourceBetween(
|
|
pageSource,
|
|
"useEffect(() => {\n if (starterHomeVisible) return;",
|
|
"profileComplete, starterHomeVisible]);",
|
|
);
|
|
|
|
// Then: streamed tokens only move the viewport while the reader stays anchored.
|
|
assert.match(autoScrollEffect, /if \(!conversationAnchor\.anchored\) return/);
|
|
const scrollGuard = autoScrollEffect.indexOf("if (!conversationAnchor.anchored) return");
|
|
const scrollCall = autoScrollEffect.indexOf("container.scrollTo(");
|
|
assert.ok(scrollGuard >= 0 && scrollGuard < scrollCall);
|
|
});
|
|
|
|
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(pageSource, " updateSession(sessionId, () => userSession);", " setDraft(\"\");");
|
|
assert.match(sendBlock, /conversationAnchor\.anchorToLatest\(\)/);
|
|
});
|
|
|
|
test("offers an accessible jump-to-latest control while reading history", () => {
|
|
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", "</button>");
|
|
|
|
assert.match(pageSource, /const jumpToLatestVisible = !rectificationSurfaceOpen[\s\S]*?&& !conversationAnchor\.anchored/);
|
|
assert.match(jumpControl, /type="button"/);
|
|
assert.match(jumpControl, /aria-label="跳到最新"/);
|
|
assert.match(jumpControl, /focus-visible:ring-3/);
|
|
assert.match(jumpControl, /min-h-11/);
|
|
assert.match(jumpControl, /min-w-11/);
|
|
assert.match(jumpControl, /onClick=\{conversationAnchor\.anchorToLatest\}/);
|
|
});
|
|
|
|
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");
|
|
const jumpControl = sourceBetween(composerWrap, "{jumpToLatestVisible && (", "</button>");
|
|
assert.match(jumpControl, /absolute inset-x-0 bottom-full/);
|
|
assert.doesNotMatch(jumpControl, /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);
|
|
});
|