diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index a7ccba61752..55693f898ff 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -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 (input: { probe: () => Promise; 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() @@ -357,7 +360,7 @@ const seed = async (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", @@ -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) @@ -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) @@ -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 diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 3ba3763b8c5..b01fcaf3a4f 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -13,6 +13,7 @@ import { ImageAttachmentPart, AgentPart, FileAttachmentPart, + PastedPart, } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" @@ -600,12 +601,20 @@ export const PromptInput: Component = (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" @@ -628,6 +637,7 @@ export const PromptInput: Component = (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" }) @@ -638,7 +648,7 @@ export const PromptInput: Component = (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)) } } @@ -748,6 +758,19 @@ export const PromptInput: Component = (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 ?? "" @@ -766,6 +789,11 @@ export const PromptInput: Component = (props) => { pushAgent(el) return } + if (el.dataset.type === "paste") { + flushText() + pushPaste(el) + return + } if (el.tagName === "BR") { buffer += "\n" return @@ -798,7 +826,7 @@ export const PromptInput: Component = (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 @@ -855,11 +883,11 @@ export const PromptInput: Component = (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*)$/) @@ -1223,6 +1251,9 @@ export const PromptInput: Component = (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", }} /> diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index a9e4e496512..edb183a963c 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -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 @@ -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) diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts index de62653211d..b369662dbfb 100644 --- a/packages/app/src/components/prompt-input/history.ts +++ b/packages/app/src/components/prompt-input/history.ts @@ -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, @@ -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 diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index fb822655911..2654d792606 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -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 @@ -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 = { @@ -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 } @@ -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),