mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): the placeholder comes back when you clear the composer
Select-all + Cut emptied the text and left the composer blank — no draft, no prompt. Delete had the same hole. The placeholder is painted on `:empty`, and a cleared editor keeps a scaffolding <br> so the contenteditable can't collapse to a sliver. Those two facts collide: the moment the break lands the editor has a child, `:empty` goes false, and the prompt never comes back. CSS can't infer emptiness on its own either. A text node is invisible to selectors, so `one<br>` and a lone `<br>` are the same shape — a structural rule like `:has(> br:only-child)` paints the placeholder straight over the user's text. The code that empties the editor is what knows, so it marks the root and the condition reads `:is(:empty, [data-empty])`. Both writers that reshape that root maintain the marker through one helper: the normalizer, and renderComposerContents for a restored draft or an undo. The message-edit composer shares the slot and the rule, so it takes the same shared class instead of drifting on its own copy. #74815 fixed the draft this stashed; the placeholder is a separate seam.
This commit is contained in:
parent
cc4cab2f59
commit
0b4bd3c7c7
5 changed files with 103 additions and 4 deletions
|
|
@ -1,6 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { composerPlainText, normalizeComposerEditorDom, RICH_INPUT_SLOT } from './rich-editor'
|
||||
import {
|
||||
composerPlainText,
|
||||
normalizeComposerEditorDom,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
|
||||
function editor(): HTMLDivElement {
|
||||
const el = document.createElement('div')
|
||||
|
|
@ -69,3 +74,66 @@ describe('an emptied composer reads as empty', () => {
|
|||
expect(composerPlainText(el)).toBe('one\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
/** The rule the stylesheet paints the placeholder with. `:empty` alone goes
|
||||
* false the instant the scaffolding <br> lands. */
|
||||
const PLACEHOLDER_SHOWS = ':is(:empty, [data-empty])'
|
||||
|
||||
describe('an emptied composer shows its placeholder again', () => {
|
||||
it('advertises emptiness once the scaffolding break is in place', () => {
|
||||
expect(emptied().matches(PLACEHOLDER_SHOWS)).toBe(true)
|
||||
})
|
||||
|
||||
it('advertises emptiness for a truly childless editor', () => {
|
||||
expect(editor().matches(PLACEHOLDER_SHOWS)).toBe(true)
|
||||
})
|
||||
|
||||
it('stops advertising it once something is typed', () => {
|
||||
const el = emptied()
|
||||
|
||||
el.replaceChildren(document.createTextNode('hi'))
|
||||
normalizeComposerEditorDom(el)
|
||||
|
||||
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
|
||||
})
|
||||
|
||||
// A text node is invisible to selectors, so `one<br>` and `<br>` are the same
|
||||
// shape to any pure-CSS rule (`:has(> br:only-child)` matches both and paints
|
||||
// the placeholder straight over the user's text). The DOM writer has to say.
|
||||
it('does not advertise emptiness for a trailing break after text', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(document.createTextNode('one'), document.createElement('br'))
|
||||
normalizeComposerEditorDom(el)
|
||||
|
||||
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not advertise emptiness for a Shift+Enter break between text', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(document.createTextNode('one'), document.createElement('br'), document.createTextNode('two'))
|
||||
normalizeComposerEditorDom(el)
|
||||
|
||||
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
|
||||
})
|
||||
|
||||
// Repainting from text (restored draft, undo, completion rebuild) is the
|
||||
// other writer that reshapes the editor root — it must not strand the marker.
|
||||
it('drops the marker when a draft is painted back in', () => {
|
||||
const el = emptied()
|
||||
|
||||
renderComposerContents(el, 'restored draft')
|
||||
|
||||
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
|
||||
})
|
||||
|
||||
it('re-advertises emptiness when a draft is painted back out', () => {
|
||||
const el = editor()
|
||||
|
||||
renderComposerContents(el, 'temporary')
|
||||
renderComposerContents(el, '')
|
||||
|
||||
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import { ActionBadges } from './micro-actions'
|
|||
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
COMPOSER_PLACEHOLDER_CLASS,
|
||||
composerPlainText,
|
||||
deleteChipBeforeCaret,
|
||||
deleteSelectionInEditor,
|
||||
|
|
@ -946,7 +947,7 @@ export function ChatBar({
|
|||
autoCorrect="off"
|
||||
className={cn(
|
||||
'min-h-[1.625rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
COMPOSER_PLACEHOLDER_CLASS,
|
||||
'**:data-ref-text:cursor-default',
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
|
||||
|
|
|
|||
|
|
@ -21,6 +21,28 @@ import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
|
|||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
||||
/** Paints `data-placeholder` while the editor is empty.
|
||||
*
|
||||
* `:empty` can't be the whole test: a cleared editor keeps a scaffolding <br>
|
||||
* so the contenteditable doesn't collapse, and that break makes `:empty`
|
||||
* false. Nor can CSS infer it on its own — a text node is invisible to
|
||||
* selectors, so `one<br>` and a lone `<br>` are the same shape, and
|
||||
* `:has(> br:only-child)` would paint the placeholder straight over the
|
||||
* user's text. The code that empties the editor is what knows, so it marks it.
|
||||
*
|
||||
* @see markEditorEmptiness */
|
||||
export const COMPOSER_PLACEHOLDER_CLASS =
|
||||
'[&:is(:empty,[data-empty])]:before:content-[attr(data-placeholder)] [&:is(:empty,[data-empty])]:before:text-muted-foreground/60'
|
||||
|
||||
/** Keep that marker in step with the editor root's contents. */
|
||||
export function markEditorEmptiness(editor: HTMLElement) {
|
||||
if (editor.childNodes.length === 0) {
|
||||
editor.dataset.empty = ''
|
||||
} else {
|
||||
delete editor.dataset.empty
|
||||
}
|
||||
}
|
||||
|
||||
/** @see referenceRe — the shared pattern every surface recognises a reference
|
||||
* with. Module-level `/g` regexes carry `lastIndex`, so call sites reset it. */
|
||||
export const REF_RE = referenceRe()
|
||||
|
|
@ -165,6 +187,10 @@ export function renderComposerContents(target: HTMLElement, text: string, option
|
|||
// typed (`/wor`) and must stay editable. Callers repainting inert text (a
|
||||
// restored draft, a sent message opened for edit) pass `trailingCommitted`.
|
||||
appendComposerContents(target, text, options)
|
||||
|
||||
// The other writer that reshapes the editor root: painting a restored draft
|
||||
// in clears the marker, clearing back to '' sets it.
|
||||
markEditorEmptiness(target)
|
||||
}
|
||||
|
||||
/** Caret range when the selection lives inside `editor`; else null. */
|
||||
|
|
@ -681,6 +707,9 @@ export function normalizeComposerEditorDom(editor: HTMLElement) {
|
|||
// composer to appear as a tiny dot/pixel. Ensure there's always at least
|
||||
// one <br> so the element maintains intrinsic height. The CSS min-height
|
||||
// is a belt; the <br> is suspenders — together they prevent the shrink.
|
||||
// That break is also why emptiness has to be marked, not inferred.
|
||||
markEditorEmptiness(editor)
|
||||
|
||||
if (editor.childNodes.length === 0) {
|
||||
editor.appendChild(document.createElement('br'))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
} from '@/app/chat/composer/inline-refs'
|
||||
import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs'
|
||||
import {
|
||||
COMPOSER_PLACEHOLDER_CLASS,
|
||||
composerPlainText,
|
||||
insertComposerContentsAtCaret,
|
||||
placeCaretEnd,
|
||||
|
|
@ -772,7 +773,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
autoCorrect="off"
|
||||
className={cn(
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
COMPOSER_PLACEHOLDER_CLASS,
|
||||
'**:data-ref-text:cursor-default',
|
||||
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1436,7 +1436,7 @@ text-* variant utilities. */ .btn-arc {
|
|||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
[data-slot='composer-rich-input']:empty::before {
|
||||
[data-slot='composer-rich-input']:is(:empty, [data-empty])::before {
|
||||
color: var(--ui-text-tertiary) !important;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue