Add plus button to prompt input for file and image attachments

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
tusharmagar 2026-02-17 13:42:53 +05:30 committed by Arjun
parent 9aa3a3f82b
commit c49a47e6bc
11 changed files with 507 additions and 115 deletions

View file

@ -357,6 +357,12 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
return await repo.fetch(id);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
const result: ModelMessage[] = [];
for (const msg of messages) {
@ -400,11 +406,41 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
});
break;
case "user":
result.push({
role: "user",
content: msg.content,
providerOptions,
});
if (typeof msg.content === 'string') {
// Legacy string — pass through unchanged
result.push({
role: "user",
content: msg.content,
providerOptions,
});
} else {
// New content parts array — collapse to text for LLM
const textSegments: string[] = [];
// Collect attachments into a header block
const attachmentParts = msg.content.filter((p: { type: string }) => p.type === "attachment");
if (attachmentParts.length > 0) {
textSegments.push("User has attached the following files:");
for (const part of attachmentParts) {
const att = (part as { type: string; attachment: { filename: string; mediaType: string; size?: number; path: string } }).attachment;
const sizeStr = att.size ? `, ${formatBytes(att.size)}` : '';
textSegments.push(`- ${att.filename} (${att.mediaType}${sizeStr}) at ${att.path}`);
}
textSegments.push(""); // blank line separator
}
// Collect text parts
const textParts = msg.content.filter((p: { type: string }) => p.type === "text");
for (const part of textParts) {
textSegments.push((part as { type: string; text: string }).text);
}
result.push({
role: "user",
content: textSegments.join("\n"),
providerOptions,
});
}
break;
case "tool":
result.push({

View file

@ -1,12 +1,16 @@
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
import { UserMessageContent } from "@x/shared/dist/message.js";
import z from "zod";
export type UserMessageContentType = z.infer<typeof UserMessageContent>;
type EnqueuedMessage = {
messageId: string;
message: string;
message: UserMessageContentType;
};
export interface IMessageQueue {
enqueue(runId: string, message: string): Promise<string>;
enqueue(runId: string, message: UserMessageContentType): Promise<string>;
dequeue(runId: string): Promise<EnqueuedMessage | null>;
}
@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator;
}
async enqueue(runId: string, message: string): Promise<string> {
async enqueue(runId: string, message: UserMessageContentType): Promise<string> {
if (!this.store[runId]) {
this.store[runId] = [];
}

View file

@ -46,10 +46,18 @@ export class FSRunsRepo implements IRunsRepo {
const messageEvent = event as z.infer<typeof MessageEvent>;
if (messageEvent.message.role === 'user') {
const content = messageEvent.message.content;
if (typeof content === 'string' && content.trim()) {
// Clean attached-files XML and @mentions, then truncate to 100 chars
const cleaned = cleanContentForTitle(content);
if (!cleaned) continue; // Skip if only attached files/mentions
let textContent: string | undefined;
if (typeof content === 'string') {
textContent = content;
} else if (Array.isArray(content)) {
textContent = content
.filter((p: { type: string }) => p.type === 'text')
.map((p: { type: string; text?: string }) => p.text || '')
.join('');
}
if (textContent && textContent.trim()) {
const cleaned = cleanContentForTitle(textContent);
if (!cleaned) continue;
return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
}
}
@ -90,9 +98,17 @@ export class FSRunsRepo implements IRunsRepo {
if (msg.role === 'user') {
// Found first user message - use as title
const content = msg.content;
if (typeof content === 'string' && content.trim()) {
// Clean attached-files XML and @mentions, then truncate
const cleaned = cleanContentForTitle(content);
let textContent: string | undefined;
if (typeof content === 'string') {
textContent = content;
} else if (Array.isArray(content)) {
textContent = content
.filter((p: { type: string }) => p.type === 'text')
.map((p: { type: string; text?: string }) => p.text || '')
.join('');
}
if (textContent && textContent.trim()) {
const cleaned = cleanContentForTitle(textContent);
if (cleaned) {
title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
}
@ -241,5 +257,13 @@ export class FSRunsRepo implements IRunsRepo {
async delete(id: string): Promise<void> {
const filePath = path.join(WorkDir, 'runs', `${id}.jsonl`);
await fsp.unlink(filePath);
// Clean up attachment sidecar directory if it exists
const attachmentsDir = path.join(WorkDir, 'runs', 'attachments', id);
try {
await fsp.rm(attachmentsDir, { recursive: true });
} catch (err: unknown) {
const e = err as { code?: string };
if (e.code !== 'ENOENT') throw err;
}
}
}

View file

@ -1,6 +1,6 @@
import z from "zod";
import container from "../di/container.js";
import { IMessageQueue } from "../application/lib/message-queue.js";
import { IMessageQueue, UserMessageContentType } from "../application/lib/message-queue.js";
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
import { IRunsRepo } from "./repo.js";
import { IAgentRuntime } from "../agents/runtime.js";
@ -19,7 +19,7 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
return run;
}
export async function createMessage(runId: string, message: string): Promise<string> {
export async function createMessage(runId: string, message: UserMessageContentType): Promise<string> {
const queue = container.resolve<IMessageQueue>('messageQueue');
const id = await queue.enqueue(runId, message);
const runtime = container.resolve<IAgentRuntime>('agentRuntime');

View file

@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js';
import { UserMessageContent } from './message.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
@ -128,7 +129,7 @@ const ipcSchemas = {
'runs:createMessage': {
req: z.object({
runId: z.string(),
message: z.string(),
message: UserMessageContent,
}),
res: z.object({
messageId: z.string(),

View file

@ -28,9 +28,36 @@ export const AssistantContentPart = z.union([
ToolCallPart,
]);
// Metadata about an attached file or image
export const Attachment = z.object({
type: z.enum(["file", "image"]), // extensible — could add "url", "audio" later
path: z.string(), // absolute file path
filename: z.string(), // display name ("photo.png")
mediaType: z.string(), // MIME type ("image/png", "text/plain")
size: z.number().optional(), // bytes
});
// A piece of user-typed text within a content array
export const UserTextPart = z.object({
type: z.literal("text"),
text: z.string(),
});
// An attachment within a content array
export const UserAttachmentPart = z.object({
type: z.literal("attachment"),
attachment: Attachment,
});
// Any single part of a user message (text or attachment)
export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]);
// Named type for user message content — used everywhere instead of repeating the union
export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]);
export const UserMessage = z.object({
role: z.literal("user"),
content: z.string(),
content: UserMessageContent,
providerOptions: ProviderOptions.optional(),
});