diff --git a/app/components/workbench/FileTree.tsx b/app/components/workbench/FileTree.tsx
index f762ab7d..bcf8415f 100644
--- a/app/components/workbench/FileTree.tsx
+++ b/app/components/workbench/FileTree.tsx
@@ -442,6 +442,38 @@ function FileContextMenu({
}
};
+ const handleTargetFile = () => {
+ try {
+ if (isFolder) {
+ return;
+ }
+
+ const success = workbenchStore.targetFile(fullPath);
+ if (success) {
+ toast.success(`File targeted`);
+ }
+ } catch (error) {
+ toast.error('Error targeting file');
+ logger.error(error);
+ }
+ };
+
+ const handleUnTargetFile = () => {
+ try {
+ if (isFolder) {
+ return;
+ }
+
+ const success = workbenchStore.unTargetFile(fullPath);
+ if (success) {
+ toast.success(`File un-targeted`);
+ }
+ } catch (error) {
+ toast.error('Error removing target');
+ logger.error(error);
+ }
+ };
+
// Handler for locking a folder with full lock
const handleLockFolder = () => {
try {
@@ -537,6 +569,18 @@ function FileContextMenu({
Unlock File
+
+
+
+
+
+
>
) : (
<>
@@ -643,6 +687,7 @@ function File({
// Check if the file is locked
const { locked } = workbenchStore.isFileLocked(fullPath);
+ const isTargeted = workbenchStore.isFileTargeted(fullPath);
const fileModifications = fileHistory[fullPath];
@@ -716,6 +761,9 @@ function File({
title={'File is locked'}
/>
)}
+ {isTargeted && (
+
+ )}
{unsavedChanges && }
diff --git a/app/lib/persistence/targetedFiles.ts b/app/lib/persistence/targetedFiles.ts
new file mode 100644
index 00000000..a0602351
--- /dev/null
+++ b/app/lib/persistence/targetedFiles.ts
@@ -0,0 +1,98 @@
+export const TARGETED_FILES_KEY = 'bolt.targetedFiles';
+
+export interface TargetedFile {
+ chatId: string;
+ path: string;
+}
+
+let targetedFilesCache: TargetedFile[] | null = null;
+const targetedFilesMap = new Map>();
+
+function getChatSet(chatId: string, create = false): Set | undefined {
+ if (create && !targetedFilesMap.has(chatId)) {
+ targetedFilesMap.set(chatId, new Set());
+ }
+ return targetedFilesMap.get(chatId);
+}
+
+function initializeCache(): TargetedFile[] {
+ if (targetedFilesCache !== null) {
+ return targetedFilesCache;
+ }
+ try {
+ if (typeof localStorage !== 'undefined') {
+ const json = localStorage.getItem(TARGETED_FILES_KEY);
+ if (json) {
+ const items = JSON.parse(json) as TargetedFile[];
+ targetedFilesCache = items;
+ rebuildLookup(items);
+ return items;
+ }
+ }
+ targetedFilesCache = [];
+ return [];
+ } catch {
+ targetedFilesCache = [];
+ return [];
+ }
+}
+
+function rebuildLookup(items: TargetedFile[]): void {
+ targetedFilesMap.clear();
+ for (const item of items) {
+ let set = targetedFilesMap.get(item.chatId);
+ if (!set) {
+ set = new Set();
+ targetedFilesMap.set(item.chatId, set);
+ }
+ set.add(item.path);
+ }
+}
+
+export function saveTargetedFiles(items: TargetedFile[]): void {
+ targetedFilesCache = [...items];
+ rebuildLookup(items);
+ try {
+ if (typeof localStorage !== 'undefined') {
+ localStorage.setItem(TARGETED_FILES_KEY, JSON.stringify(items));
+ }
+ } catch {}
+}
+
+export function getTargetedFiles(): TargetedFile[] {
+ return initializeCache();
+}
+
+export function addTargetedFile(chatId: string, path: string): void {
+ const files = getTargetedFiles();
+ const set = getChatSet(chatId, true)!;
+ set.add(path);
+ const filtered = files.filter((f) => !(f.chatId === chatId && f.path === path));
+ filtered.push({ chatId, path });
+ saveTargetedFiles(filtered);
+}
+
+export function removeTargetedFile(chatId: string, path: string): void {
+ const files = getTargetedFiles();
+ const set = getChatSet(chatId);
+ if (set) set.delete(path);
+ const filtered = files.filter((f) => !(f.chatId === chatId && f.path === path));
+ saveTargetedFiles(filtered);
+}
+
+export function isFileTargeted(chatId: string, path: string): boolean {
+ initializeCache();
+ const set = getChatSet(chatId);
+ return set ? set.has(path) : false;
+}
+
+export function getTargetedFilesForChat(chatId: string): string[] {
+ initializeCache();
+ const set = getChatSet(chatId);
+ return set ? Array.from(set) : [];
+}
+
+export function clearCache(): void {
+ targetedFilesCache = null;
+ targetedFilesMap.clear();
+}
diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts
index a7234c49..8e49da82 100644
--- a/app/lib/runtime/action-runner.ts
+++ b/app/lib/runtime/action-runner.ts
@@ -6,6 +6,8 @@ import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser';
import type { BoltShell } from '~/utils/shell';
+import { isFileLocked } from '~/utils/fileLocks';
+import { isFileTargeted, hasTargetedFiles } from '~/utils/targetFiles';
const logger = createScopedLogger('ActionRunner');
@@ -304,6 +306,16 @@ export class ActionRunner {
unreachable('Expected file action');
}
+ if (isFileLocked(action.filePath).locked) {
+ logger.warn(`Skipping locked file ${action.filePath}`);
+ return;
+ }
+
+ if (hasTargetedFiles() && !isFileTargeted(action.filePath)) {
+ logger.info(`Skipping non-targeted file ${action.filePath}`);
+ return;
+ }
+
const webcontainer = await this.#webcontainer;
const relativePath = nodePath.relative(webcontainer.workdir, action.filePath);
diff --git a/app/lib/stores/files.ts b/app/lib/stores/files.ts
index d4b87198..8097df4b 100644
--- a/app/lib/stores/files.ts
+++ b/app/lib/stores/files.ts
@@ -20,6 +20,11 @@ import {
migrateLegacyLocks,
clearCache,
} from '~/lib/persistence/lockedFiles';
+import {
+ getTargetedFilesForChat,
+ addTargetedFile,
+ removeTargetedFile,
+} from '~/lib/persistence/targetedFiles';
import { getCurrentChatId } from '~/utils/fileLocks';
const logger = createScopedLogger('FilesStore');
@@ -31,6 +36,7 @@ export interface File {
content: string;
isBinary: boolean;
isLocked?: boolean;
+ isTargeted?: boolean;
lockedByFolder?: string; // Path of the folder that locked this file
}
@@ -95,6 +101,8 @@ export class FilesStore {
// Load locked files from localStorage
this.#loadLockedFiles();
+ // Load targeted files from localStorage
+ this.#loadTargetedFiles();
if (import.meta.hot) {
// Persist our state across hot reloads
@@ -193,6 +201,38 @@ export class FilesStore {
}
}
+ /**
+ * Load targeted files from localStorage and mark them in the store
+ * @param chatId Optional chat ID (defaults to current chat)
+ */
+ #loadTargetedFiles(chatId?: string) {
+ try {
+ const currentChatId = chatId || getCurrentChatId();
+ const targeted = getTargetedFilesForChat(currentChatId);
+
+ if (targeted.length === 0) {
+ return;
+ }
+
+ const currentFiles = this.files.get();
+ const updates: FileMap = {};
+
+ for (const path of targeted) {
+ const file = currentFiles[path];
+
+ if (file?.type === 'file') {
+ updates[path] = { ...file, isTargeted: true };
+ }
+ }
+
+ if (Object.keys(updates).length > 0) {
+ this.files.set({ ...currentFiles, ...updates });
+ }
+ } catch (error) {
+ logger.error('Failed to load targeted files from localStorage', error);
+ }
+ }
+
/**
* Apply a lock to all files within a folder
* @param currentFiles Current file map
@@ -323,6 +363,40 @@ export class FilesStore {
return true;
}
+ /**
+ * Mark a file as targeted for AI edits
+ */
+ targetFile(filePath: string, chatId?: string) {
+ const file = this.getFile(filePath);
+ const currentChatId = chatId || getCurrentChatId();
+
+ if (!file) {
+ logger.error(`Cannot target non-existent file: ${filePath}`);
+ return false;
+ }
+
+ this.files.setKey(filePath, { ...file, isTargeted: true });
+ addTargetedFile(currentChatId, filePath);
+ return true;
+ }
+
+ /**
+ * Remove a file from targeted list
+ */
+ unTargetFile(filePath: string, chatId?: string) {
+ const file = this.getFile(filePath);
+ const currentChatId = chatId || getCurrentChatId();
+
+ if (!file) {
+ logger.error(`Cannot untarget non-existent file: ${filePath}`);
+ return false;
+ }
+
+ this.files.setKey(filePath, { ...file, isTargeted: false });
+ removeTargetedFile(currentChatId, filePath);
+ return true;
+ }
+
/**
* Unlock a folder and all its contents
* @param folderPath Path to the folder to unlock
@@ -440,6 +514,20 @@ export class FilesStore {
return { locked: false };
}
+ /**
+ * Check if a file is targeted for AI edits
+ */
+ isFileTargeted(filePath: string, chatId?: string): boolean {
+ const currentChatId = chatId || getCurrentChatId();
+ const file = this.getFile(filePath);
+
+ if (file?.isTargeted) {
+ return true;
+ }
+
+ return getTargetedFilesForChat(currentChatId).includes(filePath);
+ }
+
/**
* Check if a file is within a locked folder
* @param filePath Path to the file to check
diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts
index e90114cd..a2d209d3 100644
--- a/app/lib/stores/workbench.ts
+++ b/app/lib/stores/workbench.ts
@@ -312,6 +312,14 @@ export class WorkbenchStore {
return this.#filesStore.unlockFile(filePath);
}
+ targetFile(filePath: string) {
+ return this.#filesStore.targetFile(filePath);
+ }
+
+ unTargetFile(filePath: string) {
+ return this.#filesStore.unTargetFile(filePath);
+ }
+
/**
* Unlock a folder and all its contents to allow edits
* @param folderPath Path to the folder to unlock
@@ -330,6 +338,10 @@ export class WorkbenchStore {
return this.#filesStore.isFileLocked(filePath);
}
+ isFileTargeted(filePath: string) {
+ return this.#filesStore.isFileTargeted(filePath);
+ }
+
/**
* Check if a folder is locked
* @param folderPath Path to the folder to check
diff --git a/app/utils/targetFiles.ts b/app/utils/targetFiles.ts
new file mode 100644
index 00000000..e26c2457
--- /dev/null
+++ b/app/utils/targetFiles.ts
@@ -0,0 +1,47 @@
+import {
+ addTargetedFile,
+ removeTargetedFile,
+ isFileTargeted as isFileTargetedInternal,
+ getTargetedFiles,
+} from '~/lib/persistence/targetedFiles';
+import { createScopedLogger } from './logger';
+
+const logger = createScopedLogger('TargetFiles');
+
+export function getCurrentChatId(): string {
+ try {
+ if (typeof window !== 'undefined') {
+ const match = window.location.pathname.match(/\/chat\/([^/]+)/);
+ if (match && match[1]) {
+ return match[1];
+ }
+ }
+ return 'default';
+ } catch (error) {
+ logger.error('Failed to get current chat ID', error);
+ return 'default';
+ }
+}
+
+export function isFileTargeted(filePath: string, chatId?: string): boolean {
+ try {
+ const currentChatId = chatId || getCurrentChatId();
+ return isFileTargetedInternal(currentChatId, filePath);
+ } catch (error) {
+ logger.error('Failed to check if file is targeted', error);
+ return false;
+ }
+}
+
+export function hasTargetedFiles(chatId?: string): boolean {
+ try {
+ const currentChatId = chatId || getCurrentChatId();
+ const files = getTargetedFiles();
+ return files.some((f) => f.chatId === currentChatId);
+ } catch (error) {
+ logger.error('Failed to check for targeted files', error);
+ return false;
+ }
+}
+
+export { addTargetedFile, removeTargetedFile } from '~/lib/persistence/targetedFiles';