Merge pull request #1299 from AnishSarkar22/feat/swappable-filesystem
Some checks failed
Build and Push Docker Images / tag_release (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Has been cancelled
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Has been cancelled

feat: introduce swappable filesystem on desktop & monaco editor to edit local files
This commit is contained in:
Rohan Verma 2026-04-23 19:38:33 -07:00 committed by GitHub
commit a0f2851784
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 4818 additions and 418 deletions

View file

@ -7,16 +7,20 @@ import {
unstable_memoizeMarkdownComponents as memoizeMarkdownComponents,
useIsMarkdownCodeBlock,
} from "@assistant-ui/react-markdown";
import { useSetAtom } from "jotai";
import { ExternalLinkIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
import { memo, type ReactNode } from "react";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image";
import "katex/dist/katex.min.css";
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
import { useElectronAPI } from "@/hooks/use-platform";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
@ -222,6 +226,18 @@ function extractDomain(url: string): string {
}
}
// 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 {
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 }) {
if (!src) return null;
@ -392,7 +408,51 @@ const defaultComponents = memoizeMarkdownComponents({
code: function Code({ className, children, ...props }) {
const isCodeBlock = useIsMarkdownCodeBlock();
const { resolvedTheme } = useTheme();
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const params = useParams();
const electronAPI = useElectronAPI();
const language = /language-(\w+)/.exec(className || "")?.[1] ?? "text";
const codeString = String(children).replace(/\n$/, "");
const isWebLocalFileCodeBlock =
isCodeBlock &&
!electronAPI &&
isVirtualFilePathToken(codeString.trim()) &&
!codeString.trim().startsWith("//") &&
!codeString.includes("\n");
if (!isCodeBlock) {
const inlineValue = String(children ?? "").trim();
const isLocalPath =
!!electronAPI && isVirtualFilePathToken(inlineValue) && !inlineValue.startsWith("//");
const displayLocalPath = inlineValue.replace(/^\/+/, "");
const searchSpaceIdParam = params?.search_space_id;
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
? Number(searchSpaceIdParam[0])
: Number(searchSpaceIdParam);
if (isLocalPath) {
return (
<button
type="button"
className={cn(
"cursor-pointer font-mono text-[0.9em] font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openEditorPanel({
kind: "local_file",
localFilePath: inlineValue,
title: inlineValue.split("/").pop() || inlineValue,
searchSpaceId: Number.isFinite(parsedSearchSpaceId)
? parsedSearchSpaceId
: undefined,
});
}}
title="Open in editor panel"
>
{displayLocalPath}
</button>
);
}
return (
<code
className={cn(
@ -405,8 +465,19 @@ const defaultComponents = memoizeMarkdownComponents({
</code>
);
}
const language = /language-(\w+)/.exec(className || "")?.[1] ?? "text";
const codeString = String(children).replace(/\n$/, "");
if (isWebLocalFileCodeBlock) {
return (
<code
className={cn(
"aui-md-inline-code rounded-md border bg-muted px-1.5 py-0.5 font-mono text-[0.9em] font-normal",
className
)}
{...props}
>
{codeString.trim()}
</code>
);
}
return (
<LazyMarkdownCodeBlock
className={className}

View file

@ -1104,7 +1104,13 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
group.tools.flatMap((t, i) =>
i === 0
? [t.description]
: [<Dot key={i} className="inline h-4 w-4" />, t.description]
: [
<Dot
key={`dot-${group.label}-${t.description}`}
className="inline h-4 w-4"
/>,
t.description,
]
)}
</TooltipContent>
</Tooltip>

View file

@ -1,6 +1,6 @@
import { ActionBarPrimitive, AuiIf, MessagePrimitive, useAuiState } from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import { CheckIcon, CopyIcon, FileText, Pen } from "lucide-react";
import { CheckIcon, CopyIcon, FileText, Pencil } from "lucide-react";
import Image from "next/image";
import { type FC, useState } from "react";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
@ -136,7 +136,7 @@ const UserActionBar: FC = () => {
{canEdit && (
<ActionBarPrimitive.Edit asChild>
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit">
<Pen />
<Pencil />
</TooltipIconButton>
</ActionBarPrimitive.Edit>
)}

View file

@ -1,6 +1,6 @@
"use client";
import { MoreHorizontal, PenLine, Trash2 } from "lucide-react";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -29,7 +29,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
<DropdownMenuContent align="end">
{canEdit && (
<DropdownMenuItem onClick={onEdit}>
<PenLine className="mr-2 size-4" />
<Pencil className="mr-2 size-4" />
Edit
</DropdownMenuItem>
)}

View file

@ -8,7 +8,7 @@ import {
History,
MoreHorizontal,
Move,
PenLine,
Pencil,
Trash2,
} from "lucide-react";
import React, { useCallback, useRef, useState } from "react";
@ -266,7 +266,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</DropdownMenuItem>
{isEditable && (
<DropdownMenuItem onClick={() => onEdit(doc)}>
<PenLine className="mr-2 h-4 w-4" />
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
)}
@ -309,7 +309,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</ContextMenuItem>
{isEditable && (
<ContextMenuItem onClick={() => onEdit(doc)}>
<PenLine className="mr-2 h-4 w-4" />
<Pencil className="mr-2 h-4 w-4" />
Edit
</ContextMenuItem>
)}

View file

@ -12,7 +12,7 @@ import {
FolderPlus,
MoreHorizontal,
Move,
PenLine,
Pencil,
RefreshCw,
Trash2,
} from "lucide-react";
@ -399,7 +399,7 @@ export const FolderNode = React.memo(function FolderNode({
startRename();
}}
>
<PenLine className="mr-2 h-4 w-4" />
<Pencil className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
@ -456,7 +456,7 @@ export const FolderNode = React.memo(function FolderNode({
New subfolder
</ContextMenuItem>
<ContextMenuItem onClick={() => startRename()}>
<PenLine className="mr-2 h-4 w-4" />
<Pencil className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
<ContextMenuItem onClick={() => onMove(folder)}>

View file

@ -1,18 +1,31 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { Download, FileQuestionMark, FileText, Loader2, RefreshCw, XIcon } from "lucide-react";
import {
Check,
Copy,
Download,
FileQuestionMark,
FileText,
Pencil,
RefreshCw,
XIcon,
} from "lucide-react";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { VersionHistoryButton } from "@/components/documents/version-history";
import { SourceCodeEditor } from "@/components/editor/source-code-editor";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Spinner } from "@/components/ui/spinner";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
@ -32,6 +45,7 @@ interface EditorContent {
}
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
type EditorRenderMode = "rich_markdown" | "source_code";
function EditorPanelSkeleton() {
return (
@ -54,27 +68,38 @@ function EditorPanelSkeleton() {
}
export function EditorPanelContent({
kind = "document",
documentId,
localFilePath,
searchSpaceId,
title,
onClose,
}: {
documentId: number;
searchSpaceId: number;
kind?: "document" | "local_file";
documentId?: number;
localFilePath?: string;
searchSpaceId?: number;
title: string | null;
onClose?: () => void;
}) {
const electronAPI = useElectronAPI();
const [editorDoc, setEditorDoc] = useState<EditorContent | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [downloading, setDownloading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
const [localFileContent, setLocalFileContent] = useState("");
const [hasCopied, setHasCopied] = useState(false);
const markdownRef = useRef<string>("");
const copyResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initialLoadDone = useRef(false);
const changeCountRef = useRef(0);
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
const isLocalFileMode = kind === "local_file";
const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown";
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
@ -84,17 +109,48 @@ export function EditorPanelContent({
setError(null);
setEditorDoc(null);
setEditedMarkdown(null);
setLocalFileContent("");
setHasCopied(false);
setIsEditing(false);
initialLoadDone.current = false;
changeCountRef.current = 0;
const doFetch = async () => {
const token = getBearerToken();
if (!token) {
redirectToLogin();
return;
}
try {
if (isLocalFileMode) {
if (!localFilePath) {
throw new Error("Missing local file path");
}
if (!electronAPI?.readAgentLocalFileText) {
throw new Error("Local file editor is available only in desktop mode.");
}
const readResult = await electronAPI.readAgentLocalFileText(localFilePath);
if (!readResult.ok) {
throw new Error(readResult.error || "Failed to read local file");
}
const inferredTitle = localFilePath.split("/").pop() || localFilePath;
const content: EditorContent = {
document_id: -1,
title: inferredTitle,
document_type: "NOTE",
source_markdown: readResult.content,
};
markdownRef.current = content.source_markdown;
setLocalFileContent(content.source_markdown);
setDisplayTitle(title || inferredTitle);
setEditorDoc(content);
initialLoadDone.current = true;
return;
}
if (!documentId || !searchSpaceId) {
throw new Error("Missing document context");
}
const token = getBearerToken();
if (!token) {
redirectToLogin();
return;
}
const url = new URL(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
);
@ -136,7 +192,15 @@ export function EditorPanelContent({
doFetch().catch(() => {});
return () => controller.abort();
}, [documentId, searchSpaceId, title]);
}, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId, title]);
useEffect(() => {
return () => {
if (copyResetTimeoutRef.current) {
clearTimeout(copyResetTimeoutRef.current);
}
};
}, []);
const handleMarkdownChange = useCallback((md: string) => {
markdownRef.current = md;
@ -146,16 +210,55 @@ export function EditorPanelContent({
setEditedMarkdown(md);
}, []);
const handleSave = useCallback(async () => {
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
redirectToLogin();
return;
const handleCopy = useCallback(async () => {
try {
const textToCopy = markdownRef.current ?? editorDoc?.source_markdown ?? "";
await navigator.clipboard.writeText(textToCopy);
setHasCopied(true);
if (copyResetTimeoutRef.current) {
clearTimeout(copyResetTimeoutRef.current);
}
copyResetTimeoutRef.current = setTimeout(() => {
setHasCopied(false);
}, 1400);
} catch (err) {
console.error("Error copying content:", err);
}
}, [editorDoc?.source_markdown]);
const handleSave = useCallback(async (options?: { silent?: boolean }) => {
setSaving(true);
try {
if (isLocalFileMode) {
if (!localFilePath) {
throw new Error("Missing local file path");
}
if (!electronAPI?.writeAgentLocalFileText) {
throw new Error("Local file editor is available only in desktop mode.");
}
const contentToSave = markdownRef.current;
const writeResult = await electronAPI.writeAgentLocalFileText(
localFilePath,
contentToSave
);
if (!writeResult.ok) {
throw new Error(writeResult.error || "Failed to save local file");
}
setEditorDoc((prev) =>
prev ? { ...prev, source_markdown: contentToSave } : prev
);
setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current);
return true;
}
if (!searchSpaceId || !documentId) {
throw new Error("Missing document context");
}
const token = getBearerToken();
if (!token) {
toast.error("Please login to save");
redirectToLogin();
return;
}
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
{
@ -175,39 +278,190 @@ export function EditorPanelContent({
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
setEditedMarkdown(null);
toast.success("Document saved! Reindexing in background...");
return true;
} catch (err) {
console.error("Error saving document:", err);
toast.error(err instanceof Error ? err.message : "Failed to save document");
return false;
} finally {
setSaving(false);
}
}, [documentId, searchSpaceId]);
}, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId]);
const isEditableType = editorDoc
? EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "") && !isLargeDocument
? (editorRenderMode === "source_code" ||
EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
!isLargeDocument
: false;
const hasUnsavedChanges = editedMarkdown !== null;
const showDesktopHeader = !!onClose;
const showEditingActions = isEditableType && isEditing;
const localFileLanguage = inferMonacoLanguageFromPath(localFilePath);
const handleCancelEditing = useCallback(() => {
const savedContent = editorDoc?.source_markdown ?? "";
markdownRef.current = savedContent;
setLocalFileContent(savedContent);
setEditedMarkdown(null);
changeCountRef.current = 0;
setIsEditing(false);
}, [editorDoc?.source_markdown]);
return (
<>
<div className="flex items-center justify-between px-4 py-2 shrink-0 border-b">
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
{isEditableType && editedMarkdown !== null && (
<p className="text-[10px] text-muted-foreground">Unsaved changes</p>
)}
{showDesktopHeader ? (
<div className="shrink-0 border-b">
<div className="flex h-14 items-center justify-between px-4">
<h2 className="text-lg font-medium text-muted-foreground select-none">File</h2>
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close editor panel</span>
</Button>
</div>
</div>
<div className="flex h-10 items-center justify-between gap-2 border-t px-4">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-muted-foreground">{displayTitle}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{showEditingActions ? (
<>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleCancelEditing}
disabled={saving}
>
Cancel
</Button>
<Button
variant="secondary"
size="sm"
className="relative h-6 w-[56px] px-0 text-xs"
onClick={async () => {
const saveSucceeded = await handleSave({ silent: true });
if (saveSucceeded) setIsEditing(false);
}}
disabled={saving || !hasUnsavedChanges}
>
<span className={saving ? "opacity-0" : ""}>Save</span>
{saving && <Spinner size="xs" className="absolute" />}
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
void handleCopy();
}}
disabled={isLoading || !editorDoc}
>
{hasCopied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
<span className="sr-only">
{hasCopied ? "Copied file contents" : "Copy file contents"}
</span>
</Button>
{isEditableType && (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
changeCountRef.current = 0;
setEditedMarkdown(null);
setIsEditing(true);
}}
>
<Pencil className="size-3.5" />
<span className="sr-only">Edit document</span>
</Button>
)}
</>
)}
{!showEditingActions && !isLocalFileMode && editorDoc?.document_type && documentId && (
<VersionHistoryButton documentId={documentId} documentType={editorDoc.document_type} />
)}
</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{editorDoc?.document_type && (
<VersionHistoryButton documentId={documentId} documentType={editorDoc.document_type} />
)}
{onClose && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close editor panel</span>
</Button>
)}
) : (
<div className="flex h-14 items-center justify-between border-b px-4 shrink-0">
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
</div>
<div className="flex items-center gap-1 shrink-0">
{showEditingActions ? (
<>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleCancelEditing}
disabled={saving}
>
Cancel
</Button>
<Button
variant="secondary"
size="sm"
className="relative h-6 w-[56px] px-0 text-xs"
onClick={async () => {
const saveSucceeded = await handleSave({ silent: true });
if (saveSucceeded) setIsEditing(false);
}}
disabled={saving || !hasUnsavedChanges}
>
<span className={saving ? "opacity-0" : ""}>Save</span>
{saving && <Spinner size="xs" className="absolute" />}
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
void handleCopy();
}}
disabled={isLoading || !editorDoc}
>
{hasCopied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
<span className="sr-only">
{hasCopied ? "Copied file contents" : "Copy file contents"}
</span>
</Button>
{isEditableType && (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
changeCountRef.current = 0;
setEditedMarkdown(null);
setIsEditing(true);
}}
>
<Pencil className="size-3.5" />
<span className="sr-only">Edit document</span>
</Button>
)}
{!isLocalFileMode && editorDoc?.document_type && documentId && (
<VersionHistoryButton
documentId={documentId}
documentType={editorDoc.document_type}
/>
)}
</>
)}
</div>
</div>
</div>
)}
<div className="flex-1 overflow-hidden">
{isLoading ? (
@ -234,7 +488,7 @@ export function EditorPanelContent({
</p>
</div>
</div>
) : isLargeDocument ? (
) : isLargeDocument && !isLocalFileMode ? (
<div className="h-full overflow-y-auto px-5 py-4">
<Alert className="mb-4">
<FileText className="size-4" />
@ -252,6 +506,9 @@ export function EditorPanelContent({
onClick={async () => {
setDownloading(true);
try {
if (!searchSpaceId || !documentId) {
throw new Error("Missing document context");
}
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
{ method: "GET" }
@ -277,7 +534,7 @@ export function EditorPanelContent({
}}
>
{downloading ? (
<Loader2 className="size-3.5 animate-spin" />
<Spinner size="xs" />
) : (
<Download className="size-3.5" />
)}
@ -287,19 +544,36 @@ export function EditorPanelContent({
</Alert>
<MarkdownViewer content={editorDoc.source_markdown} />
</div>
) : editorRenderMode === "source_code" ? (
<div className="h-full overflow-hidden">
<SourceCodeEditor
path={localFilePath ?? "local-file.txt"}
language={localFileLanguage}
value={localFileContent}
onSave={() => {
void handleSave({ silent: true });
}}
readOnly={!isEditing}
onChange={(next) => {
markdownRef.current = next;
setLocalFileContent(next);
if (!initialLoadDone.current) return;
setEditedMarkdown(next === (editorDoc?.source_markdown ?? "") ? null : next);
}}
/>
</div>
) : isEditableType ? (
<PlateEditor
key={documentId}
key={`${isLocalFileMode ? localFilePath ?? "local-file" : documentId}-${isEditing ? "editing" : "viewing"}`}
preset="full"
markdown={editorDoc.source_markdown}
onMarkdownChange={handleMarkdownChange}
readOnly={false}
readOnly={!isEditing}
placeholder="Start writing..."
editorVariant="default"
onSave={handleSave}
hasUnsavedChanges={editedMarkdown !== null}
isSaving={saving}
defaultEditing={true}
allowModeToggle={false}
reserveToolbarSpace
defaultEditing={isEditing}
className="[&_[role=toolbar]]:!bg-sidebar"
/>
) : (
@ -324,13 +598,19 @@ function DesktopEditorPanel() {
return () => document.removeEventListener("keydown", handleKeyDown);
}, [closePanel]);
if (!panelState.isOpen || !panelState.documentId || !panelState.searchSpaceId) return null;
const hasTarget =
panelState.kind === "document"
? !!panelState.documentId && !!panelState.searchSpaceId
: !!panelState.localFilePath;
if (!panelState.isOpen || !hasTarget) return null;
return (
<div className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out">
<EditorPanelContent
documentId={panelState.documentId}
searchSpaceId={panelState.searchSpaceId}
kind={panelState.kind}
documentId={panelState.documentId ?? undefined}
localFilePath={panelState.localFilePath ?? undefined}
searchSpaceId={panelState.searchSpaceId ?? undefined}
title={panelState.title}
onClose={closePanel}
/>
@ -342,7 +622,13 @@ function MobileEditorDrawer() {
const panelState = useAtomValue(editorPanelAtom);
const closePanel = useSetAtom(closeEditorPanelAtom);
if (!panelState.documentId || !panelState.searchSpaceId) return null;
if (panelState.kind === "local_file") return null;
const hasTarget =
panelState.kind === "document"
? !!panelState.documentId && !!panelState.searchSpaceId
: !!panelState.localFilePath;
if (!hasTarget) return null;
return (
<Drawer
@ -360,8 +646,10 @@ function MobileEditorDrawer() {
<DrawerTitle className="sr-only">{panelState.title || "Editor"}</DrawerTitle>
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<EditorPanelContent
documentId={panelState.documentId}
searchSpaceId={panelState.searchSpaceId}
kind={panelState.kind}
documentId={panelState.documentId ?? undefined}
localFilePath={panelState.localFilePath ?? undefined}
searchSpaceId={panelState.searchSpaceId ?? undefined}
title={panelState.title}
/>
</div>
@ -373,8 +661,13 @@ function MobileEditorDrawer() {
export function EditorPanel() {
const panelState = useAtomValue(editorPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
const hasTarget =
panelState.kind === "document"
? !!panelState.documentId && !!panelState.searchSpaceId
: !!panelState.localFilePath;
if (!panelState.isOpen || !panelState.documentId) return null;
if (!panelState.isOpen || !hasTarget) return null;
if (!isDesktop && panelState.kind === "local_file") return null;
if (isDesktop) {
return <DesktopEditorPanel />;
@ -386,8 +679,12 @@ export function EditorPanel() {
export function MobileEditorPanel() {
const panelState = useAtomValue(editorPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
const hasTarget =
panelState.kind === "document"
? !!panelState.documentId && !!panelState.searchSpaceId
: !!panelState.localFilePath;
if (isDesktop || !panelState.isOpen || !panelState.documentId) return null;
if (isDesktop || !panelState.isOpen || !hasTarget || panelState.kind === "local_file") return null;
return <MobileEditorDrawer />;
}

View file

@ -11,12 +11,15 @@ interface EditorSaveContextValue {
isSaving: boolean;
/** Whether the user can toggle between editing and viewing modes */
canToggleMode: boolean;
/** Whether fixed-toolbar space should be reserved even when controls are hidden */
reserveToolbarSpace: boolean;
}
export const EditorSaveContext = createContext<EditorSaveContextValue>({
hasUnsavedChanges: false,
isSaving: false,
canToggleMode: false,
reserveToolbarSpace: false,
});
export function useEditorSave() {

View file

@ -42,6 +42,10 @@ export interface PlateEditorProps {
hasUnsavedChanges?: boolean;
/** Whether a save is in progress */
isSaving?: boolean;
/** Whether edit/view mode toggle UI should be available in toolbars. */
allowModeToggle?: boolean;
/** Reserve fixed-toolbar vertical space even when controls are hidden. */
reserveToolbarSpace?: boolean;
/** Start the editor in editing mode instead of viewing mode. Ignored when readOnly is true. */
defaultEditing?: boolean;
/**
@ -91,6 +95,8 @@ export function PlateEditor({
onSave,
hasUnsavedChanges = false,
isSaving = false,
allowModeToggle = true,
reserveToolbarSpace = false,
defaultEditing = false,
preset = "full",
extraPlugins = [],
@ -174,7 +180,7 @@ export function PlateEditor({
}, [html, markdown, editor]);
// When not forced read-only, the user can toggle between editing/viewing.
const canToggleMode = !readOnly;
const canToggleMode = !readOnly && allowModeToggle;
const contextProviderValue = useMemo(
() => ({
@ -182,8 +188,9 @@ export function PlateEditor({
hasUnsavedChanges,
isSaving,
canToggleMode,
reserveToolbarSpace,
}),
[onSave, hasUnsavedChanges, isSaving, canToggleMode]
[onSave, hasUnsavedChanges, isSaving, canToggleMode, reserveToolbarSpace]
);
return (

View file

@ -1,19 +1,40 @@
"use client";
import { createPlatePlugin } from "platejs/react";
import { useEditorReadOnly } from "platejs/react";
import { useEditorSave } from "@/components/editor/editor-save-context";
import { FixedToolbar } from "@/components/ui/fixed-toolbar";
import { FixedToolbarButtons } from "@/components/ui/fixed-toolbar-buttons";
function ConditionalFixedToolbar() {
const readOnly = useEditorReadOnly();
const { onSave, hasUnsavedChanges, canToggleMode, reserveToolbarSpace } = useEditorSave();
const hasVisibleControls =
!readOnly || canToggleMode || (!!onSave && hasUnsavedChanges && !readOnly);
if (!hasVisibleControls) {
if (!reserveToolbarSpace) return null;
return (
<FixedToolbar className="pointer-events-none opacity-0">
<div className="h-8 w-full" />
</FixedToolbar>
);
}
return (
<FixedToolbar>
<FixedToolbarButtons />
</FixedToolbar>
);
}
export const FixedToolbarKit = [
createPlatePlugin({
key: "fixed-toolbar",
render: {
beforeEditable: () => (
<FixedToolbar>
<FixedToolbarButtons />
</FixedToolbar>
),
beforeEditable: () => <ConditionalFixedToolbar />,
},
}),
];

View file

@ -0,0 +1,152 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useRef } from "react";
import { useTheme } from "next-themes";
import { Spinner } from "@/components/ui/spinner";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
ssr: false,
});
interface SourceCodeEditorProps {
value: string;
onChange: (next: string) => void;
path?: string;
language?: string;
readOnly?: boolean;
fontSize?: number;
onSave?: () => Promise<void> | void;
}
export function SourceCodeEditor({
value,
onChange,
path,
language = "plaintext",
readOnly = false,
fontSize = 12,
onSave,
}: SourceCodeEditorProps) {
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;
}, [onSave]);
const resolveCssColorToHex = (cssColorValue: string): string | null => {
if (typeof document === "undefined") return null;
const probe = document.createElement("div");
probe.style.color = cssColorValue;
probe.style.position = "absolute";
probe.style.pointerEvents = "none";
probe.style.opacity = "0";
document.body.appendChild(probe);
const computedColor = getComputedStyle(probe).color;
probe.remove();
const match = computedColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (!match) return null;
const toHex = (value: string) => Number(value).toString(16).padStart(2, "0");
return `#${toHex(match[1])}${toHex(match[2])}${toHex(match[3])}`;
};
const applySidebarTheme = (monaco: any) => {
const isDark = resolvedTheme === "dark";
const themeName = isDark ? "surfsense-dark" : "surfsense-light";
const fallbackBg = isDark ? "#1e1e1e" : "#ffffff";
const sidebarBgHex = resolveCssColorToHex("var(--sidebar)") ?? fallbackBg;
monaco.editor.defineTheme(themeName, {
base: isDark ? "vs-dark" : "vs",
inherit: true,
rules: [],
colors: {
"editor.background": sidebarBgHex,
"editorGutter.background": sidebarBgHex,
"minimap.background": sidebarBgHex,
"editorLineNumber.background": sidebarBgHex,
"editor.lineHighlightBackground": "#00000000",
},
});
monaco.editor.setTheme(themeName);
};
useEffect(() => {
if (!monacoRef.current) return;
applySidebarTheme(monacoRef.current);
}, [resolvedTheme]);
const isManualSaveEnabled = !!onSave && !readOnly;
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={normalizedModelPath}
language={language}
value={value}
theme={resolvedTheme === "dark" ? "surfsense-dark" : "surfsense-light"}
onChange={(next) => onChange(next ?? "")}
loading={
<div className="flex h-full w-full items-center justify-center">
<Spinner size="md" className="text-muted-foreground" />
</div>
}
beforeMount={(monaco) => {
monacoRef.current = monaco;
applySidebarTheme(monaco);
}}
onMount={(editor, monaco) => {
monacoRef.current = monaco;
applySidebarTheme(monaco);
if (!isManualSaveEnabled) return;
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
void onSaveRef.current?.();
});
}}
options={{
automaticLayout: true,
minimap: { enabled: false },
lineNumbers: "on",
lineNumbersMinChars: 3,
lineDecorationsWidth: 12,
glyphMargin: false,
folding: true,
overviewRulerLanes: 0,
hideCursorInOverviewRuler: true,
scrollBeyondLastLine: false,
renderLineHighlight: "none",
selectionHighlight: false,
occurrencesHighlight: "off",
quickSuggestions: false,
suggestOnTriggerCharacters: false,
acceptSuggestionOnEnter: "off",
parameterHints: { enabled: false },
wordBasedSuggestions: "off",
wordWrap: "off",
scrollbar: {
vertical: "auto",
horizontal: "auto",
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
alwaysConsumeMouseWheel: false,
},
tabSize: 2,
insertSpaces: true,
fontSize,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",
renderWhitespace: "selection",
smoothScrolling: true,
readOnly,
}}
/>
</div>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { PanelRight, PanelRightClose } from "lucide-react";
import { PanelRight } from "lucide-react";
import dynamic from "next/dynamic";
import { startTransition, useEffect } from "react";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
@ -49,11 +49,11 @@ function CollapseButton({ onClick }: { onClick: () => void }) {
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onClick} className="h-8 w-8 shrink-0">
<PanelRightClose className="h-4 w-4" />
<PanelRight className="h-4 w-4" />
<span className="sr-only">Collapse panel</span>
</Button>
</TooltipTrigger>
<TooltipContent side="left">Collapse panel</TooltipContent>
<TooltipContent side="bottom">Collapse panel</TooltipContent>
</Tooltip>
);
}
@ -70,7 +70,11 @@ export function RightPanelExpandButton() {
const editorState = useAtomValue(editorPanelAtom);
const hitlEditState = useAtomValue(hitlEditPanelAtom);
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen = editorState.isOpen && !!editorState.documentId;
const editorOpen =
editorState.isOpen &&
(editorState.kind === "document"
? !!editorState.documentId
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen;
@ -90,7 +94,7 @@ export function RightPanelExpandButton() {
<span className="sr-only">Expand panel</span>
</Button>
</TooltipTrigger>
<TooltipContent side="left">Expand panel</TooltipContent>
<TooltipContent side="bottom">Expand panel</TooltipContent>
</Tooltip>
</div>
);
@ -110,7 +114,11 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
const documentsOpen = documentsPanel?.open ?? false;
const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen = editorState.isOpen && !!editorState.documentId;
const editorOpen =
editorState.isOpen &&
(editorState.kind === "document"
? !!editorState.documentId
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
useEffect(() => {
@ -179,8 +187,10 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
{effectiveTab === "editor" && editorOpen && (
<div className="h-full flex flex-col">
<EditorPanelContent
documentId={editorState.documentId as number}
searchSpaceId={editorState.searchSpaceId as number}
kind={editorState.kind}
documentId={editorState.documentId ?? undefined}
localFilePath={editorState.localFilePath ?? undefined}
searchSpaceId={editorState.searchSpaceId ?? undefined}
title={editorState.title}
onClose={closeEditor}
/>

View file

@ -8,7 +8,7 @@ import {
ChevronLeft,
MessageCircleMore,
MoreHorizontal,
PenLine,
Pencil,
RotateCcwIcon,
Search,
Trash2,
@ -429,7 +429,7 @@ export function AllPrivateChatsSidebarContent({
<DropdownMenuItem
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
>
<PenLine className="mr-2 h-4 w-4" />
<Pencil className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}

View file

@ -8,7 +8,7 @@ import {
ChevronLeft,
MessageCircleMore,
MoreHorizontal,
PenLine,
Pencil,
RotateCcwIcon,
Search,
Trash2,
@ -428,7 +428,7 @@ export function AllSharedChatsSidebarContent({
<DropdownMenuItem
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
>
<PenLine className="mr-2 h-4 w-4" />
<Pencil className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}

View file

@ -1,6 +1,6 @@
"use client";
import { ArchiveIcon, MoreHorizontal, PenLine, RotateCcwIcon, Trash2 } from "lucide-react";
import { ArchiveIcon, MoreHorizontal, Pencil, RotateCcwIcon, Trash2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useState } from "react";
import { Button } from "@/components/ui/button";
@ -106,7 +106,7 @@ export function ChatListItem({
onRename();
}}
>
<PenLine className="mr-2 h-4 w-4" />
<Pencil className="mr-2 h-4 w-4" />
<span>{t("rename") || "Rename"}</span>
</DropdownMenuItem>
)}

View file

@ -6,9 +6,14 @@ import {
ChevronLeft,
ChevronRight,
FileText,
Folder,
FolderPlus,
FolderClock,
Laptop,
Lock,
Paperclip,
Search,
Server,
Trash2,
Unplug,
Upload,
@ -58,8 +63,19 @@ import {
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode";
import { useLoginGate } from "@/contexts/login-gate";
@ -76,9 +92,31 @@ import { authenticatedFetch } from "@/lib/auth-utils";
import { uploadFolderScan } from "@/lib/folder-sync-upload";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
import { queries } from "@/zero/queries/index";
import { LocalFilesystemBrowser } from "./LocalFilesystemBrowser";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"];
const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1";
const MAX_LOCAL_FILESYSTEM_ROOTS = 5;
type FilesystemSettings = {
mode: "cloud" | "desktop_local_folder";
localRootPaths: string[];
updatedAt: string;
};
interface WatchedFolderEntry {
path: string;
name: string;
excludePatterns: string[];
fileExtensions: string[] | null;
rootFolderId: number | null;
searchSpaceId: number;
active: boolean;
}
const getFolderDisplayName = (rootPath: string): string =>
rootPath.split(/[\\/]/).at(-1) || rootPath;
const SHOWCASE_CONNECTORS = [
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
@ -133,12 +171,119 @@ function AuthenticatedDocumentsSidebar({
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
const [localSearch, setLocalSearch] = useState("");
const debouncedLocalSearch = useDebouncedValue(localSearch, 250);
const localSearchInputRef = useRef<HTMLInputElement>(null);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
const [filesystemSettings, setFilesystemSettings] = useState<FilesystemSettings | null>(null);
const [localTrustDialogOpen, setLocalTrustDialogOpen] = useState(false);
const [pendingLocalPath, setPendingLocalPath] = useState<string | null>(null);
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom);
const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom);
const isElectron = typeof window !== "undefined" && !!window.electronAPI;
useEffect(() => {
if (!electronAPI?.getAgentFilesystemSettings) return;
let mounted = true;
electronAPI
.getAgentFilesystemSettings()
.then((settings: FilesystemSettings) => {
if (!mounted) return;
setFilesystemSettings(settings);
})
.catch(() => {
if (!mounted) return;
setFilesystemSettings({
mode: "cloud",
localRootPaths: [],
updatedAt: new Date().toISOString(),
});
});
return () => {
mounted = false;
};
}, [electronAPI]);
const hasLocalFilesystemTrust = useCallback(() => {
try {
return window.localStorage.getItem(LOCAL_FILESYSTEM_TRUST_KEY) === "true";
} catch {
return false;
}
}, []);
const localRootPaths = filesystemSettings?.localRootPaths ?? [];
const canAddMoreLocalRoots = localRootPaths.length < MAX_LOCAL_FILESYSTEM_ROOTS;
const applyLocalRootPath = useCallback(
async (path: string) => {
if (!electronAPI?.setAgentFilesystemSettings) return;
const nextLocalRootPaths = [...localRootPaths, path]
.filter((rootPath, index, allPaths) => allPaths.indexOf(rootPath) === index)
.slice(0, MAX_LOCAL_FILESYSTEM_ROOTS);
if (nextLocalRootPaths.length === localRootPaths.length) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder",
localRootPaths: nextLocalRootPaths,
});
setFilesystemSettings(updated);
},
[electronAPI, localRootPaths]
);
const runPickLocalRoot = useCallback(async () => {
if (!electronAPI?.pickAgentFilesystemRoot) return;
const picked = await electronAPI.pickAgentFilesystemRoot();
if (!picked) return;
await applyLocalRootPath(picked);
}, [applyLocalRootPath, electronAPI]);
const handlePickFilesystemRoot = useCallback(async () => {
if (!canAddMoreLocalRoots) return;
if (hasLocalFilesystemTrust()) {
await runPickLocalRoot();
return;
}
if (!electronAPI?.pickAgentFilesystemRoot) return;
const picked = await electronAPI.pickAgentFilesystemRoot();
if (!picked) return;
setPendingLocalPath(picked);
setLocalTrustDialogOpen(true);
}, [canAddMoreLocalRoots, electronAPI, hasLocalFilesystemTrust, runPickLocalRoot]);
const handleRemoveFilesystemRoot = useCallback(
async (rootPathToRemove: string) => {
if (!electronAPI?.setAgentFilesystemSettings) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder",
localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove),
});
setFilesystemSettings(updated);
},
[electronAPI, localRootPaths]
);
const handleClearFilesystemRoots = useCallback(async () => {
if (!electronAPI?.setAgentFilesystemSettings) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder",
localRootPaths: [],
});
setFilesystemSettings(updated);
}, [electronAPI]);
const handleFilesystemTabChange = useCallback(
async (tab: "cloud" | "local") => {
if (!electronAPI?.setAgentFilesystemSettings) return;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: tab === "cloud" ? "cloud" : "desktop_local_folder",
});
setFilesystemSettings(updated);
},
[electronAPI]
);
// AI File Sort state
const { data: searchSpaces, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
const activeSearchSpace = useMemo(
@ -196,7 +341,7 @@ function AuthenticatedDocumentsSidebar({
if (!electronAPI?.getWatchedFolders) return;
const api = electronAPI;
const folders = await api.getWatchedFolders();
const folders = (await api.getWatchedFolders()) as WatchedFolderEntry[];
if (folders.length === 0) {
try {
@ -214,9 +359,11 @@ function AuthenticatedDocumentsSidebar({
active: true,
});
}
const recovered = await api.getWatchedFolders();
const recovered = (await api.getWatchedFolders()) as WatchedFolderEntry[];
const ids = new Set(
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
recovered
.filter((f: WatchedFolderEntry) => f.rootFolderId != null)
.map((f: WatchedFolderEntry) => f.rootFolderId as number)
);
setWatchedFolderIds(ids);
return;
@ -226,7 +373,9 @@ function AuthenticatedDocumentsSidebar({
}
const ids = new Set(
folders.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
folders
.filter((f: WatchedFolderEntry) => f.rootFolderId != null)
.map((f: WatchedFolderEntry) => f.rootFolderId as number)
);
setWatchedFolderIds(ids);
}, [searchSpaceId, electronAPI]);
@ -375,8 +524,8 @@ function AuthenticatedDocumentsSidebar({
async (folder: FolderDisplay) => {
if (!electronAPI) return;
const watchedFolders = await electronAPI.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
const matched = watchedFolders.find((wf: WatchedFolderEntry) => wf.rootFolderId === folder.id);
if (!matched) {
toast.error("This folder is not being watched");
return;
@ -405,8 +554,8 @@ function AuthenticatedDocumentsSidebar({
async (folder: FolderDisplay) => {
if (!electronAPI) return;
const watchedFolders = await electronAPI.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
const matched = watchedFolders.find((wf: WatchedFolderEntry) => wf.rootFolderId === folder.id);
if (!matched) {
toast.error("This folder is not being watched");
return;
@ -438,8 +587,10 @@ function AuthenticatedDocumentsSidebar({
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
try {
if (electronAPI) {
const watchedFolders = await electronAPI.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
const watchedFolders = (await electronAPI.getWatchedFolders()) as WatchedFolderEntry[];
const matched = watchedFolders.find(
(wf: WatchedFolderEntry) => wf.rootFolderId === folder.id
);
if (matched) {
await electronAPI.removeWatchedFolder(matched.path);
}
@ -836,59 +987,11 @@ function AuthenticatedDocumentsSidebar({
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange, isMobile, setRightPanelCollapsed]);
const documentsContent = (
<>
<div className="shrink-0 flex h-14 items-center px-4">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{tSidebar("close") || "Close"}</span>
</Button>
)}
<h2 className="select-none text-lg font-semibold">{t("title") || "Documents"}</h2>
</div>
<div className="flex items-center gap-1">
{!isMobile && onDockedChange && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => {
if (isDocked) {
onDockedChange(false);
onOpenChange(false);
} else {
onDockedChange(true);
}
}}
>
{isDocked ? (
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{isDocked ? "Collapse panel" : "Expand panel"}
</TooltipContent>
</Tooltip>
)}
{headerAction}
</div>
</div>
</div>
const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings;
const currentFilesystemTab = filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud";
const cloudContent = (
<>
{/* Connected tools strip */}
<div className="shrink-0 mx-4 mt-4 mb-4 flex select-none items-center gap-2 rounded-lg border bg-muted/50 transition-colors hover:bg-muted/80">
<button
@ -1039,6 +1142,231 @@ function AuthenticatedDocumentsSidebar({
/>
</div>
</div>
</>
);
const localContent = (
<div className="flex min-h-0 flex-1 flex-col select-none">
<div className="mx-4 mt-4 mb-3">
<div className="flex h-7 w-full items-stretch rounded-lg border bg-muted/50 text-[11px] text-muted-foreground">
{localRootPaths.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="min-w-0 flex-1 flex items-center gap-1 rounded-l-lg px-2 text-left transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
title={localRootPaths.join("\n")}
aria-label="Manage selected folders"
>
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">
{localRootPaths.length === 1
? "1 folder selected"
: `${localRootPaths.length} folders selected`}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56 select-none p-0.5">
<DropdownMenuLabel className="px-1.5 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
Selected folders
</DropdownMenuLabel>
<DropdownMenuSeparator className="mx-1 my-0.5" />
{localRootPaths.map((rootPath) => (
<DropdownMenuItem
key={rootPath}
onClick={() => {
void handleRemoveFilesystemRoot(rootPath);
}}
className="group h-8 gap-1.5 px-1.5 text-sm text-foreground"
>
<Folder className="size-3.5 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{getFolderDisplayName(rootPath)}
</span>
<X className="size-3 text-muted-foreground transition-colors group-hover:text-foreground" />
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="mx-1 my-0.5" />
<DropdownMenuItem
variant="destructive"
className="h-8 px-1.5 text-xs text-destructive focus:text-destructive"
onClick={() => {
void handleClearFilesystemRoots();
}}
>
Clear all folders
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div
className="min-w-0 flex-1 flex items-center gap-1 px-2"
title="No local folders selected"
>
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">No local folders selected</span>
</div>
)}
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-3 self-center bg-border"
/>
<button
type="button"
className="flex w-8 items-center justify-center rounded-r-lg text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:opacity-50"
onClick={() => {
void handlePickFilesystemRoot();
}}
disabled={!canAddMoreLocalRoots}
aria-label="Add folder"
title="Add folder"
>
<FolderPlus className="size-3.5" />
</button>
</div>
</div>
<div className="mx-4 mb-2">
<div className="relative flex-1 min-w-0">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
<Search size={13} aria-hidden="true" />
</div>
<Input
ref={localSearchInputRef}
className="peer h-8 w-full pl-8 pr-8 text-sm bg-sidebar border-border/60 select-none focus:select-text"
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
placeholder="Search local files"
type="text"
aria-label="Search local files"
/>
{Boolean(localSearch) && (
<button
type="button"
className="absolute inset-y-0 right-0 flex h-full w-8 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear local search"
onClick={() => {
setLocalSearch("");
localSearchInputRef.current?.focus();
}}
>
<X size={13} strokeWidth={2} aria-hidden="true" />
</button>
)}
</div>
</div>
<LocalFilesystemBrowser
rootPaths={localRootPaths}
searchSpaceId={searchSpaceId}
searchQuery={debouncedLocalSearch.trim() || undefined}
onOpenFile={(localFilePath) => {
openEditorPanel({
kind: "local_file",
localFilePath,
title: localFilePath.split("/").pop() || localFilePath,
searchSpaceId,
});
}}
/>
</div>
);
const documentsContent = (
<>
<div className="shrink-0 flex h-14 items-center px-4">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-3">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{tSidebar("close") || "Close"}</span>
</Button>
)}
<h2 className="select-none text-lg font-semibold">{t("title") || "Documents"}</h2>
{showFilesystemTabs && (
<Tabs
value={currentFilesystemTab}
onValueChange={(value) => {
void handleFilesystemTabChange(value === "local" ? "local" : "cloud");
}}
>
<TabsList className="h-6 gap-0 rounded-md bg-muted/60 p-0.5 select-none">
<TabsTrigger
value="cloud"
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
title="Cloud"
>
<Server className="size-3" />
<span>Cloud</span>
</TabsTrigger>
<TabsTrigger
value="local"
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
title="Local"
>
<Laptop className="size-3" />
<span>Local</span>
</TabsTrigger>
</TabsList>
</Tabs>
)}
</div>
<div className="flex items-center gap-1">
{!isMobile && onDockedChange && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => {
if (isDocked) {
onDockedChange(false);
onOpenChange(false);
} else {
onDockedChange(true);
}
}}
>
{isDocked ? (
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{isDocked ? "Collapse panel" : "Expand panel"}
</TooltipContent>
</Tooltip>
)}
{headerAction}
</div>
</div>
</div>
{showFilesystemTabs ? (
<Tabs
value={currentFilesystemTab}
onValueChange={(value) => {
void handleFilesystemTabChange(value === "local" ? "local" : "cloud");
}}
className="flex min-h-0 flex-1 flex-col"
>
<TabsContent value="cloud" className="mt-0 flex min-h-0 flex-1 flex-col">
{cloudContent}
</TabsContent>
<TabsContent value="local" className="mt-0 flex min-h-0 flex-1 flex-col">
{localContent}
</TabsContent>
</Tabs>
) : (
cloudContent
)}
{versionDocId !== null && (
<VersionHistoryDialog
@ -1062,6 +1390,48 @@ function AuthenticatedDocumentsSidebar({
onSuccess={refreshWatchedIds}
/>
)}
<AlertDialog
open={localTrustDialogOpen}
onOpenChange={(nextOpen) => {
setLocalTrustDialogOpen(nextOpen);
if (!nextOpen) setPendingLocalPath(null);
}}
>
<AlertDialogContent className="sm:max-w-md select-none">
<AlertDialogHeader>
<AlertDialogTitle>Trust this workspace?</AlertDialogTitle>
<AlertDialogDescription>
Local mode can read and edit files inside the folders you select. Continue only if
you trust this workspace and its contents.
</AlertDialogDescription>
{pendingLocalPath && (
<AlertDialogDescription className="mt-1 whitespace-pre-wrap break-words font-mono text-xs">
Folder path: {pendingLocalPath}
</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
try {
window.localStorage.setItem(LOCAL_FILESYSTEM_TRUST_KEY, "true");
} catch {}
setLocalTrustDialogOpen(false);
const path = pendingLocalPath;
setPendingLocalPath(null);
if (path) {
await applyLocalRootPath(path);
} else {
await runPickLocalRoot();
}
}}
>
I trust this workspace
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<FolderPickerDialog
open={folderPickerOpen}

View file

@ -0,0 +1,314 @@
"use client";
import { ChevronDown, ChevronRight, FileText, Folder } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog";
import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
interface LocalFilesystemBrowserProps {
rootPaths: string[];
searchSpaceId: number;
searchQuery?: string;
onOpenFile: (fullPath: string) => void;
}
interface LocalFolderFileEntry {
relativePath: string;
fullPath: string;
size: number;
mtimeMs: number;
}
type RootLoadState = {
loading: boolean;
error: string | null;
files: LocalFolderFileEntry[];
};
interface LocalFolderNode {
key: string;
name: string;
folders: Map<string, LocalFolderNode>;
files: LocalFolderFileEntry[];
}
type LocalRootMount = {
mount: string;
rootPath: string;
};
const getFolderDisplayName = (rootPath: string): string =>
rootPath.split(/[\\/]/).at(-1) || rootPath;
function createFolderNode(key: string, name: string): LocalFolderNode {
return {
key,
name,
folders: new Map(),
files: [],
};
}
function getFileName(pathValue: string): string {
return pathValue.split(/[\\/]/).at(-1) || pathValue;
}
function toVirtualPath(relativePath: string): string {
const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
return `/${normalized}`;
}
function normalizeRootPathForLookup(rootPath: string, isWindows: boolean): string {
const normalized = rootPath.replace(/\\/g, "/").replace(/\/+$/, "");
return isWindows ? normalized.toLowerCase() : normalized;
}
function toMountedVirtualPath(mount: string, relativePath: string): string {
return `/${mount}${toVirtualPath(relativePath)}`;
}
export function LocalFilesystemBrowser({
rootPaths,
searchSpaceId,
searchQuery,
onOpenFile,
}: LocalFilesystemBrowserProps) {
const electronAPI = useElectronAPI();
const [rootStateMap, setRootStateMap] = useState<Record<string, RootLoadState>>({});
const [expandedFolderKeys, setExpandedFolderKeys] = useState<Set<string>>(new Set());
const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map());
const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []);
const isWindowsPlatform = electronAPI?.versions.platform === "win32";
useEffect(() => {
if (!electronAPI?.listFolderFiles) return;
let cancelled = false;
for (const rootPath of rootPaths) {
setRootStateMap((prev) => ({
...prev,
[rootPath]: {
loading: true,
error: null,
files: prev[rootPath]?.files ?? [],
},
}));
}
void Promise.all(
rootPaths.map(async (rootPath) => {
try {
const files = (await electronAPI.listFolderFiles({
path: rootPath,
name: getFolderDisplayName(rootPath),
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
fileExtensions: supportedExtensions,
rootFolderId: null,
searchSpaceId,
active: true,
})) as LocalFolderFileEntry[];
if (cancelled) return;
setRootStateMap((prev) => ({
...prev,
[rootPath]: {
loading: false,
error: null,
files,
},
}));
} catch (error) {
if (cancelled) return;
setRootStateMap((prev) => ({
...prev,
[rootPath]: {
loading: false,
error: error instanceof Error ? error.message : "Failed to read folder",
files: [],
},
}));
}
})
);
return () => {
cancelled = true;
};
}, [electronAPI, rootPaths, searchSpaceId, supportedExtensions]);
useEffect(() => {
if (!electronAPI?.getAgentFilesystemMounts) {
setMountByRootKey(new Map());
return;
}
let cancelled = false;
void electronAPI
.getAgentFilesystemMounts()
.then((mounts: LocalRootMount[]) => {
if (cancelled) return;
const next = new Map<string, string>();
for (const entry of mounts) {
next.set(normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform), entry.mount);
}
setMountByRootKey(next);
})
.catch(() => {
if (cancelled) return;
setMountByRootKey(new Map());
});
return () => {
cancelled = true;
};
}, [electronAPI, isWindowsPlatform, rootPaths]);
const treeByRoot = useMemo(() => {
const query = searchQuery?.trim().toLowerCase() ?? "";
const hasQuery = query.length > 0;
return rootPaths.map((rootPath) => {
const rootNode = createFolderNode(rootPath, getFolderDisplayName(rootPath));
const allFiles = rootStateMap[rootPath]?.files ?? [];
const files = hasQuery
? allFiles.filter((file) => {
const relativePath = file.relativePath.toLowerCase();
const fileName = getFileName(file.relativePath).toLowerCase();
return relativePath.includes(query) || fileName.includes(query);
})
: allFiles;
for (const file of files) {
const parts = file.relativePath.split(/[\\/]/).filter(Boolean);
let cursor = rootNode;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
const folderKey = `${cursor.key}/${part}`;
if (!cursor.folders.has(part)) {
cursor.folders.set(part, createFolderNode(folderKey, part));
}
cursor = cursor.folders.get(part) as LocalFolderNode;
}
cursor.files.push(file);
}
return { rootPath, rootNode, matchCount: files.length, totalCount: allFiles.length };
});
}, [rootPaths, rootStateMap, searchQuery]);
const toggleFolder = useCallback((folderKey: string) => {
setExpandedFolderKeys((prev) => {
const next = new Set(prev);
if (next.has(folderKey)) {
next.delete(folderKey);
} else {
next.add(folderKey);
}
return next;
});
}, []);
const renderFolder = useCallback(
(folder: LocalFolderNode, depth: number, mount: string) => {
const isExpanded = expandedFolderKeys.has(folder.key);
const childFolders = Array.from(folder.folders.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
const files = [...folder.files].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
return (
<div key={folder.key} className="select-none">
<button
type="button"
onClick={() => toggleFolder(folder.key)}
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors hover:bg-muted/60"
style={{ paddingInlineStart: `${depth * 12 + 8}px` }}
draggable={false}
>
{isExpanded ? (
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)}
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{folder.name}</span>
</button>
{isExpanded && (
<>
{childFolders.map((childFolder) => renderFolder(childFolder, depth + 1, mount))}
{files.map((file) => (
<button
key={file.fullPath}
type="button"
onClick={() => onOpenFile(toMountedVirtualPath(mount, file.relativePath))}
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors hover:bg-muted/60"
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
title={file.fullPath}
draggable={false}
>
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{getFileName(file.relativePath)}</span>
</button>
))}
</>
)}
</div>
);
},
[expandedFolderKeys, onOpenFile, toggleFolder]
);
if (rootPaths.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-10 text-center text-muted-foreground">
<p className="text-sm font-medium">No local folder selected</p>
<p className="text-xs text-muted-foreground/80">
Add a local folder above to browse files in desktop mode.
</p>
</div>
);
}
return (
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
{treeByRoot.map(({ rootPath, rootNode, matchCount, totalCount }) => {
const state = rootStateMap[rootPath];
const rootKey = normalizeRootPathForLookup(rootPath, isWindowsPlatform);
const mount = mountByRootKey.get(rootKey);
if (!state || state.loading) {
return (
<div key={rootPath} className="flex h-16 items-center gap-2 px-3 text-sm text-muted-foreground">
<Spinner size="sm" />
<span>Loading {getFolderDisplayName(rootPath)}...</span>
</div>
);
}
if (state.error) {
return (
<div key={rootPath} className="rounded-md border border-destructive/20 bg-destructive/5 p-3">
<p className="text-sm font-medium text-destructive">Failed to load local folder</p>
<p className="mt-1 text-xs text-muted-foreground">{state.error}</p>
</div>
);
}
const isEmpty = totalCount === 0;
return (
<div key={rootPath} className="mb-1">
{mount ? renderFolder(rootNode, 0, mount) : null}
{!mount && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
Unable to resolve mounted root for this folder.
</div>
)}
{isEmpty && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
No supported files found in this folder.
</div>
)}
{!isEmpty && matchCount === 0 && searchQuery && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
No matching files in this folder.
</div>
)}
</div>
);
})}
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { CreditCard, PenSquare, Zap } from "lucide-react";
import { CreditCard, SquarePen, Zap } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
@ -139,7 +139,7 @@ export function Sidebar({
{/* New chat button */}
<div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}>
<SidebarButton
icon={PenSquare}
icon={SquarePen}
label={t("new_chat")}
onClick={onNewChat}
isCollapsed={isCollapsed}

View file

@ -1,6 +1,6 @@
"use client";
import { PanelLeft, PanelLeftClose } from "lucide-react";
import { PanelLeft } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { ShortcutKbd } from "@/components/ui/shortcut-kbd";
@ -23,7 +23,7 @@ export function SidebarCollapseButton({
const button = (
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
{isCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
<PanelLeft className="h-4 w-4" />
<span className="sr-only">{isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}</span>
</Button>
);

View file

@ -7,8 +7,8 @@ import {
ExternalLink,
Info,
Languages,
Laptop,
LogOut,
Monitor,
Moon,
Sun,
UserCog,
@ -49,7 +49,7 @@ const LANGUAGES = [
const THEMES = [
{ value: "light" as const, name: "Light", icon: Sun },
{ value: "dark" as const, name: "Dark", icon: Moon },
{ value: "system" as const, name: "System", icon: Laptop },
{ value: "system" as const, name: "System", icon: Monitor },
];
const LEARN_MORE_LINKS = [

View file

@ -1,6 +1,6 @@
"use client";
import { Download, FileQuestionMark, FileText, Loader2, PenLine, RefreshCw } from "lucide-react";
import { Download, FileQuestionMark, FileText, Loader2, Pencil, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
@ -258,7 +258,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
onClick={() => setIsEditing(true)}
className="gap-1.5"
>
<PenLine className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { ChevronDownIcon, XIcon } from "lucide-react";
import { Check, ChevronDownIcon, Copy, Pencil, XIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
@ -116,6 +116,7 @@ export function ReportPanelContent({
const [exporting, setExporting] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const changeCountRef = useRef(0);
useEffect(() => {
return () => {
@ -125,6 +126,7 @@ export function ReportPanelContent({
// Editor state — tracks the latest markdown from the Plate editor
const [editedMarkdown, setEditedMarkdown] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
// Read-only when public (shareToken) OR shared (SEARCH_SPACE visibility)
const currentThreadState = useAtomValue(currentThreadAtom);
@ -188,8 +190,22 @@ export function ReportPanelContent({
// Reset edited markdown when switching versions or reports
useEffect(() => {
setEditedMarkdown(null);
setIsEditing(false);
changeCountRef.current = 0;
}, [activeReportId]);
const handleReportMarkdownChange = useCallback(
(nextMarkdown: string) => {
if (!isEditing) return;
changeCountRef.current += 1;
// Plate may emit an initial normalize/serialize change on mount.
if (changeCountRef.current <= 1) return;
const savedMarkdown = reportContent?.content ?? "";
setEditedMarkdown(nextMarkdown === savedMarkdown ? null : nextMarkdown);
},
[isEditing, reportContent?.content]
);
// Copy markdown content (uses latest editor content)
const handleCopy = useCallback(async () => {
if (!currentMarkdown) return;
@ -257,7 +273,7 @@ export function ReportPanelContent({
// Save edited report content
const handleSave = useCallback(async () => {
if (!currentMarkdown || !activeReportId) return;
if (!currentMarkdown || !activeReportId) return false;
setSaving(true);
try {
const response = await authenticatedFetch(
@ -278,9 +294,11 @@ export function ReportPanelContent({
setReportContent((prev) => (prev ? { ...prev, content: currentMarkdown } : prev));
setEditedMarkdown(null);
toast.success("Report saved successfully");
return true;
} catch (err) {
console.error("Error saving report:", err);
toast.error(err instanceof Error ? err.message : "Failed to save report");
return false;
} finally {
setSaving(false);
}
@ -288,26 +306,21 @@ export function ReportPanelContent({
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
const isPublic = !!shareToken;
const btnBg = isPublic ? "bg-main-panel" : "bg-sidebar";
const isResume = reportContent?.content_type === "typst";
const showReportEditingTier = !isResume;
const hasUnsavedChanges = editedMarkdown !== null;
const handleCancelEditing = useCallback(() => {
setEditedMarkdown(null);
changeCountRef.current = 0;
setIsEditing(false);
}, []);
return (
<>
{/* Action bar — always visible; buttons are disabled while loading */}
<div className="flex h-14 items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-2">
{/* Copy button — hidden for Typst (resume) */}
{reportContent?.content_type !== "typst" && (
<Button
variant="outline"
size="sm"
onClick={handleCopy}
disabled={isLoading || !reportContent?.content}
className={`h-8 min-w-[80px] px-3.5 py-4 text-[15px] ${btnBg} select-none`}
>
{copied ? "Copied" : "Copy"}
</Button>
)}
{/* Export — plain button for resume (typst), dropdown for others */}
{reportContent?.content_type === "typst" ? (
<Button
@ -315,7 +328,7 @@ export function ReportPanelContent({
size="sm"
onClick={() => handleExport("pdf")}
disabled={isLoading || !reportContent?.content || exporting !== null}
className={`h-8 min-w-[100px] px-3.5 py-4 text-[15px] ${btnBg} select-none`}
className={`h-8 min-w-[100px] px-3.5 py-4 text-[15px] ${isPublic ? "bg-main-panel" : "bg-sidebar"} select-none`}
>
{exporting === "pdf" ? <Spinner size="xs" /> : "Download"}
</Button>
@ -326,7 +339,7 @@ export function ReportPanelContent({
variant="outline"
size="sm"
disabled={isLoading || !reportContent?.content}
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${btnBg} select-none`}
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${isPublic ? "bg-main-panel" : "bg-sidebar"} select-none`}
>
Export
<ChevronDownIcon className="size-3" />
@ -352,7 +365,7 @@ export function ReportPanelContent({
<Button
variant="outline"
size="sm"
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${btnBg} select-none`}
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${isPublic ? "bg-main-panel" : "bg-sidebar"} select-none`}
>
v{activeVersionIndex + 1}
<ChevronDownIcon className="size-3" />
@ -383,6 +396,75 @@ export function ReportPanelContent({
)}
</div>
{showReportEditingTier && (
<div className="flex h-10 items-center justify-between gap-2 border-t border-b px-4 shrink-0">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-muted-foreground">
{reportContent?.title || title}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{!isEditing && (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
void handleCopy();
}}
disabled={isLoading || !reportContent?.content}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
<span className="sr-only">
{copied ? "Copied report content" : "Copy report content"}
</span>
</Button>
)}
{!isReadOnly &&
(isEditing ? (
<>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleCancelEditing}
disabled={saving}
>
Cancel
</Button>
<Button
variant="secondary"
size="sm"
className="relative h-6 w-[56px] px-0 text-xs"
onClick={async () => {
const saveSucceeded = await handleSave();
if (saveSucceeded) setIsEditing(false);
}}
disabled={saving || !hasUnsavedChanges}
>
<span className={saving ? "opacity-0" : ""}>Save</span>
{saving && <Spinner size="xs" className="absolute" />}
</Button>
</>
) : (
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
setEditedMarkdown(null);
changeCountRef.current = 0;
setIsEditing(true);
}}
>
<Pencil className="size-3.5" />
<span className="sr-only">Edit report</span>
</Button>
))}
</div>
</div>
)}
{/* Report content — skeleton/error/viewer/editor shown only in this area */}
<div className="flex-1 overflow-hidden">
{isLoading ? (
@ -406,15 +488,16 @@ export function ReportPanelContent({
</div>
) : (
<PlateEditor
key={`report-${activeReportId}-${isEditing ? "editing" : "viewing"}`}
preset="full"
markdown={reportContent.content}
onMarkdownChange={setEditedMarkdown}
readOnly={false}
onMarkdownChange={handleReportMarkdownChange}
readOnly={!isEditing}
placeholder="Report content..."
editorVariant="default"
onSave={handleSave}
hasUnsavedChanges={editedMarkdown !== null}
isSaving={saving}
allowModeToggle={false}
reserveToolbarSpace
defaultEditing={isEditing}
className="[&_[role=toolbar]]:!bg-sidebar"
/>
)

View file

@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { ArrowUp, ChevronDown, ClipboardCopy, Download, Info, Pen } from "lucide-react";
import { ArrowUp, ChevronDown, ClipboardCopy, Download, Info, Pencil } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
@ -247,7 +247,7 @@ export function TeamMemoryManager({ searchSpaceId }: TeamMemoryManagerProps) {
onClick={openInput}
className="absolute bottom-3 right-3 z-10 h-[54px] w-[54px] rounded-full border bg-muted/60 backdrop-blur-sm shadow-sm"
>
<Pen className="!h-5 !w-5" />
<Pencil className="!h-5 !w-5" />
</Button>
)}
</div>

View file

@ -1,7 +1,7 @@
"use client";
import { useAtom } from "jotai";
import { Brain, CircleUser, Globe, KeyRound, Monitor, ReceiptText, Sparkles } from "lucide-react";
import { Brain, CircleUser, Globe, Keyboard, KeyRound, Monitor, ReceiptText, Sparkles } from "lucide-react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
@ -51,6 +51,13 @@ const DesktopContent = dynamic(
),
{ ssr: false }
);
const DesktopShortcutsContent = dynamic(
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/DesktopShortcutsContent").then(
(m) => ({ default: m.DesktopShortcutsContent })
),
{ ssr: false }
);
const MemoryContent = dynamic(
() =>
import("@/app/dashboard/[search_space_id]/user-settings/components/MemoryContent").then(
@ -93,7 +100,18 @@ export function UserSettingsDialog() {
icon: <ReceiptText className="h-4 w-4" />,
},
...(isDesktop
? [{ value: "desktop", label: "Desktop", icon: <Monitor className="h-4 w-4" /> }]
? [
{
value: "desktop",
label: "App Preferences",
icon: <Monitor className="h-4 w-4" />,
},
{
value: "desktop-shortcuts",
label: "Hotkeys",
icon: <Keyboard className="h-4 w-4" />,
},
]
: []),
],
[t, isDesktop]
@ -116,6 +134,7 @@ export function UserSettingsDialog() {
{state.initialTab === "memory" && <MemoryContent />}
{state.initialTab === "purchases" && <PurchaseHistoryContent />}
{state.initialTab === "desktop" && <DesktopContent />}
{state.initialTab === "desktop-shortcuts" && <DesktopShortcutsContent />}
</div>
</SettingsDialog>
);

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -222,7 +222,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -241,7 +241,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, FileIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, FileIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -224,7 +224,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -1,7 +1,7 @@
"use client";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
@ -168,7 +168,7 @@ function GenericApprovalCard({
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
onClick={() => setIsEditing(true)}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen, UserIcon, UsersIcon } from "lucide-react";
import { CornerDownLeftIcon, Pencil, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
@ -251,7 +251,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, MailIcon, Pen, UserIcon, UsersIcon } from "lucide-react";
import { CornerDownLeftIcon, MailIcon, Pencil, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
@ -250,7 +250,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, MailIcon, Pen, UserIcon, UsersIcon } from "lucide-react";
import { CornerDownLeftIcon, MailIcon, Pencil, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
@ -283,7 +283,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { ClockIcon, CornerDownLeftIcon, GlobeIcon, MapPinIcon, Pen, UsersIcon } from "lucide-react";
import { ClockIcon, CornerDownLeftIcon, GlobeIcon, MapPinIcon, Pencil, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
@ -332,7 +332,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -7,7 +7,7 @@ import {
ClockIcon,
CornerDownLeftIcon,
MapPinIcon,
Pen,
Pencil,
UsersIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
@ -415,7 +415,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, FileIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, FileIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -240,7 +240,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -257,7 +257,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -273,7 +273,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -269,7 +269,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -332,7 +332,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -219,7 +219,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -196,7 +196,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -2,7 +2,7 @@
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, FileIcon, Pen } from "lucide-react";
import { CornerDownLeftIcon, FileIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
@ -209,7 +209,7 @@ function ApprovalCard({
});
}}
>
<Pen className="size-3.5" />
<Pencil className="size-3.5" />
Edit
</Button>
)}

View file

@ -1,6 +1,6 @@
"use client";
import { BookOpenIcon, PenLineIcon } from "lucide-react";
import { BookOpenIcon, Pencil } from "lucide-react";
import { usePlateState } from "platejs/react";
import { ToolbarButton } from "./toolbar";
@ -13,7 +13,7 @@ export function ModeToolbarButton() {
tooltip={readOnly ? "Click to edit" : "Click to view"}
onClick={() => setReadOnly(!readOnly)}
>
{readOnly ? <BookOpenIcon /> : <PenLineIcon />}
{readOnly ? <BookOpenIcon /> : <Pencil />}
</ToolbarButton>
);
}