diff --git a/apps/desktop/src/app/chat/composer/empty-composer.test.ts b/apps/desktop/src/app/chat/composer/empty-composer.test.ts index 4e5146c174d..fe8305d2c45 100644 --- a/apps/desktop/src/app/chat/composer/empty-composer.test.ts +++ b/apps/desktop/src/app/chat/composer/empty-composer.test.ts @@ -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
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
` and `
` 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) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index b05b563c528..74717759228 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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' diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index d7ef6c30820..0c9c7098870 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -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
+ * 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
` and a lone `
` 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
so the element maintains intrinsic height. The CSS min-height // is a belt; the
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')) } diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 16ec52cf0d2..1e6ec792d01 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -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 = ({ 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]' )} diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 95eeebe29ac..4017644307b 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -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; }