mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
* 🐛 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>
378 lines
13 KiB
TypeScript
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;
|