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:
co-authored by
Claude Opus 5.5
parent
ac224cef6a
commit
83cce7ad6a
+1
-1
@@ -257,7 +257,7 @@ Both chat surfaces pin a new turn at its head instead of following the last stre
|
||||
|
||||
- **Head:** the user row of this turn. When this turn has no user row (rectification choice tap, opening, auto-continue) the head is the new assistant row.
|
||||
- **Pin:** on send (and on those rectification new-turn entries) the scroller places that row at the top with a `space-4` gap and then holds still while the reply grows downward.
|
||||
- **Spacer:** the last assistant row's `min-height` is `calc(var(--conversation-viewport) - var(--latest-turn-head-height))`, both variables written onto the scroller by `useConversationScrollAnchor`. Not sticky, and `.conversation`'s `padding-bottom` is unchanged. The spacer lasts until the next send; switching sessions clears it.
|
||||
- **Spacer:** the last turn's entry — the `.message-list` child that holds the assistant row, its `.message-actions`, follow-up chips and (rectification) delivery card — gets `min-height: calc(var(--conversation-viewport) - var(--latest-turn-head-height))` plus `align-content: start`, both variables written onto the scroller by `useConversationScrollAnchor`. The spacer trails the whole turn: the actions row sits right under the text at its own `margin: -4px 0 8px`, and the empty space comes after it (BUG-1042; the assistant row itself never carries the spacer — that pushed the actions half a screen down). `align-content: start` keeps the rectification wrap's grid rows from stretching into the space. Not sticky, and `.conversation`'s `padding-bottom` is unchanged. The spacer lasts until the next send; switching sessions clears it.
|
||||
- **History:** opening another session still lands on the newest content once.
|
||||
- **Jump to latest:** if the growing turn (or the reader scrolling) leaves more than 96px below the fold, the shared chip appears; pressing it sticks to the bottom for the rest of the turn.
|
||||
|
||||
|
||||
@@ -1234,9 +1234,14 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
|
||||
.message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; }
|
||||
/* Last turn fills the remaining viewport so a short reply can still pin its
|
||||
head at the top. Variables are written by useConversationScrollAnchor. */
|
||||
.conversation .message-list > :last-child .message-assistant {
|
||||
head at the top. Variables are written by useConversationScrollAnchor.
|
||||
The spacer sits on the whole turn entry (assistant row + .message-actions +
|
||||
follow-ups / delivery card), not on the assistant row, so the actions stay
|
||||
under the text and the empty space trails them (BUG-1042). align-content
|
||||
keeps the rectification wrap's grid rows from stretching into that space. */
|
||||
.conversation .message-list > :last-child:has(.message-assistant) {
|
||||
min-height: calc(var(--conversation-viewport, 0px) - var(--latest-turn-head-height, 0px));
|
||||
align-content: start;
|
||||
}
|
||||
.message-thinking {
|
||||
margin: 0 0 var(--space-3);
|
||||
|
||||
@@ -84,8 +84,11 @@ export function lastTurnTail(container: HTMLElement): 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).
|
||||
// The last turn's entry, which starts at this assistant row, carries the
|
||||
// min-height spacer (BUG-1042). Subtracting the row's own height would leave
|
||||
// that entry one row short of the viewport; before BUG-1042 the row itself
|
||||
// carried the spacer and measuring it fed the spacer back into itself
|
||||
// (BUG-931). Either way the head-is-tail case subtracts nothing.
|
||||
if (tail && head === tail) return 0;
|
||||
return head.offsetHeight;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user