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:
Brooklyn Nicholson 2026-06-20 02:10:38 -05:00
parent f697c97e02
commit eed78d6ebb
11 changed files with 190 additions and 62 deletions

View file

@ -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,

View file

@ -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>

View file

@ -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)
},

View file

@ -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"

View file

@ -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>

View file

@ -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)
}
}
}

View file

@ -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'
)}
>

View file

@ -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>
</>

View file

@ -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
)

View file

@ -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 }

View file

@ -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. */