mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
✨ feat(hetero-agent): Claude Code runtime, cwd, and sidebar polish (#13970)
* ✨ feat(hetero-agent): synthesize pluginState.todos from CC TodoWrite Adapter now translates Claude Code's declarative TodoWrite tool_use input into the shared StepContextTodos shape and attaches it to tool_result. Selector drops the GTD identifier filter so any producer honoring pluginState.todos lights up the TodoProgress card. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 🐛 fix(hetero-agent): skip TodoWrite pluginState synthesis on error results A failed TodoWrite (is_error=true) means the snapshot was never applied on CC's side. Since selectTodosFromMessages now picks the latest pluginState.todos from any producer, leaking a failed-write snapshot could 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. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * 🐛 fix(hetero-agent): prefer topic-level cwd on send; route UI changes to active topic Topic-level workingDirectory now takes priority over agent-level on the send path, matching what the topic is actually pinned to. The UI picker writes to the active topic's metadata (not the agent default), and warns before switching when doing so would invalidate an existing CC session. * ✨ feat(tab): reset tab cache when page type changes to stop stale metadata bleed Switching a tab from one page type to another (e.g. agent → home) kept the previous page's cached title/avatar, so the new page rendered with the wrong header. Reset the cache on type change; preserve the merge only when the type stays the same. * 🐛 fix(hetero-agent): kill CC process tree on cancel so tool children exit SIGINT to just the claude binary was leaving bash/grep/etc. tool subprocesses running, which kept the CLI hung waiting on them. Spawn the child detached (Unix) so we can signal the whole group via process.kill(-pid, sig); use taskkill /T /F on Windows. Escalate SIGINT → SIGKILL after 2s for tool calls that swallow SIGINT, and do the same tree kill on disposeSession's SIGTERM path. * ✨ feat(hetero-agent): show "Full access" badge in CC working-directory bar Claude Code runs locally with full read/write on the working directory and permission mode switching isn't wired up yet — the badge sets that expectation up-front instead of leaving users guessing. Tooltip spells out the constraint for anyone who wants detail. * ♻️ refactor(agent-list): show runtime name (Claude Code/Codex) instead of generic "External" tag The "External" tag on heterogeneous agents didn't tell users which runtime backs the agent — multiple CLI runtimes (Claude Code, Codex, …) looked identical in the sidebar. Map the heterogeneous type to its display name so the tag identifies the actual runtime, with the raw type as a fallback for any future provider we haven't mapped yet. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
77fd0f13f0
commit
6ca5fc4bdc
18 changed files with 728 additions and 60 deletions
|
|
@ -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<void> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "历史范围",
|
||||
|
|
|
|||
|
|
@ -1 +1,7 @@
|
|||
export { ClaudeCodeApiName, ClaudeCodeIdentifier } from './types';
|
||||
export {
|
||||
ClaudeCodeApiName,
|
||||
ClaudeCodeIdentifier,
|
||||
type ClaudeCodeTodoItem,
|
||||
type ClaudeCodeTodoStatus,
|
||||
type TodoWriteArgs,
|
||||
} from './types';
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage --silent='passed-only'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lobechat/builtin-tool-claude-code": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lobechat/types": "workspace:*"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<string, ToolCallPayload[]>();
|
||||
/**
|
||||
* 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<string, TodoWriteArgs>();
|
||||
|
||||
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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string, any>;
|
||||
toolCallId: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<WorkingDirectoryContentProps>(({ 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<WorkingDirectoryContentProps>(({ 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<WorkingDirectoryContentProps>(({ 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/<encoded-cwd>/`.
|
||||
// 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 () => {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Flexbox horizontal align={'center'} className={styles.bar} gap={4}>
|
||||
<Flexbox horizontal align={'center'} className={styles.bar} gap={4} justify={'space-between'}>
|
||||
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 100, width: 100 }} />
|
||||
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 80, width: 80 }} />
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
|
@ -85,29 +105,41 @@ const WorkingDirectoryBar = memo(() => {
|
|||
</div>
|
||||
);
|
||||
|
||||
const fullAccessBadge = (
|
||||
<div className={styles.fullAccess}>
|
||||
<Icon icon={CircleAlertIcon} size={14} />
|
||||
<span>{tChat('heteroAgent.fullAccess.label')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} className={styles.bar}>
|
||||
<Popover
|
||||
content={<WorkingDirectoryContent agentId={agentId} onClose={() => setOpen(false)} />}
|
||||
open={open}
|
||||
placement="bottomLeft"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
trigger="click"
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<div>
|
||||
{open ? (
|
||||
dirButton
|
||||
) : (
|
||||
<Tooltip title={effectiveWorkingDirectory || t('localSystem.workingDirectory.notSet')}>
|
||||
{dirButton}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
{effectiveWorkingDirectory && repoType && (
|
||||
<GitStatus isGithub={repoType === 'github'} path={effectiveWorkingDirectory} />
|
||||
)}
|
||||
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<Popover
|
||||
content={<WorkingDirectoryContent agentId={agentId} onClose={() => setOpen(false)} />}
|
||||
open={open}
|
||||
placement="bottomLeft"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
trigger="click"
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<div>
|
||||
{open ? (
|
||||
dirButton
|
||||
) : (
|
||||
<Tooltip
|
||||
title={effectiveWorkingDirectory || t('localSystem.workingDirectory.notSet')}
|
||||
>
|
||||
{dirButton}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
{effectiveWorkingDirectory && repoType && (
|
||||
<GitStatus isGithub={repoType === 'github'} path={effectiveWorkingDirectory} />
|
||||
)}
|
||||
</Flexbox>
|
||||
<Tooltip title={tChat('heteroAgent.fullAccess.tooltip')}>{fullAccessBadge}</Tooltip>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ import Actions from '../Item/Actions';
|
|||
import Avatar from './Avatar';
|
||||
import { useAgentDropdownMenu } from './useDropdownMenu';
|
||||
|
||||
const HETEROGENEOUS_TYPE_LABELS: Record<string, string> = {
|
||||
'claude-code': 'Claude Code',
|
||||
'codex': 'Codex',
|
||||
};
|
||||
|
||||
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||
badge: css`
|
||||
pointer-events: none;
|
||||
|
|
@ -95,15 +100,19 @@ const AgentItem = memo<AgentItemProps>(({ 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 ? (
|
||||
<Flexbox horizontal align="center" gap={4}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{displayTitle}
|
||||
</span>
|
||||
<Tag size="small" style={{ flexShrink: 0 }}>
|
||||
{t('agentSidebar.externalTag')}
|
||||
{heterogeneousLabel}
|
||||
</Tag>
|
||||
</Flexbox>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, any>,
|
||||
) => {
|
||||
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<string, any>;
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
154
src/store/electron/actions/__tests__/tabPages.test.ts
Normal file
154
src/store/electron/actions/__tests__/tabPages.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue