feat(filesystem): refactor local filesystem handling to use mounts instead of root paths, enhancing mount management and path normalization

This commit is contained in:
Anish Sarkar 2026-04-24 05:59:21 +05:30
parent a7a758f26e
commit 30b55a9baa
16 changed files with 421 additions and 80 deletions

View file

@ -159,7 +159,7 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
/**
* Tools that should render custom UI in the chat.
*/
const TOOLS_WITH_UI = new Set([
const BASE_TOOLS_WITH_UI = new Set([
"web_search",
"generate_podcast",
"generate_report",
@ -211,6 +211,7 @@ export default function NewChatPage() {
assistantMsgId: string;
interruptData: Record<string, unknown>;
} | null>(null);
const toolsWithUI = useMemo(() => new Set([...BASE_TOOLS_WITH_UI]), []);
// Get disabled tools from the tool toggle UI
const disabledTools = useAtomValue(disabledToolsAtom);
@ -660,7 +661,8 @@ export default function NewChatPage() {
const selection = await getAgentFilesystemSelection();
if (
selection.filesystem_mode === "desktop_local_folder" &&
(!selection.local_filesystem_roots || selection.local_filesystem_roots.length === 0)
(!selection.local_filesystem_mounts ||
selection.local_filesystem_mounts.length === 0)
) {
toast.error("Select a local folder before using Local Folder mode.");
return;
@ -702,7 +704,7 @@ export default function NewChatPage() {
search_space_id: searchSpaceId,
filesystem_mode: selection.filesystem_mode,
client_platform: selection.client_platform,
local_filesystem_roots: selection.local_filesystem_roots,
local_filesystem_mounts: selection.local_filesystem_mounts,
messages: messageHistory,
mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined,
mentioned_surfsense_doc_ids: hasSurfsenseDocIds
@ -721,7 +723,7 @@ export default function NewChatPage() {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
: m
)
);
@ -736,7 +738,7 @@ export default function NewChatPage() {
break;
case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {});
batcher.flush();
break;
@ -746,7 +748,7 @@ export default function NewChatPage() {
} else {
addToolCall(
contentPartsState,
TOOLS_WITH_UI,
toolsWithUI,
parsed.toolCallId,
parsed.toolName,
parsed.input || {}
@ -842,7 +844,7 @@ export default function NewChatPage() {
const tcId = `interrupt-${action.name}`;
addToolCall(
contentPartsState,
TOOLS_WITH_UI,
toolsWithUI,
tcId,
action.name,
action.args,
@ -856,7 +858,7 @@ export default function NewChatPage() {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
: m
)
);
@ -883,7 +885,7 @@ export default function NewChatPage() {
batcher.flush();
// Skip persistence for interrupted messages -- handleResume will persist the final version
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
const finalContent = buildContentForPersistence(contentPartsState, toolsWithUI);
if (contentParts.length > 0 && !wasInterrupted) {
try {
const savedMessage = await appendMessage(currentThreadId, {
@ -919,10 +921,10 @@ export default function NewChatPage() {
const hasContent = contentParts.some(
(part) =>
(part.type === "text" && part.text.length > 0) ||
(part.type === "tool-call" && TOOLS_WITH_UI.has(part.toolName))
(part.type === "tool-call" && toolsWithUI.has(part.toolName))
);
if (hasContent && currentThreadId) {
const partialContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
const partialContent = buildContentForPersistence(contentPartsState, toolsWithUI);
try {
const savedMessage = await appendMessage(currentThreadId, {
role: "assistant",
@ -1098,7 +1100,7 @@ export default function NewChatPage() {
decisions,
filesystem_mode: selection.filesystem_mode,
client_platform: selection.client_platform,
local_filesystem_roots: selection.local_filesystem_roots,
local_filesystem_mounts: selection.local_filesystem_mounts,
}),
signal: controller.signal,
});
@ -1111,7 +1113,7 @@ export default function NewChatPage() {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
: m
)
);
@ -1126,7 +1128,7 @@ export default function NewChatPage() {
break;
case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {});
batcher.flush();
break;
@ -1138,7 +1140,7 @@ export default function NewChatPage() {
} else {
addToolCall(
contentPartsState,
TOOLS_WITH_UI,
toolsWithUI,
parsed.toolCallId,
parsed.toolName,
parsed.input || {}
@ -1189,7 +1191,7 @@ export default function NewChatPage() {
const tcId = `interrupt-${action.name}`;
addToolCall(
contentPartsState,
TOOLS_WITH_UI,
toolsWithUI,
tcId,
action.name,
action.args,
@ -1206,7 +1208,7 @@ export default function NewChatPage() {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
: m
)
);
@ -1230,7 +1232,7 @@ export default function NewChatPage() {
batcher.flush();
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
const finalContent = buildContentForPersistence(contentPartsState, toolsWithUI);
if (contentParts.length > 0) {
try {
const savedMessage = await appendMessage(resumeThreadId, {
@ -1435,7 +1437,7 @@ export default function NewChatPage() {
disabled_tools: disabledTools.length > 0 ? disabledTools : undefined,
filesystem_mode: selection.filesystem_mode,
client_platform: selection.client_platform,
local_filesystem_roots: selection.local_filesystem_roots,
local_filesystem_mounts: selection.local_filesystem_mounts,
}),
signal: controller.signal,
});
@ -1448,7 +1450,7 @@ export default function NewChatPage() {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
? { ...m, content: buildContentForUI(contentPartsState, toolsWithUI) }
: m
)
);
@ -1463,7 +1465,7 @@ export default function NewChatPage() {
break;
case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
addToolCall(contentPartsState, toolsWithUI, parsed.toolCallId, parsed.toolName, {});
batcher.flush();
break;
@ -1473,7 +1475,7 @@ export default function NewChatPage() {
} else {
addToolCall(
contentPartsState,
TOOLS_WITH_UI,
toolsWithUI,
parsed.toolCallId,
parsed.toolName,
parsed.input || {}
@ -1522,7 +1524,7 @@ export default function NewChatPage() {
batcher.flush();
// Persist messages after streaming completes
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
const finalContent = buildContentForPersistence(contentPartsState, toolsWithUI);
if (contentParts.length > 0) {
try {
// Persist user message (for both edit and reload modes, since backend deleted it)

View file

@ -226,10 +226,16 @@ function extractDomain(url: string): string {
}
}
const LOCAL_FILE_PATH_REGEX = /^\/(?:[^/\s`]+\/)*[^/\s`]+\.[^/\s`]+$/;
// Canonical local-file virtual paths are mount-prefixed: /<mount>/<relative/path>
const LOCAL_FILE_PATH_REGEX = /^\/[a-z0-9_-]+\/[^\s`]+(?:\/[^\s`]+)*$/;
function isVirtualFilePathToken(value: string): boolean {
return LOCAL_FILE_PATH_REGEX.test(value);
if (!LOCAL_FILE_PATH_REGEX.test(value) || value.startsWith("//")) {
return false;
}
const normalized = value.replace(/\/+$/, "");
const segments = normalized.split("/").filter(Boolean);
return segments.length >= 2;
}
function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {

View file

@ -31,6 +31,12 @@ export function SourceCodeEditor({
const { resolvedTheme } = useTheme();
const onSaveRef = useRef(onSave);
const monacoRef = useRef<any>(null);
const normalizedModelPath = (() => {
const raw = (path || "local-file.txt").trim();
const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`;
// Monaco model paths should be stable and POSIX-like across platforms.
return withLeadingSlash.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
})();
useEffect(() => {
onSaveRef.current = onSave;
@ -82,7 +88,7 @@ export function SourceCodeEditor({
return (
<div className="h-full w-full overflow-hidden bg-sidebar [&_.monaco-editor]:!bg-sidebar [&_.monaco-editor_.margin]:!bg-sidebar [&_.monaco-editor_.monaco-editor-background]:!bg-sidebar [&_.monaco-editor-background]:!bg-sidebar [&_.monaco-scrollable-element_.scrollbar_.slider]:rounded-full [&_.monaco-scrollable-element_.scrollbar_.slider]:bg-foreground/25 [&_.monaco-scrollable-element_.scrollbar_.slider:hover]:bg-foreground/40">
<MonacoEditor
path={path}
path={normalizedModelPath}
language={language}
value={value}
theme={resolvedTheme === "dark" ? "surfsense-dark" : "surfsense-light"}

View file

@ -1,10 +1,15 @@
export type AgentFilesystemMode = "cloud" | "desktop_local_folder";
export type ClientPlatform = "web" | "desktop";
export interface AgentFilesystemMountSelection {
mount_id: string;
root_path: string;
}
export interface AgentFilesystemSelection {
filesystem_mode: AgentFilesystemMode;
client_platform: ClientPlatform;
local_filesystem_roots?: string[];
local_filesystem_mounts?: AgentFilesystemMountSelection[];
}
const DEFAULT_SELECTION: AgentFilesystemSelection = {
@ -24,12 +29,23 @@ export async function getAgentFilesystemSelection(): Promise<AgentFilesystemSele
}
try {
const settings = await window.electronAPI.getAgentFilesystemSettings();
const firstLocalRootPath = settings.localRootPaths[0];
if (settings.mode === "desktop_local_folder" && firstLocalRootPath) {
if (settings.mode === "desktop_local_folder") {
const mounts = await window.electronAPI.getAgentFilesystemMounts?.();
const localFilesystemMounts =
mounts?.map((entry) => ({
mount_id: entry.mount,
root_path: entry.rootPath,
})) ?? [];
if (localFilesystemMounts.length === 0) {
return {
filesystem_mode: "cloud",
client_platform: "desktop",
};
}
return {
filesystem_mode: "desktop_local_folder",
client_platform: "desktop",
local_filesystem_roots: settings.localRootPaths,
local_filesystem_mounts: localFilesystemMounts,
};
}
return {