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