+ {/* Drop overlay */}
+ {isDragging && (
+
+
⬆
+
{t('agent.workspace.dragOrClick', 'Drop files to upload')}
+
+ )}
+
{/* Toolbar */}
{title &&
{title} }
@@ -514,7 +551,9 @@ export default function FileBrowser({
) : files.length === 0 ? (
- {t('common.noData')}
+ {upload && api.upload
+ ? t('agent.workspace.dragOrClick', 'Drop files here or click Upload')
+ : t('common.noData')}
) : (
diff --git a/frontend/src/hooks/useDropZone.ts b/frontend/src/hooks/useDropZone.ts
new file mode 100644
index 000000000..630dfbe41
--- /dev/null
+++ b/frontend/src/hooks/useDropZone.ts
@@ -0,0 +1,121 @@
+/**
+ * useDropZone — reusable drag-and-drop file upload hook.
+ *
+ * Uses a counter-based approach to handle nested elements correctly:
+ * dragenter/dragleave fire on every child element, so a simple boolean
+ * would flicker. The counter increments on dragenter and decrements on
+ * dragleave; isDragging is true when counter > 0.
+ */
+import { useState, useRef, useCallback, type DragEvent } from 'react';
+
+export interface UseDropZoneOptions {
+ /** Callback when files are dropped. Receives the filtered file list. */
+ onDrop: (files: File[]) => void;
+ /** When true, the drop zone is inactive (no visual feedback, drops ignored). */
+ disabled?: boolean;
+ /**
+ * Optional comma-separated list of accepted MIME types or extensions.
+ * e.g. ".json" or "image/*,.pdf"
+ * Files not matching are silently filtered out.
+ */
+ accept?: string;
+}
+
+export interface UseDropZoneReturn {
+ /** True when a drag-with-files is hovering over the zone. */
+ isDragging: boolean;
+ /** Spread these onto the container element acting as the drop zone. */
+ dropZoneProps: {
+ onDragEnter: (e: DragEvent) => void;
+ onDragOver: (e: DragEvent) => void;
+ onDragLeave: (e: DragEvent) => void;
+ onDrop: (e: DragEvent) => void;
+ };
+}
+
+/** Check whether a drag event contains files (vs plain text / URLs). */
+function hasFiles(e: DragEvent): boolean {
+ if (e.dataTransfer?.types) {
+ for (const t of Array.from(e.dataTransfer.types)) {
+ if (t === 'Files') return true;
+ }
+ }
+ return false;
+}
+
+/** Filter a FileList by an accept string (same format as ). */
+function filterFiles(files: FileList, accept?: string): File[] {
+ const list = Array.from(files);
+ if (!accept) return list;
+
+ const tokens = accept.split(',').map(t => t.trim().toLowerCase());
+
+ return list.filter(file => {
+ const ext = '.' + (file.name.split('.').pop() || '').toLowerCase();
+ const mime = file.type.toLowerCase();
+
+ return tokens.some(token => {
+ if (token.startsWith('.')) return ext === token;
+ if (token.endsWith('/*')) return mime.startsWith(token.slice(0, -1));
+ return mime === token;
+ });
+ });
+}
+
+export function useDropZone({ onDrop, disabled = false, accept }: UseDropZoneOptions): UseDropZoneReturn {
+ const [isDragging, setIsDragging] = useState(false);
+ const counterRef = useRef(0);
+
+ const handleDragEnter = useCallback((e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (disabled || !hasFiles(e)) return;
+ counterRef.current += 1;
+ if (counterRef.current === 1) setIsDragging(true);
+ }, [disabled]);
+
+ const handleDragOver = useCallback((e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!disabled && hasFiles(e)) {
+ e.dataTransfer.dropEffect = 'copy';
+ }
+ }, [disabled]);
+
+ const handleDragLeave = useCallback((e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (disabled) return;
+ counterRef.current -= 1;
+ if (counterRef.current <= 0) {
+ counterRef.current = 0;
+ setIsDragging(false);
+ }
+ }, [disabled]);
+
+ const handleDrop = useCallback((e: DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ counterRef.current = 0;
+ setIsDragging(false);
+ if (disabled) return;
+
+ const rawFiles = e.dataTransfer?.files;
+ if (!rawFiles || rawFiles.length === 0) return;
+
+ const filtered = filterFiles(rawFiles, accept);
+ if (filtered.length > 0) {
+ onDrop(filtered);
+ }
+ }, [disabled, accept, onDrop]);
+
+ return {
+ isDragging,
+ dropZoneProps: {
+ onDragEnter: handleDragEnter,
+ onDragOver: handleDragOver,
+ onDragLeave: handleDragLeave,
+ onDrop: handleDrop,
+ },
+ };
+}
diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json
index a5be3fd99..adcb5f37f 100644
--- a/frontend/src/i18n/en.json
+++ b/frontend/src/i18n/en.json
@@ -911,6 +911,15 @@
"invites": "Invitation Codes",
"identity": "OA Management"
},
+ "a2aAsync": {
+ "title": "Agent-to-Agent Async Communication",
+ "description": "Enable agents to communicate asynchronously with three modes: notify (one-way announcement), task_delegate (delegate work and get results back), and consult (synchronous question). When disabled, all agent-to-agent messages use synchronous consult mode — the same behavior as before this feature was introduced.",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "enabledHint": "Agents can use notify, task_delegate, and consult modes.",
+ "disabledHint": "All agent messages use synchronous consult mode.",
+ "enableWarning": "⚠️ You are about to enable the A2A Async Communication feature (Beta).\n\nThis feature allows agents to communicate asynchronously via notify and task_delegate modes.\n\nKnown potential issues:\n• Agent replies may contain internal technical terms (trigger names, focus items, etc.)\n• task_delegate callbacks may occasionally be delayed or dropped due to rate limiting\n• Token consumption will increase because each async message triggers a separate agent session\n• Agent loops may occur if triggers are not properly configured\n\nIf you encounter any issues, please return to this page and disable the toggle to restore stable synchronous behavior.\n\nAre you sure you want to enable this feature?"
+ },
"invites": {
"pageTitle": "Invitation Codes",
"pageDesc": "Manage invitation codes for platform registration.",
diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json
index ee020b0e1..d3fd7c8f0 100644
--- a/frontend/src/i18n/zh.json
+++ b/frontend/src/i18n/zh.json
@@ -1060,13 +1060,22 @@
"audit": "审计日志",
"config": "平台配置",
"kb": "公司知识库",
- "approvals": "审批",
- "skills": "技能管理",
+ "approvals": "审批流程",
+ "skills": "技能",
"quotas": "配额",
- "users": "用户",
+ "users": "用户管理",
"invites": "邀请码",
"identity": "OA管理"
},
+ "a2aAsync": {
+ "title": "数字员工间异步通信",
+ "description": "允许数字员工之间以三种模式通信:notify(单向通知,无需回复)、task_delegate(委派任务并等待结果返回)、consult(同步问答)。关闭后,所有数字员工间的消息将使用同步 consult 模式——即此功能上线前的原有行为。",
+ "enabled": "已开启",
+ "disabled": "已关闭",
+ "enabledHint": "数字员工可使用 notify、task_delegate 和 consult 三种通信模式。",
+ "disabledHint": "所有数字员工消息使用同步 consult 模式。",
+ "enableWarning": "⚠️ 您即将开启数字员工间异步通信功能(测试版)。\n\n此功能允许数字员工通过 notify 和 task_delegate 模式进行异步通信。\n\n已知可能的问题:\n• 数字员工的回复中可能包含内部技术术语(触发器名称、关注项等)\n• task_delegate 回调可能因速率限制而偶尔延迟或丢失\n• Token 消耗将增加,因为每条异步消息都会触发一个独立的数字员工会话\n• 如果触发器配置不当,可能出现数字员工循环调用\n\n如遇到任何问题,请返回此页面关闭开关,即可恢复稳定的同步通信行为。\n\n确定要开启此功能吗?"
+ },
"invites": {
"pageTitle": "邀请码",
"pageDesc": "管理平台注册的邀请码。",
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 1cced4e4c..1e1a199ac 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -26,6 +26,12 @@
--accent-subtle: rgba(225, 225, 232, 0.08);
--accent-text: #c0c0cc;
+ --segment-active-bg: var(--accent-primary);
+ --segment-active-text: var(--text-inverse);
+
+ /* Select chevron — @tabler/icons chevron-down, colored to --text-secondary */
+ --select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%238b8b9e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E%3Cpath d='M6 9l6 6l6 -6'/%3E%3C/svg%3E");
+
--success: #22c55e;
--success-subtle: rgba(34, 197, 94, 0.12);
--warning: #f59e0b;
@@ -195,6 +201,12 @@
--accent-subtle: rgba(58, 58, 66, 0.08);
--accent-text: #3a3a42;
+ --segment-active-bg: var(--bg-elevated);
+ --segment-active-text: var(--text-primary);
+
+ /* Select chevron — @tabler/icons chevron-down, colored to --text-secondary */
+ --select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%236b6b80' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath stroke='none' d='M0 0h24v24H0z' fill='none'/%3E%3Cpath d='M6 9l6 6l6 -6'/%3E%3C/svg%3E");
+
--success: #16a34a;
--success-subtle: rgba(22, 163, 74, 0.08);
--warning: #d97706;
@@ -279,8 +291,7 @@ button {
}
input,
-textarea,
-select {
+textarea {
font-family: inherit;
font-size: inherit;
color: inherit;
@@ -292,6 +303,26 @@ select {
transition: border-color var(--transition-fast);
}
+select {
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ background-color: var(--bg-elevated);
+ background-image: var(--select-chevron);
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+ background-size: 16px;
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-md);
+ padding: 4px 32px 4px 12px;
+ outline: none;
+ transition: border-color var(--transition-fast);
+ appearance: none;
+ -webkit-appearance: none;
+ cursor: pointer;
+ text-overflow: ellipsis;
+}
+
/* Theme-colored radio and checkbox */
input[type="radio"],
input[type="checkbox"] {
@@ -500,21 +531,21 @@ select:focus {
opacity: 1;
}
-/* Pinned icon swap: show pin by default, unpin on hover */
+/* Pinned icon swap: show pin by default, unpin only when hovering the button itself */
.sidebar-pin-btn.pinned .pin-hover {
display: none;
}
-.sidebar-agent-item:hover .sidebar-pin-btn.pinned .pin-default {
+.sidebar-pin-btn.pinned:hover .pin-default {
display: none;
}
-.sidebar-agent-item:hover .sidebar-pin-btn.pinned .pin-hover {
+.sidebar-pin-btn.pinned:hover .pin-hover {
display: inline;
}
-/* When pinned and hovered, turn red to indicate unpin */
-.sidebar-agent-item:hover .sidebar-pin-btn.pinned {
+/* When hovering the pin button itself on a pinned agent, turn red to indicate unpin */
+.sidebar-pin-btn.pinned:hover {
color: var(--error);
background: var(--error-subtle);
}
@@ -684,6 +715,18 @@ select:focus {
transition: all var(--transition-default);
}
+/* Chat page needs a fixed viewport height so the inner chat-messages
+ can scroll independently. Without this, .main-content grows with content
+ (min-height: 100vh) and overflow-y: auto on .chat-messages never fires. */
+.main-content.chat-page {
+ height: 100vh;
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+}
+
/* ─── Collapsed Sidebar Overrides ───────────────────── */
.sidebar.collapsed {
--sidebar-width: var(--sidebar-width-collapsed);
@@ -1044,23 +1087,100 @@ select:focus {
border-bottom-color: var(--accent-primary);
}
-/* Session sidebar admin tabs — same underline pattern as .tabs/.tab, compact for narrow column */
-.tabs.session-sidebar-session-tabs {
- gap: var(--space-5);
- margin-bottom: var(--space-2);
- margin-top: 0;
- padding: 2px 12px 0;
- position: relative;
- background: transparent;
- z-index: auto;
- top: auto;
+/* Session sidebar admin tabs — segment control style */
+.session-sidebar-segment-control {
+ display: flex;
+ margin: 0 12px 8px;
+ padding: 2px;
+ height: 28px;
+ background: var(--accent-subtle);
+ border-radius: 6px;
+ border: 1px solid var(--border-subtle);
+ box-sizing: border-box;
}
-.tabs.session-sidebar-session-tabs .tab {
- flex: 0 1 auto;
- padding: var(--space-2) var(--space-1) 10px;
+.session-sidebar-segment-control .segment-item {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
font-size: 12px;
+ color: var(--text-tertiary);
+ border-radius: 4px;
+ cursor: pointer;
+ transition: all 0.15s ease;
white-space: nowrap;
+ user-select: none;
+ border: none;
+ background: none;
+ padding: 0;
+ line-height: 1;
+}
+
+.session-sidebar-segment-control .segment-item:hover {
+ color: var(--text-secondary);
+}
+
+.session-sidebar-segment-control .segment-item.active {
+ background: var(--segment-active-bg);
+ color: var(--segment-active-text);
+ font-weight: 500;
+}
+
+/* New session button */
+.new-session-btn {
+ width: 100%;
+ height: 28px;
+ padding: 0 10px;
+ background: none;
+ border: 1px solid var(--border-subtle);
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ transition: all 0.15s ease;
+ box-sizing: border-box;
+ line-height: 1;
+}
+
+.new-session-btn:hover {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border-color: var(--accent-primary);
+}
+
+/* Session item — delete button & message count hover behaviour */
+.session-del-btn {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ flex-shrink: 0;
+ padding: 0;
+ border: none;
+ border-radius: 4px;
+ background: none;
+ color: var(--text-tertiary);
+ cursor: pointer;
+ transition: color 0.35s ease-in-out, background 0.35s ease-in-out;
+}
+
+.session-item:hover .session-del-btn {
+ display: flex;
+}
+
+.session-del-btn:hover {
+ color: var(--status-error);
+ background: var(--bg-hover);
+}
+
+.session-item:hover .session-msg-count {
+ display: none;
}
/* Page header */
@@ -1071,6 +1191,14 @@ select:focus {
margin-bottom: var(--space-6);
}
+/* When inside the chat page (main-content.chat-page removes outer padding),
+ give page-header its own padding to replicate the normal main-content spacing */
+.main-content.chat-page .page-header {
+ padding: var(--space-8) var(--space-8) 0;
+ flex-shrink: 0;
+}
+
+
.page-title {
font-size: var(--text-2xl);
font-weight: 600;
@@ -1345,7 +1473,7 @@ select:focus {
.login-field input,
.login-field select {
height: 44px;
- padding: 0 14px;
+ padding: 0 32px 0 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-default);
border-radius: 10px;
@@ -1578,9 +1706,20 @@ select:focus {
.chat-container {
display: flex;
flex-direction: column;
- height: calc(100vh - 100px);
+ /* Fill the chat-page main-content area (which is padding: 0, height: 100vh).
+ The page-header inside Chat is ~68px; flex: 1 + min-height: 0 lets
+ .chat-messages own the remaining space and scroll independently. */
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
}
+/* Restore horizontal padding that main-content normally provides */
+.main-content.chat-page .chat-container {
+ padding: 0 var(--space-8);
+}
+
+
/* When live panel is active, switch to horizontal layout */
.chat-container.chat-with-live-panel {
flex-direction: row;
@@ -2441,3 +2580,66 @@ select:focus {
.login-qr-back:hover {
text-decoration: underline;
}
+
+/* ─── Drop Zone Overlay (drag-and-drop upload) ───── */
+
+.drop-zone-wrapper {
+ position: relative;
+}
+
+.drop-zone-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 8px;
+ background: rgba(99, 102, 241, 0.07);
+ border: 2px dashed rgba(99, 102, 241, 0.45);
+ border-radius: 12px;
+ pointer-events: none;
+ animation: dropZoneFadeIn 0.15s ease;
+ backdrop-filter: blur(2px);
+}
+
+.drop-zone-overlay__icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: rgba(99, 102, 241, 0.12);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ color: var(--accent-primary);
+}
+
+.drop-zone-overlay__text {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--accent-primary);
+ letter-spacing: 0.2px;
+}
+
+.drop-zone-overlay__hint {
+ font-size: 11px;
+ color: var(--text-tertiary);
+}
+
+@keyframes dropZoneFadeIn {
+ from {
+ opacity: 0;
+ border-color: transparent;
+ }
+ to {
+ opacity: 1;
+ border-color: rgba(99, 102, 241, 0.45);
+ }
+}
+
+[data-theme="light"] .drop-zone-overlay {
+ background: rgba(99, 102, 241, 0.06);
+ border-color: rgba(99, 102, 241, 0.35);
+}
diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx
index 1a253f592..5c2168dbb 100644
--- a/frontend/src/pages/AgentDetail.tsx
+++ b/frontend/src/pages/AgentDetail.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useMemo, useRef, Component, ErrorInfo } from 'react';
+import React, { useState, useEffect, useMemo, useRef, useCallback, Component, ErrorInfo } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -18,6 +18,7 @@ import { useAuthStore } from '../stores';
import { copyToClipboard } from '../utils/clipboard';
import { formatFileSize } from '../utils/formatFileSize';
import { IconPaperclip, IconSend } from '@tabler/icons-react';
+import { useDropZone } from '../hooks/useDropZone';
const TABS = ['status', 'aware', 'mind', 'tools', 'skills', 'relationships', 'workspace', 'chat', 'activityLog', 'approvals', 'settings'] as const;
@@ -1515,10 +1516,14 @@ function AgentDetailInner() {
const isViewingOtherUsersSessions = canViewAllAgentChatSessions && chatScope === 'all';
- /** Sessions in scope=all that are not the current viewer's own P2P rows (for admin「其他用户」tab). */
+ /** Sessions in scope=all that are not the current viewer's own P2P rows (for admin「其他用户」tab).
+ * Agent-to-agent sessions (source_channel === 'agent') store the creator's user_id, so we must
+ * exempt them from the user_id check — otherwise they'd always be hidden. */
const otherUsersSessions = useMemo(() => {
const vu = viewerUserIdStr();
return allSessions.filter((s: any) => {
+ // Always show agent-to-agent sessions in the "Other users" tab
+ if (String(s.source_channel || '').toLowerCase() === 'agent') return true;
const su = sessionUserIdStr(s);
if (vu && su === vu) return false;
return true;
@@ -2163,6 +2168,24 @@ function AgentDetailInner() {
const resolvedAvatarText = avatarText || (resolvedSenderLabel ? resolvedSenderLabel[0] : (isLeft ? 'A' : 'U'));
const showSenderLabel = !!resolvedSenderLabel && (forceSenderLabel || !!msg.sender_name);
+ // Parse [image_data:data:image/...;base64,...] markers from user message content.
+ // The backend persists these markers in the DB to preserve multimodal context
+ // across turns. They must ALWAYS be stripped from displayContent so users never
+ // see raw base64 strings in the chat bubble.
+ // Guard: only collect extracted images for thumbnail rendering when msg.imageUrl
+ // is NOT already set — otherwise the image is already shown via the isImage path
+ // and rendering again from the marker would display it twice.
+ const IMAGE_DATA_RE = /\[image_data:(data:image\/[^;]+;base64,[^\]]+)\]/g;
+ const inlineImages: string[] = [];
+ let displayContent = msg.content || '';
+ if (displayContent.includes('[image_data:')) {
+ displayContent = displayContent.replace(IMAGE_DATA_RE, (_: string, dataUrl: string) => {
+ // Only collect for thumbnail rendering if not already shown via imageUrl
+ if (!msg.imageUrl) inlineImages.push(dataUrl);
+ return ''; // always strip the marker from displayed text
+ }).trim();
+ }
+
const timestampHtml = msg.timestamp ? (() => {
const d = new Date(msg.timestamp);
const now = new Date();
@@ -2195,9 +2218,23 @@ function AgentDetailInner() {
{msg.fileName}
))}
+ {/* Render images extracted from [image_data:] markers (multimodal context) */}
+ {inlineImages.length > 0 && (
+
+ {inlineImages.map((url, idx) => (
+
+ ))}
+
+ )}
{msg.thinking && (
- 💭 Thinking
+ Thinking
{msg.thinking}
)}
@@ -2207,8 +2244,8 @@ function AgentDetailInner() {
{t('agent.chat.thinking', 'Thinking...')}