From 13fe968480741a9c4e785e3aca8ffe5de9ac5578 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Sat, 18 Apr 2026 13:42:00 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20claude=20code=20intergratio?= =?UTF-8?q?n=20polish=20(#13942)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ๐Ÿ› fix(cc-resume): guard resume against cwd mismatch (LOBE-7336) Claude Code CLI stores sessions per-cwd under `~/.claude/projects//`, 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) * โœจ 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) * โœ… 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) * โœจ 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/main/controllers/NotificationCtr.ts | 22 +++ apps/desktop/src/main/core/browser/Browser.ts | 9 +- .../core/browser/__tests__/Browser.test.ts | 141 +++++++------- locales/en-US/chat.json | 3 +- locales/en-US/topic.json | 2 + locales/zh-CN/chat.json | 3 +- locales/zh-CN/topic.json | 2 + .../src/client/Inspector.tsx | 4 + .../src/client/Render/Skill/index.tsx | 55 ++++++ .../src/client/Render/TodoWrite/index.tsx | 178 ++++++++++++++++++ .../src/client/Render/index.ts | 17 ++ .../src/client/SkillInspector.tsx | 44 +++++ .../src/client/TodoWriteInspector.tsx | 127 +++++++++++++ .../src/client/index.ts | 2 +- .../builtin-tool-claude-code/src/types.ts | 30 +++ packages/builtin-tools/package.json | 1 + packages/builtin-tools/src/displayControls.ts | 20 ++ packages/builtin-tools/src/renders.ts | 2 + .../src/adapters/claudeCode.test.ts | 126 +++++++++++++ .../src/adapters/claudeCode.ts | 23 ++- packages/shared-tool-ui/package.json | 3 +- packages/types/src/topic/topic.ts | 8 +- .../ChatInput/ActionBar/Upload/ServerMode.tsx | 136 +++++++------ .../components/MessageContent.tsx | 1 + .../components/WorkflowCollapse.tsx | 9 +- src/locales/default/chat.ts | 4 +- src/locales/default/topic.ts | 2 + .../_layout/Sidebar/Topic/List/Item/index.tsx | 15 +- .../Topic/List/Item/useDropdownMenu.tsx | 24 ++- .../Conversation/Header/Tags/FolderTag.tsx | 79 ++++++++ .../Conversation/Header/Tags/index.tsx | 32 ++-- .../_layout/Sidebar/Topic/List/Item/index.tsx | 16 +- .../Topic/List/Item/useDropdownMenu.tsx | 21 ++- .../(main)/home/_layout/Body/Agent/index.tsx | 8 +- .../_layout/Header/components/AddButton.tsx | 9 +- .../home/_layout/hooks/useCreateMenuItems.tsx | 5 +- src/services/electron/desktopNotification.ts | 8 + .../agent/selectors/agentByIdSelectors.ts | 10 + .../aiChat/actions/__tests__/ccResume.test.ts | 95 ++++++++++ .../__tests__/gatewayEventHandler.test.ts | 4 + .../heterogeneousAgentExecutor.test.ts | 83 ++++++++ .../chat/slices/aiChat/actions/ccResume.ts | 37 ++++ .../aiChat/actions/conversationLifecycle.ts | 12 +- .../aiChat/actions/gatewayEventHandler.ts | 6 + .../actions/heterogeneousAgentExecutor.ts | 38 +++- src/store/tool/selectors/tool.ts | 12 +- 46 files changed, 1304 insertions(+), 184 deletions(-) create mode 100644 packages/builtin-tool-claude-code/src/client/Render/Skill/index.tsx create mode 100644 packages/builtin-tool-claude-code/src/client/Render/TodoWrite/index.tsx create mode 100644 packages/builtin-tool-claude-code/src/client/SkillInspector.tsx create mode 100644 packages/builtin-tool-claude-code/src/client/TodoWriteInspector.tsx create mode 100644 packages/builtin-tools/src/displayControls.ts create mode 100644 src/routes/(main)/agent/features/Conversation/Header/Tags/FolderTag.tsx create mode 100644 src/store/chat/slices/aiChat/actions/__tests__/ccResume.test.ts create mode 100644 src/store/chat/slices/aiChat/actions/ccResume.ts diff --git a/apps/desktop/src/main/controllers/NotificationCtr.ts b/apps/desktop/src/main/controllers/NotificationCtr.ts index e3a8bdfd7b..a189558f12 100644 --- a/apps/desktop/src/main/controllers/NotificationCtr.ts +++ b/apps/desktop/src/main/controllers/NotificationCtr.ts @@ -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 */ diff --git a/apps/desktop/src/main/core/browser/Browser.ts b/apps/desktop/src/main/core/browser/Browser.ts index a970cdec51..fdf4b322a9 100644 --- a/apps/desktop/src/main/core/browser/Browser.ts +++ b/apps/desktop/src/main/core/browser/Browser.ts @@ -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 */ + } }); } diff --git a/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts b/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts index ae221d70c2..7c1209a0b5 100644 --- a/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts +++ b/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts @@ -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, diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index 0d1bc6731f..148043bc3c 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -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.", diff --git a/locales/en-US/topic.json b/locales/en-US/topic.json index 7ce44730f4..f312ce091e 100644 --- a/locales/en-US/topic.json +++ b/locales/en-US/topic.json @@ -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", diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index fd8588e8b9..c76f7bef50 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -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": "่ฟ™ไธช็พค็ป„่ฟ˜ๆฒกๆœ‰ๆˆๅ‘˜ใ€‚็‚นๅ‡ปใ€Œ+ใ€้‚€่ฏทๅŠฉ็†ๅŠ ๅ…ฅ", diff --git a/locales/zh-CN/topic.json b/locales/zh-CN/topic.json index 5e0b12b414..434d7e299d 100644 --- a/locales/zh-CN/topic.json +++ b/locales/zh-CN/topic.json @@ -4,6 +4,8 @@ "actions.confirmRemoveAll": "ๆ‚จๅณๅฐ†ๅˆ ้™คๆ‰€ๆœ‰่ฏ้ข˜๏ผŒๆญคๆ“ไฝœๆ— ๆณ•ๆ’ค้”€ใ€‚", "actions.confirmRemoveTopic": "ๆ‚จๅณๅฐ†ๅˆ ้™คๆญค่ฏ้ข˜๏ผŒๆญคๆ“ไฝœๆ— ๆณ•ๆ’ค้”€ใ€‚", "actions.confirmRemoveUnstarred": "ๆ‚จๅณๅฐ†ๅˆ ้™คๆœชๅŠ ๆ˜Ÿๆ ‡็š„่ฏ้ข˜๏ผŒๆญคๆ“ไฝœๆ— ๆณ•ๆ’ค้”€ใ€‚", + "actions.copyLink": "ๅคๅˆถ้“พๆŽฅ", + "actions.copyLinkSuccess": "้“พๆŽฅๅทฒๅคๅˆถ", "actions.duplicate": "ๅคๅˆถ", "actions.export": "ๅฏผๅ‡บ่ฏ้ข˜", "actions.favorite": "ๆ”ถ่—", diff --git a/packages/builtin-tool-claude-code/src/client/Inspector.tsx b/packages/builtin-tool-claude-code/src/client/Inspector.tsx index 0d17bbff75..f1764d0615 100644 --- a/packages/builtin-tool-claude-code/src/client/Inspector.tsx +++ b/packages/builtin-tool-claude-code/src/client/Inspector.tsx @@ -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, }; diff --git a/packages/builtin-tool-claude-code/src/client/Render/Skill/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/Skill/index.tsx new file mode 100644 index 0000000000..21e2ef96c8 --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/Render/Skill/index.tsx @@ -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>(({ args, content }) => { + const skillName = args?.skill; + + return ( + + + + {skillName || 'Skill'} + + + {content && ( + + + {content} + + + )} + + ); +}); + +Skill.displayName = 'ClaudeCodeSkill'; + +export default Skill; diff --git a/packages/builtin-tool-claude-code/src/client/Render/TodoWrite/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/TodoWrite/index.tsx new file mode 100644 index 0000000000..a4e62451e0 --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/Render/TodoWrite/index.tsx @@ -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(({ item }) => { + const { status, content, activeForm } = item; + + if (status === 'in_progress') { + return ( +
+ + {activeForm || content} +
+ ); + } + + const isCompleted = status === 'completed'; + + return ( + + {content} + + ); +}); + +TodoRow.displayName = 'ClaudeCodeTodoRow'; + +interface TodoHeaderProps { + completed: number; + inProgress?: ClaudeCodeTodoItem; + total: number; +} + +const TodoHeader = memo(({ 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 ( +
+ + + {label} + + + {completed}/{total} + +
+ ); +}); + +TodoHeader.displayName = 'ClaudeCodeTodoHeader'; + +const TodoWrite = memo>(({ 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 ( + + + {todos.map((item, index) => ( + + ))} + + ); +}); + +TodoWrite.displayName = 'ClaudeCodeTodoWrite'; + +export default TodoWrite; diff --git a/packages/builtin-tool-claude-code/src/client/Render/index.ts b/packages/builtin-tool-claude-code/src/client/Render/index.ts index 3f1a8c6d9c..88dea99f23 100644 --- a/packages/builtin-tool-claude-code/src/client/Render/index.ts +++ b/packages/builtin-tool-claude-code/src/client/Render/index.ts @@ -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 = { + [ClaudeCodeApiName.TodoWrite]: 'expand', +}; diff --git a/packages/builtin-tool-claude-code/src/client/SkillInspector.tsx b/packages/builtin-tool-claude-code/src/client/SkillInspector.tsx new file mode 100644 index 0000000000..6d1da5eec2 --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/SkillInspector.tsx @@ -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>( + ({ 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
{label}
; + } + + return ( +
+ {label} + {skillName && ( + <> + : + {skillName} + + )} +
+ ); + }, +); + +SkillInspector.displayName = 'ClaudeCodeSkillInspector'; diff --git a/packages/builtin-tool-claude-code/src/client/TodoWriteInspector.tsx b/packages/builtin-tool-claude-code/src/client/TodoWriteInspector.tsx new file mode 100644 index 0000000000..8b77d89f1f --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/TodoWriteInspector.tsx @@ -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(({ 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 ( + + + + + ); +}); + +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>( + ({ 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
{label}
; + } + + return ( +
+ {stats.total > 0 && } + {label} + {summary && ( + <> + : + {summary} + + )} +
+ ); + }, +); + +TodoWriteInspector.displayName = 'ClaudeCodeTodoWriteInspector'; diff --git a/packages/builtin-tool-claude-code/src/client/index.ts b/packages/builtin-tool-claude-code/src/client/index.ts index a6525272ff..45ec0b4d43 100644 --- a/packages/builtin-tool-claude-code/src/client/index.ts +++ b/packages/builtin-tool-claude-code/src/client/index.ts @@ -1,3 +1,3 @@ export { ClaudeCodeApiName, ClaudeCodeIdentifier } from '../types'; export { ClaudeCodeInspectors } from './Inspector'; -export { ClaudeCodeRenders } from './Render'; +export { ClaudeCodeRenderDisplayControls, ClaudeCodeRenders } from './Render'; diff --git a/packages/builtin-tool-claude-code/src/types.ts b/packages/builtin-tool-claude-code/src/types.ts index e08b06156a..189a50cca2 100644 --- a/packages/builtin-tool-claude-code/src/types.ts +++ b/packages/builtin-tool-claude-code/src/types.ts @@ -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; +} diff --git a/packages/builtin-tools/package.json b/packages/builtin-tools/package.json index 7c09e8007b..2b6d62031f 100644 --- a/packages/builtin-tools/package.json +++ b/packages/builtin-tools/package.json @@ -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", diff --git a/packages/builtin-tools/src/displayControls.ts b/packages/builtin-tools/src/displayControls.ts new file mode 100644 index 0000000000..01593dd8fc --- /dev/null +++ b/packages/builtin-tools/src/displayControls.ts @@ -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> = { + [ClaudeCodeIdentifier]: ClaudeCodeRenderDisplayControls, +}; + +export const getBuiltinRenderDisplayControl = ( + identifier?: string, + apiName?: string, +): RenderDisplayControl | undefined => { + if (!identifier || !apiName) return undefined; + return BuiltinRenderDisplayControls[identifier]?.[apiName]; +}; diff --git a/packages/builtin-tools/src/renders.ts b/packages/builtin-tools/src/renders.ts index 7194001f0b..a4446e0cdd 100644 --- a/packages/builtin-tools/src/renders.ts +++ b/packages/builtin-tools/src/renders.ts @@ -81,3 +81,5 @@ export const getBuiltinRender = ( return undefined; }; + +export { getBuiltinRenderDisplayControl } from './displayControls'; diff --git a/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts b/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts index 8685767a37..4c3cc262b0 100644 --- a/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts +++ b/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts @@ -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: diff --git a/packages/heterogeneous-agents/src/adapters/claudeCode.ts b/packages/heterogeneous-agents/src/adapters/claudeCode.ts index db08cd7cc7..5483b4dc16 100644 --- a/packages/heterogeneous-agents/src/adapters/claudeCode.ts +++ b/packages/heterogeneous-agents/src/adapters/claudeCode.ts @@ -78,6 +78,14 @@ export class ClaudeCodeAdapter implements AgentEventAdapter { private messagesWithStreamedText = new Set(); /** message.ids whose thinking has already been streamed as deltas โ€” skip the full-block emission */ private messagesWithStreamedThinking = new Set(); + /** + * 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(); 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 })); } } diff --git a/packages/shared-tool-ui/package.json b/packages/shared-tool-ui/package.json index 9fbb26fcc7..36ea32a9d3 100644 --- a/packages/shared-tool-ui/package.json +++ b/packages/shared-tool-ui/package.json @@ -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:*" diff --git a/packages/types/src/topic/topic.ts b/packages/types/src/topic/topic.ts index a81334c714..ca610b3236 100644 --- a/packages/types/src/topic/topic.ts +++ b/packages/types/src/topic/topic.ts @@ -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 ` to continue the conversation. + * CC CLI stores sessions per-cwd under `~/.claude/projects//`, + * 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; } diff --git a/src/features/ChatInput/ActionBar/Upload/ServerMode.tsx b/src/features/ChatInput/ActionBar/Upload/ServerMode.tsx index 491fc87e57..177c0c2510 100644 --- a/src/features/ChatInput/ActionBar/Upload/ServerMode.tsx +++ b/src/features/ChatInput/ActionBar/Upload/ServerMode.tsx @@ -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(() => { ), }, - { - closeOnClick: false, - icon: FileUp, - key: 'upload-file', - label: ( - { - 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: ( + { + 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; - }} - > -
{t('upload.action.fileUpload')}
-
- ), - }, - { - closeOnClick: false, - icon: FolderUp, - key: 'upload-folder', - label: ( - { - if (!canUploadImage && (file.type.startsWith('image') || file.type.startsWith('video'))) - return false; + return false; + }} + > +
{t('upload.action.fileUpload')}
+
+ ), + }, + { + closeOnClick: false, + icon: FolderUp, + key: 'upload-folder', + label: ( + { + 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; - }} - > -
{t('upload.action.folderUpload')}
-
- ), - }, + return false; + }} + > +
{t('upload.action.folderUpload')}
+
+ ), + }, + ]), ]; const knowledgeItems: ItemType[] = []; diff --git a/src/features/Conversation/Messages/AssistantGroup/components/MessageContent.tsx b/src/features/Conversation/Messages/AssistantGroup/components/MessageContent.tsx index b3c27077d3..8b01e488ba 100644 --- a/src/features/Conversation/Messages/AssistantGroup/components/MessageContent.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/components/MessageContent.tsx @@ -28,6 +28,7 @@ const MessageContent = memo(({ content, hasTools, id }) => { if (!content && !hasTools) return ; if (content === LOADING_FLAT) { + if (hasTools) return null; return ; } diff --git a/src/features/Conversation/Messages/AssistantGroup/components/WorkflowCollapse.tsx b/src/features/Conversation/Messages/AssistantGroup/components/WorkflowCollapse.tsx index 618e2a917a..e13df2a315 100644 --- a/src/features/Conversation/Messages/AssistantGroup/components/WorkflowCollapse.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/components/WorkflowCollapse.tsx @@ -118,6 +118,7 @@ const WorkflowCollapse = memo( 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( 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( } }, [ committedProse, + expandedWorkingLabel, headlineState, pendingInterventionLabel, pendingInterventionPresent, showExpandedWorkingLabel, - workingLabel, ]); const streamingHeadline = useDebouncedHeadline( streamingHeadlineRaw, @@ -356,7 +359,7 @@ const WorkflowCollapse = memo( {showWorkingElapsed && ( - ({workingElapsedSeconds}s) + ({formatReasoningDuration(workingElapsedSeconds * TIME_MS_PER_SECOND)}) )} diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts index 5489eef5a9..2f2df6a01d 100644 --- a/src/locales/default/chat.ts +++ b/src/locales/default/chat.ts @@ -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.', diff --git a/src/locales/default/topic.ts b/src/locales/default/topic.ts index 00ac28a367..ff86b29ff6 100644 --- a/src/locales/default/topic.ts +++ b/src/locales/default/topic.ts @@ -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', diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx index facf671a57..ad457ab0e6 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx @@ -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(({ id, title, fav, active, threadId, meta return ( + isLoading ? ( + + ) : ( + + ) } title={ @@ -206,9 +209,13 @@ const TopicItem = memo(({ id, title, fav, active, threadId, meta contextMenuItems={dropdownMenu} disabled={editing} href={href} - loading={isLoading} title={title} icon={(() => { + if (isLoading) { + return ( + + ); + } if (metadata?.bot?.platform) { const ProviderIcon = getPlatformIcon(metadata.bot!.platform); if (ProviderIcon) { diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx index dc35ad9c3d..ec411b0c26 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx @@ -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: , + 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: , key: 'duplicate', @@ -119,6 +137,9 @@ export const useTopicItemDropdownMenu = ({ duplicateTopic(id); }, }, + { + type: 'divider' as const, + }, { icon: , key: 'share', @@ -159,6 +180,7 @@ export const useTopicItemDropdownMenu = ({ toggleEditing, t, modal, + message, handleOpenShareModal, ]); return { dropdownMenu }; diff --git a/src/routes/(main)/agent/features/Conversation/Header/Tags/FolderTag.tsx b/src/routes/(main)/agent/features/Conversation/Header/Tags/FolderTag.tsx new file mode 100644 index 0000000000..dc35c669a5 --- /dev/null +++ b/src/routes/(main)/agent/features/Conversation/Header/Tags/FolderTag.tsx @@ -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 ; + if (match?.repoType === 'git') return ; + return ; + }, [topicBoundDirectory]); + + if (!isDesktop || !topicBoundDirectory) return null; + + const displayName = topicBoundDirectory.split('/').findLast(Boolean) || topicBoundDirectory; + + const handleOpen = () => { + void localFileService.openLocalFolder({ isDirectory: true, path: topicBoundDirectory }); + }; + + return ( + +
+ {iconNode} + {displayName} +
+
+ ); +}); + +FolderTag.displayName = 'TopicFolderTag'; + +export default FolderTag; diff --git a/src/routes/(main)/agent/features/Conversation/Header/Tags/index.tsx b/src/routes/(main)/agent/features/Conversation/Header/Tags/index.tsx index f88554d1df..cfccb6b690 100644 --- a/src/routes/(main)/agent/features/Conversation/Header/Tags/index.tsx +++ b/src/routes/(main)/agent/features/Conversation/Header/Tags/index.tsx @@ -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 ( - - - {topicTitle} - + + {topicTitle && ( + + {topicTitle} + + )} + ); }); diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx index 7ce1c5d124..51375c4e85 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx @@ -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(({ id, title, fav, active, threadId }) => return ( + isLoading ? ( + + ) : ( + + ) } title={ @@ -204,10 +207,13 @@ const TopicItem = memo(({ id, title, fav, active, threadId }) => contextMenuItems={dropdownMenu} disabled={editing} href={!editing ? href : undefined} - loading={isLoading} title={title} icon={ - + isLoading ? ( + + ) : ( + + ) } slots={{ iconPostfix: unreadNode, diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx index 7ff54491e7..c6795694b4 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/useDropdownMenu.tsx @@ -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: , + 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: , @@ -128,5 +142,6 @@ export const useTopicItemDropdownMenu = ({ toggleEditing, t, modal, + message, ]); }; diff --git a/src/routes/(main)/home/_layout/Body/Agent/index.tsx b/src/routes/(main)/home/_layout/Body/Agent/index.tsx index b7962197c0..6119e51240 100644 --- a/src/routes/(main)/home/_layout/Body/Agent/index.tsx +++ b/src/routes/(main)/home/_layout/Body/Agent/index.tsx @@ -29,10 +29,12 @@ const Agent = memo(({ 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(() => { diff --git a/src/routes/(main)/home/_layout/Header/components/AddButton.tsx b/src/routes/(main)/home/_layout/Header/components/AddButton.tsx index 0e97252c33..5e14c53a59 100644 --- a/src/routes/(main)/home/_layout/Header/components/AddButton.tsx +++ b/src/routes/(main)/home/_layout/Header/components/AddButton.tsx @@ -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 ( diff --git a/src/routes/(main)/home/_layout/hooks/useCreateMenuItems.tsx b/src/routes/(main)/home/_layout/hooks/useCreateMenuItems.tsx index 729801c078..bd30261d6b 100644 --- a/src/routes/(main)/home/_layout/hooks/useCreateMenuItems.tsx +++ b/src/routes/(main)/home/_layout/hooks/useCreateMenuItems.tsx @@ -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: , key: 'newClaudeCodeAgent', label: t('newClaudeCodeAgent'), onClick: async (info) => { diff --git a/src/services/electron/desktopNotification.ts b/src/services/electron/desktopNotification.ts index 87fb71b9b9..7b438cca3f 100644 --- a/src/services/electron/desktopNotification.ts +++ b/src/services/electron/desktopNotification.ts @@ -27,6 +27,14 @@ export class DesktopNotificationService { async isMainWindowHidden(): Promise { 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 { + return ensureElectronIpc().notification.setBadgeCount(count); + } } export const desktopNotificationService = new DesktopNotificationService(); diff --git a/src/store/agent/selectors/agentByIdSelectors.ts b/src/store/agent/selectors/agentByIdSelectors.ts index f07a040b57..5da283603d 100644 --- a/src/store/agent/selectors/agentByIdSelectors.ts +++ b/src/store/agent/selectors/agentByIdSelectors.ts @@ -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, }; diff --git a/src/store/chat/slices/aiChat/actions/__tests__/ccResume.test.ts b/src/store/chat/slices/aiChat/actions/__tests__/ccResume.test.ts new file mode 100644 index 0000000000..375452abf7 --- /dev/null +++ b/src/store/chat/slices/aiChat/actions/__tests__/ccResume.test.ts @@ -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, + }); + }); +}); diff --git a/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts b/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts index 159d638539..ecf7b34cfa 100644 --- a/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts +++ b/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts @@ -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, replaceMessages: vi.fn(), }; } diff --git a/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts b/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts index 2c098c2ca8..7918b064ff 100644 --- a/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts +++ b/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts @@ -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 = + '{"description":"Get a Linear issue","name":"mcp__linear-server__get_issue","parameters":{}}'; + + 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 // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/src/store/chat/slices/aiChat/actions/ccResume.ts b/src/store/chat/slices/aiChat/actions/ccResume.ts new file mode 100644 index 0000000000..d50f2e5bce --- /dev/null +++ b/src/store/chat/slices/aiChat/actions/ccResume.ts @@ -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//`, 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, + }; +}; diff --git a/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts b/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts index 76d9c75853..62b748130f 100644 --- a/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +++ b/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts @@ -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, diff --git a/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts b/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts index 9aac728ba6..0b116c5615 100644 --- a/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts +++ b/src/store/chat/slices/aiChat/actions/gatewayEventHandler.ts @@ -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; diff --git a/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts b/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts index e0d3aa6c79..49fd68b037 100644 --- a/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts +++ b/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts @@ -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//`), + // 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) { diff --git a/src/store/tool/selectors/tool.ts b/src/store/tool/selectors/tool.ts index a75c945a9e..4ae2fcb05f 100644 --- a/src/store/tool/selectors/tool.ts +++ b/src/store/tool/selectors/tool.ts @@ -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 {