mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
✨ feat: claude code intergration polish (#13942)
* 🐛 fix(cc-resume): guard resume against cwd mismatch (LOBE-7336) Claude Code CLI stores sessions per-cwd under `~/.claude/projects/<encoded-cwd>/`, so resuming a session from a different working directory fails with "No conversation found with session ID". Persist the cwd alongside the session id on each turn and skip `--resume` when the current cwd can't be verified against the stored one, falling back to a fresh session plus a toast explaining the reset. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ✨ feat(cc-desktop): Claude Code desktop polish + completion notifications Bundles the follow-on UX improvements for Claude Code on desktop: - Completion notifications: CC / Codex / ACP runs now fire a desktop notification (when the window is hidden) plus dock badge when the turn finishes, matching the Gateway client-mode behavior. - Inspector + renders: add Skill and TodoWrite inspectors, wire them through Render/index + renders registry, expose shared displayControls. - Adapter: extend claude-code adapter with additional event coverage and regression tests. - Sidebar / home menu: clean up Topic list item and dropdown menu, rename "Claude Code Agent" entry point to "Add Claude Code" across EN/ZH. - Assorted: NotificationCtr, Browser, WorkflowCollapse, ServerMode upload, agent/tool selectors — small follow-ups surfaced while building the above. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ✅ test(browser): mock electron.app for badge-clear on focus Browser.focus handler now calls app.setBadgeCount / app.dock.setBadge to clear the completion badge when the user returns. Tests imported the Browser module without exposing app on the electron mock, causing a module-load failure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ✨ feat(cc-topic): folder chip + unify cwd into workingDirectory (#13949) ✨ feat(cc-topic): show bound folder chip and unify cwd into workingDirectory Replace the separate `ccSessionCwd` metadata field with the existing `workingDirectory` so a CC topic's bound cwd has one source of truth: persisted on first CC execution, read back by resume validation, and surfaced in a clickable folder chip next to the topic title on desktop. 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:
parent
a98d113a80
commit
13fe968480
46 changed files with 1304 additions and 184 deletions
|
|
@ -178,6 +178,28 @@ export default class NotificationCtr extends ControllerModule {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the app-level badge count (dock red dot on macOS, Unity counter on Linux,
|
||||
* overlay icon on Windows). Pass 0 to clear.
|
||||
*
|
||||
* On macOS we pair `app.setBadgeCount` with `app.dock.setBadge` — the former
|
||||
* keeps Electron's internal count (cross-platform), the latter is the
|
||||
* reliable Dock repaint trigger. Note: macOS Focus Mode / DND suppresses the
|
||||
* badge visually until the user exits Focus.
|
||||
*/
|
||||
@IpcMethod()
|
||||
setBadgeCount(count: number): void {
|
||||
try {
|
||||
const next = Math.max(0, Math.floor(count));
|
||||
app.setBadgeCount(next);
|
||||
if (macOS() && app.dock) {
|
||||
app.dock.setBadge(next > 0 ? String(next) : '');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to set badge count:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the main window is hidden
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { join } from 'node:path';
|
|||
import { APP_WINDOW_MIN_SIZE } from '@lobechat/desktop-bridge';
|
||||
import type { MainBroadcastEventKey, MainBroadcastParams } from '@lobechat/electron-client-ipc';
|
||||
import type { BrowserWindowConstructorOptions } from 'electron';
|
||||
import { BrowserWindow, ipcMain, screen, session as electronSession, shell } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain, screen, session as electronSession, shell } from 'electron';
|
||||
|
||||
import { preloadDir, resourcesDir } from '@/const/dir';
|
||||
import { isMac } from '@/const/env';
|
||||
|
|
@ -259,6 +259,13 @@ export default class Browser {
|
|||
browserWindow.on('focus', () => {
|
||||
logger.debug(`[${this.identifier}] Window 'focus' event fired.`);
|
||||
this.broadcast('windowFocused');
|
||||
// Clear any completion badge once the user returns to the app.
|
||||
try {
|
||||
app.setBadgeCount(0);
|
||||
if (process.platform === 'darwin' && app.dock) app.dock.setBadge('');
|
||||
} catch {
|
||||
/* noop — some platforms may not support badge counts */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,76 +4,89 @@ import { type App as AppCore } from '../../App';
|
|||
import Browser, { type BrowserWindowOpts } from '../Browser';
|
||||
|
||||
// Use vi.hoisted to define mocks before hoisting
|
||||
const { mockBrowserWindow, mockNativeTheme, mockIpcMain, mockScreen, MockBrowserWindow } =
|
||||
vi.hoisted(() => {
|
||||
const mockBrowserWindow = {
|
||||
center: vi.fn(),
|
||||
close: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
getBounds: vi.fn().mockReturnValue({ height: 600, width: 800, x: 0, y: 0 }),
|
||||
getContentBounds: vi.fn().mockReturnValue({ height: 600, width: 800 }),
|
||||
hide: vi.fn(),
|
||||
isDestroyed: vi.fn().mockReturnValue(false),
|
||||
isFocused: vi.fn().mockReturnValue(true),
|
||||
isFullScreen: vi.fn().mockReturnValue(false),
|
||||
isMaximized: vi.fn().mockReturnValue(false),
|
||||
isVisible: vi.fn().mockReturnValue(true),
|
||||
loadFile: vi.fn().mockResolvedValue(undefined),
|
||||
loadURL: vi.fn().mockResolvedValue(undefined),
|
||||
maximize: vi.fn(),
|
||||
minimize: vi.fn(),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
setBounds: vi.fn(),
|
||||
setFullScreen: vi.fn(),
|
||||
setPosition: vi.fn(),
|
||||
setTitleBarOverlay: vi.fn(),
|
||||
show: vi.fn(),
|
||||
unmaximize: vi.fn(),
|
||||
webContents: {
|
||||
openDevTools: vi.fn(),
|
||||
send: vi.fn(),
|
||||
session: {
|
||||
webRequest: {
|
||||
onBeforeSendHeaders: vi.fn(),
|
||||
onHeadersReceived: vi.fn(),
|
||||
},
|
||||
const {
|
||||
mockElectronApp,
|
||||
mockBrowserWindow,
|
||||
mockNativeTheme,
|
||||
mockIpcMain,
|
||||
mockScreen,
|
||||
MockBrowserWindow,
|
||||
} = vi.hoisted(() => {
|
||||
const mockBrowserWindow = {
|
||||
center: vi.fn(),
|
||||
close: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
getBounds: vi.fn().mockReturnValue({ height: 600, width: 800, x: 0, y: 0 }),
|
||||
getContentBounds: vi.fn().mockReturnValue({ height: 600, width: 800 }),
|
||||
hide: vi.fn(),
|
||||
isDestroyed: vi.fn().mockReturnValue(false),
|
||||
isFocused: vi.fn().mockReturnValue(true),
|
||||
isFullScreen: vi.fn().mockReturnValue(false),
|
||||
isMaximized: vi.fn().mockReturnValue(false),
|
||||
isVisible: vi.fn().mockReturnValue(true),
|
||||
loadFile: vi.fn().mockResolvedValue(undefined),
|
||||
loadURL: vi.fn().mockResolvedValue(undefined),
|
||||
maximize: vi.fn(),
|
||||
minimize: vi.fn(),
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
setBounds: vi.fn(),
|
||||
setFullScreen: vi.fn(),
|
||||
setPosition: vi.fn(),
|
||||
setTitleBarOverlay: vi.fn(),
|
||||
show: vi.fn(),
|
||||
unmaximize: vi.fn(),
|
||||
webContents: {
|
||||
openDevTools: vi.fn(),
|
||||
send: vi.fn(),
|
||||
session: {
|
||||
webRequest: {
|
||||
onBeforeSendHeaders: vi.fn(),
|
||||
onHeadersReceived: vi.fn(),
|
||||
},
|
||||
on: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
},
|
||||
};
|
||||
on: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
MockBrowserWindow: vi.fn().mockImplementation(() => mockBrowserWindow),
|
||||
mockBrowserWindow,
|
||||
mockIpcMain: {
|
||||
handle: vi.fn(),
|
||||
removeHandler: vi.fn(),
|
||||
},
|
||||
mockNativeTheme: {
|
||||
off: vi.fn(),
|
||||
on: vi.fn(),
|
||||
shouldUseDarkColors: false,
|
||||
themeSource: 'system',
|
||||
},
|
||||
mockScreen: {
|
||||
getDisplayMatching: vi.fn().mockReturnValue({
|
||||
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
|
||||
}),
|
||||
getDisplayNearestPoint: vi.fn().mockReturnValue({
|
||||
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
|
||||
}),
|
||||
getPrimaryDisplay: vi.fn().mockReturnValue({
|
||||
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
const mockElectronApp = {
|
||||
dock: { setBadge: vi.fn() },
|
||||
setBadgeCount: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
MockBrowserWindow: vi.fn().mockImplementation(() => mockBrowserWindow),
|
||||
mockElectronApp,
|
||||
mockBrowserWindow,
|
||||
mockIpcMain: {
|
||||
handle: vi.fn(),
|
||||
removeHandler: vi.fn(),
|
||||
},
|
||||
mockNativeTheme: {
|
||||
off: vi.fn(),
|
||||
on: vi.fn(),
|
||||
shouldUseDarkColors: false,
|
||||
themeSource: 'system',
|
||||
},
|
||||
mockScreen: {
|
||||
getDisplayMatching: vi.fn().mockReturnValue({
|
||||
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
|
||||
}),
|
||||
getDisplayNearestPoint: vi.fn().mockReturnValue({
|
||||
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
|
||||
}),
|
||||
getPrimaryDisplay: vi.fn().mockReturnValue({
|
||||
workArea: { height: 1080, width: 1920, x: 0, y: 0 },
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock electron
|
||||
vi.mock('electron', () => ({
|
||||
app: mockElectronApp,
|
||||
BrowserWindow: MockBrowserWindow,
|
||||
ipcMain: mockIpcMain,
|
||||
nativeTheme: mockNativeTheme,
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@
|
|||
"groupWizard.searchTemplates": "Search templates...",
|
||||
"groupWizard.title": "Create Group",
|
||||
"groupWizard.useTemplate": "Use Template",
|
||||
"heteroAgent.resumeReset.cwdChanged": "Working directory changed. Previous Claude Code session can only be resumed from its original directory, so a new conversation has started.",
|
||||
"hideForYou": "Direct message content is hidden. Please enable 'Show Direct Message Content' in settings to view.",
|
||||
"history.title": "The Agent will keep only the latest {{count}} messages.",
|
||||
"historyRange": "History Range",
|
||||
|
|
@ -224,7 +225,7 @@
|
|||
"minimap.senderAssistant": "Agent",
|
||||
"minimap.senderUser": "You",
|
||||
"newAgent": "Create Agent",
|
||||
"newClaudeCodeAgent": "Claude Code Agent",
|
||||
"newClaudeCodeAgent": "Add Claude Code",
|
||||
"newGroupChat": "Create Group",
|
||||
"newPage": "Create Page",
|
||||
"noAgentsYet": "This group has no members yet. Click the + button to invite agents.",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
"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.",
|
||||
"actions.confirmRemoveUnstarred": "You are about to delete unstarred topics. This action cannot be undone.",
|
||||
"actions.copyLink": "Copy Link",
|
||||
"actions.copyLinkSuccess": "Link copied",
|
||||
"actions.duplicate": "Duplicate",
|
||||
"actions.export": "Export Topics",
|
||||
"actions.favorite": "Favorite",
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@
|
|||
"groupWizard.searchTemplates": "搜索模板…",
|
||||
"groupWizard.title": "创建群组",
|
||||
"groupWizard.useTemplate": "使用模板",
|
||||
"heteroAgent.resumeReset.cwdChanged": "工作目录已切换,之前的 Claude Code 会话只能在原目录下继续,已开始新对话。",
|
||||
"hideForYou": "私信内容已隐藏。可在设置中开启「显示私信内容」查看",
|
||||
"history.title": "助理将仅保留最近 {{count}} 条消息",
|
||||
"historyRange": "历史范围",
|
||||
|
|
@ -224,7 +225,7 @@
|
|||
"minimap.senderAssistant": "助理",
|
||||
"minimap.senderUser": "你",
|
||||
"newAgent": "创建助理",
|
||||
"newClaudeCodeAgent": "Claude Code 智能体",
|
||||
"newClaudeCodeAgent": "添加 Claude Code",
|
||||
"newGroupChat": "创建群组",
|
||||
"newPage": "创建文稿",
|
||||
"noAgentsYet": "这个群组还没有成员。点击「+」邀请助理加入",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
"actions.confirmRemoveAll": "您即将删除所有话题,此操作无法撤销。",
|
||||
"actions.confirmRemoveTopic": "您即将删除此话题,此操作无法撤销。",
|
||||
"actions.confirmRemoveUnstarred": "您即将删除未加星标的话题,此操作无法撤销。",
|
||||
"actions.copyLink": "复制链接",
|
||||
"actions.copyLinkSuccess": "链接已复制",
|
||||
"actions.duplicate": "复制",
|
||||
"actions.export": "导出话题",
|
||||
"actions.favorite": "收藏",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
|
||||
import { ClaudeCodeApiName } from '../types';
|
||||
import { ReadInspector } from './ReadInspector';
|
||||
import { SkillInspector } from './SkillInspector';
|
||||
import { TodoWriteInspector } from './TodoWriteInspector';
|
||||
import { WriteInspector } from './WriteInspector';
|
||||
|
||||
// CC's own tool names (Bash / Edit / Glob / Grep / Read / Write) are already
|
||||
|
|
@ -28,5 +30,7 @@ export const ClaudeCodeInspectors = {
|
|||
translationKey: ClaudeCodeApiName.Grep,
|
||||
}),
|
||||
[ClaudeCodeApiName.Read]: ReadInspector,
|
||||
[ClaudeCodeApiName.Skill]: SkillInspector,
|
||||
[ClaudeCodeApiName.TodoWrite]: TodoWriteInspector,
|
||||
[ClaudeCodeApiName.Write]: WriteInspector,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
'use client';
|
||||
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Markdown, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import type { SkillArgs } from '../../../types';
|
||||
|
||||
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-block: 4px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
background: ${cssVar.colorBgContainer};
|
||||
`,
|
||||
}));
|
||||
|
||||
const Skill = memo<BuiltinRenderProps<SkillArgs>>(({ args, content }) => {
|
||||
const skillName = args?.skill;
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={8}>
|
||||
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
|
||||
<Icon icon={Sparkles} size={'small'} />
|
||||
<Text strong>{skillName || 'Skill'}</Text>
|
||||
</Flexbox>
|
||||
|
||||
{content && (
|
||||
<Flexbox className={styles.previewBox}>
|
||||
<Markdown style={{ maxHeight: 240, overflow: 'auto' }} variant={'chat'}>
|
||||
{content}
|
||||
</Markdown>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
Skill.displayName = 'ClaudeCodeSkill';
|
||||
|
||||
export default Skill;
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
'use client';
|
||||
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Block, Checkbox, Icon, Text } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
import { CircleArrowRight, CircleCheckBig, ListTodo } from 'lucide-react';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import type { ClaudeCodeTodoItem, TodoWriteArgs } from '../../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
header: css`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
|
||||
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
`,
|
||||
headerLabel: css`
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
color: ${cssVar.colorText};
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
headerCount: css`
|
||||
flex-shrink: 0;
|
||||
|
||||
padding-block: 2px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 999px;
|
||||
|
||||
font-family: ${cssVar.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
`,
|
||||
itemRow: css`
|
||||
width: 100%;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
border-block-end: 1px dashed ${cssVar.colorBorderSecondary};
|
||||
|
||||
&:last-child {
|
||||
border-block-end: none;
|
||||
}
|
||||
`,
|
||||
processingRow: css`
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
`,
|
||||
textCompleted: css`
|
||||
color: ${cssVar.colorTextQuaternary};
|
||||
text-decoration: line-through;
|
||||
`,
|
||||
textPending: css`
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
`,
|
||||
textProcessing: css`
|
||||
color: ${cssVar.colorText};
|
||||
`,
|
||||
}));
|
||||
|
||||
interface TodoRowProps {
|
||||
item: ClaudeCodeTodoItem;
|
||||
}
|
||||
|
||||
const TodoRow = memo<TodoRowProps>(({ item }) => {
|
||||
const { status, content, activeForm } = item;
|
||||
|
||||
if (status === 'in_progress') {
|
||||
return (
|
||||
<div className={cx(styles.itemRow, styles.processingRow)}>
|
||||
<Icon icon={CircleArrowRight} size={17} style={{ color: cssVar.colorPrimary }} />
|
||||
<span className={styles.textProcessing}>{activeForm || content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isCompleted = status === 'completed';
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
backgroundColor={cssVar.colorSuccess}
|
||||
checked={isCompleted}
|
||||
shape={'circle'}
|
||||
style={{ borderWidth: 1.5, cursor: 'default' }}
|
||||
classNames={{
|
||||
text: cx(styles.textPending, isCompleted && styles.textCompleted),
|
||||
wrapper: styles.itemRow,
|
||||
}}
|
||||
textProps={{
|
||||
type: isCompleted ? 'secondary' : undefined,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Checkbox>
|
||||
);
|
||||
});
|
||||
|
||||
TodoRow.displayName = 'ClaudeCodeTodoRow';
|
||||
|
||||
interface TodoHeaderProps {
|
||||
completed: number;
|
||||
inProgress?: ClaudeCodeTodoItem;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const TodoHeader = memo<TodoHeaderProps>(({ completed, total, inProgress }) => {
|
||||
const allDone = total > 0 && completed === total;
|
||||
|
||||
const { icon, color, label } = inProgress
|
||||
? {
|
||||
color: cssVar.colorPrimary,
|
||||
icon: CircleArrowRight,
|
||||
label: inProgress.activeForm || inProgress.content,
|
||||
}
|
||||
: allDone
|
||||
? {
|
||||
color: cssVar.colorSuccess,
|
||||
icon: CircleCheckBig,
|
||||
label: 'All tasks completed',
|
||||
}
|
||||
: {
|
||||
color: cssVar.colorTextSecondary,
|
||||
icon: ListTodo,
|
||||
label: 'Todos',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<Icon icon={icon} size={16} style={{ color, flexShrink: 0 }} />
|
||||
<Text className={styles.headerLabel} strong={!!inProgress}>
|
||||
{label}
|
||||
</Text>
|
||||
<span className={styles.headerCount}>
|
||||
{completed}/{total}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TodoHeader.displayName = 'ClaudeCodeTodoHeader';
|
||||
|
||||
const TodoWrite = memo<BuiltinRenderProps<TodoWriteArgs>>(({ args }) => {
|
||||
const todos = args?.todos;
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const items = todos ?? [];
|
||||
return {
|
||||
completed: items.filter((t) => t?.status === 'completed').length,
|
||||
inProgress: items.find((t) => t?.status === 'in_progress'),
|
||||
total: items.length,
|
||||
};
|
||||
}, [todos]);
|
||||
|
||||
if (!todos || todos.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Block variant={'outlined'} width="100%">
|
||||
<TodoHeader completed={stats.completed} inProgress={stats.inProgress} total={stats.total} />
|
||||
{todos.map((item, index) => (
|
||||
<TodoRow item={item} key={index} />
|
||||
))}
|
||||
</Block>
|
||||
);
|
||||
});
|
||||
|
||||
TodoWrite.displayName = 'ClaudeCodeTodoWrite';
|
||||
|
||||
export default TodoWrite;
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import { RunCommandRender } from '@lobechat/shared-tool-ui/renders';
|
||||
import type { RenderDisplayControl } from '@lobechat/types';
|
||||
|
||||
import { ClaudeCodeApiName } from '../../types';
|
||||
import Edit from './Edit';
|
||||
import Glob from './Glob';
|
||||
import Grep from './Grep';
|
||||
import Read from './Read';
|
||||
import Skill from './Skill';
|
||||
import TodoWrite from './TodoWrite';
|
||||
import Write from './Write';
|
||||
|
||||
/**
|
||||
|
|
@ -21,5 +24,19 @@ export const ClaudeCodeRenders = {
|
|||
[ClaudeCodeApiName.Glob]: Glob,
|
||||
[ClaudeCodeApiName.Grep]: Grep,
|
||||
[ClaudeCodeApiName.Read]: Read,
|
||||
[ClaudeCodeApiName.Skill]: Skill,
|
||||
[ClaudeCodeApiName.TodoWrite]: TodoWrite,
|
||||
[ClaudeCodeApiName.Write]: Write,
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-APIName default display control for CC tool renders.
|
||||
*
|
||||
* CC doesn't ship a LobeChat manifest (its tools come from Anthropic tool_use
|
||||
* blocks at runtime), so the store's manifest-based `getRenderDisplayControl`
|
||||
* can't reach these. The builtin-tools aggregator exposes this map via
|
||||
* `getBuiltinRenderDisplayControl` as a fallback.
|
||||
*/
|
||||
export const ClaudeCodeRenderDisplayControls: Record<string, RenderDisplayControl> = {
|
||||
[ClaudeCodeApiName.TodoWrite]: 'expand',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
highlightTextStyles,
|
||||
inspectorTextStyles,
|
||||
shinyTextStyles,
|
||||
} from '@lobechat/shared-tool-ui/styles';
|
||||
import type { BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { cx } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ClaudeCodeApiName, type SkillArgs } from '../types';
|
||||
|
||||
export const SkillInspector = memo<BuiltinInspectorProps<SkillArgs>>(
|
||||
({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const label = t(ClaudeCodeApiName.Skill as any);
|
||||
const skillName = args?.skill || partialArgs?.skill;
|
||||
|
||||
if (isArgumentsStreaming && !skillName) {
|
||||
return <div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>{label}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
inspectorTextStyles.root,
|
||||
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{skillName && (
|
||||
<>
|
||||
<span>: </span>
|
||||
<span className={highlightTextStyles.primary}>{skillName}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SkillInspector.displayName = 'ClaudeCodeSkillInspector';
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
highlightTextStyles,
|
||||
inspectorTextStyles,
|
||||
shinyTextStyles,
|
||||
} from '@lobechat/shared-tool-ui/styles';
|
||||
import type { BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ClaudeCodeApiName, type ClaudeCodeTodoItem, type TodoWriteArgs } from '../types';
|
||||
|
||||
const RING_SIZE = 14;
|
||||
const RING_STROKE = 2;
|
||||
const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2;
|
||||
const RING_CIRCUM = 2 * Math.PI * RING_RADIUS;
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
ring: css`
|
||||
transform: rotate(-90deg);
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: 6px;
|
||||
`,
|
||||
ringTrack: css`
|
||||
stroke: ${cssVar.colorFillSecondary};
|
||||
`,
|
||||
ringProgress: css`
|
||||
transition:
|
||||
stroke-dashoffset 240ms ease,
|
||||
stroke 240ms ease;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface TodoStats {
|
||||
completed: number;
|
||||
inProgress?: ClaudeCodeTodoItem;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ProgressRingProps {
|
||||
stats: TodoStats;
|
||||
}
|
||||
|
||||
const ProgressRing = memo<ProgressRingProps>(({ stats }) => {
|
||||
const { completed, total } = stats;
|
||||
const ratio = total > 0 ? completed / total : 0;
|
||||
const allDone = total > 0 && completed === total;
|
||||
const color = allDone ? cssVar.colorSuccess : cssVar.colorPrimary;
|
||||
|
||||
return (
|
||||
<svg className={styles.ring} height={RING_SIZE} width={RING_SIZE}>
|
||||
<circle
|
||||
className={styles.ringTrack}
|
||||
cx={RING_SIZE / 2}
|
||||
cy={RING_SIZE / 2}
|
||||
fill="none"
|
||||
r={RING_RADIUS}
|
||||
strokeWidth={RING_STROKE}
|
||||
/>
|
||||
<circle
|
||||
className={styles.ringProgress}
|
||||
cx={RING_SIZE / 2}
|
||||
cy={RING_SIZE / 2}
|
||||
fill="none"
|
||||
r={RING_RADIUS}
|
||||
stroke={color}
|
||||
strokeDasharray={RING_CIRCUM}
|
||||
strokeDashoffset={RING_CIRCUM * (1 - ratio)}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={RING_STROKE}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
ProgressRing.displayName = 'ClaudeCodeTodoProgressRing';
|
||||
|
||||
const computeStats = (args?: TodoWriteArgs): TodoStats => {
|
||||
const todos = args?.todos ?? [];
|
||||
return {
|
||||
completed: todos.filter((t) => t?.status === 'completed').length,
|
||||
inProgress: todos.find((t) => t?.status === 'in_progress'),
|
||||
total: todos.length,
|
||||
};
|
||||
};
|
||||
|
||||
const getSummary = (stats: TodoStats): string | undefined => {
|
||||
if (stats.total === 0) return undefined;
|
||||
if (stats.inProgress) return stats.inProgress.activeForm || stats.inProgress.content;
|
||||
return `${stats.completed}/${stats.total}`;
|
||||
};
|
||||
|
||||
export const TodoWriteInspector = memo<BuiltinInspectorProps<TodoWriteArgs>>(
|
||||
({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const label = t(ClaudeCodeApiName.TodoWrite as any);
|
||||
|
||||
const stats = useMemo(() => computeStats(args || partialArgs), [args, partialArgs]);
|
||||
const summary = getSummary(stats);
|
||||
|
||||
if (isArgumentsStreaming && stats.total === 0) {
|
||||
return <div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>{label}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
inspectorTextStyles.root,
|
||||
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
|
||||
)}
|
||||
>
|
||||
{stats.total > 0 && <ProgressRing stats={stats} />}
|
||||
<span>{label}</span>
|
||||
{summary && (
|
||||
<>
|
||||
<span>: </span>
|
||||
<span className={highlightTextStyles.primary}>{summary}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TodoWriteInspector.displayName = 'ClaudeCodeTodoWriteInspector';
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
export { ClaudeCodeApiName, ClaudeCodeIdentifier } from '../types';
|
||||
export { ClaudeCodeInspectors } from './Inspector';
|
||||
export { ClaudeCodeRenders } from './Render';
|
||||
export { ClaudeCodeRenderDisplayControls, ClaudeCodeRenders } from './Render';
|
||||
|
|
|
|||
|
|
@ -16,5 +16,35 @@ export enum ClaudeCodeApiName {
|
|||
Glob = 'Glob',
|
||||
Grep = 'Grep',
|
||||
Read = 'Read',
|
||||
Skill = 'Skill',
|
||||
TodoWrite = 'TodoWrite',
|
||||
Write = 'Write',
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of a single todo item in a `TodoWrite` tool_use.
|
||||
* Matches Claude Code's native schema — do not reuse GTD's `TodoStatus`,
|
||||
* which has a different vocabulary (`todo` / `processing`).
|
||||
*/
|
||||
export type ClaudeCodeTodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
export interface ClaudeCodeTodoItem {
|
||||
/** Present-continuous form, shown while the item is in progress */
|
||||
activeForm: string;
|
||||
/** Imperative description, shown in pending & completed states */
|
||||
content: string;
|
||||
status: ClaudeCodeTodoStatus;
|
||||
}
|
||||
|
||||
export interface TodoWriteArgs {
|
||||
todos: ClaudeCodeTodoItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Arguments for CC's built-in `Skill` tool. CC invokes this to activate an
|
||||
* installed skill (e.g. `local-testing`); the tool_result carries the skill's
|
||||
* SKILL.md body back to the model.
|
||||
*/
|
||||
export interface SkillArgs {
|
||||
skill?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./renders": "./src/renders.ts",
|
||||
"./displayControls": "./src/displayControls.ts",
|
||||
"./inspectors": "./src/inspectors.ts",
|
||||
"./interventions": "./src/interventions.ts",
|
||||
"./placeholders": "./src/placeholders.ts",
|
||||
|
|
|
|||
20
packages/builtin-tools/src/displayControls.ts
Normal file
20
packages/builtin-tools/src/displayControls.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import {
|
||||
ClaudeCodeIdentifier,
|
||||
ClaudeCodeRenderDisplayControls,
|
||||
} from '@lobechat/builtin-tool-claude-code/client';
|
||||
import { type RenderDisplayControl } from '@lobechat/types';
|
||||
|
||||
// Kept separate from `./renders` so consumers that only need display-control
|
||||
// fallbacks (e.g. the tool store selector) don't pull in every builtin tool's
|
||||
// render registry — that graph cycles back through `@/store/tool/selectors`.
|
||||
const BuiltinRenderDisplayControls: Record<string, Record<string, RenderDisplayControl>> = {
|
||||
[ClaudeCodeIdentifier]: ClaudeCodeRenderDisplayControls,
|
||||
};
|
||||
|
||||
export const getBuiltinRenderDisplayControl = (
|
||||
identifier?: string,
|
||||
apiName?: string,
|
||||
): RenderDisplayControl | undefined => {
|
||||
if (!identifier || !apiName) return undefined;
|
||||
return BuiltinRenderDisplayControls[identifier]?.[apiName];
|
||||
};
|
||||
|
|
@ -81,3 +81,5 @@ export const getBuiltinRender = (
|
|||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export { getBuiltinRenderDisplayControl } from './displayControls';
|
||||
|
|
|
|||
|
|
@ -432,6 +432,132 @@ describe('ClaudeCodeAdapter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Cumulative tools_calling (orphan tool regression)
|
||||
//
|
||||
// CC streams each tool_use content block in its OWN assistant event, even
|
||||
// when multiple tools belong to the same LLM turn (same message.id). The
|
||||
// in-memory handler dispatch updates assistant.tools via a REPLACING array
|
||||
// merge — so if the adapter emitted only the newest tool on each chunk,
|
||||
// earlier tools would vanish from the in-memory assistant.tools[] between
|
||||
// tool_result refreshes and render as orphans. Adapter must emit the full
|
||||
// cumulative list per message.id so the replacing merge preserves history.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('cumulative tools_calling per message.id', () => {
|
||||
it('includes prior tools in tools_calling when a new tool_use arrives on same message.id', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
adapter.adapt({ subtype: 'init', type: 'system' });
|
||||
|
||||
// First tool_use block of msg_1
|
||||
const e1 = adapter.adapt({
|
||||
message: {
|
||||
id: 'msg_1',
|
||||
content: [{ id: 't1', input: { path: '/a' }, name: 'Read', type: 'tool_use' }],
|
||||
},
|
||||
type: 'assistant',
|
||||
});
|
||||
const chunk1 = e1.find(
|
||||
(e) => e.type === 'stream_chunk' && e.data.chunkType === 'tools_calling',
|
||||
);
|
||||
expect(chunk1!.data.toolsCalling.map((t: any) => t.id)).toEqual(['t1']);
|
||||
|
||||
// Second tool_use block on the SAME message.id — must carry both t1 + t2
|
||||
const e2 = adapter.adapt({
|
||||
message: {
|
||||
id: 'msg_1',
|
||||
content: [{ id: 't2', input: { cmd: 'ls' }, name: 'Bash', type: 'tool_use' }],
|
||||
},
|
||||
type: 'assistant',
|
||||
});
|
||||
const chunk2 = e2.find(
|
||||
(e) => e.type === 'stream_chunk' && e.data.chunkType === 'tools_calling',
|
||||
);
|
||||
expect(chunk2!.data.toolsCalling.map((t: any) => t.id)).toEqual(['t1', 't2']);
|
||||
});
|
||||
|
||||
it('emits tool_start only for newly-seen tools, not for the cumulative prior ones', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
adapter.adapt({ subtype: 'init', type: 'system' });
|
||||
|
||||
adapter.adapt({
|
||||
message: {
|
||||
id: 'msg_1',
|
||||
content: [{ id: 't1', input: {}, name: 'Read', type: 'tool_use' }],
|
||||
},
|
||||
type: 'assistant',
|
||||
});
|
||||
|
||||
const e2 = adapter.adapt({
|
||||
message: {
|
||||
id: 'msg_1',
|
||||
content: [{ id: 't2', input: {}, name: 'Bash', type: 'tool_use' }],
|
||||
},
|
||||
type: 'assistant',
|
||||
});
|
||||
|
||||
const starts = e2.filter((e) => e.type === 'tool_start');
|
||||
expect(starts).toHaveLength(1);
|
||||
expect(starts[0].data.toolCalling.id).toBe('t2');
|
||||
});
|
||||
|
||||
it('starts a fresh accumulator when message.id advances (new LLM turn)', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
adapter.adapt({ subtype: 'init', type: 'system' });
|
||||
|
||||
adapter.adapt({
|
||||
message: {
|
||||
id: 'msg_1',
|
||||
content: [{ id: 't1', input: {}, name: 'Read', type: 'tool_use' }],
|
||||
},
|
||||
type: 'assistant',
|
||||
});
|
||||
|
||||
const events = adapter.adapt({
|
||||
message: {
|
||||
id: 'msg_2',
|
||||
content: [{ id: 't2', input: {}, name: 'Bash', type: 'tool_use' }],
|
||||
},
|
||||
type: 'assistant',
|
||||
});
|
||||
|
||||
const chunk = events.find(
|
||||
(e) => e.type === 'stream_chunk' && e.data.chunkType === 'tools_calling',
|
||||
);
|
||||
// Different message.id — the new assistant's tools[] must NOT contain t1
|
||||
expect(chunk!.data.toolsCalling.map((t: any) => t.id)).toEqual(['t2']);
|
||||
});
|
||||
|
||||
it('dedupes when CC echoes a tool_use block with the same id', () => {
|
||||
const adapter = new ClaudeCodeAdapter();
|
||||
adapter.adapt({ subtype: 'init', type: 'system' });
|
||||
|
||||
adapter.adapt({
|
||||
message: {
|
||||
id: 'msg_1',
|
||||
content: [{ id: 't1', input: {}, name: 'Read', type: 'tool_use' }],
|
||||
},
|
||||
type: 'assistant',
|
||||
});
|
||||
|
||||
// Same tool_use id re-sent — cumulative list must not duplicate it,
|
||||
// and tool_start must not fire again.
|
||||
const e2 = adapter.adapt({
|
||||
message: {
|
||||
id: 'msg_1',
|
||||
content: [{ id: 't1', input: {}, name: 'Read', type: 'tool_use' }],
|
||||
},
|
||||
type: 'assistant',
|
||||
});
|
||||
|
||||
const chunk = e2.find(
|
||||
(e) => e.type === 'stream_chunk' && e.data.chunkType === 'tools_calling',
|
||||
);
|
||||
expect(chunk!.data.toolsCalling.map((t: any) => t.id)).toEqual(['t1']);
|
||||
expect(e2.filter((e) => e.type === 'tool_start')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Partial-messages streaming (--include-partial-messages)
|
||||
// stream_event wrapper carries Anthropic SSE deltas:
|
||||
|
|
|
|||
|
|
@ -78,6 +78,14 @@ export class ClaudeCodeAdapter implements AgentEventAdapter {
|
|||
private messagesWithStreamedText = new Set<string>();
|
||||
/** message.ids whose thinking has already been streamed as deltas — skip the full-block emission */
|
||||
private messagesWithStreamedThinking = new Set<string>();
|
||||
/**
|
||||
* Cumulative tool_use blocks per message.id. CC streams each tool_use in
|
||||
* its OWN assistant event, and the handler's in-memory assistant.tools
|
||||
* update uses a REPLACING array merge — so chunks must carry every tool
|
||||
* seen on this turn, not just the latest, or prior tools render as orphans
|
||||
* until the next `fetchAndReplaceMessages`.
|
||||
*/
|
||||
private toolCallsByMessageId = new Map<string, ToolCallPayload[]>();
|
||||
|
||||
adapt(raw: any): HeterogeneousAgentEvent[] {
|
||||
if (!raw || typeof raw !== 'object') return [];
|
||||
|
|
@ -196,10 +204,17 @@ export class ClaudeCodeAdapter implements AgentEventAdapter {
|
|||
);
|
||||
}
|
||||
if (newToolCalls.length > 0) {
|
||||
events.push(this.makeChunkEvent({ chunkType: 'tools_calling', toolsCalling: newToolCalls }));
|
||||
// Also emit tool_start for each — the handler's tool_start is a no-op
|
||||
// but it's semantically correct for the lifecycle.
|
||||
for (const t of newToolCalls) {
|
||||
const msgKey = messageId ?? '';
|
||||
const existing = this.toolCallsByMessageId.get(msgKey) ?? [];
|
||||
const existingIds = new Set(existing.map((t) => t.id));
|
||||
const freshTools = newToolCalls.filter((t) => !existingIds.has(t.id));
|
||||
const cumulative = [...existing, ...freshTools];
|
||||
this.toolCallsByMessageId.set(msgKey, cumulative);
|
||||
|
||||
events.push(this.makeChunkEvent({ chunkType: 'tools_calling', toolsCalling: cumulative }));
|
||||
// tool_start fires only for newly-seen ids so an echoed tool_use does
|
||||
// not re-open a closed lifecycle.
|
||||
for (const t of freshTools) {
|
||||
events.push(this.makeEvent('tool_start', { toolCalling: t }));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./renders": "./src/Render/index.ts",
|
||||
"./inspectors": "./src/Inspector/index.ts"
|
||||
"./inspectors": "./src/Inspector/index.ts",
|
||||
"./styles": "./src/styles.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lobechat/tool-runtime": "workspace:*"
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ export interface ChatTopicMetadata {
|
|||
* CC session ID for multi-turn resume (desktop only).
|
||||
* Persisted after each CC execution so the next message in the same topic
|
||||
* can use `--resume <sessionId>` to continue the conversation.
|
||||
* CC CLI stores sessions per-cwd under `~/.claude/projects/<encoded-cwd>/`,
|
||||
* so resume requires the current cwd to equal `workingDirectory`.
|
||||
*/
|
||||
ccSessionId?: string;
|
||||
/**
|
||||
|
|
@ -80,8 +82,10 @@ export interface ChatTopicMetadata {
|
|||
userMemoryExtractRunState?: TopicUserMemoryExtractRunState;
|
||||
userMemoryExtractStatus?: 'pending' | 'completed' | 'failed';
|
||||
/**
|
||||
* Local System working directory (desktop only)
|
||||
* Priority is higher than Agent-level settings
|
||||
* Topic-level working directory (desktop only).
|
||||
* Priority is higher than Agent-level settings. Also serves as the
|
||||
* binding cwd for a CC session — written on first CC execution and
|
||||
* checked on subsequent turns to decide whether `--resume` is safe.
|
||||
*/
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ const FileUpload = memo(() => {
|
|||
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);
|
||||
|
||||
|
|
@ -90,73 +93,84 @@ const FileUpload = memo(() => {
|
|||
</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;
|
||||
// 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;
|
||||
}
|
||||
// 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]);
|
||||
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;
|
||||
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;
|
||||
}
|
||||
// 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]);
|
||||
setDropdownOpen(false);
|
||||
await upload([file]);
|
||||
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div className={cx(hotArea)}>{t('upload.action.folderUpload')}</div>
|
||||
</Upload>
|
||||
),
|
||||
},
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<div className={cx(hotArea)}>{t('upload.action.folderUpload')}</div>
|
||||
</Upload>
|
||||
),
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const knowledgeItems: ItemType[] = [];
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const MessageContent = memo<ContentBlockProps>(({ content, hasTools, id }) => {
|
|||
if (!content && !hasTools) return <ContentLoading id={id} />;
|
||||
|
||||
if (content === LOADING_FLAT) {
|
||||
if (hasTools) return null;
|
||||
return <ContentLoading id={id} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ const WorkflowCollapse = memo<WorkflowCollapseProps>(
|
|||
workflowChromeComplete = false,
|
||||
}) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const toolCallsUnit = t('task.metrics.toolCallsShort');
|
||||
const allTools = useMemo(() => collectTools(blocks), [blocks]);
|
||||
const toolsPhaseComplete = areWorkflowToolsComplete(allTools);
|
||||
const pendingInterventionPresent = useMemo(() => hasPendingIntervention(allTools), [allTools]);
|
||||
|
|
@ -179,9 +180,11 @@ const WorkflowCollapse = memo<WorkflowCollapseProps>(
|
|||
defaultValue: 'Awaiting your confirmation',
|
||||
});
|
||||
const workingLabel = t('workflow.working', { defaultValue: 'Working...' });
|
||||
const expandedWorkingLabel =
|
||||
allTools.length > 0 ? `${allTools.length} ${toolCallsUnit}` : workingLabel;
|
||||
const streamingHeadlineRaw = useMemo(() => {
|
||||
if (pendingInterventionPresent) return pendingInterventionLabel;
|
||||
if (showExpandedWorkingLabel) return workingLabel;
|
||||
if (showExpandedWorkingLabel) return expandedWorkingLabel;
|
||||
switch (headlineState.kind) {
|
||||
case 'thinking': {
|
||||
return headlineState.reasoningTitle;
|
||||
|
|
@ -198,11 +201,11 @@ const WorkflowCollapse = memo<WorkflowCollapseProps>(
|
|||
}
|
||||
}, [
|
||||
committedProse,
|
||||
expandedWorkingLabel,
|
||||
headlineState,
|
||||
pendingInterventionLabel,
|
||||
pendingInterventionPresent,
|
||||
showExpandedWorkingLabel,
|
||||
workingLabel,
|
||||
]);
|
||||
const streamingHeadline = useDebouncedHeadline(
|
||||
streamingHeadlineRaw,
|
||||
|
|
@ -356,7 +359,7 @@ const WorkflowCollapse = memo<WorkflowCollapseProps>(
|
|||
</div>
|
||||
{showWorkingElapsed && (
|
||||
<span style={{ color: cssVar.colorTextQuaternary, flexShrink: 0 }}>
|
||||
({workingElapsedSeconds}s)
|
||||
({formatReasoningDuration(workingElapsedSeconds * TIME_MS_PER_SECOND)})
|
||||
</span>
|
||||
)}
|
||||
</Flexbox>
|
||||
|
|
|
|||
|
|
@ -147,6 +147,8 @@ export default {
|
|||
'groupWizard.searchTemplates': 'Search templates...',
|
||||
'groupWizard.title': 'Create Group',
|
||||
'groupWizard.useTemplate': 'Use Template',
|
||||
'heteroAgent.resumeReset.cwdChanged':
|
||||
'Working directory changed. Previous Claude Code session can only be resumed from its original directory, so a new conversation has started.',
|
||||
'hideForYou':
|
||||
"Direct message content is hidden. Please enable 'Show Direct Message Content' in settings to view.",
|
||||
'history.title': 'The Agent will keep only the latest {{count}} messages.',
|
||||
|
|
@ -248,7 +250,7 @@ export default {
|
|||
'createModal.placeholder': 'Describe what your agent should do...',
|
||||
'createModal.title': 'What should your agent do?',
|
||||
'newAgent': 'Create Agent',
|
||||
'newClaudeCodeAgent': 'Claude Code Agent',
|
||||
'newClaudeCodeAgent': 'Add Claude Code',
|
||||
'newGroupChat': 'Create Group',
|
||||
'newPage': 'Create Page',
|
||||
'noAgentsYet': 'This group has no members yet. Click the + button to invite agents.',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ export default {
|
|||
'actions.confirmRemoveTopic': 'You are about to delete this topic. This action cannot be undone.',
|
||||
'actions.confirmRemoveUnstarred':
|
||||
'You are about to delete unstarred topics. This action cannot be undone.',
|
||||
'actions.copyLink': 'Copy Link',
|
||||
'actions.copyLinkSuccess': 'Link copied',
|
||||
'actions.duplicate': 'Duplicate',
|
||||
'actions.favorite': 'Favorite',
|
||||
'actions.unfavorite': 'Unfavorite',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { HashIcon, MessageSquareDashed } from 'lucide-react';
|
||||
import { HashIcon, Loader2Icon, MessageSquareDashed } from 'lucide-react';
|
||||
import { AnimatePresence, m } from 'motion/react';
|
||||
import { memo, Suspense, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -175,9 +175,12 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
|
|||
return (
|
||||
<NavItem
|
||||
active={active && !isInAgentSubRoute}
|
||||
loading={isLoading}
|
||||
icon={
|
||||
<Icon color={cssVar.colorTextDescription} icon={MessageSquareDashed} size={'small'} />
|
||||
isLoading ? (
|
||||
<Icon spin color={cssVar.colorWarning} icon={Loader2Icon} size={'small'} />
|
||||
) : (
|
||||
<Icon color={cssVar.colorTextDescription} icon={MessageSquareDashed} size={'small'} />
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Flexbox horizontal align={'center'} flex={1} gap={6}>
|
||||
|
|
@ -206,9 +209,13 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
|
|||
contextMenuItems={dropdownMenu}
|
||||
disabled={editing}
|
||||
href={href}
|
||||
loading={isLoading}
|
||||
title={title}
|
||||
icon={(() => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Icon spin icon={Loader2Icon} size={'small'} style={{ color: cssVar.colorWarning }} />
|
||||
);
|
||||
}
|
||||
if (metadata?.bot?.platform) {
|
||||
const ProviderIcon = getPlatformIcon(metadata.bot!.platform);
|
||||
if (ProviderIcon) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Icon } from '@lobehub/ui';
|
|||
import { App } from 'antd';
|
||||
import {
|
||||
ExternalLink,
|
||||
Link2,
|
||||
LucideCopy,
|
||||
PanelTop,
|
||||
PencilLine,
|
||||
|
|
@ -35,7 +36,7 @@ export const useTopicItemDropdownMenu = ({
|
|||
toggleEditing,
|
||||
}: TopicItemDropdownMenuProps) => {
|
||||
const { t } = useTranslation(['topic', 'common']);
|
||||
const { modal } = App.useApp();
|
||||
const { modal, message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const openTopicInNewWindow = useGlobalStore((s) => s.openTopicInNewWindow);
|
||||
|
|
@ -85,6 +86,9 @@ export const useTopicItemDropdownMenu = ({
|
|||
toggleEditing(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider' as const,
|
||||
},
|
||||
...(isDesktop
|
||||
? [
|
||||
{
|
||||
|
|
@ -109,8 +113,22 @@ export const useTopicItemDropdownMenu = ({
|
|||
if (activeAgentId) openTopicInNewWindow(activeAgentId, id);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <Icon icon={Link2} />,
|
||||
key: 'copyLink',
|
||||
label: t('actions.copyLink'),
|
||||
onClick: () => {
|
||||
if (!activeAgentId) return;
|
||||
const url = `${window.location.origin}/agent/${activeAgentId}?topic=${id}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
message.success(t('actions.copyLinkSuccess'));
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={LucideCopy} />,
|
||||
key: 'duplicate',
|
||||
|
|
@ -119,6 +137,9 @@ export const useTopicItemDropdownMenu = ({
|
|||
duplicateTopic(id);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider' as const,
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={Share2} />,
|
||||
key: 'share',
|
||||
|
|
@ -159,6 +180,7 @@ export const useTopicItemDropdownMenu = ({
|
|||
toggleEditing,
|
||||
t,
|
||||
modal,
|
||||
message,
|
||||
handleOpenShareModal,
|
||||
]);
|
||||
return { dropdownMenu };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { Github } from '@lobehub/icons';
|
||||
import { Icon, Tooltip } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { FolderIcon, GitBranchIcon } from 'lucide-react';
|
||||
import { memo, type ReactNode, useMemo } 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';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
chip: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
height: 22px;
|
||||
padding-inline: 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
|
||||
background: ${cssVar.colorFillQuaternary};
|
||||
|
||||
transition: all 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(() => {
|
||||
const { t } = useTranslation('tool');
|
||||
|
||||
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;
|
||||
|
||||
const handleOpen = () => {
|
||||
void localFileService.openLocalFolder({ isDirectory: true, path: topicBoundDirectory });
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={`${topicBoundDirectory} · ${t('localFiles.openFolder')}`}>
|
||||
<div className={styles.chip} onClick={handleOpen}>
|
||||
{iconNode}
|
||||
<span className={styles.label}>{displayName}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
FolderTag.displayName = 'TopicFolderTag';
|
||||
|
||||
export default FolderTag;
|
||||
|
|
@ -6,6 +6,7 @@ import { topicSelectors } from '@/store/chat/selectors';
|
|||
import { useSessionStore } from '@/store/session';
|
||||
import { sessionSelectors } from '@/store/session/selectors';
|
||||
|
||||
import FolderTag from './FolderTag';
|
||||
import MemberCountTag from './MemberCountTag';
|
||||
|
||||
const TitleTags = memo(() => {
|
||||
|
|
@ -20,22 +21,23 @@ const TitleTags = memo(() => {
|
|||
);
|
||||
}
|
||||
|
||||
if (!topicTitle) return null;
|
||||
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
marginLeft: 8,
|
||||
opacity: 0.6,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{topicTitle}
|
||||
</span>
|
||||
<Flexbox horizontal align={'center'} gap={8}>
|
||||
{topicTitle && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
marginLeft: 8,
|
||||
opacity: 0.6,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{topicTitle}
|
||||
</span>
|
||||
)}
|
||||
<FolderTag />
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { HashIcon, MessageSquareDashed } from 'lucide-react';
|
||||
import { HashIcon, Loader2Icon, MessageSquareDashed } from 'lucide-react';
|
||||
import { AnimatePresence, m } from 'motion/react';
|
||||
import { memo, Suspense, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -173,9 +173,12 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
|
|||
return (
|
||||
<NavItem
|
||||
active={active}
|
||||
loading={isLoading}
|
||||
icon={
|
||||
<Icon color={cssVar.colorTextDescription} icon={MessageSquareDashed} size={'small'} />
|
||||
isLoading ? (
|
||||
<Icon spin color={cssVar.colorWarning} icon={Loader2Icon} size={'small'} />
|
||||
) : (
|
||||
<Icon color={cssVar.colorTextDescription} icon={MessageSquareDashed} size={'small'} />
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Flexbox horizontal align={'center'} flex={1} gap={6}>
|
||||
|
|
@ -204,10 +207,13 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId }) =>
|
|||
contextMenuItems={dropdownMenu}
|
||||
disabled={editing}
|
||||
href={!editing ? href : undefined}
|
||||
loading={isLoading}
|
||||
title={title}
|
||||
icon={
|
||||
<Icon icon={HashIcon} size={'small'} style={{ color: cssVar.colorTextDescription }} />
|
||||
isLoading ? (
|
||||
<Icon spin icon={Loader2Icon} size={'small'} style={{ color: cssVar.colorWarning }} />
|
||||
) : (
|
||||
<Icon icon={HashIcon} size={'small'} style={{ color: cssVar.colorTextDescription }} />
|
||||
)
|
||||
}
|
||||
slots={{
|
||||
iconPostfix: unreadNode,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { type MenuProps } from '@lobehub/ui';
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { App } from 'antd';
|
||||
import { ExternalLink, LucideCopy, PanelTop, PencilLine, Trash, Wand2 } from 'lucide-react';
|
||||
import { ExternalLink, Link2, LucideCopy, PanelTop, PencilLine, Trash, Wand2 } from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
|
@ -24,7 +24,7 @@ export const useTopicItemDropdownMenu = ({
|
|||
toggleEditing,
|
||||
}: TopicItemDropdownMenuProps): (() => MenuProps['items']) => {
|
||||
const { t } = useTranslation(['topic', 'common']);
|
||||
const { modal } = App.useApp();
|
||||
const { modal, message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const openTopicInNewWindow = useGlobalStore((s) => s.openTopicInNewWindow);
|
||||
|
|
@ -58,6 +58,9 @@ export const useTopicItemDropdownMenu = ({
|
|||
toggleEditing(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider' as const,
|
||||
},
|
||||
...(isDesktop
|
||||
? [
|
||||
{
|
||||
|
|
@ -82,10 +85,21 @@ export const useTopicItemDropdownMenu = ({
|
|||
if (activeAgentId) openTopicInNewWindow(activeAgentId, id);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: 'divider' as const,
|
||||
icon: <Icon icon={Link2} />,
|
||||
key: 'copyLink',
|
||||
label: t('actions.copyLink'),
|
||||
onClick: () => {
|
||||
if (!activeGroupId) return;
|
||||
const url = `${window.location.origin}/group/${activeGroupId}?topic=${id}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
message.success(t('actions.copyLinkSuccess'));
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={LucideCopy} />,
|
||||
|
|
@ -128,5 +142,6 @@ export const useTopicItemDropdownMenu = ({
|
|||
toggleEditing,
|
||||
t,
|
||||
modal,
|
||||
message,
|
||||
]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,10 +29,12 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
|
|||
useCreateMenuItems();
|
||||
|
||||
const addMenuItems = useMemo(() => {
|
||||
const items = [createAgentMenuItem(), createGroupChatMenuItem()];
|
||||
const ccItem = createClaudeCodeMenuItem();
|
||||
if (ccItem) items.splice(1, 0, ccItem);
|
||||
return items;
|
||||
return [
|
||||
createAgentMenuItem(),
|
||||
createGroupChatMenuItem(),
|
||||
...(ccItem ? [{ type: 'divider' as const }, ccItem] : []),
|
||||
];
|
||||
}, [createAgentMenuItem, createClaudeCodeMenuItem, createGroupChatMenuItem]);
|
||||
|
||||
const handleOpenConfigGroupModal = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -33,10 +33,13 @@ const AddButton = memo(() => {
|
|||
);
|
||||
|
||||
const dropdownItems = useMemo(() => {
|
||||
const items = [createAgentMenuItem(), createGroupChatMenuItem(), createPageMenuItem()];
|
||||
const ccItem = createClaudeCodeMenuItem();
|
||||
if (ccItem) items.splice(1, 0, ccItem); // Insert after "Create Agent"
|
||||
return items;
|
||||
return [
|
||||
createAgentMenuItem(),
|
||||
createGroupChatMenuItem(),
|
||||
createPageMenuItem(),
|
||||
...(ccItem ? [{ type: 'divider' as const }, ccItem] : []),
|
||||
];
|
||||
}, [createAgentMenuItem, createClaudeCodeMenuItem, createGroupChatMenuItem, createPageMenuItem]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { isDesktop } from '@lobechat/const';
|
||||
import { ClaudeCode } from '@lobehub/icons';
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { GroupBotSquareIcon } from '@lobehub/ui/icons';
|
||||
import { App } from 'antd';
|
||||
import { type ItemType } from 'antd/es/menu/interface';
|
||||
import { BotIcon, FileTextIcon, FolderCogIcon, FolderPlus, TerminalSquareIcon } from 'lucide-react';
|
||||
import { BotIcon, FileTextIcon, FolderCogIcon, FolderPlus } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
|
@ -259,7 +260,7 @@ export const useCreateMenuItems = () => {
|
|||
(options?: CreateAgentOptions): ItemType | null => {
|
||||
if (!isDesktop || !enableHeterogeneousAgent) return null;
|
||||
return {
|
||||
icon: <Icon icon={TerminalSquareIcon} />,
|
||||
icon: <ClaudeCode size={'1em'} />,
|
||||
key: 'newClaudeCodeAgent',
|
||||
label: t('newClaudeCodeAgent'),
|
||||
onClick: async (info) => {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@ export class DesktopNotificationService {
|
|||
async isMainWindowHidden(): Promise<boolean> {
|
||||
return ensureElectronIpc().notification.isMainWindowHidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the app-level badge count (dock red dot on macOS, Unity counter on Linux,
|
||||
* overlay icon on Windows). Pass 0 to clear.
|
||||
*/
|
||||
async setBadgeCount(count: number): Promise<void> {
|
||||
return ensureElectronIpc().notification.setBadgeCount(count);
|
||||
}
|
||||
}
|
||||
|
||||
export const desktopNotificationService = new DesktopNotificationService();
|
||||
|
|
|
|||
|
|
@ -136,6 +136,15 @@ const getAgencyConfigById =
|
|||
(s: AgentStoreState): LobeAgentAgencyConfig | undefined =>
|
||||
agentSelectors.getAgentConfigById(agentId)(s)?.agencyConfig;
|
||||
|
||||
/**
|
||||
* Whether the agent is driven by an external heterogeneous runtime
|
||||
* (e.g. Claude Code) — by agentId.
|
||||
*/
|
||||
const isAgentHeterogeneousById =
|
||||
(agentId: string) =>
|
||||
(s: AgentStoreState): boolean =>
|
||||
!!getAgencyConfigById(agentId)(s)?.heterogeneousProvider;
|
||||
|
||||
/**
|
||||
* Get full agent data by agentId
|
||||
* Returns the complete agent object including metadata fields like updatedAt
|
||||
|
|
@ -159,4 +168,5 @@ export const agentByIdSelectors = {
|
|||
getAgentTTSById,
|
||||
getAgentWorkingDirectoryById,
|
||||
isAgentConfigLoadingById,
|
||||
isAgentHeterogeneousById,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import type { ChatTopicMetadata } from '@lobechat/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveCcResume } from '../ccResume';
|
||||
|
||||
describe('resolveCcResume', () => {
|
||||
it('resumes when saved cwd matches current cwd', () => {
|
||||
const metadata: ChatTopicMetadata = {
|
||||
ccSessionId: 'session-123',
|
||||
workingDirectory: '/Users/me/projA',
|
||||
};
|
||||
|
||||
expect(resolveCcResume(metadata, '/Users/me/projA')).toEqual({
|
||||
cwdChanged: false,
|
||||
resumeSessionId: 'session-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips resume when saved cwd differs from current cwd', () => {
|
||||
const metadata: ChatTopicMetadata = {
|
||||
ccSessionId: 'session-123',
|
||||
workingDirectory: '/Users/me/projA',
|
||||
};
|
||||
|
||||
expect(resolveCcResume(metadata, '/Users/me/projB')).toEqual({
|
||||
cwdChanged: true,
|
||||
resumeSessionId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats undefined current cwd as empty string (matches saved empty cwd)', () => {
|
||||
const metadata: ChatTopicMetadata = {
|
||||
ccSessionId: 'session-123',
|
||||
workingDirectory: '',
|
||||
};
|
||||
|
||||
expect(resolveCcResume(metadata, undefined)).toEqual({
|
||||
cwdChanged: false,
|
||||
resumeSessionId: 'session-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('flags mismatch when saved cwd is non-empty but current cwd is undefined', () => {
|
||||
const metadata: ChatTopicMetadata = {
|
||||
ccSessionId: 'session-123',
|
||||
workingDirectory: '/Users/me/projA',
|
||||
};
|
||||
|
||||
expect(resolveCcResume(metadata, undefined)).toEqual({
|
||||
cwdChanged: true,
|
||||
resumeSessionId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('resets legacy sessions that have no saved cwd', () => {
|
||||
// Legacy topics created before workingDirectory was persisted are unverifiable.
|
||||
// Passing the stale id through was the original bug — reset instead, and
|
||||
// let the next turn rebuild the session with a recorded cwd.
|
||||
const metadata: ChatTopicMetadata = {
|
||||
ccSessionId: 'legacy-session',
|
||||
};
|
||||
|
||||
expect(resolveCcResume(metadata, '/Users/me/any')).toEqual({
|
||||
cwdChanged: true,
|
||||
resumeSessionId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no session when nothing is stored', () => {
|
||||
expect(resolveCcResume({}, '/Users/me/projA')).toEqual({
|
||||
cwdChanged: false,
|
||||
resumeSessionId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles undefined metadata', () => {
|
||||
expect(resolveCcResume(undefined, '/Users/me/projA')).toEqual({
|
||||
cwdChanged: false,
|
||||
resumeSessionId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not flag cwd change when there is no saved sessionId', () => {
|
||||
// cwd field lingering without a sessionId shouldn't trigger the toast;
|
||||
// there's nothing to skip resuming.
|
||||
const metadata: ChatTopicMetadata = {
|
||||
workingDirectory: '/Users/me/projA',
|
||||
};
|
||||
|
||||
expect(resolveCcResume(metadata, '/Users/me/projB')).toEqual({
|
||||
cwdChanged: false,
|
||||
resumeSessionId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,10 @@ function createMockStore() {
|
|||
internal_dispatchMessage: vi.fn(),
|
||||
internal_executeClientTool: vi.fn().mockResolvedValue(undefined),
|
||||
internal_toggleToolCallingStreaming: vi.fn(),
|
||||
markUnreadCompleted: vi.fn(),
|
||||
operations: {
|
||||
'op-1': { context: { agentId: 'agent-1', scope: 'session', topicId: 'topic-1' } },
|
||||
} as Record<string, any>,
|
||||
replaceMessages: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -922,6 +922,89 @@ describe('heterogeneousAgentExecutor DB persistence', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// LOBE-7258 reproduction: Skill → ToolSearch → MCP tool
|
||||
//
|
||||
// Mirrors the exact trace from the user-reported screenshot where
|
||||
// ToolSearch loads deferred MCP schemas before the MCP tool is called.
|
||||
// Verifies tool_result content is persisted for ALL three tools so the
|
||||
// UI stops showing "loading" after each tool completes.
|
||||
// ────────────────────────────────────────────────────
|
||||
|
||||
describe('LOBE-7258 Skill → ToolSearch → MCP repro', () => {
|
||||
it('persists tool_result content for Skill, ToolSearch, and the deferred MCP tool', async () => {
|
||||
const idCounter = { tool: 0, assistant: 0 };
|
||||
mockCreateMessage.mockImplementation(async (params: any) => {
|
||||
if (params.role === 'tool') {
|
||||
idCounter.tool++;
|
||||
return { id: `tool-${idCounter.tool}` };
|
||||
}
|
||||
idCounter.assistant++;
|
||||
return { id: `ast-new-${idCounter.assistant}` };
|
||||
});
|
||||
|
||||
const schemaPayload =
|
||||
'<functions><function>{"description":"Get a Linear issue","name":"mcp__linear-server__get_issue","parameters":{}}</function></functions>';
|
||||
|
||||
await runWithEvents([
|
||||
ccInit(),
|
||||
// Turn 1: Skill invocation
|
||||
ccToolUse('msg_01', 'toolu_skill', 'Skill', { skill: 'linear' }),
|
||||
ccToolResult('toolu_skill', 'Launching skill: linear'),
|
||||
// Turn 2: ToolSearch with select: prefix (deferred schema fetch)
|
||||
ccToolUse('msg_02', 'toolu_search', 'ToolSearch', {
|
||||
query: 'select:mcp__linear-server__get_issue,mcp__linear-server__save_issue',
|
||||
max_results: 3,
|
||||
}),
|
||||
ccToolResult('toolu_search', schemaPayload),
|
||||
// Turn 3: the deferred MCP tool now callable
|
||||
ccToolUse('msg_03', 'toolu_get_issue', 'mcp__linear-server__get_issue', {
|
||||
id: 'LOBE-7258',
|
||||
}),
|
||||
ccToolResult('toolu_get_issue', '{"title":"resume error on topic switch"}'),
|
||||
ccText('msg_04', 'done'),
|
||||
ccResult(),
|
||||
]);
|
||||
|
||||
// All three tool messages should have their content persisted.
|
||||
const skillResult = mockUpdateToolMessage.mock.calls.find(([id]: any) => id === 'tool-1');
|
||||
const searchResult = mockUpdateToolMessage.mock.calls.find(([id]: any) => id === 'tool-2');
|
||||
const getIssueResult = mockUpdateToolMessage.mock.calls.find(([id]: any) => id === 'tool-3');
|
||||
|
||||
expect(skillResult).toBeDefined();
|
||||
expect(skillResult![1]).toMatchObject({ content: 'Launching skill: linear' });
|
||||
|
||||
expect(searchResult).toBeDefined();
|
||||
expect(searchResult![1]).toMatchObject({ content: schemaPayload });
|
||||
expect(searchResult![1].pluginError).toBeUndefined();
|
||||
|
||||
expect(getIssueResult).toBeDefined();
|
||||
expect(getIssueResult![1]).toMatchObject({
|
||||
content: '{"title":"resume error on topic switch"}',
|
||||
});
|
||||
|
||||
// tools[] registry on each step should contain the right tool id so the
|
||||
// UI can match tool messages to their assistant (no orphan warnings).
|
||||
const skillRegister = mockUpdateMessage.mock.calls.find(
|
||||
([id, val]: any) =>
|
||||
id === 'ast-initial' && val.tools?.some((t: any) => t.id === 'toolu_skill'),
|
||||
);
|
||||
expect(skillRegister).toBeDefined();
|
||||
|
||||
const searchRegister = mockUpdateMessage.mock.calls.find(
|
||||
([id, val]: any) =>
|
||||
id === 'ast-new-1' && val.tools?.some((t: any) => t.id === 'toolu_search'),
|
||||
);
|
||||
expect(searchRegister).toBeDefined();
|
||||
|
||||
const getIssueRegister = mockUpdateMessage.mock.calls.find(
|
||||
([id, val]: any) =>
|
||||
id === 'ast-new-2' && val.tools?.some((t: any) => t.id === 'toolu_get_issue'),
|
||||
);
|
||||
expect(getIssueRegister).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// Full multi-step E2E
|
||||
// ────────────────────────────────────────────────────
|
||||
|
|
|
|||
37
src/store/chat/slices/aiChat/actions/ccResume.ts
Normal file
37
src/store/chat/slices/aiChat/actions/ccResume.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { ChatTopicMetadata } from '@lobechat/types';
|
||||
|
||||
export interface CcResumeDecision {
|
||||
/** True when a saved cwd exists and disagrees with the current cwd. */
|
||||
cwdChanged: boolean;
|
||||
/** Session ID to pass to `--resume`, or undefined when resume must be skipped. */
|
||||
resumeSessionId: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether we can safely resume a prior Claude Code session for the
|
||||
* current turn. CC CLI stores sessions per-cwd under
|
||||
* `~/.claude/projects/<encoded-cwd>/`, so resuming from a different cwd
|
||||
* blows up with "No conversation found with session ID".
|
||||
*
|
||||
* Strict rule: only resume when the topic's bound `workingDirectory` is
|
||||
* present AND equals the current cwd. Legacy topics (sessionId present,
|
||||
* workingDirectory missing) are reset — we have no way to verify them,
|
||||
* and silently passing a stale id is exactly what caused the original
|
||||
* failure.
|
||||
*/
|
||||
export const resolveCcResume = (
|
||||
metadata: ChatTopicMetadata | undefined,
|
||||
currentWorkingDirectory: string | undefined,
|
||||
): CcResumeDecision => {
|
||||
const savedSessionId = metadata?.ccSessionId;
|
||||
const savedCwd = metadata?.workingDirectory;
|
||||
const cwd = currentWorkingDirectory ?? '';
|
||||
|
||||
const canResume = !!savedSessionId && savedCwd !== undefined && savedCwd === cwd;
|
||||
const cwdChanged = !!savedSessionId && !canResume;
|
||||
|
||||
return {
|
||||
cwdChanged,
|
||||
resumeSessionId: canResume ? savedSessionId : undefined,
|
||||
};
|
||||
};
|
||||
|
|
@ -17,6 +17,7 @@ import { TRPCClientError } from '@trpc/client';
|
|||
import { t } from 'i18next';
|
||||
|
||||
import { markUserValidAction } from '@/business/client/markUserValidAction';
|
||||
import { message as antdMessage } from '@/components/AntdStaticMethods';
|
||||
import { aiChatService } from '@/services/aiChat';
|
||||
import { chatService } from '@/services/chat';
|
||||
import { resolveSelectedSkillsWithContent } from '@/services/chat/mecha/skillPreload';
|
||||
|
|
@ -25,6 +26,7 @@ import { messageService } from '@/services/message';
|
|||
import { getAgentStoreState } from '@/store/agent';
|
||||
import { agentByIdSelectors, agentSelectors } from '@/store/agent/selectors';
|
||||
import { agentGroupByIdSelectors, getChatGroupStoreState } from '@/store/agentGroup';
|
||||
import { resolveCcResume } from '@/store/chat/slices/aiChat/actions/ccResume';
|
||||
import { type ChatStore } from '@/store/chat/store';
|
||||
import {
|
||||
createPendingCompressedGroup,
|
||||
|
|
@ -443,11 +445,17 @@ export class ConversationLifecycleActionImpl {
|
|||
const userMsg = heteroData.messages.find((m: any) => m.id === heteroData.userMessageId);
|
||||
const persistedImageList = userMsg?.imageList;
|
||||
|
||||
// Read CC session ID from topic metadata for multi-turn resume
|
||||
// Read CC session ID from topic metadata for multi-turn resume.
|
||||
// `resolveCcResume` drops the sessionId when the saved cwd doesn't
|
||||
// match the current one, so CC doesn't emit
|
||||
// "No conversation found with session ID".
|
||||
const topic = heteroContext.topicId
|
||||
? topicSelectors.getTopicById(heteroContext.topicId)(this.#get())
|
||||
: undefined;
|
||||
const resumeSessionId = topic?.metadata?.ccSessionId;
|
||||
const { cwdChanged, resumeSessionId } = resolveCcResume(topic?.metadata, workingDirectory);
|
||||
if (cwdChanged) {
|
||||
antdMessage.info(t('heteroAgent.resumeReset.cwdChanged', { ns: 'chat' }));
|
||||
}
|
||||
|
||||
await executeHeterogeneousAgent(() => this.#get(), {
|
||||
assistantMessageId: heteroData.assistantMessageId,
|
||||
|
|
|
|||
|
|
@ -204,6 +204,12 @@ export const createGatewayEventHandler = (
|
|||
enqueue(async () => {
|
||||
get().internal_toggleToolCallingStreaming(currentAssistantMessageId, undefined);
|
||||
get().completeOperation(operationId);
|
||||
|
||||
const completedOp = get().operations[operationId];
|
||||
if (completedOp?.context.agentId) {
|
||||
get().markUnreadCompleted(completedOp.context.agentId, completedOp.context.topicId);
|
||||
}
|
||||
|
||||
await fetchAndReplaceMessages(get, context).catch(console.error);
|
||||
});
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { AgentStreamEvent } from '@lobechat/agent-gateway-client';
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import type { HeterogeneousAgentEvent, ToolCallPayload } from '@lobechat/heterogeneous-agents';
|
||||
import { createAdapter } from '@lobechat/heterogeneous-agents';
|
||||
import type {
|
||||
|
|
@ -6,13 +7,33 @@ import type {
|
|||
ConversationContext,
|
||||
HeterogeneousProviderConfig,
|
||||
} from '@lobechat/types';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { heterogeneousAgentService } from '@/services/electron/heterogeneousAgent';
|
||||
import { messageService } from '@/services/message';
|
||||
import type { ChatStore } from '@/store/chat/store';
|
||||
import { markdownToTxt } from '@/utils/markdownToTxt';
|
||||
|
||||
import { createGatewayEventHandler } from './gatewayEventHandler';
|
||||
|
||||
/**
|
||||
* Fire desktop notification + dock badge when a CC/Codex/ACP run finishes.
|
||||
* Notification only shows when the window is hidden (enforced in main); the
|
||||
* badge is always set so a minimized/backgrounded app still signals completion.
|
||||
*/
|
||||
const notifyCompletion = async (title: string, body: string) => {
|
||||
if (!isDesktop) return;
|
||||
try {
|
||||
const { desktopNotificationService } = await import('@/services/electron/desktopNotification');
|
||||
await Promise.allSettled([
|
||||
desktopNotificationService.showNotification({ body, title }),
|
||||
desktopNotificationService.setBadgeCount(1),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('[HeterogeneousAgent] Desktop notification failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export interface HeterogeneousAgentExecutorParams {
|
||||
assistantMessageId: string;
|
||||
context: ConversationContext;
|
||||
|
|
@ -572,6 +593,15 @@ export const executeHeterogeneousAgent = async (
|
|||
type: 'agent_runtime_end' as const,
|
||||
};
|
||||
eventHandler(toStreamEvent(terminal, operationId));
|
||||
|
||||
// Signal completion to the user — dock badge + (window-hidden) notification.
|
||||
// Skip for aborted runs and for error terminations.
|
||||
if (!isAborted() && deferredTerminalEvent?.type !== 'error') {
|
||||
const body = accumulatedContent
|
||||
? markdownToTxt(accumulatedContent)
|
||||
: t('notification.finishChatGeneration', { ns: 'electron' });
|
||||
notifyCompletion(t('notification.finishChatGeneration', { ns: 'electron' }), body);
|
||||
}
|
||||
},
|
||||
|
||||
onError: async (error) => {
|
||||
|
|
@ -615,11 +645,15 @@ export const executeHeterogeneousAgent = async (
|
|||
// Send the prompt — blocks until process exits
|
||||
await heterogeneousAgentService.sendPrompt(agentSessionId, message, imageList);
|
||||
|
||||
// Persist CC session ID to topic metadata for multi-turn resume.
|
||||
// The adapter extracts session_id from the CC init event.
|
||||
// Persist CC session ID + the cwd it was created under, for multi-turn
|
||||
// resume. CC stores sessions per-cwd (`~/.claude/projects/<encoded-cwd>/`),
|
||||
// so the next turn must verify the cwd hasn't changed before `--resume`.
|
||||
// Reuses `workingDirectory` as the topic-level binding — pinning the
|
||||
// topic to this cwd once CC has executed here.
|
||||
if (adapter.sessionId && context.topicId) {
|
||||
get().updateTopicMetadata(context.topicId, {
|
||||
ccSessionId: adapter.sessionId,
|
||||
workingDirectory: workingDirectory ?? '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { getBuiltinRenderDisplayControl } from '@lobechat/builtin-tools/displayControls';
|
||||
import { getKlavisServerByServerIdentifier, getLobehubSkillProviderById } from '@lobechat/const';
|
||||
import { type RenderDisplayControl, type ToolManifest } from '@lobechat/types';
|
||||
|
||||
|
|
@ -80,12 +81,15 @@ const isToolHasUI = (id: string) => (s: ToolStoreState) => {
|
|||
const getRenderDisplayControl =
|
||||
(identifier: string, apiName: string) =>
|
||||
(s: ToolStoreState): RenderDisplayControl => {
|
||||
// Only builtin tools support renderDisplayControl
|
||||
const builtinTool = s.builtinTools.find((t) => t.identifier === identifier);
|
||||
if (!builtinTool) return 'collapsed';
|
||||
const manifestControl = builtinTool?.manifest.api.find(
|
||||
(a) => a.name === apiName,
|
||||
)?.renderDisplayControl;
|
||||
if (manifestControl) return manifestControl;
|
||||
|
||||
const api = builtinTool.manifest.api.find((a) => a.name === apiName);
|
||||
return api?.renderDisplayControl ?? 'collapsed';
|
||||
// Fallback for packages that don't ship a LobeChat manifest (e.g. Claude Code —
|
||||
// its tools come from Anthropic tool_use blocks at runtime).
|
||||
return getBuiltinRenderDisplayControl(identifier, apiName) ?? 'collapsed';
|
||||
};
|
||||
|
||||
export interface AvailableToolForDiscovery {
|
||||
|
|
|
|||
Loading…
Reference in a new issue