Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { promptEnabled, promptProbe } from "@/testing/prompt"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { insertAtomicPartAtSelection } from "./prompt-input/editor-insert"
import { createPromptAttachments } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
import {
Expand Down Expand Up @@ -951,12 +952,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}

range.deleteContents()
range.insertNode(gap)
range.insertNode(pill)
range.setStartAfter(gap)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
if (!insertAtomicPartAtSelection(part)) {
range.insertNode(gap)
range.insertNode(pill)
range.setStartAfter(gap)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}
}

if (part.type === "text") {
Expand Down
106 changes: 106 additions & 0 deletions packages/app/src/components/prompt-input/editor-insert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import type { AgentPart, FileAttachmentPart } from "@/context/prompt"
import { insertAtomicPartAtSelection, serializeAtomicPartHtml } from "./editor-insert"

const originalExecCommand = document.execCommand

afterEach(() => {
document.body.innerHTML = ""
if (originalExecCommand) {
document.execCommand = originalExecCommand
} else {
// @ts-expect-error happy-dom doesn't declare deletable execCommand
delete document.execCommand
}
})

describe("prompt-input editor insert", () => {
test("serializeAtomicPartHtml preserves file metadata for pill parsing", () => {
const part: FileAttachmentPart = {
type: "file",
content: "src/index.ts",
path: "/workspace/src/index.ts",
start: 0,
end: 12,
}

const html = serializeAtomicPartHtml(part)
expect(html).toContain('data-type="file"')
expect(html).toContain('data-path="/workspace/src/index.ts"')
expect(html).toContain('contenteditable="false"')
expect(html).toContain("src/index.ts")
})

test("serializeAtomicPartHtml preserves agent metadata", () => {
const part: AgentPart = {
type: "agent",
content: "codegen",
name: "codegen",
start: 0,
end: 7,
}

const html = serializeAtomicPartHtml(part)
expect(html).toContain('data-type="agent"')
expect(html).toContain('data-name="codegen"')
})

test("serializeAtomicPartHtml escapes HTML special characters", () => {
const part: FileAttachmentPart = {
type: "file",
content: '<script>alert("xss")</script>',
path: 'path/with"quotes',
start: 0,
end: 10,
}

const html = serializeAtomicPartHtml(part)
expect(html).not.toContain("<script>")
expect(html).toContain("&lt;script&gt;")
})

test("insertAtomicPartAtSelection prefers execCommand so the browser keeps undo history", () => {
const editor = document.createElement("div")
editor.setAttribute("contenteditable", "true")
editor.textContent = "hello @"
document.body.appendChild(editor)

const selection = window.getSelection()
const range = document.createRange()
range.setStart(editor.firstChild!, editor.textContent!.length)
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)

const execCommand = mock((_command: string, _showUi: boolean, _value: string) => true)
document.execCommand = execCommand as typeof document.execCommand

const part: FileAttachmentPart = {
type: "file",
content: "README.md",
path: "/workspace/README.md",
start: 0,
end: 9,
}

expect(insertAtomicPartAtSelection(part)).toBe(true)
expect(execCommand).toHaveBeenCalledTimes(1)
expect(execCommand.mock.calls[0]?.[0]).toBe("insertHTML")
expect(String(execCommand.mock.calls[0]?.[2] ?? "")).toContain('data-type="file"')
})

test("insertAtomicPartAtSelection returns false when execCommand is unavailable", () => {
// @ts-expect-error testing unavailable execCommand
document.execCommand = undefined

const part: AgentPart = {
type: "agent",
content: "codegen",
name: "codegen",
start: 0,
end: 7,
}

expect(insertAtomicPartAtSelection(part)).toBe(false)
})
})
40 changes: 40 additions & 0 deletions packages/app/src/components/prompt-input/editor-insert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { AgentPart, FileAttachmentPart } from "@/context/prompt"

type AtomicPart = FileAttachmentPart | AgentPart

function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}

export function serializeAtomicPartHtml(part: AtomicPart): string {
const attrs = [
'contenteditable="false"',
`data-type="${escapeHtml(part.type)}"`,
'style="user-select: text; cursor: default;"',
]

if (part.type === "file") {
attrs.push(`data-path="${escapeHtml(part.path)}"`)
}
if (part.type === "agent") {
attrs.push(`data-name="${escapeHtml(part.name)}"`)
}

return `<span ${attrs.join(" ")}>${escapeHtml(part.content)}</span>\u00a0`
}

/**
* Insert a tag pill via execCommand("insertHTML") so the operation is pushed
* onto the browser's native undo stack. Returns false when execCommand is
* unavailable, letting callers fall back to direct DOM manipulation.
*/
export function insertAtomicPartAtSelection(part: AtomicPart): boolean {
const execCommand = document.execCommand?.bind(document)
if (typeof execCommand !== "function") return false
return execCommand("insertHTML", false, serializeAtomicPartHtml(part))
}
Loading