feat(desktop): hydrate commands when repainting inert composer text

Two repaint sites hand the editor text that is finished rather than
mid-keystroke: the main composer's programmatic draft writes (restore,
insert, history recall) and the inline edit composer opening a sent
message. Both now render with `trailingCommitted`, so a command ending
that text chips instead of reading as a half-typed token — the edit
composer in particular showed plain text for a message the transcript
had just rendered with a pill.

Regression tests cover the paste path: a command ending the paste, one
named mid-prose beside a ref, a path left alone, a paste landing against
a word, and one landing after an existing chip.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 01:39:29 -05:00
parent ccca952b92
commit 422ecfe1da
3 changed files with 84 additions and 4 deletions

View file

@ -121,7 +121,7 @@ export function useComposerDraft({
const editor = editorRef.current
if (editor) {
renderComposerContents(editor, next)
renderComposerContents(editor, next, { trailingCommitted: true })
placeCaretEnd(editor)
}
@ -265,7 +265,7 @@ export function useComposerDraft({
const editor = editorRef.current
if (editor && document.activeElement !== editor && composerPlainText(editor) !== text) {
renderComposerContents(editor, text)
renderComposerContents(editor, text, { trailingCommitted: true })
}
if (isBrowsingHistory(sessionIdRef.current) || queueEditRef.current) {

View file

@ -230,6 +230,82 @@ describe('insertComposerContentsAtCaret', () => {
editor.remove()
})
// A directive typed by hand chips; the same directive pasted has to chip too,
// or copy/pasting a prompt silently drops every command in it.
it('chips a pasted slash command, including one that ends the paste', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/some-skill')
// Committed pills carry the trailing space the typed path appends, so a
// later full re-render doesn't read the token as half-typed.
expect(composerPlainText(editor)).toBe('/some-skill ')
editor.remove()
})
it('chips a skill named mid-paste alongside a ref', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, 'clean @file:`a.ts` with /some-skill then ship')
expect(editor.querySelectorAll('[data-slash-kind]').length).toBe(1)
expect(editor.querySelectorAll('[data-ref-kind="file"]').length).toBe(1)
expect(composerPlainText(editor)).toBe('clean @file:`a.ts` with /some-skill then ship')
editor.remove()
})
it('leaves a pasted path alone — /usr/local is not a command', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, 'see /usr/local/bin and /goal ship it')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('see /usr/local/bin and /goal ship it')
editor.remove()
})
it('does not chip a command pasted against a word — foo/clean is not a command', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'foo'
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('foo/some-skill')
editor.remove()
})
it('chips a command pasted right after an existing chip', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(refChipElement('file', '`a.ts`'))
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, '/some-skill')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
editor.remove()
})
})
describe('replaceBeforeCaret', () => {

View file

@ -168,7 +168,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
const editor = editorRef.current
if (editor) {
renderComposerContents(editor, next)
renderComposerContents(editor, next, { trailingCommitted: true })
placeCaretEnd(editor)
}
@ -187,7 +187,11 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
editor &&
(editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft))
) {
renderComposerContents(editor, draft)
// Inert by construction — this repaints on mount or when the editor
// isn't the one being typed into. A message opened for edit is finished
// text, so a `/command` ending it is committed and chips, matching how
// the transcript rendered that same message a moment ago.
renderComposerContents(editor, draft, { trailingCommitted: true })
if (document.activeElement === editor) {
placeCaretEnd(editor)