lobehub/src/features/ChatInput/InputEditor/index.tsx
Arvin Xu 8240e8685d
🐛 fix(desktop): repo-type detection for submodule/worktree + chat & sidebar polish (#13978)
* 🐛 fix(desktop): detect repo type for submodule and worktree directories

Route detectRepoType through resolveGitDir so directories where `.git`
is a pointer file (submodules, worktrees) are correctly identified as
git/github repos instead of falling back to the plain folder icon.

Fixes LOBE-7373

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(desktop): reprobe repo type for stale recent-dir entries

The recents picker rendered `entry.repoType` directly from localStorage,
so any submodule/worktree entry cached while `detectRepoType` still
returned `undefined` stayed stuck on the folder icon even after the
main-process fix. Wrap each row icon in a component that calls
`useRepoType`, which re-probes missing entries and backfills the cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(chat-input): clear autocomplete hint on IME start to prevent freeze

Dispatch KEY_ESCAPE_COMMAND on compositionstart so the autocomplete
plugin removes PlaceholderInline/PlaceholderBlock nodes before the IME
begins composing. Composing next to those placeholder nodes caused the
editor to freeze during pinyin input with a visible hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(topic-sidebar): split project grouping into ByProjectMode

Extracts project-specific group rendering from ByTimeMode into its own ByProjectMode folder, with a shared GroupedAccordion container. Project groups get a folder-icon column aligned with the topic item layout and a "new topic in {directory}" action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(desktop): read config via commondir for linked worktrees

`resolveGitDir` returns `.git/worktrees/<name>/` for linked worktrees —
that dir has its own `HEAD` but no `config`, so `detectRepoType` still
returned `undefined` and worktrees missed the repo icon. Resolve the
`commondir` pointer first so `config` is read from the shared gitdir.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:56:39 +08:00

378 lines
13 KiB
TypeScript

import { isDesktop } from '@lobechat/const';
import { HotkeyEnum, KeyEnum } from '@lobechat/const/hotkeys';
import { chainInputCompletion } from '@lobechat/prompts';
import { isCommandPressed, merge } from '@lobechat/utils';
import { INSERT_MENTION_COMMAND, ReactAutoCompletePlugin, ReactMathPlugin } from '@lobehub/editor';
import { Editor, FloatMenu, useEditorState } from '@lobehub/editor/react';
import { combineKeys } from '@lobehub/ui';
import { css, cx } from 'antd-style';
import Fuse from 'fuse.js';
import { KEY_ESCAPE_COMMAND } from 'lexical';
import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
import { useHotkeysContext } from 'react-hotkeys-hook';
import { usePasteFile, useUploadFiles } from '@/components/DragUploadZone';
import { useIMECompositionEvent } from '@/hooks/useIMECompositionEvent';
import { chatService } from '@/services/chat';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useUserStore } from '@/store/user';
import {
labPreferSelectors,
preferenceSelectors,
settingsSelectors,
systemAgentSelectors,
} from '@/store/user/selectors';
import { useAgentId } from '../hooks/useAgentId';
import { useChatInputStore, useStoreApi } from '../store';
import {
INSERT_ACTION_TAG_COMMAND,
type InsertActionTagPayload,
useSlashActionItems,
} from './ActionTag';
import { createMentionMenu } from './MentionMenu';
import type { MentionMenuState } from './MentionMenu/types';
import Placeholder from './Placeholder';
import { CHAT_INPUT_EMBED_PLUGINS, createChatInputRichPlugins } from './plugins';
import { INSERT_REFER_TOPIC_COMMAND } from './ReferTopic';
import { useMentionCategories } from './useMentionCategories';
const className = cx(css`
p {
margin-block-end: 0;
}
`);
const InputEditor = memo<{ defaultRows?: number; placeholder?: ReactNode }>(
({ defaultRows = 2, placeholder }) => {
const [editor, slashMenuRef, send, updateMarkdownContent, expand, slashPlacement] =
useChatInputStore((s) => [
s.editor,
s.slashMenuRef,
s.handleSendButton,
s.updateMarkdownContent,
s.expand,
s.slashPlacement ?? 'top',
]);
const storeApi = useStoreApi();
const state = useEditorState(editor);
const hotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.AddUserMessage));
const { enableScope, disableScope } = useHotkeysContext();
const { compositionProps, isComposingRef } = useIMECompositionEvent();
const useCmdEnterToSend = useUserStore(preferenceSelectors.useCmdEnterToSend);
// --- Category-based mention system ---
const categories = useMentionCategories();
const stateRef = useRef<MentionMenuState>({ isSearch: false, matchingString: '' });
const categoriesRef = useRef(categories);
categoriesRef.current = categories;
const allMentionItems = useMemo(() => categories.flatMap((c) => c.items), [categories]);
const fuse = useMemo(
() =>
new Fuse(allMentionItems, {
keys: ['key', 'label', 'metadata.topicTitle'],
threshold: 0.3,
}),
[allMentionItems],
);
const mentionItemsFn = useCallback(
async (
search: { leadOffset: number; matchingString: string; replaceableString: string } | null,
) => {
if (search?.matchingString) {
stateRef.current = { isSearch: true, matchingString: search.matchingString };
return fuse.search(search.matchingString).map((r) => r.item);
}
stateRef.current = { isSearch: false, matchingString: '' };
return [...allMentionItems];
},
[allMentionItems, fuse],
);
const MentionMenuComp = useMemo(() => createMentionMenu(stateRef, categoriesRef), []);
const enableMention = allMentionItems.length > 0;
// Get agent's model info for vision support check and handle paste upload
const agentId = useAgentId();
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(agentId)(s));
const provider = useAgentStore((s) => agentByIdSelectors.getAgentModelProviderById(agentId)(s));
const { handleUploadFiles } = useUploadFiles({ model, provider });
// Listen to editor's paste event for file uploads
usePasteFile(editor, handleUploadFiles);
useEffect(() => {
const fn = (e: BeforeUnloadEvent) => {
if (!state.isEmpty) {
// set returnValue to trigger alert modal
// Note: No matter what value is set, the browser will display the standard text
e.returnValue = 'You are typing something, are you sure you want to leave?';
}
};
window.addEventListener('beforeunload', fn);
return () => {
window.removeEventListener('beforeunload', fn);
};
}, [state.isEmpty]);
const enableRichRender = useUserStore(labPreferSelectors.enableInputMarkdown);
const slashActionItems = useSlashActionItems();
const slashItems = useCallback(
async (
search: { leadOffset: number; matchingString: string; replaceableString: string } | null,
) => {
const actionItems =
typeof slashActionItems === 'function'
? await slashActionItems(search)
: slashActionItems;
return actionItems;
},
[slashActionItems],
);
// --- Auto-completion ---
const inputCompletionConfig = useUserStore(systemAgentSelectors.inputCompletion);
const isAutoCompleteEnabled = inputCompletionConfig.enabled;
const getMessagesRef = useRef(storeApi.getState().getMessages);
useEffect(() => {
return storeApi.subscribe((s) => {
getMessagesRef.current = s.getMessages;
});
}, [storeApi]);
const handleAutoComplete = useCallback(
async ({
abortSignal,
afterText,
input,
}: {
abortSignal: AbortSignal;
afterText: string;
editor: any;
input: string;
selectionType: string;
}): Promise<string | null> => {
// Skip autocomplete during IME composition (e.g. Chinese input method)
if (isComposingRef.current) return null;
if (!input.trim()) return null;
// Skip when cursor is not at end of paragraph — inserting a placeholder
// mid-text causes nested editor updates that freeze the input
if (afterText.trim()) return null;
const { enabled: _, ...config } = systemAgentSelectors.inputCompletion(
useUserStore.getState(),
);
const context = getMessagesRef.current?.();
const chainParams = chainInputCompletion(input, afterText, context);
const abortController = new AbortController();
abortSignal.addEventListener('abort', () => abortController.abort());
let result = '';
try {
await chatService.fetchPresetTaskResult({
abortController,
onMessageHandle: (chunk) => {
if (chunk.type === 'text') {
result += chunk.text;
}
},
params: merge(config, chainParams),
});
} catch {
return null;
}
if (abortSignal.aborted) return null;
return result.trimEnd() || null;
},
[],
);
const autoCompletePlugin = useMemo(
() =>
isAutoCompleteEnabled
? Editor.withProps(ReactAutoCompletePlugin, {
delay: 600,
onAutoComplete: handleAutoComplete,
})
: null,
[isAutoCompleteEnabled, handleAutoComplete],
);
// --- Stable mentionOption & slashOption to prevent infinite re-render on paste ---
const mentionMarkdownWriter = useCallback((mention: any) => {
if (mention.metadata?.type === 'topic') {
return `<refer_topic name="${mention.metadata.topicTitle}" id="${mention.metadata.topicId}" />`;
}
return `<mention name="${mention.label}" id="${mention.metadata.id}" />`;
}, []);
const mentionOnSelect = useCallback((editor: any, option: any) => {
if (option.metadata?.type === 'topic') {
editor.dispatchCommand(INSERT_REFER_TOPIC_COMMAND, {
topicId: option.metadata.topicId as string,
topicTitle: String(option.metadata.topicTitle ?? option.label),
});
} else if (option.metadata?.type === 'skill' || option.metadata?.type === 'tool') {
const payload: InsertActionTagPayload = {
category: option.metadata.actionCategory as 'skill' | 'tool',
label: String(option.label),
type: String(option.metadata.actionType),
};
editor.dispatchCommand(INSERT_ACTION_TAG_COMMAND, payload);
} else {
editor.dispatchCommand(INSERT_MENTION_COMMAND, {
label: String(option.label),
metadata: option.metadata,
});
}
}, []);
const mentionOption = useMemo(
() =>
enableMention
? {
items: mentionItemsFn,
markdownWriter: mentionMarkdownWriter,
maxLength: 50,
onSelect: mentionOnSelect,
renderComp: MentionMenuComp,
}
: undefined,
[enableMention, mentionItemsFn, mentionMarkdownWriter, mentionOnSelect, MentionMenuComp],
);
const slashOption = useMemo(() => ({ items: slashItems }), [slashItems]);
const richRenderProps = useMemo(() => {
const basePlugins = !enableRichRender
? CHAT_INPUT_EMBED_PLUGINS
: createChatInputRichPlugins({
mathPlugin: Editor.withProps(ReactMathPlugin, {
renderComp: expand
? undefined
: (props) => (
<FloatMenu
{...props}
getPopupContainer={() => (slashMenuRef as any)?.current}
/>
),
}),
});
const plugins = autoCompletePlugin ? [...basePlugins, autoCompletePlugin] : basePlugins;
return !enableRichRender
? { enablePasteMarkdown: false, markdownOption: false, plugins }
: { plugins };
}, [enableRichRender, expand, slashMenuRef, autoCompletePlugin]);
return (
<Editor
autoFocus
pasteAsPlainText
className={className}
content={''}
editor={editor}
{...{ slashPlacement }}
{...richRenderProps}
mentionOption={mentionOption}
placeholder={placeholder ?? <Placeholder />}
slashOption={slashOption}
type={'text'}
variant={'chat'}
style={{
minHeight: defaultRows > 1 ? defaultRows * 23 : undefined,
}}
onCompositionEnd={({ event }) => compositionProps.onCompositionEnd(event)}
onBlur={() => {
disableScope(HotkeyEnum.AddUserMessage);
}}
onChange={() => {
updateMarkdownContent();
}}
onCompositionStart={({ event }) => {
compositionProps.onCompositionStart(event);
// Clear autocomplete placeholder nodes before IME composition starts —
// composing next to placeholder inline nodes freezes the editor.
if (isAutoCompleteEnabled) {
editor?.dispatchCommand(
KEY_ESCAPE_COMMAND,
new KeyboardEvent('keydown', { key: 'Escape' }),
);
}
}}
onContextMenu={async ({ event: e, editor }) => {
if (isDesktop) {
e.preventDefault();
const { electronSystemService } = await import('@/services/electron/system');
const selectionText = editor.getSelectionDocument('markdown') as unknown as string;
await electronSystemService.showContextMenu('editor', {
selectionText: selectionText || undefined,
});
}
}}
onFocus={() => {
enableScope(HotkeyEnum.AddUserMessage);
}}
onInit={(editor) => {
const saved = storeApi.getState()._savedEditorState;
storeApi.setState({ _savedEditorState: undefined, editor });
if (saved) {
requestAnimationFrame(() => {
editor.setDocument('json', saved);
});
}
}}
onPressEnter={({ event: e }) => {
if (e.shiftKey || isComposingRef.current) return;
// when user like alt + enter to add ai message
if (e.altKey && hotkey === combineKeys([KeyEnum.Alt, KeyEnum.Enter])) return true;
const commandKey = isCommandPressed(e);
// In fullscreen mode, Enter inserts newline; only Cmd/Ctrl+Enter sends
if (expand) {
if (commandKey) {
send();
return true;
}
return;
}
// when user like cmd + enter to send message
if (useCmdEnterToSend) {
if (commandKey) {
send();
return true;
}
} else {
if (!commandKey) {
send();
return true;
}
}
}}
/>
);
},
);
InputEditor.displayName = 'InputEditor';
export default InputEditor;