diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 7b03c9991e0..f802104f5ee 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -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(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. - +
+
+ +
+
)} ) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index e84c4f5eec6..3dd174a5244 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -709,6 +709,31 @@ canvas { font-size: var(--conversation-text-font-size); } +/* Sticky human bubbles clamp to ~2 lines with a soft bottom fade so a long + prompt doesn't dominate the viewport while you read the response stuck + beneath it. The clamp lifts on hover / focus (clicking the bubble opens the + edit composer, which already shows the full text). --human-msg-full is the + measured content height (set in UserMessage) so the expand/collapse animates + to the real height instead of overshooting the cap. */ +.sticky-human-clamp { + max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem); + overflow: hidden; + transition: max-height 0.2s ease; +} + +.sticky-human-clamp[data-clamped='true'] { + -webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent); + mask-image: linear-gradient(to bottom, #000 55%, transparent); +} + +.composer-human-message:hover .sticky-human-clamp, +.composer-human-message:focus-within .sticky-human-clamp { + max-height: min(var(--human-msg-full, 24rem), 24rem); + overflow-y: auto; + -webkit-mask-image: none; + mask-image: none; +} + /* The thread renders items in natural document flow (padding spacers, not transforms) and @tanstack/react-virtual already adjusts scrollTop itself when an off-screen turn is measured and its real height differs from the