Skip to content
13 changes: 8 additions & 5 deletions packages/app/e2e/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,11 @@ const seedSystem = [
"Do not call any extra tools.",
].join(" ")

const SEED_TIMEOUT = 60_000
const SEED_ATTEMPTS = 3

const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
const timeout = input.timeout ?? 30_000
const timeout = input.timeout ?? SEED_TIMEOUT
const end = Date.now() + timeout
while (Date.now() < end) {
const value = await input.probe()
Expand All @@ -357,7 +360,7 @@ const seed = async <T>(input: {
timeout?: number
attempts?: number
}) => {
for (let i = 0; i < (input.attempts ?? 2); i++) {
for (let i = 0; i < (input.attempts ?? SEED_ATTEMPTS); i++) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
agent: "build",
Expand Down Expand Up @@ -396,7 +399,7 @@ export async function seedSessionQuestion(
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
timeout: SEED_TIMEOUT,
probe: async () => {
const list = await sdk.question.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
Expand Down Expand Up @@ -430,7 +433,7 @@ export async function seedSessionPermission(
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
timeout: SEED_TIMEOUT,
probe: async () => {
const list = await sdk.permission.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID)
Expand Down Expand Up @@ -459,7 +462,7 @@ export async function seedSessionTodos(
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
timeout: SEED_TIMEOUT,
probe: async () => {
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
if (JSON.stringify(todos) !== target) return
Expand Down
43 changes: 37 additions & 6 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ImageAttachmentPart,
AgentPart,
FileAttachmentPart,
PastedPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
Expand Down Expand Up @@ -600,12 +601,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect: handleSlashSelect,
})

const createPill = (part: FileAttachmentPart | AgentPart) => {
const createPill = (part: FileAttachmentPart | AgentPart | PastedPart) => {
const pill = document.createElement("span")
pill.textContent = part.content
pill.textContent = part.type === "paste" ? part.summary : part.content
pill.setAttribute("data-type", part.type)
if (part.type === "file") pill.setAttribute("data-path", part.path)
if (part.type === "agent") pill.setAttribute("data-name", part.name)
if (part.type === "paste") {
pill.setAttribute("data-content", part.content)
pill.setAttribute("data-summary", part.summary)
pill.style.padding = "1px 6px"
pill.style.borderRadius = "4px"
pill.style.setProperty("color", "var(--smoke-light-12)", "important")
pill.style.setProperty("-webkit-text-fill-color", "var(--smoke-light-12)", "important")
}
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
Expand All @@ -628,6 +637,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const el = node as HTMLElement
if (el.dataset.type === "file") return true
if (el.dataset.type === "agent") return true
if (el.dataset.type === "paste") return true
return el.tagName === "BR"
})

Expand All @@ -638,7 +648,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef.appendChild(createTextFragment(part.content))
continue
}
if (part.type === "file" || part.type === "agent") {
if (part.type === "file" || part.type === "agent" || part.type === "paste") {
editorRef.appendChild(createPill(part))
}
}
Expand Down Expand Up @@ -748,6 +758,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
position += content.length
}

const pushPaste = (paste: HTMLElement) => {
const content = paste.dataset.content ?? ""
const summary = paste.dataset.summary ?? paste.textContent ?? "[Pasted text]"
parts.push({
type: "paste",
content,
summary,
start: position,
end: position + content.length,
})
position += content.length
}

const visit = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
buffer += node.textContent ?? ""
Expand All @@ -766,6 +789,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
pushAgent(el)
return
}
if (el.dataset.type === "paste") {
flushText()
pushPaste(el)
return
}
if (el.tagName === "BR") {
buffer += "\n"
return
Expand Down Expand Up @@ -798,7 +826,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const rawText =
rawParts.length === 1 && rawParts[0]?.type === "text"
? rawParts[0].content
: rawParts.map((p) => ("content" in p ? p.content : "")).join("")
: rawParts.map((p) => (p.type === "paste" ? p.summary : "content" in p ? p.content : "")).join("")
const hasNonText = rawParts.some((part) => part.type !== "text")
const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0

Expand Down Expand Up @@ -855,11 +883,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const range = selection.getRangeAt(0)
if (!editorRef.contains(range.startContainer)) return false

if (part.type === "file" || part.type === "agent") {
if (part.type === "file" || part.type === "agent" || part.type === "paste") {
const cursorPosition = getCursorPosition(editorRef)
const rawText = prompt
.current()
.map((p) => ("content" in p ? p.content : ""))
.map((p) => (p.type === "paste" ? p.summary : "content" in p ? p.content : ""))
.join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
Expand Down Expand Up @@ -1223,6 +1251,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"[&_[data-type=paste]]:bg-surface-warning-strong": true,
"[&_[data-type=paste]]:text-text-on-warning-base": true,
"[&_[data-type=paste]]:font-bold": true,
"font-mono!": store.mode === "shell",
}}
/>
Expand Down
10 changes: 8 additions & 2 deletions packages/app/src/components/prompt-input/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ function largePaste(text: string) {
return false
}

function pasteSummary(text: string) {
const lines = text.split("\n").length
return `[Pasted ~${lines} lines]`
}

type PromptAttachmentsInput = {
editor: () => HTMLDivElement | undefined
isFocused: () => boolean
Expand Down Expand Up @@ -104,9 +109,10 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
if (!plainText) return

if (largePaste(plainText)) {
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
const summary = pasteSummary(plainText)
if (input.addPart({ type: "paste", content: plainText, summary, start: 0, end: 0 })) return
input.focusEditor()
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
if (input.addPart({ type: "paste", content: plainText, summary, start: 0, end: 0 })) return
}

const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/components/prompt-input/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function clonePromptParts(prompt: Prompt): Prompt {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
if (part.type === "paste") return { ...part }
return {
...part,
selection: part.selection ? { ...part.selection } : undefined,
Expand Down Expand Up @@ -121,6 +122,10 @@ function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistory
const partB = entryB.prompt[i]
if (partA.type !== partB.type) return false
if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
if (partA.type === "paste") {
if (partB.type !== "paste") return false
if (partA.content !== partB.content || partA.summary !== partB.summary) return false
}
if (partA.type === "file") {
if (partA.path !== (partB.type === "file" ? partB.path : "")) return false
const a = partA.selection
Expand Down
10 changes: 9 additions & 1 deletion packages/app/src/context/prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export interface AgentPart extends PartBase {
name: string
}

export interface PastedPart extends PartBase {
type: "paste"
summary: string
}

export interface ImageAttachmentPart {
type: "image"
id: string
Expand All @@ -35,7 +40,7 @@ export interface ImageAttachmentPart {
dataUrl: string
}

export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | PastedPart | ImageAttachmentPart
export type Prompt = ContentPart[]

export type FileContextItem = {
Expand Down Expand Up @@ -68,6 +73,8 @@ function isPartEqual(partA: ContentPart, partB: ContentPart) {
return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection)
case "agent":
return partB.type === "agent" && partA.name === partB.name
case "paste":
return partB.type === "paste" && partA.content === partB.content && partA.summary === partB.summary
case "image":
return partB.type === "image" && partA.id === partB.id
}
Expand All @@ -90,6 +97,7 @@ function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
if (part.type === "paste") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
Expand Down
Loading