feat(desktop): clamp sticky human messages to ~2 lines until hover/focus
Long user prompts stick to the top of the thread while the response streams beneath them, so a multi-line prompt could eat most of the viewport. Clamp the read-only human bubble's text to ~2 lines with a soft bottom fade; the clamp lifts on hover or keyboard focus, and clicking the bubble still opens the edit composer (which shows the full text). Short messages are untouched — no clamp, no fade. Overflow is measured on an unclamped inner wrapper so the ResizeObserver only fires on real content/width changes, not every frame while the outer max-height animates open; the measured height feeds --human-msg-full so expand/collapse animate to the true height instead of overshooting the cap.
This commit is contained in:
@@ -74,6 +74,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
|
||||
@@ -685,6 +686,32 @@ const UserMessage: FC<{
|
||||
return messageAttachmentRefs(custom.attachmentRefs)
|
||||
})
|
||||
|
||||
// Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
|
||||
// doesn't dominate the viewport while the response streams underneath; the
|
||||
// clamp lifts on hover / focus (see styles.css). We measure the *unclamped*
|
||||
// inner wrapper so the ResizeObserver only fires on real content / width
|
||||
// changes, not on every frame while the outer max-height animates open.
|
||||
const clampInnerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [bodyClamped, setBodyClamped] = useState(false)
|
||||
|
||||
const measureClamp = useCallback(() => {
|
||||
const inner = clampInnerRef.current
|
||||
const outer = inner?.parentElement
|
||||
|
||||
if (!inner || !outer) {
|
||||
return
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(inner)
|
||||
const lineHeight = parseFloat(styles.lineHeight) || 1.5 * parseFloat(styles.fontSize) || 20
|
||||
const fullHeight = inner.scrollHeight
|
||||
|
||||
outer.style.setProperty('--human-msg-full', `${fullHeight}px`)
|
||||
setBodyClamped(fullHeight > lineHeight * 2 + 1)
|
||||
}, [])
|
||||
|
||||
useResizeObserver(measureClamp, clampInnerRef)
|
||||
|
||||
const hasBody = messageText.trim().length > 0
|
||||
const isLatestUser = messageId === latestUserId
|
||||
const showStop = isLatestUser && threadRunning && Boolean(onCancel)
|
||||
@@ -707,7 +734,11 @@ const UserMessage: FC<{
|
||||
// Render the user's text through a minimal markdown pipeline:
|
||||
// backtick `code` and ``` fenced ``` blocks, with directive chips
|
||||
// (`@file:` etc.) still resolved inside the plain-text spans.
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
<div className="sticky-human-clamp" data-clamped={bodyClamped ? 'true' : undefined}>
|
||||
<div ref={clampInnerRef}>
|
||||
<UserMessageText className="wrap-anywhere" text={messageText} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user