fix(web): 无用户行钉顶、闲置不上留白,并修会话列表 tsc 两错
Independent Staging Quality Gate / validate (push) Failing after 6m27s
Independent Staging Quality Gate / publish (push) Skipped

This commit is contained in:
jesse-ux
2026-09-17 22:25:53 +08:00
parent b5e16b96f4
commit cc1a89802e
8 changed files with 135 additions and 16 deletions
@@ -76,9 +76,26 @@ export function applyFollowBottom(
element.scrollTop = element.scrollHeight;
}
export function lastTurnTail(container: HTMLElement): HTMLElement | null {
return (container.querySelector(".message-list > :last-child .message-assistant")
?? container.querySelector(".message-assistant:last-of-type")
?? container.querySelector(".message-user:last-of-type")) as HTMLElement | null;
}
export function turnHeadHeightForSpacer(head: HTMLElement | null, tail: HTMLElement | null): number {
if (!head) return 0;
// The last assistant carries the min-height spacer. Measuring it as the turn
// head feeds the spacer back into itself (BUG-931).
if (tail && head === tail) return 0;
return head.offsetHeight;
}
export function applyTurnSpacer(container: HTMLElement, head: HTMLElement | null): void {
container.style.setProperty("--conversation-viewport", `${container.clientHeight}px`);
container.style.setProperty("--latest-turn-head-height", `${head?.offsetHeight ?? 0}px`);
container.style.setProperty(
"--latest-turn-head-height",
`${turnHeadHeightForSpacer(head, lastTurnTail(container))}px`,
);
}
export function clearTurnSpacer(container: HTMLElement): void {
@@ -86,12 +103,6 @@ export function clearTurnSpacer(container: HTMLElement): void {
container.style.removeProperty("--latest-turn-head-height");
}
export function lastTurnTail(container: HTMLElement): HTMLElement | null {
return (container.querySelector(".message-list > :last-child .message-assistant")
?? container.querySelector(".message-assistant:last-of-type")
?? container.querySelector(".message-user:last-of-type")) as HTMLElement | null;
}
export function turnTailOverflow(container: HTMLElement): number {
const last = lastTurnTail(container);
if (!last) return conversationDistanceFromBottom(container);
@@ -198,8 +209,8 @@ export function useConversationScrollAnchor(
}
if (anchoredRef.current) {
element.scrollTop = element.scrollHeight;
} else {
applyTurnSpacer(element, pinnedHeadRef.current ?? resolveTurnHead(element));
} else if (pinnedHeadRef.current) {
applyTurnSpacer(element, pinnedHeadRef.current);
}
setLatestBelowFold(latestContentBelowFold(turnTailOverflow(element)));
};
@@ -240,7 +251,9 @@ export function useConversationScrollAnchor(
const element = container.current;
holdUnpin.current = false;
shouldLand.current = false;
pinnedHeadRef.current = null;
if (element) {
clearTurnSpacer(element);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
element.scrollTo({ top: element.scrollHeight, behavior: reduceMotion ? "auto" : "smooth" });
setLatestBelowFold(false);
+1 -1
View File
@@ -27,7 +27,7 @@ export function toSidebarSessionRow(
): SidebarSession {
return {
id: session.id,
title: sessionSidebarTitle(session, library),
title: sessionSidebarTitle(session),
subtitle: sessionSidebarSubtitle(session, library) || null,
pinned: session.pinned,
archived: Boolean(session.archivedAt),
+4 -1
View File
@@ -86,7 +86,10 @@ test("sidebar titles drop the chart-name prefix and only subtitle others", () =>
archivedAt: null,
messagesHydrated: true,
};
assert.equal(sessionSidebarTitle(base, library), "半年内换工作时机");
// 原值: sessionSidebarTitle(base, library)
// 新值: sessionSidebarTitle(base)
// 原因: sessionSidebarTitle 只吃 session;第二参是 e4e73f56 的 TS 错,挡住 tsc / next build
assert.equal(sessionSidebarTitle(base), "半年内换工作时机");
assert.equal(sessionSidebarSubtitle(base, library), "9月14日 12:14");
const other = { ...base, chartProfileId: "other-1", chartProfileName: "对方", chartProfileRole: "other" as const };
assert.equal(sessionSidebarSubtitle(other, library), "9月14日 12:14 · 资料已删除 · 对方");
@@ -235,6 +235,49 @@ test("turn spacer uses conversation viewport variables and is not sticky", () =>
assert.equal(scroller.style.getPropertyValue("--latest-turn-head-height"), "");
});
test("a turn with no user row pins the new assistant and uses a full-viewport spacer", () => {
const priorUser = fakeRow("message-user", 8, 40);
const priorAssistant = fakeRow("message-assistant", 56, 80);
const laterUser = fakeRow("message-user", 152, 40);
const laterAssistant = fakeRow("message-assistant", 200, 80);
const incoming = fakeRow("message-assistant", 480, 80);
const scroller = fakeScroller([priorUser, priorAssistant, laterUser, laterAssistant, incoming], 600);
const head = resolveTurnHead(asElement(scroller));
assert.equal(head, asElement(incoming));
applyTurnSpacer(asElement(scroller), head);
assert.equal(scroller.style.getPropertyValue("--latest-turn-head-height"), "0px");
scroller.scrollTop = pinTurnScrollTop(asElement(scroller), asElement(incoming), 16);
assert.equal(scroller.scrollTop, incoming.offsetTop - 16);
const start = scroller.scrollTop;
for (let i = 0; i < 3; i += 1) {
incoming.offsetHeight += 80;
scroller.scrollHeight += 80;
applyFollowBottom(scroller, false);
}
assert.equal(scroller.scrollTop, start);
});
test("idle history does not write a spacer when the reader has only scrolled up", () => {
// 原值: follow() 在 !anchored 时无条件 applyTurnSpacer
// 新值: 只在 pinnedHeadRef 非空时写留白
// 原因: BUG-932 闲置会话上滑后子元素尺寸变化不应把最后一条助手行撑开
assert.match(
anchorSource,
/if \(anchoredRef\.current\) \{\s*element\.scrollTop = element\.scrollHeight;\s*\} else if \(pinnedHeadRef\.current\) \{\s*applyTurnSpacer\(element, pinnedHeadRef\.current\);/,
);
assert.match(anchorSource, /pinnedHeadRef\.current = null;\s*if \(element\) \{\s*clearTurnSpacer\(element\);/);
const user = fakeRow("message-user", 8, 40);
const assistant = fakeRow("message-assistant", 56, 900);
const scroller = fakeScroller([user, assistant], 600);
scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
scroller.scrollTop = Math.max(0, scroller.scrollTop - 400);
const start = scroller.scrollTop;
assistant.offsetHeight += 30;
scroller.scrollHeight += 30;
assert.equal(scroller.style.getPropertyValue("--conversation-viewport"), "");
assert.equal(Math.abs(scroller.scrollTop - start) <= 1, true);
});
test("consultation send and rectification new turns call pinLatestTurn", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const pins = chat.match(/conversationAnchor\.pinLatestTurn\(\)/g) ?? [];