diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 7b03c9991e0..dbeecd526d7 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' @@ -637,7 +638,7 @@ function messageAttachmentRefs(value: unknown): string[] { function StickyHumanMessageContainer({ children }: { children: ReactNode }) { return (
@@ -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..eae9cb6ce88 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,6 +283,7 @@ --conversation-line-height: 1.125rem; --conversation-caption-line-height: 1rem; --conversation-turn-gap: 0.375rem; + --sticky-human-top: 0.23rem; --file-tree-row-height: 1.375rem; --composer-width: 48.75rem; @@ -626,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; } @@ -704,11 +716,40 @@ 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); } +/* 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 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.08s cubic-bezier(0.4, 0, 0.2, 1); +} + +.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 @@ -909,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; } @@ -941,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; }