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:
Arvin Xu 2026-04-18 13:42:00 +08:00 committed by GitHub
parent a98d113a80
commit 13fe968480
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1304 additions and 184 deletions

View file

@ -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
*/

View file

@ -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 */
}
});
}

View file

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

View file

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

View file

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

View file

@ -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": "这个群组还没有成员。点击「+」邀请助理加入",

View file

@ -4,6 +4,8 @@
"actions.confirmRemoveAll": "您即将删除所有话题,此操作无法撤销。",
"actions.confirmRemoveTopic": "您即将删除此话题,此操作无法撤销。",
"actions.confirmRemoveUnstarred": "您即将删除未加星标的话题,此操作无法撤销。",
"actions.copyLink": "复制链接",
"actions.copyLinkSuccess": "链接已复制",
"actions.duplicate": "复制",
"actions.export": "导出话题",
"actions.favorite": "收藏",

View file

@ -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,
};

View file

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

View file

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

View file

@ -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',
};

View file

@ -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';

View file

@ -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';

View file

@ -1,3 +1,3 @@
export { ClaudeCodeApiName, ClaudeCodeIdentifier } from '../types';
export { ClaudeCodeInspectors } from './Inspector';
export { ClaudeCodeRenders } from './Render';
export { ClaudeCodeRenderDisplayControls, ClaudeCodeRenders } from './Render';

View file

@ -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;
}

View file

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

View 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];
};

View file

@ -81,3 +81,5 @@ export const getBuiltinRender = (
return undefined;
};
export { getBuiltinRenderDisplayControl } from './displayControls';

View file

@ -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:

View file

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

View file

@ -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:*"

View file

@ -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;
}

View file

@ -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[] = [];

View file

@ -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} />;
}

View file

@ -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>

View file

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

View file

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

View file

@ -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) {

View file

@ -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 };

View file

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

View file

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

View file

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

View file

@ -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,
]);
};

View file

@ -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(() => {

View file

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

View file

@ -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) => {

View file

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

View file

@ -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,
};

View file

@ -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,
});
});
});

View file

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

View file

@ -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
// ────────────────────────────────────────────────────

View 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,
};
};

View file

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

View file

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

View file

@ -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) {

View file

@ -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 {