mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
🐛 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:
@@ -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' });
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user