Add plus button to prompt input for file and image attachments (#381)

* Add plus button to prompt input for file and image attachments

Co-authored-by: Cursor <cursoragent@cursor.com>

* Refactor chat message attachment handling and improve UI for attachments in chat input and sidebar

* fixed review comments

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Arjun <6592213+arkml@users.noreply.github.com>
This commit is contained in:
Tushar 2026-02-26 21:41:17 +05:30 committed by GitHub
parent 9aa3a3f82b
commit cccb7a8a65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 782 additions and 114 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,37 @@ 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[] = [];
const attachmentLines: string[] = [];
for (const part of msg.content) {
if (part.type === "attachment") {
const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`);
} else {
textSegments.push(part.text);
}
}
if (attachmentLines.length > 0) {
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
}
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 {
textContent = content
.filter(p => p.type === 'text')
.map(p => 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 {
textContent = content
.filter(p => p.type === 'text')
.map(p => p.text)
.join('');
}
if (textContent && textContent.trim()) {
const cleaned = cleanContentForTitle(textContent);
if (cleaned) {
title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
}

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,30 @@ export const AssistantContentPart = z.union([
ToolCallPart,
]);
// 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"),
path: z.string(), // absolute file path
filename: z.string(), // display name ("photo.png")
mimeType: z.string(), // MIME type ("image/png", "text/plain")
size: z.number().optional(), // bytes
});
// 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(),
});