Merge pull request #987 from ouroboros-agent/claude/ui-smoke-followup-20260916

UI smoke follow-up: anchor a card crossing the viewport top on what follows it; re-pin two rows
This commit is contained in:
Ouroboros 2026-09-16 16:40:46 +03:00 • committed by GitHub
commit 31d62c3998
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 72 additions and 5 deletions

View file

@ -73,7 +73,10 @@ def test_ui_project_completion_pointer_keeps_project_history_scoped(direct_serve
summary.wait_for(state="visible", timeout=30_000)
summary_text = summary.inner_text()
assert target_label in summary_text
assert "Release shipped." in summary_text
# The Main row is a pointer: one status word and the shared cause sentence,
# never the answer excerpt (owner Q5=A); the answer stays in the Project.
assert "Open the Project for details." in summary_text
assert "Release shipped." not in summary_text
assert "Open Project ↗" in summary_text
assert project["id"] not in summary_text

View file

@ -291,9 +291,11 @@ def test_task_status_stays_factual_in_main_and_project_chat(
).last
child_line_toggle.focus()
child_line_toggle.press("Enter")
assert "Reason: delegated_route_unavailable" in child.locator(
":scope > [data-live-timeline]"
).inner_text()
# The card says the cause in the owner's words; the machine code stays on
# the record half, never behind a `Reason:` label (PR #970).
child_timeline = child.locator(":scope > [data-live-timeline]").inner_text()
assert "The selected tool route was unavailable; the parent can continue." in child_timeline
assert "Reason:" not in child_timeline
assert "delegated_route_unavailable" not in child_summary.inner_text()
child_line_toggle.press("Space")
child_summary.press("Space")

View file

@ -387,7 +387,7 @@ export function createTimelineAnchors({ messagesDiv, liveCardRecords }) {
&& !node.classList.contains('chat-load-older')
);
const messagesRect = messagesDiv.getBoundingClientRect();
const topNode = nodes.find((item) => {
let topNode = nodes.find((item) => {
const rect = item.getBoundingClientRect();
return rect.bottom > messagesRect.top && rect.top < messagesRect.bottom;
}) || null;
@ -436,6 +436,18 @@ export function createTimelineAnchors({ messagesDiv, liveCardRecords }) {
.filter(({ rect }) => rect.top <= messagesRect.top && rect.bottom > messagesRect.top)
.sort((a, b) => b.depth - a.depth);
node = belowTop[0]?.node || crossing[0]?.node || topNode;
if (node === topNode && topNode.getBoundingClientRect().top < messagesRect.top) {
// The card's visible part holds nothing anchorable (a wait row, a
// block without work): keep the reader's view of what FOLLOWS the
// card. Pinning the card's own top, far above the viewport, would let
// the card's shrink or growth move the content the reader is on.
const following = nodes.find((item) => {
if (item === topNode) return false;
const rect = item.getBoundingClientRect();
return rect.top >= messagesRect.top && rect.top < messagesRect.bottom;
});
if (following) { topNode = following; node = following; }
}
}
const cardChain = [];

View file

@ -327,3 +327,53 @@ test('a reader inside Reviews stays anchored when content grows above the attemp
assert.equal(anchors.restoreVisibleTimelineAnchor(anchor), true);
assert.equal(messages.scrollTop, 1120);
});
test('a card crossing the top with nothing anchorable inside keeps the reader on what follows it', () => {
// A wait-only block above the viewport (no title, no actions, no timeline
// line) used to anchor on its own top; when a wait update shrank the block,
// the messages the reader was on moved up. The reader's view of what
// FOLLOWS the card is the anchor there.
const box = (top, bottom) => ({ top, bottom, left: 0, right: 600, width: 600, height: bottom - top });
const makeNode = (name, bounds, classes = [], selectors = []) => {
const node = { name, dataset: {}, isConnected: true, parentElement: null, bounds,
classNames: new Set(classes), selectors: new Set(selectors) };
node.classList = { contains: (value) => node.classNames.has(value) };
node.getBoundingClientRect = () => node.bounds;
node.getClientRects = () => [node.bounds];
node.matches = (selector) => node.selectors.has(selector);
node.contains = (candidate) => {
for (let current = candidate; current; current = current.parentElement) if (current === node) return true;
return false;
};
node.closest = (selector) => {
for (let current = node; current; current = current.parentElement) {
if (selector === '.chat-live-card' && current.classNames?.has('chat-live-card')) return current;
}
return null;
};
node.querySelectorAll = () => [];
return node;
};
const messages = makeNode('messages', box(0, 900));
messages.scrollTop = 300;
const card = makeNode('card', box(-244, 124), ['chat-live-card']);
card.dataset.taskId = 'wait-task';
card.parentElement = messages;
const summary = makeNode('summary', box(-243, -200), [], ['[data-live-summary-button]']);
summary.parentElement = card;
card.querySelectorAll = (selector) => (selector.includes('[data-live-summary-button]') ? [summary] : []);
const bubble = makeNode('bubble', box(124, 300), ['chat-bubble']);
bubble.dataset.ts = '2026-09-06T21:02:00Z';
bubble.parentElement = messages;
messages.children = [card, bubble];
messages.contains = (candidate) => candidate === card || candidate === bubble || card.contains(candidate);
const anchors = createTimelineAnchors({ messagesDiv: messages, liveCardRecords: new Map([['wait-task', { root: card }]]) });
const anchor = anchors.captureVisibleTimelineAnchor();
assert.equal(anchor.node, bubble, 'the following message is the anchor, not the card top');
// The wait update shrank the card by 40 px: everything below moved up.
card.bounds = box(-244, 84);
bubble.bounds = box(84, 260);
assert.equal(anchors.restoreVisibleTimelineAnchor(anchor), true);
assert.equal(messages.scrollTop, 260, 'the reader stays on the same message');
});