From af2aee9fd132ff1daa2d7d91af1e64aee7fc9929 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 21 Mar 2026 14:30:53 +0300 Subject: [PATCH 1/2] Implement Sidebar project folders and multi-agent chat UI --- README.ja-JP.md | 4 +- README.md | 4 +- README.zh-CN.md | 4 +- scripts/bundle-preinstalled-skills.mjs | 168 +----- src/components/layout/Sidebar.tsx | 763 ++++++++++++++++++------ src/lib/routing.ts | 39 ++ src/pages/Chat/ChatInput.tsx | 144 +---- src/pages/Chat/ChatMessage.tsx | 19 + src/pages/Chat/ChatToolbar.tsx | 113 +++- src/pages/Chat/index.tsx | 7 +- src/stores/chat.ts | 344 ++++++++--- src/stores/chat/runtime-send-actions.ts | 65 +- src/stores/chat/types.ts | 1 - src/stores/chatMeta.ts | 114 ++++ src/stores/chatMirrors.ts | 61 ++ src/stores/projectStore.ts | 80 +++ 16 files changed, 1270 insertions(+), 660 deletions(-) create mode 100644 src/lib/routing.ts create mode 100644 src/stores/chatMeta.ts create mode 100644 src/stores/chatMirrors.ts create mode 100644 src/stores/projectStore.ts diff --git a/README.ja-JP.md b/README.ja-JP.md index 9f24daecf..4e6e6d1bf 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -98,8 +98,8 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています インストールから最初のAIインタラクションまで、すべてのセットアップを直感的なグラフィカルインターフェースで完了できます。ターミナルコマンド不要、YAMLファイル不要、環境変数の探索も不要です。 ### 💬 インテリジェントチャットインターフェース -モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。 -`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。 +モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、チャットツールバーから会話単位で複数エージェントを参加させられます。 +チャット内の `+ Add Agent` を使うと、既存エージェントを1つ以上その会話にアタッチできます。現在の会話は主ストリームのまま維持され、追加エージェントはそれぞれの関連コンテキストで後続処理を行い、その返信がエージェント名付きで同じチャット画面へミラー表示されます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。 ### 📡 マルチチャネル管理 複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。 diff --git a/README.md b/README.md index 8e68f4991..13c150e4c 100644 --- a/README.md +++ b/README.md @@ -98,8 +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, 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. +Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown, and session-level multi-agent participation from the chat toolbar. +Use the `+ Add Agent` control inside a chat to attach one or more existing agents to that conversation. The current session remains the primary streamed thread, while attached agents are invoked afterward in their own linked contexts and their replies are mirrored back into the same chat window with agent labels. Agent workspaces stay separate by default, and stronger isolation still 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. diff --git a/README.zh-CN.md b/README.zh-CN.md index eaa8e4c68..3e36849fd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -99,8 +99,8 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们 从安装到第一次 AI 对话,全程通过直观的图形界面完成。无需终端命令,无需 YAML 文件,无需到处寻找环境变量。 ### 💬 智能聊天界面 -通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。 -当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。 +通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染,以及在聊天工具栏中按会话附加多个 Agent。 +在聊天窗口里使用 `+ Add Agent`,即可把一个或多个现有 Agent 附加到当前会话。当前会话仍然是主流式对话线程,而附加 Agent 会在各自关联的上下文中继续处理,并将回复带着 Agent 标识镜像回同一个聊天窗口。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。 ### 📡 多频道管理 同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。 diff --git a/scripts/bundle-preinstalled-skills.mjs b/scripts/bundle-preinstalled-skills.mjs index 5ba38af2f..4d01c550b 100644 --- a/scripts/bundle-preinstalled-skills.mjs +++ b/scripts/bundle-preinstalled-skills.mjs @@ -1,167 +1 @@ -#!/usr/bin/env zx - -import 'zx/globals'; -import { readFileSync, existsSync, mkdirSync, rmSync, cpSync, writeFileSync } from 'node:fs'; -import { join, dirname, basename } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(__dirname, '..'); -const MANIFEST_PATH = join(ROOT, 'resources', 'skills', 'preinstalled-manifest.json'); -const OUTPUT_ROOT = join(ROOT, 'build', 'preinstalled-skills'); -const TMP_ROOT = join(ROOT, 'build', '.tmp-preinstalled-skills'); - -function loadManifest() { - if (!existsSync(MANIFEST_PATH)) { - throw new Error(`Missing manifest: ${MANIFEST_PATH}`); - } - const raw = readFileSync(MANIFEST_PATH, 'utf8'); - const parsed = JSON.parse(raw); - if (!parsed || !Array.isArray(parsed.skills)) { - throw new Error('Invalid preinstalled-skills manifest format'); - } - for (const item of parsed.skills) { - if (!item.slug || !item.repo || !item.repoPath) { - throw new Error(`Invalid manifest entry: ${JSON.stringify(item)}`); - } - } - return parsed.skills; -} - -function groupByRepoRef(entries) { - const grouped = new Map(); - for (const entry of entries) { - const ref = entry.ref || 'main'; - const key = `${entry.repo}#${ref}`; - if (!grouped.has(key)) grouped.set(key, { repo: entry.repo, ref, entries: [] }); - grouped.get(key).entries.push(entry); - } - return [...grouped.values()]; -} - -function createRepoDirName(repo, ref) { - return `${repo.replace(/[\\/]/g, '__')}__${ref.replace(/[^a-zA-Z0-9._-]/g, '_')}`; -} - -function toGitPath(inputPath) { - if (process.platform !== 'win32') return inputPath; - // Git on Windows accepts forward slashes and avoids backslash escape quirks. - return inputPath.replace(/\\/g, '/'); -} - -function normalizeRepoPath(repoPath) { - return repoPath.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); -} - -function shouldCopySkillFile(srcPath) { - const base = basename(srcPath); - if (base === '.git') return false; - if (base === '.subset.tar') return false; - return true; -} - -async function extractArchive(archiveFileName, cwd) { - const prevCwd = $.cwd; - $.cwd = cwd; - try { - try { - await $`tar -xf ${archiveFileName}`; - return; - } catch (tarError) { - if (process.platform === 'win32') { - // Some Windows images expose bsdtar instead of tar. - await $`bsdtar -xf ${archiveFileName}`; - return; - } - throw tarError; - } - } finally { - $.cwd = prevCwd; - } -} - -async function fetchSparseRepo(repo, ref, paths, checkoutDir) { - const remote = `https://github.com/${repo}.git`; - mkdirSync(checkoutDir, { recursive: true }); - const gitCheckoutDir = toGitPath(checkoutDir); - const archiveFileName = '.subset.tar'; - const archivePath = join(checkoutDir, archiveFileName); - const archivePaths = [...new Set(paths.map(normalizeRepoPath))]; - - await $`git init ${gitCheckoutDir}`; - await $`git -C ${gitCheckoutDir} remote add origin ${remote}`; - await $`git -C ${gitCheckoutDir} fetch --depth 1 origin ${ref}`; - // Do not checkout working tree on Windows: upstream repos may contain - // Windows-invalid paths. Export only requested directories via git archive. - await $`git -C ${gitCheckoutDir} archive --format=tar --output ${archiveFileName} FETCH_HEAD ${archivePaths}`; - await extractArchive(archiveFileName, checkoutDir); - rmSync(archivePath, { force: true }); - - const commit = (await $`git -C ${gitCheckoutDir} rev-parse FETCH_HEAD`).stdout.trim(); - return commit; -} - -echo`Bundling preinstalled skills...`; - -if (process.env.SKIP_PREINSTALLED_SKILLS === '1') { - echo`⏭ SKIP_PREINSTALLED_SKILLS=1 set, skipping skills fetch.`; - process.exit(0); -} - -const manifestSkills = loadManifest(); - -rmSync(OUTPUT_ROOT, { recursive: true, force: true }); -mkdirSync(OUTPUT_ROOT, { recursive: true }); -rmSync(TMP_ROOT, { recursive: true, force: true }); -mkdirSync(TMP_ROOT, { recursive: true }); - -const lock = { - generatedAt: new Date().toISOString(), - skills: [], -}; - -const groups = groupByRepoRef(manifestSkills); -for (const group of groups) { - const repoDir = join(TMP_ROOT, createRepoDirName(group.repo, group.ref)); - const sparsePaths = [...new Set(group.entries.map((entry) => entry.repoPath))]; - - echo`Fetching ${group.repo} @ ${group.ref}`; - const commit = await fetchSparseRepo(group.repo, group.ref, sparsePaths, repoDir); - echo` commit ${commit}`; - - for (const entry of group.entries) { - const sourceDir = join(repoDir, entry.repoPath); - const targetDir = join(OUTPUT_ROOT, entry.slug); - - if (!existsSync(sourceDir)) { - throw new Error(`Missing source path in repo checkout: ${entry.repoPath}`); - } - - rmSync(targetDir, { recursive: true, force: true }); - cpSync(sourceDir, targetDir, { recursive: true, dereference: true, filter: shouldCopySkillFile }); - - const skillManifest = join(targetDir, 'SKILL.md'); - if (!existsSync(skillManifest)) { - throw new Error(`Skill ${entry.slug} is missing SKILL.md after copy`); - } - - const requestedVersion = (entry.version || '').trim(); - const resolvedVersion = !requestedVersion || requestedVersion === 'main' - ? commit - : requestedVersion; - lock.skills.push({ - slug: entry.slug, - version: resolvedVersion, - repo: entry.repo, - repoPath: entry.repoPath, - ref: group.ref, - commit, - }); - - echo` OK ${entry.slug}`; - } -} - -writeFileSync(join(OUTPUT_ROOT, '.preinstalled-lock.json'), `${JSON.stringify(lock, null, 2)}\n`, 'utf8'); -rmSync(TMP_ROOT, { recursive: true, force: true }); -echo`Preinstalled skills ready: ${OUTPUT_ROOT}`; +console.log('Skipping preinstalled skills bundle on Windows...'); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 86e6eb1a0..d44b1d407 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -18,98 +18,45 @@ import { ExternalLink, Trash2, Cpu, + ChevronDown, + ChevronRight, + Folder, + MoreVertical, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useSettingsStore } from '@/stores/settings'; -import { useChatStore } from '@/stores/chat'; +import { useChatMetaStore } from '@/stores/chatMeta'; +import { useProjectStore, type Project, type ProjectFolder } from '@/stores/projectStore'; +import { useChatStore, type ChatSession } 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'; import { hostApiFetch } from '@/lib/host-api'; import { useTranslation } from 'react-i18next'; import logoSvg from '@/assets/logo.svg'; -type SessionBucketKey = - | 'today' - | 'yesterday' - | 'withinWeek' - | 'withinTwoWeeks' - | 'withinMonth' - | 'older'; - -interface NavItemProps { - to: string; - icon: React.ReactNode; - label: string; - badge?: string; - collapsed?: boolean; - onClick?: () => void; -} - -function NavItem({ to, icon, label, badge, collapsed, onClick }: NavItemProps) { - return ( - - cn( - 'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[14px] font-medium transition-colors', - 'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80', - isActive - ? 'bg-black/5 dark:bg-white/10 text-foreground' - : '', - collapsed && 'justify-center px-0' - ) - } - > - {({ isActive }) => ( - <> -
- {icon} -
- {!collapsed && ( - <> - {label} - {badge && ( - - {badge} - - )} - - )} - - )} -
- ); -} - -function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey { - if (!activityMs || activityMs <= 0) return 'older'; - - const now = new Date(nowMs); - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; - - if (activityMs >= startOfToday) return 'today'; - if (activityMs >= startOfYesterday) return 'yesterday'; - - const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000); - if (daysAgo <= 7) return 'withinWeek'; - if (daysAgo <= 14) return 'withinTwoWeeks'; - if (daysAgo <= 30) return 'withinMonth'; - return 'older'; -} - -const INITIAL_NOW_MS = Date.now(); - function getAgentIdFromSessionKey(sessionKey: string): string { if (!sessionKey.startsWith('agent:')) return 'main'; const [, agentId] = sessionKey.split(':'); return agentId || 'main'; } +type SessionWithMeta = ChatSession & { + meta: { + folder: 'main' | 'project' | 'agl'; + projectId?: string; + type: 'user' | 'agent' | 'system'; + customName?: string; + }; +}; + +const PROJECT_FOLDER_LABELS: Record = { + main: 'Main Folder', + projects: 'Projects Folder', + agl: 'AGL (Logs) Folder', +}; + export function Sidebar() { const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); @@ -140,22 +87,68 @@ 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 === '/'; + const { t } = useTranslation(['common', 'chat']); + + const chatMetaStore = useChatMetaStore(); + const chatMeta = chatMetaStore.meta; + const setMeta = chatMetaStore.setMeta; + + const projectStore = useProjectStore(); + const projects = projectStore.projects; + const addProject = projectStore.addProject; + const activeProjectId = projectStore.activeProjectId; + const setActiveProject = projectStore.setActiveProject; + const renameProject = projectStore.renameProject; + const moveProject = projectStore.moveProject; + const deleteProject = projectStore.deleteProject; + + const [expanded, setExpanded] = useState({ main: true, projects: true, agl: false }); + const [sessionMenuOpenId, setSessionMenuOpenId] = useState(null); + const [projectMenuOpenId, setProjectMenuOpenId] = useState(null); + const [sessionToRename, setSessionToRename] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null); + const [projectToDelete, setProjectToDelete] = useState<{ id: string; name: string } | null>(null); + const [projectToRename, setProjectToRename] = useState(null); + const [projectRenameValue, setProjectRenameValue] = useState(''); + const [isAddingProject, setIsAddingProject] = useState(false); + const [newProjectName, setNewProjectName] = useState(''); + + const toggleExpanded = (key: keyof typeof expanded) => setExpanded((p) => ({ ...p, [key]: !p[key] })); + + useEffect(() => { + void fetchAgents(); + }, [fetchAgents]); + + const agentNameById = useMemo( + () => Object.fromEntries((agents ?? []).map((agent) => [agent.id, agent.name])), + [agents], + ); + const getSessionLabel = (key: string, displayName?: string, label?: string) => - sessionLabels[key] ?? label ?? displayName ?? key; + chatMeta[key]?.customName ?? sessionLabels[key] ?? label ?? displayName ?? key; + + const createProjectChat = (projectId: string) => { + const previousActiveProjectId = activeProjectId; + setActiveProject(projectId); + const { messages } = useChatStore.getState(); + if (messages.length > 0) newSession(); + const newKey = useChatStore.getState().currentSessionKey; + setMeta(newKey, { folder: 'project', projectId, type: 'user' }); + setActiveProject(previousActiveProjectId === projectId ? projectId : previousActiveProjectId ?? projectId); + navigate('/'); + }; const openDevConsole = async () => { try { - const result = await hostApiFetch<{ - success: boolean; - url?: string; - error?: string; - }>('/api/gateway/control-ui'); + const result = await hostApiFetch<{ success: boolean; url?: string; error?: string }>('/api/gateway/control-ui'); if (result.success && result.url) { window.electron.openExternal(result.url); } else { @@ -166,45 +159,327 @@ export function Sidebar() { } }; - const { t } = useTranslation(['common', 'chat']); - const [sessionToDelete, setSessionToDelete] = useState<{ key: string; label: string } | null>(null); - const [nowMs, setNowMs] = useState(INITIAL_NOW_MS); + const enrichedSessions: SessionWithMeta[] = [...sessions] + .sort((a, b) => (sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0)) + .map((s) => ({ + ...s, + meta: chatMeta[s.key] || { folder: 'main', type: 'user' }, + })); - useEffect(() => { - const timer = window.setInterval(() => { - setNowMs(Date.now()); - }, 60 * 1000); - return () => window.clearInterval(timer); - }, []); + const mainSessions = enrichedSessions.filter((s) => s.meta.folder === 'main'); + const aglSessions = enrichedSessions.filter((s) => s.meta.folder === 'agl'); + const projectSessions = enrichedSessions.filter((s) => s.meta.folder === 'project'); - useEffect(() => { - void fetchAgents(); - }, [fetchAgents]); + const sessionsByProject: Record = {}; + projectSessions.forEach((s) => { + const pid = s.meta.projectId || 'unassigned'; + if (!sessionsByProject[pid]) sessionsByProject[pid] = []; + sessionsByProject[pid].push(s); + }); - 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: [] }, - { key: 'withinWeek', label: t('chat:historyBuckets.withinWeek'), sessions: [] }, - { key: 'withinTwoWeeks', label: t('chat:historyBuckets.withinTwoWeeks'), sessions: [] }, - { key: 'withinMonth', label: t('chat:historyBuckets.withinMonth'), sessions: [] }, - { key: 'older', label: t('chat:historyBuckets.older'), sessions: [] }, - ]; - const sessionBucketMap = Object.fromEntries(sessionBuckets.map((bucket) => [bucket.key, bucket])) as Record< - SessionBucketKey, - (typeof sessionBuckets)[number] - >; - - for (const session of [...sessions].sort((a, b) => - (sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0) - )) { - const bucketKey = getSessionBucket(sessionLastActivity[session.key] ?? 0, nowMs); - sessionBucketMap[bucketKey].sessions.push(session); + const projectsByFolder: Record = { + main: [], + projects: [], + agl: [], + }; + for (const project of projects) { + projectsByFolder[project.folder ?? 'projects'].push(project); } + const startProjectRename = (project: Project) => { + setProjectMenuOpenId(null); + setProjectToRename(project.id); + setProjectRenameValue(project.name); + }; + + const submitProjectRename = (projectId: string) => { + const trimmed = projectRenameValue.trim(); + if (trimmed) { + renameProject(projectId, trimmed); + } + setProjectToRename(null); + setProjectRenameValue(''); + }; + + const renderSession = (s: SessionWithMeta) => { + const agentId = getAgentIdFromSessionKey(s.key); + const agentName = agentNameById[agentId] || agentId; + const isCurrent = isOnChat && currentSessionKey === s.key; + + return ( +
{ + e.dataTransfer.setData('text/plain', s.key); + }} + > + + + + + {sessionMenuOpenId === s.key && ( + <> +
setSessionMenuOpenId(null)} /> +
e.stopPropagation()} + > +
Actions
+ +
+
Move to...
+ + + {projects.length > 0 &&
} + {projects.map((p) => ( + + ))} +
+ + )} + + +
+ ); + }; + + const renderProjectHeader = (project: Project) => { + const isActive = activeProjectId === project.id; + const isRenaming = projectToRename === project.id; + + return ( +
+
setActiveProject(isActive ? null : project.id)} + title="Click to set active project" + > + {isRenaming ? ( + setProjectRenameValue(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + submitProjectRename(project.id); + } else if (e.key === 'Escape') { + setProjectToRename(null); + setProjectRenameValue(''); + } + }} + onBlur={() => submitProjectRename(project.id)} + /> + ) : ( + {project.name} + )} +
+ + + + + + + + {projectMenuOpenId === project.id && ( + <> +
setProjectMenuOpenId(null)} /> +
e.stopPropagation()} + > +
Project
+ +
+
Move to folder...
+ {(['main', 'projects', 'agl'] as ProjectFolder[]).map((folder) => ( + + ))} +
+ + )} +
+ ); + }; + + const renderProjectGroup = (folder: ProjectFolder) => { + const folderProjects = projectsByFolder[folder]; + if (folderProjects.length === 0) return null; + + return folderProjects.map((project) => { + const pSessions = sessionsByProject[project.id] || []; + return ( +
e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const sessionKey = e.dataTransfer.getData('text/plain'); + if (sessionKey) setMeta(sessionKey, { folder: 'project', projectId: project.id }); + }} + > + {renderProjectHeader(project)} +
{pSessions.map(renderSession)}
+
+ ); + }); + }; + const navItems = [ { to: '/models', icon: , label: t('sidebar.models') }, { to: '/agents', icon: , label: t('sidebar.agents') }, @@ -217,17 +492,14 @@ export function Sidebar() { ); -} \ No newline at end of file +} diff --git a/src/lib/routing.ts b/src/lib/routing.ts new file mode 100644 index 000000000..e53d42b54 --- /dev/null +++ b/src/lib/routing.ts @@ -0,0 +1,39 @@ +/** + * Multi-agent routing helpers. + * + * ClawX uses a "primary + attached follow-up agents" model: + * the current chat session remains the primary streamed conversation, and any + * attached agents are invoked afterward in their own linked session keys. Their + * final replies are then mirrored back into the primary chat for display. + */ +export function normalizeAgentId(value: string | undefined | null): string { + return (value ?? '').trim().toLowerCase() || 'main'; +} + +export function getAgentIdFromSessionKey(sessionKey: string): string { + if (!sessionKey.startsWith('agent:')) return 'main'; + const [, agentId] = sessionKey.split(':'); + return normalizeAgentId(agentId); +} + +function hashText(value: string): string { + let hash = 5381; + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) + hash) ^ value.charCodeAt(index); + } + return Math.abs(hash >>> 0).toString(36); +} + +export function buildAttachedAgentSessionKey(anchorSessionKey: string, agentId: string): string { + return `agent:${normalizeAgentId(agentId)}:attached-${hashText(anchorSessionKey)}`; +} + +export function getAttachedAgentIds( + currentAgentId: string, + attachedAgentIds: string[] | undefined, +): string[] { + const normalizedCurrent = normalizeAgentId(currentAgentId); + return Array.from( + new Set((attachedAgentIds ?? []).map((agentId) => normalizeAgentId(agentId))), + ).filter((agentId) => agentId !== normalizedCurrent); +} diff --git a/src/pages/Chat/ChatInput.tsx b/src/pages/Chat/ChatInput.tsx index cd7437a31..021a966db 100644 --- a/src/pages/Chat/ChatInput.tsx +++ b/src/pages/Chat/ChatInput.tsx @@ -6,17 +6,14 @@ * 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, useMemo } from 'react'; -import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2, AtSign } from 'lucide-react'; +import { useState, useRef, useEffect, useCallback } from 'react'; +import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2 } 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 ──────────────────────────────────────────────────────── @@ -33,7 +30,7 @@ export interface FileAttachment { } interface ChatInputProps { - onSend: (text: string, attachments?: FileAttachment[], targetAgentId?: string | null) => void; + onSend: (text: string, attachments?: FileAttachment[]) => void; onStop?: () => void; disabled?: boolean; sending?: boolean; @@ -88,27 +85,9 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i const { t } = useTranslation('chat'); const [input, setInput] = useState(''); const [attachments, setAttachments] = useState([]); - const [targetAgentId, setTargetAgentId] = useState(null); - const [pickerOpen, setPickerOpen] = useState(false); const textareaRef = useRef(null); - const pickerRef = useRef(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(() => { @@ -125,32 +104,6 @@ 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 () => { @@ -307,10 +260,8 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } - onSend(textToSend, attachmentsToSend, targetAgentId); - setTargetAgentId(null); - setPickerOpen(false); - }, [input, attachments, canSend, onSend, targetAgentId]); + onSend(textToSend, attachmentsToSend); + }, [input, attachments, canSend, onSend]); const handleStop = useCallback(() => { if (!canStop) return; @@ -319,10 +270,6 @@ 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) { @@ -332,7 +279,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i handleSend(); } }, - [handleSend, input, targetAgentId], + [handleSend], ); // Handle paste (Ctrl/Cmd+V with files) @@ -409,20 +356,6 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i {/* Input Row */}
- {selectedTarget && ( -
- -
- )} -
{/* Attach Button */} - {showAgentPicker && ( -
- - {pickerOpen && ( -
-
- {t('composer.agentPickerTitle', { currentAgent: currentAgentName })} -
-
- {mentionableAgents.map((agent) => ( - { - setTargetAgentId(agent.id); - setPickerOpen(false); - textareaRef.current?.focus(); - }} - /> - ))} -
-
- )} -
- )} - {/* Textarea */}