From eed78d6ebb51353c93224945f8130b9143da153c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 20 Jun 2026 02:10:38 -0500 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20composer=20popout=20polish=20?= =?UTF-8?q?=E2=80=94=20peel-off=20placement,=20panels,=20chip=20editing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../app/chat/composer/completion-drawer.tsx | 21 ++- .../src/app/chat/composer/context-menu.tsx | 12 +- .../chat/composer/hooks/use-popout-drag.ts | 23 ++-- apps/desktop/src/app/chat/composer/index.tsx | 27 +++- .../src/app/chat/composer/model-pill.tsx | 9 +- .../src/app/chat/composer/rich-editor.ts | 121 +++++++++++++++--- .../src/app/chat/composer/trigger-popover.tsx | 4 +- .../src/app/shell/model-menu-panel.tsx | 5 +- .../src/components/chat/composer-dock.ts | 22 ++-- apps/desktop/src/store/composer-popout.ts | 4 + apps/desktop/src/styles.css | 4 - 11 files changed, 190 insertions(+), 62 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/completion-drawer.tsx b/apps/desktop/src/app/chat/composer/completion-drawer.tsx index 021af0bda..1f07c235b 100644 --- a/apps/desktop/src/app/chat/composer/completion-drawer.tsx +++ b/apps/desktop/src/app/chat/composer/completion-drawer.tsx @@ -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, diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx index 3866e2814..5b22fca95 100644 --- a/apps/desktop/src/app/chat/composer/context-menu.tsx +++ b/apps/desktop/src/app/chat/composer/context-menu.tsx @@ -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({ - - + + {c.attachLabel} @@ -142,7 +143,12 @@ function PromptSnippetsDialog({ onInsertText, onOpenChange, open }: PromptSnippe export function ContextMenuItem({ children, disabled, icon: Icon, onSelect }: ContextMenuItemProps) { return ( - + // Override font size + highlight to match the / · @ completion rows exactly. + {children} diff --git a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts index 650089e5d..3333995e3 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-popout-drag.ts @@ -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) }, diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index dc3d46d2e..1427a21b0 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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" diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index 8e28ac969..53a76db1b 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -91,7 +91,14 @@ export function ModelPill({ return ( - diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index f74d2ee5b..2587202c9 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -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
s / trailing
s — none of which our own + * rendering emits (we use text nodes +
+ chips). Real
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
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) + } } } diff --git a/apps/desktop/src/app/chat/composer/trigger-popover.tsx b/apps/desktop/src/app/chat/composer/trigger-popover.tsx index 6f08a7e03..da52f1dd0 100644 --- a/apps/desktop/src/app/chat/composer/trigger-popover.tsx +++ b/apps/desktop/src/app/chat/composer/trigger-popover.tsx @@ -137,7 +137,7 @@ export function ComposerTriggerPopover({ floating tooltip. */} @@ -146,7 +146,7 @@ export function ComposerTriggerPopover({ {description && ( diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 577d98f14..6f785e8fa 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -207,7 +207,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model {copy.noModels} ) : ( -
+
{groups.map(group => ( {group.provider.name} @@ -310,7 +310,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model void refreshModels() }} > - + {copy.refreshModels} @@ -318,6 +318,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')} onSelect={() => setModelVisibilityOpen(true)} > + {copy.editModels} diff --git a/apps/desktop/src/components/chat/composer-dock.ts b/apps/desktop/src/components/chat/composer-dock.ts index 8eb2b24e7..ca02cdea8 100644 --- a/apps/desktop/src/components/chat/composer-dock.ts +++ b/apps/desktop/src/components/chat/composer-dock.ts @@ -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 +) diff --git a/apps/desktop/src/store/composer-popout.ts b/apps/desktop/src/store/composer-popout.ts index d51ae46af..9327cdce5 100644 --- a/apps/desktop/src/store/composer-popout.ts +++ b/apps/desktop/src/store/composer-popout.ts @@ -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 } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 6cfdbef61..36ef859ce 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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. */