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インタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。
### 💬 インテリジェントチャットインターフェース ### 💬 インテリジェントチャットインターフェース
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングをサポートしています。 モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
### 📡 マルチチャネル管理 ### 📡 マルチチャネル管理
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。 複数の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. 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 ### 💬 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 ### 📡 Multi-Channel Management
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks. 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 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。
### 💬 智能聊天界面 ### 💬 智能聊天界面
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录以及 Markdown 富文本渲染。 通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体
当你使用 `@agent` 选择其他智能体时ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
### 📡 多频道管理 ### 📡 多频道管理
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。 同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。

View File

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

View File

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

View File

@@ -416,9 +416,9 @@ function ProviderCard({
return ( return (
<div <div
className={cn( 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 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" : "bg-transparent border border-black/10 dark:border-white/10"
)} )}
> >
@@ -435,9 +435,9 @@ function ProviderCard({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-semibold text-[15px]">{account.label}</span> <span className="font-semibold text-[15px]">{account.label}</span>
{isDefault && ( {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" /> <Check className="h-3 w-3" />
Default {t('aiProviders.card.default')}
</span> </span>
)} )}
</div> </div>

View File

@@ -1,6 +1,6 @@
{ {
"title": "Agents", "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", "refresh": "Refresh",
"addAgent": "Add Agent", "addAgent": "Add Agent",
"gatewayWarning": "Gateway service is not running. Agent/channel changes may take a moment to apply.", "gatewayWarning": "Gateway service is not running. Agent/channel changes may take a moment to apply.",
@@ -48,4 +48,4 @@
"channelRemoved": "{{channel}} removed", "channelRemoved": "{{channel}} removed",
"channelRemoveFailed": "Failed to remove channel: {{error}}" "channelRemoveFailed": "Failed to remove channel: {{error}}"
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"title": "Messaging Channels", "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", "refresh": "Refresh",
"addChannel": "Add Channel", "addChannel": "Add Channel",
"stats": { "stats": {

View File

@@ -13,7 +13,21 @@
"toolbar": { "toolbar": {
"refresh": "Refresh chat", "refresh": "Refresh chat",
"showThinking": "Show thinking", "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": { "historyBuckets": {
"today": "Today", "today": "Today",

View File

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

View File

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

View File

@@ -13,7 +13,21 @@
"toolbar": { "toolbar": {
"refresh": "チャットを更新", "refresh": "チャットを更新",
"showThinking": "思考を表示", "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": { "historyBuckets": {
"today": "今日", "today": "今日",

View File

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

View File

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

View File

@@ -13,7 +13,21 @@
"toolbar": { "toolbar": {
"refresh": "刷新聊天", "refresh": "刷新聊天",
"showThinking": "显示思考过程", "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": { "historyBuckets": {
"today": "今天", "today": "今天",

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -197,8 +197,9 @@ function AgentCard({
{agent.isDefault && ( {agent.isDefault && (
<Badge <Badge
variant="secondary" 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')} {t('defaultBadge')}
</Badge> </Badge>
)} )}

View File

@@ -6,14 +6,18 @@
* Files are staged to disk via IPC — only lightweight path references * Files are staged to disk via IPC — only lightweight path references
* are sent with the message (no base64 over WebSocket). * are sent with the message (no base64 over WebSocket).
*/ */
import { useState, useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2 } from 'lucide-react'; import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2, AtSign } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { hostApiFetch } from '@/lib/host-api'; import { hostApiFetch } from '@/lib/host-api';
import { invokeIpc } from '@/lib/api-client'; import { invokeIpc } from '@/lib/api-client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useGatewayStore } from '@/stores/gateway'; 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 ──────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────
@@ -29,7 +33,7 @@ export interface FileAttachment {
} }
interface ChatInputProps { interface ChatInputProps {
onSend: (text: string, attachments?: FileAttachment[]) => void; onSend: (text: string, attachments?: FileAttachment[], targetAgentId?: string | null) => void;
onStop?: () => void; onStop?: () => void;
disabled?: boolean; disabled?: boolean;
sending?: boolean; sending?: boolean;
@@ -81,11 +85,30 @@ function readFileAsBase64(file: globalThis.File): Promise<string> {
// ── Component ──────────────────────────────────────────────────── // ── Component ────────────────────────────────────────────────────
export function ChatInput({ onSend, onStop, disabled = false, sending = false, isEmpty = false }: ChatInputProps) { export function ChatInput({ onSend, onStop, disabled = false, sending = false, isEmpty = false }: ChatInputProps) {
const { t } = useTranslation('chat');
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [attachments, setAttachments] = useState<FileAttachment[]>([]); const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const [targetAgentId, setTargetAgentId] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const isComposingRef = useRef(false); const isComposingRef = useRef(false);
const gatewayStatus = useGatewayStore((s) => s.status); 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 // Auto-resize textarea
useEffect(() => { useEffect(() => {
@@ -102,6 +125,32 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
} }
}, [disabled]); }, [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 ───────────────────────────── // ── File staging via native dialog ─────────────────────────────
const pickFiles = useCallback(async () => { const pickFiles = useCallback(async () => {
@@ -258,8 +307,10 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = 'auto';
} }
onSend(textToSend, attachmentsToSend); onSend(textToSend, attachmentsToSend, targetAgentId);
}, [input, attachments, canSend, onSend]); setTargetAgentId(null);
setPickerOpen(false);
}, [input, attachments, canSend, onSend, targetAgentId]);
const handleStop = useCallback(() => { const handleStop = useCallback(() => {
if (!canStop) return; if (!canStop) return;
@@ -268,6 +319,10 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if (e.key === 'Backspace' && !input && targetAgentId) {
setTargetAgentId(null);
return;
}
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
const nativeEvent = e.nativeEvent as KeyboardEvent; const nativeEvent = e.nativeEvent as KeyboardEvent;
if (isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229) { 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], [handleSend, input, targetAgentId],
); );
// Handle paste (Ctrl/Cmd+V with files) // Handle paste (Ctrl/Cmd+V with files)
@@ -353,66 +408,126 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
)} )}
{/* Input Row */} {/* 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 */} <div className="flex items-end gap-1.5">
<Button {/* Attach Button */}
variant="ghost" <Button
size="icon" variant="ghost"
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" size="icon"
onClick={pickFiles} 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"
disabled={disabled || sending} onClick={pickFiles}
title="Attach files" disabled={disabled || sending}
> title={t('composer.attachFiles')}
<Paperclip className="h-4 w-4" /> >
</Button> <Paperclip className="h-4 w-4" />
</Button>
{/* Textarea */} {showAgentPicker && (
<div className="flex-1 relative"> <div ref={pickerRef} className="relative shrink-0">
<Textarea <Button
ref={textareaRef} variant="ghost"
value={input} size="icon"
onChange={(e) => setInput(e.target.value)} className={cn(
onKeyDown={handleKeyDown} 'h-10 w-10 rounded-full text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-colors',
onCompositionStart={() => { (pickerOpen || selectedTarget) && 'bg-primary/10 text-primary hover:bg-primary/20'
isComposingRef.current = true; )}
}} onClick={() => setPickerOpen((open) => !open)}
onCompositionEnd={() => { disabled={disabled || sending}
isComposingRef.current = false; title={t('composer.pickAgent')}
}} >
onPaste={handlePaste} <AtSign className="h-4 w-4" />
placeholder={disabled ? 'Gateway not connected...' : ''} </Button>
disabled={disabled} {pickerOpen && (
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" <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]">
rows={1} <div className="px-3 py-2 text-[11px] font-medium text-muted-foreground/80">
/> {t('composer.agentPickerTitle', { currentAgent: currentAgentName })}
</div> </div>
<div className="max-h-64 overflow-y-auto">
{/* Send Button */} {mentionableAgents.map((agent) => (
<Button <AgentPickerItem
onClick={sending ? handleStop : handleSend} key={agent.id}
disabled={sending ? !canStop : !canSend} agent={agent}
size="icon" selected={agent.id === targetAgentId}
className={`shrink-0 h-10 w-10 rounded-full transition-colors ${ onSelect={() => {
(sending || canSend) setTargetAgentId(agent.id);
? 'bg-black/5 dark:bg-white/10 text-foreground hover:bg-black/10 dark:hover:bg-white/20' setPickerOpen(false);
: 'text-muted-foreground/50 hover:bg-transparent bg-transparent' textareaRef.current?.focus();
}`} }}
variant="ghost" />
title={sending ? 'Stop' : 'Send'} ))}
> </div>
{sending ? ( </div>
<Square className="h-4 w-4" fill="currentColor" /> )}
) : ( </div>
<SendHorizontal className="h-[18px] w-[18px]" strokeWidth={2} />
)} )}
</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>
<div className="mt-2.5 flex items-center justify-between gap-2 text-[11px] text-muted-foreground/60 px-4"> <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="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")} /> <div className={cn("w-1.5 h-1.5 rounded-full", gatewayStatus.state === 'running' ? "bg-green-500/80" : "bg-red-500/80")} />
<span> <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> </span>
</div> </div>
{hasFailedAttachments && ( {hasFailedAttachments && (
@@ -425,7 +540,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
void pickFiles(); void pickFiles();
}} }}
> >
Retry failed attachments {t('composer.retryFailedAttachments')}
</Button> </Button>
)} )}
</div> </div>
@@ -493,3 +608,29 @@ function AttachmentPreview({
</div> </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. * Session selector, new session, refresh, and thinking toggle.
* Rendered in the Header when on the Chat page. * 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 { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useChatStore } from '@/stores/chat'; import { useChatStore } from '@/stores/chat';
import { useAgentsStore } from '@/stores/agents';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -15,10 +17,20 @@ export function ChatToolbar() {
const loading = useChatStore((s) => s.loading); const loading = useChatStore((s) => s.loading);
const showThinking = useChatStore((s) => s.showThinking); const showThinking = useChatStore((s) => s.showThinking);
const toggleThinking = useChatStore((s) => s.toggleThinking); const toggleThinking = useChatStore((s) => s.toggleThinking);
const currentAgentId = useChatStore((s) => s.currentAgentId);
const agents = useAgentsStore((s) => s.agents);
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const currentAgentName = useMemo(
() => agents.find((agent) => agent.id === currentAgentId)?.name ?? currentAgentId,
[agents, currentAgentId],
);
return ( return (
<div className="flex items-center gap-2"> <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 */} {/* Refresh */}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

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

View File

@@ -6,6 +6,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { hostApiFetch } from '@/lib/host-api'; import { hostApiFetch } from '@/lib/host-api';
import { useGatewayStore } from './gateway'; import { useGatewayStore } from './gateway';
import { useAgentsStore } from './agents';
// ── Types ──────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────
@@ -104,7 +105,11 @@ interface ChatState {
deleteSession: (key: string) => Promise<void>; deleteSession: (key: string) => Promise<void>;
cleanupEmptySession: () => void; cleanupEmptySession: () => void;
loadHistory: (quiet?: boolean) => Promise<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>; abortRun: () => Promise<void>;
handleChatEvent: (event: Record<string, unknown>) => void; handleChatEvent: (event: Record<string, unknown>) => void;
toggleThinking: () => void; toggleThinking: () => void;
@@ -664,6 +669,66 @@ function getAgentIdFromSessionKey(sessionKey: string): string {
return parts[1] || 'main'; 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 { function getCanonicalPrefixFromSessionKey(sessionKey: string): string | null {
if (!sessionKey.startsWith('agent:')) return null; if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':'); const parts = sessionKey.split(':');
@@ -1054,30 +1119,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
// ── Switch session ── // ── Switch session ──
switchSession: (key: string) => { switchSession: (key: string) => {
const { currentSessionKey, messages } = get(); set((s) => buildSessionSwitchPatch(s, key));
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),
),
} : {}),
}));
get().loadHistory(); get().loadHistory();
}, },
@@ -1332,11 +1374,22 @@ export const useChatStore = create<ChatState>((set, get) => ({
// ── Send message ── // ── 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(); const trimmed = text.trim();
if (!trimmed && (!attachments || attachments.length === 0)) return; 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) // Add user message optimistically (with local file metadata for UI display)
const nowMs = Date.now(); const nowMs = Date.now();

View File

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

View File

@@ -1,4 +1,5 @@
import { invokeIpc } from '@/lib/api-client'; import { invokeIpc } from '@/lib/api-client';
import { useAgentsStore } from '@/stores/agents';
import { import {
clearErrorRecoveryTimer, clearErrorRecoveryTimer,
clearHistoryPoll, clearHistoryPoll,
@@ -7,16 +8,78 @@ import {
setLastChatEventAt, setLastChatEventAt,
upsertImageCacheEntry, upsertImageCacheEntry,
} from './helpers'; } from './helpers';
import type { RawMessage } from './types'; import type { ChatSession, RawMessage } from './types';
import type { ChatGet, ChatSet, RuntimeActions } from './store-api'; 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'> { export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick<RuntimeActions, 'sendMessage' | 'abortRun'> {
return { 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(); const trimmed = text.trim();
if (!trimmed && (!attachments || attachments.length === 0)) return; 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) // Add user message optimistically (with local file metadata for UI display)
const nowMs = Date.now(); 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 { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api'; 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( export function createSessionActions(
set: ChatSet, set: ChatSet,
get: ChatGet, get: ChatGet,
@@ -70,7 +76,11 @@ export function createSessionActions(
] ]
: dedupedSessions; : dedupedSessions;
set({ sessions: sessionsWithCurrent, currentSessionKey: nextSessionKey }); set({
sessions: sessionsWithCurrent,
currentSessionKey: nextSessionKey,
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
});
if (currentSessionKey !== nextSessionKey) { if (currentSessionKey !== nextSessionKey) {
get().loadHistory(); get().loadHistory();
@@ -123,6 +133,7 @@ export function createSessionActions(
const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0; const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0;
set((s) => ({ set((s) => ({
currentSessionKey: key, currentSessionKey: key,
currentAgentId: getAgentIdFromSessionKey(key),
messages: [], messages: [],
streamingText: '', streamingText: '',
streamingMessage: null, streamingMessage: null,
@@ -190,6 +201,7 @@ export function createSessionActions(
lastUserMessageAt: null, lastUserMessageAt: null,
pendingToolImages: [], pendingToolImages: [],
currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY, currentSessionKey: next?.key ?? DEFAULT_SESSION_KEY,
currentAgentId: getAgentIdFromSessionKey(next?.key ?? DEFAULT_SESSION_KEY),
})); }));
if (next) { if (next) {
get().loadHistory(); get().loadHistory();
@@ -217,6 +229,7 @@ export function createSessionActions(
const newSessionEntry: ChatSession = { key: newKey, displayName: newKey }; const newSessionEntry: ChatSession = { key: newKey, displayName: newKey };
set((s) => ({ set((s) => ({
currentSessionKey: newKey, currentSessionKey: newKey,
currentAgentId: getAgentIdFromSessionKey(newKey),
sessions: [ sessions: [
...(leavingEmpty ? s.sessions.filter((sess) => sess.key !== currentSessionKey) : s.sessions), ...(leavingEmpty ? s.sessions.filter((sess) => sess.key !== currentSessionKey) : s.sessions),
newSessionEntry, newSessionEntry,

View File

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

View File

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

View File

@@ -72,6 +72,36 @@ describe('agent config lifecycle', () => {
await expect(listConfiguredAgentIds()).resolves.toEqual(['main']); 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 () => { it('deletes the config entry, bindings, runtime directory, and managed workspace for a removed agent', async () => {
await writeOpenClawJson({ await writeOpenClawJson({
agents: { 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');
});
});