Feat : allow referencing files (#1648)

I implemented file referencing feature mentioned in issue #1591 

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds support for referencing app files in chat using @file:<path>. The
input now autocompletes files, and the backend validates and surfaces
referenced files in the chat context as read-only.

- **New Features**
  - Chat input autocompletes @ mentions for apps, prompts, and files.
- Recognizes @file:<path> and converts it to a structured mention on
submit.
- Backend parses @file mentions, checks file existence, and adds a
“Referenced Files” section to the system message.
- New get-app-files IPC handler and useAppFiles hook to load file paths
for the selected app (appFilesAtom added).
  - e2e test for mentioning a file and capturing server dump.

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Mohamed Aziz Mejri
2025-10-29 17:43:19 +01:00
committed by GitHub
parent a8f3c97396
commit a3997512d2
3 changed files with 1085 additions and 9 deletions

View File

@@ -0,0 +1,15 @@
import { test } from "./helpers/test_helper";
test("mention file", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal-with-ai-rules");
await po.goToAppsTab();
await po.getChatInput().click();
await po.getChatInput().fill("[dump] @");
await po.page.getByRole("menuitem", { name: "Choose AI_RULES.md" }).click();
await po.page.getByRole("button", { name: "Send message" }).click();
await po.waitForChatCompletion();
await po.snapshotServerDump("all-messages");
});

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,7 @@ import { forwardRef } from "react";
import { useAtomValue } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { MENTION_REGEX, parseAppMentions } from "@/shared/parse_mention_apps";
import { useLoadApp } from "@/hooks/useLoadApp";
// Define the theme for mentions
const beautifulMentionsTheme: BeautifulMentionsTheme = {
@@ -38,9 +39,10 @@ const CustomMenuItem = forwardRef<
HTMLLIElement,
BeautifulMentionsMenuItemProps
>(({ selected, item, ...props }, ref) => {
const isPrompt = typeof item !== "string" && item.data?.type === "prompt";
const label = isPrompt ? "Prompt" : "App";
const value = typeof item === "string" ? item : (item as any)?.value;
const isPrompt = item.data?.type === "prompt";
const isApp = item.data?.type === "app";
const label = isPrompt ? "Prompt" : isApp ? "App" : "File";
const value = (item as any)?.value;
return (
<li
className={`m-0 flex items-center px-3 py-2 cursor-pointer whitespace-nowrap ${
@@ -56,7 +58,9 @@ const CustomMenuItem = forwardRef<
className={`px-2 py-0.5 text-xs font-medium rounded-md flex-shrink-0 ${
isPrompt
? "bg-purple-500 text-white"
: "bg-primary text-primary-foreground"
: isApp
? "bg-primary text-primary-foreground"
: "bg-blue-600 text-white"
}`}
>
{label}
@@ -181,7 +185,7 @@ function ExternalValueSyncPlugin({
// Build nodes from internal value, turning @app:Name and @prompt:<id> into mention nodes
let lastIndex = 0;
let match: RegExpExecArray | null;
const combined = /@app:([a-zA-Z0-9_-]+)|@prompt:(\d+)/g;
const combined = /@app:([a-zA-Z0-9_-]+)|@prompt:(\d+)|@file:([^\s]+)/g;
while ((match = combined.exec(value)) !== null) {
const start = match.index;
const full = match[0];
@@ -196,6 +200,9 @@ function ExternalValueSyncPlugin({
const id = Number(match[2]);
const title = promptsById[id] || `prompt:${id}`;
paragraph.append($createBeautifulMentionNode("@", title));
} else if (match[3]) {
const filePath = match[3];
paragraph.append($createBeautifulMentionNode("@", filePath));
}
lastIndex = start + full.length;
}
@@ -243,6 +250,8 @@ export function LexicalChatInput({
const { prompts } = usePrompts();
const [shouldClear, setShouldClear] = useState(false);
const selectedAppId = useAtomValue(selectedAppIdAtom);
const { app } = useLoadApp(selectedAppId);
const appFiles = app?.files;
// Prepare mention items - convert apps to mention format
const mentionItems = React.useMemo(() => {
@@ -271,7 +280,10 @@ export function LexicalChatInput({
return true;
});
const appMentions = filteredApps.map((app) => app.name);
const appMentions = filteredApps.map((app) => ({
value: app.name,
type: "app",
}));
const promptItems = (prompts || []).map((p) => ({
value: p.title,
@@ -279,10 +291,15 @@ export function LexicalChatInput({
id: p.id,
}));
const fileItems = (appFiles || []).map((item) => ({
value: item,
type: "file",
}));
return {
"@": [...appMentions, ...promptItems],
"@": [...appMentions, ...promptItems, ...fileItems],
};
}, [apps, selectedAppId, value, excludeCurrentApp, prompts]);
}, [apps, selectedAppId, value, excludeCurrentApp, prompts, appFiles]);
const initialConfig = {
namespace: "ChatInput",
@@ -325,11 +342,20 @@ export function LexicalChatInput({
const regex = new RegExp(`@(${escapedTitle})(?![\\w-])`, "g");
textContent = textContent.replace(regex, `@prompt:${id}`);
}
for (const fullPath of appFiles || []) {
const escapedDisplay = fullPath.replace(
/[.*+?^${}()|[\]\\]/g,
"\\$&",
);
const fileRegex = new RegExp(`@(${escapedDisplay})(?![\\w-])`, "g");
textContent = textContent.replace(fileRegex, `@file:${fullPath}`);
}
}
onChange(textContent);
});
},
[onChange, apps, prompts],
[onChange, apps, prompts, appFiles],
);
const handleSubmit = useCallback(() => {