feat: add @mention routing for direct single-agent conversation

- Add @mention dropdown UI in input box (keyboard navigable)
- Parse {{@agentId}} tags in message content for mention badge rendering
- Backend: route @agent messages directly to persistent agents, skip workforce
- Backend: reuse persistent agents across turns (preserve toolkit state)
- Frontend: persist mention target across turns, render in input and chat bubbles
- Fix keyboard ArrowUp/Down selection in mention dropdown
This commit is contained in:
puzhen 2026-02-27 01:03:48 +00:00
parent fb740bbe3f
commit 26a00eb2f9
13 changed files with 843 additions and 52 deletions

View file

@ -189,6 +189,25 @@ def browser_agent(options: Chat):
else:
selected_port = env("browser_port", "9222")
enabled_browser_tools = [
"browser_click",
"browser_type",
"browser_back",
"browser_forward",
"browser_select",
"browser_console_exec",
"browser_console_view",
"browser_switch_tab",
"browser_enter",
"browser_visit_page",
"browser_scroll",
"browser_sheet_read",
"browser_sheet_input",
"browser_get_page_snapshot",
]
if selected_is_external:
enabled_browser_tools.append("browser_open")
web_toolkit_custom = HybridBrowserToolkit(
options.project_id,
cdp_keep_current_page=True,
@ -197,23 +216,7 @@ def browser_agent(options: Chat):
stealth=True,
session_id=toolkit_session_id,
cdp_url=f"http://localhost:{selected_port}",
enabled_tools=[
"browser_click",
"browser_type",
"browser_back",
"browser_forward",
"browser_select",
"browser_console_exec",
"browser_console_view",
"browser_switch_tab",
"browser_enter",
"browser_visit_page",
"browser_scroll",
"browser_sheet_read",
"browser_sheet_input",
"browser_get_page_snapshot",
"browser_open",
],
enabled_tools=enabled_browser_tools,
)
# Save reference before registering for toolkits_to_register_agent

View file

@ -342,6 +342,7 @@ def improve(id: str, data: SupplementChat):
data=ImprovePayload(
question=data.question,
attaches=data.attaches or [],
target=data.target,
),
new_task_id=data.task_id,
)
@ -349,7 +350,7 @@ def improve(id: str, data: SupplementChat):
)
chat_logger.info(
"Improvement request queued with preserved context",
extra={"project_id": id},
extra={"project_id": id, "target": data.target},
)
return Response(status_code=201)

View file

@ -78,6 +78,9 @@ class Chat(BaseModel):
search_config: dict[str, str] | None = None
# User identifier for user-specific skill configurations
user_id: str | None = None
# Target agent for @mention routing: "browser", "dev", "doc",
# "media", "workforce", or None (default behavior)
target: str | None = None
@field_validator("model_type")
@classmethod
@ -141,6 +144,7 @@ class SupplementChat(BaseModel):
question: str
task_id: str | None = None
attaches: list[str] = []
target: str | None = None
class HumanReply(BaseModel):

View file

@ -35,6 +35,7 @@ from app.agent.factory import (
mcp_agent,
multi_modal_agent,
question_confirm_agent,
social_media_agent,
task_summary_agent,
)
from app.agent.listen_chat_agent import ListenChatAgent
@ -48,6 +49,7 @@ from app.service.task import (
Action,
ActionDecomposeProgressData,
ActionDecomposeTextData,
ActionEndData,
ActionImproveData,
ActionInstallMcpData,
ActionNewAgent,
@ -284,6 +286,98 @@ def build_context_for_workforce(
)
# ================================================================
# @mention direct agent routing
# ================================================================
# Maps @mention target names to (factory_fn, is_async) pairs
_AGENT_TARGET_MAP: dict[str, tuple] = {
"browser": (browser_agent, False),
"dev": (developer_agent, True),
"doc": (document_agent, True),
"media": (multi_modal_agent, False),
"social": (social_media_agent, True),
}
async def _create_persistent_agent(
target: str, options: Chat
) -> ListenChatAgent:
"""Create a persistent agent by target name using existing factories."""
if target not in _AGENT_TARGET_MAP:
raise ValueError(
f"Unknown agent target: {target}. "
f"Valid targets: {list(_AGENT_TARGET_MAP.keys())}"
)
factory_fn, is_async = _AGENT_TARGET_MAP[target]
if is_async:
agent = await factory_fn(options)
else:
agent = await asyncio.to_thread(factory_fn, options)
logger.info(
f"[DIRECT-AGENT] Created persistent agent: {target}",
extra={
"project_id": options.project_id,
"agent_name": getattr(agent, "agent_name", target),
"agent_id": getattr(agent, "agent_id", ""),
},
)
return agent
async def _run_direct_agent(
agent,
prompt: str,
question: str,
task_lock: TaskLock,
):
"""Background task that runs a direct agent step.
agent.astep() internally sends activate_agent and deactivate_agent
events via the queue, so step_solve's main loop can process them
in real-time alongside toolkit events.
After completion, puts ActionEndData into the queue so step_solve
yields the 'end' SSE event.
"""
from camel.agents.chat_agent import (
AsyncStreamingChatAgentResponse,
)
response_content = ""
try:
response = await agent.astep(prompt)
if isinstance(response, AsyncStreamingChatAgentResponse):
# Must consume the stream to trigger deactivation
# in _astream_chunks's finally block
async for chunk in response:
if chunk.msg and chunk.msg.content:
response_content += chunk.msg.content
else:
response_content = response.msg.content if response.msg else ""
except Exception as e:
logger.error(
f"[DIRECT-AGENT] Error executing agent: {e}",
exc_info=True,
)
response_content = f"Error executing agent: {e}"
# Save conversation history
task_lock.add_conversation("user", question)
task_lock.add_conversation("assistant", response_content)
# Yield control so the agent's deactivate_agent event
# (scheduled via _schedule_async_task in _astream_chunks's
# finally block) fires before the end event.
# Without this, end arrives first and frontend ignores
# deactivate_agent because task is already FINISHED.
await asyncio.sleep(0.1)
# Signal completion — step_solve's Action.end handler
# will yield sse_json("end", ...) and set status to done
await task_lock.put_queue(ActionEndData(data=response_content[:300]))
@sync_step
async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
"""Main task execution loop. Called when POST /chat endpoint
@ -469,6 +563,135 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
f"'{question[:100]}...'"
)
# --- @mention direct agent routing ---
target: str | None = None
if (
hasattr(options, "target")
and options.target
and loop_iteration == 1
):
target = options.target
elif isinstance(item, ActionImproveData) and item.data.target:
target = item.data.target
if (
target
and target != "workforce"
and target in _AGENT_TARGET_MAP
):
# Direct agent mode: keep the SAME task_id
# across turns (continuous chat in one chatStore).
# Do NOT update options.task_id here — the
# frontend reuses the existing chatStore.
logger.info(
f"[DIRECT-AGENT] Routing to @{target}",
extra={
"project_id": options.project_id,
"target": target,
"task_id": options.task_id,
},
)
# Send confirmed event so frontend transitions
# from pending/splitting state.
# direct=True tells frontend to skip task
# splitting and go straight to RUNNING.
yield sse_json(
"confirmed",
{"question": question, "direct": True},
)
# Ensure event loop is set for agent internals
set_main_event_loop(asyncio.get_running_loop())
# Get or create persistent agent
agent = task_lock.persistent_agents.get(target)
is_new_agent = agent is None
logger.info(
f"[DIRECT-AGENT] persistent_agents"
f" keys: {list(task_lock.persistent_agents.keys())},"
f" is_new={is_new_agent},"
f" target={target}",
)
if is_new_agent:
# Factory internally sends create_agent
# via ActionCreateAgentData in the queue
agent = await _create_persistent_agent(target, options)
task_lock.persistent_agents[target] = agent
logger.info(
f"[DIRECT-AGENT] Created NEW "
f"agent: {agent.agent_name} "
f"(id={agent.agent_id})",
)
else:
logger.info(
f"[DIRECT-AGENT] REUSING "
f"agent: {agent.agent_name} "
f"(id={agent.agent_id})",
)
# Reused agent: factory won't send
# create_agent, so we must send it
# explicitly for the new chatStore
tool_names = []
for t in getattr(agent, "tools", []):
fn_name = getattr(t, "get_function_name", None)
if fn_name:
tool_names.append(fn_name())
yield sse_json(
"create_agent",
{
"agent_name": agent.agent_name,
"agent_id": agent.agent_id,
"tools": tool_names,
},
)
agent.process_task_id = options.task_id
# Build prompt: reused agents already have
# conversation history in their CAMEL memory,
# so only prepend context for brand-new agents.
if is_new_agent:
conv_ctx = build_conversation_context(
task_lock,
header="=== Previous Conversation ===",
)
prompt = f"{conv_ctx}\nUser: {question}"
else:
prompt = question
if attaches_to_use:
prompt += f"\n\nAttached files: {attaches_to_use}"
# Launch agent in background task so step_solve's
# while loop can process queue events (activate,
# toolkit, deactivate) in real-time.
# agent.astep() internally sends activate_agent
# and deactivate_agent via the queue.
task = asyncio.create_task(
_run_direct_agent(
agent,
prompt,
question,
task_lock,
)
)
task_lock.add_background_task(task)
continue
if target == "workforce":
logger.info(
"[DIRECT-AGENT] @workforce: "
"cleaning up persistent agents",
extra={
"project_id": options.project_id,
},
)
await task_lock.cleanup_persistent_agents()
# --- end @mention routing ---
is_exceeded, total_length = check_conversation_history_length(
task_lock
)

View file

@ -76,6 +76,7 @@ class ImprovePayload(BaseModel):
question: str
attaches: list[str] = []
target: str | None = None
class ActionImproveData(BaseModel):
@ -225,6 +226,7 @@ class ActionStopData(BaseModel):
class ActionEndData(BaseModel):
action: Literal[Action.end] = Action.end
data: str | None = None
class ActionTimeoutData(BaseModel):
@ -351,6 +353,8 @@ class TaskLock:
"""Track if summary has been generated for this project"""
current_task_id: str | None
"""Current task ID to be used in SSE responses"""
persistent_agents: dict[str, Any]
"""Persistent agents for @mention direct chat (key: agent type name)"""
def __init__(
self, id: str, queue: asyncio.Queue, human_input: dict
@ -369,6 +373,7 @@ class TaskLock:
self.last_task_summary = ""
self.question_agent = None
self.current_task_id = None
self.persistent_agents = {}
logger.info(
"Task lock initialized",
@ -426,6 +431,35 @@ class TaskLock:
self.background_tasks.add(task)
task.add_done_callback(lambda t: self.background_tasks.discard(t))
async def cleanup_persistent_agents(self):
r"""Release all persistent agents and their resources (e.g. CDP)."""
if not self.persistent_agents:
return
logger.info(
"Cleaning up persistent agents",
extra={
"task_id": self.id,
"agents": list(self.persistent_agents.keys()),
},
)
for name, agent in self.persistent_agents.items():
try:
if (
hasattr(agent, "_cdp_release_callback")
and agent._cdp_release_callback
):
agent._cdp_release_callback(agent)
logger.info(
f"Released CDP for persistent agent {name}",
extra={"task_id": self.id},
)
except Exception as e:
logger.warning(
f"Failed to release CDP for persistent agent {name}: {e}",
extra={"task_id": self.id},
)
self.persistent_agents.clear()
async def cleanup(self):
r"""Cancel all background tasks and clean up resources"""
logger.info(
@ -435,6 +469,10 @@ class TaskLock:
"background_tasks_count": len(self.background_tasks),
},
)
# Clean up persistent agents first
await self.cleanup_persistent_agents()
for task in list(self.background_tasks):
if not task.done():
task.cancel()

View file

@ -352,6 +352,9 @@ app.commandLine.appendSwitch('max_old_space_size', '4096');
app.commandLine.appendSwitch('enable-features', 'MemoryPressureReduction');
app.commandLine.appendSwitch('renderer-process-limit', '8');
// Disable Fontations (Rust-based font engine) to prevent crashes on macOS
app.commandLine.appendSwitch('disable-features', 'Fontations');
// ==================== Proxy configuration ====================
// Read proxy from global .env file on startup
proxyUrl = readGlobalEnvKey('HTTP_PROXY');

117
server/uv.lock generated
View file

@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = "==3.12.*"
resolution-markers = [
"sys_platform == 'win32'",
@ -223,6 +223,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/2e/500ff29726ef207fdf6b625e62caf3839662c5d845897efc93bdf019192a/convert_case-1.2.3-py3-none-any.whl", hash = "sha256:ec8884050ca548e990666f82cba7ae2edfaa3c85dbead3042c2fd663b292373a", size = 9373, upload-time = "2023-05-23T19:27:06.039Z" },
]
[[package]]
name = "coverage"
version = "7.13.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
@ -327,6 +351,13 @@ dependencies = [
{ name = "sqlmodel" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.15.2" },
@ -352,6 +383,9 @@ requires-dist = [
{ name = "pydantic-i18n", specifier = ">=0.4.5" },
{ name = "pydash", specifier = ">=8.0.5" },
{ name = "pyjwt", specifier = ">=2.10.1" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" },
{ name = "python-dotenv", specifier = ">=1.1.0" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "requests", specifier = ">=2.32.4" },
@ -359,6 +393,7 @@ requires-dist = [
{ name = "sqlalchemy-utils", specifier = ">=0.41.2" },
{ name = "sqlmodel", specifier = ">=0.0.24" },
]
provides-extras = ["dev"]
[[package]]
name = "email-validator"
@ -470,6 +505,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
@ -531,6 +567,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
@ -698,6 +743,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pandas"
version = "3.0.0"
@ -752,6 +806,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "psutil"
version = "5.9.8"
@ -881,6 +944,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/b7/cc5e7974699db40014d58c7dd7c4ad4ffc244d36930dc9ec7d06ee67d7a9/pydash-8.0.6-py3-none-any.whl", hash = "sha256:ee70a81a5b292c007f28f03a4ee8e75c1f5d7576df5457b836ec7ab2839cc5d0", size = 101561, upload-time = "2026-01-17T16:42:55.448Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyjwt"
version = "2.10.1"
@ -895,6 +967,49 @@ crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"

View file

@ -29,9 +29,14 @@ import {
UploadCloud,
X,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import {
BUILTIN_AGENTS,
MentionAgent,
MentionDropdown,
} from './MentionDropdown';
/**
* File attachment object
@ -71,6 +76,10 @@ export interface InputboxProps {
privacy?: boolean;
/** Use cloud model in dev */
useCloudModelInDev?: boolean;
/** Active @mention target (e.g. "browser") — shown as a tag in the input */
mentionTarget?: string | null;
/** Callback when mention target changes */
onMentionTargetChange?: (target: string | null) => void;
}
/**
@ -126,6 +135,8 @@ export const Inputbox = ({
allowDragDrop = false,
privacy = true,
useCloudModelInDev = false,
mentionTarget,
onMentionTargetChange,
}: InputboxProps) => {
const { t } = useTranslation();
const internalTextareaRef = useRef<HTMLTextAreaElement>(null);
@ -137,6 +148,11 @@ export const Inputbox = ({
const [isRemainingOpen, setIsRemainingOpen] = useState(false);
const hoverCloseTimerRef = useRef<number | null>(null);
const [isComposing, setIsComposing] = useState(false);
const [mentionState, setMentionState] = useState<{
visible: boolean;
filter: string;
startIndex: number;
}>({ visible: false, filter: '', startIndex: -1 });
const openRemainingPopover = () => {
if (hoverCloseTimerRef.current) {
@ -168,8 +184,56 @@ export const Inputbox = ({
const hasContent = value.trim().length > 0 || files.length > 0;
const isActive = isFocused || hasContent;
const handleTextChange = (newValue: string) => {
const handleTextChange = useCallback(
(newValue: string, cursorPos?: number) => {
onChange?.(newValue);
// Detect @ mention
const pos = cursorPos ?? newValue.length;
const textBeforeCursor = newValue.slice(0, pos);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (
lastAtIndex >= 0 &&
(lastAtIndex === 0 ||
textBeforeCursor[lastAtIndex - 1] === ' ' ||
textBeforeCursor[lastAtIndex - 1] === '\n')
) {
const filterText = textBeforeCursor.slice(lastAtIndex + 1);
if (!filterText.includes(' ')) {
setMentionState({
visible: true,
filter: filterText,
startIndex: lastAtIndex,
});
return;
}
}
setMentionState({ visible: false, filter: '', startIndex: -1 });
},
[onChange]
);
const handleMentionSelect = (agent: MentionAgent) => {
// Remove the "@filter" text from the input and set the
// mention target as a rendered tag instead
const currentValue = value;
const before = currentValue.slice(0, mentionState.startIndex);
const afterFilterEnd =
mentionState.startIndex + 1 + mentionState.filter.length;
const after = currentValue.slice(afterFilterEnd);
const newValue = `${before}${after}`.trimStart();
onChange?.(newValue);
onMentionTargetChange?.(agent.id);
setMentionState({ visible: false, filter: '', startIndex: -1 });
// Focus textarea
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
el.focus();
}
});
};
const handleSend = () => {
@ -183,6 +247,23 @@ export const Inputbox = ({
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// When mention dropdown is open, let it handle navigation keys
if (mentionState.visible) {
if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape', 'Enter'].includes(e.key)) {
e.preventDefault(); // Stop textarea from scrolling / inserting newline
return; // Let MentionDropdown's global handler handle these
}
}
// Backspace at cursor position 0 removes the mention tag
if (
e.key === 'Backspace' &&
mentionTarget &&
textareaRef.current?.selectionStart === 0 &&
textareaRef.current?.selectionEnd === 0
) {
e.preventDefault();
onMentionTargetChange?.(null);
}
if (e.key === 'Enter' && !e.shiftKey && !disabled && !isComposing) {
e.preventDefault();
handleSend();
@ -289,15 +370,43 @@ export const Inputbox = ({
<div className="text-sm font-semibold">Drop files to attach</div>
</div>
)}
{/* @Mention Dropdown */}
<MentionDropdown
visible={mentionState.visible}
filter={mentionState.filter}
onSelect={handleMentionSelect}
onClose={() =>
setMentionState({ visible: false, filter: '', startIndex: -1 })
}
/>
{/* Text Input Area */}
<div className="relative box-border flex w-full items-center justify-center gap-2.5 px-0 pb-2 pt-2.5">
<div className="relative mx-2 box-border flex min-h-px min-w-px flex-1 items-center justify-center gap-2.5 py-0">
<div className="relative mx-2 box-border flex min-h-px min-w-px flex-1 items-center gap-2 py-0">
{/* @Mention Tag */}
{mentionTarget && (
<span
className="bg-info-primary/15 text-info-primary hover:bg-info-primary/25 inline-flex shrink-0 cursor-pointer items-center gap-1 rounded-md px-2 py-0.5 text-xs font-semibold transition-colors"
onClick={() => onMentionTargetChange?.(null)}
title="Click to remove"
>
@
{BUILTIN_AGENTS.find((a) => a.id === mentionTarget)?.label ??
mentionTarget}
<X size={12} className="opacity-60" />
</span>
)}
<Textarea
variant="none"
size="default"
ref={textareaRef}
value={value}
onChange={(e) => handleTextChange(e.target.value)}
onChange={(e) =>
handleTextChange(
e.target.value,
e.target.selectionStart ?? undefined
)
}
onKeyDown={handleKeyDown}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}

View file

@ -0,0 +1,156 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { cn } from '@/lib/utils';
import { useEffect, useRef, useState } from 'react';
export interface MentionAgent {
id: string;
label: string;
description: string;
}
export const BUILTIN_AGENTS: MentionAgent[] = [
{
id: 'workforce',
label: 'Workforce',
description: 'Task decomposition & multi-agent collaboration',
},
{
id: 'browser',
label: 'Browser Agent',
description: 'Browser automation',
},
{
id: 'dev',
label: 'Developer Agent',
description: 'Terminal & code execution',
},
{
id: 'doc',
label: 'Document Agent',
description: 'Document processing',
},
{
id: 'media',
label: 'Multi Modal Agent',
description: 'Image & video analysis',
},
];
interface MentionDropdownProps {
visible: boolean;
filter: string;
onSelect: (agent: MentionAgent) => void;
onClose: () => void;
}
export const MentionDropdown = ({
visible,
filter,
onSelect,
onClose,
}: MentionDropdownProps) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const filteredAgents = BUILTIN_AGENTS.filter(
(agent) =>
agent.id.toLowerCase().includes(filter.toLowerCase()) ||
agent.label.toLowerCase().includes(filter.toLowerCase())
);
// Reset selection when filter changes
useEffect(() => {
setSelectedIndex(0);
}, [filter]);
// Keyboard navigation
useEffect(() => {
if (!visible) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
setSelectedIndex((prev) =>
prev < filteredAgents.length - 1 ? prev + 1 : 0
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
setSelectedIndex((prev) =>
prev > 0 ? prev - 1 : filteredAgents.length - 1
);
} else if (e.key === 'Enter' || e.key === 'Tab') {
if (filteredAgents.length > 0) {
e.preventDefault();
e.stopPropagation();
onSelect(filteredAgents[selectedIndex]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [visible, filteredAgents, selectedIndex, onSelect, onClose]);
// Scroll selected item into view
useEffect(() => {
if (!visible || !listRef.current) return;
const item = listRef.current.children[selectedIndex] as HTMLElement;
item?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex, visible]);
if (!visible || filteredAgents.length === 0) return null;
return (
<div
ref={listRef}
className={cn(
'absolute bottom-full left-0 z-50 mb-1 w-64',
'rounded-lg border border-dropdown-border bg-dropdown-bg shadow-perfect',
'max-h-[240px] overflow-auto py-1'
)}
>
{filteredAgents.map((agent, index) => (
<div
key={agent.id}
className={cn(
'flex cursor-pointer flex-col gap-0.5 px-3 py-2 transition-colors',
index === selectedIndex
? 'bg-dropdown-item-bg-hover'
: 'hover:bg-dropdown-item-bg-hover'
)}
onMouseEnter={() => setSelectedIndex(index)}
onMouseDown={(e) => {
e.preventDefault(); // Prevent textarea blur
onSelect(agent);
}}
>
<span className="text-sm font-medium text-text-body">
@{agent.id}
<span className="ml-2 text-xs font-normal text-text-label">
{agent.label}
</span>
</span>
<span className="text-xs text-text-label">{agent.description}</span>
</div>
))}
</div>
);
};

View file

@ -24,12 +24,13 @@ const COPIED_RESET_MS = 2000;
const SKILL_TAG_REGEX = /\{\{([^}]+)\}\}/g;
function parseContentWithSkillTags(
content: string
): Array<{ type: 'text'; value: string } | { type: 'skill'; name: string }> {
const nodes: Array<
{ type: 'text'; value: string } | { type: 'skill'; name: string }
> = [];
type ContentNode =
| { type: 'text'; value: string }
| { type: 'skill'; name: string }
| { type: 'mention'; id: string };
function parseContentWithTags(content: string): ContentNode[] {
const nodes: ContentNode[] = [];
let lastIndex = 0;
let m: RegExpExecArray | null;
SKILL_TAG_REGEX.lastIndex = 0;
@ -37,7 +38,13 @@ function parseContentWithSkillTags(
if (m.index > lastIndex) {
nodes.push({ type: 'text', value: content.slice(lastIndex, m.index) });
}
nodes.push({ type: 'skill', name: m[1].trim() });
const inner = m[1].trim();
if (inner.startsWith('@')) {
// {{@browser}} → mention tag
nodes.push({ type: 'mention', id: inner.slice(1) });
} else {
nodes.push({ type: 'skill', name: inner });
}
lastIndex = m.index + m[0].length;
}
if (lastIndex < content.length) {
@ -46,6 +53,14 @@ function parseContentWithSkillTags(
return nodes.length > 0 ? nodes : [{ type: 'text', value: content }];
}
const MENTION_LABELS: Record<string, string> = {
workforce: 'Workforce',
browser: 'Browser Agent',
dev: 'Developer Agent',
doc: 'Document Agent',
media: 'Multi Modal Agent',
};
interface UserMessageCardProps {
id: string;
content: string;
@ -107,8 +122,10 @@ export function UserMessageCard({
window.electronAPI?.openSkillFolder?.(skillName);
};
const contentNodes = parseContentWithSkillTags(content);
const hasSkillTags = contentNodes.some((n) => n.type === 'skill');
const contentNodes = parseContentWithTags(content);
const hasSpecialTags = contentNodes.some(
(n) => n.type === 'skill' || n.type === 'mention'
);
return (
<div
@ -125,11 +142,23 @@ export function UserMessageCard({
</Button>
</div>
<div className="whitespace-pre-wrap break-words text-body-sm text-text-body">
{hasSkillTags
? contentNodes.map((node, i) =>
node.type === 'text' ? (
<span key={i}>{node.value}</span>
) : (
{hasSpecialTags
? contentNodes.map((node, i) => {
if (node.type === 'text') {
return <span key={i}>{node.value}</span>;
}
if (node.type === 'mention') {
return (
<span
key={i}
className="bg-info-primary/15 text-info-primary mr-1.5 inline-flex items-center rounded-md px-1.5 py-0.5 align-middle text-xs font-semibold"
>
@{MENTION_LABELS[node.id] ?? node.id}
</span>
);
}
// skill
return (
<button
key={i}
type="button"
@ -143,8 +172,8 @@ export function UserMessageCard({
<Sparkles className="h-3.5 w-3.5 text-icon-primary" />
{node.name}
</button>
)
)
);
})
: content}
</div>
{attaches && attaches.length > 0 && (

View file

@ -34,6 +34,7 @@ import { ProjectChatContainer } from './ProjectChatContainer';
export default function ChatBox(): JSX.Element {
const [message, setMessage] = useState<string>('');
const [mentionTarget, setMentionTarget] = useState<string | null>(null);
//Get Chatstore for the active project's task
const { chatStore, projectStore } = useChatStoreAdapter();
@ -261,8 +262,10 @@ export default function ChatBox(): JSX.Element {
task.messages.some(
(m) => m.step === AgentStep.TO_SUB_TASKS && !m.isConfirm
) ||
// skeleton/computing phase
(!task.messages.find((m) => m.step === AgentStep.TO_SUB_TASKS) &&
// skeleton/computing phase (not applicable after task finishes or in direct agent mode)
((task.status as string) !== ChatTaskStatus.FINISHED &&
(task.status as string) !== ChatTaskStatus.RUNNING &&
!task.messages.find((m) => m.step === AgentStep.TO_SUB_TASKS) &&
!task.hasWaitComfirm &&
task.messages.length > 0) ||
task.isTakeControl
@ -430,7 +433,31 @@ export default function ChatBox(): JSX.Element {
return;
}
const tempMessageContent = messageStr || message;
const rawMessageContent = messageStr || message;
// Use the active mentionTarget state (rendered as a tag in the input).
// Fall back to parsing @mention from text for backwards compat.
let activeMentionTarget = mentionTarget;
let tempMessageContent = rawMessageContent;
if (!activeMentionTarget) {
const mentionMatch = rawMessageContent.match(/^@(\w+)\s+([\s\S]*)/);
if (mentionMatch) {
activeMentionTarget = mentionMatch[1];
tempMessageContent = mentionMatch[2];
}
}
// Build display content: embed mention as {{@agentId}} so it
// survives in the message text and gets rendered like skill tags.
const displayContent = activeMentionTarget
? `{{@${activeMentionTarget}}} ${tempMessageContent}`
: tempMessageContent;
// Persist the mention target so the tag stays for the next turn
if (activeMentionTarget && activeMentionTarget !== mentionTarget) {
setMentionTarget(activeMentionTarget);
}
chatStore.setHasMessages(_taskId as string, true);
if (!_taskId) return;
@ -447,7 +474,8 @@ export default function ChatBox(): JSX.Element {
) ||
(!task.messages.find((m) => m.step === AgentStep.TO_SUB_TASKS) &&
!task.hasWaitComfirm &&
task.messages.length > 0) ||
task.messages.length > 0 &&
task.status !== ChatTaskStatus.FINISHED) ||
task.isTakeControl ||
// explicit confirm wait while task is pending but card not confirmed yet
(!!task.messages.find(
@ -471,7 +499,7 @@ export default function ChatBox(): JSX.Element {
chatStore.addMessages(_taskId, {
id: generateUniqueId(),
role: 'user',
content: tempMessageContent,
content: displayContent,
attaches:
JSON.parse(JSON.stringify(chatStore.tasks[_taskId]?.attaches)) ||
[],
@ -568,7 +596,9 @@ export default function ChatBox(): JSX.Element {
undefined,
undefined,
tempMessageContent,
attachesToSend
attachesToSend,
activeMentionTarget ?? undefined,
displayContent
);
chatStore.setAttaches(_taskId, []);
} catch (err: any) {
@ -603,12 +633,13 @@ export default function ChatBox(): JSX.Element {
question: tempMessageContent,
task_id: nextTaskId,
attaches: improveAttaches,
target: activeMentionTarget,
});
chatStore.setIsPending(_taskId, true);
chatStore.addMessages(_taskId, {
id: generateUniqueId(),
role: 'user',
content: tempMessageContent,
content: displayContent,
attaches: attachesForThisTurn,
});
chatStore.setAttaches(_taskId, []);
@ -648,7 +679,9 @@ export default function ChatBox(): JSX.Element {
undefined,
undefined,
tempMessageContent,
attachesToSend
attachesToSend,
activeMentionTarget ?? undefined,
displayContent
);
chatStore.setHasWaitComfirm(_taskId as string, true);
chatStore.setAttaches(_taskId, []);
@ -895,8 +928,10 @@ export default function ChatBox(): JSX.Element {
// Determine if we're in the "splitting in progress" phase (skeleton visible)
// Only show splitting if there's NO to_sub_tasks message yet (not even confirmed)
// Skip splitting phase when task is already RUNNING (e.g. direct @agent mode)
const isSkeletonPhase =
(task.status !== ChatTaskStatus.FINISHED &&
task.status !== ChatTaskStatus.RUNNING &&
!anyToSubTasksMessage &&
!task.hasWaitComfirm &&
task.messages.length > 0) ||
@ -1078,6 +1113,8 @@ export default function ChatBox(): JSX.Element {
allowDragDrop: true,
privacy: privacy,
useCloudModelInDev: useCloudModelInDev,
mentionTarget: mentionTarget,
onMentionTargetChange: setMentionTarget,
}}
/>
)}
@ -1122,6 +1159,8 @@ export default function ChatBox(): JSX.Element {
allowDragDrop: true,
privacy: privacy,
useCloudModelInDev: useCloudModelInDev,
mentionTarget: mentionTarget,
onMentionTargetChange: setMentionTarget,
}}
/>
)}

View file

@ -94,7 +94,9 @@ export interface ChatStore {
shareToken?: string,
delayTime?: number,
messageContent?: string,
messageAttaches?: File[]
messageAttaches?: File[],
target?: string,
displayContent?: string
) => Promise<void>;
handleConfirmTask: (
project_id: string,
@ -417,7 +419,9 @@ const chatStore = (initial?: Partial<ChatStore>) =>
shareToken?: string,
delayTime?: number,
messageContent?: string,
messageAttaches?: File[]
messageAttaches?: File[],
target?: string,
displayContent?: string
) => {
// ✅ Wait for backend to be ready before starting task (except for replay/share)
if (!type || type === 'normal') {
@ -480,7 +484,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
targetChatStore.getState().addMessages(newTaskId, {
id: generateUniqueId(),
role: 'user',
content: messageContent,
content: displayContent || messageContent,
attaches: messageAttaches || [],
});
targetChatStore.getState().setHasMessages(newTaskId, true);
@ -732,6 +736,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
cdp_browsers: cdp_browsers,
env_path: envPath,
search_config: searchConfig,
target: target || null,
})
: undefined,
@ -816,8 +821,22 @@ const chatStore = (initial?: Partial<ChatStore>) =>
const shouldCreateNewChat =
project_id && (question || messageContent);
// Direct agent follow-up: reuse existing chatStore
// (continuous chat — don't create a new chatStore)
const isDirectFollowUp =
agentMessages.data?.direct === true && !skipFirstConfirm;
if (isDirectFollowUp) {
// Just reset status to RUNNING so the UI unblocks
// and SSE events are not filtered out
previousChatStore.setStatus(
currentTaskId,
ChatTaskStatus.RUNNING
);
previousChatStore.setIsPending(currentTaskId, false);
}
//All except first confirmed event to reuse the existing chatStore
if (shouldCreateNewChat && !skipFirstConfirm) {
if (shouldCreateNewChat && !skipFirstConfirm && !isDirectFollowUp) {
/**
* For Tasks where appended to existing project by
* reusing same projectId. Need to create new chatStore
@ -837,6 +856,13 @@ const chatStore = (initial?: Partial<ChatStore>) =>
updateLockedReferences(newChatStore, newTaskId);
newChatStore.getState().setIsPending(newTaskId, false);
// direct=true means @agent mode: skip task splitting
if (agentMessages.data?.direct === true) {
newChatStore
.getState()
.setStatus(newTaskId, ChatTaskStatus.RUNNING);
}
if (type === 'replay') {
newChatStore
.getState()
@ -911,9 +937,12 @@ const chatStore = (initial?: Partial<ChatStore>) =>
} else {
//NOTE: Triggered only with first "confirmed" in the project
//Handle Original cases - with old chatStore
// direct=true means @agent mode: skip task splitting,
// go straight to RUNNING
const directAgent = agentMessages.data?.direct === true;
previousChatStore.setStatus(
currentTaskId,
ChatTaskStatus.PENDING
directAgent ? ChatTaskStatus.RUNNING : ChatTaskStatus.PENDING
);
previousChatStore.setHasWaitComfirm(currentTaskId, false);
}
@ -961,6 +990,8 @@ const chatStore = (initial?: Partial<ChatStore>) =>
setStreamingDecomposeText,
clearStreamingDecomposeText,
setIsTaskEdit,
setActiveAgent,
setActiveWorkspace: _setActiveWorkspace,
} = getCurrentChatStore();
currentTaskId = getCurrentTaskId();
@ -1380,6 +1411,34 @@ const chatStore = (initial?: Partial<ChatStore>) =>
status: AgentMessageStatus.RUNNING,
});
}
// Direct agent mode: create synthetic task if agent
// has no tasks assigned (no workforce ASSIGN_TASK)
const isDirect =
taskAssigning[agentIndex].tasks.length === 0 && process_task_id;
if (isDirect) {
const syntheticTaskBase = {
id: process_task_id,
content: agentMessages.data.message || '',
status: TaskStatus.RUNNING,
agent: {
agent_id: agent_id,
status: AgentStatusValue.RUNNING,
},
};
// Push separate copies so they don't share
// the same toolkits array reference
taskAssigning[agentIndex].tasks.push({
...syntheticTaskBase,
toolkits: [],
} as any);
taskRunning.push({ ...syntheticTaskBase, toolkits: [] } as any);
// Activate agent in sidebar but stay on main page
// (don't setActiveWorkspace — that would switch to
// the browser/developer full-screen workspace panel)
setActiveAgent(currentTaskId, agent_id!);
}
const taskIndex = taskRunning.findIndex(
(task) => task.id === process_task_id
);
@ -1416,8 +1475,18 @@ const chatStore = (initial?: Partial<ChatStore>) =>
);
if (taskIndex !== -1 && taskRunning[taskIndex].agent) {
taskRunning[taskIndex].agent!.status = 'completed';
taskRunning[taskIndex].status = TaskStatus.COMPLETED;
}
// Also update taskAssigning task status
const assignedTask = taskAssigning[agentIndex].tasks.find(
(task: TaskInfo) => task.id === process_task_id
);
if (assignedTask) {
assignedTask.status = TaskStatus.COMPLETED;
}
taskAssigning[agentIndex].status = AgentStatusValue.COMPLETED;
if (!type && historyId) {
const obj = {
project_name: tasks[currentTaskId].summaryTask.split('|')[0],
@ -2253,6 +2322,7 @@ const chatStore = (initial?: Partial<ChatStore>) =>
task.id === agentMessages.data.process_task_id
)
);
if (assigneeAgentIndex === -1) return;
const task = taskAssigning[assigneeAgentIndex].tasks.find(
(task: TaskInfo) =>
task.id === agentMessages.data.process_task_id

View file

@ -146,6 +146,7 @@ declare global {
current_length?: number;
max_length?: number;
text?: string;
direct?: boolean;
};
status?: AgentMessageStatusType;
}