feat(agents): support chat to agent (#403)

This commit is contained in:
Haze
2026-03-11 12:03:30 +08:00
committed by GitHub
Unverified
parent 34dcb48e27
commit 95e090ecb5
28 changed files with 887 additions and 148 deletions

View File

@@ -98,7 +98,8 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
### 💬 インテリジェントチャットインターフェース
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングをサポートしています。
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
### 📡 マルチチャネル管理
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。

View File

@@ -98,7 +98,8 @@ We are committed to maintaining strict alignment with the upstream OpenClaw proj
Complete the entire setup—from installation to your first AI interaction—through an intuitive graphical interface. No terminal commands, no YAML files, no environment variable hunting.
### 💬 Intelligent Chat Interface
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, and rich content rendering with Markdown.
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown, and direct `@agent` routing in the main composer for multi-agent setups.
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
### 📡 Multi-Channel Management
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.

View File

@@ -99,7 +99,8 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
### 💬 智能聊天界面
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录以及 Markdown 富文本渲染。
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体
当你使用 `@agent` 选择其他智能体时ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
### 📡 多频道管理
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。

View File

@@ -59,6 +59,10 @@ interface BindingConfig extends Record<string, unknown> {
interface AgentConfigDocument extends Record<string, unknown> {
agents?: AgentsConfig;
bindings?: BindingConfig[];
session?: {
mainKey?: string;
[key: string]: unknown;
};
}
export interface AgentSummary {
@@ -69,6 +73,7 @@ export interface AgentSummary {
inheritedModel: boolean;
workspace: string;
agentDir: string;
mainSessionKey: string;
channelTypes: string[];
}
@@ -201,6 +206,16 @@ function normalizeAgentIdForBinding(id: string): string {
return (id ?? '').trim().toLowerCase() || '';
}
function normalizeMainKey(value: unknown): string {
if (typeof value !== 'string') return 'main';
const trimmed = value.trim().toLowerCase();
return trimmed || 'main';
}
function buildAgentMainSessionKey(config: AgentConfigDocument, agentId: string): string {
return `agent:${normalizeAgentIdForBinding(agentId) || MAIN_AGENT_ID}:${normalizeMainKey(config.session?.mainKey)}`;
}
function getSimpleChannelBindingMap(bindings: unknown): Map<string, string> {
const owners = new Map<string, string>();
if (!Array.isArray(bindings)) return owners;
@@ -369,6 +384,7 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
inheritedModel,
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
mainSessionKey: buildAgentMainSessionKey(config, entry.id),
channelTypes: configuredChannels.filter((channelType) => channelOwners[channelType] === entryIdNorm),
};
});

View File

@@ -3,7 +3,7 @@
* Navigation sidebar with menu items.
* No longer fixed - sits inside the flex layout below the title bar.
*/
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
Network,
@@ -23,6 +23,7 @@ import { cn } from '@/lib/utils';
import { useSettingsStore } from '@/stores/settings';
import { useChatStore } from '@/stores/chat';
import { useGatewayStore } from '@/stores/gateway';
import { useAgentsStore } from '@/stores/agents';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
@@ -103,6 +104,12 @@ function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
const INITIAL_NOW_MS = Date.now();
function getAgentIdFromSessionKey(sessionKey: string): string {
if (!sessionKey.startsWith('agent:')) return 'main';
const [, agentId] = sessionKey.split(':');
return agentId || 'main';
}
export function Sidebar() {
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
@@ -133,7 +140,8 @@ export function Sidebar() {
cancelled = true;
};
}, [isGatewayRunning, loadHistory, loadSessions]);
const agents = useAgentsStore((s) => s.agents);
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
const navigate = useNavigate();
const isOnChat = useLocation().pathname === '/';
@@ -168,6 +176,15 @@ export function Sidebar() {
}, 60 * 1000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
void fetchAgents();
}, [fetchAgents]);
const agentNameById = useMemo(
() => Object.fromEntries(agents.map((agent) => [agent.id, agent.name])),
[agents],
);
const sessionBuckets: Array<{ key: SessionBucketKey; label: string; sessions: typeof sessions }> = [
{ key: 'today', label: t('chat:historyBuckets.today'), sessions: [] },
{ key: 'yesterday', label: t('chat:historyBuckets.yesterday'), sessions: [] },
@@ -265,39 +282,48 @@ export function Sidebar() {
<div className="px-2.5 pb-1 text-[11px] font-medium text-muted-foreground/60 tracking-tight">
{bucket.label}
</div>
{bucket.sessions.map((s) => (
<div key={s.key} className="group relative flex items-center">
<button
onClick={() => { switchSession(s.key); navigate('/'); }}
className={cn(
'w-full text-left rounded-lg px-2.5 py-1.5 text-[13px] truncate transition-colors pr-7',
'hover:bg-black/5 dark:hover:bg-white/5',
isOnChat && currentSessionKey === s.key
? 'bg-black/5 dark:bg-white/10 text-foreground font-medium'
: 'text-foreground/75',
)}
>
{getSessionLabel(s.key, s.displayName, s.label)}
</button>
<button
aria-label="Delete session"
onClick={(e) => {
e.stopPropagation();
setSessionToDelete({
key: s.key,
label: getSessionLabel(s.key, s.displayName, s.label),
});
}}
className={cn(
'absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity',
'opacity-0 group-hover:opacity-100',
'text-muted-foreground hover:text-destructive hover:bg-destructive/10',
)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
{bucket.sessions.map((s) => {
const agentId = getAgentIdFromSessionKey(s.key);
const agentName = agentNameById[agentId] || agentId;
return (
<div key={s.key} className="group relative flex items-center">
<button
onClick={() => { switchSession(s.key); navigate('/'); }}
className={cn(
'w-full text-left rounded-lg px-2.5 py-1.5 text-[13px] transition-colors pr-7',
'hover:bg-black/5 dark:hover:bg-white/5',
isOnChat && currentSessionKey === s.key
? 'bg-black/5 dark:bg-white/10 text-foreground font-medium'
: 'text-foreground/75',
)}
>
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 rounded-full bg-black/[0.04] px-2 py-0.5 text-[10px] font-medium text-foreground/70 dark:bg-white/[0.08]">
{agentName}
</span>
<span className="truncate">{getSessionLabel(s.key, s.displayName, s.label)}</span>
</div>
</button>
<button
aria-label="Delete session"
onClick={(e) => {
e.stopPropagation();
setSessionToDelete({
key: s.key,
label: getSessionLabel(s.key, s.displayName, s.label),
});
}}
className={cn(
'absolute right-1 flex items-center justify-center rounded p-0.5 transition-opacity',
'opacity-0 group-hover:opacity-100',
'text-muted-foreground hover:text-destructive hover:bg-destructive/10',
)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
) : null
))}

View File

@@ -416,9 +416,9 @@ function ProviderCard({
return (
<div
className={cn(
"group flex flex-col p-4 rounded-2xl transition-all relative overflow-hidden",
"group flex flex-col p-4 rounded-2xl transition-all relative overflow-hidden hover:bg-black/5 dark:hover:bg-white/5",
isDefault
? "bg-white dark:bg-accent border border-black/10 dark:border-white/10 shadow-sm"
? "bg-black/[0.04] dark:bg-white/[0.06] border border-transparent"
: "bg-transparent border border-black/10 dark:border-white/10"
)}
>
@@ -435,9 +435,9 @@ function ProviderCard({
<div className="flex items-center gap-2">
<span className="font-semibold text-[15px]">{account.label}</span>
{isDefault && (
<span className="flex items-center gap-1 text-[11px] font-medium text-blue-600 dark:text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-500/20">
<span className="flex items-center gap-1 font-mono text-[10px] font-medium px-2 py-0.5 rounded-full bg-black/[0.04] dark:bg-white/[0.08] border-0 shadow-none text-foreground/70">
<Check className="h-3 w-3" />
Default
{t('aiProviders.card.default')}
</span>
)}
</div>

View File

@@ -1,6 +1,6 @@
{
"title": "Agents",
"subtitle": "Manage your OpenClaw agents and their channel ownership",
"subtitle": "When adding a new Agent, ClawX will copy the main Agent's workspace files and runtime auth setup. The configuration can be modified through a dialog.",
"refresh": "Refresh",
"addAgent": "Add Agent",
"gatewayWarning": "Gateway service is not running. Agent/channel changes may take a moment to apply.",
@@ -48,4 +48,4 @@
"channelRemoved": "{{channel}} removed",
"channelRemoveFailed": "Failed to remove channel: {{error}}"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"title": "Messaging Channels",
"subtitle": "Manage your messaging channels and connections",
"subtitle": "Manage your messaging channels and connections. The configuration is only effective for the main Agent.",
"refresh": "Refresh",
"addChannel": "Add Channel",
"stats": {

View File

@@ -13,7 +13,21 @@
"toolbar": {
"refresh": "Refresh chat",
"showThinking": "Show thinking",
"hideThinking": "Hide thinking"
"hideThinking": "Hide thinking",
"currentAgent": "Talking to {{agent}}"
},
"composer": {
"attachFiles": "Attach files",
"pickAgent": "Choose agent",
"clearTarget": "Clear target agent",
"targetChip": "@{{agent}}",
"agentPickerTitle": "Route the next message to another agent",
"gatewayDisconnectedPlaceholder": "Gateway not connected...",
"send": "Send",
"stop": "Stop",
"gatewayConnected": "connected",
"gatewayStatus": "gateway {{state}} | port: {{port}} {{pid}}",
"retryFailedAttachments": "Retry failed attachments"
},
"historyBuckets": {
"today": "Today",

View File

@@ -1,6 +1,6 @@
{
"title": "Agents",
"subtitle": "OpenClaw エージェントとチャンネルの所属を管理します",
"subtitle": "新しい Agent を追加すると、ClawX はメイン Agent のワークスペース初期ファイルと実行時認証設定をコピーします。これらの設定は対話形式で編集できます。",
"refresh": "更新",
"addAgent": "Agent を追加",
"gatewayWarning": "Gateway サービスが停止しています。Agent または Channel の変更が反映されるまで少し時間がかかる場合があります。",
@@ -48,4 +48,4 @@
"channelRemoved": "{{channel}} を削除しました",
"channelRemoveFailed": "Channel の削除に失敗しました: {{error}}"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"title": "メッセージングチャンネル",
"subtitle": "メッセージングチャンネルと接続を管理",
"subtitle": "メッセージングチャンネルと接続を管理。設定はメイン Agent のみ有効です。",
"refresh": "更新",
"addChannel": "チャンネルを追加",
"stats": {

View File

@@ -13,7 +13,21 @@
"toolbar": {
"refresh": "チャットを更新",
"showThinking": "思考を表示",
"hideThinking": "思考を非表示"
"hideThinking": "思考を非表示",
"currentAgent": "現在の会話先: {{agent}}"
},
"composer": {
"attachFiles": "ファイルを添付",
"pickAgent": "Agent を選択",
"clearTarget": "対象 Agent をクリア",
"targetChip": "@{{agent}}",
"agentPickerTitle": "次のメッセージを別の Agent に直接送信",
"gatewayDisconnectedPlaceholder": "ゲートウェイ未接続...",
"send": "送信",
"stop": "停止",
"gatewayConnected": "接続済み",
"gatewayStatus": "gateway {{state}} | port: {{port}} {{pid}}",
"retryFailedAttachments": "失敗した添付を再試行"
},
"historyBuckets": {
"today": "今日",

View File

@@ -1,6 +1,6 @@
{
"title": "Agents",
"subtitle": "管理 OpenClaw Agent 以及它们的 Channel 归属",
"subtitle": "添加新的 Agent 时ClawX 会复制主 Agent 的工作区引导文件和运行时认证配置, 配置可以通过以对话的形式进行修改。",
"refresh": "刷新",
"addAgent": "添加 Agent",
"gatewayWarning": "Gateway 服务未运行。Agent 或 Channel 变更可能需要一点时间生效。",
@@ -48,4 +48,4 @@
"channelRemoved": "{{channel}} 已移除",
"channelRemoveFailed": "移除 Channel 失败:{{error}}"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"title": "消息频道",
"subtitle": "连接到消息平台。",
"subtitle": "连接到消息平台,配置仅对主 Agent 生效。",
"refresh": "刷新",
"addChannel": "添加频道",
"stats": {

View File

@@ -13,7 +13,21 @@
"toolbar": {
"refresh": "刷新聊天",
"showThinking": "显示思考过程",
"hideThinking": "隐藏思考过程"
"hideThinking": "隐藏思考过程",
"currentAgent": "当前对话对象:{{agent}}"
},
"composer": {
"attachFiles": "添加文件",
"pickAgent": "选择 Agent",
"clearTarget": "清除目标 Agent",
"targetChip": "@{{agent}}",
"agentPickerTitle": "将下一条消息直接发送给其他 Agent",
"gatewayDisconnectedPlaceholder": "网关未连接...",
"send": "发送",
"stop": "停止",
"gatewayConnected": "已连接",
"gatewayStatus": "gateway {{state}} | port: {{port}} {{pid}}",
"retryFailedAttachments": "重试失败的附件"
},
"historyBuckets": {
"today": "今天",

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertCircle, Bot, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-react';
import { AlertCircle, Bot, Check, Plus, RefreshCw, Settings2, Trash2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -197,8 +197,9 @@ function AgentCard({
{agent.isDefault && (
<Badge
variant="secondary"
className="font-mono text-[10px] font-medium px-2 py-0.5 rounded-full bg-black/[0.04] dark:bg-white/[0.08] border-0 shadow-none text-foreground/70"
className="flex items-center gap-1 font-mono text-[10px] font-medium px-2 py-0.5 rounded-full bg-black/[0.04] dark:bg-white/[0.08] border-0 shadow-none text-foreground/70"
>
<Check className="h-3 w-3" />
{t('defaultBadge')}
</Badge>
)}

View File

@@ -6,14 +6,18 @@
* Files are staged to disk via IPC — only lightweight path references
* are sent with the message (no base64 over WebSocket).
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2 } from 'lucide-react';
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2, AtSign } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { hostApiFetch } from '@/lib/host-api';
import { invokeIpc } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { useGatewayStore } from '@/stores/gateway';
import { useAgentsStore } from '@/stores/agents';
import { useChatStore } from '@/stores/chat';
import type { AgentSummary } from '@/types/agent';
import { useTranslation } from 'react-i18next';
// ── Types ────────────────────────────────────────────────────────
@@ -29,7 +33,7 @@ export interface FileAttachment {
}
interface ChatInputProps {
onSend: (text: string, attachments?: FileAttachment[]) => void;
onSend: (text: string, attachments?: FileAttachment[], targetAgentId?: string | null) => void;
onStop?: () => void;
disabled?: boolean;
sending?: boolean;
@@ -81,11 +85,30 @@ function readFileAsBase64(file: globalThis.File): Promise<string> {
// ── Component ────────────────────────────────────────────────────
export function ChatInput({ onSend, onStop, disabled = false, sending = false, isEmpty = false }: ChatInputProps) {
const { t } = useTranslation('chat');
const [input, setInput] = useState('');
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const [targetAgentId, setTargetAgentId] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const isComposingRef = useRef(false);
const gatewayStatus = useGatewayStore((s) => s.status);
const agents = useAgentsStore((s) => s.agents);
const currentAgentId = useChatStore((s) => s.currentAgentId);
const currentAgentName = useMemo(
() => agents.find((agent) => agent.id === currentAgentId)?.name ?? currentAgentId,
[agents, currentAgentId],
);
const mentionableAgents = useMemo(
() => agents.filter((agent) => agent.id !== currentAgentId),
[agents, currentAgentId],
);
const selectedTarget = useMemo(
() => agents.find((agent) => agent.id === targetAgentId) ?? null,
[agents, targetAgentId],
);
const showAgentPicker = mentionableAgents.length > 0;
// Auto-resize textarea
useEffect(() => {
@@ -102,6 +125,32 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
}
}, [disabled]);
useEffect(() => {
if (!targetAgentId) return;
if (targetAgentId === currentAgentId) {
setTargetAgentId(null);
setPickerOpen(false);
return;
}
if (!agents.some((agent) => agent.id === targetAgentId)) {
setTargetAgentId(null);
setPickerOpen(false);
}
}, [agents, currentAgentId, targetAgentId]);
useEffect(() => {
if (!pickerOpen) return;
const handlePointerDown = (event: MouseEvent) => {
if (!pickerRef.current?.contains(event.target as Node)) {
setPickerOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
};
}, [pickerOpen]);
// ── File staging via native dialog ─────────────────────────────
const pickFiles = useCallback(async () => {
@@ -258,8 +307,10 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
onSend(textToSend, attachmentsToSend);
}, [input, attachments, canSend, onSend]);
onSend(textToSend, attachmentsToSend, targetAgentId);
setTargetAgentId(null);
setPickerOpen(false);
}, [input, attachments, canSend, onSend, targetAgentId]);
const handleStop = useCallback(() => {
if (!canStop) return;
@@ -268,6 +319,10 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Backspace' && !input && targetAgentId) {
setTargetAgentId(null);
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
const nativeEvent = e.nativeEvent as KeyboardEvent;
if (isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229) {
@@ -277,7 +332,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
handleSend();
}
},
[handleSend],
[handleSend, input, targetAgentId],
);
// Handle paste (Ctrl/Cmd+V with files)
@@ -353,66 +408,126 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
)}
{/* Input Row */}
<div className={`flex items-end gap-1.5 bg-white dark:bg-[#1a1a19] rounded-[28px] shadow-sm border p-1.5 transition-all ${dragOver ? 'border-primary ring-1 ring-primary' : 'border-black/10 dark:border-white/10'}`}>
<div className={`relative bg-white dark:bg-[#1a1a19] rounded-[28px] shadow-sm border p-1.5 transition-all ${dragOver ? 'border-primary ring-1 ring-primary' : 'border-black/10 dark:border-white/10'}`}>
{selectedTarget && (
<div className="px-2.5 pt-2 pb-1">
<button
type="button"
onClick={() => setTargetAgentId(null)}
className="inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-[13px] font-medium text-foreground transition-colors hover:bg-primary/10"
title={t('composer.clearTarget')}
>
<span>{t('composer.targetChip', { agent: selectedTarget.name })}</span>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
)}
{/* Attach Button */}
<Button
variant="ghost"
size="icon"
className="shrink-0 h-10 w-10 rounded-full text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-colors"
onClick={pickFiles}
disabled={disabled || sending}
title="Attach files"
>
<Paperclip className="h-4 w-4" />
</Button>
<div className="flex items-end gap-1.5">
{/* Attach Button */}
<Button
variant="ghost"
size="icon"
className="shrink-0 h-10 w-10 rounded-full text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-colors"
onClick={pickFiles}
disabled={disabled || sending}
title={t('composer.attachFiles')}
>
<Paperclip className="h-4 w-4" />
</Button>
{/* Textarea */}
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onCompositionStart={() => {
isComposingRef.current = true;
}}
onCompositionEnd={() => {
isComposingRef.current = false;
}}
onPaste={handlePaste}
placeholder={disabled ? 'Gateway not connected...' : ''}
disabled={disabled}
className="min-h-[40px] max-h-[200px] resize-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none bg-transparent py-2.5 px-2 text-[15px] placeholder:text-muted-foreground/60 leading-relaxed"
rows={1}
/>
</div>
{/* Send Button */}
<Button
onClick={sending ? handleStop : handleSend}
disabled={sending ? !canStop : !canSend}
size="icon"
className={`shrink-0 h-10 w-10 rounded-full transition-colors ${
(sending || canSend)
? 'bg-black/5 dark:bg-white/10 text-foreground hover:bg-black/10 dark:hover:bg-white/20'
: 'text-muted-foreground/50 hover:bg-transparent bg-transparent'
}`}
variant="ghost"
title={sending ? 'Stop' : 'Send'}
>
{sending ? (
<Square className="h-4 w-4" fill="currentColor" />
) : (
<SendHorizontal className="h-[18px] w-[18px]" strokeWidth={2} />
{showAgentPicker && (
<div ref={pickerRef} className="relative shrink-0">
<Button
variant="ghost"
size="icon"
className={cn(
'h-10 w-10 rounded-full text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-colors',
(pickerOpen || selectedTarget) && 'bg-primary/10 text-primary hover:bg-primary/20'
)}
onClick={() => setPickerOpen((open) => !open)}
disabled={disabled || sending}
title={t('composer.pickAgent')}
>
<AtSign className="h-4 w-4" />
</Button>
{pickerOpen && (
<div className="absolute left-0 bottom-full z-20 mb-2 w-72 overflow-hidden rounded-2xl border border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#1a1a19]">
<div className="px-3 py-2 text-[11px] font-medium text-muted-foreground/80">
{t('composer.agentPickerTitle', { currentAgent: currentAgentName })}
</div>
<div className="max-h-64 overflow-y-auto">
{mentionableAgents.map((agent) => (
<AgentPickerItem
key={agent.id}
agent={agent}
selected={agent.id === targetAgentId}
onSelect={() => {
setTargetAgentId(agent.id);
setPickerOpen(false);
textareaRef.current?.focus();
}}
/>
))}
</div>
</div>
)}
</div>
)}
</Button>
{/* Textarea */}
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onCompositionStart={() => {
isComposingRef.current = true;
}}
onCompositionEnd={() => {
isComposingRef.current = false;
}}
onPaste={handlePaste}
placeholder={disabled ? t('composer.gatewayDisconnectedPlaceholder') : ''}
disabled={disabled}
className="min-h-[40px] max-h-[200px] resize-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 shadow-none bg-transparent py-2.5 px-2 text-[15px] placeholder:text-muted-foreground/60 leading-relaxed"
rows={1}
/>
</div>
{/* Send Button */}
<Button
onClick={sending ? handleStop : handleSend}
disabled={sending ? !canStop : !canSend}
size="icon"
className={`shrink-0 h-10 w-10 rounded-full transition-colors ${
(sending || canSend)
? 'bg-black/5 dark:bg-white/10 text-foreground hover:bg-black/10 dark:hover:bg-white/20'
: 'text-muted-foreground/50 hover:bg-transparent bg-transparent'
}`}
variant="ghost"
title={sending ? t('composer.stop') : t('composer.send')}
>
{sending ? (
<Square className="h-4 w-4" fill="currentColor" />
) : (
<SendHorizontal className="h-[18px] w-[18px]" strokeWidth={2} />
)}
</Button>
</div>
</div>
<div className="mt-2.5 flex items-center justify-between gap-2 text-[11px] text-muted-foreground/60 px-4">
<div className="flex items-center gap-1.5">
<div className={cn("w-1.5 h-1.5 rounded-full", gatewayStatus.state === 'running' ? "bg-green-500/80" : "bg-red-500/80")} />
<span>
gateway {gatewayStatus.state === 'running' ? 'connected' : gatewayStatus.state} | port: {gatewayStatus.port} {gatewayStatus.pid ? `| pid: ${gatewayStatus.pid}` : ''}
{t('composer.gatewayStatus', {
state: gatewayStatus.state === 'running'
? t('composer.gatewayConnected')
: gatewayStatus.state,
port: gatewayStatus.port,
pid: gatewayStatus.pid ? `| pid: ${gatewayStatus.pid}` : '',
})}
</span>
</div>
{hasFailedAttachments && (
@@ -425,7 +540,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
void pickFiles();
}}
>
Retry failed attachments
{t('composer.retryFailedAttachments')}
</Button>
)}
</div>
@@ -493,3 +608,29 @@ function AttachmentPreview({
</div>
);
}
function AgentPickerItem({
agent,
selected,
onSelect,
}: {
agent: AgentSummary;
selected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
'flex w-full flex-col items-start rounded-xl px-3 py-2 text-left transition-colors',
selected ? 'bg-primary/10 text-foreground' : 'hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<span className="text-[14px] font-medium text-foreground">{agent.name}</span>
<span className="text-[11px] text-muted-foreground">
{agent.modelDisplay}
</span>
</button>
);
}

View File

@@ -3,10 +3,12 @@
* Session selector, new session, refresh, and thinking toggle.
* Rendered in the Header when on the Chat page.
*/
import { RefreshCw, Brain } from 'lucide-react';
import { useMemo } from 'react';
import { RefreshCw, Brain, Bot } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useChatStore } from '@/stores/chat';
import { useAgentsStore } from '@/stores/agents';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
@@ -15,10 +17,20 @@ export function ChatToolbar() {
const loading = useChatStore((s) => s.loading);
const showThinking = useChatStore((s) => s.showThinking);
const toggleThinking = useChatStore((s) => s.toggleThinking);
const currentAgentId = useChatStore((s) => s.currentAgentId);
const agents = useAgentsStore((s) => s.agents);
const { t } = useTranslation('chat');
const currentAgentName = useMemo(
() => agents.find((agent) => agent.id === currentAgentId)?.name ?? currentAgentId,
[agents, currentAgentId],
);
return (
<div className="flex items-center gap-2">
<div className="hidden sm:flex items-center gap-1.5 rounded-full border border-black/10 bg-white/70 px-3 py-1.5 text-[12px] font-medium text-foreground/80 dark:border-white/10 dark:bg-white/5">
<Bot className="h-3.5 w-3.5 text-primary" />
<span>{t('toolbar.currentAgent', { agent: currentAgentName })}</span>
</div>
{/* Refresh */}
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from 'react';
import { AlertCircle, Loader2, Sparkles } from 'lucide-react';
import { useChatStore, type RawMessage } from '@/stores/chat';
import { useGatewayStore } from '@/stores/gateway';
import { useAgentsStore } from '@/stores/agents';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput';
@@ -32,6 +33,7 @@ export function Chat() {
const sendMessage = useChatStore((s) => s.sendMessage);
const abortRun = useChatStore((s) => s.abortRun);
const clearError = useChatStore((s) => s.clearError);
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession);
@@ -51,6 +53,10 @@ export function Chat() {
};
}, [cleanupEmptySession]);
useEffect(() => {
void fetchAgents();
}, [fetchAgents]);
// Auto-scroll on new messages, streaming, or activity changes
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });

View File

@@ -6,6 +6,7 @@
import { create } from 'zustand';
import { hostApiFetch } from '@/lib/host-api';
import { useGatewayStore } from './gateway';
import { useAgentsStore } from './agents';
// ── Types ────────────────────────────────────────────────────────
@@ -104,7 +105,11 @@ interface ChatState {
deleteSession: (key: string) => Promise<void>;
cleanupEmptySession: () => void;
loadHistory: (quiet?: boolean) => Promise<void>;
sendMessage: (text: string, attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>) => Promise<void>;
sendMessage: (
text: string,
attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>,
targetAgentId?: string | null,
) => Promise<void>;
abortRun: () => Promise<void>;
handleChatEvent: (event: Record<string, unknown>) => void;
toggleThinking: () => void;
@@ -664,6 +669,66 @@ function getAgentIdFromSessionKey(sessionKey: string): string {
return parts[1] || 'main';
}
function normalizeAgentId(value: string | undefined | null): string {
return (value ?? '').trim().toLowerCase() || 'main';
}
function buildFallbackMainSessionKey(agentId: string): string {
return `agent:${normalizeAgentId(agentId)}:main`;
}
function resolveMainSessionKeyForAgent(agentId: string | undefined | null): string | null {
if (!agentId) return null;
const normalizedAgentId = normalizeAgentId(agentId);
const summary = useAgentsStore.getState().agents.find((agent) => agent.id === normalizedAgentId);
return summary?.mainSessionKey || buildFallbackMainSessionKey(normalizedAgentId);
}
function ensureSessionEntry(sessions: ChatSession[], sessionKey: string): ChatSession[] {
if (sessions.some((session) => session.key === sessionKey)) {
return sessions;
}
return [...sessions, { key: sessionKey, displayName: sessionKey }];
}
function clearSessionEntryFromMap<T extends Record<string, unknown>>(entries: T, sessionKey: string): T {
return Object.fromEntries(Object.entries(entries).filter(([key]) => key !== sessionKey)) as T;
}
function buildSessionSwitchPatch(
state: Pick<
ChatState,
'currentSessionKey' | 'messages' | 'sessions' | 'sessionLabels' | 'sessionLastActivity'
>,
nextSessionKey: string,
): Partial<ChatState> {
const leavingEmpty = !state.currentSessionKey.endsWith(':main') && state.messages.length === 0;
const nextSessions = leavingEmpty
? state.sessions.filter((session) => session.key !== state.currentSessionKey)
: state.sessions;
return {
currentSessionKey: nextSessionKey,
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
sessions: ensureSessionEntry(nextSessions, nextSessionKey),
sessionLabels: leavingEmpty
? clearSessionEntryFromMap(state.sessionLabels, state.currentSessionKey)
: state.sessionLabels,
sessionLastActivity: leavingEmpty
? clearSessionEntryFromMap(state.sessionLastActivity, state.currentSessionKey)
: state.sessionLastActivity,
messages: [],
streamingText: '',
streamingMessage: null,
streamingTools: [],
activeRunId: null,
error: null,
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
};
}
function getCanonicalPrefixFromSessionKey(sessionKey: string): string | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
@@ -1054,30 +1119,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
// ── Switch session ──
switchSession: (key: string) => {
const { currentSessionKey, messages } = get();
const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0;
set((s) => ({
currentSessionKey: key,
currentAgentId: getAgentIdFromSessionKey(key),
messages: [],
streamingText: '',
streamingMessage: null,
streamingTools: [],
activeRunId: null,
error: null,
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
...(leavingEmpty ? {
sessions: s.sessions.filter((s) => s.key !== currentSessionKey),
sessionLabels: Object.fromEntries(
Object.entries(s.sessionLabels).filter(([k]) => k !== currentSessionKey),
),
sessionLastActivity: Object.fromEntries(
Object.entries(s.sessionLastActivity).filter(([k]) => k !== currentSessionKey),
),
} : {}),
}));
set((s) => buildSessionSwitchPatch(s, key));
get().loadHistory();
},
@@ -1332,11 +1374,22 @@ export const useChatStore = create<ChatState>((set, get) => ({
// ── Send message ──
sendMessage: async (text: string, attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>) => {
sendMessage: async (
text: string,
attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>,
targetAgentId?: string | null,
) => {
const trimmed = text.trim();
if (!trimmed && (!attachments || attachments.length === 0)) return;
const { currentSessionKey } = get();
const targetSessionKey = resolveMainSessionKeyForAgent(targetAgentId) ?? get().currentSessionKey;
if (targetSessionKey !== get().currentSessionKey) {
set((s) => buildSessionSwitchPatch(s, targetSessionKey));
await get().loadHistory(true);
}
const currentSessionKey = targetSessionKey;
// Add user message optimistically (with local file metadata for UI display)
const nowMs = Date.now();

View File

@@ -18,6 +18,7 @@ export const initialChatState: Pick<
| 'pendingToolImages'
| 'sessions'
| 'currentSessionKey'
| 'currentAgentId'
| 'sessionLabels'
| 'sessionLastActivity'
| 'showThinking'
@@ -38,6 +39,7 @@ export const initialChatState: Pick<
sessions: [],
currentSessionKey: DEFAULT_SESSION_KEY,
currentAgentId: 'main',
sessionLabels: {},
sessionLastActivity: {},

View File

@@ -1,4 +1,5 @@
import { invokeIpc } from '@/lib/api-client';
import { useAgentsStore } from '@/stores/agents';
import {
clearErrorRecoveryTimer,
clearHistoryPoll,
@@ -7,16 +8,78 @@ import {
setLastChatEventAt,
upsertImageCacheEntry,
} from './helpers';
import type { RawMessage } from './types';
import type { ChatSession, RawMessage } from './types';
import type { ChatGet, ChatSet, RuntimeActions } from './store-api';
function normalizeAgentId(value: string | undefined | null): string {
return (value ?? '').trim().toLowerCase() || 'main';
}
function getAgentIdFromSessionKey(sessionKey: string): string {
if (!sessionKey.startsWith('agent:')) return 'main';
const [, agentId] = sessionKey.split(':');
return agentId || 'main';
}
function buildFallbackMainSessionKey(agentId: string): string {
return `agent:${normalizeAgentId(agentId)}:main`;
}
function resolveMainSessionKeyForAgent(agentId: string | undefined | null): string | null {
if (!agentId) return null;
const normalizedAgentId = normalizeAgentId(agentId);
const summary = useAgentsStore.getState().agents.find((agent) => agent.id === normalizedAgentId);
return summary?.mainSessionKey || buildFallbackMainSessionKey(normalizedAgentId);
}
function ensureSessionEntry(sessions: ChatSession[], sessionKey: string): ChatSession[] {
if (sessions.some((session) => session.key === sessionKey)) {
return sessions;
}
return [...sessions, { key: sessionKey, displayName: sessionKey }];
}
export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick<RuntimeActions, 'sendMessage' | 'abortRun'> {
return {
sendMessage: async (text: string, attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>) => {
sendMessage: async (
text: string,
attachments?: Array<{ fileName: string; mimeType: string; fileSize: number; stagedPath: string; preview: string | null }>,
targetAgentId?: string | null,
) => {
const trimmed = text.trim();
if (!trimmed && (!attachments || attachments.length === 0)) return;
const { currentSessionKey } = get();
const targetSessionKey = resolveMainSessionKeyForAgent(targetAgentId) ?? get().currentSessionKey;
if (targetSessionKey !== get().currentSessionKey) {
const current = get();
const leavingEmpty = !current.currentSessionKey.endsWith(':main') && current.messages.length === 0;
set((s) => ({
currentSessionKey: targetSessionKey,
currentAgentId: getAgentIdFromSessionKey(targetSessionKey),
sessions: ensureSessionEntry(
leavingEmpty ? s.sessions.filter((session) => session.key !== current.currentSessionKey) : s.sessions,
targetSessionKey,
),
sessionLabels: leavingEmpty
? Object.fromEntries(Object.entries(s.sessionLabels).filter(([key]) => key !== current.currentSessionKey))
: s.sessionLabels,
sessionLastActivity: leavingEmpty
? Object.fromEntries(Object.entries(s.sessionLastActivity).filter(([key]) => key !== current.currentSessionKey))
: s.sessionLastActivity,
messages: [],
streamingText: '',
streamingMessage: null,
streamingTools: [],
activeRunId: null,
error: null,
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
}));
await get().loadHistory(true);
}
const currentSessionKey = targetSessionKey;
// Add user message optimistically (with local file metadata for UI display)
const nowMs = Date.now();

View File

@@ -3,6 +3,12 @@ import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers'
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
function getAgentIdFromSessionKey(sessionKey: string): string {
if (!sessionKey.startsWith('agent:')) return 'main';
const [, agentId] = sessionKey.split(':');
return agentId || 'main';
}
export function createSessionActions(
set: ChatSet,
get: ChatGet,
@@ -70,7 +76,11 @@ export function createSessionActions(
]
: dedupedSessions;
set({ sessions: sessionsWithCurrent, currentSessionKey: nextSessionKey });
set({
sessions: sessionsWithCurrent,
currentSessionKey: nextSessionKey,
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
});
if (currentSessionKey !== nextSessionKey) {
get().loadHistory();
@@ -123,6 +133,7 @@ export function createSessionActions(
const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0;
set((s) => ({
currentSessionKey: key,
currentAgentId: getAgentIdFromSessionKey(key),
messages: [],
streamingText: '',
streamingMessage: null,
@@ -190,6 +201,7 @@ export function createSessionActions(
lastUserMessageAt: null,
pendingToolImages: [],
currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY,
currentAgentId: getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY),
}));
if (next) {
get().loadHistory();
@@ -217,6 +229,7 @@ export function createSessionActions(
const newSessionEntry: ChatSession = { key: newKey, displayName: newKey };
set((s) => ({
currentSessionKey: newKey,
currentAgentId: getAgentIdFromSessionKey(newKey),
sessions: [
...(leavingEmpty ? s.sessions.filter((sess) => sess.key !== currentSessionKey) : s.sessions),
newSessionEntry,

View File

@@ -76,6 +76,7 @@ export interface ChatState {
// Sessions
sessions: ChatSession[];
currentSessionKey: string;
currentAgentId: string;
/** First user message text per session key, used as display label */
sessionLabels: Record<string, string>;
/** Last message timestamp (ms) per session key, used for sorting */
@@ -100,7 +101,8 @@ export interface ChatState {
fileSize: number;
stagedPath: string;
preview: string | null;
}>
}>,
targetAgentId?: string | null,
) => Promise<void>;
abortRun: () => Promise<void>;
handleChatEvent: (event: Record<string, unknown>) => void;

View File

@@ -6,6 +6,7 @@ export interface AgentSummary {
inheritedModel: boolean;
workspace: string;
agentDir: string;
mainSessionKey: string;
channelTypes: string[];
}

View File

@@ -72,6 +72,36 @@ describe('agent config lifecycle', () => {
await expect(listConfiguredAgentIds()).resolves.toEqual(['main']);
});
it('includes canonical per-agent main session keys in the snapshot', async () => {
await writeOpenClawJson({
session: {
mainKey: 'desk',
},
agents: {
list: [
{ id: 'main', name: 'Main', default: true },
{ id: 'research', name: 'Research' },
],
},
});
const { listAgentsSnapshot } = await import('@electron/utils/agent-config');
const snapshot = await listAgentsSnapshot();
expect(snapshot.agents).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'main',
mainSessionKey: 'agent:main:desk',
}),
expect.objectContaining({
id: 'research',
mainSessionKey: 'agent:research:desk',
}),
]),
);
});
it('deletes the config entry, bindings, runtime directory, and managed workspace for a removed agent', async () => {
await writeOpenClawJson({
agents: {

View File

@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { ChatInput } from '@/pages/Chat/ChatInput';
const { agentsState, chatState, gatewayState } = vi.hoisted(() => ({
agentsState: {
agents: [] as Array<Record<string, unknown>>,
},
chatState: {
currentAgentId: 'main',
},
gatewayState: {
status: { state: 'running', port: 18789 },
},
}));
vi.mock('@/stores/agents', () => ({
useAgentsStore: (selector: (state: typeof agentsState) => unknown) => selector(agentsState),
}));
vi.mock('@/stores/chat', () => ({
useChatStore: (selector: (state: typeof chatState) => unknown) => selector(chatState),
}));
vi.mock('@/stores/gateway', () => ({
useGatewayStore: (selector: (state: typeof gatewayState) => unknown) => selector(gatewayState),
}));
vi.mock('@/lib/host-api', () => ({
hostApiFetch: vi.fn(),
}));
vi.mock('@/lib/api-client', () => ({
invokeIpc: vi.fn(),
}));
function translate(key: string, vars?: Record<string, unknown>): string {
switch (key) {
case 'composer.attachFiles':
return 'Attach files';
case 'composer.pickAgent':
return 'Choose agent';
case 'composer.clearTarget':
return 'Clear target agent';
case 'composer.targetChip':
return `@${String(vars?.agent ?? '')}`;
case 'composer.agentPickerTitle':
return 'Route the next message to another agent';
case 'composer.gatewayDisconnectedPlaceholder':
return 'Gateway not connected...';
case 'composer.send':
return 'Send';
case 'composer.stop':
return 'Stop';
case 'composer.gatewayConnected':
return 'connected';
case 'composer.gatewayStatus':
return `gateway ${String(vars?.state ?? '')} | port: ${String(vars?.port ?? '')} ${String(vars?.pid ?? '')}`.trim();
case 'composer.retryFailedAttachments':
return 'Retry failed attachments';
default:
return key;
}
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: translate,
}),
}));
describe('ChatInput agent targeting', () => {
beforeEach(() => {
agentsState.agents = [];
chatState.currentAgentId = 'main';
gatewayState.status = { state: 'running', port: 18789 };
});
it('hides the @agent picker when only one agent is configured', () => {
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
];
render(<ChatInput onSend={vi.fn()} />);
expect(screen.queryByTitle('Choose agent')).not.toBeInTheDocument();
});
it('lets the user select an agent target and sends it with the message', () => {
const onSend = vi.fn();
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
{
id: 'research',
name: 'Research',
isDefault: false,
modelDisplay: 'Claude',
inheritedModel: false,
workspace: '~/.openclaw/workspace-research',
agentDir: '~/.openclaw/agents/research/agent',
mainSessionKey: 'agent:research:desk',
channelTypes: [],
},
];
render(<ChatInput onSend={onSend} />);
fireEvent.click(screen.getByTitle('Choose agent'));
fireEvent.click(screen.getByText('Research'));
expect(screen.getByText('@Research')).toBeInTheDocument();
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Hello direct agent' } });
fireEvent.click(screen.getByTitle('Send'));
expect(onSend).toHaveBeenCalledWith('Hello direct agent', undefined, 'research');
});
});

View File

@@ -0,0 +1,190 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { gatewayRpcMock, hostApiFetchMock, agentsState } = vi.hoisted(() => ({
gatewayRpcMock: vi.fn(),
hostApiFetchMock: vi.fn(),
agentsState: {
agents: [] as Array<Record<string, unknown>>,
},
}));
vi.mock('@/stores/gateway', () => ({
useGatewayStore: {
getState: () => ({
rpc: gatewayRpcMock,
}),
},
}));
vi.mock('@/stores/agents', () => ({
useAgentsStore: {
getState: () => agentsState,
},
}));
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
describe('chat target routing', () => {
beforeEach(() => {
vi.resetModules();
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-11T12:00:00Z'));
window.localStorage.clear();
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'MiniMax',
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:main',
channelTypes: [],
},
{
id: 'research',
name: 'Research',
isDefault: false,
modelDisplay: 'Claude',
inheritedModel: false,
workspace: '~/.openclaw/workspace-research',
agentDir: '~/.openclaw/agents/research/agent',
mainSessionKey: 'agent:research:desk',
channelTypes: [],
},
];
gatewayRpcMock.mockReset();
gatewayRpcMock.mockImplementation(async (method: string) => {
if (method === 'chat.history') {
return { messages: [] };
}
if (method === 'chat.send') {
return { runId: 'run-text' };
}
if (method === 'chat.abort') {
return { ok: true };
}
if (method === 'sessions.list') {
return { sessions: [] };
}
throw new Error(`Unexpected gateway RPC: ${method}`);
});
hostApiFetchMock.mockReset();
hostApiFetchMock.mockResolvedValue({ success: true, result: { runId: 'run-media' } });
});
afterEach(() => {
vi.useRealTimers();
});
it('switches to the selected agent main session before sending text', async () => {
const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({
currentSessionKey: 'agent:main:main',
currentAgentId: 'main',
sessions: [{ key: 'agent:main:main' }],
messages: [{ role: 'assistant', content: 'Existing main history' }],
sessionLabels: {},
sessionLastActivity: {},
sending: false,
activeRunId: null,
streamingText: '',
streamingMessage: null,
streamingTools: [],
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
error: null,
loading: false,
thinkingLevel: null,
showThinking: true,
});
await useChatStore.getState().sendMessage('Hello direct agent', undefined, 'research');
const state = useChatStore.getState();
expect(state.currentSessionKey).toBe('agent:research:desk');
expect(state.currentAgentId).toBe('research');
expect(state.sessions.some((session) => session.key === 'agent:research:desk')).toBe(true);
expect(state.messages.at(-1)?.content).toBe('Hello direct agent');
const historyCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.history');
expect(historyCall?.[1]).toEqual({ sessionKey: 'agent:research:desk', limit: 200 });
const sendCall = gatewayRpcMock.mock.calls.find(([method]) => method === 'chat.send');
expect(sendCall?.[1]).toMatchObject({
sessionKey: 'agent:research:desk',
message: 'Hello direct agent',
deliver: false,
});
expect(typeof (sendCall?.[1] as { idempotencyKey?: unknown })?.idempotencyKey).toBe('string');
});
it('uses the selected agent main session for attachment sends', async () => {
const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({
currentSessionKey: 'agent:main:main',
currentAgentId: 'main',
sessions: [{ key: 'agent:main:main' }],
messages: [],
sessionLabels: {},
sessionLastActivity: {},
sending: false,
activeRunId: null,
streamingText: '',
streamingMessage: null,
streamingTools: [],
pendingFinal: false,
lastUserMessageAt: null,
pendingToolImages: [],
error: null,
loading: false,
thinkingLevel: null,
showThinking: true,
});
await useChatStore.getState().sendMessage(
'',
[
{
fileName: 'design.png',
mimeType: 'image/png',
fileSize: 128,
stagedPath: '/tmp/design.png',
preview: 'data:image/png;base64,abc',
},
],
'research',
);
expect(useChatStore.getState().currentSessionKey).toBe('agent:research:desk');
expect(hostApiFetchMock).toHaveBeenCalledWith(
'/api/chat/send-with-media',
expect.objectContaining({
method: 'POST',
body: expect.any(String),
}),
);
const payload = JSON.parse(
(hostApiFetchMock.mock.calls[0]?.[1] as { body: string }).body,
) as {
sessionKey: string;
message: string;
media: Array<{ filePath: string }>;
};
expect(payload.sessionKey).toBe('agent:research:desk');
expect(payload.message).toBe('Process the attached file(s).');
expect(payload.media[0]?.filePath).toBe('/tmp/design.png');
});
});