diff --git a/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts b/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts index f749df211e..9d5f2f260b 100644 --- a/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts +++ b/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts @@ -292,8 +292,15 @@ export default class HeterogeneousAgentCtr extends ControllerModule { logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`); + // `detached: true` on Unix puts the child in a new process group so we + // can SIGINT/SIGKILL the whole tree (claude + any tool subprocesses) + // via `process.kill(-pid, sig)` on cancel. Without this, SIGINT to just + // the claude binary can leave bash/grep/etc. tool children running and + // the CLI hung waiting on them. Windows has different semantics — use + // taskkill /T /F there; no detached flag needed. const proc = spawn(session.command, cliArgs, { cwd, + detached: process.platform !== 'win32', env: { ...process.env, ...session.env }, stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'], }); @@ -385,14 +392,58 @@ export default class HeterogeneousAgentCtr extends ControllerModule { } /** - * Cancel an ongoing session. + * Signal the whole process tree spawned by this session. + * + * On Unix the child was spawned with `detached: true`, so negating the pid + * signals the process group — reaching tool subprocesses (bash, grep, etc.) + * that would otherwise orphan after a parent-only kill. Falls back to the + * direct signal if the group kill raises (ESRCH when the leader is already + * gone). On Windows we shell out to `taskkill /T /F` which walks the tree. + */ + private killProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void { + if (!proc.pid || proc.killed) return; + + if (process.platform === 'win32') { + try { + spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore' }); + } catch (err) { + logger.warn('taskkill failed:', err); + } + return; + } + + try { + process.kill(-proc.pid, signal); + } catch { + try { + proc.kill(signal); + } catch { + // already exited + } + } + } + + /** + * Cancel an ongoing session: SIGINT the CC tree, escalate to SIGKILL after + * 2s if the CLI hasn't exited (some tool calls swallow SIGINT). The + * `exit` handler on the spawned proc broadcasts completion and clears + * `session.process`, so the escalation is a no-op when the graceful path + * already landed. */ @IpcMethod() async cancelSession(params: CancelSessionParams): Promise { const session = this.sessions.get(params.sessionId); - if (session?.process) { - session.process.kill('SIGINT'); - } + if (!session?.process || session.process.killed) return; + + const proc = session.process; + this.killProcessTree(proc, 'SIGINT'); + + setTimeout(() => { + if (session.process === proc && !proc.killed) { + logger.warn('Session did not exit after SIGINT, escalating to SIGKILL:', params.sessionId); + this.killProcessTree(proc, 'SIGKILL'); + } + }, 2000); } /** @@ -404,10 +455,11 @@ export default class HeterogeneousAgentCtr extends ControllerModule { if (!session) return; if (session.process && !session.process.killed) { - session.process.kill('SIGTERM'); + const proc = session.process; + this.killProcessTree(proc, 'SIGTERM'); setTimeout(() => { - if (session.process && !session.process.killed) { - session.process.kill('SIGKILL'); + if (session.process === proc && !proc.killed) { + this.killProcessTree(proc, 'SIGKILL'); } }, 3000); } @@ -427,7 +479,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule { electronApp.on('before-quit', () => { for (const [, session] of this.sessions) { if (session.process && !session.process.killed) { - session.process.kill('SIGTERM'); + this.killProcessTree(session.process, 'SIGTERM'); } } this.sessions.clear(); diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index 258c5c3112..134fa7401a 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -18,7 +18,6 @@ "agentDefaultMessage": "Hi, I’m **{{name}}**. One sentence is enough.\n\nWant me to match your workflow better? Go to [Agent Settings]({{url}}) and fill in the Agent Profile (you can edit it anytime).", "agentDefaultMessageWithSystemRole": "Hi, I’m **{{name}}**. One sentence is enough—you're in control.", "agentDefaultMessageWithoutEdit": "Hi, I’m **{{name}}**. One sentence is enough—you're in control.", - "agentSidebar.externalTag": "External", "agents": "Agents", "artifact.generating": "Generating", "artifact.inThread": "Cannot view in subtopic, please switch to the main conversation area to open", @@ -124,7 +123,13 @@ "groupWizard.searchTemplates": "Search templates...", "groupWizard.title": "Create Group", "groupWizard.useTemplate": "Use Template", + "heteroAgent.fullAccess.label": "Full access", + "heteroAgent.fullAccess.tooltip": "Claude Code runs locally with full read/write access to the working directory. Switching permission modes is not available yet.", "heteroAgent.resumeReset.cwdChanged": "Working directory changed. Previous Claude Code session can only be resumed from its original directory, so a new conversation has started.", + "heteroAgent.switchCwd.cancel": "Cancel", + "heteroAgent.switchCwd.content": "Claude Code sessions are pinned to a working directory. Switching will start a new session for this topic — chat messages stay, but the previous session context cannot be resumed.", + "heteroAgent.switchCwd.ok": "Switch and start new session", + "heteroAgent.switchCwd.title": "Switch working directory?", "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", diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index bb1e470d0d..14ef0a1cfc 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -18,7 +18,6 @@ "agentDefaultMessage": "你好,我是 **{{name}}**。从一句话开始就行。\n\n想让我更贴近你的工作方式:去 [助理设置]({{url}}) 补充助理档案(随时可改)", "agentDefaultMessageWithSystemRole": "你好,我是 **{{name}}**。从一句话开始就行——决定权在你", "agentDefaultMessageWithoutEdit": "你好,我是 **{{name}}**。从一句话开始就行——决定权在你", - "agentSidebar.externalTag": "外部", "agents": "助理", "artifact.generating": "生成中", "artifact.inThread": "子话题中暂不支持查看。请回到主对话区打开", @@ -124,7 +123,13 @@ "groupWizard.searchTemplates": "搜索模板…", "groupWizard.title": "创建群组", "groupWizard.useTemplate": "使用模板", + "heteroAgent.fullAccess.label": "完全访问权限", + "heteroAgent.fullAccess.tooltip": "Claude Code 在本地运行,对工作目录拥有完全的读写权限。当前暂不支持切换权限模式。", "heteroAgent.resumeReset.cwdChanged": "工作目录已切换,之前的 Claude Code 会话只能在原目录下继续,已开始新对话。", + "heteroAgent.switchCwd.cancel": "取消", + "heteroAgent.switchCwd.content": "Claude Code 会话会绑定到具体的工作目录。切换后将为当前话题开启一个新会话——消息记录会保留,但之前会话的上下文无法恢复。", + "heteroAgent.switchCwd.ok": "切换并开启新会话", + "heteroAgent.switchCwd.title": "切换工作目录?", "hideForYou": "私信内容已隐藏。可在设置中开启「显示私信内容」查看", "history.title": "助理将仅保留最近 {{count}} 条消息", "historyRange": "历史范围", diff --git a/packages/builtin-tool-claude-code/src/index.ts b/packages/builtin-tool-claude-code/src/index.ts index 67d81e7752..389c3ba352 100644 --- a/packages/builtin-tool-claude-code/src/index.ts +++ b/packages/builtin-tool-claude-code/src/index.ts @@ -1 +1,7 @@ -export { ClaudeCodeApiName, ClaudeCodeIdentifier } from './types'; +export { + ClaudeCodeApiName, + ClaudeCodeIdentifier, + type ClaudeCodeTodoItem, + type ClaudeCodeTodoStatus, + type TodoWriteArgs, +} from './types'; diff --git a/packages/heterogeneous-agents/package.json b/packages/heterogeneous-agents/package.json index 5a79d733df..be1331b22b 100644 --- a/packages/heterogeneous-agents/package.json +++ b/packages/heterogeneous-agents/package.json @@ -7,6 +7,9 @@ "test": "vitest", "test:coverage": "vitest --coverage --silent='passed-only'" }, + "dependencies": { + "@lobechat/builtin-tool-claude-code": "workspace:*" + }, "devDependencies": { "@lobechat/types": "workspace:*" } diff --git a/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts b/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts index c8657d32a6..0ec6aeb0af 100644 --- a/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts +++ b/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts @@ -183,6 +183,188 @@ describe('ClaudeCodeAdapter', () => { }); }); + describe('TodoWrite pluginState synthesis', () => { + const driveTodoWrite = (adapter: ClaudeCodeAdapter, input: unknown, toolId = 't1') => { + adapter.adapt({ subtype: 'init', type: 'system' }); + adapter.adapt({ + message: { + id: 'msg_1', + content: [{ id: toolId, input, name: 'TodoWrite', type: 'tool_use' }], + }, + type: 'assistant', + }); + const events = adapter.adapt({ + message: { + content: [ + { + content: 'Todos have been modified successfully', + tool_use_id: toolId, + type: 'tool_result', + }, + ], + role: 'user', + }, + type: 'user', + }); + const result = events.find((e) => e.type === 'tool_result'); + return result!.data.pluginState as + | { todos: { items: Array<{ status: string; text: string }>; updatedAt: string } } + | undefined; + }; + + it('maps pending/in_progress/completed to todo/processing/completed', () => { + const adapter = new ClaudeCodeAdapter(); + const pluginState = driveTodoWrite(adapter, { + todos: [ + { activeForm: 'Doing A', content: 'Do A', status: 'in_progress' }, + { activeForm: 'Doing B', content: 'Do B', status: 'pending' }, + { activeForm: 'Doing C', content: 'Do C', status: 'completed' }, + ], + }); + + expect(pluginState).toBeDefined(); + expect(pluginState!.todos.items).toEqual([ + { status: 'processing', text: 'Doing A' }, + { status: 'todo', text: 'Do B' }, + { status: 'completed', text: 'Do C' }, + ]); + expect(new Date(pluginState!.todos.updatedAt).toISOString()).toBe( + pluginState!.todos.updatedAt, + ); + }); + + it('falls back to content when activeForm is missing on in_progress item', () => { + const adapter = new ClaudeCodeAdapter(); + const pluginState = driveTodoWrite(adapter, { + todos: [{ activeForm: '', content: 'Do the thing', status: 'in_progress' }], + }); + expect(pluginState!.todos.items[0]).toEqual({ + status: 'processing', + text: 'Do the thing', + }); + }); + + it('does not set pluginState for non-TodoWrite tools', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + adapter.adapt({ + message: { + id: 'msg_1', + content: [{ id: 't1', input: { path: '/a' }, name: 'Read', type: 'tool_use' }], + }, + type: 'assistant', + }); + const events = adapter.adapt({ + message: { + content: [{ content: 'ok', tool_use_id: 't1', type: 'tool_result' }], + role: 'user', + }, + type: 'user', + }); + const result = events.find((e) => e.type === 'tool_result'); + expect(result!.data.pluginState).toBeUndefined(); + }); + + it('does NOT synthesize pluginState when tool_result is marked is_error', () => { + // Guard: a failed TodoWrite was never applied on CC's side; persisting + // a derived snapshot would let `selectTodosFromMessages` overwrite the + // live todo UI with changes that never actually happened. + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + adapter.adapt({ + message: { + id: 'msg_1', + content: [ + { + id: 't1', + input: { todos: [{ activeForm: 'A', content: 'a', status: 'pending' }] }, + name: 'TodoWrite', + type: 'tool_use', + }, + ], + }, + type: 'assistant', + }); + const events = adapter.adapt({ + message: { + content: [ + { + content: 'Invalid todos payload', + is_error: true, + tool_use_id: 't1', + type: 'tool_result', + }, + ], + role: 'user', + }, + type: 'user', + }); + const result = events.find((e) => e.type === 'tool_result'); + expect(result!.data.isError).toBe(true); + expect(result!.data.pluginState).toBeUndefined(); + + // Cache must still be drained — a later TodoWrite on a new id should + // synthesize only from its own args, not inherit the failed one. + adapter.adapt({ + message: { + id: 'msg_2', + content: [ + { + id: 't2', + input: { todos: [{ activeForm: 'B', content: 'b', status: 'completed' }] }, + name: 'TodoWrite', + type: 'tool_use', + }, + ], + }, + type: 'assistant', + }); + const next = adapter.adapt({ + message: { + content: [{ content: 'ok', tool_use_id: 't2', type: 'tool_result' }], + role: 'user', + }, + type: 'user', + }); + const nextState = next.find((e) => e.type === 'tool_result')!.data.pluginState; + expect(nextState.todos.items).toEqual([{ status: 'completed', text: 'b' }]); + }); + + it('drains the cached input so a repeat tool_use id gets a fresh synthesis', () => { + const adapter = new ClaudeCodeAdapter(); + const first = driveTodoWrite(adapter, { + todos: [{ activeForm: 'A', content: 'a', status: 'pending' }], + }); + expect(first!.todos.items).toHaveLength(1); + + // Second TodoWrite on a new tool_use id — should resynthesize from its + // own args, not leak from the prior cache. + adapter.adapt({ + message: { + id: 'msg_2', + content: [ + { + id: 't2', + input: { todos: [{ activeForm: 'B', content: 'b', status: 'completed' }] }, + name: 'TodoWrite', + type: 'tool_use', + }, + ], + }, + type: 'assistant', + }); + const events = adapter.adapt({ + message: { + content: [{ content: 'ok', tool_use_id: 't2', type: 'tool_result' }], + role: 'user', + }, + type: 'user', + }); + const second = events.find((e) => e.type === 'tool_result')!.data.pluginState; + expect(second.todos.items).toEqual([{ status: 'completed', text: 'b' }]); + }); + }); + describe('multi-step execution (message.id boundary)', () => { it('does NOT emit step boundary for the first assistant after init', () => { const adapter = new ClaudeCodeAdapter(); diff --git a/packages/heterogeneous-agents/src/adapters/claudeCode.ts b/packages/heterogeneous-agents/src/adapters/claudeCode.ts index 209c79c1e1..41886f88c8 100644 --- a/packages/heterogeneous-agents/src/adapters/claudeCode.ts +++ b/packages/heterogeneous-agents/src/adapters/claudeCode.ts @@ -34,6 +34,12 @@ * - `tool_result` blocks are in `type: 'user'` events, not assistant events */ +import { + ClaudeCodeApiName, + type ClaudeCodeTodoItem, + type TodoWriteArgs, +} from '@lobechat/builtin-tool-claude-code'; + import type { AgentCLIPreset, AgentEventAdapter, @@ -44,6 +50,39 @@ import type { UsageData, } from '../types'; +/** + * CC's TodoWrite is a declarative state-write tool: its `tool_use.input` IS + * the target todos list, and the `tool_result` content is just a confirmation + * string. Translating the input into the shared `StepContextTodos` shape lets + * the Gateway/ACP-aligned `pluginState.todos` contract light up the + * TodoProgress card without any CC-specific knowledge leaking into selectors + * or executors. + * + * Word mapping: CC `pending|in_progress|completed` → shared `todo|processing|completed`. + * Text field: use `activeForm` while in progress (present-continuous is what + * the header surfaces), fall back to `content` for every other state. + */ +const synthesizeTodoWritePluginState = ( + args: TodoWriteArgs, +): { + todos: { + items: Array<{ status: 'todo' | 'processing' | 'completed'; text: string }>; + updatedAt: string; + }; +} => { + const items = (args.todos || []).map((todo: ClaudeCodeTodoItem) => { + const status = + todo.status === 'in_progress' + ? 'processing' + : todo.status === 'pending' + ? 'todo' + : 'completed'; + const text = todo.status === 'in_progress' ? todo.activeForm || todo.content : todo.content; + return { status, text } as const; + }); + return { todos: { items, updatedAt: new Date().toISOString() } }; +}; + /** * Convert a raw Anthropic-shape usage object (per-turn or grand-total from * Claude Code's `result` event) into the provider-agnostic `UsageData` shape. @@ -127,6 +166,14 @@ export class ClaudeCodeAdapter implements AgentEventAdapter { * until the next `fetchAndReplaceMessages`. */ private toolCallsByMessageId = new Map(); + /** + * Cached TodoWrite inputs keyed by tool_use.id. Populated in `handleAssistant` + * when a TodoWrite tool_use block arrives and drained in `handleUser` at + * tool_result time so the synthesized pluginState can travel with the result + * event. Entries are deleted immediately after emit to keep long sessions + * bounded. + */ + private todoWriteInputs = new Map(); adapt(raw: any): HeterogeneousAgentEvent[] { if (!raw || typeof raw !== 'object') return []; @@ -219,6 +266,9 @@ export class ClaudeCodeAdapter implements AgentEventAdapter { }; newToolCalls.push(toolPayload); this.pendingToolCalls.add(block.id); + if (block.name === ClaudeCodeApiName.TodoWrite && block.input) { + this.todoWriteInputs.set(block.id, block.input as TodoWriteArgs); + } break; } } @@ -282,11 +332,29 @@ export class ClaudeCodeAdapter implements AgentEventAdapter { .join('\n') : JSON.stringify(block.content || ''); + // Synthesize pluginState for tools whose input IS the target state. + // TodoWrite is currently the only such tool; future CC tools (Task, + // Skill activation, …) extend this same collection point. + // + // Guard on `is_error`: a failed TodoWrite means the snapshot was never + // applied on CC's side, so we must not persist it here either. Since + // `selectTodosFromMessages` picks the latest `pluginState.todos` from + // any producer, leaking a failed write would overwrite the live todo + // UI with changes that never actually happened. Drain the cache either + // way so a retry with a fresh tool_use id doesn't inherit stale args. + const cachedTodoArgs = this.todoWriteInputs.get(toolCallId); + if (cachedTodoArgs) this.todoWriteInputs.delete(toolCallId); + const pluginState = + cachedTodoArgs && !block.is_error + ? synthesizeTodoWritePluginState(cachedTodoArgs) + : undefined; + // Emit tool_result for executor to persist content to tool message events.push( this.makeEvent('tool_result', { content: resultContent, isError: !!block.is_error, + pluginState, toolCallId, } satisfies ToolResultData), ); diff --git a/packages/heterogeneous-agents/src/types.ts b/packages/heterogeneous-agents/src/types.ts index 98243f531a..1e02f27a05 100644 --- a/packages/heterogeneous-agents/src/types.ts +++ b/packages/heterogeneous-agents/src/types.ts @@ -65,6 +65,13 @@ export interface ToolEndData { export interface ToolResultData { content: string; isError?: boolean; + /** + * Normalized result-domain state for this tool call. Adapters may synthesize + * this for tools whose tool_use input *is* the target state (e.g. CC's + * TodoWrite) so consumers can render derived UI from a single message shape, + * without each consumer re-parsing tool args. + */ + pluginState?: Record; toolCallId: string; } diff --git a/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx b/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx index 709cab9cf5..f3e9b95bba 100644 --- a/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx +++ b/src/features/ChatInput/RuntimeConfig/WorkingDirectory.tsx @@ -1,6 +1,7 @@ import { isDesktop } from '@lobechat/const'; import { Github } from '@lobehub/icons'; import { Flexbox, Icon } from '@lobehub/ui'; +import { confirmModal } from '@lobehub/ui/base-ui'; import { createStaticStyles, cssVar } from 'antd-style'; import { CheckIcon, FolderIcon, FolderOpenIcon, GitBranchIcon, XIcon } from 'lucide-react'; import { memo, type ReactNode, useCallback, useMemo, useState } from 'react'; @@ -108,7 +109,7 @@ interface WorkingDirectoryContentProps { } const WorkingDirectoryContent = memo(({ agentId, onClose }) => { - const { t } = useTranslation('plugin'); + const { t } = useTranslation(['plugin', 'chat']); const agentWorkingDirectory = useAgentStore((s) => agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s), @@ -116,7 +117,12 @@ const WorkingDirectoryContent = memo(({ agentId, o const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory); const effectiveDir = topicWorkingDirectory || agentWorkingDirectory; + const activeTopicId = useChatStore((s) => s.activeTopicId); + const activeTopic = useChatStore((s) => + s.activeTopicId ? topicSelectors.getTopicById(s.activeTopicId)(s) : undefined, + ); const updateAgentRuntimeEnvConfig = useAgentStore((s) => s.updateAgentRuntimeEnvConfigById); + const updateTopicMetadata = useChatStore((s) => s.updateTopicMetadata); const [recentDirs, setRecentDirs] = useState(getRecentDirs); @@ -130,11 +136,50 @@ const WorkingDirectoryContent = memo(({ agentId, o const selectDir = useCallback( async (entry: RecentDirEntry) => { - await updateAgentRuntimeEnvConfig(agentId, { workingDirectory: entry.path }); - setRecentDirs(addRecentDir(entry)); - onClose?.(); + const newPath = entry.path; + // Scope of the write: once a topic is active, changing cwd updates the + // topic's own binding (each topic is a CC session pinned to a dir). + // Only when there's no topic yet (blank conversation) do we touch the + // agent-level default so the next new topic inherits it. + const commit = async () => { + if (activeTopicId) { + await updateTopicMetadata(activeTopicId, { workingDirectory: newPath }); + } else { + await updateAgentRuntimeEnvConfig(agentId, { workingDirectory: newPath }); + } + setRecentDirs(addRecentDir(entry)); + onClose?.(); + }; + + // CC sessions are pinned per-cwd under `~/.claude/projects//`. + // Changing the topic's cwd makes `--resume` fail, so we warn before the + // implicit session reset. + const priorSessionId = activeTopic?.metadata?.heteroSessionId; + const priorCwd = activeTopic?.metadata?.workingDirectory; + const wouldResetSession = !!priorSessionId && !!priorCwd && priorCwd !== newPath; + + if (wouldResetSession) { + confirmModal({ + cancelText: t('heteroAgent.switchCwd.cancel', { ns: 'chat' }), + content: t('heteroAgent.switchCwd.content', { ns: 'chat' }), + okText: t('heteroAgent.switchCwd.ok', { ns: 'chat' }), + onOk: commit, + title: t('heteroAgent.switchCwd.title', { ns: 'chat' }), + }); + return; + } + + await commit(); }, - [agentId, updateAgentRuntimeEnvConfig, onClose], + [ + activeTopicId, + activeTopic, + agentId, + t, + updateAgentRuntimeEnvConfig, + updateTopicMetadata, + onClose, + ], ); const handleChooseFolder = useCallback(async () => { diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts index 1187b6f306..1f747f388f 100644 --- a/src/locales/default/chat.ts +++ b/src/locales/default/chat.ts @@ -27,12 +27,6 @@ export default { 'agentDefaultMessageWithoutEdit': "Hi, I’m **{{name}}**. One sentence is enough—you're in control.", 'agents': 'Agents', - /** - * Sidebar tag for agents driven by an external CLI runtime (Claude Code, etc.). - * Deliberately separate from `group.profile.external` so it can evolve - * independently (e.g. swap to "Claude Code" per provider later). - */ - 'agentSidebar.externalTag': 'External', 'artifact.generating': 'Generating', 'artifact.inThread': 'Cannot view in subtopic, please switch to the main conversation area to open', @@ -147,8 +141,16 @@ export default { 'groupWizard.searchTemplates': 'Search templates...', 'groupWizard.title': 'Create Group', 'groupWizard.useTemplate': 'Use Template', + 'heteroAgent.fullAccess.label': 'Full access', + 'heteroAgent.fullAccess.tooltip': + 'Claude Code runs locally with full read/write access to the working directory. Switching permission modes is not available yet.', 'heteroAgent.resumeReset.cwdChanged': 'Working directory changed. Previous Claude Code session can only be resumed from its original directory, so a new conversation has started.', + 'heteroAgent.switchCwd.cancel': 'Cancel', + 'heteroAgent.switchCwd.content': + 'Claude Code sessions are pinned to a working directory. Switching will start a new session for this topic — chat messages stay, but the previous session context cannot be resumed.', + 'heteroAgent.switchCwd.ok': 'Switch and start new session', + 'heteroAgent.switchCwd.title': 'Switch working directory?', '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.', diff --git a/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx b/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx index afe2b67f15..74955526b2 100644 --- a/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx +++ b/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx @@ -3,7 +3,13 @@ import { Github } from '@lobehub/icons'; import { Flexbox, Icon, Popover, Skeleton, Tooltip } from '@lobehub/ui'; import { createStaticStyles, cssVar } from 'antd-style'; -import { ChevronDownIcon, FolderIcon, GitBranchIcon, SquircleDashed } from 'lucide-react'; +import { + ChevronDownIcon, + CircleAlertIcon, + FolderIcon, + GitBranchIcon, + SquircleDashed, +} from 'lucide-react'; import { memo, type ReactNode, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -18,7 +24,6 @@ import { topicSelectors } from '@/store/chat/selectors'; const styles = createStaticStyles(({ css }) => ({ bar: css` - gap: 4px; padding-block: 0; padding-inline: 4px; `, @@ -42,10 +47,24 @@ const styles = createStaticStyles(({ css }) => ({ background: ${cssVar.colorFillTertiary}; } `, + fullAccess: css` + display: flex; + gap: 6px; + align-items: center; + + padding-block: 2px; + padding-inline: 6px; + border-radius: 4px; + + font-size: 12px; + font-weight: 500; + color: ${cssVar.colorWarning}; + `, })); const WorkingDirectoryBar = memo(() => { const { t } = useTranslation('plugin'); + const { t: tChat } = useTranslation('chat'); const agentId = useAgentId(); const [open, setOpen] = useState(false); @@ -67,8 +86,9 @@ const WorkingDirectoryBar = memo(() => { if (!agentId || isLoading) { return ( - + + ); } @@ -85,29 +105,41 @@ const WorkingDirectoryBar = memo(() => { ); + const fullAccessBadge = ( +
+ + {tChat('heteroAgent.fullAccess.label')} +
+ ); + return ( - - setOpen(false)} />} - open={open} - placement="bottomLeft" - styles={{ content: { padding: 4 } }} - trigger="click" - onOpenChange={setOpen} - > -
- {open ? ( - dirButton - ) : ( - - {dirButton} - - )} -
-
- {effectiveWorkingDirectory && repoType && ( - - )} + + + setOpen(false)} />} + open={open} + placement="bottomLeft" + styles={{ content: { padding: 4 } }} + trigger="click" + onOpenChange={setOpen} + > +
+ {open ? ( + dirButton + ) : ( + + {dirButton} + + )} +
+
+ {effectiveWorkingDirectory && repoType && ( + + )} +
+ {fullAccessBadge}
); }); diff --git a/src/routes/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx b/src/routes/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx index e8d5723bc3..db41aebef6 100644 --- a/src/routes/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx +++ b/src/routes/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx @@ -21,6 +21,11 @@ import Actions from '../Item/Actions'; import Avatar from './Avatar'; import { useAgentDropdownMenu } from './useDropdownMenu'; +const HETEROGENEOUS_TYPE_LABELS: Record = { + 'claude-code': 'Claude Code', + 'codex': 'Codex', +}; + const styles = createStaticStyles(({ css, cssVar }) => ({ badge: css` pointer-events: none; @@ -95,15 +100,19 @@ const AgentItem = memo(({ item, style, className }) => { // Get display title with fallback const displayTitle = title || t('untitledAgent'); - // Heterogeneous agents (Claude Code, etc.) get an "External" tag so they - // stand out in the sidebar — mirrors the group-member pattern. - const titleNode = heterogeneousType ? ( + // Heterogeneous agents (Claude Code, Codex, …) show their runtime as a tag + // so they stand out from built-in agents in the sidebar. + const heterogeneousLabel = heterogeneousType + ? (HETEROGENEOUS_TYPE_LABELS[heterogeneousType] ?? heterogeneousType) + : null; + + const titleNode = heterogeneousLabel ? ( {displayTitle} - {t('agentSidebar.externalTag')} + {heterogeneousLabel} ) : ( diff --git a/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts b/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts index 800e74c599..dff0796c83 100644 --- a/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +++ b/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts @@ -351,8 +351,17 @@ export class ConversationLifecycleActionImpl { // creation time. Otherwise the row stays NULL until the post-execution // metadata write — which never lands on cancel/error and meanwhile // makes By-Project grouping miss the topic and `--resume` unsafe. - const workingDirectory = + // + // Priority: topic-level cwd (once a topic is bound to a project) wins + // over the agent-level default. Without this, a topic pinned to dir A + // would silently execute under the agent's current default dir B and + // lose resume. + const existingTopic = operationContext.topicId + ? topicSelectors.getTopicById(operationContext.topicId)(this.#get()) + : undefined; + const agentWorkingDirectory = agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(getAgentStoreState()); + const workingDirectory = existingTopic?.metadata?.workingDirectory || agentWorkingDirectory; // Persist messages to DB first (same as client mode) let heteroData: SendMessageServerResponse | undefined; @@ -431,6 +440,13 @@ export class ConversationLifecycleActionImpl { // Complete sendMessage operation, start ACP execution as child operation this.#get().completeOperation(operationId); + // Clear editor temp state — the user's message is already persisted, so + // a later Stop click must NOT restore it into the input (would feel like + // the app re-sent the message). Client/Gateway paths clear this at + // line 684-686 after `sendMessageInServer` resolves, but the hetero + // branch returns early (line 498) and never reaches that clear. + this.#get().updateOperationMetadata(operationId, { inputEditorTempState: null }); + if (heteroData.topicId) this.#get().internal_updateTopicLoading(heteroData.topicId, true); // Start heterogeneous agent execution diff --git a/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts b/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts index fc6904c3bd..e0a5368f9c 100644 --- a/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts +++ b/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts @@ -223,6 +223,11 @@ const persistNewToolCalls = async ( /** * Update a tool message's content in DB when tool_result arrives. + * + * `pluginState` (when provided by the adapter) is written in the same request + * as `content` so downstream consumers observe a single atomic update — + * critical for `selectTodosFromMessages` which reads both role=tool and + * `pluginState.todos` in one pass. */ const persistToolResult = async ( toolCallId: string, @@ -230,6 +235,7 @@ const persistToolResult = async ( isError: boolean, state: ToolPersistenceState, context: ConversationContext, + pluginState?: Record, ) => { const toolMsgId = state.toolMsgIdByCallId.get(toolCallId); if (!toolMsgId) { @@ -243,6 +249,7 @@ const persistToolResult = async ( { content, pluginError: isError ? { message: content } : undefined, + pluginState, }, { agentId: context.agentId, @@ -377,13 +384,14 @@ export const executeHeterogeneousAgent = async ( for (const event of events) { // ─── tool_result: update tool message content in DB (ACP-only) ─── if (event.type === 'tool_result') { - const { content, isError, toolCallId } = event.data as { + const { content, isError, pluginState, toolCallId } = event.data as { content: string; isError?: boolean; + pluginState?: Record; toolCallId: string; }; persistQueue = persistQueue.then(() => - persistToolResult(toolCallId, content, !!isError, toolState, context), + persistToolResult(toolCallId, content, !!isError, toolState, context, pluginState), ); // Don't forward — the tool_end that follows triggers fetchAndReplaceMessages // which reads the updated content from DB. diff --git a/src/store/chat/slices/message/selectors/dbMessage.test.ts b/src/store/chat/slices/message/selectors/dbMessage.test.ts index 3bf9339cb6..818747f84c 100644 --- a/src/store/chat/slices/message/selectors/dbMessage.test.ts +++ b/src/store/chat/slices/message/selectors/dbMessage.test.ts @@ -170,6 +170,71 @@ describe('selectTodosFromMessages', () => { expect(new Date(result!.updatedAt).toISOString()).toBe(result!.updatedAt); }); + it('should pick up pluginState.todos from a non-GTD tool message (CC TodoWrite)', () => { + // Heterogeneous-agent tools (Claude Code TodoWrite, future ACP/Codex + // equivalents) synthesize `pluginState.todos` with identifier + // 'claude-code'. The selector is a shared contract on `pluginState.todos` + // — it must not filter by plugin.identifier. + const messages: UIChatMessage[] = [ + { + id: 'msg-1', + role: 'tool', + content: 'Todos have been modified successfully', + plugin: { + identifier: 'claude-code', + apiName: 'TodoWrite', + arguments: '{}', + }, + pluginState: { + todos: { + items: [ + { text: 'Investigating the bug', status: 'processing' }, + { text: 'Write a test', status: 'todo' }, + ], + updatedAt: '2026-04-19T00:00:00.000Z', + }, + }, + } as unknown as UIChatMessage, + ]; + + const result = selectTodosFromMessages(messages); + + expect(result).toBeDefined(); + expect(result?.items).toHaveLength(2); + expect(result?.items[0].status).toBe('processing'); + expect(result?.updatedAt).toBe('2026-04-19T00:00:00.000Z'); + }); + + it('should prefer the most recent pluginState.todos across producers', () => { + // GTD wrote first, then CC TodoWrite wrote later — the latest producer + // wins regardless of identifier. + const messages: UIChatMessage[] = [ + createGTDToolMessage({ + items: [{ text: 'old gtd task', status: 'todo' }], + updatedAt: '2026-04-01T00:00:00.000Z', + }), + { + id: 'msg-2', + role: 'tool', + content: 'Todos have been modified successfully', + plugin: { + identifier: 'claude-code', + apiName: 'TodoWrite', + arguments: '{}', + }, + pluginState: { + todos: { + items: [{ text: 'cc task', status: 'processing' }], + updatedAt: '2026-04-19T00:00:00.000Z', + }, + }, + } as unknown as UIChatMessage, + ]; + + const result = selectTodosFromMessages(messages); + expect(result?.items[0].text).toBe('cc task'); + }); + it('should handle legacy array format for todos', () => { const messages: UIChatMessage[] = [ { diff --git a/src/store/chat/slices/message/selectors/dbMessage.ts b/src/store/chat/slices/message/selectors/dbMessage.ts index 2c2386de44..6b90baa508 100644 --- a/src/store/chat/slices/message/selectors/dbMessage.ts +++ b/src/store/chat/slices/message/selectors/dbMessage.ts @@ -1,5 +1,4 @@ import { LobeActivatorIdentifier } from '@lobechat/builtin-tool-activator'; -import { GTDIdentifier } from '@lobechat/builtin-tool-gtd'; import { SkillsIdentifier } from '@lobechat/builtin-tool-skills'; import { type StepActivatedSkill, @@ -234,8 +233,13 @@ export const selectActivatedSkillsFromMessages = ( /** * Select the latest todos state from messages array * - * Searches messages in reverse order to find the most recent GTD tool message - * that contains todos state. + * Searches messages in reverse order to find the most recent tool message + * that carries a `pluginState.todos` payload — regardless of which tool + * produced it. `pluginState.todos` is treated as a shared contract: GTD + * writes it via its client state mutation, and heterogeneous agent adapters + * (Claude Code TodoWrite, future ACP/Codex equivalents) synthesize it onto + * the tool_result event. Any new producer that honors the shape gets picked + * up automatically. * * This is a pure function that can be used for both: * - UI display (showing current todos) @@ -251,8 +255,7 @@ export const selectTodosFromMessages = ( for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; - // Check if this is a GTD tool message with todos state - if (msg.role === 'tool' && msg.plugin?.identifier === GTDIdentifier && msg.pluginState?.todos) { + if (msg.role === 'tool' && msg.pluginState?.todos) { const todos = msg.pluginState.todos as { items?: unknown[]; updatedAt?: string }; // Handle the todos structure: { items: TodoItem[], updatedAt: string } diff --git a/src/store/electron/actions/__tests__/tabPages.test.ts b/src/store/electron/actions/__tests__/tabPages.test.ts new file mode 100644 index 0000000000..30cd99bdf2 --- /dev/null +++ b/src/store/electron/actions/__tests__/tabPages.test.ts @@ -0,0 +1,154 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type PageReference } from '@/features/Electron/titlebar/RecentlyViewed/types'; +import { useElectronStore } from '@/store/electron'; +import { initialState } from '@/store/electron/initialState'; + +const buildAgentTab = (agentId = 'abc123'): PageReference => ({ + cached: { + avatar: 'avatar.png', + backgroundColor: '#fff', + title: 'Claude Code', + }, + id: `agent:${agentId}`, + lastVisited: 1, + params: { agentId }, + type: 'agent', +}); + +const buildHomeReference = (): PageReference => ({ + id: 'home', + lastVisited: Date.now(), + params: {}, + type: 'home', +}); + +describe('tabPages actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + useElectronStore.setState({ ...initialState, activeTabId: null, tabs: [] }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('updateTab', () => { + it('drops stale cached data when the tab switches to a different page type', () => { + const { result } = renderHook(() => useElectronStore()); + const agentTab = buildAgentTab(); + + act(() => { + useElectronStore.setState({ activeTabId: agentTab.id, tabs: [agentTab] }); + }); + + act(() => { + // Simulate navigating from an agent page to the home page. `useTabNavigation` + // passes no cached data for home, so the previous agent's cached title + // must not leak through. + result.current.updateTab(agentTab.id, buildHomeReference(), undefined); + }); + + const updatedTab = result.current.tabs[0]; + expect(updatedTab.type).toBe('home'); + expect(updatedTab.id).toBe('home'); + expect(updatedTab.cached).toBeUndefined(); + expect(result.current.activeTabId).toBe('home'); + }); + + it('merges cached data when the tab type stays the same', () => { + const { result } = renderHook(() => useElectronStore()); + const agentTab = buildAgentTab('abc'); + + act(() => { + useElectronStore.setState({ activeTabId: agentTab.id, tabs: [agentTab] }); + }); + + act(() => { + result.current.updateTab( + agentTab.id, + { + id: 'agent:xyz', + lastVisited: Date.now(), + params: { agentId: 'xyz' }, + type: 'agent', + }, + { title: 'New Agent' }, + ); + }); + + const updatedTab = result.current.tabs[0]; + expect(updatedTab.id).toBe('agent:xyz'); + expect(updatedTab.cached).toEqual({ + avatar: 'avatar.png', + backgroundColor: '#fff', + title: 'New Agent', + }); + }); + + it('keeps previous cached data when same-type update passes undefined cached', () => { + const { result } = renderHook(() => useElectronStore()); + const agentTab = buildAgentTab('abc'); + + act(() => { + useElectronStore.setState({ activeTabId: agentTab.id, tabs: [agentTab] }); + }); + + act(() => { + result.current.updateTab( + agentTab.id, + { + id: 'agent:xyz', + lastVisited: Date.now(), + params: { agentId: 'xyz' }, + type: 'agent', + }, + undefined, + ); + }); + + expect(result.current.tabs[0].cached).toEqual(agentTab.cached); + }); + + it('overwrites cached data when switching to a different type, even if cached is provided', () => { + const { result } = renderHook(() => useElectronStore()); + const agentTab = buildAgentTab(); + + act(() => { + useElectronStore.setState({ activeTabId: agentTab.id, tabs: [agentTab] }); + }); + + act(() => { + result.current.updateTab( + agentTab.id, + { + id: 'group:g1', + lastVisited: Date.now(), + params: { groupId: 'g1' }, + type: 'group', + }, + { title: 'My Group' }, + ); + }); + + expect(result.current.tabs[0].cached).toEqual({ title: 'My Group' }); + }); + + it('does nothing when the tab id is not found', () => { + const { result } = renderHook(() => useElectronStore()); + const agentTab = buildAgentTab(); + + act(() => { + useElectronStore.setState({ activeTabId: agentTab.id, tabs: [agentTab] }); + }); + + act(() => { + result.current.updateTab('non-existent', buildHomeReference()); + }); + + expect(result.current.tabs).toEqual([agentTab]); + expect(result.current.activeTabId).toBe(agentTab.id); + }); + }); +}); diff --git a/src/store/electron/actions/tabPages.ts b/src/store/electron/actions/tabPages.ts index 52dae87693..525f7cee42 100644 --- a/src/store/electron/actions/tabPages.ts +++ b/src/store/electron/actions/tabPages.ts @@ -159,10 +159,16 @@ export class TabPagesActionImpl { const index = tabs.findIndex((t) => t.id === id); if (index < 0) return; + const prev = tabs[index]; + // When the page type changes (e.g. agent -> home), the previous cached + // data (title/avatar) belongs to a different page and must not bleed + // through — otherwise the tab keeps showing the old page's title. + const sameType = prev.type === reference.type; + const newTabs = [...tabs]; newTabs[index] = { ...reference, - cached: cached ? { ...newTabs[index].cached, ...cached } : newTabs[index].cached, + cached: sameType ? (cached ? { ...prev.cached, ...cached } : prev.cached) : cached, lastVisited: Date.now(), };