diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 899de26e..5d5b2da2 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -27,6 +27,7 @@ import { flattenAssistantTextParts, suggestPlanTitleFromText } from '@/lib/messa import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta'; import { useMessageTTS } from '@/hooks/useMessageTTS'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { useFactsFit } from './useFactsFit'; import { useConfigStore } from '@/stores/useConfigStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { TextSelectionMenu } from './TextSelectionMenu'; @@ -2172,6 +2173,8 @@ const AssistantMessageBody = React.memo(({ // pointer path; these rows call the same handlers, minus the transient // copied/sharing states that only make sense on a button that stays put. const [actionSheetOpen, setActionSheetOpen] = React.useState(false); + const footerFactsRef = React.useRef(null); + useFactsFit(footerFactsRef); const { isPlaying: isFooterTTSPlaying, play: playFooterTTS, stop: stopFooterTTS } = useMessageTTS(); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); const canOpenMessagePreview = !isMiniChatSurface && !isMobile && !isVSCode; @@ -2441,65 +2444,58 @@ const AssistantMessageBody = React.memo(({ narrows: first the time, then the agent, then the thinking effort. Model and duration never leave — the model only truncates once those two alone stop fitting. */} -
- - {footerModelName ? ( - - {footerHasLogo && footerLogoSrc ? ( - - ) : ( - - )} - {footerModelName} - - ) : null} - - {/* Thinking effort and agent drop out from the tail — the - agent first — when the row runs short. */} - - {footerVariant && !['default', 'none'].includes(footerVariant.toLowerCase()) ? ( - - · - {footerVariant[0].toLowerCase() + footerVariant.slice(1)} - - ) : null} - {footerAgentName ? ( - - · - {footerAgentName} - - ) : null} - +
+ {footerModelName ? ( + + {footerHasLogo && footerLogoSrc ? ( + + ) : ( + + )} + {footerModelName} + + ) : null} + {footerVariant && !['default', 'none'].includes(footerVariant.toLowerCase()) ? ( + + · + {footerVariant[0].toLowerCase() + footerVariant.slice(1)} + + ) : null} + {footerAgentName ? ( + + · + {footerAgentName} + + ) : null} {turnDurationText ? ( - + {footerModelName ? · : null} {turnDurationText} ) : null} - {/* Pointer surfaces keep the timestamp inline (it goes first - when space runs out); touch reads it in the actions - sheet, where nothing can push it off the row. */} + {/* Pointer surfaces keep the timestamp inline (it is the first + fact the row gives up); touch reads it in the actions sheet, + where nothing can push it off the row. */} {footerTimestamp && !(alwaysShowMessageActions || isTouchContext) ? ( - - - · - {footerTimestamp} - + + · + {footerTimestamp} ) : null}
diff --git a/packages/ui/src/components/chat/message/useFactsFit.ts b/packages/ui/src/components/chat/message/useFactsFit.ts new file mode 100644 index 00000000..e29a4ab4 --- /dev/null +++ b/packages/ui/src/components/chat/message/useFactsFit.ts @@ -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): void => { + const applyRef = React.useRef<() => void>(() => {}); + + applyRef.current = () => { + const container = ref.current; + if (!container) return; + + const model = container.querySelector('[data-fact-model]'); + if (!model) return; + + const facts = Array.from(container.querySelectorAll('[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]); +}; diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index ad74a9e6..3128449c 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -917,69 +917,19 @@ html:not(.dark) .chat-scroll { } } -/* Assistant message footer: the run's facts collapse by priority instead of - wrapping. The model and the duration always stay — the model truncates only - once even those two stop fitting — while the thinking effort, the agent and - the timestamp drop out in that order as the row narrows. The optional group - 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. */ +/* Assistant message footer: one line of facts, in priority order. Which of + them fit is decided by useFactsFit, which hides the least important ones + until the model name stops being truncated — CSS can only guess at the + model's width, and a wrapped-away fact leaves its width behind as a hole. */ .message-footer__facts { display: flex; align-items: center; min-width: 0; + flex: 1 1 auto; overflow: hidden; } -.message-footer__facts-core { - 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. */ +/* Each fact carries its own leading separator and spacing. */ .message-footer__fact { display: flex; align-items: center;