diff --git a/src/features/AgentDocumentsExplorer/DocumentExplorerTree.tsx b/src/features/AgentDocumentsExplorer/DocumentExplorerTree.tsx index 15771585f1..adafeab90a 100644 --- a/src/features/AgentDocumentsExplorer/DocumentExplorerTree.tsx +++ b/src/features/AgentDocumentsExplorer/DocumentExplorerTree.tsx @@ -21,6 +21,23 @@ import { isOrphanSkillBundleItem } from './types'; import { canDropDocument } from './utils/canDrop'; const SKILL_INDEX_FILENAME = 'SKILL.md'; +const FILE_TREE_HOST_TAG = 'file-tree-container'; +const RENAME_INPUT_SELECTOR = 'input[data-item-rename-input]'; + +// pierre/trees auto-selects the full value when the rename input mounts. For +// files with extensions (e.g. `Untitled document.md`), narrow the selection to +// the stem so the user can type a new name without overwriting the suffix. +const selectStemOfActiveRenameInput = (root: HTMLElement | null) => { + if (!root) return; + const host = root.querySelector(FILE_TREE_HOST_TAG); + const input = host?.shadowRoot?.querySelector(RENAME_INPUT_SELECTOR); + if (!input) return; + const value = input.value; + const dotIndex = value.lastIndexOf('.'); + // Skip dotfiles and extension-less names — leave pierre's full-selection. + if (dotIndex <= 0) return; + input.setSelectionRange(0, dotIndex); +}; const styles = createStaticStyles(({ css, cssVar }) => ({ tree: css` @@ -53,9 +70,13 @@ const DocumentExplorerTree = memo(({ agentId, data, mutate, style }) => { const { t } = useTranslation(['chat', 'common']); const openDocument = useChatStore((s) => s.openDocument); const treeRef = useRef(null); + const containerRef = useRef(null); const startInlineRename = useCallback((id: string) => { treeRef.current?.startRenaming(id); + // Match the new-file flow: leave the extension out of the selection so + // the user can retype only the stem. + requestAnimationFrame(() => selectStemOfActiveRenameInput(containerRef.current)); }, []); const ops = useDocumentTreeOps({ agentId, data, mutate }); @@ -113,7 +134,12 @@ const DocumentExplorerTree = memo(({ agentId, data, mutate, style }) => { const focusNewRowForRename = useCallback((pendingId: string) => { // Defer past the current task so React commits the inserted row and the // tree adapter rebuilds its id→path map before we trigger rename. - setTimeout(() => treeRef.current?.startRenaming(pendingId), 0); + setTimeout(() => { + treeRef.current?.startRenaming(pendingId); + // After pierre's input.select() runs in its own layout effect, narrow + // selection to the stem so the `.md` extension stays intact. + requestAnimationFrame(() => selectStemOfActiveRenameInput(containerRef.current)); + }, 0); }, []); const handleCreateFolder = useCallback( @@ -233,7 +259,7 @@ const DocumentExplorerTree = memo(({ agentId, data, mutate, style }) => { ); return ( -
+
iconsColored canDrag={canDrag} diff --git a/src/features/AgentDocumentsExplorer/hooks/useDocumentTreeOps.ts b/src/features/AgentDocumentsExplorer/hooks/useDocumentTreeOps.ts index 8778f0fba1..84159cae4d 100644 --- a/src/features/AgentDocumentsExplorer/hooks/useDocumentTreeOps.ts +++ b/src/features/AgentDocumentsExplorer/hooks/useDocumentTreeOps.ts @@ -18,6 +18,7 @@ interface UseDocumentTreeOpsArgs { } const ROOT_PATH = './'; +const DEFAULT_DOCUMENT_EXTENSION = '.md'; const joinPath = (parentPath: string, segment: string) => parentPath === ROOT_PATH ? `${ROOT_PATH}${segment}` : `${parentPath}/${segment}`; @@ -112,17 +113,20 @@ export const useDocumentTreeOps = ({ // Picks a unique filename within the given parent. Used for client-side // dedup of "Untitled" rows because the path-based VFS mkdir is idempotent // (same name re-uses the existing folder), and writeByPath in always-new - // mode rejects collisions outright. + // mode rejects collisions outright. When an extension is provided, the + // numeric suffix is inserted before the extension so the file keeps its + // type (e.g. `Untitled document 2.md`). const pickUniqueFilename = useCallback( - (parentDocumentId: string | null, baseName: string): string => { + (parentDocumentId: string | null, baseStem: string, extension = ''): string => { const siblings = dataRef.current.filter((doc) => (doc.parentId ?? null) === parentDocumentId); const taken = new Set(siblings.map((doc) => doc.filename)); - if (!taken.has(baseName)) return baseName; + const first = `${baseStem}${extension}`; + if (!taken.has(first)) return first; for (let i = 2; i < 1000; i += 1) { - const candidate = `${baseName} ${i}`; + const candidate = `${baseStem} ${i}${extension}`; if (!taken.has(candidate)) return candidate; } - return `${baseName} ${Date.now()}`; + return `${baseStem} ${Date.now()}${extension}`; }, [], ); @@ -183,9 +187,13 @@ export const useDocumentTreeOps = ({ return; } - const baseName = t('workingPanel.resources.tree.untitledDocument'); + const baseStem = t('workingPanel.resources.tree.untitledDocument'); const parentDocumentId = parentId ? (byRowId.get(parentId)?.documentId ?? null) : null; - const pendingFilename = pickUniqueFilename(parentDocumentId, baseName); + const pendingFilename = pickUniqueFilename( + parentDocumentId, + baseStem, + DEFAULT_DOCUMENT_EXTENSION, + ); const pending = makePendingDocument({ agentId, @@ -200,11 +208,13 @@ export const useDocumentTreeOps = ({ try { const result = parentPath === ROOT_PATH - ? // Server's createDocument auto-deduplicates filenames at the root. + ? // Pass the client-picked filename (with extension) as the title so + // the server keeps the `.md` suffix; server dedup remains a safety + // net for racing clients. await agentDocumentService.createDocument({ agentId, content: '', - title: baseName, + title: pendingFilename, }) : await agentDocumentService.writeByPath({ agentId, diff --git a/src/server/services/agentDocuments/index.test.ts b/src/server/services/agentDocuments/index.test.ts index c767ffb9f5..e2ac9a24de 100644 --- a/src/server/services/agentDocuments/index.test.ts +++ b/src/server/services/agentDocuments/index.test.ts @@ -151,6 +151,37 @@ describe('AgentDocumentsService', () => { expect(result).toEqual({ id: 'new-doc', filename: 'note-2' }); }); + it('should append collision suffix before the filename extension', async () => { + mockModel.findByFilename + .mockResolvedValueOnce({ id: 'existing-doc' }) + .mockResolvedValueOnce(undefined); + mockModel.create.mockResolvedValue({ id: 'new-doc', filename: 'Untitled document-2.md' }); + + const service = new AgentDocumentsService(db, userId); + const result = await service.createDocument('agent-1', 'Untitled document.md', 'content'); + + expect(mockModel.findByFilename).toHaveBeenNthCalledWith( + 1, + 'agent-1', + 'Untitled document.md', + ); + expect(mockModel.findByFilename).toHaveBeenNthCalledWith( + 2, + 'agent-1', + 'Untitled document-2.md', + ); + expect(mockModel.create).toHaveBeenCalledWith( + 'agent-1', + 'Untitled document-2.md', + 'content', + { + editorData: { root: { children: [] } }, + title: 'Untitled document.md', + }, + ); + expect(result).toEqual({ id: 'new-doc', filename: 'Untitled document-2.md' }); + }); + it('should throw after too many filename collisions', async () => { mockModel.findByFilename.mockResolvedValue({ id: 'existing-doc' }); diff --git a/src/server/services/agentDocuments/index.ts b/src/server/services/agentDocuments/index.ts index 25ff5f2644..279130a560 100644 --- a/src/server/services/agentDocuments/index.ts +++ b/src/server/services/agentDocuments/index.ts @@ -36,6 +36,14 @@ import { const MAX_UNIQUE_FILENAME_ATTEMPTS = 1000; +const appendFilenameSuffix = (filename: string, suffix: number): string => { + const dotIndex = filename.lastIndexOf('.'); + + if (dotIndex <= 0) return `${filename}-${suffix}`; + + return `${filename.slice(0, dotIndex)}-${suffix}${filename.slice(dotIndex)}`; +}; + interface UpsertDocumentParams { agentId: string; content: string; @@ -189,7 +197,7 @@ export class AgentDocumentsService { ); } - filename = `${baseFilename}-${suffix}`; + filename = appendFilenameSuffix(baseFilename, suffix); suffix += 1; }