diff --git a/apps/desktop/src/main/controllers/SystemCtr.ts b/apps/desktop/src/main/controllers/SystemCtr.ts index 5c4236a79e..82b89d30de 100644 --- a/apps/desktop/src/main/controllers/SystemCtr.ts +++ b/apps/desktop/src/main/controllers/SystemCtr.ts @@ -247,9 +247,10 @@ export default class SystemController extends ControllerModule { @IpcMethod() async detectRepoType(dirPath: string): Promise<'git' | 'github' | undefined> { - const gitConfigPath = path.join(dirPath, '.git', 'config'); + const commonDir = await this.resolveCommonGitDir(dirPath); + if (!commonDir) return undefined; try { - const config = await readFile(gitConfigPath, 'utf8'); + const config = await readFile(path.join(commonDir, 'config'), 'utf8'); if (config.includes('github.com')) return 'github'; return 'git'; } catch { @@ -460,6 +461,24 @@ export default class SystemController extends ControllerModule { } } + /** + * Resolve the common git dir — where shared state like `config` and + * `packed-refs` lives. For linked worktrees, `resolveGitDir` returns + * `.git/worktrees//` which has its own `HEAD` but no `config`; + * the `commondir` pointer inside it resolves to the main repo's gitdir. + */ + private async resolveCommonGitDir(dirPath: string): Promise { + const gitDir = await this.resolveGitDir(dirPath); + if (!gitDir) return undefined; + try { + const commondir = (await readFile(path.join(gitDir, 'commondir'), 'utf8')).trim(); + if (!commondir) return gitDir; + return path.isAbsolute(commondir) ? commondir : path.resolve(gitDir, commondir); + } catch { + return gitDir; + } + } + /** * Resolve the actual `.git` directory for a working tree. * Supports both standard layouts and worktree pointer files (`.git` as a regular file). diff --git a/locales/en-US/topic.json b/locales/en-US/topic.json index e29da718b0..7a46e17c4e 100644 --- a/locales/en-US/topic.json +++ b/locales/en-US/topic.json @@ -1,5 +1,6 @@ { "actions.addNewTopic": "Start New Topic", + "actions.addNewTopicInProject": "Start new topic in {{directory}}", "actions.autoRename": "Smart Rename", "actions.confirmRemoveAll": "You are about to delete all topics. This action cannot be undone.", "actions.confirmRemoveTopic": "You are about to delete this topic. This action cannot be undone.", diff --git a/locales/zh-CN/topic.json b/locales/zh-CN/topic.json index 0912f6824d..569c3590e3 100644 --- a/locales/zh-CN/topic.json +++ b/locales/zh-CN/topic.json @@ -1,5 +1,6 @@ { "actions.addNewTopic": "开启新话题", + "actions.addNewTopicInProject": "在 {{directory}} 中开启新话题", "actions.autoRename": "智能重命名", "actions.confirmRemoveAll": "您即将删除所有话题,此操作无法撤销。", "actions.confirmRemoveTopic": "您即将删除此话题,此操作无法撤销。", diff --git a/src/features/ChatInput/InputEditor/index.tsx b/src/features/ChatInput/InputEditor/index.tsx index a0426c5d4a..5c55327bec 100644 --- a/src/features/ChatInput/InputEditor/index.tsx +++ b/src/features/ChatInput/InputEditor/index.tsx @@ -7,6 +7,7 @@ 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'; @@ -300,13 +301,23 @@ const InputEditor = memo<{ defaultRows?: number; placeholder?: ReactNode }>( minHeight: defaultRows > 1 ? defaultRows * 23 : undefined, }} onCompositionEnd={({ event }) => compositionProps.onCompositionEnd(event)} - onCompositionStart={({ event }) => compositionProps.onCompositionStart(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(); diff --git a/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx b/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx index f3e9b95bba..5ded521585 100644 --- a/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx +++ b/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx @@ -14,6 +14,7 @@ import { useChatStore } from '@/store/chat'; import { topicSelectors } from '@/store/chat/selectors'; import { addRecentDir, getRecentDirs, type RecentDirEntry, removeRecentDir } from './recentDirs'; +import { useRepoType } from './useRepoType'; const styles = createStaticStyles(({ css }) => ({ chooseFolderItem: css` @@ -103,6 +104,15 @@ const renderDirIcon = (repoType?: 'git' | 'github'): ReactNode => { ); }; +// Backfills `repoType` for entries cached before detection supported submodule / +// worktree layouts — `useRepoType` probes and updates the recents cache. +const RecentDirIcon = memo<{ entry: RecentDirEntry }>(({ entry }) => { + const probed = useRepoType(entry.path); + return <>{renderDirIcon(entry.repoType ?? probed)}; +}); + +RecentDirIcon.displayName = 'RecentDirIcon'; + interface WorkingDirectoryContentProps { agentId: string; onClose?: () => void; @@ -223,7 +233,7 @@ const WorkingDirectoryContent = memo(({ agentId, o key={entry.path} onClick={() => selectDir(entry)} > - {renderDirIcon(entry.repoType)} +
{getDirName(entry.path)}
{entry.path}
diff --git a/src/locales/default/topic.ts b/src/locales/default/topic.ts index b18d0b15da..5a220b6b78 100644 --- a/src/locales/default/topic.ts +++ b/src/locales/default/topic.ts @@ -1,5 +1,6 @@ export default { 'actions.addNewTopic': 'Start New Topic', + 'actions.addNewTopicInProject': 'Start new topic in {{directory}}', 'actions.autoRename': 'Smart Rename', 'actions.confirmRemoveAll': 'You are about to delete all topics. This action cannot be undone.', 'actions.confirmRemoveTopic': 'You are about to delete this topic. This action cannot be undone.', diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/index.tsx index 93943e7da6..e6522ec1a6 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/index.tsx @@ -14,6 +14,7 @@ import { useUserStore } from '@/store/user'; import { preferenceSelectors } from '@/store/user/selectors'; import AllTopicsDrawer from '../AllTopicsDrawer'; +import ByProjectMode from '../TopicListContent/ByProjectMode'; import ByTimeMode from '../TopicListContent/ByTimeMode'; import FlatMode from '../TopicListContent/FlatMode'; @@ -48,7 +49,13 @@ const TopicList = memo(() => { }} /> )} - {topicGroupMode === 'flat' ? : } + {topicGroupMode === 'flat' ? ( + + ) : topicGroupMode === 'byProject' ? ( + + ) : ( + + )} ); diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx new file mode 100644 index 0000000000..29c9cc981a --- /dev/null +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/GroupItem.tsx @@ -0,0 +1,87 @@ +import { AccordionItem, ActionIcon, Center, Flexbox, Icon, Text } from '@lobehub/ui'; +import { cssVar } from 'antd-style'; +import { FolderClosedIcon, PlusIcon } from 'lucide-react'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { isDesktop } from '@/const/version'; +import { useAgentStore } from '@/store/agent'; +import { useChatStore } from '@/store/chat'; + +import TopicItem from '../../List/Item'; +import { type GroupItemComponentProps } from '../GroupedAccordion'; + +const PROJECT_GROUP_PREFIX = 'project:'; + +const GroupItem = memo(({ group, activeTopicId, activeThreadId }) => { + const { t } = useTranslation('topic'); + const { id, title, children } = group; + + const workingDirectory = useMemo( + () => (id.startsWith(PROJECT_GROUP_PREFIX) ? id.slice(PROJECT_GROUP_PREFIX.length) : undefined), + [id], + ); + + const handleAddTopic = useCallback(async () => { + if (!workingDirectory) return; + const agentId = useAgentStore.getState().activeAgentId; + if (agentId) { + await useAgentStore.getState().updateAgentRuntimeEnvConfigById(agentId, { workingDirectory }); + } + useChatStore.getState().switchTopic(null, { skipRefreshMessage: true }); + }, [workingDirectory]); + + const canAddTopic = isDesktop && !!workingDirectory; + + return ( + { + e.stopPropagation(); + void handleAddTopic(); + }} + /> + ) : undefined + } + title={ + +
+ +
+ + {title} + +
+ } + > + + {children.map((topic) => ( + + ))} + +
+ ); +}); + +export default GroupItem; diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/index.tsx new file mode 100644 index 0000000000..d6e38119ae --- /dev/null +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByProjectMode/index.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { memo } from 'react'; + +import GroupedAccordion from '../GroupedAccordion'; +import GroupItem from './GroupItem'; + +const ByProjectMode = memo(() => ); + +ByProjectMode.displayName = 'ByProjectMode'; + +export default ByProjectMode; diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx index 4d49e6c60d..5a6748084b 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/GroupItem.tsx @@ -3,20 +3,13 @@ import dayjs from 'dayjs'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { type GroupedTopic } from '@/types/topic'; - import TopicItem from '../../List/Item'; +import { type GroupItemComponentProps } from '../GroupedAccordion'; const preformat = (id: string) => id.startsWith('20') ? (id.includes('-') ? dayjs(id).format('MMMM') : id) : undefined; -interface GroupItemProps { - activeThreadId?: string; - activeTopicId?: string; - group: GroupedTopic; -} - -const GroupItem = memo(({ group, activeTopicId, activeThreadId }) => { +const GroupItem = memo(({ group, activeTopicId, activeThreadId }) => { const { t } = useTranslation('topic'); const { id, title, children } = group; diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/index.tsx index 913d37cfa3..3cc32126e8 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/ByTimeMode/index.tsx @@ -1,79 +1,11 @@ 'use client'; -import { Accordion, Flexbox } from '@lobehub/ui'; -import isEqual from 'fast-deep-equal'; -import { MoreHorizontal } from 'lucide-react'; -import { memo, useEffect, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import NavItem from '@/features/NavPanel/components/NavItem'; -import SkeletonList from '@/features/NavPanel/components/SkeletonList'; -import { useChatStore } from '@/store/chat'; -import { topicSelectors } from '@/store/chat/selectors'; -import { useGlobalStore } from '@/store/global'; -import { systemStatusSelectors } from '@/store/global/selectors'; -import { useUserStore } from '@/store/user'; -import { preferenceSelectors } from '@/store/user/selectors'; +import { memo } from 'react'; +import GroupedAccordion from '../GroupedAccordion'; import GroupItem from './GroupItem'; -const ByTimeMode = memo(() => { - const { t } = useTranslation('topic'); - const topicPageSize = useGlobalStore(systemStatusSelectors.topicPageSize); - const topicSortBy = useUserStore(preferenceSelectors.topicSortBy); - const topicGroupMode = useUserStore(preferenceSelectors.topicGroupMode); - - const [hasMore, isExpandingPageSize, openAllTopicsDrawer] = useChatStore((s) => [ - topicSelectors.hasMoreTopics(s), - topicSelectors.isExpandingPageSize(s), - s.openAllTopicsDrawer, - ]); - const [activeTopicId, activeThreadId] = useChatStore((s) => [s.activeTopicId, s.activeThreadId]); - - const groupSelector = useMemo( - () => topicSelectors.groupedTopicsForSidebar(topicPageSize, topicSortBy, topicGroupMode), - [topicPageSize, topicSortBy, topicGroupMode], - ); - const groupTopics = useChatStore(groupSelector, isEqual); - - const [topicGroupKeys, updateSystemStatus] = useGlobalStore((s) => [ - systemStatusSelectors.topicGroupKeys(s), - s.updateSystemStatus, - ]); - - // Reset expanded keys when grouping changes so all groups start expanded - useEffect(() => { - updateSystemStatus({ expandTopicGroupKeys: undefined }); - }, [topicSortBy, topicGroupMode, updateSystemStatus]); - - const expandedKeys = useMemo(() => { - return topicGroupKeys || groupTopics.map((group) => group.id); - }, [topicGroupKeys, groupTopics]); - - return ( - - {/* Grouped topics */} - updateSystemStatus({ expandTopicGroupKeys: keys as any })} - > - {groupTopics.map((group) => ( - - ))} - - {isExpandingPageSize && } - {hasMore && !isExpandingPageSize && ( - - )} - - ); -}); +const ByTimeMode = memo(() => ); ByTimeMode.displayName = 'ByTimeMode'; diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/GroupedAccordion.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/GroupedAccordion.tsx new file mode 100644 index 0000000000..0166b797e6 --- /dev/null +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/GroupedAccordion.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { Accordion, Flexbox } from '@lobehub/ui'; +import isEqual from 'fast-deep-equal'; +import { MoreHorizontal } from 'lucide-react'; +import { type ComponentType, memo, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import NavItem from '@/features/NavPanel/components/NavItem'; +import SkeletonList from '@/features/NavPanel/components/SkeletonList'; +import { useChatStore } from '@/store/chat'; +import { topicSelectors } from '@/store/chat/selectors'; +import { useGlobalStore } from '@/store/global'; +import { systemStatusSelectors } from '@/store/global/selectors'; +import { useUserStore } from '@/store/user'; +import { preferenceSelectors } from '@/store/user/selectors'; +import { type GroupedTopic } from '@/types/topic'; + +export interface GroupItemComponentProps { + activeThreadId?: string; + activeTopicId?: string; + group: GroupedTopic; +} + +interface GroupedAccordionProps { + GroupItem: ComponentType; +} + +const GroupedAccordion = memo(({ GroupItem }) => { + const { t } = useTranslation('topic'); + const topicPageSize = useGlobalStore(systemStatusSelectors.topicPageSize); + const topicSortBy = useUserStore(preferenceSelectors.topicSortBy); + const topicGroupMode = useUserStore(preferenceSelectors.topicGroupMode); + + const [hasMore, isExpandingPageSize, openAllTopicsDrawer] = useChatStore((s) => [ + topicSelectors.hasMoreTopics(s), + topicSelectors.isExpandingPageSize(s), + s.openAllTopicsDrawer, + ]); + const [activeTopicId, activeThreadId] = useChatStore((s) => [s.activeTopicId, s.activeThreadId]); + + const groupSelector = useMemo( + () => topicSelectors.groupedTopicsForSidebar(topicPageSize, topicSortBy, topicGroupMode), + [topicPageSize, topicSortBy, topicGroupMode], + ); + const groupTopics = useChatStore(groupSelector, isEqual); + + const [topicGroupKeys, updateSystemStatus] = useGlobalStore((s) => [ + systemStatusSelectors.topicGroupKeys(s), + s.updateSystemStatus, + ]); + + useEffect(() => { + updateSystemStatus({ expandTopicGroupKeys: undefined }); + }, [topicSortBy, topicGroupMode, updateSystemStatus]); + + const expandedKeys = useMemo( + () => topicGroupKeys || groupTopics.map((group) => group.id), + [topicGroupKeys, groupTopics], + ); + + return ( + + updateSystemStatus({ expandTopicGroupKeys: keys as any })} + > + {groupTopics.map((group) => ( + + ))} + + {isExpandingPageSize && } + {hasMore && !isExpandingPageSize && ( + + )} + + ); +}); + +GroupedAccordion.displayName = 'GroupedAccordion'; + +export default GroupedAccordion; diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/index.tsx index 6a61e23d02..c0d95dac73 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/TopicListContent/index.tsx @@ -13,6 +13,7 @@ import { topicSelectors } from '@/store/chat/selectors'; import { useUserStore } from '@/store/user'; import { preferenceSelectors } from '@/store/user/selectors'; +import ByProjectMode from './ByProjectMode'; import ByTimeMode from './ByTimeMode'; import FlatMode from './FlatMode'; import SearchResult from './SearchResult'; @@ -46,7 +47,13 @@ const TopicListContent = memo(() => { }} /> )} - {topicGroupMode === 'flat' ? : } + {topicGroupMode === 'flat' ? ( + + ) : topicGroupMode === 'byProject' ? ( + + ) : ( + + )} ); });