🐛 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>
This commit is contained in:
Arvin Xu 2026-04-19 23:56:39 +08:00 committed by GitHub
parent 46df77ac3f
commit 8240e8685d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 255 additions and 86 deletions

View file

@ -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/<name>/` 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<string | undefined> {
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).

View file

@ -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.",

View file

@ -1,5 +1,6 @@
{
"actions.addNewTopic": "开启新话题",
"actions.addNewTopicInProject": "在 {{directory}} 中开启新话题",
"actions.autoRename": "智能重命名",
"actions.confirmRemoveAll": "您即将删除所有话题,此操作无法撤销。",
"actions.confirmRemoveTopic": "您即将删除此话题,此操作无法撤销。",

View file

@ -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();

View file

@ -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<WorkingDirectoryContentProps>(({ agentId, o
key={entry.path}
onClick={() => selectDir(entry)}
>
{renderDirIcon(entry.repoType)}
<RecentDirIcon entry={entry} />
<Flexbox flex={1} style={{ minWidth: 0 }}>
<div className={styles.dirName}>{getDirName(entry.path)}</div>
<div className={styles.dirPath}>{entry.path}</div>

View file

@ -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.',

View file

@ -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' ? <FlatMode /> : <ByTimeMode />}
{topicGroupMode === 'flat' ? (
<FlatMode />
) : topicGroupMode === 'byProject' ? (
<ByProjectMode />
) : (
<ByTimeMode />
)}
<AllTopicsDrawer open={allTopicsDrawerOpen} onClose={closeAllTopicsDrawer} />
</>
);

View file

@ -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<GroupItemComponentProps>(({ 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 (
<AccordionItem
itemKey={id}
paddingBlock={4}
paddingInline={4}
action={
canAddTopic ? (
<ActionIcon
icon={PlusIcon}
size={'small'}
title={t('actions.addNewTopicInProject', { directory: title })}
tooltipProps={{ placement: 'right' }}
onClick={(e) => {
e.stopPropagation();
void handleAddTopic();
}}
/>
) : undefined
}
title={
<Flexbox horizontal align="center" gap={8} height={24} style={{ overflow: 'hidden' }}>
<Center flex={'none'} height={24} width={28}>
<Icon
color={cssVar.colorTextSecondary}
icon={FolderClosedIcon}
size={{ size: 15, strokeWidth: 1.5 }}
/>
</Center>
<Text ellipsis fontSize={14} style={{ flex: 1 }} type={'secondary'}>
{title}
</Text>
</Flexbox>
}
>
<Flexbox gap={1} paddingBlock={1}>
{children.map((topic) => (
<TopicItem
active={activeTopicId === topic.id}
fav={topic.favorite}
id={topic.id}
key={topic.id}
metadata={topic.metadata}
threadId={activeThreadId}
title={topic.title}
/>
))}
</Flexbox>
</AccordionItem>
);
});
export default GroupItem;

View file

@ -0,0 +1,12 @@
'use client';
import { memo } from 'react';
import GroupedAccordion from '../GroupedAccordion';
import GroupItem from './GroupItem';
const ByProjectMode = memo(() => <GroupedAccordion GroupItem={GroupItem} />);
ByProjectMode.displayName = 'ByProjectMode';
export default ByProjectMode;

View file

@ -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<GroupItemProps>(({ group, activeTopicId, activeThreadId }) => {
const GroupItem = memo<GroupItemComponentProps>(({ group, activeTopicId, activeThreadId }) => {
const { t } = useTranslation('topic');
const { id, title, children } = group;

View file

@ -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 (
<Flexbox gap={2}>
{/* Grouped topics */}
<Accordion
expandedKeys={expandedKeys}
gap={2}
onExpandedChange={(keys) => updateSystemStatus({ expandTopicGroupKeys: keys as any })}
>
{groupTopics.map((group) => (
<GroupItem
activeThreadId={activeThreadId}
activeTopicId={activeTopicId}
group={group}
key={group.id}
/>
))}
</Accordion>
{isExpandingPageSize && <SkeletonList rows={3} />}
{hasMore && !isExpandingPageSize && (
<NavItem icon={MoreHorizontal} title={t('loadMore')} onClick={openAllTopicsDrawer} />
)}
</Flexbox>
);
});
const ByTimeMode = memo(() => <GroupedAccordion GroupItem={GroupItem} />);
ByTimeMode.displayName = 'ByTimeMode';

View file

@ -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<GroupItemComponentProps>;
}
const GroupedAccordion = memo<GroupedAccordionProps>(({ 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 (
<Flexbox gap={2}>
<Accordion
expandedKeys={expandedKeys}
gap={2}
onExpandedChange={(keys) => updateSystemStatus({ expandTopicGroupKeys: keys as any })}
>
{groupTopics.map((group) => (
<GroupItem
activeThreadId={activeThreadId}
activeTopicId={activeTopicId}
group={group}
key={group.id}
/>
))}
</Accordion>
{isExpandingPageSize && <SkeletonList rows={3} />}
{hasMore && !isExpandingPageSize && (
<NavItem icon={MoreHorizontal} title={t('loadMore')} onClick={openAllTopicsDrawer} />
)}
</Flexbox>
);
});
GroupedAccordion.displayName = 'GroupedAccordion';
export default GroupedAccordion;

View file

@ -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' ? <FlatMode /> : <ByTimeMode />}
{topicGroupMode === 'flat' ? (
<FlatMode />
) : topicGroupMode === 'byProject' ? (
<ByProjectMode />
) : (
<ByTimeMode />
)}
</>
);
});