feat: better pane management and toolbar api
This commit is contained in:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user