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