feat(cc-agent): improve for CC integration mode (#13950)

*  feat(cc-agent-profile): swap model/skills pickers for CC CLI status in CC mode

When an agent runs under the Claude Code heterogeneous runtime, its model and tools are
owned by the external CLI, so the profile page's model selector and integration-skills
block are misleading. Replace them with a card that re-detects `claude --version` on
mount and shows the resolved binary path — useful when CLAUDE_CODE_BIN or similar
points at a non-default CLI.

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

* 💄 style(cc-agent-profile): hide cron for CC agent and polish render previews

- Hide cron sidebar entry when current agent is heterogeneous (CC)
- Allow model avatar in agent header emoji picker
- Add padding to Glob/Grep/Read/Write preview boxes for consistent spacing
- Simplify NavPanelDraggable by removing slide animation layer

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

* ♻️ refactor(shared-tool-ui): extract ToolResultCard for Read/Write/Glob/Grep renders

Hoist the shared card shell (icon + header + preview box) into
@lobechat/shared-tool-ui/components so the four Claude Code Render
files no longer duplicate container/header/previewBox styles.

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

* 💄 style(agent-header): restyle title and expand actions menu

Bold the topic title, render the working directory as plain text (no chip/icon), move the "..." menu to the left, and expand it with pin/rename/copy working directory/copy session ID/delete. Fall back to "New Topic" when no topic is active.

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

* 💄 style(topic-list): replace spinning loader with ring-and-arc loading icon

Adds a reusable RingLoadingIcon (static track + rotating arc, mirroring the send-button style) and swaps the topic-item loader over to it so the loading state reads as a polished ring rather than a thin spinning dash.

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

* 💄 style(topic-list): switch unread indicator to a radar ping effect

Replaces the glowing neon-dot pulse with a smaller 6px core dot plus a CSS-keyframe ripple ring that scales out and fades, giving the unread marker a subtler, more refined cadence.

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

* 💄 style(cc-chat-input): drop file upload in CC mode, surface typo toggle

Claude Code brings its own file handling and knowledge context, so the
paperclip dropdown only showed "Upload Image" + a useless "View More"
link — confusing and not clean. Replace fileUpload with typo in the
heterogeneous chat input, and fold ServerMode back into a single
Upload/index.tsx now that the ClientMode/ServerMode split is gone.

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-18 16:53:58 +08:00 committed by GitHub
parent 13fe968480
commit 5dc94cbc45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 867 additions and 684 deletions

View file

@ -191,6 +191,10 @@
"analytics.telemetry.desc": "Help us improve {{appName}} with anonymous usage data",
"analytics.telemetry.title": "Send Anonymous Usage Data",
"analytics.title": "Analytics",
"ccStatus.detecting": "Detecting Claude Code CLI...",
"ccStatus.redetect": "Re-detect",
"ccStatus.title": "Claude Code CLI",
"ccStatus.unavailable": "Claude Code CLI not found. Please install or configure it.",
"checking": "Checking...",
"checkingPermissions": "Checking permissions...",
"creds.actions.delete": "Delete",

View file

@ -6,6 +6,10 @@
"actions.confirmRemoveUnstarred": "You are about to delete unstarred topics. This action cannot be undone.",
"actions.copyLink": "Copy Link",
"actions.copyLinkSuccess": "Link copied",
"actions.copySessionId": "Copy Session ID",
"actions.copySessionIdSuccess": "Session ID copied",
"actions.copyWorkingDirectory": "Copy Working Directory",
"actions.copyWorkingDirectorySuccess": "Working directory copied",
"actions.duplicate": "Duplicate",
"actions.export": "Export Topics",
"actions.favorite": "Favorite",
@ -36,6 +40,7 @@
"importLoading": "Importing conversation...",
"importSuccess": "Successfully imported {{count}} messages",
"loadMore": "Load More",
"newTopic": "New Topic",
"searchPlaceholder": "Search Topics...",
"searchResultEmpty": "No search results found.",
"temp": "Temporary",

View file

@ -191,6 +191,10 @@
"analytics.telemetry.desc": "通过匿名使用数据帮助我们改进 {{appName}}",
"analytics.telemetry.title": "发送匿名使用数据",
"analytics.title": "数据统计",
"ccStatus.detecting": "正在检测 Claude Code CLI…",
"ccStatus.redetect": "重新检测",
"ccStatus.title": "Claude Code CLI",
"ccStatus.unavailable": "未检测到 Claude Code CLI请先安装或配置",
"checking": "检查中…",
"checkingPermissions": "检查权限中…",
"creds.actions.delete": "删除",

View file

@ -6,6 +6,10 @@
"actions.confirmRemoveUnstarred": "您即将删除未加星标的话题,此操作无法撤销。",
"actions.copyLink": "复制链接",
"actions.copyLinkSuccess": "链接已复制",
"actions.copySessionId": "复制会话 ID",
"actions.copySessionIdSuccess": "会话 ID 已复制",
"actions.copyWorkingDirectory": "复制工作目录",
"actions.copyWorkingDirectorySuccess": "工作目录已复制",
"actions.duplicate": "复制",
"actions.export": "导出话题",
"actions.favorite": "收藏",
@ -36,6 +40,7 @@
"importLoading": "正在导入对话…",
"importSuccess": "已导入 {{count}} 条消息",
"loadMore": "更多",
"newTopic": "新话题",
"searchPlaceholder": "搜索话题…",
"searchResultEmpty": "暂无搜索结果",
"temp": "临时",

View file

@ -1,33 +1,20 @@
'use client';
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Flexbox, Highlighter, Icon, Text } from '@lobehub/ui';
import { Highlighter, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { FolderSearch } from 'lucide-react';
import { memo, useMemo } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
padding: 8px;
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorFillQuaternary};
`,
count: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
`,
header: css`
padding-inline: 4px;
color: ${cssVar.colorTextSecondary};
`,
pattern: css`
font-family: ${cssVar.fontFamilyCode};
`,
previewBox: css`
overflow: hidden;
border-radius: 8px;
background: ${cssVar.colorBgContainer};
`,
scope: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
@ -50,36 +37,37 @@ const Glob = memo<BuiltinRenderProps<GlobArgs>>(({ args, content }) => {
}, [content]);
return (
<Flexbox className={styles.container} gap={8}>
<Flexbox horizontal align={'center'} className={styles.header} gap={8} wrap={'wrap'}>
<Icon icon={FolderSearch} size={'small'} />
{pattern && (
<Text strong className={styles.pattern}>
{pattern}
</Text>
)}
{scope && (
<Text ellipsis className={styles.scope}>
{scope}
</Text>
)}
{matchCount > 0 && <Text className={styles.count}>{`${matchCount} matches`}</Text>}
</Flexbox>
<ToolResultCard
wrapHeader
icon={FolderSearch}
header={
<>
{pattern && (
<Text strong className={styles.pattern}>
{pattern}
</Text>
)}
{scope && (
<Text ellipsis className={styles.scope}>
{scope}
</Text>
)}
{matchCount > 0 && <Text className={styles.count}>{`${matchCount} matches`}</Text>}
</>
}
>
{content && (
<Flexbox className={styles.previewBox}>
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{content}
</Highlighter>
</Flexbox>
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{content}
</Highlighter>
)}
</Flexbox>
</ToolResultCard>
);
});

View file

@ -1,29 +1,16 @@
'use client';
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Flexbox, Highlighter, Icon, Tag, Text } from '@lobehub/ui';
import { Highlighter, Tag, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { Search } from 'lucide-react';
import { memo } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
padding: 8px;
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorFillQuaternary};
`,
header: css`
padding-inline: 4px;
color: ${cssVar.colorTextSecondary};
`,
pattern: css`
font-family: ${cssVar.fontFamilyCode};
`,
previewBox: css`
overflow: hidden;
border-radius: 8px;
background: ${cssVar.colorBgContainer};
`,
scope: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
@ -45,36 +32,37 @@ const Grep = memo<BuiltinRenderProps<GrepArgs>>(({ args, content }) => {
const glob = args?.glob || args?.type;
return (
<Flexbox className={styles.container} gap={8}>
<Flexbox horizontal align={'center'} className={styles.header} gap={8} wrap={'wrap'}>
<Icon icon={Search} size={'small'} />
{pattern && (
<Text strong className={styles.pattern}>
{pattern}
</Text>
)}
{glob && <Tag>{glob}</Tag>}
{scope && (
<Text ellipsis className={styles.scope}>
{scope}
</Text>
)}
</Flexbox>
<ToolResultCard
wrapHeader
icon={Search}
header={
<>
{pattern && (
<Text strong className={styles.pattern}>
{pattern}
</Text>
)}
{glob && <Tag>{glob}</Tag>}
{scope && (
<Text ellipsis className={styles.scope}>
{scope}
</Text>
)}
</>
}
>
{content && (
<Flexbox className={styles.previewBox}>
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{content}
</Highlighter>
</Flexbox>
<Highlighter
wrap
language={'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{content}
</Highlighter>
)}
</Flexbox>
</ToolResultCard>
);
});

View file

@ -1,32 +1,19 @@
'use client';
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Flexbox, Highlighter, Icon, Text } from '@lobehub/ui';
import { Highlighter, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { FileText } from 'lucide-react';
import path from 'path-browserify-esm';
import { memo, useMemo } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
padding: 8px;
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorFillQuaternary};
`,
header: css`
padding-inline: 4px;
color: ${cssVar.colorTextSecondary};
`,
path: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
word-break: break-all;
`,
previewBox: css`
overflow: hidden;
border-radius: 8px;
background: ${cssVar.colorBgContainer};
`,
}));
interface ReadArgs {
@ -57,31 +44,31 @@ const Read = memo<BuiltinRenderProps<ReadArgs>>(({ args, content }) => {
const source = useMemo(() => stripLineNumbers(content || ''), [content]);
return (
<Flexbox className={styles.container} gap={8}>
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
<Icon icon={FileText} size={'small'} />
<Text strong>{fileName || 'Read'}</Text>
{filePath && filePath !== fileName && (
<Text ellipsis className={styles.path}>
{filePath}
</Text>
)}
</Flexbox>
<ToolResultCard
icon={FileText}
header={
<>
<Text strong>{fileName || 'Read'}</Text>
{filePath && filePath !== fileName && (
<Text ellipsis className={styles.path}>
{filePath}
</Text>
)}
</>
}
>
{source && (
<Flexbox className={styles.previewBox}>
<Highlighter
wrap
language={ext || 'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{source}
</Highlighter>
</Flexbox>
<Highlighter
wrap
language={ext || 'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
>
{source}
</Highlighter>
)}
</Flexbox>
</ToolResultCard>
);
});

View file

@ -1,32 +1,19 @@
'use client';
import { ToolResultCard } from '@lobechat/shared-tool-ui/components';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Flexbox, Highlighter, Icon, Markdown, Skeleton, Text } from '@lobehub/ui';
import { Highlighter, Markdown, Skeleton, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { FilePlus2 } from 'lucide-react';
import path from 'path-browserify-esm';
import { memo } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
padding: 8px;
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorFillQuaternary};
`,
header: css`
padding-inline: 4px;
color: ${cssVar.colorTextSecondary};
`,
path: css`
font-size: 12px;
color: ${cssVar.colorTextTertiary};
word-break: break-all;
`,
previewBox: css`
overflow: hidden;
border-radius: 8px;
background: ${cssVar.colorBgContainer};
`,
}));
interface WriteArgs {
@ -46,7 +33,7 @@ const Write = memo<BuiltinRenderProps<WriteArgs>>(({ args }) => {
if (ext === 'md' || ext === 'mdx') {
return (
<Markdown style={{ maxHeight: 240, overflow: 'auto', padding: '0 8px' }} variant={'chat'}>
<Markdown style={{ maxHeight: 240, overflow: 'auto' }} variant={'chat'}>
{args.content}
</Markdown>
);
@ -66,19 +53,21 @@ const Write = memo<BuiltinRenderProps<WriteArgs>>(({ args }) => {
};
return (
<Flexbox className={styles.container} gap={8}>
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
<Icon icon={FilePlus2} size={'small'} />
<Text strong>{fileName || 'Write'}</Text>
{filePath && filePath !== fileName && (
<Text ellipsis className={styles.path}>
{filePath}
</Text>
)}
</Flexbox>
{args.content && <Flexbox className={styles.previewBox}>{renderContent()}</Flexbox>}
</Flexbox>
<ToolResultCard
icon={FilePlus2}
header={
<>
<Text strong>{fileName || 'Write'}</Text>
{filePath && filePath !== fileName && (
<Text ellipsis className={styles.path}>
{filePath}
</Text>
)}
</>
}
>
{renderContent()}
</ToolResultCard>
);
});

View file

@ -4,6 +4,7 @@
"private": true,
"exports": {
".": "./src/index.ts",
"./components": "./src/components/index.ts",
"./renders": "./src/Render/index.ts",
"./inspectors": "./src/Inspector/index.ts",
"./styles": "./src/styles.ts"

View file

@ -0,0 +1,51 @@
'use client';
import { Flexbox, Icon } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { type LucideIcon } from 'lucide-react';
import { memo, type ReactNode } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
padding: 8px;
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorFillQuaternary};
`,
header: css`
padding-inline: 4px;
color: ${cssVar.colorTextSecondary};
`,
previewBox: css`
overflow: hidden;
padding: 8px;
border-radius: 8px;
background: ${cssVar.colorBgContainer};
`,
}));
interface ToolResultCardProps {
children?: ReactNode;
header: ReactNode;
icon: LucideIcon;
wrapHeader?: boolean;
}
export const ToolResultCard = memo<ToolResultCardProps>(
({ icon, header, children, wrapHeader }) => (
<Flexbox className={styles.container} gap={8}>
<Flexbox
horizontal
align={'center'}
className={styles.header}
gap={8}
wrap={wrapHeader ? 'wrap' : undefined}
>
<Icon icon={icon} size={'small'} />
{header}
</Flexbox>
{children && <Flexbox className={styles.previewBox}>{children}</Flexbox>}
</Flexbox>
),
);
ToolResultCard.displayName = 'ToolResultCard';

View file

@ -0,0 +1,2 @@
export { FilePathDisplay } from './FilePathDisplay';
export { ToolResultCard } from './ToolResultCard';

View file

@ -0,0 +1,52 @@
import { cssVar, cx } from 'antd-style';
import { type CSSProperties, type SVGProps } from 'react';
interface RingLoadingIconProps extends SVGProps<SVGSVGElement> {
ringColor?: string;
size?: number | string;
style?: CSSProperties;
}
const RingLoadingIcon = ({
ref,
size = 16,
className,
style,
ringColor = cssVar.colorBorder,
...rest
}: RingLoadingIconProps & { ref?: React.RefObject<SVGSVGElement | null> }) => {
return (
<svg
className={cx('anticon', className)}
color="currentColor"
height={size}
ref={ref}
style={{ flex: 'none', lineHeight: 1, ...style }}
viewBox="0 0 1024 1024"
width={size}
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<g fill="none">
<circle cx="512" cy="512" fill="none" r="400" stroke={ringColor} strokeWidth="128" />
<path
d="M912 512C912 290.92 733.08 112 512 112"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="128"
>
<animateTransform
attributeName="transform"
dur="1s"
from="0 512 512"
repeatCount="indefinite"
to="360 512 512"
type="rotate"
/>
</path>
</g>
</svg>
);
};
export default RingLoadingIcon;

View file

@ -1,284 +0,0 @@
import { validateVideoFileSize } from '@lobechat/utils/client';
import { type ItemType } from '@lobehub/ui';
import { Icon, Tooltip } from '@lobehub/ui';
import { Upload } from 'antd';
import { css, cx } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { ArrowRight, FileUp, FolderUp, ImageUp, LibraryBig, Paperclip } from 'lucide-react';
import { memo, Suspense, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { message } from '@/components/AntdStaticMethods';
import FileIcon from '@/components/FileIcon';
import RepoIcon from '@/components/LibIcon';
import TipGuide from '@/components/TipGuide';
import { AttachKnowledgeModal } from '@/features/LibraryModal';
import { useModelSupportVision } from '@/hooks/useModelSupportVision';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useFileStore } from '@/store/file';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import { useAgentId } from '../../hooks/useAgentId';
import Action from '../components/Action';
import { type ActionDropdownMenuItems } from '../components/ActionDropdown';
import CheckboxItem from '../components/CheckboxWithLoading';
const hotArea = css`
&::before {
content: '';
position: absolute;
inset: 0;
background-color: transparent;
}
`;
const FileUpload = memo(() => {
const { t } = useTranslation('chat');
const upload = useFileStore((s) => s.uploadChatFiles);
const agentId = useAgentId();
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(agentId)(s));
const provider = useAgentStore((s) => agentByIdSelectors.getAgentModelProviderById(agentId)(s));
const isHeterogeneous = useAgentStore((s) =>
agentByIdSelectors.isAgentHeterogeneousById(agentId)(s),
);
const canUploadImage = useModelSupportVision(model, provider);
const [showTip, updateGuideState] = useUserStore((s) => [
preferenceSelectors.showUploadFileInKnowledgeBaseTip(s),
s.updateGuideState,
]);
const [modalOpen, setModalOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [updating, setUpdating] = useState(false);
const files = useAgentStore((s) => agentByIdSelectors.getAgentFilesById(agentId)(s), isEqual);
const knowledgeBases = useAgentStore(
(s) => agentByIdSelectors.getAgentKnowledgeBasesById(agentId)(s),
isEqual,
);
const [toggleFile, toggleKnowledgeBase] = useAgentStore((s) => [
s.toggleFile,
s.toggleKnowledgeBase,
]);
const uploadItems: ActionDropdownMenuItems = [
{
closeOnClick: false,
disabled: !canUploadImage,
icon: ImageUp,
key: 'upload-image',
label: canUploadImage ? (
<Upload
multiple
accept={'image/*'}
showUploadList={false}
beforeUpload={async (file) => {
setDropdownOpen(false);
await upload([file]);
return false;
}}
>
<div className={cx(hotArea)}>{t('upload.action.imageUpload')}</div>
</Upload>
) : (
<Tooltip placement={'right'} title={t('upload.action.imageDisabled')}>
<div className={cx(hotArea)}>{t('upload.action.imageUpload')}</div>
</Tooltip>
),
},
// Heterogeneous agents (e.g. Claude Code) currently only support image upload.
...(isHeterogeneous
? []
: [
{
closeOnClick: false,
icon: FileUp,
key: 'upload-file',
label: (
<Upload
multiple
showUploadList={false}
beforeUpload={async (file) => {
if (
!canUploadImage &&
(file.type.startsWith('image') || file.type.startsWith('video'))
)
return false;
// Validate video file size
const validation = validateVideoFileSize(file);
if (!validation.isValid) {
message.error(
t('upload.validation.videoSizeExceeded', {
actualSize: validation.actualSize,
}),
);
return false;
}
setDropdownOpen(false);
await upload([file]);
return false;
}}
>
<div className={cx(hotArea)}>{t('upload.action.fileUpload')}</div>
</Upload>
),
},
{
closeOnClick: false,
icon: FolderUp,
key: 'upload-folder',
label: (
<Upload
directory
multiple={true}
showUploadList={false}
beforeUpload={async (file) => {
if (
!canUploadImage &&
(file.type.startsWith('image') || file.type.startsWith('video'))
)
return false;
// Validate video file size
const validation = validateVideoFileSize(file);
if (!validation.isValid) {
message.error(
t('upload.validation.videoSizeExceeded', {
actualSize: validation.actualSize,
}),
);
return false;
}
setDropdownOpen(false);
await upload([file]);
return false;
}}
>
<div className={cx(hotArea)}>{t('upload.action.folderUpload')}</div>
</Upload>
),
},
]),
];
const knowledgeItems: ItemType[] = [];
// Only add knowledge base items if there are files or knowledge bases
if (files.length > 0 || knowledgeBases.length > 0) {
knowledgeItems.push({
children: [
// first the files
...files.map((item) => ({
icon: <FileIcon fileName={item.name} fileType={item.type} size={20} />,
key: item.id,
label: (
<CheckboxItem
checked={item.enabled}
id={item.id}
label={item.name}
onUpdate={async (id, enabled) => {
setUpdating(true);
await toggleFile(id, enabled);
setUpdating(false);
}}
/>
),
})),
// then the knowledge bases
...knowledgeBases.map((item) => ({
icon: <RepoIcon />,
key: item.id,
label: (
<CheckboxItem
checked={item.enabled}
id={item.id}
label={item.name}
onUpdate={async (id, enabled) => {
setUpdating(true);
await toggleKnowledgeBase(id, enabled);
setUpdating(false);
}}
/>
),
})),
],
key: 'relativeFilesOrLibraries',
label: t('knowledgeBase.relativeFilesOrLibraries'),
type: 'group',
});
}
// Always add the "View More" option
knowledgeItems.push(
{
type: 'divider',
},
{
extra: <Icon icon={ArrowRight} />,
icon: LibraryBig,
key: 'knowledge-base-store',
label: t('knowledgeBase.viewMore'),
onClick: () => {
setModalOpen(true);
},
},
);
const items: ActionDropdownMenuItems = [
...uploadItems,
...(knowledgeItems.length > 0 ? knowledgeItems : []),
];
const content = (
<Action
icon={Paperclip}
loading={updating}
open={dropdownOpen}
showTooltip={false}
title={t('upload.action.tooltip')}
trigger={'both'}
dropdown={{
maxHeight: 500,
maxWidth: 480,
menu: { items },
minWidth: 240,
}}
onOpenChange={setDropdownOpen}
/>
);
return (
<Suspense fallback={<Action disabled icon={Paperclip} title={t('upload.action.tooltip')} />}>
{showTip ? (
<TipGuide
open={showTip}
placement={'top'}
title={t('knowledgeBase.uploadGuide')}
onOpenChange={() => {
updateGuideState({ uploadFileInKnowledgeBase: false });
}}
>
{content}
</TipGuide>
) : (
content
)}
<AttachKnowledgeModal open={modalOpen} setOpen={setModalOpen} />
</Suspense>
);
});
export default FileUpload;

View file

@ -1,10 +1,277 @@
import { validateVideoFileSize } from '@lobechat/utils/client';
import { type ItemType } from '@lobehub/ui';
import { Icon, Tooltip } from '@lobehub/ui';
import { Upload } from 'antd';
import { css, cx } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { ArrowRight, FileUp, FolderUp, ImageUp, LibraryBig, Paperclip } from 'lucide-react';
import { memo, Suspense, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { message } from '@/components/AntdStaticMethods';
import FileIcon from '@/components/FileIcon';
import RepoIcon from '@/components/LibIcon';
import TipGuide from '@/components/TipGuide';
import { AttachKnowledgeModal } from '@/features/LibraryModal';
import { useModelSupportVision } from '@/hooks/useModelSupportVision';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors } from '@/store/agent/selectors';
import { useFileStore } from '@/store/file';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import ServerMode from './ServerMode';
import { useAgentId } from '../../hooks/useAgentId';
import Action from '../components/Action';
import { type ActionDropdownMenuItems } from '../components/ActionDropdown';
import CheckboxItem from '../components/CheckboxWithLoading';
const Upload = () => {
const { enableKnowledgeBase } = useServerConfigStore(featureFlagsSelectors);
return enableKnowledgeBase && <ServerMode />;
};
const hotArea = css`
&::before {
content: '';
position: absolute;
inset: 0;
background-color: transparent;
}
`;
export default Upload;
const FileUpload = memo(() => {
const { t } = useTranslation('chat');
const enableKnowledgeBase = useServerConfigStore(
(s) => featureFlagsSelectors(s).enableKnowledgeBase,
);
const upload = useFileStore((s) => s.uploadChatFiles);
const agentId = useAgentId();
const model = useAgentStore((s) => agentByIdSelectors.getAgentModelById(agentId)(s));
const provider = useAgentStore((s) => agentByIdSelectors.getAgentModelProviderById(agentId)(s));
const canUploadImage = useModelSupportVision(model, provider);
const [showTip, updateGuideState] = useUserStore((s) => [
preferenceSelectors.showUploadFileInKnowledgeBaseTip(s),
s.updateGuideState,
]);
const [modalOpen, setModalOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [updating, setUpdating] = useState(false);
const files = useAgentStore((s) => agentByIdSelectors.getAgentFilesById(agentId)(s), isEqual);
const knowledgeBases = useAgentStore(
(s) => agentByIdSelectors.getAgentKnowledgeBasesById(agentId)(s),
isEqual,
);
const [toggleFile, toggleKnowledgeBase] = useAgentStore((s) => [
s.toggleFile,
s.toggleKnowledgeBase,
]);
if (!enableKnowledgeBase) return null;
const uploadItems: ActionDropdownMenuItems = [
{
closeOnClick: false,
disabled: !canUploadImage,
icon: ImageUp,
key: 'upload-image',
label: canUploadImage ? (
<Upload
multiple
accept={'image/*'}
showUploadList={false}
beforeUpload={async (file) => {
setDropdownOpen(false);
await upload([file]);
return false;
}}
>
<div className={cx(hotArea)}>{t('upload.action.imageUpload')}</div>
</Upload>
) : (
<Tooltip placement={'right'} title={t('upload.action.imageDisabled')}>
<div className={cx(hotArea)}>{t('upload.action.imageUpload')}</div>
</Tooltip>
),
},
{
closeOnClick: false,
icon: FileUp,
key: 'upload-file',
label: (
<Upload
multiple
showUploadList={false}
beforeUpload={async (file) => {
if (!canUploadImage && (file.type.startsWith('image') || file.type.startsWith('video')))
return false;
// Validate video file size
const validation = validateVideoFileSize(file);
if (!validation.isValid) {
message.error(
t('upload.validation.videoSizeExceeded', {
actualSize: validation.actualSize,
}),
);
return false;
}
setDropdownOpen(false);
await upload([file]);
return false;
}}
>
<div className={cx(hotArea)}>{t('upload.action.fileUpload')}</div>
</Upload>
),
},
{
closeOnClick: false,
icon: FolderUp,
key: 'upload-folder',
label: (
<Upload
directory
multiple={true}
showUploadList={false}
beforeUpload={async (file) => {
if (!canUploadImage && (file.type.startsWith('image') || file.type.startsWith('video')))
return false;
// Validate video file size
const validation = validateVideoFileSize(file);
if (!validation.isValid) {
message.error(
t('upload.validation.videoSizeExceeded', {
actualSize: validation.actualSize,
}),
);
return false;
}
setDropdownOpen(false);
await upload([file]);
return false;
}}
>
<div className={cx(hotArea)}>{t('upload.action.folderUpload')}</div>
</Upload>
),
},
];
const knowledgeItems: ItemType[] = [];
// Only add knowledge base items if there are files or knowledge bases
if (files.length > 0 || knowledgeBases.length > 0) {
knowledgeItems.push({
children: [
// first the files
...files.map((item) => ({
icon: <FileIcon fileName={item.name} fileType={item.type} size={20} />,
key: item.id,
label: (
<CheckboxItem
checked={item.enabled}
id={item.id}
label={item.name}
onUpdate={async (id, enabled) => {
setUpdating(true);
await toggleFile(id, enabled);
setUpdating(false);
}}
/>
),
})),
// then the knowledge bases
...knowledgeBases.map((item) => ({
icon: <RepoIcon />,
key: item.id,
label: (
<CheckboxItem
checked={item.enabled}
id={item.id}
label={item.name}
onUpdate={async (id, enabled) => {
setUpdating(true);
await toggleKnowledgeBase(id, enabled);
setUpdating(false);
}}
/>
),
})),
],
key: 'relativeFilesOrLibraries',
label: t('knowledgeBase.relativeFilesOrLibraries'),
type: 'group',
});
}
// Always add the "View More" option
knowledgeItems.push(
{
type: 'divider',
},
{
extra: <Icon icon={ArrowRight} />,
icon: LibraryBig,
key: 'knowledge-base-store',
label: t('knowledgeBase.viewMore'),
onClick: () => {
setModalOpen(true);
},
},
);
const items: ActionDropdownMenuItems = [
...uploadItems,
...(knowledgeItems.length > 0 ? knowledgeItems : []),
];
const content = (
<Action
icon={Paperclip}
loading={updating}
open={dropdownOpen}
showTooltip={false}
title={t('upload.action.tooltip')}
trigger={'both'}
dropdown={{
maxHeight: 500,
maxWidth: 480,
menu: { items },
minWidth: 240,
}}
onOpenChange={setDropdownOpen}
/>
);
return (
<Suspense fallback={<Action disabled icon={Paperclip} title={t('upload.action.tooltip')} />}>
{showTip ? (
<TipGuide
open={showTip}
placement={'top'}
title={t('knowledgeBase.uploadGuide')}
onOpenChange={() => {
updateGuideState({ uploadFileInKnowledgeBase: false });
}}
>
{content}
</TipGuide>
) : (
content
)}
<AttachKnowledgeModal open={modalOpen} setOpen={setModalOpen} />
</Suspense>
);
});
export default FileUpload;

View file

@ -1,10 +1,9 @@
'use client';
import { DraggablePanel, Freeze } from '@lobehub/ui';
import { DraggablePanel } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { AnimatePresence, m, useIsPresent } from 'motion/react';
import { type ReactNode } from 'react';
import { memo, Suspense, useLayoutEffect, useMemo, useRef } from 'react';
import { memo, Suspense, useMemo, useRef } from 'react';
import { isDesktop } from '@/const/version';
import { TOGGLE_BUTTON_ID } from '@/features/NavPanel/ToggleLeftPanelButton';
@ -12,39 +11,11 @@ import Footer from '@/routes/(main)/home/_layout/Footer';
import { USER_DROPDOWN_ICON_ID } from '@/routes/(main)/home/_layout/Header/components/User';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
import type { PanelSlideMotionDirection } from '@/utils/motion/panelSlideMotion';
import {
isPanelLayerMotionDisabled,
panelSlideMotionVariantsLeft,
} from '@/utils/motion/panelSlideMotion';
import { isMacOS } from '@/utils/platform';
import { useNavPanelSizeChangeHandler } from '../hooks/useNavPanel';
import { BACK_BUTTON_ID } from './BackButton';
const getMotionDirectionByHistory = (
history: string[],
nextKey: string,
): PanelSlideMotionDirection => {
const currentKey = history.at(-1);
if (currentKey === nextKey) return 0;
return history.includes(nextKey) ? -1 : 1;
};
interface ExitingFrozenContentProps {
children: ReactNode;
}
const ExitingFrozenContent = memo<ExitingFrozenContentProps>(({ children }) => {
const isPresent = useIsPresent();
return <Freeze frozen={!isPresent}>{children}</Freeze>;
});
ExitingFrozenContent.displayName = 'ExitingFrozenContent';
const draggableStyles = createStaticStyles(({ css, cssVar }) => ({
content: css`
position: relative;
@ -139,8 +110,6 @@ export const NavPanelDraggable = memo<NavPanelDraggableProps>(({ activeContent }
systemStatusSelectors.showLeftPanel(s),
s.toggleLeftPanel,
]);
const animationMode = useUserStore(userGeneralSettingsSelectors.animationMode);
const shouldUseMotion = !isPanelLayerMotionDisabled(animationMode);
const handleSizeChange = useNavPanelSizeChangeHandler();
const defaultWidthRef = useRef(0);
@ -163,35 +132,6 @@ export const NavPanelDraggable = memo<NavPanelDraggableProps>(({ activeContent }
[],
);
const historyRef = useRef([activeContent.key]);
const directionRef = useRef<PanelSlideMotionDirection>(0);
const history = historyRef.current;
const direction = shouldUseMotion ? getMotionDirectionByHistory(history, activeContent.key) : 0;
if (direction !== 0) {
directionRef.current = direction;
}
useLayoutEffect(() => {
if (!shouldUseMotion) return;
const snapshot = historyRef.current;
const currentKey = snapshot.at(-1);
const nextKey = activeContent.key;
if (currentKey === nextKey) return;
const existingIndex = snapshot.lastIndexOf(nextKey);
if (existingIndex !== -1) {
snapshot.splice(existingIndex + 1);
return;
}
snapshot.push(nextKey);
}, [activeContent.key, shouldUseMotion]);
const motionDirection = shouldUseMotion ? directionRef.current : 0;
return (
<DraggablePanel
className={draggableStyles.panel}
@ -208,26 +148,9 @@ export const NavPanelDraggable = memo<NavPanelDraggableProps>(({ activeContent }
onSizeDragging={handleSizeChange}
>
<div className={draggableStyles.inner}>
{shouldUseMotion ? (
<AnimatePresence custom={motionDirection} initial={false} mode="sync">
<m.div
animate="animate"
className={draggableStyles.layer}
custom={motionDirection}
exit="exit"
initial="initial"
key={activeContent.key}
transition={panelSlideMotionVariantsLeft.transition}
variants={panelSlideMotionVariantsLeft}
>
<ExitingFrozenContent>{activeContent.node}</ExitingFrozenContent>
</m.div>
</AnimatePresence>
) : (
<div className={draggableStyles.layer} key={activeContent.key}>
{activeContent.node}
</div>
)}
<div className={draggableStyles.layer} key={activeContent.key}>
{activeContent.node}
</div>
</div>
<Suspense>
<Footer />

View file

@ -206,6 +206,12 @@ export default {
'analytics.telemetry.desc': 'Help us improve {{appName}} with anonymous usage data',
'analytics.telemetry.title': 'Send Anonymous Usage Data',
'analytics.title': 'Analytics',
// Claude Code CLI status (shown on agent profile page in CC integration mode)
'ccStatus.detecting': 'Detecting Claude Code CLI...',
'ccStatus.redetect': 'Re-detect',
'ccStatus.title': 'Claude Code CLI',
'ccStatus.unavailable': 'Claude Code CLI not found. Please install or configure it.',
'checking': 'Checking...',
// Credentials Management

View file

@ -7,6 +7,10 @@ export default {
'You are about to delete unstarred topics. This action cannot be undone.',
'actions.copyLink': 'Copy Link',
'actions.copyLinkSuccess': 'Link copied',
'actions.copySessionId': 'Copy Session ID',
'actions.copySessionIdSuccess': 'Session ID copied',
'actions.copyWorkingDirectory': 'Copy Working Directory',
'actions.copyWorkingDirectorySuccess': 'Working directory copied',
'actions.duplicate': 'Duplicate',
'actions.favorite': 'Favorite',
'actions.unfavorite': 'Unfavorite',
@ -38,6 +42,7 @@ export default {
'importLoading': 'Importing conversation...',
'importSuccess': 'Successfully imported {{count}} messages',
'loadMore': 'Load More',
'newTopic': 'New Topic',
'searchPlaceholder': 'Search Topics...',
'searchResultEmpty': 'No search results found.',
'temp': 'Temporary',

View file

@ -11,6 +11,7 @@ import EmptyNavItem from '@/features/NavPanel/components/EmptyNavItem';
import SkeletonList from '@/features/NavPanel/components/SkeletonList';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import CronTopicGroup from './CronTopicGroup';
@ -26,10 +27,11 @@ const CronTopicList = memo<CronTopicListProps>(({ itemKey }) => {
s.activeAgentId,
s.useFetchCronTopicsWithJobInfo,
]);
const isHeterogeneous = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
const { data: cronTopicsGroupsWithJobInfo = [], isLoading } = useFetchCronTopicsWithJobInfo(
agentId,
enableBusinessFeatures,
enableBusinessFeatures && !isHeterogeneous,
);
const handleCreateCronJob = useCallback(() => {
@ -37,7 +39,7 @@ const CronTopicList = memo<CronTopicListProps>(({ itemKey }) => {
router.push(urlJoin('/agent', agentId, 'cron', 'new'));
}, [agentId, router]);
if (!enableBusinessFeatures) return null;
if (!enableBusinessFeatures || isHeterogeneous) return null;
const addAction = (
<ActionIcon

View file

@ -1,10 +1,10 @@
import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { HashIcon, Loader2Icon, MessageSquareDashed } from 'lucide-react';
import { AnimatePresence, m } from 'motion/react';
import { createStaticStyles, cssVar, keyframes } from 'antd-style';
import { HashIcon, MessageSquareDashed } from 'lucide-react';
import { memo, Suspense, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import RingLoadingIcon from '@/components/RingLoading';
import { isDesktop } from '@/const/version';
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
import NavItem from '@/features/NavPanel/components/NavItem';
@ -21,39 +21,51 @@ import Actions from './Actions';
import Editing from './Editing';
import { useTopicItemDropdownMenu } from './useDropdownMenu';
const styles = createStaticStyles(({ css }) => ({
neonDotWrapper: css`
position: absolute;
inset: 0;
const rippleAnim = keyframes`
0% {
transform: scale(1);
opacity: 0.7;
}
100% {
transform: scale(3);
opacity: 0;
}
`;
display: flex;
flex-shrink: 0;
const styles = createStaticStyles(({ css }) => ({
unreadWrapper: css`
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
width: 14px;
height: 14px;
`,
dotContainer: css`
will-change: width;
unreadDot: css`
position: relative;
z-index: 1;
width: 18px;
height: 18px;
margin-inline-start: -6px;
transition: width 0.2s ${cssVar.motionEaseOut};
`,
neonDot: css`
width: 6px;
height: 6px;
border-radius: 50%;
background: ${cssVar.colorInfo};
box-shadow:
0 0 3px ${cssVar.colorInfo},
0 0 6px ${cssVar.colorInfo};
`,
unreadRipple: css`
position: absolute;
inset: 0;
width: 6px;
height: 6px;
margin: auto;
border: 1px solid ${cssVar.colorInfo};
border-radius: 50%;
background: transparent;
animation: ${rippleAnim} 1.8s ease-out infinite;
`,
}));
@ -129,44 +141,10 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
});
const hasUnread = id && isUnreadCompleted;
const infoColor = cssVar.colorInfo;
const unreadNode = (
<span className={styles.dotContainer} style={{ width: hasUnread ? 18 : 0 }}>
<AnimatePresence mode="popLayout">
{hasUnread && (
<m.div
className={styles.neonDotWrapper}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: 1,
opacity: 1,
}}
exit={{
scale: 0,
opacity: 0,
}}
>
<m.span
className={styles.neonDot}
initial={false}
animate={{
scale: [1, 1.3, 1],
opacity: [1, 0.9, 1],
boxShadow: [
`0 0 3px ${infoColor}, 0 0 6px ${infoColor}`,
`0 0 5px ${infoColor}, 0 0 8px color-mix(in srgb, ${infoColor} 60%, transparent)`,
`0 0 3px ${infoColor}, 0 0 6px ${infoColor}`,
],
}}
transition={{
duration: 1.2,
repeat: Infinity,
ease: 'easeInOut',
}}
/>
</m.div>
)}
</AnimatePresence>
const unreadIcon = (
<span className={styles.unreadWrapper}>
<span className={styles.unreadRipple} />
<span className={styles.unreadDot} />
</span>
);
@ -177,7 +155,11 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
active={active && !isInAgentSubRoute}
icon={
isLoading ? (
<Icon spin color={cssVar.colorWarning} icon={Loader2Icon} size={'small'} />
<RingLoadingIcon
ringColor={cssVar.colorWarningBorder}
size={14}
style={{ color: cssVar.colorWarning }}
/>
) : (
<Icon color={cssVar.colorTextDescription} icon={MessageSquareDashed} size={'small'} />
)
@ -213,9 +195,14 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
icon={(() => {
if (isLoading) {
return (
<Icon spin icon={Loader2Icon} size={'small'} style={{ color: cssVar.colorWarning }} />
<RingLoadingIcon
ringColor={cssVar.colorWarningBorder}
size={14}
style={{ color: cssVar.colorWarning }}
/>
);
}
if (hasUnread) return unreadIcon;
if (metadata?.bot?.platform) {
const ProviderIcon = getPlatformIcon(metadata.bot!.platform);
if (ProviderIcon) {
@ -226,9 +213,6 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
<Icon icon={HashIcon} size={'small'} style={{ color: cssVar.colorTextDescription }} />
);
})()}
slots={{
iconPostfix: unreadNode,
}}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
/>

View file

@ -1,35 +1,127 @@
'use client';
import { Icon } from '@lobehub/ui';
import { type DropdownItem } from '@lobehub/ui';
import { Maximize2 } from 'lucide-react';
import { type DropdownItem, Icon } from '@lobehub/ui';
import { App } from 'antd';
import { Copy, Hash, Maximize2, PencilLine, Star, Trash } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { isDesktop } from '@/const/version';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
export const useMenu = (): { menuItems: DropdownItem[] } => {
const { t } = useTranslation('chat');
const { t } = useTranslation(['chat', 'topic', 'common']);
const { modal, message } = App.useApp();
const [wideScreen, toggleWideScreen] = useGlobalStore((s) => [
systemStatusSelectors.wideScreen(s),
s.toggleWideScreen,
]);
const menuItems = useMemo<DropdownItem[]>(
() => [
{
checked: wideScreen,
icon: <Icon icon={Maximize2} />,
key: 'full-width',
label: t('viewMode.fullWidth'),
onCheckedChange: toggleWideScreen,
type: 'switch',
},
],
[t, toggleWideScreen, wideScreen],
);
const activeTopic = useChatStore(topicSelectors.currentActiveTopic);
const workingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
const [favoriteTopic, removeTopic] = useChatStore((s) => [s.favoriteTopic, s.removeTopic]);
const topicId = activeTopic?.id;
const isFavorite = !!activeTopic?.favorite;
const menuItems = useMemo<DropdownItem[]>(() => {
const items: DropdownItem[] = [];
if (topicId) {
items.push(
{
icon: <Icon icon={Star} />,
key: 'favorite',
label: t(isFavorite ? 'actions.unfavorite' : 'actions.favorite', { ns: 'topic' }),
onClick: () => {
favoriteTopic(topicId, !isFavorite);
},
},
{
icon: <Icon icon={PencilLine} />,
key: 'rename',
label: t('rename', { ns: 'common' }),
onClick: () => {
useChatStore.setState({ topicRenamingId: topicId });
},
},
{ type: 'divider' as const },
);
if (isDesktop && workingDirectory) {
items.push({
icon: <Icon icon={Copy} />,
key: 'copyWorkingDirectory',
label: t('actions.copyWorkingDirectory', { ns: 'topic' }),
onClick: () => {
void navigator.clipboard.writeText(workingDirectory);
message.success(t('actions.copyWorkingDirectorySuccess', { ns: 'topic' }));
},
});
}
items.push(
{
icon: <Icon icon={Hash} />,
key: 'copySessionId',
label: t('actions.copySessionId', { ns: 'topic' }),
onClick: () => {
void navigator.clipboard.writeText(topicId);
message.success(t('actions.copySessionIdSuccess', { ns: 'topic' }));
},
},
{ type: 'divider' as const },
);
}
items.push({
checked: wideScreen,
icon: <Icon icon={Maximize2} />,
key: 'full-width',
label: t('viewMode.fullWidth'),
onCheckedChange: toggleWideScreen,
type: 'switch',
});
if (topicId) {
items.push(
{ type: 'divider' as const },
{
danger: true,
icon: <Icon icon={Trash} />,
key: 'delete',
label: t('delete', { ns: 'common' }),
onClick: () => {
modal.confirm({
centered: true,
okButtonProps: { danger: true },
onOk: async () => {
await removeTopic(topicId);
},
title: t('actions.confirmRemoveTopic', { ns: 'topic' }),
});
},
},
);
}
return items;
}, [
topicId,
isFavorite,
workingDirectory,
wideScreen,
favoriteTopic,
removeTopic,
toggleWideScreen,
t,
modal,
message,
]);
return { menuItems };
};

View file

@ -1,12 +1,9 @@
import { Github } from '@lobehub/icons';
import { Icon, Tooltip } from '@lobehub/ui';
import { Tooltip } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { FolderIcon, GitBranchIcon } from 'lucide-react';
import { memo, type ReactNode, useMemo } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { isDesktop } from '@/const/version';
import { getRecentDirs } from '@/features/ChatInput/RuntimeConfig/recentDirs';
import { localFileService } from '@/services/electron/localFileService';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
@ -15,32 +12,23 @@ const styles = createStaticStyles(({ css }) => ({
chip: css`
cursor: pointer;
overflow: hidden;
display: inline-flex;
gap: 4px;
align-items: center;
height: 22px;
padding-inline: 8px;
border-radius: 4px;
max-width: 200px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
font-size: 13px;
color: ${cssVar.colorTextTertiary};
text-overflow: ellipsis;
white-space: nowrap;
background: ${cssVar.colorFillQuaternary};
transition: all 0.2s;
transition: color 0.2s;
&:hover {
color: ${cssVar.colorText};
background: ${cssVar.colorFillSecondary};
}
`,
label: css`
overflow: hidden;
max-width: 200px;
text-overflow: ellipsis;
white-space: nowrap;
`,
}));
const FolderTag = memo(() => {
@ -48,14 +36,6 @@ const FolderTag = memo(() => {
const topicBoundDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
const iconNode = useMemo((): ReactNode => {
if (!topicBoundDirectory) return null;
const match = getRecentDirs().find((d) => d.path === topicBoundDirectory);
if (match?.repoType === 'github') return <Github size={12} />;
if (match?.repoType === 'git') return <Icon icon={GitBranchIcon} size={12} />;
return <Icon icon={FolderIcon} size={12} />;
}, [topicBoundDirectory]);
if (!isDesktop || !topicBoundDirectory) return null;
const displayName = topicBoundDirectory.split('/').findLast(Boolean) || topicBoundDirectory;
@ -66,10 +46,9 @@ const FolderTag = memo(() => {
return (
<Tooltip title={`${topicBoundDirectory} · ${t('localFiles.openFolder')}`}>
<div className={styles.chip} onClick={handleOpen}>
{iconNode}
<span className={styles.label}>{displayName}</span>
</div>
<span className={styles.chip} onClick={handleOpen}>
{displayName}
</span>
</Tooltip>
);
});

View file

@ -1,5 +1,7 @@
import { Flexbox } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
@ -10,6 +12,7 @@ import FolderTag from './FolderTag';
import MemberCountTag from './MemberCountTag';
const TitleTags = memo(() => {
const { t } = useTranslation('topic');
const topicTitle = useChatStore((s) => topicSelectors.currentActiveTopic(s)?.title);
const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
@ -23,20 +26,19 @@ const TitleTags = memo(() => {
return (
<Flexbox horizontal align={'center'} gap={8}>
{topicTitle && (
<span
style={{
fontSize: 14,
marginLeft: 8,
opacity: 0.6,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{topicTitle}
</span>
)}
<span
style={{
color: cssVar.colorText,
fontSize: 14,
fontWeight: 600,
marginLeft: 8,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{topicTitle || t('newTopic')}
</span>
<FolderTag />
</Flexbox>
);

View file

@ -15,15 +15,20 @@ const Header = memo(() => {
return (
<NavHeader
left={
<Flexbox style={{ backgroundColor: cssVar.colorBgContainer }}>
<Flexbox
horizontal
align={'center'}
gap={4}
style={{ backgroundColor: cssVar.colorBgContainer }}
>
<Tags />
<HeaderActions />
</Flexbox>
}
right={
<Flexbox horizontal align={'center'} style={{ backgroundColor: cssVar.colorBgContainer }}>
<ShareButton />
<WorkingPanelToggle />
<HeaderActions />
</Flexbox>
}
/>

View file

@ -9,17 +9,17 @@ import { useChatStore } from '@/store/chat';
import WorkingDirectoryBar from './WorkingDirectoryBar';
// Heterogeneous agents (e.g. Claude Code) bring their own toolchain, memory,
// and model, so LobeHub-side pickers don't apply. Only file upload is kept
// (images feed into the agent via stream-json stdin).
const leftActions: ActionKeys[] = ['fileUpload'];
// and model, so LobeHub-side pickers don't apply. Typo is kept so the user
// can still toggle the rich-text formatting bar.
const leftActions: ActionKeys[] = ['typo'];
const rightActions: ActionKeys[] = [];
/**
* HeterogeneousChatInput
*
* Simplified ChatInput for heterogeneous agents (Claude Code, etc.).
* Keeps only: text input, image/file upload, send button, and a
* working-directory picker no model/tools/memory/KB/MCP/runtime-mode.
* Keeps only: text input, typo toggle, send button, and a working-directory
* picker no model/tools/memory/KB/MCP/runtime-mode/upload.
*/
const HeterogeneousChatInput = memo(() => {
return (

View file

@ -99,6 +99,7 @@ const AgentHeader = memo(() => {
>
{/* Avatar Section */}
<EmojiPicker
allowModelAvatar
allowUpload
allowDelete={!!meta.avatar}
loading={uploading}

View file

@ -0,0 +1,116 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { type ToolStatus } from '@lobechat/electron-client-ipc';
import { ActionIcon, CopyButton, Flexbox, Icon, Tag, Text, Tooltip } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { CheckCircle2, Loader2Icon, RefreshCw, XCircle } from 'lucide-react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toolDetectorService } from '@/services/electron/toolDetector';
const useStyles = createStyles(({ css, token }) => ({
card: css`
padding-block: 8px;
padding-inline: 12px;
border: 1px solid ${token.colorBorderSecondary};
border-radius: ${token.borderRadiusLG}px;
background: ${token.colorFillQuaternary};
`,
path: css`
font-family: ${token.fontFamilyCode};
font-size: 12px;
color: ${token.colorTextTertiary};
`,
}));
const CCStatusCard = memo(() => {
const { t } = useTranslation('setting');
const { styles } = useStyles();
const [status, setStatus] = useState<ToolStatus | undefined>();
const [detecting, setDetecting] = useState(true);
const detect = useCallback(async () => {
if (!isDesktop) return;
setDetecting(true);
try {
const result = await toolDetectorService.detectTool('claude', true);
setStatus(result);
} catch (error) {
console.error('[CCStatusCard] Failed to detect claude CLI:', error);
setStatus({ available: false, error: (error as Error).message });
} finally {
setDetecting(false);
}
}, []);
useEffect(() => {
void detect();
}, [detect]);
const renderBody = () => {
if (detecting) {
return (
<Flexbox horizontal align="center" gap={8}>
<Icon spin icon={Loader2Icon} size={16} style={{ opacity: 0.6 }} />
<Text type="secondary">{t('ccStatus.detecting')}</Text>
</Flexbox>
);
}
if (!status || !status.available) {
return (
<Flexbox horizontal align="center" gap={8}>
<Icon color="var(--ant-color-error)" icon={XCircle} size={16} />
<Text type="secondary">{t('ccStatus.unavailable')}</Text>
</Flexbox>
);
}
return (
<Flexbox horizontal align="center" gap={8} style={{ flex: 1, minWidth: 0 }}>
<Icon color="var(--ant-color-success)" icon={CheckCircle2} size={16} />
{status.version && <Tag color="processing">{status.version}</Tag>}
{status.path && (
<Tooltip title={status.path}>
<Flexbox
horizontal
align="center"
gap={4}
style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}
>
<Text ellipsis className={styles.path}>
{status.path}
</Text>
<CopyButton content={status.path} size="small" />
</Flexbox>
</Tooltip>
)}
</Flexbox>
);
};
return (
<Flexbox className={styles.card} gap={8} style={{ marginBottom: 12 }}>
<Flexbox horizontal align="center" gap={8} justify="space-between">
<Text strong>{t('ccStatus.title')}</Text>
<Tooltip title={t('ccStatus.redetect')}>
<ActionIcon
disabled={detecting}
icon={RefreshCw}
loading={detecting}
size="small"
onClick={detect}
/>
</Tooltip>
</Flexbox>
{renderBody()}
</Flexbox>
);
});
CCStatusCard.displayName = 'CCStatusCard';
export default CCStatusCard;

View file

@ -15,10 +15,12 @@ import AgentSettings from '../AgentSettings';
import EditorCanvas from '../EditorCanvas';
import AgentHeader from './AgentHeader';
import AgentTool from './AgentTool';
import CCStatusCard from './CCStatusCard';
const ProfileEditor = memo(() => {
const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
const updateConfig = useAgentStore((s) => s.updateAgentConfig);
const isHeterogeneous = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
const enableBusinessFeatures = useServerConfigStore(serverConfigSelectors.enableBusinessFeatures);
return (
@ -31,25 +33,32 @@ const ProfileEditor = memo(() => {
>
{/* Header: Avatar + Name + Description */}
<AgentHeader />
{/* Config Bar: Model Selector */}
<Flexbox
horizontal
align={'center'}
gap={8}
justify={'flex-start'}
style={{ marginBottom: 12 }}
>
<ModelSelect
initialWidth
popupWidth={400}
value={{
model: config.model,
provider: config.provider,
}}
onChange={updateConfig}
/>
</Flexbox>
<AgentTool />
{isHeterogeneous ? (
// CC integration mode: show CLI version + path instead of model/skills pickers
<CCStatusCard />
) : (
<>
{/* Config Bar: Model Selector */}
<Flexbox
horizontal
align={'center'}
gap={8}
justify={'flex-start'}
style={{ marginBottom: 12 }}
>
<ModelSelect
initialWidth
popupWidth={400}
value={{
model: config.model,
provider: config.provider,
}}
onChange={updateConfig}
/>
</Flexbox>
<AgentTool />
</>
)}
</Flexbox>
<Divider />
{/* Main Content: Prompt Editor */}