feat: better pane management and toolbar api

This commit is contained in:
Brooklyn Nicholson
2026-05-02 15:22:18 -05:00
parent a66303eaef
commit 6dcf5bcbc0
17 changed files with 772 additions and 348 deletions
@@ -177,7 +177,7 @@ export const Intro: FC<IntroProps> = ({ personality, seed }) => {
}, [advanceFrame, frameOffset])
return (
<div className="pointer-events-none absolute inset-0 z-1 flex flex-col items-center justify-center px-[calc(var(--vsq)*50)] text-center text-muted-foreground">
<div className="pointer-events-none flex min-h-[calc(100vh-var(--titlebar-height)-var(--thread-composer-clearance)-var(--composer-shell-pad-block-end))] flex-col items-center justify-center px-[calc(var(--vsq)*50)] text-center text-muted-foreground">
<button
aria-label="Change Hermes pose"
className="pointer-events-auto mb-5 h-56 w-64 cursor-default border-0 bg-transparent p-0"
@@ -36,6 +36,17 @@ describe('preprocessMarkdown', () => {
expect(output).toContain('- **Scroll wheel** - zoom')
})
it('drops fences around a preview-only URL block', () => {
const fence = '```'
const input = ['Server is back.', '', fence, 'http://localhost:8812/', fence].join('\n')
const output = preprocessMarkdown(input)
expect(output).toContain('Server is back.')
expect(output).not.toContain('```')
expect(output).not.toContain('http://localhost:8812/')
})
it('demotes prose sentence masquerading as fence info', () => {
const input = ['```Heads up - a bunny got added', '- Pure white (`#ffffff`)', '- Ambient dropped to 0.18'].join('\n')
const output = preprocessMarkdown(input)
@@ -18,7 +18,7 @@ import {
mediaPathFromMarkdownHref
} from '@/lib/media'
import { isLikelyProseCodeBlock, isLikelyProseFence, sanitizeLanguageTag } from '@/lib/markdown-code'
import { previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets'
import { isLikelyPreviewCandidate, previewTargetFromMarkdownHref, stripPreviewTargets } from '@/lib/preview-targets'
import { cn } from '@/lib/utils'
/**
@@ -77,6 +77,15 @@ function findClosingFence(lines: string[], start: number, marker: string): numbe
return -1
}
function isPreviewOnlyFence(body: string): boolean {
const lines = body
.split('\n')
.map(line => line.trim())
.filter(Boolean)
return lines.length === 1 && isLikelyPreviewCandidate(lines[0])
}
function normalizeFenceBlocks(text: string): string {
const sourceLines = text.split('\n')
const out: string[] = []
@@ -116,6 +125,14 @@ function normalizeFenceBlocks(text: string): string {
continue
}
if (closeIndex !== -1 && isPreviewOnlyFence(body)) {
// Agents often fence a lone preview URL to make it copyable. The chat UI
// already renders a preview card for that URL, so don't show code fences.
out.push(...bodyLines)
index = closeIndex + 1
continue
}
if (closeIndex === -1) {
if (!body.trim()) {
index += 1
@@ -406,7 +423,7 @@ const MarkdownTextImpl = () => {
<li className={cn('leading-relaxed', className)} {...props} />
),
table: ({ className, ...props }: ComponentProps<'table'>) => (
<div className="w-full overflow-x-auto rounded-md border border-border">
<div className="max-w-full overflow-x-auto rounded-md border border-border">
<table
className={cn(
'w-full border-collapse text-sm [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
@@ -442,7 +459,7 @@ const MarkdownTextImpl = () => {
<StreamdownTextPrimitive
caret="block"
components={components}
containerClassName="aui-md text-foreground"
containerClassName="aui-md max-w-full overflow-hidden text-foreground"
lineNumbers={false}
mode="streaming"
parseIncompleteMarkdown
@@ -33,6 +33,7 @@ import {
useRef,
useState
} from 'react'
import spinners from 'unicode-animations'
// Scroll behavior: delegated to `use-stick-to-bottom` (StackBlitz), the
// reference implementation that powers bolt.new and several other streaming
// chat UIs. It handles everything we care about — spring-animated catch-up,
@@ -44,7 +45,6 @@ import {
// keeping `$threadScrolledUp` in sync with `isAtBottom` for the composer's
// dim-when-scrolled-away treatment.
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
import spinners from 'unicode-animations'
import { useElapsedSeconds } from '@/components/assistant-ui/activity-timer'
import { ActivityTimerText } from '@/components/assistant-ui/activity-timer-text'
@@ -115,9 +115,7 @@ export const Thread: FC<{
}> = ({ intro, loading, onBranchInNewChat, sessionKey }) => {
return (
<GeneratedImageProvider>
<ThreadPrimitive.Root className="relative grid h-full min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent">
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
<ThreadPrimitive.Root className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
<ThreadPrimitive.ViewportProvider>
{/*
* <StickToBottom> renders a wrapper <div>; <StickToBottom.Content>
@@ -140,16 +138,17 @@ export const Thread: FC<{
* content above the composer, not hidden behind it.
*/}
<StickToBottom
className="relative h-full min-h-0"
className="relative h-full min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
initial="instant"
resize="instant"
>
<ThreadScrollSync sessionKey={sessionKey} />
<StickToBottom.Content
className="mx-auto flex w-full max-w-[48rem] flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
className="mx-auto flex w-full max-w-[48rem] min-w-0 flex-col gap-3 px-4 pt-[calc(var(--vsq)*19)] sm:px-6 lg:px-8"
data-slot="aui_thread-content"
scrollClassName="overflow-y-auto overscroll-contain"
scrollClassName="overflow-x-hidden overflow-y-auto overscroll-contain"
>
<AuiIf condition={s => Boolean(intro) && s.thread.isEmpty}>{intro && <Intro {...intro} />}</AuiIf>
<ThreadPrimitive.Messages
components={{
AssistantMessage: () => <AssistantMessage onBranchInNewChat={onBranchInNewChat} />,
@@ -357,25 +356,26 @@ const ThreadScrollSync: FC<{ sessionKey?: string | null }> = ({ sessionKey }) =>
* textarea grows (multi-line input), attachments expand, or the composer
* enters a focused/expanded state.
*/
const COMPOSER_BREATHING_ROOM_PX = 20
const COMPOSER_BREATHING_ROOM_PX = 36
const DEFAULT_COMPOSER_CLEARANCE_PX = 192
const ComposerClearance: FC = () => {
const [height, setHeight] = useState<number>(() => {
// Sensible default until the observer wires up (~ 8rem).
if (typeof document === 'undefined') return 128
// Keep enough space even while the floating composer is still mounting.
if (typeof document === 'undefined') {
return DEFAULT_COMPOSER_CLEARANCE_PX
}
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
return composer ? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX : 128
return composer ? composer.getBoundingClientRect().height + COMPOSER_BREATHING_ROOM_PX : DEFAULT_COMPOSER_CLEARANCE_PX
})
useEffect(() => {
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
let composerObserver: ResizeObserver | null = null
let observedComposer: HTMLElement | null = null
if (!composer) {
return
}
const apply = () => {
const apply = (composer: HTMLElement) => {
const h = composer.getBoundingClientRect().height
setHeight(prev => {
@@ -385,11 +385,30 @@ const ComposerClearance: FC = () => {
})
}
apply()
const observer = new ResizeObserver(apply)
observer.observe(composer)
const bindComposer = () => {
const composer = document.querySelector<HTMLElement>('[data-slot="composer-root"]')
return () => observer.disconnect()
if (!composer || composer === observedComposer) {
return false
}
observedComposer = composer
apply(composer)
composerObserver?.disconnect()
composerObserver = new ResizeObserver(() => apply(composer))
composerObserver.observe(composer)
return true
}
bindComposer()
const mutationObserver = new MutationObserver(() => void bindComposer())
mutationObserver.observe(document.body, { childList: true, subtree: true })
return () => {
composerObserver?.disconnect()
mutationObserver.disconnect()
}
}, [])
return <div aria-hidden="true" className="shrink-0" style={{ height: `${height}px` }} />
@@ -435,11 +454,11 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
return (
<MessagePrimitive.Root
className="group flex w-full flex-col gap-2 self-start"
className="group flex w-full min-w-0 max-w-full flex-col gap-2 self-start overflow-hidden"
data-role="assistant"
data-slot="aui_assistant-message-root"
>
<div className="wrap-anywhere text-pretty text-foreground" data-slot="aui_assistant-message-content">
<div className="wrap-anywhere min-w-0 max-w-full overflow-hidden text-pretty text-foreground" data-slot="aui_assistant-message-content">
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
@@ -540,7 +559,7 @@ const ThinkingDisclosure: FC<{
const elapsed = useElapsedSeconds(pending)
return (
<div className="mb-3 text-sm text-muted-foreground">
<div className="mb-2 text-sm text-muted-foreground">
<button
aria-expanded={open}
className="inline-flex max-w-full items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
@@ -757,11 +776,11 @@ const UserMessage: FC = () => {
return (
<MessagePrimitive.Root
className="group flex max-w-[min(72%,34rem)] flex-col items-end gap-2 self-end"
className="group flex min-w-0 max-w-[min(72%,34rem)] flex-col items-end gap-2 self-end overflow-hidden"
data-role="user"
data-slot="aui_user-message-root"
>
<div className="wrap-anywhere whitespace-pre-line rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
<div className="wrap-anywhere max-w-full overflow-hidden whitespace-pre-line rounded-2xl border border-[color-mix(in_srgb,var(--dt-user-bubble-border)_78%,transparent)] bg-[color-mix(in_srgb,var(--dt-user-bubble)_94%,transparent)] px-3 py-2 leading-[1.48] text-foreground/95">
<MessagePrimitive.Parts components={{ Text: DirectiveText }} />
</div>
<div className="min-h-6">
@@ -165,7 +165,7 @@ export const ToolFallback = ({ toolCallId, toolName, args, result }: ToolCallMes
}, [isPending])
return (
<div className="mb-3 mt-1 text-sm text-muted-foreground">
<div className="mb-2 mt-2 text-sm text-muted-foreground">
<button
className="inline-grid max-w-full grid-cols-[0.75rem_minmax(0,auto)_minmax(0,1fr)_auto_auto] items-center gap-1 rounded-md py-0.5 pr-1 text-left text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => setOpen(v => !v)}