diff --git a/.changeset/grumpy-bugs-behave.md b/.changeset/grumpy-bugs-behave.md new file mode 100644 index 000000000..38a82a388 --- /dev/null +++ b/.changeset/grumpy-bugs-behave.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Allow upload of more file types ('txt', 'json', 'log', 'md', 'docx', 'ipynb', 'pdf', 'xml') diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 64116073f..d41da9cfe 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -96,6 +96,7 @@ export const clineMessageSchema = z.object({ say: clineSaySchema.optional(), text: z.string().optional(), images: z.array(z.string()).optional(), + files: z.array(z.string()).optional(), partial: z.boolean().optional(), reasoning: z.string().optional(), conversationHistoryIndex: z.number().optional(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21f28baf2..81fbdd535 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -612,6 +612,9 @@ importers: '@types/lodash.debounce': specifier: ^4.0.9 version: 4.0.9 + '@types/lru-cache': + specifier: ^7.10.9 + version: 7.10.10 '@vscode/codicons': specifier: ^0.0.36 version: 0.0.36 @@ -3896,6 +3899,10 @@ packages: '@types/lodash@4.17.17': resolution: {integrity: sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==} + '@types/lru-cache@7.10.10': + resolution: {integrity: sha512-nEpVRPWW9EBmx2SCfNn3ClYxPL7IktPX12HhIoSc/H5mMjdeW3+YsXIpseLQ2xF35+OcpwKQbEUw5VtqE4PDNA==} + deprecated: This is a stub types definition. lru-cache provides its own type definitions, so you do not need this installed. + '@types/mdast@3.0.15': resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} @@ -13425,6 +13432,10 @@ snapshots: '@types/lodash@4.17.17': {} + '@types/lru-cache@7.10.10': + dependencies: + lru-cache: 11.1.0 + '@types/mdast@3.0.15': dependencies: '@types/unist': 2.0.11 diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index a7011dae5..03fb72b21 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -146,7 +146,7 @@ export async function presentAssistantMessage(cline: Task) { } } - await cline.say("text", content, undefined, block.partial) + await cline.say("text", content, undefined, undefined, block.partial) // kilocode_change break } case "tool_use": diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 8a3983f85..760e9565d 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -93,7 +93,7 @@ export function getCheckpointService(cline: Task) { provider?.postMessageToWebview({ type: "currentCheckpointUpdated", text: to }) cline - .say("checkpoint_saved", to, undefined, undefined, { isFirst, from, to }, undefined, { + .say("checkpoint_saved", to, undefined, undefined, undefined, { isFirst, from, to }, undefined, { isNonInteractive: true, }) .catch((err) => { diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index eb37000b6..ad08af218 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -80,18 +80,31 @@ Otherwise, if you have not completed the task and do not need additional informa invalidMcpToolArgumentError: (serverName: string, toolName: string) => `Invalid JSON argument used with ${serverName} for ${toolName}. Please retry with a properly formatted JSON argument.`, + // kilocode_change start toolResult: ( text: string, images?: string[], + fileString?: string, ): string | Array => { + let toolResultOutput = [] + + if (!(images && images.length > 0) && !fileString) { + return text + } + + const textBlock: Anthropic.TextBlockParam = { type: "text", text } + toolResultOutput.push(textBlock) if (images && images.length > 0) { - const textBlock: Anthropic.TextBlockParam = { type: "text", text } const imageBlocks: Anthropic.ImageBlockParam[] = formatImagesIntoBlocks(images) - // Placing images after text leads to better results - return [textBlock, ...imageBlocks] - } else { - return text + toolResultOutput.push(...imageBlocks) + } + + if (fileString) { + const fileBlock: Anthropic.TextBlockParam = { type: "text", text: fileString } + toolResultOutput.push(fileBlock) } + return toolResultOutput + // kilocode_change end }, imageBlocks: (images?: string[]): Anthropic.ImageBlockParam[] => { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index f28f41a6f..d57ef4e69 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -88,6 +88,7 @@ import { parseMentions } from "../mentions" // kilocode_change import { parseKiloSlashCommands } from "../slash-commands/kilo" // kilocode_change import { GlobalFileNames } from "../../shared/globalFileNames" // kilocode_change import { ensureLocalKilorulesDirExists } from "../context/instructions/kilo-rules" // kilocode_change +import { processFilesIntoText } from "../../integrations/misc/process-files" export type ClineEvents = { message: [{ action: "created" | "updated"; message: ClineMessage }] @@ -113,6 +114,7 @@ export type TaskOptions = { consecutiveMistakeLimit?: number task?: string images?: string[] + files?: string[] // kilocode_change historyItem?: HistoryItem experiments?: Record startTask?: boolean @@ -176,6 +178,7 @@ export class Task extends EventEmitter { private askResponse?: ClineAskResponse private askResponseText?: string private askResponseImages?: string[] + private askResponseFiles?: string[] // kilocode_change public lastMessageTs?: number // Tool Use @@ -212,6 +215,7 @@ export class Task extends EventEmitter { consecutiveMistakeLimit = 3, task, images, + files, // kilocode_change historyItem, startTask = true, rootTask, @@ -222,7 +226,8 @@ export class Task extends EventEmitter { super() this.context = context // kilocode_change - if (startTask && !task && !images && !historyItem) { + if (startTask && !task && !images && !files && !historyItem) { + // kilocode_change throw new Error("Either historyItem or task/images must be provided") } @@ -265,8 +270,9 @@ export class Task extends EventEmitter { onCreated?.(this) if (startTask) { - if (task || images) { - this.startTask(task, images) + if (task || images || files) { + // kilocode_change + this.startTask(task, images, files) } else if (historyItem) { this.resumeTaskFromHistory() } else { @@ -287,11 +293,12 @@ export class Task extends EventEmitter { static create(options: TaskOptions): [Task, Promise] { const instance = new Task({ ...options, startTask: false }) - const { images, task, historyItem } = options + const { images, task, historyItem, files } = options // kilocode_change let promise - if (images || task) { - promise = instance.startTask(task, images) + if (images || task || files) { + // kilocode_change + promise = instance.startTask(task, images, files) // kilocode_change } else if (historyItem) { promise = instance.resumeTaskFromHistory() } else { @@ -406,7 +413,8 @@ export class Task extends EventEmitter { text?: string, partial?: boolean, progressStatus?: ToolProgressStatus, - ): Promise<{ response: ClineAskResponse; text?: string; images?: string[] }> { + ): Promise<{ response: ClineAskResponse; text?: string; images?: string[]; files?: string[] }> { + // kilocode_change // If this Cline instance was aborted by the provider, then the only // thing keeping us alive is a promise still running in the background, // in which case we don't want to send its result to the webview as it @@ -454,6 +462,7 @@ export class Task extends EventEmitter { this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined + this.askResponseFiles = undefined // kilocode_change // Bug for the history books: // In the webview we use the ts as the chatrow key for the @@ -478,6 +487,7 @@ export class Task extends EventEmitter { this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined + this.askResponseFiles = undefined // kilocode_change askTs = Date.now() this.lastMessageTs = askTs await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text }) @@ -488,6 +498,7 @@ export class Task extends EventEmitter { this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined + this.askResponseFiles = undefined askTs = Date.now() this.lastMessageTs = askTs await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text }) @@ -502,18 +513,26 @@ export class Task extends EventEmitter { throw new Error("Current ask promise was ignored") } - const result = { response: this.askResponse!, text: this.askResponseText, images: this.askResponseImages } + const result = { + response: this.askResponse!, + text: this.askResponseText, + images: this.askResponseImages, + files: this.askResponseFiles, // kilocode_change + } this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined + this.askResponseFiles = undefined this.emit("taskAskResponded") return result } - async handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { + async handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[], files?: string[]) { + // kilocode_change this.askResponse = askResponse this.askResponseText = text this.askResponseImages = images + this.askResponseFiles = files // kilocode_change } async handleTerminalOperation(terminalOperation: "continue" | "abort") { @@ -572,6 +591,7 @@ export class Task extends EventEmitter { "condense_context_error", error, undefined /* images */, + undefined /* files */, // kilocode_change false /* partial */, undefined /* checkpoint */, undefined /* progressStatus */, @@ -585,6 +605,7 @@ export class Task extends EventEmitter { "condense_context", undefined /* text */, undefined /* images */, + undefined /* files */, // kilocode_change false /* partial */, undefined /* checkpoint */, undefined /* progressStatus */, @@ -597,6 +618,7 @@ export class Task extends EventEmitter { type: ClineSay, text?: string, images?: string[], + files?: string[], // kilocode_change partial?: boolean, checkpoint?: Record, progressStatus?: ToolProgressStatus, @@ -620,6 +642,7 @@ export class Task extends EventEmitter { // Existing partial message, so update it. lastMessage.text = text lastMessage.images = images + lastMessage.files = files // kilocode_change lastMessage.partial = partial lastMessage.progressStatus = progressStatus this.updateClineMessage(lastMessage) @@ -637,6 +660,7 @@ export class Task extends EventEmitter { say: type, text, images, + files, // kilocode_change partial, contextCondense, }) @@ -652,6 +676,7 @@ export class Task extends EventEmitter { lastMessage.text = text lastMessage.images = images + lastMessage.files = files // kilocode_change lastMessage.partial = false lastMessage.progressStatus = progressStatus @@ -690,6 +715,7 @@ export class Task extends EventEmitter { say: type, text, images, + files, // kilocode_change checkpoint, contextCondense, }) @@ -708,7 +734,7 @@ export class Task extends EventEmitter { // Start / Abort / Resume - private async startTask(task?: string, images?: string[]): Promise { + private async startTask(task?: string, images?: string[], files?: string[]): Promise { // `conversationHistory` (for API) and `clineMessages` (for webview) // need to be in sync. // If the extension process were killed, then on restart the @@ -719,20 +745,31 @@ export class Task extends EventEmitter { this.apiConversationHistory = [] await this.providerRef.deref()?.postStateToWebview() - await this.say("text", task, images) + await this.say("text", task, images, files) // kilocode_change this.isInitialized = true let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images) - console.log(`[subtasks] task ${this.taskId}.${this.instanceId} starting`) - await this.initiateTaskLoop([ + let userContent: UserContent = [ { type: "text", text: `\n${task}\n`, }, ...imageBlocks, - ]) + ] + // kilocode_change start + if (files && files.length > 0) { + const fileContentString = await processFilesIntoText(files) + if (fileContentString) { + userContent.push({ + type: "text", + text: fileContentString, + }) + } + } + // kilocode_change end + await this.initiateTaskLoop(userContent) } public async resumePausedTask(lastMessage: string) { @@ -811,13 +848,15 @@ export class Task extends EventEmitter { this.isInitialized = true - const { response, text, images } = await this.ask(askType) // calls poststatetowebview + const { response, text, images, files } = await this.ask(askType) // calls poststatetowebview let responseText: string | undefined let responseImages: string[] | undefined + let responseFiles: string[] | undefined // kilocode_change if (response === "messageResponse") { - await this.say("user_feedback", text, images) + await this.say("user_feedback", text, images, files) // kilocode_change responseText = text responseImages = images + responseFiles = files // kilocode_change } // Make sure that the api conversation history can be resumed by the API, @@ -988,7 +1027,17 @@ export class Task extends EventEmitter { if (responseImages && responseImages.length > 0) { newUserContent.push(...formatResponse.imageBlocks(responseImages)) } - + // kilocode_change start + if (responseFiles && responseFiles.length > 0) { + const fileContentString = await processFilesIntoText(responseFiles) + if (fileContentString) { + newUserContent.push({ + type: "text", + text: fileContentString, + }) + } + } + // kilocode_change end await this.overwriteApiConversationHistory(modifiedApiConversationHistory) console.log(`[subtasks] task ${this.taskId}.${this.instanceId} resuming from history item`) @@ -1137,7 +1186,8 @@ export class Task extends EventEmitter { } if (this.consecutiveMistakeCount >= this.consecutiveMistakeLimit) { - const { response, text, images } = await this.ask( + const { response, text, images, files } = await this.ask( + // kilocode_change "mistake_limit_reached", t("common:errors.mistake_limit_guidance"), ) @@ -1149,8 +1199,18 @@ export class Task extends EventEmitter { ...formatResponse.imageBlocks(images), ], ) - - await this.say("user_feedback", text, images) + // kilocode_change start + if (files && files.length > 0) { + const fileContentString = await processFilesIntoText(files) + if (fileContentString) { + userContent.push({ + type: "text", + text: fileContentString, + }) + } + } + // kilocode_change end + await this.say("user_feedback", text, images, files) // kilocode_change // Track consecutive mistake errors in telemetry. // TelemetryService.instance.captureConsecutiveMistakeError(this.taskId) @@ -1346,7 +1406,7 @@ export class Task extends EventEmitter { switch (chunk.type) { case "reasoning": reasoningMessage += chunk.text - await this.say("reasoning", reasoningMessage, undefined, true) + await this.say("reasoning", reasoningMessage, undefined, undefined, true) break case "usage": inputTokens += chunk.inputTokens @@ -1738,7 +1798,7 @@ export class Task extends EventEmitter { // Show countdown timer for (let i = rateLimitDelay; i > 0; i--) { const delayMessage = `Rate limiting for ${i} seconds...` - await this.say("api_req_retry_delayed", delayMessage, undefined, true) + await this.say("api_req_retry_delayed", delayMessage, undefined, undefined, true) await delay(1000) } } @@ -1787,6 +1847,7 @@ export class Task extends EventEmitter { "condense_context", undefined /* text */, undefined /* images */, + undefined /* files */, // kilocode_change false /* partial */, undefined /* checkpoint */, undefined /* progressStatus */, @@ -1892,6 +1953,7 @@ export class Task extends EventEmitter { "api_req_retry_delayed", `${errorMsg}\n\nRetry attempt ${retryAttempt + 1}\nRetrying in ${i} seconds...`, undefined, + undefined, true, ) await delay(1000) @@ -1901,6 +1963,7 @@ export class Task extends EventEmitter { "api_req_retry_delayed", `${errorMsg}\n\nRetry attempt ${retryAttempt + 1}\nRetrying now...`, undefined, + undefined, false, ) diff --git a/src/core/tools/attemptCompletionTool.ts b/src/core/tools/attemptCompletionTool.ts index cc226cd39..860ed1246 100644 --- a/src/core/tools/attemptCompletionTool.ts +++ b/src/core/tools/attemptCompletionTool.ts @@ -45,7 +45,13 @@ export async function attemptCompletionTool( } else { // last message is completion_result // we have command string, which means we have the result as well, so finish it (doesnt have to exist yet) - await cline.say("completion_result", removeClosingTag("result", result), undefined, false) + await cline.say( + "completion_result", + removeClosingTag("result", result), + undefined, + undefined, + false, + ) // kilocode_change: do not get instance // TelemetryService.instance.captureTaskCompleted(cline.taskId) @@ -55,7 +61,13 @@ export async function attemptCompletionTool( } } else { // no command, still outputting partial result - await cline.say("completion_result", removeClosingTag("result", result), undefined, block.partial) + await cline.say( + "completion_result", + removeClosingTag("result", result), + undefined, + undefined, + block.partial, + ) } return } else { @@ -81,9 +93,8 @@ export async function attemptCompletionTool( if (command && !isCommandDisabled) { if (lastMessage && lastMessage.ask !== "command") { // Haven't sent a command message yet so first send completion_result then command. - await cline.say("completion_result", result, undefined, false) - // kilocode_change: do not get instance - // TelemetryService.instance.captureTaskCompleted(cline.taskId) + await cline.say("completion_result", result, undefined, undefined, false) + // telemetryService.captureTaskCompleted(cline.taskId) cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.toolUsage) } @@ -107,9 +118,9 @@ export async function attemptCompletionTool( // User didn't reject, but the command may have output. commandResult = execCommandResult } else { - await cline.say("completion_result", result, undefined, false) + await cline.say("completion_result", result, undefined, undefined, false) // kilocode_change: do not get instance - // TelemetryService.instance.captureTaskCompleted(cline.taskId) + //TelemetryService.instance.captureTaskCompleted(cline.taskId) cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage(), cline.toolUsage) } diff --git a/src/core/tools/browserActionTool.ts b/src/core/tools/browserActionTool.ts index 13cb9b0ec..21d021d28 100644 --- a/src/core/tools/browserActionTool.ts +++ b/src/core/tools/browserActionTool.ts @@ -48,6 +48,7 @@ export async function browserActionTool( text: removeClosingTag("text", text), } satisfies ClineSayBrowserAction), undefined, + undefined, block.partial, ) } @@ -120,6 +121,7 @@ export async function browserActionTool( text, } satisfies ClineSayBrowserAction), undefined, + undefined, false, ) diff --git a/src/core/tools/newTaskTool.ts b/src/core/tools/newTaskTool.ts index bdb6d9a00..8010ae229 100644 --- a/src/core/tools/newTaskTool.ts +++ b/src/core/tools/newTaskTool.ts @@ -82,7 +82,7 @@ export async function newTaskTool( // Delay to allow mode change to take effect before next tool is executed. await delay(500) - const newCline = await provider.initClineWithTask(message, undefined, cline) + const newCline = await provider.initClineWithTask(message, undefined, undefined, cline) cline.emit("taskSpawned", newCline.taskId) pushToolResult(`Successfully created new task in ${targetMode.name} mode with message: ${message}`) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 49e1e53eb..3e5647b86 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -490,8 +490,8 @@ export class ClineProvider extends EventEmitter implements this.log("Webview view resolved") } - public async initClineWithSubTask(parent: Task, task?: string, images?: string[]) { - return this.initClineWithTask(task, images, parent) + public async initClineWithSubTask(parent: Task, task?: string, images?: string[], files?: string[]) { + return this.initClineWithTask(task, images, files, parent) } // When initializing a new task, (not from history but from a tool command @@ -503,6 +503,7 @@ export class ClineProvider extends EventEmitter implements public async initClineWithTask( task?: string, images?: string[], + files?: string[], parentTask?: Task, options: Partial< Pick< @@ -533,6 +534,7 @@ export class ClineProvider extends EventEmitter implements fuzzyMatchThreshold, task, images, + files, experiments, rootTask: this.clineStack.length > 0 ? this.clineStack[0] : undefined, parentTask, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 61a5741a4..c21f8e9af 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -4,7 +4,7 @@ import pWaitFor from "p-wait-for" import * as vscode from "vscode" import axios from "axios" // kilocode_change -import { type Language, type ProviderSettings, type GlobalState, TelemetryEventName } from "@roo-code/types" +import type { Language, ProviderSettings, GlobalState } from "@roo-code/types" import { CloudService } from "@roo-code/cloud" import { TelemetryService } from "@roo-code/telemetry" @@ -13,13 +13,13 @@ import { changeLanguage, t } from "../../i18n" import { Package } from "../../shared/package" import { RouterName, toRouterName, ModelRecord } from "../../shared/api" import { supportPrompt } from "../../shared/support-prompt" - import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage" import { checkExistKey } from "../../shared/checkExistApiConfig" import { experimentDefault } from "../../shared/experiments" import { Terminal } from "../../integrations/terminal/Terminal" import { openFile, openImage } from "../../integrations/misc/open-file" import { selectImages } from "../../integrations/misc/process-images" +import { selectFiles } from "../../integrations/misc/process-files" import { getTheme } from "../../integrations/theme/getTheme" import { discoverChromeHostUrl, tryChromeHostUrl } from "../../services/browser/browserDiscovery" import { searchWorkspaceFiles } from "../../services/search/file-search" @@ -127,7 +127,7 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We // Initializing new instance of Cline will make sure that any // agentically running promises in old instance don't affect our new // task. This essentially creates a fresh slate for the new task. - await provider.initClineWithTask(message.text, message.images) + await provider.initClineWithTask(message.text, message.images, message.files) break // kilocode_change start case "condense": @@ -178,7 +178,9 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We await provider.postStateToWebview() break case "askResponse": - provider.getCurrentCline()?.handleWebviewAskResponse(message.askResponse!, message.text, message.images) + provider + .getCurrentCline() + ?.handleWebviewAskResponse(message.askResponse!, message.text, message.images, message.files) break case "autoCondenseContext": await updateGlobalState("autoCondenseContext", message.bool) @@ -204,7 +206,8 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We break case "selectImages": const images = await selectImages() - await provider.postMessageToWebview({ type: "selectedImages", images }) + const { files } = await selectFiles() // kilocode_change + await provider.postMessageToWebview({ type: "selectedImages", images, filePaths: files }) // kilocode_change break case "exportCurrentTask": const currentTaskId = provider.getCurrentCline()?.taskId @@ -1090,7 +1093,10 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We supportPrompt.create("ENHANCE", { userInput: message.text }, customSupportPrompts), ) - await provider.postMessageToWebview({ type: "enhancedPrompt", text: enhancedPrompt }) + await provider.postMessageToWebview({ + type: "enhancedPrompt", + text: enhancedPrompt, + }) } catch (error) { provider.log( `Error enhancing prompt: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, diff --git a/src/extension/api.ts b/src/extension/api.ts index 4911dde61..825f1d830 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -96,11 +96,13 @@ export class API extends EventEmitter implements RooCodeAPI { configuration, text, images, + filePaths, newTab, }: { configuration: RooCodeSettings text?: string images?: string[] + filePaths?: string[] newTab?: boolean }) { let provider: ClineProvider @@ -130,9 +132,9 @@ export class API extends EventEmitter implements RooCodeAPI { await provider.removeClineFromStack() await provider.postStateToWebview() await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) - await provider.postMessageToWebview({ type: "invoke", invoke: "newChat", text, images }) + await provider.postMessageToWebview({ type: "invoke", invoke: "newChat", text, images, filePaths }) - const { taskId } = await provider.initClineWithTask(text, images, undefined, { + const { taskId } = await provider.initClineWithTask(text, images, filePaths, undefined, { consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER, }) diff --git a/src/integrations/misc/process-files.ts b/src/integrations/misc/process-files.ts new file mode 100644 index 000000000..de384c560 --- /dev/null +++ b/src/integrations/misc/process-files.ts @@ -0,0 +1,123 @@ +import * as vscode from "vscode" +import fs from "fs/promises" +import * as path from "path" +import { extractTextFromFile } from "./extract-text" + +/** + * Gets the MIME type for a file based on its extension + */ +function getMimeType(filePath: string): string { + const ext = path.extname(filePath).toLowerCase() + switch (ext) { + case ".png": + return "image/png" + case ".jpg": + case ".jpeg": + return "image/jpeg" + case ".webp": + return "image/webp" + default: + return "application/octet-stream" + } +} + +/** + * Supports processing of images and other file types + */ +export async function selectFiles(): Promise<{ images: string[]; files: string[] }> { + const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp"] // supported by anthropic and openrouter + const OTHER_FILE_EXTENSIONS = ["xml", "json", "txt", "log", "md", "docx", "ipynb", "pdf"] + + const options: vscode.OpenDialogOptions = { + canSelectMany: true, + openLabel: "Upload Images & Files", + filters: { + "All Files": [...IMAGE_EXTENSIONS, ...OTHER_FILE_EXTENSIONS], + Images: IMAGE_EXTENSIONS, + Documents: OTHER_FILE_EXTENSIONS, + }, + } + + const fileUris = await vscode.window.showOpenDialog(options) + + if (!fileUris || fileUris.length === 0) { + return { images: [], files: [] } + } + + const images: string[] = [] + const files: string[] = [] + + for (const uri of fileUris) { + const filePath = uri.fsPath + const fileExtension = path.extname(filePath).toLowerCase().substring(1) + const isImage = IMAGE_EXTENSIONS.includes(fileExtension) + + if (isImage) { + try { + const buffer = await fs.readFile(filePath) + const stats = await fs.stat(filePath) + + if (stats.size > 20 * 1024 * 1024) { + vscode.window.showErrorMessage( + `Image too large: ${path.basename(filePath)} was skipped (size exceeds 20mb).`, + ) + continue + } + + const base64 = buffer.toString("base64") + const mimeType = getMimeType(filePath) + images.push(`data:${mimeType};base64,${base64}`) + } catch (error) { + console.error(`Error processing image ${filePath}:`, error) + vscode.window.showErrorMessage(`Error processing image: ${path.basename(filePath)}`) + } + } else { + try { + const stats = await fs.stat(filePath) + if (stats.size > 20 * 1024 * 1024) { + // 20MB limit + vscode.window.showErrorMessage( + `File too large: ${path.basename(filePath)} was skipped (size exceeds 20MB).`, + ) + continue + } + files.push(filePath) + } catch (error) { + console.error(`Error processing file ${filePath}:`, error) + vscode.window.showErrorMessage(`Error processing file: ${path.basename(filePath)}`) + } + } + } + + return { images, files } +} + +/** + * Helper function used to load file(s) and format them into a string + */ +export async function processFilesIntoText(files: string[]): Promise { + const fileContentsPromises = files.map(async (filePath) => { + // Normalize path separators to forward slashes + const normalizedPath = filePath.split(path.sep).join("/") + try { + const content = await extractTextFromFile(filePath) + return `\n${content}\n` + } catch (error) { + console.error(`Error processing file ${filePath}:`, error) + const errorMessage = error instanceof Error ? error.message : String(error) + return `\nError fetching content: ${errorMessage}\n` + } + }) + + const fileContents = await Promise.all(fileContentsPromises) + + const validFileContents = fileContents.filter((content) => content !== null).join("\n\n") + + if (validFileContents) { + return `Files attached by the user:\n\n${validFileContents}` + } + + // returns empty string if no files were loaded properly, basically it shows + // the user text saying that the file wasn't able to be read + return "" +} diff --git a/src/package.json b/src/package.json index 1190dcb4b..61308159a 100644 --- a/src/package.json +++ b/src/package.json @@ -461,6 +461,7 @@ "@roo-code/types": "workspace:^", "@qdrant/js-client-rest": "^1.14.0", "@types/lodash.debounce": "^4.0.9", + "@types/lru-cache": "^7.10.9", "@vscode/codicons": "^0.0.36", "async-mutex": "^0.5.0", "axios": "^1.7.4", @@ -480,8 +481,8 @@ "i18next": "^25.0.0", "ignore": "^7.0.3", "isbinaryfile": "^5.0.2", - "lru-cache": "^11.1.0", "lodash.debounce": "^4.0.8", + "lru-cache": "^11.1.0", "mammoth": "^1.8.0", "monaco-vscode-textmate-theme-converter": "^0.1.7", "node-cache": "^5.1.2", diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index d185ffb6b..26a2aef92 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -184,6 +184,7 @@ export interface WebviewMessage { askResponse?: ClineAskResponse apiConfiguration?: ProviderSettings images?: string[] + files?: string[] bool?: boolean value?: number commands?: string[] diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index e97f188e4..e77cf4802 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -931,7 +931,11 @@ export const ChatRowContent = ({ {message.images && message.images.length > 0 && ( - + )} ) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 28fbc10a7..8651e749b 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -24,7 +24,7 @@ import { SelectDropdown, DropdownOptionType, Button } from "@/components/ui" import { useVSCodeTheme } from "@/kilocode/hooks/useVSCodeTheme" // kilocode_change import Thumbnails from "../common/Thumbnails" -import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" +import { MAX_IMAGES_AND_FILES_PER_MESSAGE } from "./ChatView" import ContextMenu from "./ContextMenu" import { VolumeX, Pin, Check } from "lucide-react" import { IconButton } from "./IconButton" @@ -51,9 +51,11 @@ interface ChatTextAreaProps { placeholderText: string selectedImages: string[] setSelectedImages: React.Dispatch> + selectedFiles: string[] + setSelectedFiles: React.Dispatch> onSend: () => void - onSelectImages: () => void - shouldDisableImages: boolean + onSelectFilesAndImages: () => void + shouldDisableFilesAndImages: boolean onHeightChange?: (height: number) => void mode: Mode setMode: (value: Mode) => void @@ -70,9 +72,11 @@ const ChatTextArea = forwardRef( placeholderText, selectedImages, setSelectedImages, + selectedFiles, + setSelectedFiles, onSend, - onSelectImages, - shouldDisableImages, + onSelectFilesAndImages, + shouldDisableFilesAndImages, onHeightChange, mode, setMode, @@ -297,7 +301,7 @@ const ChatTextArea = forwardRef( } // Call the image selection function - onSelectImages() + onSelectFilesAndImages() return } // kilocode_change end @@ -743,7 +747,7 @@ const ChatTextArea = forwardRef( return type === "image" && acceptedTypes.includes(subtype) }) - if (!shouldDisableImages && imageItems.length > 0) { + if (!shouldDisableFilesAndImages && imageItems.length > 0) { e.preventDefault() const imagePromises = imageItems.map((item) => { @@ -775,13 +779,28 @@ const ChatTextArea = forwardRef( const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null) if (dataUrls.length > 0) { - setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE)) + const filesAndImagesLength = selectedImages.length + selectedFiles.length + const availableSlots = MAX_IMAGES_AND_FILES_PER_MESSAGE - filesAndImagesLength + + if (availableSlots > 0) { + const imagesToAdd = Math.min(dataUrls.length, availableSlots) + setSelectedImages((prevImages) => [...prevImages, ...dataUrls.slice(0, imagesToAdd)]) + } } else { console.warn(t("chat:noValidImages")) } } }, - [shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t], + [ + shouldDisableFilesAndImages, + setSelectedImages, + cursorPosition, + setInputValue, + inputValue, + t, + selectedFiles, + selectedImages, + ], ) const handleMenuMouseDown = useCallback(() => { @@ -901,7 +920,7 @@ const ChatTextArea = forwardRef( return type === "image" && acceptedTypes.includes(subtype) }) - if (!shouldDisableImages && imageFiles.length > 0) { + if (!shouldDisableFilesAndImages && imageFiles.length > 0) { const imagePromises = imageFiles.map((file) => { return new Promise((resolve) => { const reader = new FileReader() @@ -925,7 +944,7 @@ const ChatTextArea = forwardRef( if (dataUrls.length > 0) { setSelectedImages((prevImages) => - [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE), + [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_AND_FILES_PER_MESSAGE), ) if (typeof vscode !== "undefined") { @@ -944,7 +963,7 @@ const ChatTextArea = forwardRef( setInputValue, setCursorPosition, setIntendedCursorPosition, - shouldDisableImages, + shouldDisableFilesAndImages, setSelectedImages, t, ], @@ -962,7 +981,7 @@ const ChatTextArea = forwardRef( } }) - const placeholderBottomText = `\n(${t("chat:addContext")}${shouldDisableImages ? `, ${t("chat:dragFiles")}` : `, ${t("chat:dragFilesImages")}`})` + const placeholderBottomText = `\n(${t("chat:addContext")}${shouldDisableFilesAndImages ? `, ${t("chat:dragFiles")}` : `, ${t("chat:dragFilesImages")}`})` return (
( setSelectedMenuIndex(4) }} /> - (
- {selectedImages.length > 0 && ( + {(selectedImages.length > 0 || selectedFiles.length > 0) && ( diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 5d7056a76..2f0d87b7f 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -52,7 +52,8 @@ export interface ChatViewRef { focusInput: () => void // kilocode_change } -export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images +// Anthropic limits to 20 images, which we use to constrain both images & files for simplicity +export const MAX_IMAGES_AND_FILES_PER_MESSAGE = 20 const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0 @@ -96,11 +97,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - messagesRef.current = messages - }, [messages]) - const { tasks } = useTaskSearch() // Initialize expanded state based on the persisted setting (default to expanded if undefined) @@ -129,6 +125,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const [sendingDisabled, setSendingDisabled] = useState(false) const [selectedImages, setSelectedImages] = useState([]) + const [selectedFiles, setSelectedFiles] = useState([]) // we need to hold on to the ask because useEffect > lastMessage will always let us know when an ask comes in and handle it, but by the time handleMessage is called, the last message might not be the ask anymore (it could be a say that followed) const [clineAsk, setClineAsk] = useState(undefined) @@ -496,6 +493,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + (text: string, images: string[], files: string[]) => { text = text.trim() - if (text || images.length > 0) { - if (messagesRef.current.length === 0) { - vscode.postMessage({ type: "newTask", text, images }) - } else if (clineAskRef.current) { - // Use clineAskRef.current - switch ( - clineAskRef.current // Use clineAskRef.current - ) { + if (text || images.length > 0 || files.length > 0) { + if (messages.length === 0) { + vscode.postMessage({ type: "newTask", text, images, files }) + } else if (clineAsk) { + switch (clineAsk) { case "followup": case "tool": case "browser_action_launch": @@ -527,7 +522,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + (text: string, images: string[], files: string[]) => { // Avoid nested template literals by breaking down the logic let newValue = text @@ -560,8 +562,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction vscode.postMessage({ type: "clearTask" }), []) @@ -570,7 +573,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + (text?: string, images?: string[], files?: string[]) => { + // kilocode_change: add files const trimmedInput = text?.trim() switch (clineAsk) { @@ -589,6 +593,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + (text?: string, images?: string[], files?: string[]) => { + // kilocode_change: add files const trimmedInput = text?.trim() if (isStreaming) { @@ -649,6 +656,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction vscode.postMessage({ type: "selectImages" }), []) + // kilocode_change start + const selectFilesAndImages = useCallback(async () => { + try { + vscode.postMessage({ type: "selectImages" }) + + const handleFileSelection = (event: MessageEvent) => { + const message = event.data + if (message.type === "selectedImages") { + window.removeEventListener("message", handleFileSelection) + + const currentTotal = selectedImages.length + selectedFiles.length + const availableSlots = MAX_IMAGES_AND_FILES_PER_MESSAGE - currentTotal + + if (availableSlots > 0) { + if (message.images?.length > 0) { + const imagesToAdd = Math.min(message.images.length, availableSlots) + if (imagesToAdd > 0) { + setSelectedImages((prevImages) => { + const newImages = message.images.slice(0, imagesToAdd) + const uniqueNewImages = newImages.filter((img: string) => !prevImages.includes(img)) + return [...prevImages, ...uniqueNewImages] + }) + } + } + + if (message.filePaths?.length > 0) { + const remainingSlots = availableSlots - (message.images?.length || 0) + if (remainingSlots > 0) { + const filesToAdd = Math.min(message.filePaths.length, remainingSlots) + setSelectedFiles((prevFiles) => { + const newFiles = message.filePaths.slice(0, filesToAdd) + const uniqueNewFiles = newFiles.filter((file: string) => !prevFiles.includes(file)) + return [...prevFiles, ...uniqueNewFiles] + }) + } + } + } + } + } + + window.addEventListener("message", handleFileSelection) + } catch (error) { + console.error("Error selecting files and images:", error) + } + }, [selectedImages, selectedFiles]) + // kilocode_change end - const shouldDisableImages = - !model?.supportsImages || sendingDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE + // kilocode_change start + const shouldDisableFilesAndImages = selectedImages.length + selectedFiles.length >= MAX_IMAGES_AND_FILES_PER_MESSAGE + // kilocode_change end const handleMessage = useCallback( (e: MessageEvent) => { @@ -692,30 +746,30 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0) { - setSelectedImages((prevImages) => - [...prevImages, ...newImages].slice(0, MAX_IMAGES_PER_MESSAGE), - ) - } - break case "invoke": switch (message.invoke!) { case "newChat": handleChatReset() break case "sendMessage": - handleSendMessage(message.text ?? "", message.images ?? []) + handleSendMessage(message.text ?? "", message.images ?? [], message.filePaths ?? []) // kilocode_change break case "setChatBoxMessage": - handleSetChatBoxMessage(message.text ?? "", message.images ?? []) + handleSetChatBoxMessage(message.text ?? "", message.images ?? [], message.filePaths ?? []) // kilocode_change break case "primaryButtonClick": - handlePrimaryButtonClick(message.text ?? "", message.images ?? []) + handlePrimaryButtonClick( + message.text ?? "", + message.images ?? [], + message.filePaths ?? [], // kilocode_change + ) break case "secondaryButtonClick": - handleSecondaryButtonClick(message.text ?? "", message.images ?? []) + handleSecondaryButtonClick( + message.text ?? "", + message.images ?? [], + message.filePaths ?? [], // kilocode_change + ) break } break @@ -1200,25 +1254,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - if (event?.shiftKey) { - // Always append to existing text, don't overwrite - setInputValue((currentValue) => { - return currentValue !== "" ? `${currentValue} \n${answer}` : answer - }) - } else { - handleSendMessage(answer, []) - } - }, - [handleSendMessage, setInputValue], // setInputValue is stable, handleSendMessage depends on clineAsk - ) - - const handleBatchFileResponse = useCallback((response: { [key: string]: boolean }) => { - // Handle batch file response, e.g., for file uploads - vscode.postMessage({ type: "askResponse", askResponse: "objectResponse", text: JSON.stringify(response) }) - }, []) - const itemContent = useCallback( (index: number, messageOrGroup: ClineMessage | ClineMessage[]) => { // browser session group @@ -1230,6 +1265,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction expandedRows[messageTs] ?? false} onToggleExpand={(messageTs: number) => { setExpandedRows((prev) => ({ @@ -1247,25 +1283,32 @@ const ChatViewComponent: React.ForwardRefRenderFunction toggleRowExpansion(messageOrGroup.ts)} + lastModifiedMessage={modifiedMessages.at(-1)} + isLast={index === groupedMessages.length - 1} onHeightChange={handleRowHeightChange} isStreaming={isStreaming} - onSuggestionClick={handleSuggestionClickInRow} // This was already stabilized - onBatchFileResponse={handleBatchFileResponse} + onSuggestionClick={(answer: string, event?: React.MouseEvent) => { + if (event?.shiftKey) { + // Always append to existing text, don't overwrite + setInputValue((currentValue) => { + return currentValue !== "" ? `${currentValue} \n${answer}` : answer + }) + } else { + handleSendMessage(answer, [], []) + } + }} /> ) }, [ expandedRows, - toggleRowExpansion, modifiedMessages, groupedMessages.length, handleRowHeightChange, isStreaming, - handleSuggestionClickInRow, - handleBatchFileResponse, + toggleRowExpansion, + handleSendMessage, ], ) @@ -1356,14 +1399,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction ({ + // kilocode_change start acceptInput: () => { if (enableButtons && primaryButtonText) { - handlePrimaryButtonClick(inputValue, selectedImages) + handlePrimaryButtonClick(inputValue, selectedImages, selectedFiles) } else if (!sendingDisabled && !isProfileDisabled && (inputValue.trim() || selectedImages.length > 0)) { - handleSendMessage(inputValue, selectedImages) + handleSendMessage(inputValue, selectedImages, selectedFiles) } }, - // kilocode_change start focusInput: () => { if (textAreaRef.current) { textAreaRef.current.focus() @@ -1432,7 +1475,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction kilocode_change: do not show */} {/* Show the task history preview if expanded and tasks exist */} {taskHistory.length > 0 && isExpanded && } -

+

handlePrimaryButtonClick(inputValue, selectedImages)}> + onClick={() => handlePrimaryButtonClick(inputValue, selectedImages, selectedFiles)}> {primaryButtonText} )} @@ -1563,7 +1606,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction handleSecondaryButtonClick(inputValue, selectedImages)}> + onClick={() => + handleSecondaryButtonClick(inputValue, selectedImages, selectedFiles) + }> {isStreaming ? t("chat:cancel.title") : secondaryButtonText} )} @@ -1581,9 +1626,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction handleSendMessage(inputValue, selectedImages)} - onSelectImages={selectImages} - shouldDisableImages={shouldDisableImages} + setSelectedFiles={setSelectedFiles} + selectedFiles={selectedFiles} + onSend={() => handleSendMessage(inputValue, selectedImages, selectedFiles)} + onSelectFilesAndImages={selectFilesAndImages} + shouldDisableFilesAndImages={shouldDisableFilesAndImages} onHeightChange={() => { if (isAtBottom) { scrollToBottomAuto() diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index e4681efc3..b7d515f26 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -97,7 +97,7 @@ const ContextMenu: React.FC = ({ return No results found // kilocode_change start case ContextMenuOptionType.Image: - return Add Image + return Upload Images & Files // kilocode_change end case ContextMenuOptionType.Git: if (option.value) { @@ -158,7 +158,7 @@ const ContextMenu: React.FC = ({ ) } else { - return Add {option.type === ContextMenuOptionType.File ? "File" : "Folder"} + return Reference {option.type === ContextMenuOptionType.File ? "File" : "Folder"} // kilocode_change } } } diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 17493bd2d..f91560cb0 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -141,7 +141,9 @@ const TaskHeader = ({ {highlightText(task.text, false, customModes)} - {task.images && task.images.length > 0 && } + {((task.images && task.images.length > 0) || (task.files && task.files.length > 0)) && ( + + )}

{isTaskExpanded && contextWindow > 0 && ( diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx index ce9714fb2..d7f8658e3 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx @@ -63,6 +63,10 @@ describe("ChatTextArea", () => { mode: defaultModeSlug, setMode: jest.fn(), modeShortcutText: "(⌘. for next mode)", + selectedFiles: [], + setSelectedFiles: jest.fn(), + onSelectFilesAndImages: jest.fn(), + shouldDisableFilesAndImages: false, } beforeEach(() => { diff --git a/webview-ui/src/components/chat/__tests__/ChatView.test.tsx b/webview-ui/src/components/chat/__tests__/ChatView.test.tsx index 5b618378c..d1e09c52c 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.test.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.test.tsx @@ -70,7 +70,7 @@ interface ChatTextAreaProps { sendingDisabled?: boolean placeholderText?: string selectedImages?: string[] - shouldDisableImages?: boolean + shouldDisableFilesAndImages?: boolean } const mockInputRef = React.createRef() diff --git a/webview-ui/src/components/common/Thumbnails.tsx b/webview-ui/src/components/common/Thumbnails.tsx index acdf5f429..00cf0dd8e 100644 --- a/webview-ui/src/components/common/Thumbnails.tsx +++ b/webview-ui/src/components/common/Thumbnails.tsx @@ -4,13 +4,15 @@ import { vscode } from "@src/utils/vscode" interface ThumbnailsProps { images: string[] + files: string[] style?: React.CSSProperties setImages?: React.Dispatch> + setFiles?: React.Dispatch> onHeightChange?: (height: number) => void } -const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProps) => { - const [hoveredIndex, setHoveredIndex] = useState(null) +const Thumbnails = ({ images, files, style, setImages, setFiles, onHeightChange }: ThumbnailsProps) => { + const [hoveredIndex, setHoveredIndex] = useState(null) const containerRef = useRef(null) const { width } = useWindowSize() @@ -24,18 +26,27 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp onHeightChange?.(height) } setHoveredIndex(null) - }, [images, width, onHeightChange]) + }, [images, files, width, onHeightChange]) - const handleDelete = (index: number) => { + const handleDeleteImages = (index: number) => { setImages?.((prevImages) => prevImages.filter((_, i) => i !== index)) } - const isDeletable = setImages !== undefined + const handleDeleteFiles = (index: number) => { + setFiles?.((prevFiles) => prevFiles.filter((_, i) => i !== index)) + } + + const isDeletableImages = setImages !== undefined + const isDeletableFiles = setFiles !== undefined const handleImageClick = (image: string) => { vscode.postMessage({ type: "openImage", text: image }) } + const handleFileClick = (filePath: string) => { + vscode.postMessage({ type: "openFile", text: filePath }) + } + return (
{images.map((image, index) => (
setHoveredIndex(index)} + onMouseEnter={() => setHoveredIndex(`${index}`)} onMouseLeave={() => setHoveredIndex(null)}> handleImageClick(image)} /> - {isDeletable && hoveredIndex === index && ( + {isDeletableImages && hoveredIndex === `${index}` && (
handleDelete(index)} + onClick={() => handleDeleteImages(index)} style={{ position: "absolute", top: -4, @@ -91,6 +102,79 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp )}
))} + {/* kilocode_change start */} + {files.map((filePath, index) => { + const fileName = filePath.split(/[\\/]/).pop() || filePath + + return ( +
setHoveredIndex(`file-${index}`)} + onMouseLeave={() => setHoveredIndex(null)}> +
handleFileClick(filePath)}> + + + {fileName} + +
+ {isDeletableFiles && hoveredIndex === `file-${index}` && ( +
handleDeleteFiles(index)} + style={{ + position: "absolute", + top: -4, + right: -4, + width: 13, + height: 13, + borderRadius: "50%", + backgroundColor: "var(--vscode-badge-background)", + display: "flex", + justifyContent: "center", + alignItems: "center", + cursor: "pointer", + }}> + +
+ )} +
+ ) + })} + {/* kilocode_change end */}
) }