fix(desktop): composer popout polish — peel-off placement, panels, chip editing
- Peel-off undock drops the floating composer under the cursor (centered horizontally, preserving the vertical grab offset) instead of snapping to the docked corner. - Unify the / · @ · ? completion drawer and the attach (+) menu onto one shared glassy panel primitive (composerPanelCard): smallest theme font, hairline border, nous shadow; floats off the composer, inset from the left. - Directive chips: Backspace removes the chip + its auto-inserted trailing space atomically (no orphaned space), and a phantom trailing block left by contenteditable no longer falsely expands the composer to two rows. - Model picker: scroll area capped at max(150px, 30dvh); footer rows aligned (matching icons, dropped a redundant margin). - Composer focus shifts the border ~15% toward foreground (no fill change); input is cursor-text; trimmed control icon/button sizes.
This commit is contained in:
parent
f697c97e02
commit
eed78d6ebb
11 changed files with 190 additions and 62 deletions
|
|
@ -2,21 +2,20 @@ import type { Unstable_TriggerAdapter } from '@assistant-ui/core'
|
|||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { composerFusedDockCard } from '@/components/chat/composer-dock'
|
||||
import { composerPanelCard } from '@/components/chat/composer-dock'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Same docked chrome as the queue/status stack, but its own thing: a narrow,
|
||||
// left-aligned card (not full width) that fuses to the composer's edge instead
|
||||
// of floating above it. `left-1` matches the stack's `mx-1` inset; the negative
|
||||
// margin overlaps the seam so the composer's (now-transparent) edge border reads
|
||||
// as shared. Fused (opaque) fill — the composer surface swaps to the same fill
|
||||
// while a drawer is open, so the two paint as one panel.
|
||||
const DRAWER_SHELL =
|
||||
'absolute left-1 z-50 w-80 max-w-[calc(100%-0.5rem)] max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain p-1 text-xs text-popover-foreground'
|
||||
// A standalone glassy panel floating just off the composer edge, inset from the
|
||||
// left. Skin is the shared composerPanelCard (also used by the attach menu).
|
||||
const DRAWER_SHELL = cn(
|
||||
'absolute left-2 z-50 w-80 max-w-[calc(100%-1rem)] max-h-[min(22rem,calc(100vh-8rem))]',
|
||||
'overflow-y-auto overscroll-contain p-1 text-popover-foreground',
|
||||
composerPanelCard
|
||||
)
|
||||
|
||||
export const COMPLETION_DRAWER_CLASS = cn(DRAWER_SHELL, 'bottom-full -mb-[9px]', composerFusedDockCard('top'))
|
||||
export const COMPLETION_DRAWER_CLASS = cn(DRAWER_SHELL, 'bottom-full mb-1')
|
||||
|
||||
export const COMPLETION_DRAWER_BELOW_CLASS = cn(DRAWER_SHELL, 'top-full -mt-[9px]', composerFusedDockCard('bottom'))
|
||||
export const COMPLETION_DRAWER_BELOW_CLASS = cn(DRAWER_SHELL, 'top-full mt-1')
|
||||
|
||||
export function ComposerCompletionDrawer({
|
||||
adapter,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
import { composerPanelCard } from '@/components/chat/composer-dock'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
|
|
@ -57,8 +58,8 @@ export function ContextMenu({
|
|||
<Codicon name="add" size="0.875rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
<DropdownMenuContent align="start" className={cn('w-60', composerPanelCard)} side="top" sideOffset={6}>
|
||||
<DropdownMenuLabel className="px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)">
|
||||
{c.attachLabel}
|
||||
</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
|
|
@ -142,7 +143,12 @@ function PromptSnippetsDialog({ onInsertText, onOpenChange, open }: PromptSnippe
|
|||
|
||||
export function ContextMenuItem({ children, disabled, icon: Icon, onSelect }: ContextMenuItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
|
||||
// Override font size + highlight to match the / · @ completion rows exactly.
|
||||
<DropdownMenuItem
|
||||
className="text-[length:var(--conversation-tool-font-size)] focus:bg-(--ui-bg-tertiary)"
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<Icon />
|
||||
<span>{children}</span>
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from 'react'
|
||||
|
||||
import type { PopoutPosition } from '@/store/composer-popout'
|
||||
import { setComposerPopoutPosition } from '@/store/composer-popout'
|
||||
import { POPOUT_WIDTH_REM, setComposerPopoutPosition } from '@/store/composer-popout'
|
||||
|
||||
// Floating surface long-press before it becomes draggable (the 5px platform drags
|
||||
// instantly; this only covers grabbing the composer body itself).
|
||||
|
|
@ -67,13 +67,6 @@ function isFloatDragPlatform(target: EventTarget | null) {
|
|||
return gestureTargetOk(target)
|
||||
}
|
||||
|
||||
function positionFromRect(rect: DOMRect): PopoutPosition {
|
||||
return {
|
||||
bottom: window.innerHeight - rect.bottom,
|
||||
right: window.innerWidth - rect.right
|
||||
}
|
||||
}
|
||||
|
||||
/** 0 (far) → 1 (inside the dock zone). Drives both the dock glow and the
|
||||
* release-to-dock test (which fires at proximity 1). */
|
||||
function dockProximityOf(rect: DOMRect) {
|
||||
|
|
@ -154,7 +147,19 @@ export function useComposerPopoutGestures({
|
|||
return
|
||||
}
|
||||
|
||||
const next = positionFromRect(composer.getBoundingClientRect())
|
||||
// The docked composer is full-width; the floating one is compact. Center it
|
||||
// horizontally on the cursor (the docked grab-X is meaningless at the new
|
||||
// width), but preserve the vertical grab offset so the pointer keeps its
|
||||
// spot (grab the top → stay at the top).
|
||||
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
|
||||
const rect = composer.getBoundingClientRect()
|
||||
const boxWidth = POPOUT_WIDTH_REM * rem
|
||||
const grabY = Math.min(Math.max(0, state.startY - rect.top), rect.height)
|
||||
const next: PopoutPosition = {
|
||||
bottom: window.innerHeight - (clientY - grabY + rect.height),
|
||||
right: window.innerWidth - clientX - boxWidth / 2
|
||||
}
|
||||
|
||||
onPopOutRef.current()
|
||||
beginFloatDrag(state, clientX, clientY, next)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import {
|
|||
isBrowsingHistory,
|
||||
resetBrowseState
|
||||
} from '@/store/composer-input-history'
|
||||
import { $composerPopoutPosition, $composerPoppedOut, setComposerPoppedOut } from '@/store/composer-popout'
|
||||
import { $composerPopoutPosition, $composerPoppedOut, POPOUT_WIDTH_REM, setComposerPoppedOut } from '@/store/composer-popout'
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
enqueueQueuedPrompt,
|
||||
|
|
@ -88,6 +88,7 @@ import {
|
|||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteChipBeforeCaret,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
|
|
@ -441,7 +442,10 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
if (draft.includes('\n')) {
|
||||
// Only a non-trailing newline forces an immediate expand. A trailing newline
|
||||
// (or phantom \n from contenteditable junk) is left to the ResizeObserver,
|
||||
// which expands only when the editor's real height actually grows.
|
||||
if (draft.trimEnd().includes('\n')) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [draft, expanded])
|
||||
|
|
@ -890,6 +894,22 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
// Plain Backspace right after a directive chip: remove the chip + its
|
||||
// auto-inserted trailing space as one unit, so deleting a directive never
|
||||
// leaves an orphaned space. (Modified backspaces stay native.)
|
||||
if (
|
||||
event.key === 'Backspace' &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
deleteChipBeforeCaret(event.currentTarget)
|
||||
) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
|
||||
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
|
||||
if (
|
||||
|
|
@ -1930,7 +1950,7 @@ export function ChatBar({
|
|||
bottom: `${popoutPosition.bottom}px`,
|
||||
right: `${popoutPosition.right}px`,
|
||||
// A compact one-sentence width when floating.
|
||||
['--composer-popout-width' as string]: '19.5rem'
|
||||
['--composer-popout-width' as string]: `${POPOUT_WIDTH_REM}rem`
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
|
@ -1995,7 +2015,6 @@ export function ChatBar({
|
|||
className={cn(
|
||||
'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
'group-has-data-[state=open]/composer:border-t-transparent',
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
data-slot="composer-surface"
|
||||
|
|
|
|||
|
|
@ -91,7 +91,14 @@ export function ModelPill({
|
|||
return (
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-label={title} className={pillClass} disabled={disabled} title={title} type="button" variant="ghost">
|
||||
<Button
|
||||
aria-label={title}
|
||||
className={pillClass}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
|
|
|||
|
|
@ -172,6 +172,60 @@ export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Backspace at a collapsed caret immediately after a chip: delete the chip AND
|
||||
* the single trailing space we auto-insert after it, atomically — so removing a
|
||||
* directive never strands an orphaned space (the contenteditable-driven cleanup
|
||||
* was unreliable). Returns whether it ran. */
|
||||
export function deleteChipBeforeCaret(editor: HTMLElement): boolean {
|
||||
const hit = composerSelectionRange(editor)
|
||||
|
||||
if (!hit || !hit.range.collapsed) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { startContainer, startOffset } = hit.range
|
||||
let chip: ChildNode | null = null
|
||||
|
||||
if (startContainer === editor) {
|
||||
chip = startOffset > 0 ? editor.childNodes[startOffset - 1] : null
|
||||
} else if (startContainer.nodeType === Node.TEXT_NODE && startOffset === 0) {
|
||||
chip = startContainer.previousSibling
|
||||
}
|
||||
|
||||
if (chip?.nodeType !== Node.ELEMENT_NODE || !(chip as HTMLElement).dataset.refText) {
|
||||
return false
|
||||
}
|
||||
|
||||
const after = chip.nextSibling
|
||||
chip.remove()
|
||||
|
||||
// Drop the auto-inserted trailing space; keep any real following text.
|
||||
if (after?.nodeType === Node.TEXT_NODE) {
|
||||
const text = after.textContent ?? ''
|
||||
|
||||
if (text === ' ') {
|
||||
after.remove()
|
||||
} else if (text.startsWith(' ')) {
|
||||
after.textContent = text.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
const caret = document.createRange()
|
||||
|
||||
if (after?.isConnected) {
|
||||
caret.setStartBefore(after)
|
||||
} else {
|
||||
caret.selectNodeContents(editor)
|
||||
caret.collapse(false)
|
||||
}
|
||||
|
||||
caret.collapse(true)
|
||||
hit.selection.removeAllRanges()
|
||||
hit.selection.addRange(caret)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Remove a non-collapsed selection in-editor. Skips collapsed carets so word/
|
||||
* line delete (Opt/Cmd+Backspace) stays native. Returns whether anything ran. */
|
||||
export function deleteSelectionInEditor(editor: HTMLElement) {
|
||||
|
|
@ -242,35 +296,68 @@ export function placeCaretEnd(element: HTMLElement) {
|
|||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
/** Drop contenteditable junk that serializes as `\n` and falsely expands the composer. */
|
||||
export function normalizeComposerEditorDom(editor: HTMLElement) {
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
|
||||
return
|
||||
/** Nothing but a break / whitespace (recursively) — i.e. no real text or chip. */
|
||||
function isBlankNode(node: ChildNode | null): boolean {
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (node.nodeName === 'BR') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return !(node.textContent || '').trim()
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement
|
||||
|
||||
return !el.dataset.refText && Array.from(el.childNodes).every(isBlankNode)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/** Drop contenteditable junk that serializes as `\n` and falsely expands the
|
||||
* composer. Editing around a contenteditable=false chip makes Chromium wrap the
|
||||
* remainder in stray block <div>s / trailing <br>s — none of which our own
|
||||
* rendering emits (we use text nodes + <br> + chips). Real <br> line breaks
|
||||
* (Shift+Enter, which sit after actual text) are preserved. */
|
||||
export function normalizeComposerEditorDom(editor: HTMLElement) {
|
||||
// A trailing block wrapper holding only a break/whitespace is the phantom
|
||||
// "new line" Chromium adds after a chip on backspace — drop it.
|
||||
const tailBlock = editor.lastChild as HTMLElement | null
|
||||
|
||||
if (
|
||||
tailBlock?.nodeType === Node.ELEMENT_NODE &&
|
||||
(tailBlock.tagName === 'DIV' || tailBlock.tagName === 'P') &&
|
||||
isBlankNode(tailBlock)
|
||||
) {
|
||||
editor.removeChild(tailBlock)
|
||||
}
|
||||
|
||||
// Unwrap a lone block wrapper back to inline content.
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeType === Node.ELEMENT_NODE) {
|
||||
const wrapper = editor.firstChild as HTMLElement
|
||||
|
||||
if (wrapper.tagName === 'DIV' && wrapper.dataset.slot !== RICH_INPUT_SLOT) {
|
||||
if ((wrapper.tagName === 'DIV' || wrapper.tagName === 'P') && wrapper.dataset.slot !== RICH_INPUT_SLOT) {
|
||||
editor.replaceChildren(...Array.from(wrapper.childNodes))
|
||||
}
|
||||
}
|
||||
|
||||
// A trailing <br> right after a chip / only whitespace is a phantom line.
|
||||
const last = editor.lastChild
|
||||
|
||||
if (last?.nodeName !== 'BR') {
|
||||
return
|
||||
}
|
||||
if (last?.nodeName === 'BR') {
|
||||
let prev: ChildNode | null = last.previousSibling
|
||||
|
||||
let prev: ChildNode | null = last.previousSibling
|
||||
while (prev?.nodeType === Node.TEXT_NODE && !(prev.textContent || '').trim()) {
|
||||
prev = prev.previousSibling
|
||||
}
|
||||
|
||||
while (prev?.nodeType === Node.TEXT_NODE && !(prev.textContent || '').trim()) {
|
||||
prev = prev.previousSibling
|
||||
}
|
||||
|
||||
if ((prev as HTMLElement | null)?.dataset.refText) {
|
||||
editor.removeChild(last)
|
||||
if (!prev || (prev as HTMLElement).dataset?.refText) {
|
||||
editor.removeChild(last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export function ComposerTriggerPopover({
|
|||
floating tooltip. */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.8125rem] font-medium leading-snug text-foreground',
|
||||
'font-medium leading-snug text-foreground',
|
||||
active ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
>
|
||||
|
|
@ -146,7 +146,7 @@ export function ComposerTriggerPopover({
|
|||
{description && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[0.6875rem] leading-snug text-(--ui-text-tertiary)',
|
||||
'leading-snug text-(--ui-text-tertiary)',
|
||||
active ? 'whitespace-normal break-words' : 'truncate'
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||
{copy.noModels}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className="max-h-80 overflow-y-auto py-0.5">
|
||||
<div className="max-h-[max(150px,30dvh)] overflow-y-auto py-0.5">
|
||||
{groups.map(group => (
|
||||
<DropdownMenuGroup className="py-0.5" key={group.provider.slug}>
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{group.provider.name}</DropdownMenuLabel>
|
||||
|
|
@ -310,7 +310,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||
void refreshModels()
|
||||
}}
|
||||
>
|
||||
<Codicon className={cn('mr-1.5', refreshing && 'animate-spin')} name="sync" size="0.75rem" />
|
||||
<Codicon className={cn(refreshing && 'animate-spin')} name="sync" size="0.75rem" />
|
||||
{copy.refreshModels}
|
||||
</DropdownMenuItem>
|
||||
|
||||
|
|
@ -318,6 +318,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
|||
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
|
||||
onSelect={() => setModelVisibilityOpen(true)}
|
||||
>
|
||||
<Codicon name="settings-gear" size="0.75rem" />
|
||||
{copy.editModels}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* The composer surface and everything docked to it (slash·@ popover, `?` help)
|
||||
* paint ONE shared `--composer-fill` var. The state ladder (rest / scrolled /
|
||||
* focused / drawer-open) lives in styles.css on `[data-slot='composer-root']`,
|
||||
* so the two layers can never disagree — drawer-open forces an opaque fill via
|
||||
* `:has()`, because translucent glass sampling different backdrops (thread vs
|
||||
* fade gradient) renders as different colors even with identical tints.
|
||||
* The composer surface and the status/queue stack paint ONE shared
|
||||
* `--composer-fill` var. The state ladder (rest / scrolled) lives in styles.css
|
||||
* on `[data-slot='composer-root']`, so the layers can never disagree.
|
||||
*/
|
||||
export const composerFill = 'bg-(--composer-fill)'
|
||||
|
||||
|
|
@ -26,6 +23,13 @@ const composerDockEdge = (edge: 'bottom' | 'top') =>
|
|||
export const composerDockCard = (edge: 'bottom' | 'top' = 'top') =>
|
||||
cn(composerDockEdge(edge), composerFill, composerSurfaceGlass)
|
||||
|
||||
/** Fused docked card — completion drawers. Shares `--composer-fill` with the
|
||||
* composer surface, which goes opaque while a drawer is open. */
|
||||
export const composerFusedDockCard = (edge: 'bottom' | 'top' = 'top') => cn(composerDockEdge(edge), composerFill)
|
||||
/** Floating composer panel skin — the `/`·`@`·`?` completion drawer and the
|
||||
* attach (`+`) menu. Glassy translucent card, hairline border, full radius,
|
||||
* smallest type, soft nous shadow. Uses an explicit fill (not `--composer-fill`)
|
||||
* so it renders identically whether mounted inside the composer or portaled out
|
||||
* of it. Visual skin only — consumers add their own size/position/padding. */
|
||||
export const composerPanelCard = cn(
|
||||
'rounded-2xl border border-border/65 shadow-nous text-[length:var(--conversation-tool-font-size)]',
|
||||
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
|
||||
composerSurfaceGlass
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ export interface PopoutPosition {
|
|||
right: number
|
||||
}
|
||||
|
||||
// Floating composer width (rem). Shared by the inline style that sets
|
||||
// --composer-popout-width and the peel-off drag math (to center it on the cursor).
|
||||
export const POPOUT_WIDTH_REM = 19.5
|
||||
|
||||
// Default pop-out placement: tucked into the bottom-right of the thread, clear
|
||||
// of the window chrome. Matches the brief's "default to the right bottom".
|
||||
const DEFAULT_POSITION: PopoutPosition = { bottom: 24, right: 24 }
|
||||
|
|
|
|||
|
|
@ -1102,10 +1102,6 @@ canvas {
|
|||
--composer-fill: color-mix(in srgb, var(--dt-card) 48%, transparent);
|
||||
}
|
||||
|
||||
[data-slot='composer-root']:has([data-slot='composer-completion-drawer']) {
|
||||
--composer-fill: color-mix(in srgb, var(--dt-card) 90%, var(--dt-background));
|
||||
}
|
||||
|
||||
/* Tool/thinking blocks now live at message-text alignment (no leading
|
||||
chevron column to escape into), so their headers and bodies share a
|
||||
common left edge with the model's text. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue