The footer collapsed its facts by wrapping the overflow onto a clipped second line. The fact disappeared but its width did not: the row kept the space it had been given, so a dropped agent left a hole between the effort and the duration — visible on both the phone and the desktop. Decide it by measuring instead. The row cannot overflow, because the model name truncates, so "did it fit" is read off the model: while the model is cut, hide the next fact by priority — timestamp, then agent, then thinking effort — and measure again. Hidden facts leave the layout entirely, the model truncates only when there is nothing left to give, and the row stays tight at every width. CSS could not do this. Container-width breakpoints know nothing about the model's length and dropped facts that still fitted; wrap-and-clip is what left the hole in the first place. Testing: package type-check, lint, the message test suite under the isolated runner; drove a real chat in a browser (430px drops the timestamp and the agent, 330px also the effort, restoring the width brings them all back, no gaps in any state). The ResizeObserver path is unverified — this preview browser delivers no observer callbacks at all — while the window resize path is.
77 lines
2.8 KiB
TypeScript
77 lines
2.8 KiB
TypeScript
import React from 'react';
|
|
|
|
/**
|
|
* Keeps the assistant footer's facts on one line by dropping the least
|
|
* important ones until the rest fit.
|
|
*
|
|
* The row itself never overflows — the model name truncates instead — so "did
|
|
* it fit" is read off the model, and facts marked `data-fact-priority` are
|
|
* hidden in that order (1 goes first) until the model is whole again. That is
|
|
* the rule the design asks for: the timestamp goes, then the agent, then the
|
|
* thinking effort, and only a row with nothing left to give truncates the
|
|
* model.
|
|
*
|
|
* CSS alone cannot do this. Hiding on container-width breakpoints guesses at
|
|
* the model's length and drops facts that would have fitted, and wrapping the
|
|
* overflow onto a clipped second line leaves the dropped fact's width behind as
|
|
* a hole in the middle of the row.
|
|
*
|
|
* The measuring is deliberately blunt: a handful of layout reads after each
|
|
* render of a row that exists once per turn, and only for the turns on screen.
|
|
*/
|
|
export const useFactsFit = (ref: React.RefObject<HTMLElement | null>): void => {
|
|
const applyRef = React.useRef<() => void>(() => {});
|
|
|
|
applyRef.current = () => {
|
|
const container = ref.current;
|
|
if (!container) return;
|
|
|
|
const model = container.querySelector<HTMLElement>('[data-fact-model]');
|
|
if (!model) return;
|
|
|
|
const facts = Array.from(container.querySelectorAll<HTMLElement>('[data-fact-priority]'))
|
|
.sort((left, right) => Number(left.dataset.factPriority) - Number(right.dataset.factPriority));
|
|
|
|
for (const fact of facts) fact.style.display = '';
|
|
|
|
const modelFits = () => model.scrollWidth <= model.clientWidth + 1;
|
|
for (const fact of facts) {
|
|
if (modelFits()) return;
|
|
fact.style.display = 'none';
|
|
}
|
|
};
|
|
|
|
// After every render: the facts change while a turn finishes (the duration
|
|
// keeps counting), and that changes what fits without changing any box the
|
|
// observer below watches.
|
|
React.useLayoutEffect(() => {
|
|
applyRef.current();
|
|
});
|
|
|
|
React.useLayoutEffect(() => {
|
|
const container = ref.current;
|
|
if (!container) return;
|
|
|
|
let inCallback = false;
|
|
const refit = () => {
|
|
// Hiding a fact never resizes the row (its width comes from the layout
|
|
// above it), but guard the re-entry anyway.
|
|
if (inCallback) return;
|
|
inCallback = true;
|
|
applyRef.current();
|
|
inCallback = false;
|
|
};
|
|
|
|
// The window covers the common cases (a desktop window resized, a phone
|
|
// rotated); the observer covers the ones that leave the window alone —
|
|
// a sidebar opening, a panel dragged wider.
|
|
window.addEventListener('resize', refit);
|
|
const observer = new ResizeObserver(refit);
|
|
observer.observe(container);
|
|
return () => {
|
|
window.removeEventListener('resize', refit);
|
|
observer.disconnect();
|
|
};
|
|
}, [ref]);
|
|
};
|