@@ -420,46 +420,99 @@ function ExpandablePrompt({
420420 children : string ;
421421 lines : 2 | 4 ;
422422} ) {
423+ // The prompt is truncated by hand — not with -webkit-line-clamp — so the
424+ // "more" toggle can sit inline right after the ellipsis on the last visible
425+ // line, like "...prompt…more". A hidden copy of the full text is measured to
426+ // find how much fits, leaving room for the toggle; the visible body renders
427+ // the cut. Measuring the full text (not the visible, already-cut text) keeps
428+ // the ResizeObserver stable instead of oscillating as content swaps.
423429 const observerRef = useRef < ResizeObserver | null > ( null ) ;
424430 const [ expanded , setExpanded ] = useState ( false ) ;
425- const [ truncated , setTruncated ] = useState ( false ) ;
431+ const [ cut , setCut ] = useState < string | null > ( null ) ;
426432
427433 const measureRef = useCallback (
428- ( body : HTMLDivElement | null ) => {
434+ ( measure : HTMLDivElement | null ) => {
429435 observerRef . current ?. disconnect ( ) ;
430436 observerRef . current = null ;
431- if ( ! body || expanded ) return ;
432- const measure = ( ) => setTruncated ( body . scrollHeight > body . clientHeight ) ;
433- measure ( ) ;
434- const observer = new ResizeObserver ( measure ) ;
435- observer . observe ( body ) ;
437+ if ( ! measure || expanded ) return ;
438+
439+ const compute = ( ) => {
440+ const lineHeight = parseFloat ( getComputedStyle ( measure ) . lineHeight ) ;
441+ const maxHeight = lineHeight * lines ;
442+ if ( measure . scrollHeight <= maxHeight + 0.5 ) {
443+ setCut ( null ) ;
444+ return ;
445+ }
446+ // Find the longest prefix that still fits in `lines` once "…more" is
447+ // appended — so the toggle can sit inline right after the ellipsis on the
448+ // last line. We probe by swapping the measure's text node to "prefix…more"
449+ // and reading scrollHeight (no per-line geometry), then restore it so the
450+ // next resize re-measures against the uncut prompt. `children` is the
451+ // source of truth (and a dep below) so a polled prompt update re-measures
452+ // even when its rendered size is unchanged.
453+ const text = measure . firstChild as Text ;
454+ const fits = ( end : number ) => {
455+ text . nodeValue = `${ children . slice ( 0 , end ) . trimEnd ( ) } …more` ;
456+ return measure . scrollHeight <= maxHeight + 0.5 ;
457+ } ;
458+ let lo = 0 ;
459+ let hi = children . length ;
460+ let best = 0 ;
461+ while ( lo <= hi ) {
462+ const mid = ( lo + hi ) >> 1 ;
463+ if ( fits ( mid ) ) {
464+ best = mid ;
465+ lo = mid + 1 ;
466+ } else {
467+ hi = mid - 1 ;
468+ }
469+ }
470+ text . nodeValue = children ;
471+ // Even when no full character fits alongside "…more" (best === 0, only at
472+ // extreme narrow widths), still cut so the toggle shows and the prompt
473+ // stays expandable instead of silently clipped.
474+ setCut ( `${ children . slice ( 0 , best ) . trimEnd ( ) } …` ) ;
475+ } ;
476+
477+ compute ( ) ;
478+ const observer = new ResizeObserver ( compute ) ;
479+ observer . observe ( measure ) ;
436480 observerRef . current = observer ;
437481 } ,
438- [ expanded ] ,
482+ [ children , expanded , lines ] ,
439483 ) ;
440484
485+ const truncated = cut !== null ;
486+ const displayText = expanded || ! truncated ? children : cut ;
487+
488+ const clampClass = lines === 2 ? "max-h-[2lh]" : "max-h-[4lh]" ;
489+
441490 return (
442- < div >
443- < ThreadItemBody
444- ref = { measureRef }
445- className = { cn (
446- "wrap-break-word whitespace-pre-wrap" ,
447- ! expanded && ( lines === 2 ? "line-clamp-2" : "line-clamp-4" ) ,
448- ) }
491+ < ThreadItemBody className = "wrap-break-word relative overflow-hidden whitespace-pre-line" >
492+ < div
493+ aria-hidden
494+ className = "pointer-events-none invisible absolute top-0 right-0 left-0"
449495 >
450- { children }
451- </ ThreadItemBody >
452- { ( truncated || expanded ) && (
453- < button
454- type = "button"
455- aria-expanded = { expanded }
456- className = "text-muted-foreground text-xs underline underline-offset-2 hover:text-foreground"
457- onClick = { ( ) => setExpanded ( ( value ) => ! value ) }
458- >
459- { expanded ? "less" : "more" }
460- </ button >
461- ) }
462- </ div >
496+ < div ref = { measureRef } className = "wrap-break-word whitespace-pre-line" >
497+ { children }
498+ </ div >
499+ </ div >
500+ < div
501+ className = { cn ( ! expanded && clampClass , ! expanded && "overflow-hidden" ) }
502+ >
503+ { displayText }
504+ { truncated && (
505+ < button
506+ type = "button"
507+ aria-expanded = { expanded }
508+ className = "pl-1 text-muted-foreground text-xs underline underline-offset-2 hover:text-foreground"
509+ onClick = { ( ) => setExpanded ( ( value ) => ! value ) }
510+ >
511+ { expanded ? "less" : "more" }
512+ </ button >
513+ ) }
514+ </ div >
515+ </ ThreadItemBody >
463516 ) ;
464517}
465518
0 commit comments