From 2a4b6e49745bcd2a03dc294ec2fea99aae6f0b4d Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 4 Jun 2026 00:54:39 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(agent-doc):=20default=20new?= =?UTF-8?q?=20files=20to=20.md=20and=20preserve=20IME=20composition=20(#15?= =?UTF-8?q?427)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * πŸ› fix(agent-doc): default new files to .md and preserve IME composition - Append `.md` to newly-created agent documents; pre-select only the stem in the inline rename input so the extension stays intact. - Wire `useIMECompositionEvent` on the explorer container so Enter pressed during IME composition (e.g. Chinese pinyin) no longer commits the half-formed name through pierre/trees' shadow-DOM input. * πŸ› fix(agent-doc): use native capture listener for IME guard React `onKeyDownCapture` can lose to pierre/trees' bubble handler in some event ordering edge cases, and the original guard missed IMEs that report `keyCode === 229` or fire Enter just after compositionend in the same task. - Bind a native `keydown` capture listener on the container so we can inspect `composedPath()` and confirm the keydown originated inside the shadow-DOM rename input. - Extend the IME guard with an `imeSessionRef` that stays true through one extra microtask after compositionend. - Drop the React `onKeyDownCapture` prop in favour of the native listener. * βͺ revert(agent-doc): drop IME guard pending pierre/trees upstream fix The inline rename input lives in pierre/trees' shadow DOM and we can't reliably suppress its IME-composing Enter commit from the outside. Roll back the local hack and track the issue upstream instead. The default `.md` extension and stem-only selection on rename stay in place. * ✨ feat(agent-doc): preselect stem on inline rename too Existing files entering inline rename (right-click β†’ Rename, or F2) now narrow the selection to the stem after pierre/trees' `input.select()`, matching the new-file flow so the user never has to retype `.md`. * πŸ› fix(agent-doc): preserve extension on filename collisions --- .../DocumentExplorerTree.tsx | 30 ++++++++++++++++-- .../hooks/useDocumentTreeOps.ts | 28 +++++++++++------ .../services/agentDocuments/index.test.ts | 31 +++++++++++++++++++ src/server/services/agentDocuments/index.ts | 10 +++++- 4 files changed, 87 insertions(+), 12 deletions(-) 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; }