🐛 fix(agent-doc): default new files to .md and preserve IME composition (#15427)

* 🐛 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
This commit is contained in:
Innei
2026-06-04 00:54:39 +09:00
committed by GitHub
parent 2fb0970cf9
commit 2a4b6e4974
4 changed files with 87 additions and 12 deletions
@@ -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<HTMLInputElement>(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<Props>(({ agentId, data, mutate, style }) => {
const { t } = useTranslation(['chat', 'common']);
const openDocument = useChatStore((s) => s.openDocument);
const treeRef = useRef<ExplorerTreeHandle | null>(null);
const containerRef = useRef<HTMLDivElement | null>(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<Props>(({ 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<Props>(({ agentId, data, mutate, style }) => {
);
return (
<div className={styles.tree} style={{ ...style, ...treeStyleVars }}>
<div className={styles.tree} ref={containerRef} style={{ ...style, ...treeStyleVars }}>
<ExplorerTree<AgentDocumentItem>
iconsColored
canDrag={canDrag}
@@ -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,
@@ -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' });
+9 -1
View File
@@ -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;
}