From 9bdf01852ac1bfdcb65af2d75156b395750b2e08 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 23:29:05 -0500 Subject: [PATCH 1/6] feat(desktop): clamp sticky human messages to ~2 lines until hover/focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/assistant-ui/thread.tsx | 33 ++++++++++++++++++- apps/desktop/src/styles.css | 25 ++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) 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 From 06aa140fa195e10bf2647a673eee585528b598bc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 23:42:38 -0500 Subject: [PATCH 2/6] fix(desktop): inset sticky human messages with --sticky-human-top Pin user bubbles 0.75rem below the scroll top via a single token instead of flush top-0, so the sticky header doesn't sit hard against the thread edge. --- apps/desktop/src/components/assistant-ui/thread.tsx | 2 +- apps/desktop/src/styles.css | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index f802104f5ee..dbeecd526d7 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -638,7 +638,7 @@ function messageAttachmentRefs(value: unknown): string[] { function StickyHumanMessageContainer({ children }: { children: ReactNode }) { return (
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 3dd174a5244..541d0eefec0 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -272,6 +272,7 @@ --conversation-line-height: 1.125rem; --conversation-caption-line-height: 1rem; --conversation-turn-gap: 0.375rem; + --sticky-human-top: 0.75rem; --file-tree-row-height: 1.375rem; --composer-width: 48.75rem; @@ -704,6 +705,10 @@ canvas { padding-inline-start: var(--md-text-indent, 0.5rem); } +[data-slot='aui_user-message-root'] { + top: var(--sticky-human-top); +} + [data-slot='aui_user-message-root'], [data-slot='aui_edit-composer-root'] { font-size: var(--conversation-text-font-size); From 3ab783a7bb691d58eb9d0f68b1b00fae3967f840 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 23:43:25 -0500 Subject: [PATCH 3/6] chore: uptick --- apps/desktop/src/styles.css | 46 +++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 541d0eefec0..428845eefc1 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -76,8 +76,7 @@ --shadow-header: 0 0.0625rem 0 color-mix(in srgb, var(--dt-foreground) 7%, transparent), 0 0.625rem 1.5rem -1.25rem color-mix(in srgb, #000 16%, transparent); - --shadow-composer: - 0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent); + --shadow-composer: 0 0.0625rem 0.125rem color-mix(in srgb, #000 5%, transparent); --shadow-composer-focus: 0 0 0 0.125rem color-mix(in srgb, var(--dt-composer-ring) calc(10% * var(--composer-ring-strength)), transparent), 0 0 0 0.0625rem color-mix(in srgb, var(--dt-composer-ring) calc(22% * var(--composer-ring-strength)), transparent), @@ -133,15 +132,23 @@ --ui-cyan: #4c7f8c; --ui-blue: #0053fd; --ui-purple: #9e94d5; - --ui-bg-chrome: color-mix(in srgb, var(--theme-background-seed) var(--theme-mix-chrome), var(--theme-neutral-chrome)); - --ui-bg-sidebar: color-mix(in srgb, var(--theme-sidebar-seed) var(--theme-mix-sidebar), var(--theme-neutral-sidebar)); - --ui-bg-editor: color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card)); - --ui-bg-elevated: color-mix(in srgb, var(--theme-elevated-seed) var(--theme-mix-elevated), var(--theme-neutral-card)); - --ui-bg-card: color-mix( + --ui-bg-chrome: color-mix( in srgb, - var(--ui-accent) 4%, - color-mix(in srgb, var(--ui-base) 4%, transparent) + var(--theme-background-seed) var(--theme-mix-chrome), + var(--theme-neutral-chrome) ); + --ui-bg-sidebar: color-mix( + in srgb, + var(--theme-sidebar-seed) var(--theme-mix-sidebar), + var(--theme-neutral-sidebar) + ); + --ui-bg-editor: color-mix(in srgb, var(--theme-card-seed) var(--theme-mix-card), var(--theme-neutral-card)); + --ui-bg-elevated: color-mix( + in srgb, + var(--theme-elevated-seed) var(--theme-mix-elevated), + var(--theme-neutral-card) + ); + --ui-bg-card: color-mix(in srgb, var(--ui-accent) 4%, color-mix(in srgb, var(--ui-base) 4%, transparent)); --ui-bg-input: #fcfcfc; --ui-bg-primary: color-mix( in srgb, @@ -218,7 +225,11 @@ --ui-sidebar-surface-background: var(--ui-bg-sidebar); --ui-chat-surface-background: var(--ui-bg-chrome); --ui-editor-surface-background: var(--ui-bg-chrome); - --ui-chat-bubble-background: color-mix(in srgb, var(--theme-bubble-seed) var(--theme-mix-bubble), var(--theme-neutral-card)); + --ui-chat-bubble-background: color-mix( + in srgb, + var(--theme-bubble-seed) var(--theme-mix-bubble), + var(--theme-neutral-card) + ); --ui-chat-bubble-opaque-background: var(--ui-bg-editor); --ui-inline-code-background: color-mix(in srgb, #141414 5%, transparent); --ui-inline-code-border: color-mix(in srgb, #141414 8%, transparent); @@ -272,7 +283,7 @@ --conversation-line-height: 1.125rem; --conversation-caption-line-height: 1rem; --conversation-turn-gap: 0.375rem; - --sticky-human-top: 0.75rem; + --sticky-human-top: 0.23rem; --file-tree-row-height: 1.375rem; --composer-width: 48.75rem; @@ -627,7 +638,7 @@ canvas { .scrollbar-dt::-webkit-scrollbar-thumb, .scrollbar-dt *::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--dt-midground) 18%, transparent); - border-radius: 9999rem; + border-radius: 9999rem; border: 0.125rem solid transparent; background-clip: padding-box; } @@ -939,8 +950,7 @@ canvas { background: transparent !important; } -[data-slot='aui_assistant-message-content'] - > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) { +[data-slot='aui_assistant-message-content'] > :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure']) { opacity: 0.67; transition: opacity 120ms ease-out; } @@ -971,12 +981,8 @@ canvas { margin-top: 1rem; } -[data-slot='aui_assistant-message-content'] - [data-slot='aui_thinking-disclosure'] - + [data-slot='tool-block'], -[data-slot='aui_assistant-message-content'] - [data-slot='tool-block'] - + [data-slot='aui_thinking-disclosure'] { +[data-slot='aui_assistant-message-content'] [data-slot='aui_thinking-disclosure'] + [data-slot='tool-block'], +[data-slot='aui_assistant-message-content'] [data-slot='tool-block'] + [data-slot='aui_thinking-disclosure'] { margin-top: 0.75rem; } From e5472da584707c0f569851a71e8e3419ecbefd11 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 23:43:52 -0500 Subject: [PATCH 4/6] fix(desktop): drop sticky human clamp max-height transition --- apps/desktop/src/styles.css | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 428845eefc1..eaf05cb2d7c 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -729,12 +729,11 @@ canvas { 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. */ + measured content height (set in UserMessage) so hover expand uses 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'] { From 84eb5f1f891b15901cc68d93ea86abbb9bc88d65 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 23:44:06 -0500 Subject: [PATCH 5/6] fix(desktop): restore sticky human clamp transition at 0.75s --- apps/desktop/src/styles.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index eaf05cb2d7c..befdb99d279 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -729,11 +729,12 @@ canvas { 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 hover expand uses the real - height instead of overshooting the cap. */ + measured content height (set in UserMessage) so 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.75s ease; } .sticky-human-clamp[data-clamped='true'] { From cbc1d901ba447c48a587c060fce8be39cf10cbcc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 2 Jun 2026 23:44:51 -0500 Subject: [PATCH 6/6] chore: uptick --- apps/desktop/src/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index befdb99d279..eae9cb6ce88 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -734,7 +734,7 @@ canvas { .sticky-human-clamp { max-height: calc(2 * var(--dt-line-height) * var(--conversation-text-font-size) + 0.15rem); overflow: hidden; - transition: max-height 0.75s ease; + transition: max-height 0.08s cubic-bezier(0.4, 0, 0.2, 1); } .sticky-human-clamp[data-clamped='true'] {