feat: improve document editor panel behavior

This commit is contained in:
Anish Sarkar 2026-05-20 02:02:59 +05:30
parent 89a8438864
commit cb1cf26ef3
8 changed files with 380 additions and 81 deletions

View file

@ -3,10 +3,11 @@ import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right
interface EditorPanelState { interface EditorPanelState {
isOpen: boolean; isOpen: boolean;
kind: "document" | "local_file"; kind: "document" | "local_file" | "memory";
documentId: number | null; documentId: number | null;
localFilePath: string | null; localFilePath: string | null;
searchSpaceId: number | null; searchSpaceId: number | null;
memoryScope: "user" | "team" | null;
title: string | null; title: string | null;
} }
@ -16,6 +17,7 @@ const initialState: EditorPanelState = {
documentId: null, documentId: null,
localFilePath: null, localFilePath: null,
searchSpaceId: null, searchSpaceId: null,
memoryScope: null,
title: null, title: null,
}; };
@ -38,6 +40,12 @@ export const openEditorPanelAtom = atom(
title?: string; title?: string;
searchSpaceId?: number; searchSpaceId?: number;
} }
| {
kind: "memory";
memoryScope: "user" | "team";
title?: string;
searchSpaceId?: number;
}
) => { ) => {
if (!get(editorPanelAtom).isOpen) { if (!get(editorPanelAtom).isOpen) {
set(preEditorCollapsedAtom, get(rightPanelCollapsedAtom)); set(preEditorCollapsedAtom, get(rightPanelCollapsedAtom));
@ -49,6 +57,21 @@ export const openEditorPanelAtom = atom(
documentId: null, documentId: null,
localFilePath: payload.localFilePath, localFilePath: payload.localFilePath,
searchSpaceId: payload.searchSpaceId ?? null, searchSpaceId: payload.searchSpaceId ?? null,
memoryScope: null,
title: payload.title ?? null,
});
set(rightPanelTabAtom, "editor");
set(rightPanelCollapsedAtom, false);
return;
}
if (payload.kind === "memory") {
set(editorPanelAtom, {
isOpen: true,
kind: "memory",
documentId: null,
localFilePath: null,
searchSpaceId: payload.searchSpaceId ?? null,
memoryScope: payload.memoryScope,
title: payload.title ?? null, title: payload.title ?? null,
}); });
set(rightPanelTabAtom, "editor"); set(rightPanelTabAtom, "editor");
@ -61,6 +84,7 @@ export const openEditorPanelAtom = atom(
documentId: payload.documentId, documentId: payload.documentId,
localFilePath: null, localFilePath: null,
searchSpaceId: payload.searchSpaceId, searchSpaceId: payload.searchSpaceId,
memoryScope: null,
title: payload.title ?? null, title: payload.title ?? null,
}); });
set(rightPanelTabAtom, "editor"); set(rightPanelTabAtom, "editor");

View file

@ -9,6 +9,7 @@ import {
MoreHorizontal, MoreHorizontal,
Move, Move,
Pencil, Pencil,
RotateCcw,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import React, { useCallback, useRef, useState } from "react"; import React, { useCallback, useRef, useState } from "react";
@ -61,8 +62,13 @@ interface DocumentNodeProps {
onEdit: (doc: DocumentNodeDoc) => void; onEdit: (doc: DocumentNodeDoc) => void;
onDelete: (doc: DocumentNodeDoc) => void; onDelete: (doc: DocumentNodeDoc) => void;
onMove: (doc: DocumentNodeDoc) => void; onMove: (doc: DocumentNodeDoc) => void;
onReset?: (doc: DocumentNodeDoc) => void;
onExport?: (doc: DocumentNodeDoc, format: string) => void; onExport?: (doc: DocumentNodeDoc, format: string) => void;
onVersionHistory?: (doc: DocumentNodeDoc) => void; onVersionHistory?: (doc: DocumentNodeDoc) => void;
canDelete?: boolean;
canMove?: boolean;
canMention?: boolean;
canEdit?: boolean;
contextMenuOpen?: boolean; contextMenuOpen?: boolean;
onContextMenuOpenChange?: (open: boolean) => void; onContextMenuOpenChange?: (open: boolean) => void;
} }
@ -76,8 +82,13 @@ export const DocumentNode = React.memo(function DocumentNode({
onEdit, onEdit,
onDelete, onDelete,
onMove, onMove,
onReset,
onExport, onExport,
onVersionHistory, onVersionHistory,
canDelete = true,
canMove = true,
canMention = true,
canEdit = true,
contextMenuOpen, contextMenuOpen,
onContextMenuOpenChange, onContextMenuOpenChange,
}: DocumentNodeProps) { }: DocumentNodeProps) {
@ -85,8 +96,13 @@ export const DocumentNode = React.memo(function DocumentNode({
const isFailed = statusState === "failed"; const isFailed = statusState === "failed";
const isProcessing = statusState === "pending" || statusState === "processing"; const isProcessing = statusState === "pending" || statusState === "processing";
const isUnavailable = isProcessing || isFailed; const isUnavailable = isProcessing || isFailed;
const isSelectable = !isUnavailable; const isMemoryDocument =
const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type) && !isUnavailable; doc.document_type === "USER_MEMORY" || doc.document_type === "TEAM_MEMORY";
const isSelectable = canMention && !isUnavailable;
const isEditable =
canEdit &&
(isMemoryDocument || EDITABLE_DOCUMENT_TYPES.has(doc.document_type)) &&
!isUnavailable;
const handleCheckChange = useCallback(() => { const handleCheckChange = useCallback(() => {
if (isSelectable) { if (isSelectable) {
@ -94,13 +110,22 @@ export const DocumentNode = React.memo(function DocumentNode({
} }
}, [doc, isMentioned, isSelectable, onToggleChatMention]); }, [doc, isMentioned, isSelectable, onToggleChatMention]);
const handlePrimaryClick = useCallback(() => {
if (canMention) {
handleCheckChange();
return;
}
onPreview(doc);
}, [canMention, doc, handleCheckChange, onPreview]);
const [{ isDragging }, drag] = useDrag( const [{ isDragging }, drag] = useDrag(
() => ({ () => ({
type: DND_TYPES.DOCUMENT, type: DND_TYPES.DOCUMENT,
item: { id: doc.id }, item: { id: doc.id },
canDrag: canMove,
collect: (monitor) => ({ isDragging: monitor.isDragging() }), collect: (monitor) => ({ isDragging: monitor.isDragging() }),
}), }),
[doc.id] [canMove, doc.id]
); );
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
@ -130,9 +155,11 @@ export const DocumentNode = React.memo(function DocumentNode({
const attachRef = useCallback( const attachRef = useCallback(
(node: HTMLDivElement | null) => { (node: HTMLDivElement | null) => {
(rowRef as React.MutableRefObject<HTMLDivElement | null>).current = node; (rowRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
drag(node); if (canMove) {
drag(node);
}
}, },
[drag] [canMove, drag]
); );
return ( return (
@ -187,12 +214,39 @@ export const DocumentNode = React.memo(function DocumentNode({
); );
} }
return ( return (
<Checkbox <>
checked={isMentioned} {isMemoryDocument ? (
onCheckedChange={handleCheckChange} <button
onClick={(e) => e.stopPropagation()} type="button"
className="h-3.5 w-3.5 shrink-0" aria-disabled="true"
/> tabIndex={-1}
className="h-3.5 w-3.5 shrink-0 cursor-default"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={false}
disabled
aria-disabled
className="h-3.5 w-3.5 pointer-events-none"
/>
</button>
) : canMention ? (
<Checkbox
checked={isMentioned}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5 shrink-0"
/>
) : (
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
{getDocumentTypeIcon(
doc.document_type as DocumentTypeEnum,
"h-3.5 w-3.5 text-muted-foreground"
)}
</span>
)}
</>
); );
})()} })()}
@ -205,8 +259,8 @@ export const DocumentNode = React.memo(function DocumentNode({
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
aria-disabled={!isSelectable} aria-disabled={canMention ? !isSelectable : false}
onClick={handleCheckChange} onClick={handlePrimaryClick}
className="h-full min-w-0 flex-1 justify-start bg-transparent px-0 py-0 text-left font-normal text-inherit hover:bg-transparent hover:text-inherit" className="h-full min-w-0 flex-1 justify-start bg-transparent px-0 py-0 text-left font-normal text-inherit hover:bg-transparent hover:text-inherit"
> >
<span ref={titleRef} className="min-w-0 flex-1 truncate"> <span ref={titleRef} className="min-w-0 flex-1 truncate">
@ -268,10 +322,12 @@ export const DocumentNode = React.memo(function DocumentNode({
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem onClick={() => onMove(doc)}> {canMove && (
<Move className="mr-2 h-4 w-4" /> <DropdownMenuItem onClick={() => onMove(doc)}>
Move to... <Move className="mr-2 h-4 w-4" />
</DropdownMenuItem> Move to...
</DropdownMenuItem>
)}
{onExport && ( {onExport && (
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger disabled={isUnavailable}> <DropdownMenuSubTrigger disabled={isUnavailable}>
@ -289,10 +345,18 @@ export const DocumentNode = React.memo(function DocumentNode({
Versions Versions
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}> {isMemoryDocument && onReset && (
<Trash2 className="mr-2 h-4 w-4" /> <DropdownMenuItem onClick={() => onReset(doc)}>
Delete <RotateCcw className="mr-2 h-4 w-4" />
</DropdownMenuItem> Reset
</DropdownMenuItem>
)}
{canDelete && (
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</span> </span>
@ -311,10 +375,12 @@ export const DocumentNode = React.memo(function DocumentNode({
Edit Edit
</ContextMenuItem> </ContextMenuItem>
)} )}
<ContextMenuItem onClick={() => onMove(doc)}> {canMove && (
<Move className="mr-2 h-4 w-4" /> <ContextMenuItem onClick={() => onMove(doc)}>
Move to... <Move className="mr-2 h-4 w-4" />
</ContextMenuItem> Move to...
</ContextMenuItem>
)}
{onExport && ( {onExport && (
<ContextMenuSub> <ContextMenuSub>
<ContextMenuSubTrigger disabled={isUnavailable}> <ContextMenuSubTrigger disabled={isUnavailable}>
@ -332,10 +398,18 @@ export const DocumentNode = React.memo(function DocumentNode({
Versions Versions
</ContextMenuItem> </ContextMenuItem>
)} )}
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}> {isMemoryDocument && onReset && (
<Trash2 className="mr-2 h-4 w-4" /> <ContextMenuItem onClick={() => onReset(doc)}>
Delete <RotateCcw className="mr-2 h-4 w-4" />
</ContextMenuItem> Reset
</ContextMenuItem>
)}
{canDelete && (
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
)}
</ContextMenuContent> </ContextMenuContent>
)} )}
</ContextMenu> </ContextMenu>

View file

@ -32,6 +32,7 @@ interface FolderTreeViewProps {
onEditDocument: (doc: DocumentNodeDoc) => void; onEditDocument: (doc: DocumentNodeDoc) => void;
onDeleteDocument: (doc: DocumentNodeDoc) => void; onDeleteDocument: (doc: DocumentNodeDoc) => void;
onMoveDocument: (doc: DocumentNodeDoc) => void; onMoveDocument: (doc: DocumentNodeDoc) => void;
onResetDocument?: (doc: DocumentNodeDoc) => void;
onExportDocument?: (doc: DocumentNodeDoc, format: string) => void; onExportDocument?: (doc: DocumentNodeDoc, format: string) => void;
onVersionHistory?: (doc: DocumentNodeDoc) => void; onVersionHistory?: (doc: DocumentNodeDoc) => void;
activeTypes: DocumentTypeEnum[]; activeTypes: DocumentTypeEnum[];
@ -74,6 +75,7 @@ export function FolderTreeView({
onEditDocument, onEditDocument,
onDeleteDocument, onDeleteDocument,
onMoveDocument, onMoveDocument,
onResetDocument,
onExportDocument, onExportDocument,
onVersionHistory, onVersionHistory,
activeTypes, activeTypes,
@ -236,6 +238,47 @@ export function FolderTreeView({
return states; return states;
}, [folders, docsByFolder, foldersByParent, folderMap]); }, [folders, docsByFolder, foldersByParent, folderMap]);
const renderDocumentNode = useCallback(
(d: DocumentNodeDoc, depth: number) => {
const isMemoryDocument =
d.document_type === "USER_MEMORY" || d.document_type === "TEAM_MEMORY";
return (
<DocumentNode
key={`doc-${d.id}`}
doc={d}
depth={depth}
isMentioned={!isMemoryDocument && mentionedDocKeys.has(getMentionDocKey(d))}
onToggleChatMention={onToggleChatMention}
onPreview={onPreviewDocument}
onEdit={onEditDocument}
onDelete={onDeleteDocument}
onMove={onMoveDocument}
onReset={onResetDocument}
onExport={isMemoryDocument ? undefined : onExportDocument}
onVersionHistory={isMemoryDocument ? undefined : onVersionHistory}
canDelete={!isMemoryDocument}
canMove={!isMemoryDocument}
canMention={!isMemoryDocument}
canEdit
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
/>
);
},
[
mentionedDocKeys,
onDeleteDocument,
onEditDocument,
onExportDocument,
onMoveDocument,
onPreviewDocument,
onResetDocument,
onToggleChatMention,
onVersionHistory,
openContextMenuId,
]
);
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] { function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
const key = parentId ?? "root"; const key = parentId ?? "root";
const childFolders = (foldersByParent[key] ?? []).slice().sort((a, b) => { const childFolders = (foldersByParent[key] ?? []).slice().sort((a, b) => {
@ -263,23 +306,7 @@ export function FolderTreeView({
return state === "pending" || state === "processing"; return state === "pending" || state === "processing";
}); });
for (const d of processingDocs) { for (const d of processingDocs) {
nodes.push( nodes.push(renderDocumentNode(d, depth));
<DocumentNode
key={`doc-${d.id}`}
doc={d}
depth={depth}
isMentioned={mentionedDocKeys.has(getMentionDocKey(d))}
onToggleChatMention={onToggleChatMention}
onPreview={onPreviewDocument}
onEdit={onEditDocument}
onDelete={onDeleteDocument}
onMove={onMoveDocument}
onExport={onExportDocument}
onVersionHistory={onVersionHistory}
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
/>
);
} }
} }
@ -343,23 +370,7 @@ export function FolderTreeView({
: childDocs; : childDocs;
for (const d of remainingDocs) { for (const d of remainingDocs) {
nodes.push( nodes.push(renderDocumentNode(d, depth));
<DocumentNode
key={`doc-${d.id}`}
doc={d}
depth={depth}
isMentioned={mentionedDocKeys.has(getMentionDocKey(d))}
onToggleChatMention={onToggleChatMention}
onPreview={onPreviewDocument}
onEdit={onEditDocument}
onDelete={onDeleteDocument}
onMove={onMoveDocument}
onExport={onExportDocument}
onVersionHistory={onVersionHistory}
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
/>
);
} }
return nodes; return nodes;

View file

@ -107,13 +107,15 @@ export function EditorPanelContent({
kind = "document", kind = "document",
documentId, documentId,
localFilePath, localFilePath,
memoryScope,
searchSpaceId, searchSpaceId,
title, title,
onClose, onClose,
}: { }: {
kind?: "document" | "local_file"; kind?: "document" | "local_file" | "memory";
documentId?: number; documentId?: number;
localFilePath?: string; localFilePath?: string;
memoryScope?: "user" | "team";
searchSpaceId?: number; searchSpaceId?: number;
title: string | null; title: string | null;
onClose?: () => void; onClose?: () => void;
@ -135,6 +137,7 @@ export function EditorPanelContent({
const changeCountRef = useRef(0); const changeCountRef = useRef(0);
const [displayTitle, setDisplayTitle] = useState(title || "Untitled"); const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
const isLocalFileMode = kind === "local_file"; const isLocalFileMode = kind === "local_file";
const isMemoryMode = kind === "memory";
const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown"; const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown";
const resolveLocalVirtualPath = useCallback( const resolveLocalVirtualPath = useCallback(
@ -199,6 +202,39 @@ export function EditorPanelContent({
initialLoadDone.current = true; initialLoadDone.current = true;
return; return;
} }
if (isMemoryMode) {
if (memoryScope === "team" && !searchSpaceId) {
throw new Error("Missing search space context");
}
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${
memoryScope === "team"
? `/api/v1/searchspaces/${searchSpaceId}/memory`
: "/api/v1/users/me/memory"
}`,
{ method: "GET" }
);
if (controller.signal.aborted) return;
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to fetch memory" }));
throw new Error(errorData.detail || "Failed to fetch memory");
}
const data = (await response.json()) as { memory_md?: string };
const content: EditorContent = {
document_id: memoryScope === "team" ? -1002 : -1001,
title: title || (memoryScope === "team" ? "Team Memory" : "Personal Memory"),
document_type: memoryScope === "team" ? "TEAM_MEMORY" : "USER_MEMORY",
source_markdown: data.memory_md ?? "",
};
markdownRef.current = content.source_markdown;
setDisplayTitle(content.title);
setEditorDoc(content);
initialLoadDone.current = true;
return;
}
if (!documentId || !searchSpaceId) { if (!documentId || !searchSpaceId) {
throw new Error("Missing document context"); throw new Error("Missing document context");
} }
@ -253,7 +289,9 @@ export function EditorPanelContent({
documentId, documentId,
electronAPI, electronAPI,
isLocalFileMode, isLocalFileMode,
isMemoryMode,
localFilePath, localFilePath,
memoryScope,
resolveLocalVirtualPath, resolveLocalVirtualPath,
searchSpaceId, searchSpaceId,
title, title,
@ -316,6 +354,39 @@ export function EditorPanelContent({
setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current); setEditedMarkdown(markdownRef.current === contentToSave ? null : markdownRef.current);
return true; return true;
} }
if (isMemoryMode) {
if (memoryScope === "team" && !searchSpaceId) {
throw new Error("Missing search space context");
}
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${
memoryScope === "team"
? `/api/v1/searchspaces/${searchSpaceId}/memory`
: "/api/v1/users/me/memory"
}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ memory_md: markdownRef.current }),
}
);
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to save memory" }));
throw new Error(errorData.detail || "Failed to save memory");
}
const data = (await response.json()) as { memory_md?: string };
const savedContent = data.memory_md ?? markdownRef.current;
markdownRef.current = savedContent;
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: savedContent } : prev));
setEditedMarkdown(null);
if (!options?.silent) {
toast.success("Memory saved");
}
return true;
}
if (!searchSpaceId || !documentId) { if (!searchSpaceId || !documentId) {
throw new Error("Missing document context"); throw new Error("Missing document context");
} }
@ -361,14 +432,17 @@ export function EditorPanelContent({
documentId, documentId,
electronAPI, electronAPI,
isLocalFileMode, isLocalFileMode,
isMemoryMode,
localFilePath, localFilePath,
memoryScope,
resolveLocalVirtualPath, resolveLocalVirtualPath,
searchSpaceId, searchSpaceId,
] ]
); );
const isEditableType = editorDoc const isEditableType = editorDoc
? (editorRenderMode === "source_code" || ? (isMemoryMode ||
editorRenderMode === "source_code" ||
EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) && EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
!isLargeDocument !isLargeDocument
: false; : false;
@ -495,7 +569,7 @@ export function EditorPanelContent({
</> </>
) : ( ) : (
<> <>
{!isLocalFileMode && editorDoc?.document_type && documentId && ( {!isLocalFileMode && !isMemoryMode && editorDoc?.document_type && documentId && (
<VersionHistoryButton <VersionHistoryButton
documentId={documentId} documentId={documentId}
documentType={editorDoc.document_type} documentType={editorDoc.document_type}
@ -568,7 +642,7 @@ export function EditorPanelContent({
</> </>
) : ( ) : (
<> <>
{!isLocalFileMode && editorDoc?.document_type && documentId && ( {!isLocalFileMode && !isMemoryMode && editorDoc?.document_type && documentId && (
<VersionHistoryButton <VersionHistoryButton
documentId={documentId} documentId={documentId}
documentType={editorDoc.document_type} documentType={editorDoc.document_type}
@ -664,7 +738,13 @@ export function EditorPanelContent({
<div className="flex h-full min-h-0 flex-col"> <div className="flex h-full min-h-0 flex-col">
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
<PlateEditor <PlateEditor
key={`${isLocalFileMode ? (localFilePath ?? "local-file") : documentId}-${isEditing ? "editing" : "viewing"}`} key={`${
isMemoryMode
? `memory-${memoryScope ?? "user"}`
: isLocalFileMode
? (localFilePath ?? "local-file")
: documentId
}-${isEditing ? "editing" : "viewing"}`}
preset="full" preset="full"
markdown={editorDoc.source_markdown} markdown={editorDoc.source_markdown}
onMarkdownChange={handleMarkdownChange} onMarkdownChange={handleMarkdownChange}
@ -679,7 +759,7 @@ export function EditorPanelContent({
// Edit mode keeps raw text so the user can edit/delete // Edit mode keeps raw text so the user can edit/delete
// tokens directly. `local_file` never reaches this branch // tokens directly. `local_file` never reaches this branch
// (handled by the source_code editor above). // (handled by the source_code editor above).
enableCitations={!isEditing && !isLocalFileMode} enableCitations={!isEditing && !isLocalFileMode && !isMemoryMode}
/> />
</div> </div>
</div> </div>
@ -708,7 +788,9 @@ function DesktopEditorPanel() {
const hasTarget = const hasTarget =
panelState.kind === "document" panelState.kind === "document"
? !!panelState.documentId && !!panelState.searchSpaceId ? !!panelState.documentId && !!panelState.searchSpaceId
: !!panelState.localFilePath; : panelState.kind === "local_file"
? !!panelState.localFilePath
: !!panelState.memoryScope;
if (!panelState.isOpen || !hasTarget) return null; if (!panelState.isOpen || !hasTarget) return null;
return ( return (
@ -717,6 +799,7 @@ function DesktopEditorPanel() {
kind={panelState.kind} kind={panelState.kind}
documentId={panelState.documentId ?? undefined} documentId={panelState.documentId ?? undefined}
localFilePath={panelState.localFilePath ?? undefined} localFilePath={panelState.localFilePath ?? undefined}
memoryScope={panelState.memoryScope ?? undefined}
searchSpaceId={panelState.searchSpaceId ?? undefined} searchSpaceId={panelState.searchSpaceId ?? undefined}
title={panelState.title} title={panelState.title}
onClose={closePanel} onClose={closePanel}
@ -734,7 +817,7 @@ function MobileEditorDrawer() {
const hasTarget = const hasTarget =
panelState.kind === "document" panelState.kind === "document"
? !!panelState.documentId && !!panelState.searchSpaceId ? !!panelState.documentId && !!panelState.searchSpaceId
: !!panelState.localFilePath; : !!panelState.memoryScope;
if (!hasTarget) return null; if (!hasTarget) return null;
return ( return (
@ -756,6 +839,7 @@ function MobileEditorDrawer() {
kind={panelState.kind} kind={panelState.kind}
documentId={panelState.documentId ?? undefined} documentId={panelState.documentId ?? undefined}
localFilePath={panelState.localFilePath ?? undefined} localFilePath={panelState.localFilePath ?? undefined}
memoryScope={panelState.memoryScope ?? undefined}
searchSpaceId={panelState.searchSpaceId ?? undefined} searchSpaceId={panelState.searchSpaceId ?? undefined}
title={panelState.title} title={panelState.title}
/> />
@ -771,7 +855,9 @@ export function EditorPanel() {
const hasTarget = const hasTarget =
panelState.kind === "document" panelState.kind === "document"
? !!panelState.documentId && !!panelState.searchSpaceId ? !!panelState.documentId && !!panelState.searchSpaceId
: !!panelState.localFilePath; : panelState.kind === "local_file"
? !!panelState.localFilePath
: !!panelState.memoryScope;
if (!panelState.isOpen || !hasTarget) return null; if (!panelState.isOpen || !hasTarget) return null;
if (!isDesktop && panelState.kind === "local_file") return null; if (!isDesktop && panelState.kind === "local_file") return null;
@ -789,7 +875,9 @@ export function MobileEditorPanel() {
const hasTarget = const hasTarget =
panelState.kind === "document" panelState.kind === "document"
? !!panelState.documentId && !!panelState.searchSpaceId ? !!panelState.documentId && !!panelState.searchSpaceId
: !!panelState.localFilePath; : panelState.kind === "local_file"
? !!panelState.localFilePath
: !!panelState.memoryScope;
if (isDesktop || !panelState.isOpen || !hasTarget || panelState.kind === "local_file") if (isDesktop || !panelState.isOpen || !hasTarget || panelState.kind === "local_file")
return null; return null;

View file

@ -103,7 +103,11 @@ export function RightPanelToggleButton({
const reportOpen = reportState.isOpen && !!reportState.reportId; const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen = const editorOpen =
editorState.isOpen && editorState.isOpen &&
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath); (editorState.kind === "document"
? !!editorState.documentId
: editorState.kind === "memory"
? !!editorState.memoryScope
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const citationOpen = citationState.isOpen && citationState.chunkId != null; const citationOpen = citationState.isOpen && citationState.chunkId != null;
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen; const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
@ -151,7 +155,11 @@ export function RightPanelExpandButton() {
const reportOpen = reportState.isOpen && !!reportState.reportId; const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen = const editorOpen =
editorState.isOpen && editorState.isOpen &&
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath); (editorState.kind === "document"
? !!editorState.documentId
: editorState.kind === "memory"
? !!editorState.memoryScope
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const citationOpen = citationState.isOpen && citationState.chunkId != null; const citationOpen = citationState.isOpen && citationState.chunkId != null;
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen; const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
@ -193,7 +201,11 @@ export function RightPanel({
const reportOpen = reportState.isOpen && !!reportState.reportId; const reportOpen = reportState.isOpen && !!reportState.reportId;
const editorOpen = const editorOpen =
editorState.isOpen && editorState.isOpen &&
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath); (editorState.kind === "document"
? !!editorState.documentId
: editorState.kind === "memory"
? !!editorState.memoryScope
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const citationOpen = citationState.isOpen && citationState.chunkId != null; const citationOpen = citationState.isOpen && citationState.chunkId != null;
@ -292,6 +304,7 @@ export function RightPanel({
kind={editorState.kind} kind={editorState.kind}
documentId={editorState.documentId ?? undefined} documentId={editorState.documentId ?? undefined}
localFilePath={editorState.localFilePath ?? undefined} localFilePath={editorState.localFilePath ?? undefined}
memoryScope={editorState.memoryScope ?? undefined}
searchSpaceId={editorState.searchSpaceId ?? undefined} searchSpaceId={editorState.searchSpaceId ?? undefined}
title={editorState.title} title={editorState.title}
onClose={closeEditor} onClose={closeEditor}

View file

@ -88,7 +88,31 @@ const DesktopLocalTabContent = dynamic(
{ ssr: false } { ssr: false }
); );
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"]; const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = [
"SURFSENSE_DOCS",
"USER_MEMORY",
"TEAM_MEMORY",
];
const MEMORY_DOCUMENTS: DocumentNodeDoc[] = [
{
id: -1001,
title: "MEMORY.md",
document_type: "USER_MEMORY",
folderId: null,
status: { state: "ready" },
},
{
id: -1002,
title: "TEAM_MEMORY.md",
document_type: "TEAM_MEMORY",
folderId: null,
status: { state: "ready" },
},
];
function isMemoryDocument(doc: { document_type: string }) {
return doc.document_type === "USER_MEMORY" || doc.document_type === "TEAM_MEMORY";
}
const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1"; const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1";
const MAX_LOCAL_FILESYSTEM_ROOTS = 10; const MAX_LOCAL_FILESYSTEM_ROOTS = 10;
@ -879,6 +903,7 @@ function AuthenticatedDocumentsSidebarBase({
const handleToggleChatMention = useCallback( const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => { (doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
if (isMemoryDocument(doc)) return;
const key = getMentionDocKey({ ...doc, kind: "doc" }); const key = getMentionDocKey({ ...doc, kind: "doc" });
if (isMentioned) { if (isMentioned) {
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key)); setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
@ -927,11 +952,66 @@ function AuthenticatedDocumentsSidebarBase({
[treeFolders, setSidebarDocs] [treeFolders, setSidebarDocs]
); );
const treeDocumentsWithMemory = useMemo(
() => [...MEMORY_DOCUMENTS, ...treeDocuments],
[treeDocuments]
);
const searchFilteredDocuments = useMemo(() => { const searchFilteredDocuments = useMemo(() => {
const query = debouncedSearch.trim().toLowerCase(); const query = debouncedSearch.trim().toLowerCase();
if (!query) return treeDocuments; if (!query) return treeDocumentsWithMemory;
return treeDocuments.filter((d) => d.title.toLowerCase().includes(query)); return treeDocumentsWithMemory.filter((d) => d.title.toLowerCase().includes(query));
}, [treeDocuments, debouncedSearch]); }, [treeDocumentsWithMemory, debouncedSearch]);
const openMemoryDocument = useCallback(
(doc: DocumentNodeDoc) => {
if (doc.document_type === "USER_MEMORY") {
openEditorPanel({
kind: "memory",
memoryScope: "user",
searchSpaceId,
title: doc.title,
});
return true;
}
if (doc.document_type === "TEAM_MEMORY") {
openEditorPanel({
kind: "memory",
memoryScope: "team",
searchSpaceId,
title: doc.title,
});
return true;
}
return false;
},
[openEditorPanel, searchSpaceId]
);
const handleResetMemoryDocument = useCallback(
async (doc: DocumentNodeDoc) => {
if (!isMemoryDocument(doc)) return;
if (!window.confirm(`Reset ${doc.title.toLowerCase()}? This clears the memory document.`)) {
return;
}
const endpoint =
doc.document_type === "USER_MEMORY"
? `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/users/me/memory/reset`
: `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/memory/reset`;
try {
const response = await authenticatedFetch(endpoint, { method: "POST" });
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Reset failed" }));
throw new Error(errorData.detail || "Reset failed");
}
toast.success(`${doc.title} reset`);
openMemoryDocument(doc);
} catch (error) {
toast.error((error as Error)?.message || `Failed to reset ${doc.title.toLowerCase()}`);
}
},
[openMemoryDocument, searchSpaceId]
);
const typeCounts = useMemo(() => { const typeCounts = useMemo(() => {
const counts: Partial<Record<string, number>> = {}; const counts: Partial<Record<string, number>> = {};
@ -1169,6 +1249,7 @@ function AuthenticatedDocumentsSidebarBase({
onCreateFolder={handleCreateFolder} onCreateFolder={handleCreateFolder}
searchQuery={debouncedSearch.trim() || undefined} searchQuery={debouncedSearch.trim() || undefined}
onPreviewDocument={(doc) => { onPreviewDocument={(doc) => {
if (openMemoryDocument(doc)) return;
openEditorPanel({ openEditorPanel({
documentId: doc.id, documentId: doc.id,
searchSpaceId, searchSpaceId,
@ -1176,6 +1257,7 @@ function AuthenticatedDocumentsSidebarBase({
}); });
}} }}
onEditDocument={(doc) => { onEditDocument={(doc) => {
if (openMemoryDocument(doc)) return;
openEditorPanel({ openEditorPanel({
documentId: doc.id, documentId: doc.id,
searchSpaceId, searchSpaceId,
@ -1184,6 +1266,7 @@ function AuthenticatedDocumentsSidebarBase({
}} }}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument} onMoveDocument={handleMoveDocument}
onResetDocument={handleResetMemoryDocument}
onExportDocument={handleExportDocument} onExportDocument={handleExportDocument}
onVersionHistory={(doc) => setVersionDocId(doc.id)} onVersionHistory={(doc) => setVersionDocId(doc.id)}
activeTypes={activeTypes} activeTypes={activeTypes}

View file

@ -1,6 +1,7 @@
import { IconUsersGroup } from "@tabler/icons-react"; import { IconUsersGroup } from "@tabler/icons-react";
import { import {
BookOpen, BookOpen,
Brain,
File, File,
FileText, FileText,
Globe, Globe,
@ -120,6 +121,9 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return <Webhook {...iconProps} />; return <Webhook {...iconProps} />;
case "SURFSENSE_DOCS": case "SURFSENSE_DOCS":
return <BookOpen {...iconProps} />; return <BookOpen {...iconProps} />;
case "USER_MEMORY":
case "TEAM_MEMORY":
return <Brain {...iconProps} />;
case "DEEP": case "DEEP":
return <Sparkles {...iconProps} />; return <Sparkles {...iconProps} />;
case "DEEPER": case "DEEPER":

View file

@ -29,6 +29,8 @@ export const documentTypeEnum = z.enum([
"LOCAL_FOLDER_FILE", "LOCAL_FOLDER_FILE",
"SURFSENSE_DOCS", "SURFSENSE_DOCS",
"NOTE", "NOTE",
"USER_MEMORY",
"TEAM_MEMORY",
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR", "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
"COMPOSIO_GMAIL_CONNECTOR", "COMPOSIO_GMAIL_CONNECTOR",
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR", "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",