fix(chat): keep latest-turn actions under the reply (BUG-1042)

The BUG-930 pin spacer sat on the last assistant row, pushing its sibling
.message-actions (and follow-ups / delivery card) below half a screen of
empty space. Move the same min-height onto the last turn's entry
(`:last-child:has(.message-assistant)`) with align-content: start so the
rectification grid wrap does not stretch its rows. Hook logic unchanged.

Records BUG-1043 / BUG-1044 (pre-existing, investigating) found during
browser verification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-26 10:19:00 +08:00
co-authored by Claude Opus 5.5
parent ac224cef6a
commit 83cce7ad6a
13 changed files with 189 additions and 7 deletions
@@ -218,16 +218,21 @@ test("resolveTurnHead prefers the user row unless this turn has no user", () =>
});
test("turn spacer uses conversation viewport variables and is not sticky", () => {
// 原值:选择器 `.conversation .message-list > :last-child .message-assistant`(留白加在助手行上)。
// 新值:`.conversation .message-list > :last-child:has(.message-assistant)`(留白加在整轮外层,含 .message-actions / 追问建议 / 交付卡)。
// 原因:BUG-1042——助手行的 min-height 把其后的点赞 / 踩 / 复制 / 重试一排顶到留白下面;计算式与变量口径不变(BUG-930 钉顶保持)。
assert.match(
globalsSource,
/\.conversation \.message-list > :last-child \.message-assistant \{[^}]*min-height: calc\(var\(--conversation-viewport, 0px\) - var\(--latest-turn-head-height, 0px\)\)/,
/\.conversation \.message-list > :last-child:has\(\.message-assistant\) \{[^}]*min-height: calc\(var\(--conversation-viewport, 0px\) - var\(--latest-turn-head-height, 0px\)\)/,
);
const spacerRule = sourceBetween(
globalsSource,
".conversation .message-list > :last-child .message-assistant {",
".conversation .message-list > :last-child:has(.message-assistant) {",
"}",
);
assert.doesNotMatch(spacerRule, /sticky/);
// The rectification wrap is a grid: without this its rows stretch into the spacer.
assert.match(spacerRule, /align-content:\s*start/);
assert.doesNotMatch(anchorSource, /position:\s*sticky/);
const scroller = fakeScroller([], 400);
@@ -240,6 +245,20 @@ test("turn spacer uses conversation viewport variables and is not sticky", () =>
assert.equal(scroller.style.getPropertyValue("--latest-turn-head-height"), "");
});
test("the turn spacer trails the actions row instead of sitting between text and actions (BUG-1042)", () => {
// No rule may put the viewport spacer on the assistant row itself.
assert.doesNotMatch(globalsSource, /\.message-assistant\s*\{[^}]*--conversation-viewport/);
// Both surfaces render the actions inside the same last-child entry, after the row.
const transcript = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8");
const entry = sourceBetween(transcript, '<div className="message-entry">', "</div>");
assert.ok(entry.indexOf("<ChatMessageRow") < entry.indexOf("<ChatMessageActions"));
assert.ok(entry.indexOf("<ChatMessageActions") < entry.indexOf("<ConversationFollowUps"));
const rectEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.ok(rectEntry.indexOf("<ChatMessageRow") < rectEntry.indexOf("<ChatMessageActions"));
const rectChat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(rectChat, /className="rectification-message-wrap rectification-message-entry"\s*>\s*<RectificationMessageEntry/);
});
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);