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:
committed by
GitHub
parent
a8f3c97396
commit
a3997512d2
15
e2e-tests/mention_files.spec.ts
Normal file
15
e2e-tests/mention_files.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
1035
e2e-tests/snapshots/mention_files.spec.ts_mention-file-1.txt
Normal file
1035
e2e-tests/snapshots/mention_files.spec.ts_mention-file-1.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user