fix(chat): drop message footer facts by priority without leaving a hole

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.
This commit is contained in:
Bohdan Triapitsyn
2026-09-09 17:41:15 +03:00
parent fce3f174d2
commit 8cb426083b
3 changed files with 131 additions and 109 deletions
@@ -27,6 +27,7 @@ import { flattenAssistantTextParts, suggestPlanTitleFromText } from '@/lib/messa
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta'; import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
import { useMessageTTS } from '@/hooks/useMessageTTS'; import { useMessageTTS } from '@/hooks/useMessageTTS';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useFactsFit } from './useFactsFit';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { TextSelectionMenu } from './TextSelectionMenu'; import { TextSelectionMenu } from './TextSelectionMenu';
@@ -2172,6 +2173,8 @@ const AssistantMessageBody = React.memo(({
// pointer path; these rows call the same handlers, minus the transient // pointer path; these rows call the same handlers, minus the transient
// copied/sharing states that only make sense on a button that stays put. // copied/sharing states that only make sense on a button that stays put.
const [actionSheetOpen, setActionSheetOpen] = React.useState(false); const [actionSheetOpen, setActionSheetOpen] = React.useState(false);
const footerFactsRef = React.useRef<HTMLDivElement>(null);
useFactsFit(footerFactsRef);
const { isPlaying: isFooterTTSPlaying, play: playFooterTTS, stop: stopFooterTTS } = useMessageTTS(); const { isPlaying: isFooterTTSPlaying, play: playFooterTTS, stop: stopFooterTTS } = useMessageTTS();
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
const canOpenMessagePreview = !isMiniChatSurface && !isMobile && !isVSCode; const canOpenMessagePreview = !isMiniChatSurface && !isMobile && !isVSCode;
@@ -2441,65 +2444,58 @@ const AssistantMessageBody = React.memo(({
narrows: first the time, then the agent, then the thinking narrows: first the time, then the agent, then the thinking
effort. Model and duration never leave — the model only effort. Model and duration never leave — the model only
truncates once those two alone stop fitting. */} truncates once those two alone stop fitting. */}
<div className="message-footer__facts flex-1 whitespace-nowrap text-sm text-muted-foreground/60"> <div ref={footerFactsRef} className="message-footer__facts whitespace-nowrap text-sm text-muted-foreground/60">
<span className="message-footer__facts-core"> {footerModelName ? (
{footerModelName ? ( <span className="flex min-w-0 shrink items-center gap-1.5">
<span className="flex min-w-0 items-center gap-1.5"> {footerHasLogo && footerLogoSrc ? (
{footerHasLogo && footerLogoSrc ? ( <img
<img src={footerLogoSrc}
src={footerLogoSrc} alt=""
alt="" className="h-3.5 w-3.5 flex-shrink-0"
className="h-3.5 w-3.5 flex-shrink-0" style={{
style={{ filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)', }}
}} onError={handleFooterLogoError}
onError={handleFooterLogoError} />
/> ) : (
) : ( <Icon
<Icon name="brain-ai-3"
name="brain-ai-3" className="h-3.5 w-3.5 flex-shrink-0"
className="h-3.5 w-3.5 flex-shrink-0" style={{ color: `var(${getAgentColor(footerAgentName).var})` }}
style={{ color: `var(${getAgentColor(footerAgentName).var})` }} />
/> )}
)} <span data-fact-model className="truncate">{footerModelName}</span>
<span className="truncate">{footerModelName}</span> </span>
</span> ) : null}
) : null} {footerVariant && !['default', 'none'].includes(footerVariant.toLowerCase()) ? (
</span> <span data-fact-priority="3" className="message-footer__fact">
{/* Thinking effort and agent drop out from the tail — the <span className="opacity-60" aria-hidden>·</span>
agent first — when the row runs short. */} {footerVariant[0].toLowerCase() + footerVariant.slice(1)}
<span className="message-footer__facts-optional"> </span>
{footerVariant && !['default', 'none'].includes(footerVariant.toLowerCase()) ? ( ) : null}
<span className="message-footer__fact"> {footerAgentName ? (
<span className="opacity-60" aria-hidden>·</span> <span data-fact-priority="2" className="message-footer__fact">
{footerVariant[0].toLowerCase() + footerVariant.slice(1)} <span className="opacity-60" aria-hidden>·</span>
</span> {footerAgentName}
) : null} </span>
{footerAgentName ? ( ) : null}
<span className="message-footer__fact">
<span className="opacity-60" aria-hidden>·</span>
{footerAgentName}
</span>
) : null}
</span>
{turnDurationText ? ( {turnDurationText ? (
<span className="message-footer__facts-duration message-footer__fact tabular-nums"> <span className="message-footer__fact tabular-nums">
{footerModelName ? <span className="opacity-60" aria-hidden>·</span> : null} {footerModelName ? <span className="opacity-60" aria-hidden>·</span> : null}
{turnDurationText} {turnDurationText}
</span> </span>
) : null} ) : null}
{/* Pointer surfaces keep the timestamp inline (it goes first {/* Pointer surfaces keep the timestamp inline (it is the first
when space runs out); touch reads it in the actions fact the row gives up); touch reads it in the actions sheet,
sheet, where nothing can push it off the row. */} where nothing can push it off the row. */}
{footerTimestamp && !(alwaysShowMessageActions || isTouchContext) ? ( {footerTimestamp && !(alwaysShowMessageActions || isTouchContext) ? (
<span className="message-footer__facts-optional message-footer__facts-optional--drops-first"> <span
<span data-fact-priority="1"
className={cn(footerTimestampClassName, 'message-footer__fact')} className={cn(footerTimestampClassName, 'message-footer__fact')}
aria-label={`Message time: ${footerTimestamp}`} aria-label={`Message time: ${footerTimestamp}`}
> >
<span className="opacity-60" aria-hidden>·</span> <span className="opacity-60" aria-hidden>·</span>
{footerTimestamp} {footerTimestamp}
</span>
</span> </span>
) : null} ) : null}
</div> </div>
@@ -0,0 +1,76 @@
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]);
};
+6 -56
View File
@@ -917,69 +917,19 @@ html:not(.dark) .chat-scroll {
} }
} }
/* Assistant message footer: the run's facts collapse by priority instead of /* Assistant message footer: one line of facts, in priority order. Which of
wrapping. The model and the duration always stay — the model truncates only them fit is decided by useFactsFit, which hides the least important ones
once even those two stop fitting — while the thinking effort, the agent and until the model name stops being truncated — CSS can only guess at the
the timestamp drop out in that order as the row narrows. The optional group model's width, and a wrapped-away fact leaves its width behind as a hole. */
wraps its overflow onto a second line that its single-line height clips
away, so a row that exists once per message costs no measurement. */
.message-footer__facts { .message-footer__facts {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
flex: 1 1 auto;
overflow: hidden; overflow: hidden;
} }
.message-footer__facts-core { /* Each fact carries its own leading separator and spacing. */
display: flex;
align-items: center;
gap: 0.375rem;
min-width: 0;
flex: 0 1 auto;
}
.message-footer__facts-optional {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
align-items: center;
min-width: 0;
/* Takes only the width of its facts (no grow — growing would push the
duration to the far edge and leave a hole after the agent) and gives that
width up long before the model starts truncating. */
flex: 0 9999 auto;
max-height: 1.25rem;
overflow: hidden;
}
/* A hairline item holds the start of the first line so that EVERY fact can
wrap off it. Without something already on the line, the browser keeps the
first fact there and the clip slices it in half instead of dropping it. */
.message-footer__facts-optional::before {
content: '';
flex: 0 0 1px;
/* Full line height: it has to define the first line, or the facts that wrap
past it land a pixel below and slip under the clip. */
height: 1.25rem;
}
/* The timestamp gives up its room before the agent and the effort do — it is
the first fact the row can afford to lose (on touch it is not here at all:
it lives in the actions sheet, where it can never be lost). */
.message-footer__facts-optional--drops-first {
flex: 0 99999 auto;
}
/* The duration sits after the facts that can disappear and never shrinks. */
.message-footer__facts-duration {
display: flex;
align-items: center;
flex: 0 0 auto;
white-space: nowrap;
}
/* Each fact carries its own leading separator and spacing, so the group needs
no gap that would offset the zero-width holder. */
.message-footer__fact { .message-footer__fact {
display: flex; display: flex;
align-items: center; align-items: center;