Merge remote-tracking branch 'origin/main' into fix/tooltips

# Conflicts:
#	src/components/Session/HeaderBox/index.tsx
#	src/components/TopBar/index.tsx
This commit is contained in:
4pmtong 2026-07-17 16:52:22 +08:00
commit ff57b79bdf
146 changed files with 8101 additions and 984 deletions

View file

@ -1,6 +1,8 @@
<!-- Thank you for contributing! -->
### Related Issue
# Pull Request
## Related Issue
<!-- REQUIRED: Link to the issue this PR resolves. PRs without a linked issue will be closed. -->
@ -8,27 +10,29 @@
Closes #
### Description
## Description
<!-- REQUIRED: Describe what this PR does and why. PRs without a description will not be reviewed. -->
### Testing Evidence (REQUIRED)
## Testing Evidence (REQUIRED)
<!-- REQUIRED: Every PR must include human-verified testing proof (e.g., test logs, screenshots, or screen recordings). -->
<!-- REQUIRED for frontend/UI changes: You MUST attach at least one screenshot or screen recording in this PR. -->
<!-- Frontend/UI PRs without visual evidence will not be reviewed. -->
- [ ] I have included human-verified testing evidence in this PR.
- [ ] This PR includes frontend/UI changes, and I attached screenshot(s) or screen recording(s).
- [ ] No frontend/UI changes in this PR.
### What is the purpose of this pull request? <!-- (put an "X" next to an item) -->
## What is the purpose of this pull request? <!-- (put an "X" next to an item) -->
- [ ] Bug fix
- [ ] New Feature
- [ ] Documentation update
- [ ] Other
### Contribution Guidelines Acknowledgement
## Contribution Guidelines Acknowledgement
- [ ] I have read and agree to the [Eigent Contribution Guideline](https://github.com/eigent-ai/eigent/blob/main/CONTRIBUTING.md#eigent-contribution-guideline)

4
.gitignore vendored
View file

@ -17,6 +17,7 @@ dist-ssr
dist-electron
release
*.local
*.tsbuildinfo
backend/context_files/
@ -70,3 +71,6 @@ storybook-static
nul
backend/benchmark/jobs/
# TypeScript incremental build info
*.tsbuildinfo

View file

@ -35,6 +35,23 @@ from app.model.subscription_runtime import (
from app.service.task import ActionCreateAgentData, Agents, get_task_lock
from app.utils.event_loop_utils import _schedule_async_task
# OpenAI chat-completions streaming only returns token usage when
# `stream_options.include_usage` is requested. Without it the request-level
# usage callback (on_request_usage) fires with 0 tokens, and because the
# step-level deactivate is zeroed once request-level reporting is active,
# streaming steps end up uncounted. These platforms use native (non-OpenAI)
# SDKs that reject `stream_options` and surface streaming usage on their own,
# so they are excluded from the injection below.
_NATIVE_STREAM_USAGE_PLATFORMS = {
"anthropic",
"aws-bedrock",
"aws-bedrock-converse",
"cohere",
"mistral",
"reka",
"watsonx",
}
def agent_model(
agent_name: str,
@ -73,11 +90,21 @@ def agent_model(
if custom_model_config and custom_model_config.has_custom_config():
for attr in config_attrs:
effective_config[attr] = getattr(
custom_model_config, attr, None
) or getattr(options, attr)
custom_value = getattr(custom_model_config, attr, None)
effective_config[attr] = (
custom_value
if custom_value is not None
else getattr(options, attr)
)
extra_params = (
custom_model_config.extra_params or options.extra_params or {}
custom_model_config.extra_params
if custom_model_config.extra_params is not None
else options.extra_params or {}
)
explicit_model_config = (
custom_model_config.model_config_dict
if custom_model_config.model_config_dict is not None
else options.model_config_dict or {}
)
logger.info(
f"Agent {agent_name} using custom model config: "
@ -88,6 +115,7 @@ def agent_model(
for attr in config_attrs:
effective_config[attr] = getattr(options, attr)
extra_params = options.extra_params or {}
explicit_model_config = options.model_config_dict or {}
has_explicit_custom_api_key = (
custom_model_config is not None
@ -100,10 +128,12 @@ def agent_model(
base_effective_config = dict(effective_config)
base_extra_params = dict(extra_params or {})
base_model_config = dict(explicit_model_config or {})
def build_model(force_refresh: bool = False):
effective_config = dict(base_effective_config)
extra_params = dict(base_extra_params)
explicit_model_config = dict(base_model_config)
if use_subscription_runtime:
effective_config, extra_params = apply_subscription_runtime(
@ -113,10 +143,16 @@ def agent_model(
force_refresh=force_refresh,
)
effective_api_url = effective_config.get("api_url")
is_effective_cloud = isinstance(effective_api_url, str) and any(
marker in effective_api_url
for marker in ("eigent-proxy", "proxy.eigent.ai")
)
# Cloud mode: inject default Bedrock region and adjust URL for proxy.
if (
effective_config.get("model_platform") == "aws-bedrock-converse"
and options.is_cloud()
and is_effective_cloud
):
(
effective_config["api_url"],
@ -128,7 +164,7 @@ def agent_model(
# construction does not blow up when the frontend omits extra_params.
if (
effective_config.get("model_platform") == "azure"
and options.is_cloud()
and is_effective_cloud
):
extra_params = patch_azure_cloud_config(extra_params)
init_param_keys = {
@ -151,8 +187,10 @@ def agent_model(
init_params = {}
model_config: dict[str, Any] = {}
if options.is_cloud():
model_config["user"] = str(options.project_id)
# A nested model_config_dict may arrive inside legacy extra_params
# while stored providers migrate to the explicit top-level field.
# Treat it as less specific than the explicit request field.
nested_model_config = extra_params.pop("model_config_dict", None)
excluded_keys = {"model_platform", "model_type", "api_key", "url"}
@ -169,6 +207,13 @@ def agent_model(
else:
model_config[k] = v
if isinstance(nested_model_config, dict):
model_config.update(nested_model_config)
# The explicit model config is the canonical API and wins over legacy
# flat values from extra_params.
model_config.update(explicit_model_config)
# Auto-inject prompt caching based on model platform
try:
model_platform_enum = ModelPlatformType(
@ -190,6 +235,12 @@ def agent_model(
exc_info=True,
)
# Runtime-owned values are applied after user configuration.
if is_effective_cloud:
model_config["user"] = str(options.project_id)
if use_subscription_runtime:
model_config["stream"] = True
model_config["store"] = False
if agent_name == Agents.task_agent:
model_config["stream"] = True
if agent_name == Agents.browser_agent:
@ -217,6 +268,21 @@ def agent_model(
if model_config.get("max_tokens") is None:
model_config["max_tokens"] = 128000
# Ensure streaming steps still report token usage. OpenAI-family
# providers omit usage from streamed responses unless include_usage
# is set, which would otherwise make request-level accounting count 0.
# `stream_options: false` in extra_params opts out entirely, for
# endpoints that reject the parameter (e.g. older vLLM/Azure).
if model_config.get("stream_options") is False:
model_config.pop("stream_options")
elif model_config.get("stream") and (
effective_config["model_platform"].lower()
not in _NATIVE_STREAM_USAGE_PLATFORMS
):
stream_options = model_config.setdefault("stream_options", {})
if isinstance(stream_options, dict):
stream_options.setdefault("include_usage", True)
return ModelFactory.create(
model_platform=effective_config["model_platform"],
model_type=effective_config["model_type"],

View file

@ -13,6 +13,7 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import asyncio
import inspect
import json
import logging
import threading
@ -36,6 +37,7 @@ from camel.types import ModelPlatformType, ModelType
from camel.types.agents import ToolCallingRecord
from pydantic import BaseModel
from app.component.environment import env
from app.service.task import (
Action,
ActionActivateAgentData,
@ -43,7 +45,9 @@ from app.service.task import (
ActionBudgetNotEnough,
ActionDeactivateAgentData,
ActionDeactivateToolkitData,
ActionRequestUsageData,
get_task_lock,
get_task_lock_if_exists,
set_process_task,
)
from app.utils.event_loop_utils import _schedule_async_task
@ -52,11 +56,30 @@ from app.utils.event_loop_utils import _schedule_async_task
logger = logging.getLogger("agent")
# Default 30 minutes; long agent turns (e.g. writing many chapters in one
# run) can legitimately exceed it, so allow tuning without a rebuild.
# A non-positive value disables the per-step timeout entirely.
def default_step_timeout() -> float | None:
raw = env("AGENT_STEP_TIMEOUT_SECONDS", "1800")
try:
value = float(raw)
except (TypeError, ValueError):
logger.warning(
"Invalid AGENT_STEP_TIMEOUT_SECONDS value %r; using 1800", raw
)
return 1800.0
return value if value > 0 else None
class ListenChatAgent(ChatAgent):
_cdp_clone_lock = (
threading.Lock()
) # Protects CDP URL mutation during clone
_camel_has_request_usage: bool = (
"on_request_usage" in inspect.signature(ChatAgent.__init__).parameters
)
def __init__(
self,
api_task_id: str,
@ -95,12 +118,20 @@ class ListenChatAgent(ChatAgent):
pause_event: asyncio.Event | None = None,
prune_tool_calls_from_memory: bool = False,
enable_snapshot_clean: bool = False,
step_timeout: float | None = 1800, # 30 minutes
step_timeout: float | None = None,
model_reload_callback: (
Callable[[], BaseModelBackend | ModelManager] | None
) = None,
**kwargs: Any,
) -> None:
self.api_task_id = api_task_id
self.agent_name = agent_name
self._user_on_request_usage = kwargs.pop("on_request_usage", None)
if self._camel_has_request_usage:
kwargs["on_request_usage"] = self._on_request_usage
if step_timeout is None:
step_timeout = default_step_timeout()
super().__init__(
system_message=system_message,
model=model,
@ -124,13 +155,39 @@ class ListenChatAgent(ChatAgent):
step_timeout=step_timeout,
**kwargs,
)
self.api_task_id = api_task_id
self.agent_name = agent_name
self._model_reload_callback = model_reload_callback
self._model_reload_lock = threading.Lock()
process_task_id: str = ""
def _on_request_usage(self, payload: dict[str, Any]) -> Any:
request_usage = payload.get("request_usage") or {}
step_usage = payload.get("step_usage") or {}
request_tokens = int(request_usage.get("total_tokens") or 0)
# Lock may be gone if the task was stopped mid-request.
task_lock = get_task_lock_if_exists(self.api_task_id)
if request_tokens > 0 and task_lock is not None:
_schedule_async_task(
task_lock.put_queue(
ActionRequestUsageData(
data={
"agent_name": self.agent_name,
"process_task_id": self.process_task_id,
"agent_id": self.agent_id,
"tokens": request_tokens,
"request_index": payload.get("request_index", 0),
"response_id": payload.get("response_id", ""),
"step_total_tokens": int(
step_usage.get("total_tokens") or 0
),
}
)
)
)
if self._user_on_request_usage is not None:
return self._user_on_request_usage(payload)
return None
@staticmethod
def _is_retryable_model_auth_error(error: BaseException) -> bool:
error_text = str(error).lower()
@ -193,7 +250,17 @@ class ListenChatAgent(ChatAgent):
message: The accumulated message content
tokens: The total token count used
"""
task_lock = get_task_lock(self.api_task_id)
if self._camel_has_request_usage:
tokens = 0
# A missing lock (task stopped mid-step) must not fail the step.
task_lock = get_task_lock_if_exists(self.api_task_id)
if task_lock is None:
logger.warning(
"Task lock %s missing; dropping deactivate event for %s",
self.api_task_id,
self.agent_name,
)
return
_schedule_async_task(
task_lock.put_queue(
ActionDeactivateAgentData(
@ -440,19 +507,7 @@ class ListenChatAgent(ChatAgent):
assert message is not None
_schedule_async_task(
task_lock.put_queue(
ActionDeactivateAgentData(
data={
"agent_name": self.agent_name,
"process_task_id": self.process_task_id,
"agent_id": self.agent_id,
"message": message,
"tokens": total_tokens,
},
)
)
)
self._send_agent_deactivate(message, total_tokens)
if error_info is not None:
raise error_info
@ -567,19 +622,7 @@ class ListenChatAgent(ChatAgent):
# Streaming responses handle deactivation in _astream_chunks
assert message is not None
asyncio.create_task(
task_lock.put_queue(
ActionDeactivateAgentData(
data={
"agent_name": self.agent_name,
"process_task_id": self.process_task_id,
"agent_id": self.agent_id,
"message": message,
"tokens": total_tokens,
},
)
)
)
self._send_agent_deactivate(message, total_tokens)
if error_info is not None:
raise error_info
@ -977,6 +1020,10 @@ class ListenChatAgent(ChatAgent):
else:
cloned_tools, toolkits_to_register = self._clone_tools()
clone_kwargs: dict[str, Any] = {}
if self._user_on_request_usage is not None:
clone_kwargs["on_request_usage"] = self._user_on_request_usage
new_agent = ListenChatAgent(
api_task_id=self.api_task_id,
agent_name=self.agent_name,
@ -1004,6 +1051,7 @@ class ListenChatAgent(ChatAgent):
enable_snapshot_clean=self._enable_snapshot_clean,
step_timeout=self.step_timeout,
stream_accumulate=self.stream_accumulate,
**clone_kwargs,
)
new_agent.process_task_id = self.process_task_id

View file

@ -20,6 +20,7 @@ from camel.messages import BaseMessage
from camel.toolkits import ScreenshotToolkit as BaseScreenshotToolkit
from PIL import Image
from app.agent.listen_chat_agent import default_step_timeout
from app.agent.toolkit.abstract_toolkit import AbstractToolkit
from app.component.environment import env
from app.utils.listen.toolkit_listen import auto_listen_toolkit
@ -86,7 +87,9 @@ class ScreenshotToolkit(BaseScreenshotToolkit, AbstractToolkit):
tools=[],
toolkits_to_register_agent=None,
external_tools=None,
step_timeout=getattr(self.agent, "step_timeout", 1800),
step_timeout=getattr(
self.agent, "step_timeout", default_step_timeout()
),
)
response = vision_agent.step(message)
if getattr(response, "msg", None) is not None:

View file

@ -117,6 +117,35 @@ def _get_merged_skill_config(
return config
def _normalize_agent_name(agent_name: str | None) -> str | None:
"""Canonicalize agent names for skill scope matching.
Backend Single Agent uses Agents.single_agent == "single_agent". Accept
legacy aliases so Skills UI bindings stay effective.
"""
if agent_name is None:
return None
cleaned = str(agent_name).strip()
if not cleaned:
return None
lowered = cleaned.lower()
if lowered == "single_agent" or lowered == "agents.single_agent":
return "single_agent"
return cleaned
def _agent_in_list(agent_name: str | None, agents: list) -> bool:
normalized = _normalize_agent_name(agent_name)
if not normalized:
return False
selected = {
_normalize_agent_name(str(item))
for item in agents
if _normalize_agent_name(str(item))
}
return normalized in selected
def _is_skill_enabled(
skill_name: str, config: dict[str, SkillEntryConfig]
) -> bool:
@ -167,7 +196,7 @@ def _is_agent_allowed(
)
return False
return agent_name in selected_agents
return _agent_in_list(agent_name, selected_agents)
allowed_agents = skill_config.get("agents", [])
@ -182,7 +211,7 @@ def _is_agent_allowed(
)
return False
return agent_name in allowed_agents
return _agent_in_list(agent_name, allowed_agents)
def _build_allowed_skills(

View file

@ -136,7 +136,13 @@ def add_agent(id: str, data: NewAgent):
)
logger.debug(
"New agent data",
extra={"task_id": id, "agent_data": data.model_dump_json()},
extra={
"task_id": id,
"agent_name": data.name,
"tools": list(data.tools),
"has_mcp_tools": bool(data.mcp_tools),
"has_custom_model_config": data.custom_model_config is not None,
},
)
# Set user-specific environment path for this thread
set_user_env_path(data.env_path)

View file

@ -83,6 +83,10 @@ class Chat(BaseModel):
env_path: str | None = None
summary_prompt: str = DEFAULT_SUMMARY_PROMPT
new_agents: list["NewAgent"] = []
# Parameters forwarded with each inference request, such as temperature,
# top_p, or max_tokens. Constructor-only provider settings remain in
# extra_params for backward compatibility.
model_config_dict: dict[str, Any] | None = None
# For provider-specific parameters like Azure
extra_params: dict | None = None
# User-specific search engine configurations
@ -230,6 +234,7 @@ class AgentModelConfig(BaseModel):
model_type: str | None = None
api_key: str | None = None
api_url: str | None = None
model_config_dict: dict[str, Any] | None = None
extra_params: dict | None = None
def has_custom_config(self) -> bool:
@ -240,6 +245,7 @@ class AgentModelConfig(BaseModel):
self.model_type is not None,
self.api_key is not None,
self.api_url is not None,
self.model_config_dict is not None,
self.extra_params is not None,
]
)

View file

@ -1701,6 +1701,8 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
yield sse_json("activate_agent", item.data)
elif item.action == Action.deactivate_agent:
yield sse_json("deactivate_agent", dict(item.data))
elif item.action == Action.request_usage:
yield sse_json("request_usage", dict(item.data))
elif item.action == Action.assign_task:
yield sse_json("assign_task", item.data)
elif item.action == Action.activate_toolkit:
@ -2810,7 +2812,15 @@ async def new_agent_model(
},
)
logger.debug(
"New agent data", extra={"agent_data": data.model_dump_json()}
"New agent data",
extra={
"agent_name": data.name,
"tools": list(data.tools),
"has_mcp_tools": bool(data.mcp_tools),
"has_custom_model_config": (
getattr(data, "custom_model_config", None) is not None
),
},
)
working_directory = get_working_directory(options)
tool_names = []

View file

@ -175,6 +175,8 @@ def _action_to_sse(item: ActionData) -> str | None:
return sse_json("activate_agent", item.data)
if item.action == Action.deactivate_agent:
return sse_json("deactivate_agent", item.data)
if item.action == Action.request_usage:
return sse_json("request_usage", item.data)
if item.action == Action.assign_task:
return sse_json("assign_task", item.data)
if item.action == Action.activate_toolkit:

View file

@ -52,6 +52,7 @@ class Action(str, Enum):
create_agent = "create_agent" # backend -> user
activate_agent = "activate_agent" # backend -> user
deactivate_agent = "deactivate_agent" # backend -> user
request_usage = "request_usage" # backend -> user
assign_task = "assign_task" # backend -> user
activate_toolkit = "activate_toolkit" # backend -> user
deactivate_toolkit = "deactivate_toolkit" # backend -> user
@ -160,6 +161,21 @@ class ActionDeactivateAgentData(BaseModel):
data: DataDict
class RequestUsageDataDict(TypedDict):
agent_name: str
agent_id: str
process_task_id: str
tokens: int
request_index: int
response_id: str
step_total_tokens: int
class ActionRequestUsageData(BaseModel):
action: Literal[Action.request_usage] = Action.request_usage
data: RequestUsageDataDict
class ActionAssignTaskData(BaseModel):
action: Literal[Action.assign_task] = Action.assign_task
data: dict[
@ -298,6 +314,7 @@ ActionData = (
| ActionCreateAgentData
| ActionActivateAgentData
| ActionDeactivateAgentData
| ActionRequestUsageData
| ActionAssignTaskData
| ActionActivateToolkitData
| ActionDeactivateToolkitData

View file

@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.11,<3.12"
dependencies = [
"pip>=23.0",
"camel-ai[eigent]==0.2.91a3",
"camel-ai[eigent]==0.2.91a5",
"fastapi>=0.115.12",
"fastapi-babel>=1.0.0",
"uvicorn[standard]>=0.34.2",

View file

@ -18,7 +18,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.agent.agent_model import agent_model
from app.model.chat import Chat
from app.model.chat import AgentModelConfig, Chat
from app.service.task import Agents
pytestmark = pytest.mark.unit
@ -67,6 +68,7 @@ class TestAgentFactoryFunctions:
"model_platform": "openai",
"model_type": "gpt-5.5",
"auth_source": "codex_subscription",
"model_config_dict": {"stream": False, "store": True},
}
)
monkeypatch.setenv("CODEX_RESOLVER_URL", "http://127.0.0.1:12345")
@ -154,6 +156,232 @@ class TestAgentFactoryFunctions:
assert "store" not in model_config
assert kwargs["url"] == "https://api.openai.com/v1"
def test_explicit_model_config_wins_over_legacy_extra_params(
self, sample_chat_data
):
options = Chat(
**{
**sample_chat_data,
"extra_params": {
"temperature": 0.8,
"top_p": 0.6,
"max_retries": 7,
"model_config_dict": {
"temperature": 0.5,
"presence_penalty": 0.1,
},
},
"model_config_dict": {
"temperature": 0.2,
"max_tokens": 2048,
},
}
)
from app.service.task import task_locks
mock_task_lock = MagicMock()
mock_task_lock.put_queue = MagicMock(return_value=None)
task_locks[options.project_id] = mock_task_lock
_m = sys.modules["app.agent.agent_model"]
with (
patch.object(_m, "ListenChatAgent"),
patch.object(_m, "ModelFactory") as mock_model_factory,
patch.object(_m, "get_task_lock", return_value=mock_task_lock),
patch.object(_m, "_schedule_async_task"),
):
mock_model_factory.create.return_value = MagicMock()
agent_model("TestAgent", "You are helpful", options, [])
kwargs = mock_model_factory.create.call_args.kwargs
assert kwargs["max_retries"] == 7
assert kwargs["model_config_dict"]["temperature"] == 0.2
assert kwargs["model_config_dict"]["top_p"] == 0.6
assert kwargs["model_config_dict"]["presence_penalty"] == 0.1
assert kwargs["model_config_dict"]["max_tokens"] == 2048
assert "max_retries" not in kwargs["model_config_dict"]
assert "model_config_dict" not in kwargs["model_config_dict"]
def test_runtime_owned_agent_model_values_override_user_config(
self, sample_chat_data
):
options = Chat(
**{
**sample_chat_data,
"model_config_dict": {
"stream": False,
"parallel_tool_calls": True,
},
}
)
from app.service.task import task_locks
mock_task_lock = MagicMock()
mock_task_lock.put_queue = MagicMock(return_value=None)
task_locks[options.project_id] = mock_task_lock
_m = sys.modules["app.agent.agent_model"]
with (
patch.object(_m, "ListenChatAgent"),
patch.object(_m, "ModelFactory") as mock_model_factory,
patch.object(_m, "get_task_lock", return_value=mock_task_lock),
patch.object(_m, "_schedule_async_task"),
):
mock_model_factory.create.return_value = MagicMock()
agent_model(Agents.task_agent, "Task agent", options, [])
agent_model(Agents.browser_agent, "Browser agent", options, [])
task_config = mock_model_factory.create.call_args_list[0].kwargs[
"model_config_dict"
]
browser_config = mock_model_factory.create.call_args_list[1].kwargs[
"model_config_dict"
]
assert task_config["stream"] is True
assert browser_config["parallel_tool_calls"] is False
def test_per_agent_explicit_model_config_overrides_task_config(
self, sample_chat_data
):
options = Chat(
**{
**sample_chat_data,
"model_config_dict": {"temperature": 0.7, "top_p": 0.8},
}
)
custom_config = AgentModelConfig(
model_config_dict={"temperature": 0.1}
)
from app.service.task import task_locks
mock_task_lock = MagicMock()
mock_task_lock.put_queue = MagicMock(return_value=None)
task_locks[options.project_id] = mock_task_lock
_m = sys.modules["app.agent.agent_model"]
with (
patch.object(_m, "ListenChatAgent"),
patch.object(_m, "ModelFactory") as mock_model_factory,
patch.object(_m, "get_task_lock", return_value=mock_task_lock),
patch.object(_m, "_schedule_async_task"),
):
mock_model_factory.create.return_value = MagicMock()
agent_model(
"CustomAgent",
"Custom agent",
options,
[],
custom_model_config=custom_config,
)
model_config = mock_model_factory.create.call_args.kwargs[
"model_config_dict"
]
assert model_config["temperature"] == 0.1
assert "top_p" not in model_config
def test_per_agent_empty_credentials_do_not_inherit_task_credentials(
self, sample_chat_data
):
options = Chat(**sample_chat_data)
custom_config = AgentModelConfig(
model_platform="aws-bedrock-converse",
model_type="anthropic.claude-3-5-sonnet-20241022-v2:0",
api_key="",
api_url="",
extra_params={
"region_name": "us-east-1",
"aws_access_key_id": "worker-access-key",
"aws_secret_access_key": "worker-secret-key",
},
)
from app.service.task import task_locks
mock_task_lock = MagicMock()
mock_task_lock.put_queue = MagicMock(return_value=None)
task_locks[options.project_id] = mock_task_lock
_m = sys.modules["app.agent.agent_model"]
with (
patch.object(_m, "ListenChatAgent"),
patch.object(_m, "ModelFactory") as mock_model_factory,
patch.object(_m, "get_task_lock", return_value=mock_task_lock),
patch.object(_m, "_schedule_async_task"),
):
mock_model_factory.create.return_value = MagicMock()
agent_model(
"BedrockAgent",
"Bedrock agent",
options,
[],
custom_model_config=custom_config,
)
kwargs = mock_model_factory.create.call_args.kwargs
assert kwargs["api_key"] == ""
assert kwargs["url"] == ""
assert kwargs["aws_access_key_id"] == "worker-access-key"
assert kwargs["aws_secret_access_key"] == "worker-secret-key"
def test_direct_per_agent_provider_is_not_treated_as_task_cloud_model(
self, sample_chat_data
):
options = Chat(
**{
**sample_chat_data,
"api_url": "https://eigent-proxy.example.com",
}
)
custom_config = AgentModelConfig(
model_platform="aws-bedrock-converse",
model_type="anthropic.claude-3-5-sonnet-20241022-v2:0",
api_key="",
api_url="https://bedrock-runtime.us-east-1.amazonaws.com",
extra_params={
"region_name": "eu-west-1",
"aws_access_key_id": "worker-access-key",
"aws_secret_access_key": "worker-secret-key",
},
)
from app.service.task import task_locks
mock_task_lock = MagicMock()
mock_task_lock.put_queue = MagicMock(return_value=None)
task_locks[options.project_id] = mock_task_lock
_m = sys.modules["app.agent.agent_model"]
with (
patch.object(_m, "ListenChatAgent"),
patch.object(_m, "ModelFactory") as mock_model_factory,
patch.object(_m, "get_task_lock", return_value=mock_task_lock),
patch.object(_m, "_schedule_async_task"),
):
mock_model_factory.create.return_value = MagicMock()
agent_model(
"BedrockAgent",
"Bedrock agent",
options,
[],
custom_model_config=custom_config,
)
kwargs = mock_model_factory.create.call_args.kwargs
assert kwargs["url"] == (
"https://bedrock-runtime.us-east-1.amazonaws.com"
)
assert kwargs["region_name"] == "eu-west-1"
assert "user" not in (kwargs["model_config_dict"] or {})
def test_agent_model_with_missing_options(self):
"""Test agent_model with missing required options."""
agent_name = "ErrorAgent"
@ -184,7 +412,7 @@ class TestAgentIntegration:
# Create task lock
mock_task_lock = MagicMock()
mock_task_lock.put_queue = AsyncMock()
mock_task_lock.put_queue = MagicMock(return_value=None)
task_locks[api_task_id] = mock_task_lock
# Create agent

View file

@ -25,7 +25,7 @@ from app.controller.task_controller import (
start,
take_control,
)
from app.model.chat import NewAgent, TaskContent, UpdateData
from app.model.chat import AgentModelConfig, NewAgent, TaskContent, UpdateData
from app.service.task import Action
@ -126,6 +126,11 @@ class TestTaskController:
tools=["search", "code"],
mcp_tools=None,
env_path=".env",
custom_model_config=AgentModelConfig(
model_platform="aws-bedrock-converse",
api_key="super-secret-api-key",
extra_params={"aws_secret_access_key": "super-secret-aws-key"},
),
)
with (
@ -137,12 +142,26 @@ class TestTaskController:
patch(
"app.controller.task_controller._queue_action_from_worker"
) as mock_queue_action,
patch("app.controller.task_controller.logger") as mock_logger,
):
response = add_agent(task_id, new_agent)
assert isinstance(response, Response)
assert response.status_code == 204
mock_queue_action.assert_called_once()
mock_logger.debug.assert_called_once_with(
"New agent data",
extra={
"task_id": task_id,
"agent_name": "Test Agent",
"tools": ["search", "code"],
"has_mcp_tools": False,
"has_custom_model_config": True,
},
)
logged_calls = repr(mock_logger.mock_calls)
assert "super-secret-api-key" not in logged_calls
assert "super-secret-aws-key" not in logged_calls
def test_start_task_nonexistent_task(self):
"""Test start task with nonexistent task ID."""

View file

@ -30,6 +30,7 @@ class TestAgentModelConfig:
assert config.model_type is None
assert config.api_key is None
assert config.api_url is None
assert config.model_config_dict is None
assert config.extra_params is None
def test_agent_model_config_creation_with_values(self):
@ -39,13 +40,18 @@ class TestAgentModelConfig:
model_type="gpt-4",
api_key="test-key",
api_url="https://api.openai.com/v1",
extra_params={"temperature": 0.7},
model_config_dict={"temperature": 0.7, "stream": False},
extra_params={"api_version": "2025-01-01"},
)
assert config.model_platform == "openai"
assert config.model_type == "gpt-4"
assert config.api_key == "test-key"
assert config.api_url == "https://api.openai.com/v1"
assert config.extra_params == {"temperature": 0.7}
assert config.model_config_dict == {
"temperature": 0.7,
"stream": False,
}
assert config.extra_params == {"api_version": "2025-01-01"}
def test_has_custom_config_false_when_empty(self):
"""Test has_custom_config returns False for empty config."""
@ -74,6 +80,36 @@ class TestAgentModelConfig:
config = AgentModelConfig(api_key="some-key")
assert config.has_custom_config() is True
def test_has_custom_config_true_with_only_model_config_dict(self):
config = AgentModelConfig(model_config_dict={"temperature": 0.2})
assert config.has_custom_config() is True
class TestChatModelConfig:
def test_chat_accepts_and_serializes_model_config_dict(
self, sample_chat_data
):
chat = Chat(
**{
**sample_chat_data,
"model_config_dict": {
"temperature": 0.2,
"top_p": 0.9,
"response_format": {"type": "json_object"},
},
}
)
assert chat.model_config_dict == {
"temperature": 0.2,
"top_p": 0.9,
"response_format": {"type": "json_object"},
}
assert chat.model_dump()["model_config_dict"] == (
chat.model_config_dict
)
class TestNewAgentWithModelConfig:
"""Tests for NewAgent with custom_model_config."""

View file

@ -20,7 +20,7 @@ import pytest
from camel.tasks import Task
from camel.tasks.task import TaskState
from app.model.chat import Chat, NewAgent
from app.model.chat import AgentModelConfig, Chat, NewAgent
from app.service.chat_service import (
_extract_stream_chunk_content,
_render_subtask_report,
@ -1024,6 +1024,11 @@ class TestChatServiceAgentOperations:
tools=["search", "code"],
mcp_tools=None,
env_path=".env",
custom_model_config=AgentModelConfig(
model_platform="aws-bedrock-converse",
api_key="super-secret-api-key",
extra_params={"aws_secret_access_key": "super-secret-aws-key"},
),
)
mock_agent = MagicMock()
@ -1038,10 +1043,23 @@ class TestChatServiceAgentOperations:
"app.agent.toolkit.human_toolkit.get_task_lock",
return_value=MagicMock(),
),
patch("app.service.chat_service.logger") as mock_logger,
):
result = await new_agent_model(agent_data, options)
assert result is mock_agent
mock_logger.debug.assert_any_call(
"New agent data",
extra={
"agent_name": "TestAgent",
"tools": ["search", "code"],
"has_mcp_tools": False,
"has_custom_model_config": True,
},
)
logged_calls = repr(mock_logger.mock_calls)
assert "super-secret-api-key" not in logged_calls
assert "super-secret-aws-key" not in logged_calls
@pytest.mark.asyncio
async def test_construct_workforce(self, sample_chat_data, mock_task_lock):

View file

@ -198,3 +198,42 @@ def test_agent_skill_toolkit_reads_canonical_user_config_path(
assert agent_skill_toolkit._get_user_config_path("user_42") == (
tmp_path / ".eigent" / "user_42" / "skills-config.json"
)
def test_skill_toolkit_allows_single_agent_aliases():
config = {
"demo": {
"enabled": True,
"scope": {
"isGlobal": False,
"selectedAgents": ["single_agent"],
},
}
}
assert agent_skill_toolkit._is_agent_allowed(
"demo", "single_agent", config
)
assert agent_skill_toolkit._is_agent_allowed(
"demo", "Agents.single_agent", config
)
assert not agent_skill_toolkit._is_agent_allowed(
"demo", "foo.single_agent", config
)
assert not agent_skill_toolkit._is_agent_allowed(
"demo", "developer_agent", config
)
def test_skill_toolkit_normalizes_selected_single_agent_aliases():
config = {
"demo": {
"enabled": True,
"scope": {
"isGlobal": False,
"selectedAgents": ["Agents.single_agent"],
},
}
}
assert agent_skill_toolkit._is_agent_allowed(
"demo", "single_agent", config
)

8
backend/uv.lock generated
View file

@ -244,7 +244,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiofiles", specifier = ">=24.1.0" },
{ name = "camel-ai", extras = ["eigent"], specifier = "==0.2.91a3" },
{ name = "camel-ai", extras = ["eigent"], specifier = "==0.2.91a5" },
{ name = "debugpy", specifier = ">=1.8.17" },
{ name = "fastapi", specifier = ">=0.115.12" },
{ name = "fastapi-babel", specifier = ">=1.0.0" },
@ -315,7 +315,7 @@ wheels = [
[[package]]
name = "camel-ai"
version = "0.2.91a3"
version = "0.2.91a5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "astor" },
@ -333,9 +333,9 @@ dependencies = [
{ name = "tiktoken" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/ce/44a09fb47372d8ebea3a0c7aafefd5640caa1c1ab0cdfccfd0260de5b495/camel_ai-0.2.91a3.tar.gz", hash = "sha256:eb94ba4bd084967f55fe73568bbeda4a49d3bf5ac2eb79e018870df3661d1d85", size = 1230710, upload-time = "2026-04-25T14:00:37.206Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/c9/7015c4f4733b535e2f56eef9a4c1da7b9e49db8d209dc850e996f2b6b5da/camel_ai-0.2.91a5.tar.gz", hash = "sha256:9606d50d7ec4062cc44e0f4c085e043c7d5b7b2779ea66c4ac9142c98a3380a7", size = 1239665, upload-time = "2026-07-13T11:24:06.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/8c/ba682c71766bc61a1a1115af50bddb6040432f85784a0ec5df2ed91897a9/camel_ai-0.2.91a3-py3-none-any.whl", hash = "sha256:ead956ab8d2f22685cded8c03e7ecad6bdee3fee85041eb1488bdaad7e115372", size = 1718236, upload-time = "2026-04-25T14:00:34.606Z" },
{ url = "https://files.pythonhosted.org/packages/81/c7/80317df8f49ff9d16e8e29eb838b4f9e3f537a02c697c55b18bc6a24d9f4/camel_ai-0.2.91a5-py3-none-any.whl", hash = "sha256:dd1f3ce9ce324bbd29952d4f2020c5279d31ebc70c428661c258bdb67c9943d1", size = 1729980, upload-time = "2026-07-13T11:24:03.785Z" },
]
[package.optional-dependencies]

View file

@ -22,6 +22,8 @@ import path from 'path';
import * as unzipper from 'unzipper';
import { URL } from 'url';
import { parseStringPromise } from 'xml2js';
import { normalizeLegacySandboxPath } from './utils/filePath';
import { findDirectoriesByName } from './utils/log';
interface FileInfo {
path: string;
@ -474,6 +476,8 @@ export class FileReader {
public openFile(type: string, filePath: string, _isShowSourceCode: boolean) {
return new Promise(async (resolve, reject) => {
try {
filePath = normalizeLegacySandboxPath(filePath);
// check if it is a remote file
if (!this.isLocalFile(filePath)) {
console.log('detect remote file, start downloading:', filePath);
@ -819,6 +823,56 @@ export class FileReader {
}
}
/**
* Resolves the `camel_logs` directories to export as `{ src, destName }`
* entries (destName is the path inside `~/.eigent`, so the zip stays readable).
*
* Prefers the specific task the user last ran (via {@link resolveTaskPaths},
* the same resolution `get-file-list` uses). Falls back to every `camel_logs`
* folder under the user's identity roots when no task is supplied or its
* folder is missing.
*/
public getCamelLogEntries(
email: string,
taskId?: string,
projectId?: string,
userId?: string | number | null
): { src: string; destName: string }[] {
const userHome = app.getPath('home');
const eigentRoot = path.join(userHome, '.eigent');
const toEntry = (dir: string) => ({
src: dir,
destName: path.relative(eigentRoot, dir) || path.basename(dir),
});
if (taskId) {
const { logPath } = this.resolveTaskPaths(
email,
taskId,
projectId,
userId
);
const camelLogPath = path.join(logPath, 'camel_logs');
if (fs.existsSync(camelLogPath)) {
return [toEntry(camelLogPath)];
}
return [];
}
const identities = this.getStorageIdentityCandidates(email, userId);
const seen = new Set<string>();
const entries: { src: string; destName: string }[] = [];
for (const identity of identities) {
const identityRoot = path.join(eigentRoot, identity);
for (const dir of findDirectoriesByName(identityRoot, 'camel_logs', 4)) {
if (seen.has(dir)) continue;
seen.add(dir);
entries.push(toEntry(dir));
}
}
return entries;
}
public deleteTaskFiles(
email: string,
taskId: string,

View file

@ -64,7 +64,7 @@ import {
removeEnvKey,
updateEnvBlock,
} from './utils/envUtil';
import { createDiagnosticsZip, zipFolder } from './utils/log';
import { createDiagnosticsZip, zipDirectories, zipFolder } from './utils/log';
import { addMcp, readMcpConfig, removeMcp, updateMcp } from './utils/mcpConfig';
import {
checkVenvExistsForPreCheck,
@ -94,6 +94,18 @@ let browser_port = 9222;
let use_external_cdp = false;
let proxyUrl: string | null = null;
const PREVIEW_WEBVIEW_PARTITION = 'persist:session-preview';
const isHttpOrHttpsUrl = (url: unknown): url is string => {
if (typeof url !== 'string') return false;
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
};
// CDP Browser Pool
interface CdpBrowser {
id: string;
@ -1132,6 +1144,55 @@ function registerIpcHandlers() {
}
});
// Camel (backend) logs live per task at
// ~/.eigent/<identity>/[project_<id>/]task_<taskId>/camel_logs.
// Targets the task the user last ran when provided; otherwise exports all.
ipcMain.handle(
'export-camel-log',
async (
_event,
email: string,
taskId?: string,
projectId?: string,
userId?: string | number | null
) => {
try {
if (typeof email !== 'string' || !email) {
return { success: false, error: 'Missing email' };
}
const manager = checkManagerInstance(fileReader, 'FileReader');
const camelLogEntries = manager.getCamelLogEntries(
email,
taskId,
projectId,
userId
);
if (camelLogEntries.length === 0) {
return { success: false, error: 'no log file' };
}
const appVersion = app.getVersion();
const defaultFileName = `eigent-camel-logs-${appVersion}-${Date.now()}.zip`;
const { canceled, filePath } = await dialog.showSaveDialog({
title: 'Save Camel logs',
defaultPath: defaultFileName,
filters: [{ name: 'ZIP archive', extensions: ['zip'] }],
});
if (canceled || !filePath) {
return { success: false, error: '' };
}
await zipDirectories(filePath, camelLogEntries);
return { success: true, savedPath: filePath };
} catch (error: any) {
log.error('export-camel-log failed:', error);
return { success: false, error: error.message };
}
}
);
ipcMain.handle('get-diagnostics-info', async () => {
return {
version: app.getVersion(),
@ -1226,6 +1287,18 @@ function registerIpcHandlers() {
}
});
ipcMain.handle('open-external', async (_event, url: string) => {
try {
if (!isHttpOrHttpsUrl(url)) {
return { success: false, error: 'Invalid external URL' };
}
await shell.openExternal(url);
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
});
ipcMain.handle(
'upload-log',
async (
@ -2379,6 +2452,55 @@ async function createWindow() {
},
});
// Renderer <webview> guests (session preview browser) host arbitrary web
// content, and the host window itself runs with elevated webPreferences.
// Enforce safe guest settings at attach time so no tag attribute (even one
// forged by a compromised renderer) can grant a guest host privileges.
win.webContents.on('will-attach-webview', (event, webPreferences, params) => {
delete webPreferences.preload;
webPreferences.nodeIntegration = false;
webPreferences.contextIsolation = true;
webPreferences.webSecurity = true;
webPreferences.partition = PREVIEW_WEBVIEW_PARTITION;
if (
params.partition !== PREVIEW_WEBVIEW_PARTITION ||
!isHttpOrHttpsUrl(params.src)
) {
event.preventDefault();
}
});
// Route window.open / target=_blank into the same guest instead of spawning
// popup windows, and only allow web URLs. Together with the attach guard
// above, this is the only main-process involvement the guests need.
win.webContents.on('did-attach-webview', (_event, contents) => {
const preventUnsafeNavigation = (
event: Electron.Event,
navigationUrl: string
) => {
if (!isHttpOrHttpsUrl(navigationUrl)) {
event.preventDefault();
}
};
const guestNavigationEvents = contents as unknown as {
on: (
eventName: string,
listener: (event: Electron.Event, navigationUrl: string) => void
) => void;
};
guestNavigationEvents.on('will-navigate', preventUnsafeNavigation);
guestNavigationEvents.on('will-frame-navigate', preventUnsafeNavigation);
guestNavigationEvents.on('will-redirect', preventUnsafeNavigation);
contents.setWindowOpenHandler(({ url }) => {
if (isHttpOrHttpsUrl(url)) {
void contents.loadURL(url);
}
return { action: 'deny' };
});
});
if (process.platform === 'darwin') {
win.once('ready-to-show', () => {
if (win && !win.isDestroyed()) {

View file

@ -164,6 +164,30 @@ export function registerUpdateIpcHandlers() {
ipcMain.handle('quit-and-install', () => {
autoUpdater.quitAndInstall(false, true);
});
// Dev-only: replay updater events from DevTools to exercise the top-bar
// update UI, e.g.
// window.ipcRenderer.invoke('debug-update-event', 'download-progress', { percent: 40 })
if (!app.isPackaged) {
const allowedChannels = [
'update-can-available',
'download-progress',
'update-downloaded',
'update-error',
];
ipcMain.handle(
'debug-update-event',
(
event: Electron.IpcMainInvokeEvent,
channel: string,
payload?: unknown
) => {
if (!allowedChannels.includes(channel)) return false;
event.sender.send(channel, payload);
return true;
}
);
}
}
function startDownload(

View file

@ -0,0 +1,23 @@
// ========= 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. =========
export function normalizeLegacySandboxPath(
filePath: string,
platform: NodeJS.Platform = process.platform
): string {
if (platform === 'win32') return filePath;
const normalized = filePath.replace(/\\/g, '/');
return /^x:\//i.test(normalized) ? normalized.slice(2) : filePath;
}

View file

@ -44,6 +44,68 @@ export function zipFolder(
export type DiagnosticsLogFile = { src: string; destName: string };
export type LogDirectoryEntry = { src: string; destName: string };
/**
* Zips multiple directories into one archive, each under its destName.
*/
export function zipDirectories(
outputZipPath: string,
dirs: LogDirectoryEntry[]
): Promise<string> {
return new Promise((resolve, reject) => {
const output = fs.createWriteStream(outputZipPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', () => resolve(outputZipPath));
archive.on('error', (err: any) => {
log.error('Archive error:', err);
reject(err);
});
archive.pipe(output);
for (const dir of dirs) {
archive.directory(dir.src, dir.destName);
}
archive.finalize();
});
}
/**
* Finds directories named `dirName` under `rootDir`. Camel logs live at
* `~/.eigent/<email>/[project_<id>/]task_<taskId>/camel_logs`, so a shallow
* bounded walk is enough.
*/
export function findDirectoriesByName(
rootDir: string,
dirName: string,
maxDepth = 3
): string[] {
const found: string[] = [];
const walk = (dir: string, depth: number) => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
const fullPath = path.join(dir, entry.name);
if (entry.name === dirName) {
found.push(fullPath);
} else if (depth < maxDepth) {
walk(fullPath, depth + 1);
}
}
};
if (fs.existsSync(rootDir)) {
walk(rootDir, 0);
}
return found;
}
/**
* Stages log files and bug_report.txt into a temp directory, zips to outputZipPath, then removes the staging dir.
*/

View file

@ -76,10 +76,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
webviewDestroy: (webviewId: string) =>
ipcRenderer.invoke('webview-destroy', webviewId),
exportLog: () => ipcRenderer.invoke('export-log'),
exportCamelLog: (
email: string,
taskId?: string,
projectId?: string,
userId?: string | number | null
) => ipcRenderer.invoke('export-camel-log', email, taskId, projectId, userId),
getDiagnosticsInfo: () => ipcRenderer.invoke('get-diagnostics-info'),
exportDiagnosticsZip: (payload: { description: string; steps?: string }) =>
ipcRenderer.invoke('export-diagnostics-zip', payload),
openMailto: (url: string) => ipcRenderer.invoke('open-mailto', url),
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
uploadLog: (email: string, taskId: string, baseUrl: string, token: string) =>
ipcRenderer.invoke('upload-log', email, taskId, baseUrl, token),
// mcp

View file

@ -108,6 +108,7 @@ SPACE_COMMANDS = {
SPACE_DISCARD_PROJECT_OVERLAYS,
}
NON_BRAIN_COMMANDS = {SWITCH_PROJECT_VIEW, *SPACE_COMMANDS}
REMOTE_CONTROL_HIDDEN_STEPS = ("request_usage",)
def _now() -> datetime:
@ -1569,7 +1570,11 @@ class RemoteControlService:
task_ids = [legacy_history.task_id]
if not task_ids:
return RemoteControlStepsOut(items=[], has_more=False, next_since=since)
stmt = select(ChatStep).where(ChatStep.task_id.in_(task_ids), ChatStep.id > since)
stmt = select(ChatStep).where(
ChatStep.task_id.in_(task_ids),
ChatStep.id > since,
ChatStep.step.not_in(REMOTE_CONTROL_HIDDEN_STEPS),
)
stmt = stmt.order_by(ChatStep.id.desc() if order == "desc" else ChatStep.id.asc()).limit(limit + 1)
rows = list(db.exec(stmt).all())
has_more = len(rows) > limit
@ -1593,6 +1598,8 @@ class RemoteControlService:
@staticmethod
def publish_chat_step(step: ChatStep, db: Session) -> None:
if step.step in REMOTE_CONTROL_HIDDEN_STEPS:
return
history = db.exec(select(ChatHistory).where(ChatHistory.task_id == step.task_id)).first()
if not history:
logger.warning("Skipping remote-control step publish for orphan step", extra={"task_id": step.task_id})

View file

@ -75,7 +75,9 @@ async def auto_login(db_session: Session = Depends(session)) -> LoginResponse:
raise UserException(code.error, _("Failed to create default user"))
logger.info("Auto login successful", extra={"user_id": user.id, "email": user.email})
return LoginResponse(token=create_access_token(user.id), email=user.email)
return LoginResponse(
token=create_access_token(user.id), email=user.email, user_id=user.id
)
class RefreshTokenIn(BaseModel):

View file

@ -60,6 +60,7 @@ class LoginByPasswordIn(BaseModel):
class LoginResponse(BaseModel):
token: str
email: EmailStr
user_id: int | None = None
redirect_url: str | None = None

View file

@ -139,8 +139,7 @@ class TestSnapshotEndpointAuthRequirements:
sig = inspect.signature(list_chat_snapshots)
param_names = list(sig.parameters.keys())
assert "auth" in param_names, (
"list_chat_snapshots is missing the 'auth' parameter — "
"unauthenticated users can list all snapshots"
"list_chat_snapshots is missing the 'auth' parameter — unauthenticated users can list all snapshots"
)
def test_get_snapshot_requires_auth_dependency(self):
@ -181,8 +180,7 @@ def test_create_share_link_requires_auth_dependency():
sig = inspect.signature(create_share_link)
param_names = list(sig.parameters.keys())
assert "auth" in param_names, (
"create_share_link is missing the 'auth' parameter — "
"unauthenticated users can generate share tokens"
"create_share_link is missing the 'auth' parameter — unauthenticated users can generate share tokens"
)

View file

@ -15,8 +15,6 @@
import os
from unittest.mock import patch
import pytest
class TestChatShareSecretKey:
"""Tests for ChatShare secret key generation.

View file

@ -12,9 +12,7 @@
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from urllib.parse import quote_plus, urlparse, parse_qs
import pytest
from urllib.parse import parse_qs, quote_plus, urlparse
class TestGoogleSearchUrlEncoding:

View file

@ -528,6 +528,98 @@ def test_list_steps_controller_returns_service_response(monkeypatch):
assert result is expected
def test_list_steps_excludes_request_usage(monkeypatch):
from app.domains.remote_control.service.remote_control_service import RemoteControlService
history = SimpleNamespace(task_id="task_1")
visible_step = SimpleNamespace(
id=12,
task_id="task_1",
step="ask",
data={"question": "Continue?"},
timestamp=12.0,
)
class FakeResult:
def __init__(self, items):
self.items = items
def all(self):
return self.items
class FakeDb:
def __init__(self):
self.calls = 0
def exec(self, statement):
self.calls += 1
if self.calls == 1:
return FakeResult([history])
sql = str(statement.compile(compile_kwargs={"literal_binds": True}))
assert "request_usage" in sql
assert "NOT IN" in sql
return FakeResult([visible_step])
monkeypatch.setattr(
RemoteControlService,
"_owned_session",
staticmethod(lambda _session_id, _user_id, _db: SimpleNamespace()),
)
monkeypatch.setattr(
RemoteControlService,
"_effective_target",
staticmethod(lambda _session: ("project_1", None, None, None)),
)
monkeypatch.setattr(
RemoteControlService,
"_ensure_project_in_session_space",
staticmethod(lambda *_args: None),
)
result = RemoteControlService.list_steps(
"rcs_test",
123,
None,
0,
10,
"asc",
FakeDb(),
)
assert [item.step for item in result.items] == ["ask"]
def test_publish_chat_step_excludes_request_usage(monkeypatch):
from app.domains.remote_control.service.remote_control_service import (
RemoteControlRedis,
RemoteControlService,
)
class FailDb:
def exec(self, _statement):
raise AssertionError("request_usage should not query or publish")
published = []
monkeypatch.setattr(
RemoteControlRedis,
"publish",
lambda channel, payload: published.append((channel, payload)),
)
RemoteControlService.publish_chat_step(
SimpleNamespace(
id=13,
task_id="task_1",
step="request_usage",
data={"tokens": 10},
timestamp=13.0,
),
FailDb(),
)
assert published == []
@pytest.mark.asyncio
async def test_ws_reconnect_rate_limit_is_per_identifier(monkeypatch):
from app.domains.remote_control.api import remote_control_controller as controller

View file

@ -20,7 +20,7 @@ import { StackProvider, StackTheme } from '@stackframe/react';
import { QueryClientProvider } from '@tanstack/react-query';
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Toaster } from 'sonner';
import { Toaster } from './components/ui/sonner';
import { useBackgroundTaskProcessor } from './hooks/useBackgroundTaskProcessor';
import { useExecutionSubscription } from './hooks/useExecutionSubscription';
import { useRemoteControlBridge } from './hooks/useRemoteControlBridge';
@ -92,7 +92,7 @@ function App() {
return (
<StackProvider app={stackClientApp}>
<StackTheme>{content}</StackTheme>
<Toaster style={{ zIndex: '999999 !important', position: 'fixed' }} />
<Toaster style={{ zIndex: 999999, position: 'fixed' }} />
</StackProvider>
);
}

View file

@ -36,6 +36,11 @@ import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { INIT_PROVODERS } from '@/lib/llm';
import {
type AgentModelConfigSource,
buildAgentModelConfig,
buildAgentModelConfigFromProvider,
} from '@/lib/modelConfig';
import {
getLocalPlatformName,
LOCAL_MODEL_OPTIONS,
@ -71,11 +76,10 @@ interface McpItem {
type WorkerModelMode = 'eigent' | 'custom' | 'local';
interface WorkerModelOption {
interface WorkerModelOption extends AgentModelConfigSource {
value: string;
label: string;
model_platform: string;
model_type: string;
provider_id?: number;
}
export function AddWorker({
@ -125,6 +129,7 @@ export function AddWorker({
const [workerModelMode, setWorkerModelMode] =
useState<WorkerModelMode>('eigent');
const [workerModelName, setWorkerModelName] = useState('');
const [modelSelectionTouched, setModelSelectionTouched] = useState(false);
const [customModelOptions, setCustomModelOptions] = useState<
WorkerModelOption[]
>([]);
@ -300,6 +305,7 @@ export function AddWorker({
setShowModelConfig(false);
setWorkerModelMode('eigent');
setWorkerModelName('');
setModelSelectionTouched(false);
setCustomModelOptions([]);
setLocalModelOptions([]);
};
@ -322,10 +328,63 @@ export function AddWorker({
setWorkerName(workerInfo.workerInfo?.name || '');
setWorkerDescription(workerInfo.workerInfo?.description || '');
setSelectedTools(workerInfo.workerInfo?.selectedTools || []);
setWorkerModelMode('eigent');
setWorkerModelName('');
setModelSelectionTouched(false);
setShowModelConfig(
Number.isInteger(workerInfo.workerInfo?.model_provider_id)
);
}, [dialogOpen, edit, workerInfo]);
useEffect(() => {
if (
!dialogOpen ||
!edit ||
!showModelConfig ||
!workerInfo ||
modelSelectionTouched
)
return;
const providerId = workerInfo.workerInfo?.model_provider_id;
if (!Number.isInteger(providerId)) return;
const customOption = customModelOptions.find(
(option) => option.provider_id === providerId
);
if (customOption) {
setWorkerModelMode('custom');
setWorkerModelName(customOption.value);
return;
}
const localOption = localModelOptions.find(
(option) => option.provider_id === providerId
);
if (localOption) {
setWorkerModelMode('local');
setWorkerModelName(localOption.value);
}
}, [
customModelOptions,
dialogOpen,
edit,
localModelOptions,
modelSelectionTouched,
showModelConfig,
workerInfo,
]);
useEffect(() => {
if (!showModelConfig) return;
const editingProviderId = workerInfo?.workerInfo?.model_provider_id;
if (
dialogOpen &&
edit &&
Number.isInteger(editingProviderId) &&
!modelSelectionTouched
) {
return;
}
const options = activeWorkerModelOptions;
if (options.length === 0) {
setWorkerModelName('');
@ -334,7 +393,15 @@ export function AddWorker({
if (!options.some((opt) => opt.value === workerModelName)) {
setWorkerModelName(options[0].value);
}
}, [activeWorkerModelOptions, showModelConfig, workerModelName]);
}, [
activeWorkerModelOptions,
dialogOpen,
edit,
modelSelectionTouched,
showModelConfig,
workerInfo,
workerModelName,
]);
useEffect(() => {
if (!showModelConfig) return;
@ -358,6 +425,8 @@ export function AddWorker({
.map((provider: any) => {
const modelType = String(provider.model_type || '');
const providerName = String(provider.provider_name || '');
const agentModelConfig =
buildAgentModelConfigFromProvider(provider);
return {
value: `${providerName}::${modelType}`,
label: modelType
@ -365,6 +434,11 @@ export function AddWorker({
: providerName,
model_platform: providerName,
model_type: modelType,
provider_id: Number(provider.id),
api_key: agentModelConfig.api_key,
api_url: agentModelConfig.api_url,
model_config_dict: agentModelConfig.model_config_dict,
extra_params: agentModelConfig.extra_params,
};
});
@ -373,13 +447,10 @@ export function AddWorker({
localProviderIds.has(provider.provider_name)
)
.map((provider: any) => {
const config = provider.encrypted_config || {};
const modelPlatform = String(
config.model_platform || provider.provider_name || ''
);
const modelType = String(
config.model_type || provider.model_type || ''
);
const agentModelConfig =
buildAgentModelConfigFromProvider(provider);
const modelPlatform = agentModelConfig.model_platform;
const modelType = agentModelConfig.model_type || '';
const platformName = getLocalPlatformName(modelPlatform);
return {
value: `${modelPlatform}::${modelType}`,
@ -388,6 +459,11 @@ export function AddWorker({
: platformName,
model_platform: modelPlatform,
model_type: modelType,
provider_id: Number(provider.id),
api_key: agentModelConfig.api_key,
api_url: agentModelConfig.api_url,
model_config_dict: agentModelConfig.model_config_dict,
extra_params: agentModelConfig.extra_params,
};
});
@ -451,6 +527,23 @@ export function AddWorker({
}
}
}
const selectedModelOption = workerModelOptions[workerModelMode].find(
(option) => option.value === workerModelName
);
const customModelConfig =
showModelConfig && selectedModelOption
? buildAgentModelConfig(selectedModelOption)
: undefined;
const modelProviderId =
showModelConfig && selectedModelOption?.provider_id
? selectedModelOption.provider_id
: showModelConfig &&
edit &&
!modelSelectionTouched &&
Number.isInteger(workerInfo?.workerInfo?.model_provider_id)
? workerInfo?.workerInfo?.model_provider_id
: undefined;
if (edit) {
const newWorkerList = workerList.map((worker) => {
if (worker.type === workerInfo?.type) {
@ -473,6 +566,7 @@ export function AddWorker({
tools: localTool,
mcp_tools: mcpLocal,
selectedTools: JSON.parse(JSON.stringify(selectedTools)),
model_provider_id: modelProviderId,
},
};
return {
@ -503,22 +597,12 @@ export function AddWorker({
tools: localTool,
mcp_tools: mcpLocal,
selectedTools: JSON.parse(JSON.stringify(selectedTools)),
model_provider_id: modelProviderId,
},
};
setWorkerList([...workerList, worker]);
} else {
// Add-worker custom model config is applied to this agent only.
const selectedModelOption = workerModelOptions[workerModelMode].find(
(opt) => opt.value === workerModelName
);
const customModelConfig =
showModelConfig && selectedModelOption
? {
model_platform: selectedModelOption.model_platform,
model_type: selectedModelOption.model_type || undefined,
}
: undefined;
if (activeProjectId) {
fetchPost(`/task/${activeProjectId}/add-agent`, {
name: workerName,
@ -542,6 +626,7 @@ export function AddWorker({
tools: localTool,
mcp_tools: mcpLocal,
selectedTools: JSON.parse(JSON.stringify(selectedTools)),
model_provider_id: modelProviderId,
},
};
setWorkerList([...workerList, worker]);
@ -753,6 +838,7 @@ export function AddWorker({
<Switch
checked={showModelConfig}
onCheckedChange={(checked) => {
setModelSelectionTouched(true);
setShowModelConfig(checked);
if (!checked) {
setWorkerModelName('');
@ -771,9 +857,10 @@ export function AddWorker({
</label>
<Select
value={workerModelMode}
onValueChange={(value) =>
setWorkerModelMode(value as WorkerModelMode)
}
onValueChange={(value) => {
setModelSelectionTouched(true);
setWorkerModelMode(value as WorkerModelMode);
}}
>
<SelectTrigger
className="w-full"
@ -803,7 +890,10 @@ export function AddWorker({
</label>
<Select
value={workerModelName}
onValueChange={setWorkerModelName}
onValueChange={(value) => {
setModelSelectionTouched(true);
setWorkerModelName(value);
}}
>
<SelectTrigger
className="w-full"

View file

@ -0,0 +1,173 @@
// ========= 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. =========
/**
* Approval-mode picker for the chat input bar same pill-trigger shell as
* `ThinkingEffortSelect` / `ModelSelect` / `ProjectModeToggle` so the
* controls read as one family in the `BoxFooter` row.
*
* UI only for now: it holds its own local state and is not wired to the
* human-toolkit / approval backend.
*/
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { Check, ChevronDown, ShieldCheck, TriangleAlert } from 'lucide-react';
import { useState } from 'react';
export type ApprovalMode = 'manual' | 'skip';
interface ApprovalOption {
value: ApprovalMode;
label: string;
icon: typeof ShieldCheck;
}
const APPROVAL_OPTIONS: ApprovalOption[] = [
{ value: 'manual', label: 'Manual Approval', icon: ShieldCheck },
{ value: 'skip', label: 'Skip all approval', icon: TriangleAlert },
];
// Keep in sync with `DropdownMenuContent`'s `w-[180px]` below.
const MENU_CONTENT_WIDTH_CLASS = 'w-[180px]';
const triggerShellClass = cn(
'rounded-xl px-2 py-1 inline-flex max-w-[min(100%,320px)] shrink-0 items-center gap-1.5',
'bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default'
);
export interface ApprovalModeSelectProps {
disabled?: boolean;
/** Shows the current mode in the same read-only shell as the model/mode controls. */
readOnly?: boolean;
/** When true, hides the text label and shows only the icon (narrow footer). */
compact?: boolean;
className?: string;
}
export function ApprovalModeSelect({
disabled,
readOnly = false,
compact = false,
className,
}: ApprovalModeSelectProps) {
const [value, setValue] = useState<ApprovalMode>('manual');
const current =
APPROVAL_OPTIONS.find((o) => o.value === value) ?? APPROVAL_OPTIONS[0];
const CurrentIcon = current.icon;
if (readOnly) {
return (
<div
role="status"
title={current.label}
aria-label={current.label}
className={cn(
triggerShellClass,
'pointer-events-none bg-transparent',
{ 'opacity-50': disabled },
className
)}
>
<span className="inline-flex min-h-[1.25rem] min-w-0 items-center gap-1.5 overflow-hidden">
<CurrentIcon
className="h-3.5 w-3.5 shrink-0 opacity-80"
aria-hidden
/>
{!compact && (
<span className="min-w-0 truncate !text-label-xs font-semibold">
{current.label}
</span>
)}
</span>
</div>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
title={current.label}
aria-label={current.label}
aria-haspopup="menu"
className={cn(
triggerShellClass,
'min-w-0 cursor-pointer border-0 text-left',
'justify-between font-semibold transition-colors',
'hover:bg-ds-bg-neutral-subtle-default active:bg-ds-bg-neutral-subtle-default data-[state=open]:bg-ds-bg-neutral-subtle-default',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-border-neutral-strong-default focus-visible:ring-offset-2 focus-visible:ring-offset-ds-bg-neutral-default-default',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<CurrentIcon
className="h-3.5 w-3.5 shrink-0 opacity-80"
aria-hidden
/>
{!compact && (
<span className="min-w-0 flex-1 truncate text-left !text-label-xs text-ds-text-neutral-default-default">
{current.label}
</span>
)}
</span>
<ChevronDown
className="h-3.5 w-3.5 shrink-0 opacity-80"
aria-hidden
strokeWidth={2}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="top"
sideOffset={4}
collisionPadding={12}
avoidCollisions
className={MENU_CONTENT_WIDTH_CLASS}
>
{APPROVAL_OPTIONS.map((option) => {
const OptionIcon = option.icon;
return (
<DropdownMenuItem
key={option.value}
onSelect={() => setValue(option.value)}
className="flex items-center justify-between gap-2"
>
<span className="flex items-center gap-2">
<OptionIcon
className="h-4 w-4 shrink-0 opacity-80"
aria-hidden
/>
<span className="text-body-sm">{option.label}</span>
</span>
{value === option.value && (
<Check className="h-4 w-4 text-ds-text-success-default-default" />
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,80 @@
// ========= 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 { ProjectModeToggle } from '@/components/Workspace/ProjectModeToggle';
import { useIsCompactWidth } from '@/hooks/useIsCompactWidth';
import type { SessionModeType } from '@/types/constants';
import { ModelSelect } from './ModelSelect';
/**
* Below this footer width the session mode control collapses to icon-only so
* everything stays on a single row.
*/
const COMPACT_WIDTH_THRESHOLD = 460;
export interface BoxFooterProps {
/** Left side: single-agent / multi-agent mode control. */
sessionMode: SessionModeType;
onSessionModeChange?: (mode: SessionModeType) => void;
/** Project whose pinned model the model selector reads and writes. */
projectId?: string | null;
/**
* Project-setup controls: interactive on the workspace composer only.
* Once the project has started both controls render read-only.
*/
interactive?: boolean;
disabled?: boolean;
}
/**
* BoxFooter project-setup row under BoxMain in the BottomBox shell.
* Left: session mode control. Right: default/project-pinned model control.
* Stays on a single row; the left control collapses to icon-only when the
* footer gets narrow.
*/
export function BoxFooter({
sessionMode,
onSessionModeChange,
projectId,
interactive = false,
disabled = false,
}: BoxFooterProps) {
const [footerRef, compact] = useIsCompactWidth<HTMLDivElement>(
COMPACT_WIDTH_THRESHOLD
);
return (
<div
ref={footerRef}
className="flex w-full items-center justify-between gap-2 px-3 py-1"
>
<div className="flex min-w-0 shrink items-center gap-1">
<ProjectModeToggle
value={sessionMode}
onValueChange={onSessionModeChange ?? (() => {})}
readOnly={!interactive}
compact={compact}
className="shrink-0"
/>
</div>
<div className="flex shrink-0 items-center gap-1">
<ModelSelect
disabled={disabled}
projectId={projectId}
readOnly={!interactive && !projectId}
/>
</div>
</div>
);
}

View file

@ -13,35 +13,28 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { ProjectModeToggle } from '@/components/Workspace/ProjectModeToggle';
import { TooltipSimple } from '@/components/ui/tooltip';
import { processDroppedFiles } from '@/lib/fileUtils';
import { cn } from '@/lib/utils';
import type { TriggerInput } from '@/types';
import type { SessionModeType } from '@/types/constants';
import {
ArrowRight,
FileText,
Hammer,
Image,
Paperclip,
Plus,
UploadCloud,
WandSparkles,
X,
} from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { ChatInputModelDropdown } from './ChatInputModelDropdown';
import { RichChatInput } from './RichChatInput';
/**
@ -86,12 +79,12 @@ export interface InputboxProps {
privacy?: boolean;
/** Use cloud model in dev */
useCloudModelInDev?: boolean;
/** Session mode for the mode-select row; omit to hide it. */
sessionMode?: SessionModeType;
/** Called when the user changes mode (workspace only). */
onSessionModeChange?: (mode: SessionModeType) => void;
/** Full toggle on workspace; on session chat, only the current mode is shown. */
sessionModeSelectInteractive?: boolean;
/** Connector picker panel state; the toggle button only renders when the callback is provided. */
connectorPanelOpen?: boolean;
onToggleConnectorPanel?: () => void;
/** Skill picker panel state; the toggle button only renders when the callback is provided. */
skillPanelOpen?: boolean;
onToggleSkillPanel?: () => void;
/** Callback when trigger is being created (for placeholder) */
onTriggerCreating?: (triggerData: TriggerInput) => void;
/** Callback when trigger is created successfully */
@ -152,9 +145,10 @@ export const Inputbox = ({
allowDragDrop = false,
privacy = true,
useCloudModelInDev = false,
sessionMode,
onSessionModeChange,
sessionModeSelectInteractive = false,
connectorPanelOpen = false,
onToggleConnectorPanel,
skillPanelOpen = false,
onToggleSkillPanel,
onTriggerCreating: _onTriggerCreating,
onTriggerCreated: _onTriggerCreated,
}: InputboxProps) => {
@ -310,7 +304,7 @@ export const Inputbox = ({
return (
<div
className={cn(
'rounded-3xl border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default p-3 shadow-lg relative flex w-full flex-col items-start border border-solid transition-colors',
'relative flex w-full flex-col items-start rounded-3xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default p-3 transition-colors',
(isFocused || hasContent) &&
'border-ds-border-information-default-default',
isDragging &&
@ -323,7 +317,7 @@ export const Inputbox = ({
onDrop={handleDrop}
>
{isDragging && (
<div className="inset-0 gap-2 rounded-2xl border-ds-border-neutral-strong-default bg-ds-bg-information-subtle-default text-ds-text-neutral-default-default backdrop-blur-sm pointer-events-none absolute z-20 flex flex-col items-center justify-center border-2 border-dashed">
<div className="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-ds-border-neutral-strong-default bg-ds-bg-information-subtle-default text-ds-text-neutral-default-default backdrop-blur-sm">
<UploadCloud className="h-8 w-8" />
<div className="text-sm font-semibold">
{t('chat.drop-files-to-attach')}
@ -332,14 +326,14 @@ export const Inputbox = ({
)}
{/* Layer 2: File attachments (only show if has files) */}
{files.length > 0 && (
<div className="gap-1 pb-2 relative box-border flex w-full flex-wrap items-start">
<div className="relative box-border flex w-full flex-wrap items-start gap-1 pb-2">
{visibleFiles.map((file) => {
const isHovered = hoveredFilePath === file.filePath;
return (
<div
key={file.filePath}
className={cn(
'max-w-24 gap-0.5 rounded-md bg-ds-bg-neutral-default-default pr-1 relative box-border flex h-auto items-center'
'relative box-border flex h-auto max-w-24 items-center gap-0.5 rounded-md bg-ds-bg-neutral-default-default pr-1'
)}
onMouseEnter={() => setHoveredFilePath(file.filePath)}
onMouseLeave={() =>
@ -352,7 +346,7 @@ export const Inputbox = ({
<a
href="#"
className={cn(
'h-6 w-6 rounded-md flex cursor-pointer items-center justify-center'
'flex h-6 w-6 cursor-pointer items-center justify-center rounded-md'
)}
onClick={(e) => {
e.preventDefault();
@ -371,7 +365,7 @@ export const Inputbox = ({
{/* File Name */}
<p
className={cn(
"my-0 text-xs font-bold leading-tight text-ds-text-neutral-default-default relative min-h-px min-w-px flex-1 overflow-hidden font-['Inter'] overflow-ellipsis whitespace-nowrap"
"relative my-0 min-h-px min-w-px flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap font-['Inter'] text-xs font-bold leading-tight text-ds-text-neutral-default-default"
)}
title={file.fileName}
>
@ -390,14 +384,14 @@ export const Inputbox = ({
buttonContent="text"
textWeight="bold"
buttonRadius="full"
className="rounded-lg bg-ds-bg-neutral-strong-default relative box-border flex h-auto items-center"
className="relative box-border flex h-auto items-center rounded-lg bg-ds-bg-neutral-strong-default"
onMouseEnter={openRemainingPopover}
onMouseLeave={scheduleCloseRemainingPopover}
onClick={(e) => {
e.stopPropagation();
}}
>
<p className="my-0 text-xs font-bold leading-tight text-ds-text-neutral-default-default font-['Inter'] whitespace-nowrap">
<p className="my-0 whitespace-nowrap font-['Inter'] text-xs font-bold leading-tight text-ds-text-neutral-default-default">
{remainingCount}+
</p>
</Button>
@ -406,17 +400,17 @@ export const Inputbox = ({
align="end"
side="right"
sideOffset={4}
className="max-w-40 rounded-lg border-ds-border-neutral-subtle-default bg-ds-bg-neutral-default-default p-1 shadow-perfect !w-auto border-solid"
className="!w-auto max-w-40 rounded-lg border-solid border-ds-border-neutral-subtle-default bg-ds-bg-neutral-default-default p-1 shadow-perfect"
onMouseEnter={openRemainingPopover}
onMouseLeave={scheduleCloseRemainingPopover}
>
<div className="scrollbar-hide gap-1 flex max-h-[176px] flex-col overflow-auto">
<div className="scrollbar-hide flex max-h-[176px] flex-col gap-1 overflow-auto">
{files.slice(maxVisibleFiles).map((file) => {
const isHovered = hoveredFilePath === file.filePath;
return (
<div
key={file.filePath}
className="gap-1 rounded-lg bg-ds-bg-neutral-strong-default px-1 py-0.5 hover:bg-ds-bg-neutral-default-hover flex cursor-pointer items-center transition-colors duration-300"
className="flex cursor-pointer items-center gap-1 rounded-lg bg-ds-bg-neutral-strong-default px-1 py-0.5 transition-colors duration-300 hover:bg-ds-bg-neutral-default-hover"
onMouseEnter={() => setHoveredFilePath(file.filePath)}
onMouseLeave={() =>
setHoveredFilePath((prev) =>
@ -427,7 +421,7 @@ export const Inputbox = ({
<a
href="#"
className={cn(
'h-6 w-6 rounded-md flex cursor-pointer items-center justify-center'
'flex h-6 w-6 cursor-pointer items-center justify-center rounded-md'
)}
onClick={(e) => {
e.preventDefault();
@ -445,7 +439,7 @@ export const Inputbox = ({
getFileIcon(file.fileName)
)}
</a>
<p className="my-0 text-xs font-bold leading-tight text-ds-text-neutral-default-default flex-1 overflow-hidden font-['Inter'] text-ellipsis whitespace-nowrap">
<p className="my-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap font-['Inter'] text-xs font-bold leading-tight text-ds-text-neutral-default-default">
{file.fileName}
</p>
</div>
@ -459,7 +453,7 @@ export const Inputbox = ({
)}
{/* Layer 3: Text input area */}
<div className="gap-2.5 pb-3 relative flex w-full flex-1 items-start justify-center">
<div className="relative flex w-full flex-1 items-start justify-center gap-2.5 pb-3">
<RichChatInput
ref={textareaRef as React.RefObject<HTMLDivElement>}
value={value}
@ -489,67 +483,83 @@ export const Inputbox = ({
</div>
{/* Layer 4: Action buttons */}
<div className="flex w-full items-center justify-between">
{/* Left: Add File Button and Add Trigger Button */}
<div className="gap-2 flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex w-full flex-wrap items-center justify-between gap-y-2">
{/* Left: add files/photos + connector picker + skill picker */}
<div className="flex min-w-0 items-center gap-2">
<TooltipSimple content="Attach" side="top">
<Button
type="button"
variant="ghost"
size="xs"
buttonContent="icon-only"
textWeight="bold"
buttonRadius="lg"
disabled={
disabled ||
!privacy ||
useCloudModelInDev ||
typeof onAddFile !== 'function'
}
aria-label={t('chat.input-attach-add-files-or-photos')}
onClick={() => onAddFile?.()}
>
<Paperclip />
</Button>
</TooltipSimple>
{onToggleConnectorPanel && (
<TooltipSimple content="MCPs" side="top">
<Button
type="button"
data-picker-trigger
variant="ghost"
size="xs"
buttonContent="icon-only"
textWeight="bold"
buttonRadius="lg"
disabled={disabled}
aria-label={t('chat.input-attach-menu-trigger')}
aria-haspopup="menu"
aria-label={t('chat.input-add-connector', {
defaultValue: 'Add connectors',
})}
aria-haspopup="true"
aria-expanded={connectorPanelOpen}
className={cn(
connectorPanelOpen && 'bg-ds-bg-neutral-strong-default'
)}
onClick={onToggleConnectorPanel}
>
<Plus />
<Hammer />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
className="min-w-[13.5rem]"
>
<DropdownMenuItem
disabled={
!privacy ||
useCloudModelInDev ||
typeof onAddFile !== 'function'
}
onSelect={() => {
onAddFile?.();
}}
</TooltipSimple>
)}
{onToggleSkillPanel && (
<TooltipSimple content="Skills" side="top">
<Button
type="button"
data-picker-trigger
variant="ghost"
size="xs"
buttonContent="icon-only"
textWeight="bold"
buttonRadius="lg"
disabled={disabled}
aria-label={t('chat.input-add-skill', {
defaultValue: 'Add skills',
})}
aria-haspopup="true"
aria-expanded={skillPanelOpen}
className={cn(
skillPanelOpen && 'bg-ds-bg-neutral-strong-default'
)}
onClick={onToggleSkillPanel}
>
<Paperclip
className="text-ds-icon-neutral-default-default"
aria-hidden
/>
{t('chat.input-attach-add-files-or-photos')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChatInputModelDropdown
disabled={disabled}
readOnly={
sessionMode !== undefined &&
sessionModeSelectInteractive === false
}
/>
<WandSparkles />
</Button>
</TooltipSimple>
)}
</div>
{/* Right: Session mode (workspace: full toggle; session: current mode only) + send */}
<div className="gap-2 flex items-center">
{sessionMode !== undefined && (
<ProjectModeToggle
value={sessionMode}
onValueChange={onSessionModeChange ?? (() => {})}
readOnly={!sessionModeSelectInteractive}
className="shrink-0"
/>
)}
{/* Right: send */}
<div className="flex shrink-0 items-center gap-2">
<Button
size="xs"
buttonContent="icon-only"

View file

@ -48,6 +48,8 @@ import {
} from '@/shared/modelProviderImages';
import { useAuthStore } from '@/store/authStore';
import { useCloudModelStore } from '@/store/cloudModelStore';
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
import { useSpaceStore } from '@/store/spaceStore';
import type { Provider } from '@/types';
import {
@ -57,15 +59,20 @@ import {
Key,
Layers,
Server,
Sparkles,
} from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export interface ChatInputModelDropdownProps {
export interface ModelSelectProps {
disabled?: boolean;
/**
* Project whose pinned model this dropdown reads and writes. When set,
* selections update only that Project's captured model; the global
* default model is left untouched.
*/
projectId?: string | null;
/**
* When true, shows the current default model in the same shell as
* `ProjectModeToggle` (readOnly) no chevron, not interactive,
@ -80,10 +87,11 @@ const modelTriggerShellClass = cn(
'bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default'
);
export function ChatInputModelDropdown({
export function ModelSelect({
disabled,
projectId,
readOnly = false,
}: ChatInputModelDropdownProps) {
}: ModelSelectProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const {
@ -105,6 +113,26 @@ export function ChatInputModelDropdown({
const effectiveCloudModelId = useCloudModelStore((state) =>
state.getEffectiveModelId(cloud_model_type)
);
const setProjectModel = useProjectRuntimeStore(
(state) => state.setProjectModel
);
const runtimePinnedSelection = useProjectRuntimeStore((state) =>
projectId
? (state.projects[projectId]?.metadata?.modelSelection ?? null)
: null
);
const spacePinnedSelection = useSpaceStore((state) => {
if (!projectId) return null;
const spaceId = state.projectIdIndex[projectId];
if (!spaceId) return null;
return (
state.projectsBySpaceId[spaceId]?.[projectId]?.metadata?.modelSelection ??
null
);
});
const pinnedSelection = projectId
? (runtimePinnedSelection ?? spacePinnedSelection)
: null;
const cloudModelOptions = useMemo(
() =>
cloudModels.map((model) => ({
@ -276,14 +304,64 @@ export function ChatInputModelDropdown({
}, [refreshCodexStatus]);
const handleCodexSetDefault = useCallback(() => {
if (projectId) {
const codexModelId = codex_model_type || 'gpt-5.5';
setProjectModel(projectId, {
modelType: 'codex_subscription',
codex_model_type: codexModelId,
model_platform: 'openai',
model_type: codexModelId,
});
return;
}
setCloudPrefer(false);
setLocalPrefer(false);
setForm((f) => f.map((fi) => ({ ...fi, prefer: false })));
setModelType('codex_subscription');
}, [setModelType]);
}, [codex_model_type, projectId, setModelType, setProjectModel]);
/** Model name only in the trigger (e.g. "Gemini 3.1 Pro Preview", no cloud/source prefix). */
const triggerModelName = useMemo(() => {
if (pinnedSelection) {
if (pinnedSelection.modelType === 'codex_subscription') {
const pinnedCodexModelType = pinnedSelection.codex_model_type || '';
return `Codex Subscription${pinnedCodexModelType ? ` (${pinnedCodexModelType})` : ''}`;
}
if (pinnedSelection.modelType === 'cloud') {
return getCloudModelDisplayName(
pinnedSelection.cloud_model_type || cloud_model_type
);
}
if (pinnedSelection.modelType === 'custom') {
const idx =
pinnedSelection.provider_id !== undefined
? form.findIndex(
(f) => f.provider_id === pinnedSelection.provider_id
)
: -1;
if (idx !== -1) {
const mt = form[idx].model_type || '';
return `${items[idx].name}${mt ? ` (${mt})` : ''}`;
}
}
if (pinnedSelection.modelType === 'local') {
const platform = Object.keys(localProviderIds).find(
(key) => localProviderIds[key] === pinnedSelection.provider_id
);
if (platform) {
const mt = localTypes[platform] || '';
return `${getLocalPlatformName(platform)}${mt ? ` (${mt})` : ''}`;
}
}
// Providers are still loading (or the pinned provider disappeared):
// fall back to the identifiers captured with the pin.
if (pinnedSelection.model_platform || pinnedSelection.model_type) {
const platformLabel = pinnedSelection.model_platform || '';
const mt = pinnedSelection.model_type || '';
return platformLabel ? `${platformLabel}${mt ? ` (${mt})` : ''}` : mt;
}
}
if (modelType === 'codex_subscription') {
return `Codex Subscription${codex_model_type ? ` (${codex_model_type})` : ''}`;
}
@ -315,8 +393,10 @@ export function ChatInputModelDropdown({
items,
localPrefer,
localPlatform,
localProviderIds,
localTypes,
modelType,
pinnedSelection,
t,
]);
@ -335,6 +415,41 @@ export function ChatInputModelDropdown({
navigate(DEFAULT_MODEL_CONFIGURE_PATH);
return;
}
if (projectId) {
// Pin the choice to this Project only; the global default model
// (and the server-side preferred provider) stays unchanged.
if (category === 'cloud') {
setProjectModel(projectId, {
modelType: 'cloud',
cloud_model_type: modelId,
});
return;
}
if (category === 'custom') {
const idx = items.findIndex((item) => item.id === modelId);
const providerId = idx !== -1 ? form[idx]?.provider_id : undefined;
if (providerId === undefined) return;
setProjectModel(projectId, {
modelType: 'custom',
provider_id: providerId,
model_platform: modelId,
model_type: form[idx]?.model_type || undefined,
});
return;
}
if (category === 'local') {
const providerId = localProviderIds[modelId];
if (providerId === undefined) return;
setProjectModel(projectId, {
modelType: 'local',
provider_id: providerId,
model_platform: modelId,
model_type: localTypes[modelId] || undefined,
});
return;
}
return;
}
await applyDefaultModelSelection({
category,
modelId,
@ -358,13 +473,21 @@ export function ChatInputModelDropdown({
form,
localProviderIds,
localPlatform,
localTypes,
navigate,
projectId,
setProjectModel,
setModelType,
setCloudModelType,
t,
]
);
// Grow the trigger to match the open dropdown's content width (never shrink
// below its own natural content width). Keep in sync with the `w-[180px]`
// on `DropdownMenuContent` below.
const [open, setOpen] = useState(false);
const activeSubTriggerRef = useRef<HTMLElement | null>(null);
// Bottom-align the sub content with the trigger row purely imperatively:
@ -394,9 +517,8 @@ export function ChatInputModelDropdown({
}
)}
>
<span className="min-w-0 gap-1.5 inline-flex min-h-[1.25rem] items-center overflow-hidden">
<Sparkles className="size-3.5 shrink-0" strokeWidth={2} aria-hidden />
<span className="min-w-0 !text-label-xs font-semibold truncate">
<span className="inline-flex min-h-[1.25rem] min-w-0 items-center gap-1.5 overflow-hidden">
<span className="min-w-0 truncate !text-label-xs font-semibold">
{triggerModelName}
</span>
</span>
@ -406,8 +528,9 @@ export function ChatInputModelDropdown({
return (
<DropdownMenu
onOpenChange={(open) => {
if (open) void fetchCloudModels();
onOpenChange={(next) => {
setOpen(next);
if (next) void fetchCloudModels();
}}
>
<DropdownMenuTrigger asChild>
@ -420,19 +543,17 @@ export function ChatInputModelDropdown({
className={cn(
modelTriggerShellClass,
'min-w-0 cursor-pointer border-0 text-left',
'font-semibold justify-between transition-colors',
'hover:bg-ds-bg-neutral-subtle-hover active:bg-ds-bg-neutral-subtle-default',
'focus-visible:ring-ds-border-neutral-strong-default focus-visible:ring-offset-ds-bg-neutral-default-default focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
'disabled:pointer-events-none disabled:opacity-50'
'justify-between font-semibold transition-all',
'hover:bg-ds-bg-neutral-subtle-default active:bg-ds-bg-neutral-subtle-default data-[state=open]:bg-ds-bg-neutral-subtle-default',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-border-neutral-strong-default focus-visible:ring-offset-2 focus-visible:ring-offset-ds-bg-neutral-default-default',
'disabled:pointer-events-none disabled:opacity-50',
// While open, only the trigger's content grows to the menu width;
// the chevron stays pinned at the right edge.
open && 'min-w-[180px]'
)}
>
<span className="min-w-0 gap-1.5 flex flex-1 items-center overflow-hidden">
<Sparkles
className="size-3.5 shrink-0"
strokeWidth={2}
aria-hidden
/>
<span className="min-w-0 !text-label-xs text-ds-text-neutral-default-default flex-1 truncate text-left">
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<span className="min-w-0 flex-1 truncate text-center !text-label-xs text-ds-text-neutral-default-default">
{triggerModelName}
</span>
</span>
@ -444,7 +565,7 @@ export function ChatInputModelDropdown({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
align="end"
side="top"
sideOffset={4}
collisionPadding={12}
@ -454,7 +575,7 @@ export function ChatInputModelDropdown({
{import.meta.env.VITE_USE_LOCAL_PROXY !== 'true' && (
<DropdownMenuSub>
<DropdownMenuSubTrigger
className="min-w-0 gap-2 [&>svg:first-child]:!h-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4 flex w-full items-center justify-start"
className="flex w-full min-w-0 items-center justify-start gap-2 [&>svg:first-child]:!h-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4"
onPointerEnter={(e) => {
activeSubTriggerRef.current = e.currentTarget;
}}
@ -465,7 +586,7 @@ export function ChatInputModelDropdown({
className="mt-0.5 h-4 w-4 shrink-0"
aria-hidden
/>
<span className="min-w-0 text-body-sm flex-1 text-left">
<span className="min-w-0 flex-1 text-left text-body-sm">
{t('setting.eigent-cloud')}
</span>
</DropdownMenuSubTrigger>
@ -482,7 +603,11 @@ export function ChatInputModelDropdown({
className="flex items-center justify-between"
>
<span className="text-body-sm">{model.name}</span>
{cloudPrefer && effectiveCloudModelId === model.id && (
{(pinnedSelection
? pinnedSelection.modelType === 'cloud' &&
(pinnedSelection.cloud_model_type ||
effectiveCloudModelId) === model.id
: cloudPrefer && effectiveCloudModelId === model.id) && (
<Check className="h-4 w-4 text-ds-text-success-default-default" />
)}
</DropdownMenuItem>
@ -493,16 +618,16 @@ export function ChatInputModelDropdown({
<DropdownMenuSub>
<DropdownMenuSubTrigger
className="min-w-0 gap-2 [&>svg:first-child]:!h-5 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4 flex w-full items-center justify-start"
className="flex w-full min-w-0 items-center justify-start gap-2 [&>svg:first-child]:!h-5 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4"
onPointerEnter={(e) => {
activeSubTriggerRef.current = e.currentTarget;
}}
>
<Layers
className="text-ds-icon-neutral-default-default shrink-0"
className="shrink-0 text-ds-icon-neutral-default-default"
aria-hidden
/>
<span className="min-w-0 text-body-sm flex-1 text-left">
<span className="min-w-0 flex-1 text-left text-body-sm">
{t('setting.custom-model')}
</span>
</DropdownMenuSubTrigger>
@ -524,9 +649,15 @@ export function ChatInputModelDropdown({
const isConfigured = isSubscriptionAuth
? codexStatus.connected
: !!form[idx]?.provider_id;
const isPreferred = isSubscriptionAuth
? modelType === 'codex_subscription'
: form[idx]?.prefer;
const isPreferred = pinnedSelection
? isSubscriptionAuth
? pinnedSelection.modelType === 'codex_subscription'
: pinnedSelection.modelType === 'custom' &&
pinnedSelection.provider_id !== undefined &&
form[idx]?.provider_id === pinnedSelection.provider_id
: isSubscriptionAuth
? modelType === 'codex_subscription'
: form[idx]?.prefer;
const modelImage = getModelImage(item.id);
return (
@ -545,7 +676,7 @@ export function ChatInputModelDropdown({
}}
className="flex items-center justify-between"
>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{modelImage ? (
<img
src={modelImage}
@ -566,15 +697,15 @@ export function ChatInputModelDropdown({
{item.name}
</span>
</div>
<div className="gap-1 flex items-center">
<div className="flex items-center gap-1">
{!isConfigured && (
<div className="h-2 w-2 bg-ds-text-neutral-subtle-default rounded-full opacity-10" />
<div className="h-2 w-2 rounded-full bg-ds-text-neutral-subtle-default opacity-10" />
)}
{isPreferred && (
<Check className="h-4 w-4 text-ds-text-success-default-default" />
)}
{isConfigured && !isPreferred && (
<div className="h-2 w-2 bg-ds-text-success-default-default rounded-full" />
<div className="h-2 w-2 rounded-full bg-ds-text-success-default-default" />
)}
</div>
</DropdownMenuItem>
@ -585,16 +716,16 @@ export function ChatInputModelDropdown({
<DropdownMenuSub>
<DropdownMenuSubTrigger
className="min-w-0 gap-2 [&>svg:first-child]:!h-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4 flex w-full items-center justify-start"
className="flex w-full min-w-0 items-center justify-start gap-2 [&>svg:first-child]:!h-4 [&>svg:first-child]:!min-h-4 [&>svg:first-child]:!w-4 [&>svg:first-child]:!min-w-4"
onPointerEnter={(e) => {
activeSubTriggerRef.current = e.currentTarget;
}}
>
<HardDrive
className="text-ds-icon-neutral-default-default shrink-0"
className="shrink-0 text-ds-icon-neutral-default-default"
aria-hidden
/>
<span className="min-w-0 text-body-sm flex-1 text-left">
<span className="min-w-0 flex-1 text-left text-body-sm">
{t('setting.local-model')}
</span>
</DropdownMenuSubTrigger>
@ -604,7 +735,11 @@ export function ChatInputModelDropdown({
>
{LOCAL_MODEL_OPTIONS.map((model) => {
const isConfigured = !!localProviderIds[model.id];
const isPreferred = localPrefer && localPlatform === model.id;
const isPreferred = pinnedSelection
? pinnedSelection.modelType === 'local' &&
pinnedSelection.provider_id !== undefined &&
localProviderIds[model.id] === pinnedSelection.provider_id
: localPrefer && localPlatform === model.id;
const modelImage = getModelImage(`local-${model.id}`);
return (
@ -615,7 +750,7 @@ export function ChatInputModelDropdown({
}}
className="flex items-center justify-between"
>
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{modelImage ? (
<img
src={modelImage}
@ -636,15 +771,15 @@ export function ChatInputModelDropdown({
{model.name}
</span>
</div>
<div className="gap-1 flex items-center">
<div className="flex items-center gap-1">
{!isConfigured && (
<div className="h-2 w-2 bg-ds-text-neutral-subtle-default rounded-full opacity-10" />
<div className="h-2 w-2 rounded-full bg-ds-text-neutral-subtle-default opacity-10" />
)}
{isPreferred && (
<Check className="h-4 w-4 text-ds-text-success-default-default" />
)}
{isConfigured && !isPreferred && (
<div className="h-2 w-2 bg-ds-text-success-default-default rounded-full" />
<div className="h-2 w-2 rounded-full bg-ds-text-success-default-default" />
)}
</div>
</DropdownMenuItem>

View file

@ -0,0 +1,358 @@
// ========= 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 { proxyFetchGet } from '@/api/http';
import ellipseIcon from '@/assets/mcp/Ellipse-25.svg';
import { Button } from '@/components/ui/button';
import { integrationLeadingIconUrl } from '@/lib/connectorIcons';
import {
RICH_CONNECTOR_STYLE_CLASSES,
RICH_SKILL_STYLE_CLASSES,
connectorNameToToken,
hashSkillLabel,
} from '@/lib/richText';
import { skillNameToDirName } from '@/lib/skillToolkit';
import { cn } from '@/lib/utils';
import { useSkillsStore } from '@/store/skillsStore';
import { Check, Plus, Wrench } from 'lucide-react';
import { Fragment, useEffect, useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
/**
* An item shown in a picker panel. `token` is the exact string inserted inline
* into the rich chat input when the item is selected (`#skill` / `@connector`).
*/
export interface PickerItem {
id: string;
name: string;
token: string;
}
/** A labelled section within a picker (e.g. built-in vs. your own connectors). */
export interface PickerGroup {
id: string;
/** Section heading; omit for a single ungrouped list (e.g. skills). */
label?: string;
items: PickerItem[];
}
interface PickerPanelProps {
title: string;
groups: PickerGroup[];
/** Current input text — an item is "added" when its token appears in it. */
inputValue: string;
onToggleItem: (item: PickerItem) => void;
/** Leading token tag for a row (`#skill` / `@connector`). */
renderTag: (item: PickerItem) => ReactNode;
/** Leading logo/icon for a row, shown before the item name. Omit for no logo. */
renderLogo?: (item: PickerItem) => ReactNode;
loading?: boolean;
emptyLabel: string;
emptyActionLabel: string;
onEmptyAction: () => void;
}
/**
* Floating list panel shown above BoxMain in the BottomBox shell. Selecting an
* item inserts its token inline into the input; selecting an added item removes
* it. Purely presentational the trigger and open state live in BottomBox.
*/
export function PickerPanel({
title,
groups,
inputValue,
onToggleItem,
renderTag,
renderLogo,
loading = false,
emptyLabel,
emptyActionLabel,
onEmptyAction,
}: PickerPanelProps) {
const nonEmptyGroups = groups.filter((g) => g.items.length > 0);
const totalItems = nonEmptyGroups.reduce((n, g) => n + g.items.length, 0);
return (
<div className="flex w-full flex-col overflow-hidden rounded-2xl border border-solid border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default">
{/* Header */}
<div className="flex items-center gap-1 px-3 pb-1 pt-2">
<span className="text-xs font-bold text-ds-text-neutral-muted-default">
{title}
</span>
{totalItems > 0 && (
<span className="text-xs font-bold text-ds-text-neutral-muted-default">
{totalItems}
</span>
)}
</div>
{/* List: max-h-[240px] caps the panel's scrollable area */}
<div className="scrollbar-always-visible flex max-h-[240px] flex-col gap-0.5 overflow-y-auto p-1">
{loading ? (
<>
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-8 w-full animate-pulse rounded-lg bg-ds-bg-neutral-strong-default"
/>
))}
</>
) : totalItems === 0 ? (
<div className="flex w-full items-center justify-between gap-2 px-2 py-2">
<span className="text-xs font-normal text-ds-text-neutral-muted-default">
{emptyLabel}
</span>
<Button
variant="ghost"
size="xs"
buttonContent="text"
onClick={onEmptyAction}
>
{emptyActionLabel}
</Button>
</div>
) : (
nonEmptyGroups.map((group) => (
<Fragment key={group.id}>
{group.label && (
<div className="px-2 pb-0.5 pt-1.5 text-xs font-bold text-ds-text-neutral-muted-default">
{group.label}
</div>
)}
{group.items.map((item) => (
<PickerPanelItem
key={item.id}
item={item}
tag={renderTag(item)}
logo={renderLogo?.(item)}
added={inputValue.includes(item.token)}
onToggle={() => onToggleItem(item)}
/>
))}
</Fragment>
))
)}
</div>
</div>
);
}
interface PickerPanelItemProps {
item: PickerItem;
tag: ReactNode;
logo?: ReactNode;
added: boolean;
onToggle: () => void;
}
function PickerPanelItem({
item,
tag,
logo,
added,
onToggle,
}: PickerPanelItemProps) {
return (
<button
type="button"
aria-pressed={added}
className="group flex w-full items-center gap-2 rounded-xl border-0 bg-ds-bg-neutral-subtle-default px-2 py-1.5 text-left transition-colors hover:bg-ds-bg-neutral-default-default"
onClick={onToggle}
>
{logo && (
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
{logo}
</span>
)}
<span className="min-w-0 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap text-sm font-medium text-ds-text-neutral-default-default">
{item.name}
</span>
<span className="max-w-[45%] shrink-0 overflow-hidden whitespace-nowrap">
{tag}
</span>
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
{added ? (
<Check size={16} className="text-ds-icon-success-default-default" />
) : (
<Plus
size={16}
className="text-ds-icon-neutral-muted-default opacity-0 transition-opacity group-hover:opacity-100"
/>
)}
</span>
</button>
);
}
interface WiredPickerPanelProps {
inputValue: string;
onToggleItem: (item: PickerItem) => void;
}
/** Built-in integrations excluded from the MCP connector list (mirrors settings). */
const EXCLUDED_BUILTIN_CONNECTORS = ['Search', 'RAG'];
/**
* Full connector list matching the Connectors settings page: built-in
* integrations (`/api/v1/config/info`) plus the user's own MCPs
* (`/api/v1/mcp/users`), shown as two labelled sections.
*/
export function ConnectorPickerPanel({
inputValue,
onToggleItem,
}: WiredPickerPanelProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [builtIn, setBuiltIn] = useState<PickerItem[]>([]);
const [yourMcps, setYourMcps] = useState<PickerItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
Promise.allSettled([
proxyFetchGet('/api/v1/config/info'),
proxyFetchGet('/api/v1/mcp/users'),
])
.then(([infoRes, usersRes]) => {
if (cancelled) return;
if (
infoRes.status === 'fulfilled' &&
infoRes.value &&
typeof infoRes.value === 'object'
) {
setBuiltIn(
Object.keys(infoRes.value)
.filter((key) => !EXCLUDED_BUILTIN_CONNECTORS.includes(key))
.map((key) => ({
id: `builtin-${key}`,
name: key,
token: connectorNameToToken(key),
}))
);
}
if (usersRes.status === 'fulfilled') {
const list = Array.isArray(usersRes.value)
? usersRes.value
: (usersRes.value?.items ?? []);
setYourMcps(
list.map((item: { id: number; mcp_name: string }) => ({
id: `user-${item.id}`,
name: item.mcp_name,
token: connectorNameToToken(item.mcp_name),
}))
);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const groups: PickerGroup[] = [
{
id: 'builtin',
label: t('setting.mcp-sidebar-built-in'),
items: builtIn,
},
{ id: 'yours', label: t('setting.your-own-mcps'), items: yourMcps },
];
return (
<PickerPanel
title={t('chat.input-attach-connectors')}
groups={groups}
inputValue={inputValue}
onToggleItem={onToggleItem}
renderTag={(item) => (
<span
className={cn(
'rounded px-1 py-px text-xs font-medium',
RICH_CONNECTOR_STYLE_CLASSES
)}
>
{item.token}
</span>
)}
renderLogo={(item) => {
if (!item.id.startsWith('builtin-')) {
return (
<Wrench size={16} className="text-ds-icon-neutral-muted-default" />
);
}
const iconUrl = integrationLeadingIconUrl(item.name);
return iconUrl ? (
<img src={iconUrl} alt="" className="h-4 w-4 object-contain" />
) : (
<img src={ellipseIcon} alt="" className="h-3 w-3" />
);
}}
loading={loading}
emptyLabel={t('chat.no-connectors-added')}
emptyActionLabel={t('chat.input-attach-manage-connectors')}
onEmptyAction={() => navigate('/history?tab=connectors')}
/>
);
}
/** Lists the user's enabled skills from the skills store. */
export function SkillPickerPanel({
inputValue,
onToggleItem,
}: WiredPickerPanelProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const skills = useSkillsStore((s) => s.skills);
const items = useMemo(
() =>
skills
.filter((s) => s.enabled)
.map((s) => ({
id: s.id,
name: s.name,
token: `#${s.skillDirName || skillNameToDirName(s.name)}`,
})),
[skills]
);
return (
<PickerPanel
title={t('chat.input-attach-skills')}
groups={[{ id: 'skills', items }]}
inputValue={inputValue}
onToggleItem={onToggleItem}
renderTag={(item) => {
const clsIdx =
hashSkillLabel(item.token) % RICH_SKILL_STYLE_CLASSES.length;
return (
<span
className={cn(
'rounded px-1 py-px text-xs font-medium',
RICH_SKILL_STYLE_CLASSES[clsIdx]
)}
>
{item.token}
</span>
);
}}
emptyLabel={t('chat.no-skills-added')}
emptyActionLabel={t('chat.input-attach-manage-skills')}
onEmptyAction={() => navigate('/history?tab=agents')}
/>
);
}

View file

@ -44,12 +44,12 @@ export function QueuedBox({
return (
<div
className={cn(
'border-solid-80 gap-1 rounded-t-2xl py-1 border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default flex w-full flex-col items-start justify-center border border-b-0',
'border-solid-80 flex w-full flex-col items-start justify-center gap-1 rounded-2xl border border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default py-1',
className
)}
>
{/* Queuing Header Top */}
<div className="gap-1 px-2.5 py-0 relative box-border flex w-full items-center">
<div className="relative box-border flex w-full items-center gap-1 px-2.5 py-0">
{/* Lead Button for expand/collapse */}
<Button
variant="ghost"
@ -71,8 +71,8 @@ export function QueuedBox({
</Button>
{/* Middle - Queued Title */}
<div className="gap-0.5 relative flex min-h-px min-w-px flex-1 items-center">
<div className="mr-1 relative flex shrink-0 flex-col justify-center">
<div className="relative flex min-h-px min-w-px flex-1 items-center gap-0.5">
<div className="relative mr-1 flex shrink-0 flex-col justify-center">
<span className="text-xs font-bold text-ds-text-neutral-default-default">
{queuedMessages.length}
</span>
@ -88,7 +88,7 @@ export function QueuedBox({
{/* Header Content - Accordion Items for queued tasks */}
<div
className={cn(
'scrollbar-always-visible gap-1 px-2 py-0 ease-in-out relative box-border flex w-full flex-col items-start overflow-y-auto transition-all duration-200',
'scrollbar-always-visible relative box-border flex w-full flex-col items-start gap-1 overflow-y-auto px-2 py-0 transition-all duration-200 ease-in-out',
isExpanded && queuedMessages.length > 0
? 'max-h-[156px] opacity-100'
: 'max-h-0 opacity-0'
@ -117,16 +117,16 @@ function QueueingItem({ content, onRemove }: QueueingItemProps) {
return (
<div
className="gap-2 rounded-md px-1 py-1 bg-ds-bg-neutral-strong-default hover:bg-ds-bg-neutral-default-default relative box-border flex w-full cursor-pointer items-center transition-all duration-200"
className="relative box-border flex w-full cursor-pointer items-center gap-2 rounded-md bg-ds-bg-neutral-strong-default px-1 py-1 transition-all duration-200 hover:bg-ds-bg-neutral-default-default"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="h-5 w-5 rounded-md p-0.5 flex shrink-0 items-center justify-center bg-transparent">
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-transparent p-0.5">
<Circle size={16} className="text-ds-icon-neutral-muted-default" />
</div>
<div className="relative flex min-h-px min-w-px flex-1 flex-col justify-center overflow-hidden overflow-ellipsis">
<p className="m-0 text-xs font-normal overflow-hidden overflow-ellipsis whitespace-nowrap">
<p className="m-0 overflow-hidden overflow-ellipsis whitespace-nowrap text-xs font-normal">
{content}
</p>
</div>
@ -136,10 +136,10 @@ function QueueingItem({ content, onRemove }: QueueingItemProps) {
size="xs"
buttonContent="icon-only"
className={cn(
'h-5 w-5 rounded-md p-0.5 shrink-0 transition-all duration-200',
'h-5 w-5 shrink-0 rounded-md p-0.5 transition-all duration-200',
isHovered
? 'translate-x-0 hover:bg-ds-bg-neutral-default-hover opacity-100'
: 'translate-x-2 pointer-events-none opacity-0'
? 'translate-x-0 opacity-100 hover:bg-ds-bg-neutral-default-hover'
: 'pointer-events-none translate-x-2 opacity-0'
)}
onClick={(e) => {
e.preventDefault();

View file

@ -233,10 +233,13 @@ export const RichChatInput = React.forwardRef<
plain.length === 0 ? '' : segmentsToHtml(tokenizeRichPlainText(plain));
el.innerHTML = html || '<br />';
if (restoreOffset !== undefined) {
requestAnimationFrame(() => {
setCaretOffset(el, Math.min(restoreOffset, plain.length));
scrollCaretIntoView(el);
});
// Restore the caret synchronously. Reassigning innerHTML above collapses
// the selection to offset 0; deferring the restore to requestAnimationFrame
// left a full frame during which fast keystrokes were inserted at the
// start (the "cursor jumps to the beginning" bug). Only the scroll, which
// needs layout, stays deferred.
setCaretOffset(el, Math.min(restoreOffset, plain.length));
requestAnimationFrame(() => scrollCaretIntoView(el));
}
}, []);
@ -324,6 +327,48 @@ export const RichChatInput = React.forwardRef<
resizeHeight();
};
/**
* `#skill` / `@connector` chips are atomic (`contenteditable="false"`), so the
* caret can only sit immediately before or after one never inside. Delete
* the whole token in one keypress instead of falling through to the browser's
* native "select the atomic node first" behavior, which can take two presses
* (or none, depending on browser) to actually remove it.
*/
const handleChipAwareDelete = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>): boolean => {
if (e.key !== 'Backspace' && e.key !== 'Delete') return false;
const el = rootRef.current;
if (!el) return false;
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return false;
const caret = getCaretOffset(el);
const segments = tokenizeRichPlainText(value);
let offset = 0;
for (const seg of segments) {
const start = offset;
const end = offset + seg.text.length;
const isChip = seg.type === 'skill' || seg.type === 'connector';
const hit =
isChip &&
((e.key === 'Backspace' && end === caret) ||
(e.key === 'Delete' && start === caret));
if (hit) {
e.preventDefault();
const newValue = value.slice(0, start) + value.slice(end);
internalUpdate.current = true;
onChange(newValue, start);
applyHtml(newValue, start);
resizeHeight();
return true;
}
offset = end;
}
return false;
},
[applyHtml, onChange, resizeHeight, value]
);
const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
@ -410,7 +455,10 @@ export const RichChatInput = React.forwardRef<
suppressContentEditableWarning
onInput={handleInput}
onPaste={handlePaste}
onKeyDown={onKeyDown}
onKeyDown={(e) => {
if (handleChipAwareDelete(e)) return;
onKeyDown?.(e);
}}
onFocus={onFocus}
onBlur={handleBlur}
onCompositionStart={() => {

View file

@ -0,0 +1,147 @@
// ========= 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. =========
/**
* Reasoning/thinking effort picker for the chat input bar same pill-trigger
* shell as `ModelSelect` / `ProjectModeToggle` so the three controls
* read as one family in the `BoxFooter` row.
*/
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { ThinkingEffort, type ThinkingEffortType } from '@/types/constants';
import { Check, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export interface ThinkingEffortSelectProps {
value: ThinkingEffortType;
onValueChange?: (effort: ThinkingEffortType) => void;
disabled?: boolean;
/** Shows the current effort in the same read-only shell as the model/mode controls. */
readOnly?: boolean;
className?: string;
}
const EFFORT_OPTIONS: ThinkingEffortType[] = [
ThinkingEffort.LIGHT,
ThinkingEffort.MEDIUM,
ThinkingEffort.HIGH,
ThinkingEffort.EXTRA_HIGH,
ThinkingEffort.ULTRA,
];
// Keep in sync with `DropdownMenuContent`'s `w-[160px]` below.
const MENU_CONTENT_WIDTH_CLASS = 'w-[160px]';
const triggerShellClass = cn(
'rounded-xl px-2 py-1 inline-flex max-w-[min(100%,320px)] shrink-0 items-center gap-1.5',
'bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default'
);
export function ThinkingEffortSelect({
value,
onValueChange,
disabled,
readOnly = false,
className,
}: ThinkingEffortSelectProps) {
const { t } = useTranslation();
const effortLabel = (effort: ThinkingEffortType) =>
t(`layout.thinking-effort-${effort}`);
const currentLabel = effortLabel(value);
if (readOnly) {
return (
<div
role="status"
title={currentLabel}
aria-label={currentLabel}
className={cn(
triggerShellClass,
'pointer-events-none bg-transparent',
{ 'opacity-50': disabled },
className
)}
>
<span className="inline-flex min-h-[1.25rem] min-w-0 items-center gap-1.5 overflow-hidden">
<span className="min-w-0 truncate !text-label-xs font-semibold">
{currentLabel}
</span>
</span>
</div>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
title={currentLabel}
aria-label={currentLabel}
aria-haspopup="menu"
className={cn(
triggerShellClass,
'min-w-0 cursor-pointer border-0 text-left',
'justify-between font-semibold transition-colors',
'hover:bg-ds-bg-neutral-subtle-default active:bg-ds-bg-neutral-subtle-default data-[state=open]:bg-ds-bg-neutral-subtle-default',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-border-neutral-strong-default focus-visible:ring-offset-2 focus-visible:ring-offset-ds-bg-neutral-default-default',
'disabled:pointer-events-none disabled:opacity-50',
className
)}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<span className="min-w-0 flex-1 truncate text-left !text-label-xs text-ds-text-neutral-default-default">
{currentLabel}
</span>
</span>
<ChevronDown
className="h-3.5 w-3.5 shrink-0 opacity-80"
aria-hidden
strokeWidth={2}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="top"
sideOffset={4}
collisionPadding={12}
avoidCollisions
className={MENU_CONTENT_WIDTH_CLASS}
>
{EFFORT_OPTIONS.map((effort) => (
<DropdownMenuItem
key={effort}
onSelect={() => onValueChange?.(effort)}
className="flex items-center justify-between"
>
<span className="text-body-sm">{effortLabel(effort)}</span>
{value === effort && (
<Check className="h-4 w-4 text-ds-text-success-default-default" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -35,7 +35,7 @@ export function UsageLimitBanner({
return (
<div
className={cn(
'mb-2 flex min-h-12 w-full items-center justify-between gap-3 rounded-xl border px-4 py-2 shadow-sm',
'flex min-h-12 w-full items-center justify-between gap-3 rounded-xl border px-4 py-2 shadow-sm',
isDanger
? 'border-text-error/30 bg-surface-error-subtle text-text-error'
: 'border-border-warning bg-surface-warning text-text-warning'

View file

@ -13,11 +13,14 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { Button } from '@/components/ui/button';
import { type ChatTaskStatusType } from '@/types/constants';
import { type SessionModeType } from '@/types/constants';
import { TriangleAlert } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { BoxFooter } from './BoxFooter';
import { BoxHeaderConfirm, BoxHeaderSave } from './BoxHeader';
import { FileAttachment, Inputbox, InputboxProps } from './InputBox';
import { ConnectorPickerPanel, SkillPickerPanel } from './PickerPanel';
import { QueuedBox, QueuedMessage } from './QueuedBox';
import {
UsageLimitBanner,
@ -31,10 +34,18 @@ export type BottomBoxState =
| 'running'
| 'finished';
/** Main-slot content, orthogonal to `state`. Future variants (e.g. 'multiSelect') plug in here. */
export type BottomBoxVariant = 'input';
type PickerPanelKind = 'connector' | 'skill';
interface BottomBoxProps {
// General state
state: BottomBoxState;
/** Main-slot variant; defaults to the input composer. */
variant?: BottomBoxVariant;
// Queue-related props
queuedMessages?: QueuedMessage[];
onRemoveQueuedMessage?: (id: string) => void;
@ -48,18 +59,18 @@ interface BottomBoxProps {
onSavePlan?: () => void;
onEdit?: () => void;
// Task info
taskTime?: string;
taskStatus?: ChatTaskStatusType;
// Pause/Resume
onPauseResume?: () => void;
pauseResumeLoading?: boolean;
// Input props
inputProps: Omit<InputboxProps, 'className'> & { className?: string };
usageLimitBanner?: UsageLimitBannerProps | null;
// BoxFooter (project-setup controls: mode + model); omit sessionMode to hide the row.
sessionMode?: SessionModeType;
onSessionModeChange?: (mode: SessionModeType) => void;
/** Interactive during project setup (workspace); once the project starts the row is read-only. */
sessionModeSelectInteractive?: boolean;
/** Project whose pinned model the footer model selector reads and writes. */
modelSelectProjectId?: string | null;
// Loading states
loading?: boolean;
@ -70,6 +81,7 @@ interface BottomBoxProps {
export default function BottomBox({
state,
variant = 'input',
queuedMessages = [],
onRemoveQueuedMessage,
subtitle,
@ -79,6 +91,10 @@ export default function BottomBox({
onEdit,
inputProps,
usageLimitBanner,
sessionMode,
onSessionModeChange,
sessionModeSelectInteractive = false,
modelSelectProjectId,
loading = false,
noModelOverlay = false,
onSelectModel,
@ -86,27 +102,116 @@ export default function BottomBox({
const { t } = useTranslation();
const enableQueuedBox = true; //TODO: Fix the reason of queued box disable in https://github.com/eigent-ai/eigent/issues/684
// Picker panels (connector/skill) float above BoxMain and are mutually exclusive.
const [openPanel, setOpenPanel] = useState<PickerPanelKind | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const togglePanel = (panel: PickerPanelKind) =>
setOpenPanel((prev) => (prev === panel ? null : panel));
// Close the floating panel on outside click (ignore the trigger buttons,
// which own their own toggle).
useEffect(() => {
if (!openPanel) return;
const onPointerDown = (e: PointerEvent) => {
const target = e.target as HTMLElement | null;
if (
panelRef.current?.contains(target ?? null) ||
target?.closest('[data-picker-trigger]')
) {
return;
}
setOpenPanel(null);
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, [openPanel]);
const inputValue = inputProps.value ?? '';
/** Focus the input and drop the caret at the end after a programmatic edit. */
const focusInputEnd = () => {
requestAnimationFrame(() => {
const el = inputProps.textareaRef?.current;
if (!el) return;
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
});
};
/** Append a `#skill` / `@connector` token to the input, space-separated. */
const insertToken = (token: string) => {
const trimmed = inputValue.replace(/\s+$/, '');
const next = (trimmed.length ? `${trimmed} ` : '') + `${token} `;
inputProps.onChange?.(next);
focusInputEnd();
};
/** Remove a previously inserted token (and one adjacent space) from the input. */
const removeToken = (token: string) => {
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const next = inputValue
.replace(new RegExp(`\\s?${escaped}`), '')
.replace(/\s{2,}/g, ' ')
.replace(/^\s+/, '');
inputProps.onChange?.(next);
focusInputEnd();
};
const toggleToken = (token: string) =>
inputValue.includes(token) ? removeToken(token) : insertToken(token);
// Background color reflects current state only
let backgroundClass = 'bg-ds-bg-neutral-subtle-default';
let backgroundClass = 'bg-ds-bg-neutral-default-default';
if (state === 'confirm' || state === 'save')
backgroundClass = 'bg-ds-bg-completed-subtle-default';
backgroundClass = 'bg-ds-bg-completed-default-default';
const showQueuedBox = enableQueuedBox && queuedMessages.length > 0;
const hasOverlay = showQueuedBox || !!usageLimitBanner || !!openPanel;
return (
<div className="relative z-50 flex w-full flex-col rounded-t-2xl bg-ds-bg-neutral-subtle-default backdrop-blur-xl">
{/* QueuedBox overlay (should not affect BoxMain layout) */}
{enableQueuedBox && queuedMessages.length > 0 && (
<div className="pointer-events-auto z-50 px-2">
<QueuedBox
queuedMessages={queuedMessages}
onRemoveQueuedMessage={onRemoveQueuedMessage}
/>
<div className="relative z-50 flex w-full flex-col rounded-3xl bg-ds-bg-neutral-default-default">
{/* Floating overlays: never affect BoxMain layout */}
{hasOverlay && (
<div className="pointer-events-auto absolute inset-x-0 bottom-full z-[60] mb-1 flex flex-col gap-1">
{showQueuedBox && (
<QueuedBox
queuedMessages={queuedMessages}
onRemoveQueuedMessage={onRemoveQueuedMessage}
/>
)}
{usageLimitBanner && <UsageLimitBanner {...usageLimitBanner} />}
{openPanel && (
<div
ref={panelRef}
className="duration-150 animate-in fade-in-0 slide-in-from-bottom-1"
>
{openPanel === 'connector' ? (
<ConnectorPickerPanel
inputValue={inputValue}
onToggleItem={(item) => toggleToken(item.token)}
/>
) : (
<SkillPickerPanel
inputValue={inputValue}
onToggleItem={(item) => toggleToken(item.token)}
/>
)}
</div>
)}
{/* future: human-in-the-loop approval cards mount here */}
</div>
)}
{/* BoxMain */}
<div
className={`relative mb-sm flex w-full flex-col rounded-3xl ${backgroundClass}`}
className={`relative flex w-full flex-col rounded-3xl ${backgroundClass}`}
>
{/* BoxHeader variants */}
{/* BoxHeader variants — project confirmation */}
{state === 'confirm' && (
<BoxHeaderConfirm
subtitle={subtitle}
@ -125,9 +230,27 @@ export default function BottomBox({
/>
)}
{/* Inputbox (always visible) */}
{usageLimitBanner && <UsageLimitBanner {...usageLimitBanner} />}
<Inputbox {...inputProps} />
{/* Main box — variant slot */}
{variant === 'input' && (
<Inputbox
{...inputProps}
connectorPanelOpen={openPanel === 'connector'}
onToggleConnectorPanel={() => togglePanel('connector')}
skillPanelOpen={openPanel === 'skill'}
onToggleSkillPanel={() => togglePanel('skill')}
/>
)}
{/* Box footer — project-setup controls (mode + model); read-only once started */}
{sessionMode !== undefined && (
<BoxFooter
sessionMode={sessionMode}
onSessionModeChange={onSessionModeChange}
projectId={modelSelectProjectId}
interactive={sessionModeSelectInteractive}
disabled={inputProps.disabled}
/>
)}
{noModelOverlay && onSelectModel ? (
<div

View file

@ -80,6 +80,7 @@ export const MarkDown = memo(
const host = useHost();
const electronAPI = host?.electronAPI;
const openFilePreview = usePageTabStore((s) => s.openFilePreview);
const openBrowserPreview = usePageTabStore((s) => s.openBrowserPreview);
const [displayedContent, setDisplayedContent] = useState('');
const [html, setHtml] = useState('');
const [previewImage, setPreviewImage] = useState<string | null>(null);
@ -298,6 +299,22 @@ export const MarkDown = memo(
if (filePath) {
openFilePreview(fileInfoFromPath(filePath));
}
return;
}
// Web links stay inside the session: open them in the preview
// browser of this project. (On the web host, where no embedded
// browser exists, fall back to a regular browser tab.)
const link = target.closest('a[href]');
if (link) {
const href = link.getAttribute('href') ?? '';
if (/^https?:\/\//i.test(href)) {
e.preventDefault();
if (electronAPI) {
openBrowserPreview(href);
} else {
window.open(href, '_blank', 'noopener,noreferrer');
}
}
}
};
@ -307,7 +324,7 @@ export const MarkDown = memo(
return () => {
div.removeEventListener('click', handleContentClick);
};
}, [html, openFilePreview]);
}, [html, openFilePreview, openBrowserPreview, electronAPI]);
return (
<>

View file

@ -22,6 +22,7 @@ import {
ChatTaskStatus,
type ChatTaskStatusType,
SessionMode,
TaskStatus,
} from '@/types/constants';
import { AnimatePresence, motion } from 'framer-motion';
import { Bot, ChevronDown, ChevronRight } from 'lucide-react';
@ -85,6 +86,32 @@ function mergeTaggedAgentLogs(taskAssigning: Agent[] | undefined): TaggedLog[] {
);
}
/**
* The single agent drives its work through a todo list (TODO_STATE). The
* in-progress todo's `active_form` (e.g. "Searching Google for relevant
* papers") is plumbed into that task's `content` in the store. Surface it as
* the live header label so the user sees what the agent is doing *right now*
* instead of a static "CAMEL Agent" tag.
*
* Falls back to the most recently completed step so the label never flashes
* empty between todos or after the run finishes.
*/
export function getSingleAgentActiveForm(
task: { taskAssigning?: Agent[] } | undefined
): string {
const single = task?.taskAssigning?.find((a) => a.type === 'single_agent');
const tasks = single?.tasks ?? [];
const running = tasks.find((tk) => tk.status === TaskStatus.RUNNING);
if (running?.content?.trim()) return running.content.trim();
for (let i = tasks.length - 1; i >= 0; i--) {
const tk = tasks[i]!;
if (tk.status === TaskStatus.COMPLETED && tk.content?.trim()) {
return tk.content.trim();
}
}
return '';
}
function titleCaseMethod(method: string): string {
if (!method) return '';
return method.charAt(0).toUpperCase() + method.slice(1);
@ -611,7 +638,11 @@ function useTaskWorkStoreSnapshot(
return `${log.length}:${last?.step ?? ''}:${msgLen}:${last?.data?.toolkit_name ?? ''}:${last?.data?.method_name ?? ''}`;
})
.join('>');
return `${t.status}|${t.taskTime}|${t.elapsed}|${logDigest}`;
// Single-agent header tracks the live in-progress todo `active_form`
// (carried on each task's `content`). Fold the running/last-completed
// step into the digest so the header re-renders as todos advance.
const activeFormDigest = getSingleAgentActiveForm(t);
return `${t.status}|${t.taskTime}|${t.elapsed}|${logDigest}|${activeFormDigest}`;
},
() => ''
);
@ -959,12 +990,14 @@ const AgentGroupRow = memo(function AgentGroupRow({
open,
onToggle,
isSingleAgent,
singleAgentActiveForm,
}: {
group: AgentGroup;
taskRunning: boolean;
open: boolean;
onToggle: () => void;
isSingleAgent: boolean;
singleAgentActiveForm: string;
}) {
const { agentLabel, progressLabel, latestToolTitle, latestToolRunning } =
getGroupHeaderParts(group);
@ -974,6 +1007,16 @@ const AgentGroupRow = memo(function AgentGroupRow({
const icon = isSingleAgent
? DEFAULT_BOT_ICON
: (agentDisplay?.icon ?? DEFAULT_BOT_ICON);
const useSingleAgentLiveHeader =
isSingleAgent && group.agentType === 'single_agent';
// Single agent: surface the live in-progress `active_form` in place of the
// static "CAMEL Agent" label. Fall back to the static label only when no
// step description is available (never expected while running).
const singleAgentLabel =
useSingleAgentLiveHeader && singleAgentActiveForm
? singleAgentActiveForm
: agentLabel;
const headerParts: string[] = [agentLabel];
if (progressLabel) headerParts.push(`(${progressLabel})`);
@ -986,54 +1029,103 @@ const AgentGroupRow = memo(function AgentGroupRow({
type="button"
aria-expanded={open}
onClick={onToggle}
className="my-1 flex w-fit min-w-0 max-w-full items-center gap-2 px-0 py-1 text-left transition-opacity hover:opacity-80"
className={cn(
'my-1 flex w-fit min-w-0 max-w-full gap-2 px-0 py-1 text-left transition-opacity hover:opacity-80',
useSingleAgentLiveHeader ? 'items-start' : 'items-center'
)}
>
{icon ? (
<span className="flex shrink-0 items-center">{icon}</span>
// my-0.5 centers the 16px icon within the 20px label-sm line so a
// single-line header reads as icon/text center-aligned, while a
// wrapped header keeps the icon pinned to the first line (items-start).
<span
className={cn(
'flex shrink-0 items-center',
useSingleAgentLiveHeader ? 'my-0.5' : ''
)}
>
{icon}
</span>
) : null}
<span className="inline-flex min-w-0 max-w-full items-baseline gap-1.5 truncate">
{headerRunning ? (
<ShinyText
text={headerText}
speed={2.5}
className="truncate !text-label-sm font-normal"
/>
) : (
<>
<span className="text-label-sm font-normal text-ds-text-neutral-muted-default">
{agentLabel}
</span>
{progressLabel ? (
<span className="text-label-sm text-ds-text-neutral-subtle-default">
({progressLabel})
{useSingleAgentLiveHeader ? (
<span className="min-w-0 block max-w-full">
{/* Cross-fade/slide whenever the in-progress step changes so the
header animates from one `active_form` to the next. Wraps onto
multiple lines instead of truncating. */}
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={singleAgentLabel}
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.24, ease: CONTENT_EASE }}
className="min-w-0 block break-words whitespace-normal"
>
{headerRunning ? (
<ShinyText
text={singleAgentLabel}
speed={2.5}
className="!text-label-sm font-normal !block break-words whitespace-normal"
/>
) : (
<span className="text-label-sm font-normal text-ds-text-neutral-muted-default block break-words whitespace-normal">
{singleAgentLabel}
</span>
)}
</motion.span>
</AnimatePresence>
</span>
) : (
<span className="inline-flex min-w-0 max-w-full items-baseline gap-1.5 truncate">
{headerRunning ? (
<ShinyText
text={headerText}
speed={2.5}
className="truncate !text-label-sm font-normal"
/>
) : (
<>
<span className="text-label-sm font-normal text-ds-text-neutral-muted-default">
{agentLabel}
</span>
) : null}
{latestToolTitle ? (
<>
{progressLabel ? (
<span className="text-label-sm text-ds-text-neutral-subtle-default">
·
({progressLabel})
</span>
<span className="truncate text-label-sm font-normal text-ds-text-neutral-subtle-default">
{latestToolTitle}
</span>
</>
) : null}
</>
)}
</span>
) : null}
{latestToolTitle ? (
<>
<span className="text-label-sm text-ds-text-neutral-subtle-default">
·
</span>
<span className="truncate text-label-sm font-normal text-ds-text-neutral-subtle-default">
{latestToolTitle}
</span>
</>
) : null}
</>
)}
</span>
)}
{open ? (
<ChevronDown
size={16}
aria-hidden
className="shrink-0 text-ds-icon-neutral-subtle-default"
className={cn(
'shrink-0 text-ds-icon-neutral-subtle-default',
useSingleAgentLiveHeader ? 'my-0.5' : ''
)}
/>
) : (
<ChevronRight
size={16}
aria-hidden
className="shrink-0 text-ds-icon-neutral-subtle-default"
className={cn(
'shrink-0 text-ds-icon-neutral-subtle-default',
useSingleAgentLiveHeader ? 'my-0.5' : ''
)}
/>
)}
</button>
@ -1181,6 +1273,9 @@ export function TaskWorkLogAccordion({
const elapsedMs = useWorkLogElapsedMs(chatStore, taskId, snapshot);
const taskRunning = status === ChatTaskStatus.RUNNING;
const isSingleAgent = task?.sessionMode === SessionMode.SINGLE_AGENT;
const singleAgentActiveForm = isSingleAgent
? getSingleAgentActiveForm(task)
: '';
// Normalize status with task-level context — once the task stops,
// every entry (and any running message/tool) is done regardless of whether
@ -1302,6 +1397,7 @@ export function TaskWorkLogAccordion({
open={isOpen(entry)}
onToggle={() => toggle(entry.id)}
isSingleAgent={isSingleAgent}
singleAgentActiveForm={singleAgentActiveForm}
/>
) : (
<AgentBlockRow

View file

@ -14,6 +14,7 @@
import { useHost } from '@/host';
import {
RICH_CONNECTOR_STYLE_CLASSES,
RICH_SKILL_STYLE_CLASSES,
hashSkillLabel,
httpUrlOrNull,
@ -21,6 +22,7 @@ import {
tokenizeRichPlainText,
} from '@/lib/richText';
import { cn } from '@/lib/utils';
import { usePageTabStore } from '@/store/pageTabStore';
import { Fragment, type ReactNode } from 'react';
/** Same tokens as `UserMessageCard` body (13px / 20px). */
@ -58,7 +60,13 @@ function parseContentWithTags(content: string): ContentNode[] {
return nodes.length > 0 ? nodes : [{ type: 'text', value: content }];
}
function renderMessageRichSegments(text: string, keyPrefix: string): ReactNode {
function renderMessageRichSegments(
text: string,
keyPrefix: string,
/** When set, URL clicks open here (the session's preview browser) instead
* of following the anchor out of the app. */
onOpenUrl?: (url: string) => void
): ReactNode {
return tokenizeRichPlainText(text).map((seg, i) => {
const key = `${keyPrefix}-${i}`;
if (seg.type === 'text') {
@ -73,8 +81,14 @@ function renderMessageRichSegments(text: string, keyPrefix: string): ReactNode {
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-ds-text-information-default-default decoration-ds-border-information-default-default underline underline-offset-2"
onClick={(e) => e.stopPropagation()}
className="text-ds-text-information-default-default underline decoration-ds-border-information-default-default underline-offset-2"
onClick={(e) => {
e.stopPropagation();
if (onOpenUrl) {
e.preventDefault();
onOpenUrl(href);
}
}}
>
{seg.text}
</a>
@ -82,12 +96,25 @@ function renderMessageRichSegments(text: string, keyPrefix: string): ReactNode {
}
return <span key={key}>{seg.text}</span>;
}
if (seg.type === 'connector') {
return (
<span
key={key}
className={cn(
'inline rounded px-0.5 align-baseline font-normal',
RICH_CONNECTOR_STYLE_CLASSES
)}
>
{seg.text}
</span>
);
}
const clsIdx = hashSkillLabel(seg.text) % RICH_SKILL_STYLE_CLASSES.length;
return (
<span
key={key}
className={cn(
'rounded px-0.5 font-normal inline align-baseline',
'inline rounded px-0.5 align-baseline font-normal',
RICH_SKILL_STYLE_CLASSES[clsIdx]
)}
>
@ -115,6 +142,7 @@ export function UserMessageRichContent({
className,
}: UserMessageRichContentProps) {
const host = useHost();
const openBrowserPreview = usePageTabStore((s) => s.openBrowserPreview);
const contentNodes = parseContentWithTags(content);
const handleOpenSkillFolder = (skillName: string) => {
@ -122,6 +150,10 @@ export function UserMessageRichContent({
host?.electronAPI?.openSkillFolder?.(skillName);
};
// Desktop: links open in this project's preview browser; on the web host
// (no embedded browser) the anchor's target=_blank fallback applies.
const handleOpenUrl = host?.electronAPI ? openBrowserPreview : undefined;
const bodyClass =
variant === 'card'
? 'text-ds-text-neutral-default-default font-sans relative z-0 break-words whitespace-pre-wrap'
@ -134,7 +166,7 @@ export function UserMessageRichContent({
if (node.type === 'text') {
return (
<Fragment key={i}>
{renderMessageRichSegments(node.value, `n${i}`)}
{renderMessageRichSegments(node.value, `n${i}`, handleOpenUrl)}
</Fragment>
);
}
@ -151,7 +183,7 @@ export function UserMessageRichContent({
}}
title="Open skill folder"
className={cn(
'mx-0 rounded px-0.5 font-normal inline cursor-pointer align-baseline [font:inherit] hover:opacity-90',
'mx-0 inline cursor-pointer rounded-lg px-1 align-baseline font-normal [font:inherit] hover:opacity-90',
RICH_SKILL_STYLE_CLASSES[clsIdx]
)}
>

View file

@ -156,6 +156,19 @@ export const ProjectChatContainer: React.FC<ProjectChatContainerProps> = ({
}, 0);
}, [chatStore?.activeTaskId]);
// When switching projects, jump to the latest message (bottom) instead of
// staying at the top. Deferred so the switched-to project's messages have
// rendered; instant (not smooth) so we don't scroll through the whole
// history on every switch.
useEffect(() => {
if (!activeProjectId) return;
const timer = setTimeout(() => {
const el = scrollContainerRef.current;
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'auto' });
}, 100);
return () => clearTimeout(timer);
}, [activeProjectId, scrollContainerRef]);
// Intersection Observer for scroll-based animations
useEffect(() => {
const root = scrollContainerRef.current;
@ -341,7 +354,7 @@ export const ProjectChatContainer: React.FC<ProjectChatContainerProps> = ({
return (
<div className={`relative z-10 w-full ${className}`}>
<div
className="pt-0 mx-auto w-full max-w-[600px]"
className="mx-auto w-full max-w-[600px] pt-0"
style={{ paddingBottom: scrollBottomInsetPx }}
>
<AnimatePresence mode="popLayout">

View file

@ -41,8 +41,7 @@ ChatBox/
├── index.tsx
├── InputBox.tsx
├── RichChatInput.tsx
├── ChatInputModelDropdown.tsx
├── BoxAction.tsx
├── ModelSelect.tsx
├── BoxHeader.tsx
└── QueuedBox.tsx
```
@ -91,9 +90,8 @@ ChatBox/
## BottomBox (`BottomBox/`)
- **`index.tsx`**: Wires `BoxHeader`, `InputBox` / `RichChatInput`, `BoxAction`, `QueuedBox` to task state (pending, running, confirm, etc.).
- **`InputBox` / `RichChatInput` / `ChatInputModelDropdown`**: Text input, model picker, rich input where applicable.
- **`BoxAction`**: Confirm, edit, send, stop, and related actions.
- **`index.tsx`**: Wires `BoxHeader`, `InputBox` / `RichChatInput`, `QueuedBox` to task state (pending, running, confirm, etc.).
- **`InputBox` / `RichChatInput` / `ModelSelect`**: Text input, model picker, rich input where applicable.
- **`BoxHeader`**: Task summary, timing, and header affordances.
- **`QueuedBox`**: Queued user messages when the task pipeline is busy.

View file

@ -86,7 +86,7 @@ export function ExpandedOverlay({
}}
>
<div className="flex w-full max-w-[600px] flex-col">
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border border-solid border-ds-border-neutral-subtle-disabled bg-ds-bg-splitting-subtle-default">
<div className="mx-2 flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border border-solid border-ds-border-neutral-subtle-disabled bg-ds-bg-splitting-subtle-default">
<div className="flex shrink-0 items-center gap-2 px-3 pt-2">
<div className="min-w-0 flex-1 text-body-sm font-bold text-ds-text-neutral-default-default">
{t('chat.subtasks-planning')}

View file

@ -15,7 +15,6 @@
import {
fetchDelete,
fetchPost,
fetchPut,
proxyFetchDelete,
proxyFetchGet,
uploadFileToBrain,
@ -36,7 +35,11 @@ import { buildProjectContinuationContext } from '@/store/chatStore';
import { usePageTabStore } from '@/store/pageTabStore';
import { useSpaceStore } from '@/store/spaceStore';
import { ExecutionStatus } from '@/types';
import { AgentStep, ChatTaskStatus, SessionMode } from '@/types/constants';
import {
AgentStep,
ChatTaskStatus,
SessionMode,
} from '@/types/constants';
import {
useCallback,
useEffect,
@ -378,7 +381,7 @@ export default function ChatBox(): JSX.Element {
}, [navigate]);
// Task time tracking
const [taskTime, setTaskTime] = useState(
const [, setTaskTime] = useState(
chatStore?.getFormattedTaskTime(chatStore?.activeTaskId as string) ||
'00:00'
);
@ -1030,31 +1033,6 @@ export default function ChatBox(): JSX.Element {
}
};
// Pause/Resume handler
const handlePauseResume = () => {
const taskId = chatStore.activeTaskId as string;
const task = chatStore.tasks[taskId];
const type = task.status === 'running' ? 'pause' : 'resume';
setIsPauseResumeLoading(true);
if (type === 'pause') {
let { taskTime, elapsed } = task;
const now = Date.now();
elapsed += now - taskTime;
chatStore.setElapsed(taskId, elapsed);
chatStore.setTaskTime(taskId, 0);
chatStore.setStatus(taskId, 'pause');
} else {
chatStore.setTaskTime(taskId, Date.now());
chatStore.setStatus(taskId, 'running');
}
fetchPut(`/task/${projectStore.activeProjectId}/take-control`, {
action: type,
});
setIsPauseResumeLoading(false);
};
// Stop task handler - triggers Action.skip_task which preserves context
const handleSkip = async () => {
const taskId = chatStore.activeTaskId as string;
@ -1248,10 +1226,10 @@ export default function ChatBox(): JSX.Element {
const chatColumn = (
<>
{/* Main: scroll (scrollbar on panel edge) + BottomBox overlay when chatting */}
<div className="min-h-0 min-w-0 relative flex flex-1 flex-col overflow-hidden">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div
ref={scrollContainerRef}
className="scrollbar-always-visible min-h-0 min-w-0 pl-2 flex-1 overflow-x-hidden overflow-y-auto"
className="scrollbar-always-visible min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pl-2"
>
{hasAnyMessages ? (
<ProjectChatContainer
@ -1262,7 +1240,7 @@ export default function ChatBox(): JSX.Element {
/>
) : (
<div className="mx-auto flex min-h-full w-full max-w-[600px] flex-col">
<div className="gap-1 pb-4 flex flex-1 flex-col items-center justify-end"></div>
<div className="flex flex-1 flex-col items-center justify-end gap-1 pb-4"></div>
{chatStore.activeTaskId && (
<BottomBox
@ -1293,9 +1271,10 @@ export default function ChatBox(): JSX.Element {
textareaRef: textareaRef,
allowDragDrop: true,
useCloudModelInDev: useCloudModelInDev,
sessionMode: effectiveSessionMode,
sessionModeSelectInteractive: false,
}}
sessionMode={effectiveSessionMode}
sessionModeSelectInteractive={false}
modelSelectProjectId={activeProjectId}
/>
)}
</div>
@ -1309,9 +1288,9 @@ export default function ChatBox(): JSX.Element {
<div
ref={bottomBoxOverlayRef}
data-bottom-box-overlay
className="inset-x-0 bottom-0 pointer-events-none absolute z-30 flex justify-center"
className="pointer-events-none absolute inset-x-0 bottom-0 z-30 flex justify-center"
>
<div className="px-2 pointer-events-auto mx-auto w-full max-w-[600px]">
<div className="pointer-events-auto mx-auto w-full max-w-[600px] rounded-t-3xl bg-ds-bg-neutral-subtle-default px-2 pb-1">
<BottomBox
state={getBottomBoxState()}
queuedMessages={queuedMessages}
@ -1349,10 +1328,6 @@ export default function ChatBox(): JSX.Element {
}
}}
onEdit={handleEditQuery}
taskTime={taskTime}
taskStatus={chatStore.tasks[chatStore.activeTaskId]?.status}
onPauseResume={handlePauseResume}
pauseResumeLoading={isPauseResumeLoading}
loading={loading}
inputProps={{
value: message,
@ -1376,9 +1351,10 @@ export default function ChatBox(): JSX.Element {
textareaRef: textareaRef,
allowDragDrop: true,
useCloudModelInDev: useCloudModelInDev,
sessionMode: displaySessionMode,
sessionModeSelectInteractive: false,
}}
sessionMode={displaySessionMode}
sessionModeSelectInteractive={false}
modelSelectProjectId={activeProjectId}
/>
</div>
</div>
@ -1388,7 +1364,7 @@ export default function ChatBox(): JSX.Element {
);
return (
<div className="min-h-0 relative flex h-full w-full flex-1 flex-col overflow-hidden">
<div className="relative flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
{chatColumn}
</div>
);

View file

@ -21,7 +21,9 @@ import {
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { useHost } from '@/host';
import { ExternalLink, Loader2 } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
import { Download, ExternalLink, Loader2 } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@ -40,18 +42,83 @@ interface ReportBugDialogProps {
onOpenChange: (open: boolean) => void;
}
type LogKind = 'eigent' | 'camel';
export default function ReportBugDialog({
open,
onOpenChange,
}: ReportBugDialogProps) {
const host = useHost();
const { t } = useTranslation();
const email = useAuthStore((s) => s.email);
const userId = useAuthStore((s) => s.user_id);
const projectStore = useProjectRuntimeStore();
const [description, setDescription] = useState('');
const [steps, setSteps] = useState('');
const [meta, setMeta] = useState<DiagnosticsInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
const [exportingLog, setExportingLog] = useState<LogKind | null>(null);
const hasElectron = Boolean(host?.electronAPI?.exportDiagnosticsZip);
const canDownloadLogs = Boolean(host?.electronAPI?.exportLog);
const handleDownloadLog = useCallback(
async (kind: LogKind) => {
const api = host?.electronAPI;
if (!api || exportingLog) return;
setExportingLog(kind);
try {
let result;
if (kind === 'eigent') {
result = await api.exportLog();
} else {
// Target the task the user last ran so we grab the right camel_logs.
const activeProjectId = projectStore.activeProjectId ?? undefined;
const activeTaskId =
(activeProjectId
? projectStore.peekActiveChatStore(activeProjectId)
: null
)?.getState().activeTaskId ?? undefined;
result = await api.exportCamelLog(
email,
activeTaskId,
activeProjectId,
userId
);
}
if (result?.success && result.savedPath) {
toast.success(
t('layout.support-log-saved', {
defaultValue: 'Log saved to {{path}}',
path: result.savedPath,
})
);
} else if (result?.error === 'no log file') {
toast.error(
t('layout.support-log-empty', {
defaultValue: 'No log files found yet.',
})
);
} else if (result?.error) {
toast.error(
t('layout.support-log-export-failed', {
defaultValue: 'Could not export the log.',
})
);
}
// empty error means the user canceled the save dialog — stay silent
} catch {
toast.error(
t('layout.support-log-export-failed', {
defaultValue: 'Could not export the log.',
})
);
} finally {
setExportingLog(null);
}
},
[email, userId, exportingLog, host?.electronAPI, projectStore, t]
);
useEffect(() => {
if (!open) return;
@ -145,16 +212,16 @@ export default function ReportBugDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
size="md"
className="gap-0 !rounded-xl border-ds-border-neutral-strong-default !bg-ds-bg-neutral-strong-default p-0 shadow-sm sm:max-w-[560px] border"
className="gap-0 !rounded-xl border border-ds-border-neutral-strong-default !bg-ds-bg-neutral-strong-default p-0 shadow-sm sm:max-w-[560px]"
>
<div className="bg-ds-bg-neutral-strong-default rounded-t-xl pl-md pr-12 pt-md pb-2 w-full text-left">
<DialogTitle className="m-0 text-body-md font-bold text-ds-text-neutral-default-default block w-full text-left">
{t('layout.report-bug-dialog-title')}
<div className="w-full rounded-t-xl bg-ds-bg-neutral-strong-default pb-2 pl-md pr-12 pt-md text-left">
<DialogTitle className="m-0 block w-full text-left text-body-md font-bold text-ds-text-neutral-default-default">
{t('layout.support', { defaultValue: 'Support' })}
</DialogTitle>
</div>
<div className="gap-md bg-ds-bg-neutral-strong-default px-md pt-2 pb-md flex max-h-[min(70vh,520px)] flex-col text-left">
<div className="flex max-h-[min(70vh,520px)] flex-col gap-md bg-ds-bg-neutral-strong-default px-md pb-md pt-2 text-left">
{meta && (
<p className="text-body-sm text-ds-text-neutral-subtle-default m-0">
<p className="m-0 text-body-sm text-ds-text-neutral-subtle-default">
{t('layout.report-bug-meta', {
version: meta.version,
os: meta.platform,
@ -162,6 +229,11 @@ export default function ReportBugDialog({
})}
</p>
)}
<h3 className="m-0 text-body-sm font-bold text-ds-text-neutral-default-default">
{t('layout.report-bug-dialog-title', {
defaultValue: 'Report a bug',
})}
</h3>
<label
className="text-body-sm font-medium text-ds-text-neutral-default-default"
htmlFor="report-bug-description"
@ -188,17 +260,96 @@ export default function ReportBugDialog({
placeholder={t('layout.report-bug-field-steps-placeholder')}
className="min-h-[72px] resize-y"
/>
{canDownloadLogs && (
<div className="mt-1 flex flex-col gap-sm border-0 border-t border-solid border-ds-border-neutral-subtle-default pt-md">
<h3 className="m-0 text-body-sm font-bold text-ds-text-neutral-default-default">
{t('layout.support-logs-heading', {
defaultValue: 'Download logs',
})}
</h3>
<div className="flex items-center justify-between gap-md">
<div className="flex min-w-0 flex-col">
<span className="text-body-sm font-medium text-ds-text-neutral-default-default">
{t('layout.support-eigent-log', {
defaultValue: 'Eigent log',
})}
</span>
<span className="text-body-xs text-ds-text-neutral-subtle-default">
{t('layout.support-eigent-log-desc', {
defaultValue: 'Desktop app logs',
})}
</span>
</div>
<Button
variant="secondary"
size="sm"
className="shrink-0"
onClick={() => void handleDownloadLog('eigent')}
disabled={exportingLog !== null}
aria-label={t('layout.support-eigent-log', {
defaultValue: 'Eigent log',
})}
>
{exportingLog === 'eigent' ? (
<Loader2
className="h-4 w-4 shrink-0 animate-spin"
aria-hidden
/>
) : (
<Download className="h-4 w-4 shrink-0" aria-hidden />
)}
{t('layout.support-download', { defaultValue: 'Download' })}
</Button>
</div>
{Boolean(host?.electronAPI?.exportCamelLog) && (
<div className="flex items-center justify-between gap-md">
<div className="flex min-w-0 flex-col">
<span className="text-body-sm font-medium text-ds-text-neutral-default-default">
{t('layout.support-camel-log', {
defaultValue: 'Camel log',
})}
</span>
<span className="text-body-xs text-ds-text-neutral-subtle-default">
{t('layout.support-camel-log-desc', {
defaultValue: 'Agent task logs (CAMEL backend)',
})}
</span>
</div>
<Button
variant="secondary"
size="sm"
className="shrink-0"
onClick={() => void handleDownloadLog('camel')}
disabled={exportingLog !== null || !email}
aria-label={t('layout.support-camel-log', {
defaultValue: 'Camel log',
})}
>
{exportingLog === 'camel' ? (
<Loader2
className="h-4 w-4 shrink-0 animate-spin"
aria-hidden
/>
) : (
<Download className="h-4 w-4 shrink-0" aria-hidden />
)}
{t('layout.support-download', { defaultValue: 'Download' })}
</Button>
</div>
)}
</div>
)}
</div>
<DialogFooter className="!rounded-b-xl p-md gap-sm sm:!flex-col flex !flex-col !border-0 !border-t-0 bg-transparent shadow-none">
<DialogFooter className="flex !flex-col gap-sm !rounded-b-xl !border-0 !border-t-0 bg-transparent p-md shadow-none sm:!flex-col">
{hasElectron && (
<p className="text-body-xs text-ds-text-neutral-subtle-default m-0 w-full text-right">
<p className="m-0 w-full text-right text-body-xs text-ds-text-neutral-subtle-default">
{t('layout.report-bug-zip-hint', {
defaultValue:
'A diagnostics zip will be saved — attach it to the issue.',
})}
</p>
)}
<div className="gap-sm flex w-full flex-row justify-end">
<div className="flex w-full flex-row justify-end gap-sm">
<Button
variant="ghost"
size="md"
@ -215,7 +366,7 @@ export default function ReportBugDialog({
>
{submitting ? (
<Loader2
className="h-4 w-4 animate-spin shrink-0"
className="h-4 w-4 shrink-0 animate-spin"
aria-hidden
/>
) : (

View file

@ -32,6 +32,8 @@ export interface FilePreviewProps {
file: FileInfo | null;
/** Outer surface background class (project page uses default-default). */
surfaceClassName?: string;
/** Remove the standalone rounded/margin frame when hosted in a tab shell. */
embedded?: boolean;
/** Sibling project files, used by the HTML renderer to resolve local assets. */
projectFiles?: FileInfo[];
/** Close the preview column. */
@ -51,6 +53,7 @@ export interface FilePreviewProps {
export function FilePreview({
file,
surfaceClassName = 'bg-ds-bg-neutral-default-default',
embedded = false,
projectFiles = [],
onClose,
onJumpToContext,
@ -248,11 +251,12 @@ export function FilePreview({
}
projectFiles={projectFiles}
surfaceClassName={surfaceClassName}
embedded={embedded}
onRevealFile={handleRevealFile}
onDownloadFile={handleDownloadFile}
onToggleSourceCode={handleToggleSourceCode}
emptyState={
<div className="gap-3 px-6 text-ds-text-neutral-muted-default flex h-full w-full flex-1 flex-col items-center justify-center text-center">
<div className="flex h-full w-full flex-1 flex-col items-center justify-center gap-3 px-6 text-center text-ds-text-neutral-muted-default">
<FileText className="h-12 w-12 text-ds-icon-neutral-muted-default" />
<p className="text-sm">
{t('chat.no-file-selected', {

View file

@ -627,14 +627,14 @@ export const FileTree: React.FC<FileTreeProps> = ({
onSelectFile(fileInfo);
}
}}
className={`mb-1 min-w-0 gap-2 rounded-lg px-2 py-1.5 hover:bg-ds-bg-neutral-subtle-hover flex w-full flex-row items-center justify-start text-left transition-colors ${
className={`mb-1 flex w-full min-w-0 flex-row items-center justify-start gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-ds-bg-neutral-subtle-hover ${
isRowSelected
? 'bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default'
: 'text-ds-text-neutral-muted-default bg-transparent'
: 'bg-transparent text-ds-text-neutral-muted-default'
}`}
>
{child.isFolder ? (
<span className="w-4 inline-flex shrink-0 items-center justify-start">
<span className="inline-flex w-4 shrink-0 items-center justify-start">
{isExpanded ? (
<ChevronDown className={rowIconClass} />
) : (
@ -653,13 +653,13 @@ export const FileTree: React.FC<FileTreeProps> = ({
)
)}
<span className="min-w-0 text-body-sm font-medium leading-normal flex-1 truncate text-left">
<span className="min-w-0 flex-1 truncate text-left text-body-sm font-medium leading-normal">
{child.name}
</span>
</button>
{hasNested ? (
<div className="ml-4 border-ds-border-neutral-subtle-default pl-1 border-y-0 border-r-0 border-l border-solid">
<div className="ml-4 border-y-0 border-l border-r-0 border-solid border-ds-border-neutral-subtle-default pl-1">
<FileTree
node={child}
level={level + 1}
@ -1599,15 +1599,15 @@ export default function Folder({ data: _data }: { data?: Agent }) {
return (
<div className="flex h-full w-full flex-col overflow-hidden">
{/* header */}
<div className="gap-2 border-ds-border-neutral-subtle-default p-2 flex w-full shrink-0 items-center border-x-0 border-t-0 border-b-1 border-solid">
<div className="min-w-0 flex max-w-[min(20rem,45%)] items-center">
<div className="border-b-1 flex w-full shrink-0 items-center gap-2 border-x-0 border-t-0 border-solid border-ds-border-neutral-subtle-default p-2">
<div className="flex min-w-0 max-w-[min(20rem,45%)] items-center">
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
aria-pressed={isFileSidebarOpen}
className="text-ds-icon-neutral-default-default shrink-0"
className="shrink-0 text-ds-icon-neutral-default-default"
aria-label={
isFileSidebarOpen
? t('chat.hide-file-sidebar', {
@ -1635,21 +1635,21 @@ export default function Folder({ data: _data }: { data?: Agent }) {
)}
</Button>
<span
className="min-w-0 text-body-sm font-semibold text-ds-text-neutral-default-default truncate leading-none"
className="min-w-0 truncate text-body-sm font-semibold leading-none text-ds-text-neutral-default-default"
title={folderHeaderTitle}
>
{folderHeaderTitle}
</span>
</div>
<div className="min-w-0 gap-2 ml-auto flex items-center">
<div className="h-7 w-32 max-w-xs rounded-lg relative min-w-[10rem] shrink-0">
<Search className="left-2 h-3.5 w-3.5 text-ds-text-brand-default-default pointer-events-none absolute top-1/2 -translate-y-1/2" />
<div className="ml-auto flex min-w-0 items-center gap-2">
<div className="relative h-7 w-32 min-w-[10rem] max-w-xs shrink-0 rounded-lg">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ds-text-brand-default-default" />
<input
type="text"
value={fileSearchQuery}
onChange={(e) => setFileSearchQuery(e.target.value)}
placeholder={t('chat.search')}
className="h-7 rounded-lg border-ds-border-neutral-subtle-default py-0 pl-7 pr-2 text-sm focus:ring-ds-ring-brand-default-focus w-full border border-solid leading-none focus:ring-2 focus:ring-offset-0 focus:outline-none"
className="h-7 w-full rounded-lg border border-solid border-ds-border-neutral-subtle-default py-0 pl-7 pr-2 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-ds-ring-brand-default-focus focus:ring-offset-0"
aria-label={t('chat.search')}
/>
</div>
@ -1670,18 +1670,18 @@ export default function Folder({ data: _data }: { data?: Agent }) {
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="border-ds-border-neutral-default-default bg-ds-bg-neutral-strong-default z-50"
className="z-50 border-ds-border-neutral-default-default bg-ds-bg-neutral-strong-default"
>
<DropdownMenuItem
onClick={() => handleOpenInIDE('system')}
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
>
<FolderIcon className="size-4 shrink-0" aria-hidden />
{t('chat.open-in-file-manager')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleOpenInIDE('cursor')}
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
>
<img
src={cursorIcon}
@ -1693,7 +1693,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleOpenInIDE('vscode')}
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
>
<img
src={vsCodeIcon}
@ -1709,11 +1709,11 @@ export default function Folder({ data: _data }: { data?: Agent }) {
</div>
</div>
<div className="min-h-0 flex flex-1 overflow-hidden">
<div className="flex min-h-0 flex-1 overflow-hidden">
{/* sidebar */}
{isFileSidebarOpen ? (
<div className="w-64 border-ds-border-neutral-subtle-default flex h-full flex-shrink-0 flex-col border-y-0 border-r border-l-0 border-solid">
<div className="h-8 px-1 flex items-center">
<div className="flex h-full w-64 flex-shrink-0 flex-col border-y-0 border-l-0 border-r border-solid border-ds-border-neutral-subtle-default">
<div className="flex h-8 items-center px-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@ -1722,7 +1722,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
size="sm"
buttonContent="text"
>
<span className="min-w-0 font-bold truncate text-left">
<span className="min-w-0 truncate text-left font-bold">
{t('chat.files')}
</span>
<ChevronDown className="size-3.5 shrink-0 opacity-70" />
@ -1731,7 +1731,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
<DropdownMenuContent
side="bottom"
align="start"
className="border-ds-border-neutral-default-default bg-ds-bg-neutral-strong-default z-50 min-w-[10rem]"
className="z-50 min-w-[10rem] border-ds-border-neutral-default-default bg-ds-bg-neutral-strong-default"
>
<DropdownMenuRadioGroup
value={fileTreeScope}
@ -1741,7 +1741,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
>
<DropdownMenuRadioItem
value="all"
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
>
{t('folder.files-scope-all', {
defaultValue: 'All files',
@ -1749,7 +1749,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="new"
className="bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover cursor-pointer"
className="cursor-pointer bg-dropdown-item-bg-default hover:bg-dropdown-item-bg-hover"
>
{t('folder.files-scope-new', {
defaultValue: 'New files',
@ -1760,7 +1760,7 @@ export default function Folder({ data: _data }: { data?: Agent }) {
</DropdownMenu>
</div>
<div className="scrollbar-always-visible min-h-0 flex-1 overflow-y-auto">
<div className="pl-1.5 h-full">
<div className="h-full pl-1.5">
<FileTree
node={sidebarFileTree}
selectedFile={selectedFile}
@ -1883,7 +1883,7 @@ function ImageLoader({ selectedFile }: { selectedFile: FileInfo }) {
if (!src) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="h-8 w-8 animate-spin mx-auto rounded-full" />
<div className="mx-auto h-8 w-8 animate-spin rounded-full" />
</div>
);
}
@ -1912,7 +1912,7 @@ function AudioLoader({ selectedFile }: { selectedFile: FileInfo }) {
}, [selectedFile]);
return (
<div className="gap-4 px-8 flex w-full flex-col items-center">
<div className="flex w-full flex-col items-center gap-4 px-8">
<p className="text-sm font-medium text-ds-text-neutral-default-default">
{selectedFile.name}
</p>
@ -2802,7 +2802,7 @@ function HtmlRenderer({
if (selectedFile.content && !processedHtml) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mb-4 h-8 w-8 animate-spin mx-auto rounded-full" />
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full" />
</div>
);
}
@ -2819,7 +2819,7 @@ function HtmlRenderer({
{/* Content area with zoom */}
<div
className="min-h-0 bg-code-surface flex-1 overflow-hidden"
className="min-h-0 flex-1 overflow-hidden bg-code-surface"
onWheel={handleWheel}
>
<div
@ -2859,6 +2859,8 @@ export interface FileViewerPanelProps {
projectFiles: FileInfo[];
/** Outer surface background class. */
surfaceClassName?: string;
/** Remove standalone spacing/radius when rendered inside another panel shell. */
embedded?: boolean;
/** Clicking the breadcrumb (reveal in folder / download remote). Ignored when
* {@link onBreadcrumbSegmentClick} is provided (segments become individually
* clickable instead). */
@ -2889,6 +2891,7 @@ export function FileViewerPanel({
breadcrumbSegments,
projectFiles,
surfaceClassName = 'bg-ds-bg-neutral-subtle-default',
embedded = false,
onRevealFile,
onBreadcrumbSegmentClick,
onDownloadFile,
@ -2901,19 +2904,21 @@ export function FileViewerPanel({
return (
<div
className={`min-w-0 mb-sm rounded-xl flex flex-1 flex-col overflow-hidden ${surfaceClassName}`}
className={`${
embedded ? 'min-w-0' : 'mb-sm min-w-0 rounded-xl'
} flex flex-1 flex-col overflow-hidden ${surfaceClassName}`}
>
{/* head */}
{selectedFile && (
<div className="py-2 gap-2 pl-3 pr-2 flex flex-shrink-0 items-center justify-between">
<div className="flex flex-shrink-0 items-center justify-between gap-2 py-2 pl-3 pr-2">
<div
onClick={segmentsClickable ? undefined : onRevealFile}
className={`min-w-0 flex flex-1 items-center overflow-hidden ${
className={`flex min-w-0 flex-1 items-center overflow-hidden ${
segmentsClickable ? '' : 'cursor-pointer'
}`}
>
<nav
className="scrollbar-always-visible min-w-0 gap-1 text-body-sm text-ds-text-neutral-muted-default flex max-w-full items-center overflow-x-auto"
className="scrollbar-always-visible flex min-w-0 max-w-full items-center gap-1 overflow-x-auto text-body-sm text-ds-text-neutral-muted-default"
aria-label={t('folder.file-path-breadcrumb', {
defaultValue: 'File path',
})}
@ -2925,7 +2930,7 @@ export function FileViewerPanel({
<Fragment key={`${index}-${segment}`}>
{index > 0 ? (
<ChevronRight
className="h-3.5 w-3.5 text-ds-icon-neutral-muted-default shrink-0"
className="h-3.5 w-3.5 shrink-0 text-ds-icon-neutral-muted-default"
aria-hidden
/>
) : null}
@ -2933,7 +2938,7 @@ export function FileViewerPanel({
<button
type="button"
onClick={() => onBreadcrumbSegmentClick?.(index)}
className="font-normal text-ds-text-neutral-muted-default hover:text-ds-text-neutral-default-default shrink-0 cursor-pointer hover:underline"
className="shrink-0 cursor-pointer font-normal text-ds-text-neutral-muted-default hover:text-ds-text-neutral-default-default hover:underline"
>
{segment}
</button>
@ -2941,8 +2946,8 @@ export function FileViewerPanel({
<span
className={
isLast
? 'font-bold text-ds-text-neutral-default-default shrink-0'
: 'font-normal shrink-0'
? 'shrink-0 font-bold text-ds-text-neutral-default-default'
: 'shrink-0 font-normal'
}
>
{segment}
@ -2953,7 +2958,7 @@ export function FileViewerPanel({
})}
</nav>
</div>
<div className="gap-0.5 flex flex-shrink-0 items-center">
<div className="flex flex-shrink-0 items-center gap-0.5">
<Button
size="icon"
variant="ghost"
@ -2980,7 +2985,7 @@ export function FileViewerPanel({
{/* content */}
<div
className={`min-h-0 flex flex-1 flex-col ${
className={`flex min-h-0 flex-1 flex-col ${
selectedFile?.type === 'html' && !isShowSourceCode
? 'overflow-hidden'
: 'scrollbar-always-visible overflow-y-auto'
@ -2989,8 +2994,8 @@ export function FileViewerPanel({
<div
className={`flex flex-col ${
selectedFile?.type === 'html' && !isShowSourceCode
? 'min-h-0 h-full'
: 'py-2 pl-4 pr-2 min-h-full'
? 'h-full min-h-0'
: 'min-h-full py-2 pl-4 pr-2'
} file-viewer-content`}
>
{selectedFile ? (
@ -3027,9 +3032,9 @@ export function FileViewerPanel({
/>
)
) : selectedFile.type === 'zip' ? (
<div className="text-ds-text-neutral-muted-default flex h-full w-full items-center justify-center">
<div className="flex h-full w-full items-center justify-center text-ds-text-neutral-muted-default">
<div className="text-center">
<FileText className="mb-4 h-12 w-12 text-ds-text-neutral-muted-default mx-auto" />
<FileText className="mx-auto mb-4 h-12 w-12 text-ds-text-neutral-muted-default" />
<p className="text-sm">
{t('folder.zip-file-is-not-supported-yet')}
</p>
@ -3048,14 +3053,14 @@ export function FileViewerPanel({
<ImageLoader selectedFile={selectedFile} />
</div>
) : (
<pre className="font-mono text-sm text-ds-text-neutral-default-default overflow-auto break-words whitespace-pre-wrap">
<pre className="overflow-auto whitespace-pre-wrap break-words font-mono text-sm text-ds-text-neutral-default-default">
{selectedFile.content}
</pre>
)
) : (
<div className="flex h-full w-full items-center justify-center">
<div className="text-center">
<div className="mb-4 h-8 w-8 animate-spin mx-auto rounded-full"></div>
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full"></div>
<p className="text-body-sm text-ds-text-neutral-muted-default">
{t('chat.loading')}
</p>
@ -3064,9 +3069,9 @@ export function FileViewerPanel({
)
) : (
(emptyState ?? (
<div className="text-ds-text-neutral-muted-default flex h-full w-full flex-1 items-center justify-center">
<div className="flex h-full w-full flex-1 items-center justify-center text-ds-text-neutral-muted-default">
<div className="text-center">
<FileText className="mb-4 h-12 w-12 text-ds-text-neutral-muted-default mx-auto" />
<FileText className="mx-auto mb-4 h-12 w-12 text-ds-text-neutral-muted-default" />
<p className="text-sm">
{t('chat.select-a-file-to-view-its-contents')}
</p>

View file

@ -67,6 +67,8 @@ export interface ProjectPageSidebarProps {
className?: string;
}
let didAttemptBootSessionResume = false;
export default function ProjectPageSidebar({
chatStore: _chatStore,
className,
@ -314,6 +316,35 @@ export default function ProjectPageSidebar({
]
);
// Boot-time session resume: reopen the last visited Project (the way an
// editor reopens its last workspace) instead of landing on the empty home
// tab. One-shot per renderer boot (launch or reload; the flag is module
// scoped so sidebar remounts within a session never re-trigger it), and
// only from the pristine boot state (default tab, no active Project), so
// deliberately navigating home later is never hijacked. Uses the same
// path as clicking the Project in the sidebar.
useEffect(() => {
if (didAttemptBootSessionResume) return;
didAttemptBootSessionResume = true;
if (activeWorkspaceTab !== 'workforce') return;
if (projectStore.activeProjectId) return;
if (!activeSpaceId) return;
const lastVisitedId =
useSpaceStore.getState().lastVisitedProjectBySpace[activeSpaceId];
if (!lastVisitedId) return;
const lastVisitedMeta = projectMetasForActiveSpace.find(
(project) => project.id === lastVisitedId
);
if (!lastVisitedMeta || !shouldShowProjectInNavList(lastVisitedMeta)) {
return;
}
void selectProject(lastVisitedId);
// One-shot boot effect: later changes to these values must not
// re-trigger a resume.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const navProjects = useMemo(
() =>
projectMetasForActiveSpace

View file

@ -19,8 +19,8 @@ import { Button } from '@/components/ui/button';
import { TooltipSimple } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { useAuthStore } from '@/store/authStore';
import { usePageTabStore } from '@/store/pageTabStore';
import { ArrowLeft, FileText } from 'lucide-react';
import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore';
import { ArrowLeft, GalleryThumbnails } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export interface HeaderBoxProps {
@ -40,14 +40,18 @@ export function HeaderBox({
const { t } = useTranslation();
const { appearance } = useAuthStore();
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
const filePreviewOpen = usePageTabStore((s) => s.filePreviewOpen);
const toggleFilePreview = usePageTabStore((s) => s.toggleFilePreview);
const sessionPreviewOpen = usePageTabStore(
(s) => getSessionPreviewSlice(s).open
);
const toggleSessionPreview = usePageTabStore((s) => s.toggleSessionPreview);
const tokenIcon = appearance === 'dark' ? tokenDarkIcon : tokenLightIcon;
const backToWorkspaceTooltip = t('layout.back-to-workspace-tooltip', {
defaultValue: 'Back to workspace',
});
const filePreviewTooltip = t('layout.toggle-file-preview-tooltip', {
defaultValue: 'Toggle file preview',
// Own key (not the old file-preview one): the control's meaning changed, so
// stale translations must not carry over.
const windowPreviewTooltip = t('layout.toggle-window-preview-tooltip', {
defaultValue: 'Toggle window preview',
});
if (empty) {
@ -80,7 +84,7 @@ export function HeaderBox({
</TooltipSimple>
</div>
{/* Right: project total token count + file preview toggle */}
{/* Right: project total token count + unified preview toggle */}
<div className="flex items-center gap-2 text-ds-text-neutral-muted-default">
<div className="flex items-center gap-1">
<img src={tokenIcon} alt="" className="h-3.5 w-3.5" />
@ -89,22 +93,22 @@ export function HeaderBox({
<AnimatedTokenNumber value={totalTokens} />
</span>
</div>
<TooltipSimple content={filePreviewTooltip} variant="instant">
<TooltipSimple content={windowPreviewTooltip} variant="instant">
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
onClick={toggleFilePreview}
onClick={toggleSessionPreview}
className={cn(
'no-drag shrink-0 text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-strong-default',
filePreviewOpen &&
sessionPreviewOpen &&
'bg-ds-bg-neutral-strong-default text-ds-text-neutral-default-default'
)}
aria-label={filePreviewTooltip}
aria-pressed={filePreviewOpen}
aria-label={windowPreviewTooltip}
aria-pressed={sessionPreviewOpen}
>
<FileText className="h-4 w-4" aria-hidden />
<GalleryThumbnails className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
</div>

View file

@ -0,0 +1,287 @@
// ========= 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 { Button } from '@/components/ui/button';
import { TooltipSimple } from '@/components/ui/tooltip';
import { useHost } from '@/host';
import { cn } from '@/lib/utils';
import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore';
import { Plus, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { previewTabIcon } from './tabKinds';
import { BrowserTab } from './tabs/browser/BrowserTab';
import { CanvasTab } from './tabs/CanvasTab';
import { ChooserTab } from './tabs/ChooserTab';
import { FileTab } from './tabs/FileTab';
import { ReviewTab } from './tabs/ReviewTab';
import { TerminalTab } from './tabs/TerminalTab';
// Tabs render at a comfortable default width and shrink evenly as more are
// added, down to a minimum that keeps the title/close affordance legible.
// Once every tab is at its minimum the tab list scrolls horizontally.
const TAB_DEFAULT_WIDTH = 176;
const TAB_MIN_WIDTH = 92;
export interface PreviewPanelProps {
onJumpToContext?: (file: FileInfo | null) => void;
/**
* False while the display panel's open animation is still running. Browser
* tabs hold their fixed-position webview guest parked until it settles so
* the page doesn't pop in over the chat mid-animation.
*/
displaySettled?: boolean;
}
/**
* Unified preview panel: a tab strip plus a content router that dispatches to
* one component per tab kind (chooser / browser / file / review / terminal /
* canvas). Embedded browsers live in the always-mounted PreviewBrowserLayer;
* this panel only renders their chrome via BrowserTab.
*/
export function PreviewPanel({
onJumpToContext,
displaySettled = true,
}: PreviewPanelProps) {
const { t } = useTranslation();
const host = useHost();
const tabs = usePageTabStore((state) => getSessionPreviewSlice(state).tabs);
const activeTabId = usePageTabStore(
(state) => getSessionPreviewSlice(state).activeTabId
);
const addChooserPreviewTab = usePageTabStore(
(state) => state.addChooserPreviewTab
);
const choosePreviewTabType = usePageTabStore(
(state) => state.choosePreviewTabType
);
const selectSessionPreviewTab = usePageTabStore(
(state) => state.selectSessionPreviewTab
);
const closeSessionPreviewTab = usePageTabStore(
(state) => state.closeSessionPreviewTab
);
const activeTab = useMemo(
() => tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] ?? null,
[activeTabId, tabs]
);
// Embedded browsing relies on the desktop host's <webview> tag; on the web
// the panel still works but URLs open in a regular browser tab.
const isDesktop = Boolean(host?.electronAPI);
const tabListRef = useRef<HTMLDivElement>(null);
const [tabOverflow, setTabOverflow] = useState({ start: false, end: false });
const updateTabOverflow = useCallback(() => {
const el = tabListRef.current;
if (!el) return;
const { scrollLeft, scrollWidth, clientWidth } = el;
setTabOverflow({
start: scrollLeft > 1,
end: scrollLeft + clientWidth < scrollWidth - 1,
});
}, []);
useEffect(() => {
const el = tabListRef.current;
if (!el) return;
updateTabOverflow();
const observer = new ResizeObserver(updateTabOverflow);
observer.observe(el);
el.addEventListener('scroll', updateTabOverflow, { passive: true });
return () => {
observer.disconnect();
el.removeEventListener('scroll', updateTabOverflow);
};
}, [updateTabOverflow, tabs.length]);
if (!activeTab) return null;
// Roving tabindex: only the selected tab is in the Tab order; Left/Right
// (and Home/End) move both selection and focus along the strip.
const handleTabListKeyDown = (event: React.KeyboardEvent) => {
const currentIndex = tabs.findIndex((tab) => tab.id === activeTab.id);
let nextIndex: number;
switch (event.key) {
case 'ArrowLeft':
nextIndex = Math.max(0, currentIndex - 1);
break;
case 'ArrowRight':
nextIndex = Math.min(tabs.length - 1, currentIndex + 1);
break;
case 'Home':
nextIndex = 0;
break;
case 'End':
nextIndex = tabs.length - 1;
break;
default:
return;
}
event.preventDefault();
if (nextIndex === currentIndex) return;
selectSessionPreviewTab(tabs[nextIndex].id);
tabListRef.current
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
[nextIndex]?.focus();
};
const renderActiveContent = () => {
switch (activeTab.type) {
case 'chooser':
return (
<ChooserTab
onChoose={(kind) => choosePreviewTabType(activeTab.id, kind)}
/>
);
case 'browser':
// Keyed so each browser tab keeps its own address state.
return (
<BrowserTab
key={activeTab.id}
tab={activeTab}
isDesktop={isDesktop}
viewportSettled={displaySettled}
/>
);
case 'file':
return <FileTab tab={activeTab} onJumpToContext={onJumpToContext} />;
case 'review':
return <ReviewTab />;
case 'terminal':
return <TerminalTab />;
case 'canvas':
return <CanvasTab key={activeTab.id} />;
default:
return null;
}
};
return (
<div className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden px-1 pb-2">
<div className="flex h-[44px] shrink-0 items-center justify-start gap-1 px-1.5">
<div className="relative flex min-w-0 items-center">
<div
ref={tabListRef}
role="tablist"
aria-label={t('layout.preview-tabs', {
defaultValue: 'Preview tabs',
})}
onKeyDown={handleTabListKeyDown}
className="scrollbar-hide flex h-7 w-full min-w-0 items-center gap-1.5 overflow-x-auto overflow-y-hidden"
>
{tabs.map((tab) => {
const selected = tab.id === activeTab.id;
const Icon = previewTabIcon(tab.type);
return (
<div
key={tab.id}
style={{
flex: `0 1 ${TAB_DEFAULT_WIDTH}px`,
minWidth: TAB_MIN_WIDTH,
maxWidth: TAB_DEFAULT_WIDTH,
}}
className={cn(
// Every tab uses the selected colors; unselected tabs are
// just dimmed (40% at rest, 80% on hover) so selection
// reads as full opacity rather than a color change.
'group relative h-7 rounded-lg bg-ds-bg-neutral-strong-default text-ds-text-neutral-default-default transition-opacity',
selected ? 'opacity-100' : 'opacity-40 hover:opacity-80'
)}
>
<button
type="button"
role="tab"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
onClick={() => selectSessionPreviewTab(tab.id)}
className="flex h-full w-full min-w-0 cursor-pointer items-center gap-2 border-0 bg-transparent px-2 text-left text-inherit"
>
<Icon className="h-4 w-4 shrink-0" aria-hidden />
<span className="truncate text-sm font-medium">
{tab.title}
</span>
</button>
<button
type="button"
aria-label={t('layout.close-preview-tab', {
defaultValue: 'Close tab',
})}
onClick={(event) => {
event.stopPropagation();
closeSessionPreviewTab(tab.id);
}}
className={cn(
'absolute inset-y-0 right-1 top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 cursor-pointer items-center justify-center rounded-lg border-0 px-1 text-inherit opacity-0 transition-opacity group-hover:opacity-100',
// Keyboard users can't hover — reveal on focus too.
'focus-visible:opacity-100 group-focus-within:opacity-100',
'bg-ds-bg-neutral-subtle-default hover:bg-ds-bg-neutral-muted-default'
)}
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
);
})}
</div>
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-y-0 left-0 w-6 backdrop-blur-[2px] transition-opacity duration-150',
tabOverflow.start ? 'opacity-100' : 'opacity-0'
)}
style={{
maskImage: 'linear-gradient(to right, black, transparent)',
WebkitMaskImage: 'linear-gradient(to right, black, transparent)',
}}
/>
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-y-0 right-0 w-8 backdrop-blur-[2px] transition-opacity duration-150',
tabOverflow.end ? 'opacity-100' : 'opacity-0'
)}
style={{
maskImage: 'linear-gradient(to left, black, transparent)',
WebkitMaskImage: 'linear-gradient(to left, black, transparent)',
}}
/>
</div>
<TooltipSimple
content={t('layout.add-preview-tab', { defaultValue: 'New tab' })}
>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
onClick={addChooserPreviewTab}
aria-label={t('layout.add-preview-tab', {
defaultValue: 'New tab',
})}
className="shrink-0"
>
<Plus className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
</div>
<div className="flex min-h-0 w-full flex-1 flex-col overflow-hidden rounded-xl border-solid border-ds-border-neutral-subtle-disabled bg-ds-bg-neutral-subtle-default">
{renderActiveContent()}
</div>
</div>
);
}
export default PreviewPanel;

View file

@ -0,0 +1,77 @@
// ========= 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 type { PreviewTabKind, SessionPreviewTab } from '@/store/pageTabStore';
import {
ClipboardCheck,
FileText,
Globe,
type LucideIcon,
PanelsTopLeft,
Shapes,
SquareTerminal,
} from 'lucide-react';
export interface PreviewKindMeta {
kind: PreviewTabKind;
icon: LucideIcon;
/** i18n key + fallback used for the tab title and chooser row label. */
labelKey: string;
defaultLabel: string;
/** One-line description shown in the chooser. */
descriptionKey: string;
defaultDescription: string;
}
/**
* The content kinds the chooser offers, in display order. Single source of
* truth for icon + copy so the tab strip and chooser never drift.
*
* `review`, `terminal`, and `canvas` are reserved tab types (their components
* and store plumbing exist, and persisted tabs still render) but are hidden
* from the chooser until a later version ships their content. Re-add their
* entries here when that lands.
*/
export const PREVIEW_TAB_KINDS: PreviewKindMeta[] = [
{
kind: 'browser',
icon: Globe,
labelKey: 'layout.preview-kind-browser',
defaultLabel: 'Browser',
descriptionKey: 'layout.preview-kind-browser-desc',
defaultDescription: 'Open and navigate web pages in an embedded browser.',
},
{
kind: 'file',
icon: FileText,
labelKey: 'layout.preview-kind-file',
defaultLabel: 'Files',
descriptionKey: 'layout.preview-kind-file-desc',
defaultDescription: 'Preview files produced or referenced in this session.',
},
];
const KIND_ICONS: Record<SessionPreviewTab['type'], LucideIcon> = {
chooser: PanelsTopLeft,
browser: Globe,
file: FileText,
review: ClipboardCheck,
terminal: SquareTerminal,
canvas: Shapes,
};
/** Icon for any tab (including the chooser) — used by the tab strip. */
export function previewTabIcon(type: SessionPreviewTab['type']): LucideIcon {
return KIND_ICONS[type];
}

View file

@ -0,0 +1,91 @@
// ========= 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 {
addEdge,
Background,
BackgroundVariant,
type Connection,
Controls,
type Edge,
MiniMap,
type Node,
ReactFlow,
ReactFlowProvider,
useEdgesState,
useNodesState,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useCallback } from 'react';
const INITIAL_NODES: Node[] = [
{
id: 'start',
type: 'input',
position: { x: 0, y: 40 },
data: { label: 'Start' },
},
{
id: 'idea',
position: { x: 220, y: 160 },
data: { label: 'Idea' },
},
];
const INITIAL_EDGES: Edge[] = [
{ id: 'start-idea', source: 'start', target: 'idea' },
];
function CanvasFlow() {
const [nodes, , onNodesChange] = useNodesState(INITIAL_NODES);
const [edges, setEdges, onEdgesChange] = useEdgesState(INITIAL_EDGES);
const onConnect = useCallback(
(connection: Connection) => setEdges((eds) => addEdge(connection, eds)),
[setEdges]
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
proOptions={{ hideAttribution: true }}
className="bg-ds-bg-neutral-default-default"
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<MiniMap pannable zoomable className="!bg-ds-bg-neutral-subtle-default" />
<Controls />
</ReactFlow>
);
}
/**
* Free-form React Flow canvas. Each canvas tab keeps its own flow state in its
* own ReactFlowProvider so it stays isolated from the workspace workflow graph.
*/
export function CanvasTab() {
return (
<div className="h-full min-h-0 w-full overflow-hidden">
<ReactFlowProvider>
<CanvasFlow />
</ReactFlowProvider>
</div>
);
}
export default CanvasTab;

View file

@ -0,0 +1,84 @@
// ========= 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 type { PreviewTabKind } from '@/store/pageTabStore';
import { ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { PREVIEW_TAB_KINDS } from '../tabKinds';
export interface ChooserTabProps {
/** Open the given content kind (replaces this chooser tab in place). */
onChoose: (kind: PreviewTabKind) => void;
}
/**
* The default starter tab. Lists every content kind as a vertical row; picking
* one turns this tab into that kind via the store's `choosePreviewTabType`.
*/
export function ChooserTab({ onChoose }: ChooserTabProps) {
const { t } = useTranslation();
return (
<div className="flex h-full min-h-0 w-full flex-col items-center justify-center overflow-y-auto p-4">
<div className="w-full max-w-[420px]">
<p className="mb-3 px-1 text-sm font-medium text-ds-text-neutral-muted-default">
{t('layout.preview-chooser-title', {
defaultValue: 'Open a new view',
})}
</p>
<div className="flex flex-col gap-1.5">
{PREVIEW_TAB_KINDS.map(
({
kind,
icon: Icon,
labelKey,
defaultLabel,
descriptionKey,
defaultDescription,
}) => (
<button
key={kind}
type="button"
onClick={() => onChoose(kind)}
className={cn(
'group flex w-full items-center gap-3 rounded-xl border-solid border-transparent bg-ds-bg-neutral-default-default px-3 py-2.5 text-left transition-colors',
'hover:border-ds-border-neutral-default-default hover:bg-ds-bg-neutral-default-hover'
)}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-ds-bg-neutral-subtle-default text-ds-text-neutral-default-default">
<Icon className="h-[18px] w-[18px]" aria-hidden />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="text-sm font-medium text-ds-text-neutral-default-default">
{t(labelKey, { defaultValue: defaultLabel })}
</span>
<span className="truncate text-xs text-ds-text-neutral-muted-default">
{t(descriptionKey, { defaultValue: defaultDescription })}
</span>
</span>
<ChevronRight
className="h-4 w-4 shrink-0 text-ds-text-neutral-muted-default opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden
/>
</button>
)
)}
</div>
</div>
</div>
);
}
export default ChooserTab;

View file

@ -0,0 +1,35 @@
// ========= 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 { FilePreview } from '@/components/Folder/FilePreview';
import type { SessionFileTab } from '@/store/pageTabStore';
export interface FileTabProps {
tab: SessionFileTab;
onJumpToContext?: (file: FileInfo | null) => void;
}
/** File preview surface for one file tab. */
export function FileTab({ tab, onJumpToContext }: FileTabProps) {
return (
<FilePreview
file={tab.file}
embedded
surfaceClassName="bg-ds-bg-neutral-default-default"
onJumpToContext={onJumpToContext}
/>
);
}
export default FileTab;

View file

@ -0,0 +1,25 @@
// ========= 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. =========
/**
* Review surface intentionally blank for now. Content (diff/change review)
* will be added later; this reserves the tab type and its container.
*/
export function ReviewTab() {
return (
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden" />
);
}
export default ReviewTab;

View file

@ -14,33 +14,25 @@
import { useTranslation } from 'react-i18next';
interface BoxActionProps {
/** Task status for determining what button to show */
status?: 'running' | 'finished' | 'pending' | 'pause';
/** Task time display */
taskTime?: string;
/** Callback for pause/resume */
onPauseResume?: () => void;
/** Loading state for pause/resume */
pauseResumeLoading?: boolean;
className?: string;
}
export function BoxAction({
status: _status,
taskTime: _taskTime,
onPauseResume: _onPauseResume,
pauseResumeLoading: _pauseResumeLoading = false,
className,
}: BoxActionProps) {
/**
* Terminal surface. Rendered as a terminal-styled placeholder for now; the
* container and tab type are in place so a live PTY / agent terminal (the
* existing xterm `Terminal` component) can be dropped in later.
*/
export function TerminalTab() {
const { t } = useTranslation();
return (
<div
className={`z-50 flex items-center justify-between gap-sm pl-4 ${className || ''}`}
>
{/* Placeholder for future actions */}
<div></div>
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden bg-ds-bg-neutral-strong-default">
<div className="min-h-0 flex-1 overflow-auto p-3 font-mono text-body-sm text-ds-text-neutral-muted-default">
<div className="text-ds-text-neutral-default-default">Eigent:~$</div>
<div className="mt-1 opacity-70">
{t('layout.preview-terminal-placeholder', {
defaultValue: 'Terminal output will appear here.',
})}
</div>
</div>
</div>
);
}
export default TerminalTab;

View file

@ -0,0 +1,297 @@
// ========= 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 { Button } from '@/components/ui/button';
import { TooltipSimple } from '@/components/ui/tooltip';
import { useHost } from '@/host';
import { normalizeBrowserUrl } from '@/lib/browserUrl';
import { cn } from '@/lib/utils';
import { type SessionBrowserTab, usePageTabStore } from '@/store/pageTabStore';
import { ArrowLeft, ArrowRight, ExternalLink, RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getPreviewWebview } from './webviewRegistry';
export interface BrowserTabProps {
tab: SessionBrowserTab;
/** Desktop host embeds a real <webview>; web falls back to opening tabs. */
isDesktop: boolean;
/**
* False while the display panel is still animating open. The viewport is
* only published once settled so the fixed-position guest (which the panel's
* clip-path can't clip) doesn't appear over the chat mid-animation.
*/
viewportSettled?: boolean;
}
/**
* Browser chrome (toolbar + address bar) for one browser tab. The page itself
* is a `<webview>` guest owned by PreviewBrowserLayer; this component publishes
* the rect the guest should fill via `previewBrowserViewport` and drives
* navigation through the webview registry. Keyed by tab id upstream so each
* browser tab keeps its own address state.
*/
export function BrowserTab({
tab,
isDesktop,
viewportSettled = true,
}: BrowserTabProps) {
const { t } = useTranslation();
const host = useHost();
const updateBrowserPreviewTab = usePageTabStore(
(state) => state.updateBrowserPreviewTab
);
const setPreviewBrowserViewport = usePageTabStore(
(state) => state.setPreviewBrowserViewport
);
const containerRef = useRef<HTMLDivElement>(null);
const [addressInput, setAddressInput] = useState(tab.url);
const [addressError, setAddressError] = useState<string | null>(null);
const [addressFocused, setAddressFocused] = useState(false);
const nav = tab.navigation;
// Follow the page's URL in the address bar — but never while the user is in
// the field, so redirects/SPA navigation events can't clobber their typing.
// (Unsubmitted edits revert to the page URL on blur.)
useEffect(() => {
if (addressFocused) return;
setAddressInput(tab.url);
setAddressError(null);
}, [tab.url, addressFocused]);
const navigateTo = useCallback(
async (rawUrl: string) => {
const normalized = normalizeBrowserUrl(rawUrl);
if (!normalized.ok) {
setAddressError(normalized.error);
return;
}
setAddressError(null);
setAddressInput(normalized.url);
if (!isDesktop) {
// Web host: no embedded view — open in a regular browser tab instead.
updateBrowserPreviewTab(tab.id, { url: normalized.url });
window.open(normalized.url, '_blank', 'noopener,noreferrer');
return;
}
const element = getPreviewWebview(tab.webviewId);
if (element?.loadURL) {
// Guest already mounted: navigate it in place (keeps history).
try {
await element.loadURL(normalized.url);
} catch (error) {
setAddressError(
error instanceof Error ? error.message : 'Unable to open this URL'
);
}
return;
}
// No guest yet (blank tab): setting the URL mounts one in the layer.
updateBrowserPreviewTab(tab.id, { url: normalized.url });
},
[isDesktop, tab.id, tab.webviewId, updateBrowserPreviewTab]
);
const openExternal = useCallback(
async (rawUrl: string) => {
const normalized = normalizeBrowserUrl(rawUrl);
if (!normalized.ok) {
setAddressError(normalized.error);
return;
}
setAddressError(null);
if (isDesktop && host?.electronAPI?.openExternal) {
const result = await host.electronAPI.openExternal(normalized.url);
if (result && result.success === false) {
setAddressError(result.error || 'Unable to open this URL');
}
return;
}
window.open(normalized.url, '_blank', 'noopener,noreferrer');
},
[host?.electronAPI, isDesktop]
);
// Publish this container's rect so the layer can position the guest over it.
// Held back until the panel animation settles; cleared on unmount (switching
// tabs / closing) so guests park, not float.
useEffect(() => {
if (!isDesktop || !viewportSettled) return;
const container = containerRef.current;
if (!container) {
setPreviewBrowserViewport(null);
return;
}
const publish = () => {
const rect = container.getBoundingClientRect();
setPreviewBrowserViewport({
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
});
};
publish();
const observer = new ResizeObserver(publish);
observer.observe(container);
window.addEventListener('resize', publish);
return () => {
observer.disconnect();
window.removeEventListener('resize', publish);
setPreviewBrowserViewport(null);
};
}, [isDesktop, viewportSettled, tab.id, setPreviewBrowserViewport]);
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex h-[44px] shrink-0 items-center gap-1.5 px-2">
<TooltipSimple
content={t('layout.browser-back', { defaultValue: 'Back' })}
>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
disabled={!isDesktop || !nav?.canGoBack}
onClick={() => getPreviewWebview(tab.webviewId)?.goBack?.()}
aria-label={t('layout.browser-back', { defaultValue: 'Back' })}
>
<ArrowLeft className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
<TooltipSimple
content={t('layout.browser-forward', { defaultValue: 'Forward' })}
>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
disabled={!isDesktop || !nav?.canGoForward}
onClick={() => getPreviewWebview(tab.webviewId)?.goForward?.()}
aria-label={t('layout.browser-forward', {
defaultValue: 'Forward',
})}
>
<ArrowRight className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
<TooltipSimple
content={t('layout.browser-reload', { defaultValue: 'Reload' })}
>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
disabled={!isDesktop || !tab.url}
onClick={() => getPreviewWebview(tab.webviewId)?.reload?.()}
aria-label={t('layout.browser-reload', { defaultValue: 'Reload' })}
>
<RefreshCw
className={cn('h-4 w-4', nav?.isLoading && 'animate-spin')}
aria-hidden
/>
</Button>
</TooltipSimple>
<form
className="flex min-w-0 flex-1 items-center"
onSubmit={(event) => {
event.preventDefault();
void navigateTo(addressInput);
}}
>
<input
type="text"
value={addressInput}
onChange={(event) => {
setAddressInput(event.target.value);
if (addressError) setAddressError(null);
}}
onFocus={() => setAddressFocused(true)}
onBlur={() => setAddressFocused(false)}
placeholder={t('layout.browser-url-placeholder', {
defaultValue: 'Enter a URL',
})}
aria-label={t('layout.browser-url-placeholder', {
defaultValue: 'Enter a URL',
})}
aria-invalid={Boolean(addressError)}
className={cn(
'placeholder:text-input-label-default/10 h-[28px] w-full min-w-0 rounded-xl border-none bg-ds-bg-neutral-subtle-default px-3 text-body-sm text-ds-text-neutral-default-default outline-none transition-colors',
'hover:bg-ds-bg-neutral-subtle-default hover:ring-1 hover:ring-ds-ring-neutral-strong-default hover:ring-offset-0',
'focus:bg-ds-bg-neutral-subtle-default focus:ring-1 focus:ring-ds-ring-brand-default-focus focus:ring-offset-0',
addressError
? 'border-ds-border-status-error-default-default'
: 'border-ds-border-neutral-default-default'
)}
/>
</form>
<TooltipSimple
content={t('layout.browser-open-external', {
defaultValue: 'Open externally',
})}
>
<Button
type="button"
variant="ghost"
size="sm"
buttonContent="icon-only"
disabled={!addressInput}
onClick={() => openExternal(addressInput)}
aria-label={t('layout.browser-open-external', {
defaultValue: 'Open externally',
})}
>
<ExternalLink className="h-4 w-4" aria-hidden />
</Button>
</TooltipSimple>
</div>
{addressError ? (
<p className="text-ds-text-danger-default-default shrink-0 px-3 py-1 text-xs">
{addressError}
</p>
) : null}
{/* The <webview> guest is positioned over this container by
PreviewBrowserLayer via the published viewport rect. */}
<div
ref={containerRef}
className="relative min-h-0 flex-1 overflow-hidden bg-ds-bg-neutral-strong-default"
>
{!tab.url ? (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-ds-text-neutral-muted-default">
{t('layout.browser-blank', {
defaultValue: 'Enter a URL to start browsing.',
})}
</div>
) : !isDesktop ? (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-ds-text-neutral-muted-default">
{t('layout.browser-desktop-only', {
defaultValue:
'Embedded browsing is available in the desktop app. This URL opened in your system browser.',
})}
</div>
) : null}
</div>
</div>
);
}
export default BrowserTab;

View file

@ -0,0 +1,310 @@
// ========= 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 { useHost } from '@/host';
import {
type PreviewBrowserViewport,
type SessionBrowserNavigationState,
type SessionBrowserTab,
usePageTabStore,
} from '@/store/pageTabStore';
import { useEffect, useRef, useState } from 'react';
import {
type PreviewWebviewElement,
registerPreviewWebview,
unregisterPreviewWebview,
} from './webviewRegistry';
/**
* Hosts the preview panel's embedded browsers as Electron `<webview>` tags.
*
* Why a separate, always-mounted layer instead of rendering inside the panel:
* - `<webview>` is a DOM element, so dropdowns/dialogs overlay it naturally
* (unlike a native WebContentsView, which paints above the whole document).
* - A `<webview>` reloads whenever it is detached or reparented. Keeping the
* elements here positioned over the panel via `previewBrowserViewport`,
* parked offscreen when not visible preserves each guest's page state and
* full back/forward history across panel close, tab hops, and project
* switches for the lifetime of the workspace page.
*
* Mount once (in the workspace page). Renders nothing on the web host.
*/
/** Above page content, below portaled overlays (menus z-50, tooltips z-100). */
const GUEST_Z_INDEX = 30;
/** Matches the preview panel's rounded-xl browser container. */
const GUEST_RADIUS = 12;
/** Guest fade when (un)covering its viewport — softens show/park hops. */
const GUEST_FADE_MS = 160;
/**
* Guests of projects that have been out of scope this long are destroyed to
* reclaim their renderer processes. The tab's URL is persisted, so returning
* to an evicted project simply reloads its pages (history is lost the price
* of not keeping every visited project's Chromium processes alive forever).
*/
const IDLE_PROJECT_EVICT_MS = 10 * 60_000;
const EVICT_SWEEP_INTERVAL_MS = 60_000;
const PARKED_STYLE: React.CSSProperties = {
position: 'fixed',
left: -10_000,
top: 0,
width: 1024,
height: 640,
visibility: 'hidden',
pointerEvents: 'none',
};
function visibleStyle(
viewport: PreviewBrowserViewport,
shown: boolean
): React.CSSProperties {
return {
position: 'fixed',
left: viewport.x,
top: viewport.y,
width: viewport.width,
height: viewport.height,
zIndex: GUEST_Z_INDEX,
borderRadius: GUEST_RADIUS,
overflow: 'hidden',
opacity: shown ? 1 : 0,
transition: `opacity ${GUEST_FADE_MS}ms ease`,
};
}
function browserTitle(state: SessionBrowserNavigationState): string {
const explicitTitle = state.title.trim();
if (explicitTitle) return explicitTitle;
if (!state.url || state.url.startsWith('about:')) return 'New tab';
try {
return new URL(state.url).hostname || 'New tab';
} catch {
return 'New tab';
}
}
const GUEST_EVENTS = [
'did-navigate',
'did-navigate-in-page',
'did-start-loading',
'did-stop-loading',
'page-title-updated',
'dom-ready',
] as const;
interface PreviewGuestProps {
projectId: string;
tab: SessionBrowserTab;
visible: boolean;
viewport: PreviewBrowserViewport | null;
}
function PreviewGuest({
projectId,
tab,
visible,
viewport,
}: PreviewGuestProps) {
const containerRef = useRef<HTMLDivElement>(null);
// The guest reloads if its src attribute changes, so freeze the mount-time
// URL; later navigation happens on the element (loadURL / link clicks) and
// flows back into the store through the events below.
const initialUrlRef = useRef(tab.url);
const tabIdRef = useRef(tab.id);
useEffect(() => {
tabIdRef.current = tab.id;
}, [tab.id]);
// Show/park with a short opacity fade instead of an instant hop:
// 'parked' → offscreen, 'faded' → over the viewport at opacity 0,
// 'shown' → opacity 1. `lastViewport` remembers the rect the guest was
// last shown at so the fade-out happens in place even after the panel
// stops publishing a viewport (tab switch / project switch unmounts the
// publisher in the same commit that hides the guest).
const showing = visible && viewport !== null;
const [lastViewport, setLastViewport] =
useState<PreviewBrowserViewport | null>(null);
useEffect(() => {
if (viewport) setLastViewport(viewport);
}, [viewport]);
const [phase, setPhase] = useState<'parked' | 'faded' | 'shown'>('parked');
useEffect(() => {
if (showing) {
setPhase('faded');
// Double rAF so the opacity-0 frame commits before the fade-in starts.
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setPhase('shown'));
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}
setPhase((current) => (current === 'parked' ? 'parked' : 'faded'));
const timer = window.setTimeout(() => setPhase('parked'), GUEST_FADE_MS);
return () => window.clearTimeout(timer);
}, [showing]);
useEffect(() => {
const container = containerRef.current;
if (!container || !initialUrlRef.current) return;
const element = document.createElement('webview') as PreviewWebviewElement;
element.setAttribute('src', initialUrlRef.current);
element.setAttribute('partition', 'persist:session-preview');
// Popups are redirected into the same guest by the main process
// (did-attach-webview → setWindowOpenHandler), so target=_blank links work.
element.setAttribute('allowpopups', 'true');
element.style.display = 'flex';
element.style.width = '100%';
element.style.height = '100%';
const notify = () => {
// Guest methods throw until the webview is attached; treat as no state.
let state: SessionBrowserNavigationState;
try {
state = {
url: element.getURL?.() ?? '',
title: element.getTitle?.() ?? '',
isLoading: element.isLoading?.() ?? false,
canGoBack: element.canGoBack?.() ?? false,
canGoForward: element.canGoForward?.() ?? false,
};
} catch {
return;
}
const url = state.url.startsWith('about:') ? '' : state.url;
usePageTabStore
.getState()
.updateBrowserPreviewTabIn(projectId, tabIdRef.current, {
title: browserTitle(state),
navigation: { ...state, url },
// Guests mount only while the tab has a URL, so never write an
// empty one back (early events can fire before a location exists).
...(url ? { url } : {}),
});
};
GUEST_EVENTS.forEach((event) => element.addEventListener(event, notify));
container.appendChild(element);
registerPreviewWebview(tab.webviewId, element);
return () => {
GUEST_EVENTS.forEach((event) =>
element.removeEventListener(event, notify)
);
unregisterPreviewWebview(tab.webviewId);
element.remove();
};
}, [projectId, tab.webviewId]);
const rect = viewport ?? lastViewport;
return (
<div
ref={containerRef}
data-preview-webview-id={tab.webviewId}
style={
phase === 'parked' || !rect
? PARKED_STYLE
: visibleStyle(rect, phase === 'shown')
}
/>
);
}
export function PreviewBrowserLayer() {
const host = useHost();
const byProject = usePageTabStore((s) => s.sessionPreviewByProject);
const scopeProjectId = usePageTabStore((s) => s.sessionPreviewProjectId);
const viewport = usePageTabStore((s) => s.previewBrowserViewport);
// Persisted slices may reference many projects; only mount guests for
// projects the user has actually visited this run so startup stays light.
const [activatedProjects, setActivatedProjects] = useState<Set<string>>(
() => new Set()
);
useEffect(() => {
if (!scopeProjectId) return;
setActivatedProjects((current) => {
if (current.has(scopeProjectId)) return current;
const next = new Set(current);
next.add(scopeProjectId);
return next;
});
}, [scopeProjectId]);
// Each guest is a full renderer process, so don't keep every visited
// project's guests alive forever: track when a project leaves scope and
// deactivate it (unmounting its guests) once it has idled long enough.
const idleSinceRef = useRef(new Map<string, number>());
useEffect(() => {
if (!scopeProjectId) return;
const idleSince = idleSinceRef.current;
idleSince.delete(scopeProjectId);
return () => {
idleSince.set(scopeProjectId, Date.now());
};
}, [scopeProjectId]);
useEffect(() => {
const timer = window.setInterval(() => {
setActivatedProjects((current) => {
let next: Set<string> | null = null;
for (const projectId of current) {
const idleSince = idleSinceRef.current.get(projectId);
if (
idleSince === undefined ||
Date.now() - idleSince < IDLE_PROJECT_EVICT_MS
) {
continue;
}
next ??= new Set(current);
next.delete(projectId);
idleSinceRef.current.delete(projectId);
}
return next ?? current;
});
}, EVICT_SWEEP_INTERVAL_MS);
return () => window.clearInterval(timer);
}, []);
// Embedded browsing needs the desktop host's <webview> tag.
if (!host?.electronAPI) return null;
const guests: React.ReactNode[] = [];
for (const [projectId, slice] of Object.entries(byProject)) {
if (!activatedProjects.has(projectId)) continue;
for (const tab of slice.tabs) {
if (tab.type !== 'browser' || !tab.url) continue;
const visible =
projectId === scopeProjectId &&
slice.open &&
slice.activeTabId === tab.id;
guests.push(
<PreviewGuest
key={tab.webviewId}
projectId={projectId}
tab={tab}
visible={visible}
viewport={viewport}
/>
);
}
}
return <>{guests}</>;
}
export default PreviewBrowserLayer;

View file

@ -0,0 +1,58 @@
// ========= 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. =========
/**
* Minimal surface of Electron's `<webview>` tag that the preview uses. Typed
* locally (instead of importing electron types) so the renderer stays
* host-agnostic: on the web the element never mounts and none of this runs.
* All methods are optional they only exist once the guest is attached.
*/
export interface PreviewWebviewElement extends HTMLElement {
src?: string;
loadURL?: (url: string) => Promise<void>;
getURL?: () => string;
getTitle?: () => string;
isLoading?: () => boolean;
canGoBack?: () => boolean;
canGoForward?: () => boolean;
goBack?: () => void;
goForward?: () => void;
reload?: () => void;
stop?: () => void;
}
/**
* Live `<webview>` elements keyed by the store's webviewId. The browser layer
* registers elements as they mount; the preview panel's toolbar and address
* bar drive navigation through this without owning the elements (which must
* outlive the panel so guests keep their history).
*/
const registry = new Map<string, PreviewWebviewElement>();
export function registerPreviewWebview(
webviewId: string,
element: PreviewWebviewElement
): void {
registry.set(webviewId, element);
}
export function unregisterPreviewWebview(webviewId: string): void {
registry.delete(webviewId);
}
export function getPreviewWebview(
webviewId: string
): PreviewWebviewElement | undefined {
return registry.get(webviewId);
}

View file

@ -13,14 +13,14 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import ChatBox from '@/components/ChatBox';
import { FilePreview } from '@/components/Folder/FilePreview';
import { HeaderBox } from '@/components/Session/HeaderBox';
import { PreviewPanel } from '@/components/Session/PreviewPanel';
import Workspace from '@/components/Workspace';
import useChatStoreAdapter from '@/hooks/useChatStoreAdapter';
import { useSelectedProjectTurn } from '@/hooks/useSelectedProjectTurn';
import { inferSessionModeFromTask } from '@/lib/sessionMode';
import { cn } from '@/lib/utils';
import { usePageTabStore } from '@/store/pageTabStore';
import { getSessionPreviewSlice, usePageTabStore } from '@/store/pageTabStore';
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
import { useSpaceStore } from '@/store/spaceStore';
import {
@ -28,6 +28,7 @@ import {
SessionMode,
type SessionModeType,
} from '@/types/constants';
import { AnimatePresence, motion } from 'framer-motion';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { SessionSidePanel } from './SessionSidePanel';
import {
@ -35,16 +36,15 @@ import {
SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS,
} from './sessionSidePanelLayout';
/**
* When the inline preview is open, the chat column is pinned to its max width
* (so the chat flow keeps its comfortable reading width) and the preview fills
* the rest. The chat content itself is capped at 600px; 680 leaves side gutters.
*/
/** Maximum width the resizable chat column can reclaim while display is open. */
const CHAT_PRIORITY_WIDTH = 680;
/** Smallest the chat column may be dragged to. */
const CHAT_MIN_WIDTH = 360;
/** Keep at least this much room for the preview when the chat is widened. */
const PREVIEW_MIN_WIDTH = 320;
const DISPLAY_PANEL_EASE: [number, number, number, number] = [0.16, 1, 0.3, 1];
/** Display panel open/close animation duration (framer transition below). */
const DISPLAY_PANEL_ANIMATION_MS = 300;
/**
* Active Project: header + chat (left) and a mode-dependent side panel (right).
@ -60,9 +60,19 @@ export default function Session({ isNewProject = false }: SessionProps) {
const { chatStore, projectStore } = useChatStoreAdapter();
const activeWorkspaceTab = usePageTabStore((s) => s.activeWorkspaceTab);
const setActiveWorkspaceTab = usePageTabStore((s) => s.setActiveWorkspaceTab);
const filePreviewOpen = usePageTabStore((s) => s.filePreviewOpen);
const filePreviewFile = usePageTabStore((s) => s.filePreviewFile);
const closeFilePreview = usePageTabStore((s) => s.closeFilePreview);
const closeSessionPreview = usePageTabStore((s) => s.closeSessionPreview);
const setSessionPreviewProject = usePageTabStore(
(s) => s.setSessionPreviewProject
);
const sessionPreviewProjectId = usePageTabStore(
(s) => s.sessionPreviewProjectId
);
// Only trust the preview slice once the scope points at this project — on
// switch the scope effect below lags the first render by one frame, and
// rendering the stale slice would flash the previous project's browser.
const previewOpen =
usePageTabStore((s) => getSessionPreviewSlice(s).open) &&
sessionPreviewProjectId === (projectStore.activeProjectId ?? null);
const activeProjectId = projectStore.activeProjectId;
const isHistoryLoadingActiveProject = useProjectRuntimeStore((s) =>
activeProjectId
@ -229,45 +239,65 @@ export default function Session({ isNewProject = false }: SessionProps) {
setIsSidePanelVisible((prev) => !prev);
}, []);
// Inline preview column sizing. The chat column is width-controlled so it can
// animate between full-width (closed) and its max width (open); the preview is
// flex-1 and fills the remaining space ("full width"), absorbing the extra
// room freed by the side-panel fold. Dragging the divider resizes the chat.
// Chat/display sizing. When display opens the chat collapses to its minimum;
// display takes the remaining room before the independently folded side panel.
const chatRowRef = useRef<HTMLDivElement>(null);
const [chatWidth, setChatWidth] = useState(CHAT_PRIORITY_WIDTH);
const [isResizingPreview, setIsResizingPreview] = useState(false);
// Opening the inline file preview auto-folds the session side panel so the
// preview has room, and resets the chat back to its priority max width.
// Point the preview store at this project. Its saved tabs (and, within this
// app run, the live webviews behind them) are restored on switch-back —
// webviews are intentionally NOT destroyed here so history survives.
useEffect(() => {
if (filePreviewOpen) {
setIsSidePanelVisible(false);
const rowWidth = chatRowRef.current?.getBoundingClientRect().width ?? 0;
const maxChat = rowWidth
? Math.max(CHAT_MIN_WIDTH, rowWidth - PREVIEW_MIN_WIDTH)
: CHAT_PRIORITY_WIDTH;
setChatWidth(Math.min(CHAT_PRIORITY_WIDTH, maxChat));
}
}, [filePreviewOpen]);
setSessionPreviewProject(activeProjectId ?? null);
}, [activeProjectId, setSessionPreviewProject]);
// The preview is a project-page concern; close it when leaving that tab or
// switching projects so it never lingers over an unrelated view.
// Last chat width the user dragged to; reopening display restores it instead
// of resetting to the minimum.
const userChatWidthRef = useRef<number | null>(null);
// Opening display auto-folds the session side panel and collapses chat
// (to the user's remembered width, if they resized before).
useEffect(() => {
if (activeWorkspaceTab !== 'project') {
closeFilePreview();
if (previewOpen) {
setIsSidePanelVisible(false);
setChatWidth(userChatWidthRef.current ?? CHAT_MIN_WIDTH);
}
}, [activeWorkspaceTab, activeProjectId, closeFilePreview]);
}, [previewOpen]);
// Embedded browser guests are `position: fixed` in a separate layer, so the
// panel's clip-path entrance can't clip them. Hold the guest parked until
// the entrance finishes (then it fades in over the settled rect) instead of
// letting the page pop in full-size over the chat mid-animation.
const [displaySettled, setDisplaySettled] = useState(false);
useEffect(() => {
if (!previewOpen) {
setDisplaySettled(false);
return;
}
const timer = window.setTimeout(
() => setDisplaySettled(true),
DISPLAY_PANEL_ANIMATION_MS + 20
);
return () => window.clearTimeout(timer);
}, [previewOpen]);
const handlePreviewResizeStart = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
const rowWidth =
chatRowRef.current?.getBoundingClientRect().width ?? window.innerWidth;
const sidePanelWidth =
document.getElementById('session-side-panel')?.getBoundingClientRect()
.width ?? 0;
// Chat never exceeds its priority max width, and always leaves room for
// the preview's minimum width.
// display's minimum width plus the independent session panel.
const maxChat = Math.max(
CHAT_MIN_WIDTH,
Math.min(CHAT_PRIORITY_WIDTH, rowWidth - PREVIEW_MIN_WIDTH)
Math.min(
CHAT_PRIORITY_WIDTH,
rowWidth - sidePanelWidth - PREVIEW_MIN_WIDTH
)
);
const startX = e.clientX;
const startWidth = chatWidth;
@ -278,6 +308,7 @@ export default function Session({ isNewProject = false }: SessionProps) {
maxChat,
Math.max(CHAT_MIN_WIDTH, startWidth + (ev.clientX - startX))
);
userChatWidthRef.current = next;
setChatWidth(next);
};
const onUp = () => {
@ -303,9 +334,9 @@ export default function Session({ isNewProject = false }: SessionProps) {
setActiveWorkspaceTab('inbox', {
clearInboxForProjectId: activeProjectId ?? null,
});
closeFilePreview();
closeSessionPreview();
},
[selectedTurn, setActiveWorkspaceTab, activeProjectId, closeFilePreview]
[selectedTurn, setActiveWorkspaceTab, activeProjectId, closeSessionPreview]
);
const toggleExpandedOverlay = useCallback(() => {
@ -336,10 +367,10 @@ export default function Session({ isNewProject = false }: SessionProps) {
if (isNewProject) {
return (
<div className="min-h-0 min-w-0 flex h-full w-full flex-1 flex-row overflow-hidden">
<div className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden">
<div className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<HeaderBox empty />
<div className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<Workspace
variant="new-project"
embedded
@ -352,7 +383,7 @@ export default function Session({ isNewProject = false }: SessionProps) {
<div
id="session-side-panel"
className={cn(
'min-h-0 ease-out flex shrink-0 flex-col overflow-hidden transition-[width] duration-200',
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width] duration-200 ease-out',
isSidePanelVisible
? SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS
: cn(SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS, 'rounded-l-xl')
@ -365,59 +396,83 @@ export default function Session({ isNewProject = false }: SessionProps) {
}
return (
<div className="min-h-0 min-w-0 flex h-full w-full flex-1 flex-row overflow-hidden">
<div className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden">
<div
ref={chatRowRef}
className="flex h-full min-h-0 w-full min-w-0 flex-1 flex-row overflow-hidden"
>
{/* Chat content: owns the project header and folds when display opens. */}
<div
style={previewOpen ? { width: chatWidth } : undefined}
className={cn(
'flex min-h-0 min-w-0 flex-col overflow-hidden',
previewOpen ? 'shrink-0' : 'flex-1',
!isResizingPreview && 'transition-[width] duration-200 ease-out'
)}
>
{chatStore.activeTaskId && hasAnyMessages && (
<HeaderBox
totalTokens={chatStore.tasks[chatStore.activeTaskId]?.tokens || 0}
/>
)}
<div
ref={chatRowRef}
className="min-h-0 min-w-0 flex flex-1 flex-row overflow-hidden"
>
<div
style={{ width: filePreviewOpen ? chatWidth : '100%' }}
className={cn(
'min-h-0 flex shrink-0 flex-col overflow-hidden',
!isResizingPreview && 'ease-out transition-[width] duration-200'
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ChatBox />
</div>
</div>
<AnimatePresence initial={false}>
{previewOpen && (
<motion.div
key="session-display-content"
initial={{
clipPath: 'inset(0 0 0 100%)',
opacity: 0,
}}
animate={{
clipPath: 'inset(0 0 0 0%)',
opacity: 1,
flexGrow: 1,
}}
exit={{
clipPath: 'inset(0 0 0 100%)',
opacity: 0,
flexGrow: 0,
}}
transition={{ duration: 0.3, ease: DISPLAY_PANEL_EASE }}
style={{ transformOrigin: 'right center' }}
className="flex min-h-0 min-w-0 flex-1 overflow-hidden"
>
<ChatBox />
</div>
{filePreviewOpen && (
<div
onPointerDown={handlePreviewResizeStart}
role="separator"
aria-orientation="vertical"
data-resize-handle-state={isResizingPreview ? 'drag' : 'inactive'}
className={cn(
// Mirrors the project sidebar ResizableHandle: transparent 2px
// rail with a centered ::after line, brand color on hover/drag.
'hover:bg-ds-bg-brand-subtle-default relative z-10 flex w-[2px] shrink-0 cursor-col-resize items-center justify-center bg-transparent transition-all',
// Widen the pointer hit area without changing the visible width.
"before:inset-y-0 before:-left-1 before:-right-1 before:absolute before:content-['']",
'after:inset-y-0 after:w-1 after:bg-ds-bg-neutral-default-default after:absolute after:left-1/2 after:-translate-x-1/2 after:transition-all',
// Transparent 2px rail with a centered line and wider hit area.
'relative z-10 flex w-[2px] shrink-0 cursor-col-resize items-center justify-center bg-transparent transition-all hover:bg-ds-bg-brand-subtle-default',
"before:absolute before:inset-y-0 before:-left-1 before:-right-1 before:content-['']",
'after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 after:bg-ds-bg-neutral-default-default after:transition-all',
isResizingPreview &&
'bg-ds-bg-brand-subtle-default after:bg-ds-bg-brand-default-focus'
)}
/>
)}
<div className="min-h-0 min-w-0 flex flex-1 flex-col overflow-hidden">
<FilePreview
file={filePreviewFile}
surfaceClassName="bg-ds-bg-neutral-default-default"
onClose={closeFilePreview}
onJumpToContext={handleJumpToContext}
/>
</div>
</div>
</div>
{/* Display content: middle column between chat and session. */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{activeProjectId ? (
<PreviewPanel
displaySettled={displaySettled}
onJumpToContext={handleJumpToContext}
/>
) : null}
</div>
</motion.div>
)}
</AnimatePresence>
<div
id="session-side-panel"
className={cn(
'min-h-0 ease-out flex shrink-0 flex-col overflow-hidden transition-[width] duration-200',
'flex min-h-0 shrink-0 flex-col overflow-hidden transition-[width] duration-200 ease-out',
isSidePanelVisible
? SESSION_SIDE_PANEL_EXPANDED_OUTER_CLASS
: cn(SESSION_SIDE_PANEL_FOLDED_OUTER_CLASS, 'rounded-l-xl')

View file

@ -0,0 +1,190 @@
// ========= 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 { Button } from '@/components/ui/button';
import { TooltipSimple } from '@/components/ui/tooltip';
import { useHost } from '@/host';
import type { ProgressInfo } from 'electron-updater';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
// Survives TopBar remounts so a background download is only auto-started once
// per app session; after that the slot falls back to a manual button.
const AUTO_DOWNLOAD_KEY = 'eigent-update-auto-download-started';
type UpdatePhase = 'idle' | 'available' | 'downloading' | 'downloaded';
/**
* Software-update slot at the left end of the top bar's trailing button group.
* idle (auto background download with inline progress) "Launch new version".
*/
export default function UpdateButton() {
const { t } = useTranslation();
const host = useHost();
const ipc = host?.ipcRenderer;
const [phase, setPhase] = useState<UpdatePhase>('idle');
const [progress, setProgress] = useState(0);
const [failed, setFailed] = useState(false);
const startDownload = useCallback(() => {
setFailed(false);
setProgress(0);
setPhase('downloading');
void ipc?.invoke('start-download');
}, [ipc]);
useEffect(() => {
if (!ipc) return;
const onUpdateCanAvailable = (
_event: Electron.IpcRendererEvent,
info: VersionInfo
) => {
setPhase((current) => {
if (current !== 'idle') return current;
return info.update ? 'available' : 'idle';
});
};
const onDownloadProgress = (
_event: Electron.IpcRendererEvent,
info: ProgressInfo
) => {
setFailed(false);
setProgress(info.percent ?? 0);
setPhase((current) =>
current === 'downloaded' ? current : 'downloading'
);
};
const onUpdateDownloaded = () => {
setPhase('downloaded');
};
const onUpdateError = () => {
// Inline retry affordance instead of a toast; only relevant mid-download.
setPhase((current) => {
if (current !== 'downloading') return current;
setFailed(true);
return 'available';
});
};
ipc.on('update-can-available', onUpdateCanAvailable);
ipc.on('download-progress', onDownloadProgress);
ipc.on('update-downloaded', onUpdateDownloaded);
ipc.on('update-error', onUpdateError);
void ipc.invoke('check-update');
return () => {
ipc.off('update-can-available', onUpdateCanAvailable);
ipc.off('download-progress', onDownloadProgress);
ipc.off('update-downloaded', onUpdateDownloaded);
ipc.off('update-error', onUpdateError);
};
}, [ipc]);
// Auto-start the background download the first time an update is seen.
useEffect(() => {
if (phase !== 'available' || failed) return;
if (sessionStorage.getItem(AUTO_DOWNLOAD_KEY)) return;
sessionStorage.setItem(AUTO_DOWNLOAD_KEY, '1');
startDownload();
}, [phase, failed, startDownload]);
if (phase === 'idle') return null;
if (phase === 'downloading') {
const percent = Math.round(progress);
const label = t('update.downloading', {
defaultValue: 'Downloading update',
});
return (
<TooltipSimple content={label} side="bottom" align="end">
<div
className="no-drag flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2"
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
>
<div className="h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-ds-bg-neutral-strong-default">
<div
className="h-full rounded-full bg-ds-bg-brand-default-default transition-all duration-200 ease-out"
style={{ width: `${percent}%` }}
/>
</div>
<span className="text-xs tabular-nums text-ds-text-neutral-subtle-default">
{percent}%
</span>
</div>
</TooltipSimple>
);
}
if (phase === 'downloaded') {
const label = t('update.launch-new-version', {
defaultValue: 'Launch new version',
});
return (
<TooltipSimple
content={t('update.click-to-install-update', {
defaultValue: 'Click to install update',
})}
side="bottom"
align="end"
>
<Button
type="button"
variant="primary"
size="sm"
className="no-drag shrink-0 rounded-full px-3"
onClick={() => void ipc?.invoke('quit-and-install')}
aria-label={label}
>
{label}
</Button>
</TooltipSimple>
);
}
// 'available' after the session already auto-downloaded once, or after a
// download error: manual (re)start.
const label = t('layout.update', { defaultValue: 'Update' });
return (
<TooltipSimple
content={
failed
? t('update.update-failed-retry', {
defaultValue: 'Update failed — click to retry',
})
: label
}
side="bottom"
align="end"
>
<Button
type="button"
variant="primary"
size="sm"
className="no-drag shrink-0 rounded-full px-3"
onClick={startDownload}
aria-label={label}
>
{label}
</Button>
</TooltipSimple>
);
}

View file

@ -21,6 +21,7 @@ import { type HistoryTabId } from '@/components/Dashboard/HistoryTabsNav';
import InviteCodeDialog from '@/components/Dialog/InviteCodeDialog';
import ReportBugDialog from '@/components/Dialog/ReportBugDialog';
import { SpaceSwitchDropdown } from '@/components/ProjectPageSidebar/SpaceSwitchDropdown';
import UpdateButton from '@/components/TopBar/UpdateButton';
import AlertDialog from '@/components/ui/alertDialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -167,8 +168,6 @@ function HeaderWin() {
const appearance = useAuthStore((state) => state.appearance);
const email = useAuthStore((s) => s.email);
const userId = useAuthStore((s) => s.user_id);
const [packageUpdateAvailable, setPackageUpdateAvailable] = useState(false);
const ipcRenderer = host?.ipcRenderer;
const { isInstalling, installationState } = useInstallationUI();
const _isInstallationActive =
isInstalling || installationState === 'waiting-backend';
@ -179,35 +178,6 @@ function HeaderWin() {
setPlatform(p);
}, [host]);
useEffect(() => {
const ipc = ipcRenderer;
if (!ipc) return;
const onUpdateCanAvailable = (
_event: Electron.IpcRendererEvent,
info: VersionInfo
) => {
setPackageUpdateAvailable(Boolean(info.update));
};
const onUpdateDownloaded = () => {
setPackageUpdateAvailable(false);
};
ipc.on('update-can-available', onUpdateCanAvailable);
ipc.on('update-downloaded', onUpdateDownloaded);
void ipc.invoke('check-update');
return () => {
ipc.off('update-can-available', onUpdateCanAvailable);
ipc.off('update-downloaded', onUpdateDownloaded);
};
}, [ipcRenderer]);
const handleStartDownload = useCallback(() => {
void ipcRenderer?.invoke('start-download');
}, [ipcRenderer]);
const isHistoryRoute = useMemo(() => {
const path = location.pathname.replace(/\/$/, '') || '/';
return path === '/history' || path.endsWith('/history');
@ -639,7 +609,9 @@ function HeaderWin() {
platform === 'darwin' && 'px-1.5'
} no-drag relative z-50 flex h-7 shrink-0 items-center`}
>
<div className="flex h-full shrink-0 items-center">
<div className="flex h-full shrink-0 items-center gap-0.5">
{/* Update slot: hidden → background download progress → launch new version */}
<UpdateButton />
<TooltipSimple
content={t('layout.support')}
side="bottom"
@ -741,25 +713,6 @@ function HeaderWin() {
</motion.div>
)}
</AnimatePresence>
{packageUpdateAvailable && (
<TooltipSimple
content={t('layout.update')}
side="bottom"
align="end"
variant="instant"
>
<Button
type="button"
variant="primary"
size="sm"
className="no-drag shrink-0 rounded-full px-3"
onClick={handleStartDownload}
aria-label={t('layout.update')}
>
{t('layout.update')}
</Button>
</TooltipSimple>
)}
</div>
</div>
</div>

View file

@ -12,7 +12,7 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { Bird, CodeXml, FileText, Globe, Image } from 'lucide-react';
import { Bird, Bot, CodeXml, FileText, Globe, Image } from 'lucide-react';
import type { ReactNode } from 'react';
export type WorkflowAgentType =
@ -22,6 +22,11 @@ export type WorkflowAgentType =
| 'multi_modal_agent'
| 'social_media_agent';
/** Backend agent name used by Single Agent SkillToolkit (`Agents.single_agent`). */
export const SINGLE_AGENT_ID = 'single_agent' as const;
export type SkillScopeAgentType = WorkflowAgentType | typeof SINGLE_AGENT_ID;
export interface AgentDisplayInfo {
name: string;
icon: ReactNode;
@ -132,12 +137,95 @@ export const WORKFLOW_AGENT_LIST: {
},
];
/** Get display info (name + icon) by agent name; returns undefined if not a workflow agent. */
/**
* Agents shown in Skills "Select agent access".
* Always includes Single Agent first so skill scope can target `single_agent`
* the same way backend SkillToolkit / Agents.single_agent does.
*/
export const SKILL_SCOPE_AGENT_LIST: {
id: SkillScopeAgentType;
name: string;
icon: ReactNode;
}[] = [
{
id: SINGLE_AGENT_ID,
// Product label (layout.workspace-session-single-agent). Value stays `single_agent`.
name: 'Single Agent',
icon: <Bot size={16} className="text-ds-text-neutral-default-default" />,
},
...WORKFLOW_AGENT_LIST,
];
export type SkillScopeAgentOption = {
value: string;
label: string;
};
/**
* Canonicalize skill-scope agent ids so UI + backend agree.
* Accepts legacy aliases such as `Agents.single_agent`.
*/
export function normalizeSkillScopeAgentId(agentName?: string | null): string {
const raw = String(agentName ?? '').trim();
if (!raw) return '';
const lowered = raw.toLowerCase();
if (lowered === SINGLE_AGENT_ID || lowered === 'agents.single_agent') {
return SINGLE_AGENT_ID;
}
return raw;
}
/**
* Build the Skills page agent-access options:
* Single Agent (stable) + workforce workers + custom workers (deduped by value).
*/
export function buildSkillScopeAgentOptions(
workerList: Array<{ name: string }> = []
): SkillScopeAgentOption[] {
const baseAgents: SkillScopeAgentOption[] = SKILL_SCOPE_AGENT_LIST.filter(
(a) => a.id !== 'social_media_agent'
).map((a) => ({ value: a.id, label: a.name }));
// Guarantee Single Agent stays first even if list construction changes later.
const combined: SkillScopeAgentOption[] = [];
const seen = new Set<string>();
for (const agent of baseAgents) {
const value = normalizeSkillScopeAgentId(agent.value) || agent.value;
if (seen.has(value)) continue;
seen.add(value);
combined.push({ value, label: agent.label });
}
for (const worker of workerList) {
const value = normalizeSkillScopeAgentId(worker.name);
if (!value || seen.has(value)) continue;
seen.add(value);
const label =
value === SINGLE_AGENT_ID
? 'Single Agent'
: String(worker.name || value).trim() || value;
combined.push({ value, label });
}
// Last-resort: if somehow dropped, prepend Single Agent.
if (!combined.some((agent) => agent.value === SINGLE_AGENT_ID)) {
combined.unshift({ value: SINGLE_AGENT_ID, label: 'Single Agent' });
} else if (combined[0]?.value !== SINGLE_AGENT_ID) {
const single = combined.find((agent) => agent.value === SINGLE_AGENT_ID)!;
combined.splice(combined.indexOf(single), 1);
combined.unshift(single);
}
return combined;
}
/** Get display info (name + icon) by agent name; returns undefined if unknown. */
export function getWorkflowAgentDisplay(
agentName: string
): { name: string; icon: ReactNode } | undefined {
const entry = WORKFLOW_AGENT_LIST.find(
(a) => a.id.toLowerCase() === agentName.toLowerCase()
const normalized = normalizeSkillScopeAgentId(agentName);
const entry = SKILL_SCOPE_AGENT_LIST.find(
(a) => a.id.toLowerCase() === normalized.toLowerCase()
);
if (!entry) return undefined;
return { name: entry.name, icon: entry.icon };

View file

@ -63,6 +63,8 @@ export interface ProjectModeToggleProps {
* bar (see model read-only chip).
*/
readOnly?: boolean;
/** When true, hides the text label and shows only the leading icon (narrow footer). */
compact?: boolean;
}
export function ProjectModeToggle({
@ -70,6 +72,7 @@ export function ProjectModeToggle({
onValueChange,
className,
readOnly = false,
compact = false,
}: ProjectModeToggleProps) {
const { t } = useTranslation();
const chevronScale = useAnimationControls();
@ -82,6 +85,7 @@ export function ProjectModeToggle({
const isSingle = value === SessionMode.SINGLE_AGENT;
const nextMode = isSingle ? SessionMode.WORKFORCE : SessionMode.SINGLE_AGENT;
const label = isSingle ? labelSingle : labelWorkforce;
const LeadingIcon = isSingle ? Joystick : Gamepad2;
const toggle = () => onValueChange(nextMode);
@ -92,8 +96,6 @@ export function ProjectModeToggle({
})();
}, [chevronScale]);
const LeadingIcon = isSingle ? Joystick : Gamepad2;
const shellClass = cn(
'rounded-xl px-2 py-1 inline-flex items-center gap-1.5',
'bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default',
@ -116,7 +118,8 @@ export function ProjectModeToggle({
return (
<div
role="status"
aria-label={modeAriaLabel}
aria-label={`${modeAriaLabel}: ${label}`}
title={compact ? label : undefined}
className={cn(shellClass, 'pointer-events-none bg-transparent')}
>
<span className="inline-flex min-h-[1.25rem] items-center gap-1.5 overflow-hidden">
@ -125,7 +128,9 @@ export function ProjectModeToggle({
strokeWidth={2}
aria-hidden
/>
<span className="!text-label-xs font-semibold">{label}</span>
{!compact && (
<span className="!text-label-xs font-semibold">{label}</span>
)}
</span>
</div>
);
@ -137,10 +142,11 @@ export function ProjectModeToggle({
<motion.button
type="button"
aria-label={`${modeAriaLabel}: ${label}. ${cycleHint}`}
title={compact ? label : undefined}
className={cn(
shellClass,
'cursor-pointer border-0 text-left',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-border-neutral-strong-default focus-visible:ring-offset-2 focus-visible:ring-offset-ds-bg-neutral-default-default'
'hover:bg-ds-bg-neutral-subtle-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ds-border-neutral-strong-default focus-visible:ring-offset-2 focus-visible:ring-offset-ds-bg-neutral-default-default'
)}
onPointerDown={pulseChevrons}
onClick={toggle}
@ -160,9 +166,11 @@ export function ProjectModeToggle({
strokeWidth={2}
aria-hidden
/>
<span className="whitespace-nowrap !text-label-xs font-semibold">
{label}
</span>
{!compact && (
<span className="whitespace-nowrap !text-label-xs font-semibold">
{label}
</span>
)}
</motion.span>
</AnimatePresence>
</span>

View file

@ -390,9 +390,6 @@ export default function Workspace({
'Legacy Spaces are read-only. Create a new Space to start a Project.',
})
: t('layout.project-task-placeholder'),
sessionMode: effectiveSessionMode,
onSessionModeChange: setActiveProjectMode,
sessionModeSelectInteractive: true,
});
const taskAssigning =
@ -515,6 +512,9 @@ export default function Workspace({
noModelOverlay={!hasModel}
onSelectModel={() => navigate('/history?tab=agents')}
inputProps={buildComposerInputProps()}
sessionMode={effectiveSessionMode}
onSessionModeChange={setActiveProjectMode}
sessionModeSelectInteractive
/>
</div>
<AddWorker

View file

@ -13,6 +13,7 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { useTheme } from 'next-themes';
import { createPortal } from 'react-dom';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
@ -49,7 +50,7 @@ const FOLD_AT_THREE_CSS = `
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
const toaster = (
<>
<style>{FOLD_AT_THREE_CSS}</style>
<Sonner
@ -70,6 +71,12 @@ const Toaster = ({ ...props }: ToasterProps) => {
/>
</>
);
// Render into <body>, not inside #root. #root has `backdrop-filter`, which
// creates a stacking context that would trap toasts *below* dialog/overlay
// portals (also body-level) no matter how high their z-index is.
if (typeof document === 'undefined') return toaster;
return createPortal(toaster, document.body);
};
export { Toaster };

View file

@ -1,114 +0,0 @@
// ========= 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 { Progress } from '@/components/ui/progress';
import { useHost } from '@/host';
import type { ProgressInfo } from 'electron-updater';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
const Update = () => {
const [downloadProgress, setDownloadProgress] = useState<number>(0);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const { t } = useTranslation();
const host = useHost();
const ipc = host?.ipcRenderer;
const checkUpdate = useCallback(() => {
void ipc?.invoke('check-update');
}, [ipc]);
const onUpdateError = useCallback(
(_event: Electron.IpcRendererEvent, err: ErrorType) => {
toast.dismiss('download-progress');
setIsDownloading(false);
setDownloadProgress(0);
toast.error(t('update.update-error'), {
description: err.message,
});
},
[t]
);
const onDownloadProgress = useCallback(
(_event: Electron.IpcRendererEvent, progress: ProgressInfo) => {
setIsDownloading(true);
setDownloadProgress(progress.percent ?? 0);
},
[]
);
// listen to download progress and update toast
useEffect(() => {
if (isDownloading) {
toast.custom(
(_toastId) => (
<div className="rounded-lg bg-ds-bg-neutral-inverse-default p-4 shadow-lg w-[300px]">
<div className="mb-2 text-sm font-medium">
{t('update.downloading-update')}
</div>
<Progress value={downloadProgress} className="mb-2" />
<div className="text-xs text-gray-500">
{Math.round(downloadProgress)}% {t('update.complete')}
</div>
</div>
),
{
id: 'download-progress',
duration: Infinity,
position: 'bottom-right',
}
);
}
}, [downloadProgress, isDownloading, t]);
const onUpdateDownloaded = useCallback(
(_event: Electron.IpcRendererEvent) => {
toast.dismiss('download-progress');
setIsDownloading(false);
toast.success(t('update.download-completed'), {
description: t('update.click-to-install-update'),
action: {
label: t('update.install'),
onClick: () => void ipc?.invoke('quit-and-install'),
},
duration: Infinity,
});
},
[t, ipc]
);
useEffect(() => {
if (sessionStorage.getItem('updateElectronShown')) {
return;
}
sessionStorage.setItem('updateElectronShown', '1');
ipc?.on('update-error', onUpdateError);
ipc?.on('download-progress', onDownloadProgress);
ipc?.on('update-downloaded', onUpdateDownloaded);
checkUpdate();
return () => {
ipc?.off('update-error', onUpdateError);
ipc?.off('download-progress', onDownloadProgress);
ipc?.off('update-downloaded', onUpdateDownloaded);
};
}, [ipc, onUpdateError, onDownloadProgress, onUpdateDownloaded, checkUpdate]);
return null;
};
export default Update;

View file

@ -0,0 +1,43 @@
// ========= 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 { useEffect, useRef, useState } from 'react';
/**
* Tracks whether an element's own width has dropped below `threshold` px, using
* a ResizeObserver so it reacts to container (not viewport) resizes e.g. side
* panels opening beside the chat composer. Returns a ref to attach and the
* current compact flag; state only flips when it crosses the threshold, so it
* won't re-render on every resize tick.
*/
export function useIsCompactWidth<T extends HTMLElement>(threshold: number) {
const ref = useRef<T>(null);
const [compact, setCompact] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const update = (width: number) => setCompact(width < threshold);
update(el.getBoundingClientRect().width);
const observer = new ResizeObserver((entries) => {
for (const entry of entries) update(entry.contentRect.width);
});
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return [ref, compact] as const;
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "فتح متصفح",
"input-attach-manage-browsers": "إدارة المتصفحات",
"input-attach-menu-trigger": "إضافة ملفات أو صور، أو فتح المهارات أو الموصلات أو المتصفح من القائمة",
"input-attach-connectors": "الموصلات",
"input-add-connector": "إضافة موصلات",
"input-add-skill": "إضافة مهارات",
"no-connectors-added": "لم تتم إضافة أي موصلات بعد",
"no-skills-added": "لم تتم إضافة أي مهارات بعد",
"subtasks-planning": "تخطيط المهام الفرعية",
"expand-plan": "توسيع الخطة",
"minimize-plan": "تصغير الخطة",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "خطوط",
"onboarding-setup-bg-dotted": "منقط",
"onboarding-setup-bg-dashed": "متقطع",
"onboarding-setup-get-started": "ابدأ الآن"
"onboarding-setup-get-started": "ابدأ الآن",
"thinking-effort-label": "مستوى التفكير",
"thinking-effort-light": "خفيف",
"thinking-effort-medium": "متوسط",
"thinking-effort-high": "مرتفع",
"thinking-effort-extra_high": "مرتفع جدًا",
"thinking-effort-ultra": "فائق",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -198,6 +198,9 @@
"api-key-setting": "إعداد مفتاح API",
"api-host-setting": "إعداد مضيف API",
"model-type-setting": "إعداد نوع النموذج",
"model-parameters-setting": "معلمات النموذج (JSON، اختياري)",
"model-parameters-placeholder": "أدخل معلمات النموذج ككائن JSON، مثال: {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "يجب أن تكون معلمات النموذج كائن JSON صالحًا.",
"please-select": "يرجى الاختيار",
"configuring": "جارٍ التكوين...",

View file

@ -8,5 +8,8 @@
"complete": "مكتمل",
"download-completed": "اكتمل التنزيل",
"click-to-install-update": "انقر لتثبيت التحديث",
"install": "تثبيت"
"install": "تثبيت",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "Browser öffnen",
"input-attach-manage-browsers": "Browser verwalten",
"input-attach-menu-trigger": "Dateien oder Fotos hinzufügen oder Skills, Connectors oder Browser über das Menü öffnen",
"input-attach-connectors": "Connectors",
"input-add-connector": "Connectors hinzufügen",
"input-add-skill": "Skills hinzufügen",
"no-connectors-added": "Noch keine Connectors hinzugefügt",
"no-skills-added": "Noch keine Skills hinzugefügt",
"subtasks-planning": "Teilaufgabenplanung",
"expand-plan": "Plan erweitern",
"minimize-plan": "Plan minimieren",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "Liniert",
"onboarding-setup-bg-dotted": "Gepunktet",
"onboarding-setup-bg-dashed": "Gestrichelt",
"onboarding-setup-get-started": "Loslegen"
"onboarding-setup-get-started": "Loslegen",
"thinking-effort-label": "Denkaufwand",
"thinking-effort-light": "Niedrig",
"thinking-effort-medium": "Mittel",
"thinking-effort-high": "Hoch",
"thinking-effort-extra_high": "Sehr hoch",
"thinking-effort-ultra": "Ultra",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -300,6 +300,9 @@
"api-key-setting": "API-Schlüssel-Einstellung",
"api-host-setting": "API-Host-Einstellung",
"model-type-setting": "Modelltyp-Einstellung",
"model-parameters-setting": "Modellparameter (JSON, optional)",
"model-parameters-placeholder": "Modellparameter als JSON-Objekt eingeben, z. B. {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "Modellparameter müssen ein gültiges JSON-Objekt sein.",
"please-select": "Bitte auswählen",
"configuring": "Wird konfiguriert...",

View file

@ -8,5 +8,8 @@
"complete": "Abgeschlossen",
"download-completed": "Download abgeschlossen",
"click-to-install-update": "Klicken Sie, um das Update zu installieren",
"install": "Installieren"
"install": "Installieren",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "Open a browser",
"input-attach-manage-browsers": "Manage browsers",
"input-attach-menu-trigger": "Add files or photos, or open Skills, Connectors, or Browser from the menu",
"input-attach-connectors": "Connectors",
"input-add-connector": "Add connectors",
"input-add-skill": "Add skills",
"no-connectors-added": "No connectors added yet",
"no-skills-added": "No skills added yet",
"subtasks-planning": "Subtasks Planning",
"expand-plan": "Expand plan",
"minimize-plan": "Minimize plan",

View file

@ -96,6 +96,15 @@
"notifications-empty": "No notifications yet.",
"notification-panel-dismiss": "Dismiss",
"support": "Support",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log.",
"log-file-empty": "Log file is empty",
"new": "New",
"new-project": "New Project",
@ -514,5 +523,11 @@
"onboarding-setup-bg-ruled": "Ruled",
"onboarding-setup-bg-dotted": "Dotted",
"onboarding-setup-bg-dashed": "Dashed",
"onboarding-setup-get-started": "Get Started"
"onboarding-setup-get-started": "Get Started",
"thinking-effort-label": "Thinking effort",
"thinking-effort-light": "Light",
"thinking-effort-medium": "Medium",
"thinking-effort-high": "High",
"thinking-effort-extra_high": "Extra High",
"thinking-effort-ultra": "Ultra"
}

View file

@ -226,6 +226,9 @@
"api-key-setting": "API Key Setting",
"api-host-setting": "API Host Setting",
"model-type-setting": "Model Type Setting",
"model-parameters-setting": "Model Parameters (JSON) (Optional)",
"model-parameters-placeholder": "Enter model parameters as a JSON object, e.g. {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "Model parameters must be a valid JSON object.",
"please-select": "Please select",
"configuring": "Configuring...",

View file

@ -8,5 +8,8 @@
"complete": "Complete",
"download-completed": "Download completed",
"click-to-install-update": "Click to install update",
"install": "Install"
"install": "Install",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "Abrir un navegador",
"input-attach-manage-browsers": "Gestionar navegadores",
"input-attach-menu-trigger": "Añadir archivos o fotos o abrir Habilidades, Conectores o Navegador desde el menú",
"input-attach-connectors": "Conectores",
"input-add-connector": "Añadir conectores",
"input-add-skill": "Añadir habilidades",
"no-connectors-added": "Aún no se han añadido conectores",
"no-skills-added": "Aún no se han añadido habilidades",
"subtasks-planning": "Planificación de subtareas",
"expand-plan": "Expandir plan",
"minimize-plan": "Minimizar plan",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "Rayado",
"onboarding-setup-bg-dotted": "Punteado",
"onboarding-setup-bg-dashed": "Discontinuo",
"onboarding-setup-get-started": "Empezar"
"onboarding-setup-get-started": "Empezar",
"thinking-effort-label": "Esfuerzo de razonamiento",
"thinking-effort-light": "Bajo",
"thinking-effort-medium": "Medio",
"thinking-effort-high": "Alto",
"thinking-effort-extra_high": "Muy alto",
"thinking-effort-ultra": "Ultra",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -300,6 +300,9 @@
"api-key-setting": "Configuración de clave API",
"api-host-setting": "Configuración de host API",
"model-type-setting": "Configuración de tipo de modelo",
"model-parameters-setting": "Parámetros del modelo (JSON, opcional)",
"model-parameters-placeholder": "Introduce los parámetros del modelo como un objeto JSON, p. ej. {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "Los parámetros del modelo deben ser un objeto JSON válido.",
"please-select": "Por favor seleccione",
"configuring": "Configurando...",

View file

@ -8,5 +8,8 @@
"complete": "Completo",
"download-completed": "Descarga completada",
"click-to-install-update": "Click para instalar actualización",
"install": "Instalar"
"install": "Instalar",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "Ouvrir un navigateur",
"input-attach-manage-browsers": "Gérer les navigateurs",
"input-attach-menu-trigger": "Ajouter fichiers ou photos ou ouvrir Compétences, Connecteurs ou Navigateur depuis le menu",
"input-attach-connectors": "Connecteurs",
"input-add-connector": "Ajouter des connecteurs",
"input-add-skill": "Ajouter des compétences",
"no-connectors-added": "Aucun connecteur ajouté pour le moment",
"no-skills-added": "Aucune compétence ajoutée pour le moment",
"subtasks-planning": "Planification des sous-tâches",
"expand-plan": "Développer le plan",
"minimize-plan": "Réduire le plan",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "Ligné",
"onboarding-setup-bg-dotted": "Pointillé",
"onboarding-setup-bg-dashed": "Tirets",
"onboarding-setup-get-started": "Commencer"
"onboarding-setup-get-started": "Commencer",
"thinking-effort-label": "Effort de réflexion",
"thinking-effort-light": "Léger",
"thinking-effort-medium": "Moyen",
"thinking-effort-high": "Élevé",
"thinking-effort-extra_high": "Très élevé",
"thinking-effort-ultra": "Ultra",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -300,6 +300,9 @@
"api-key-setting": "Paramètre de clé API",
"api-host-setting": "Paramètre d'hôte API",
"model-type-setting": "Paramètre de type de modèle",
"model-parameters-setting": "Paramètres du modèle (JSON, facultatif)",
"model-parameters-placeholder": "Saisissez les paramètres du modèle sous forme dobjet JSON, par ex. {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "Les paramètres du modèle doivent être un objet JSON valide.",
"please-select": "Veuillez sélectionner",
"configuring": "Configuration...",

View file

@ -8,5 +8,8 @@
"complete": "Terminé",
"download-completed": "Téléchargement terminé",
"click-to-install-update": "Cliquez pour installer la mise à jour",
"install": "Installer"
"install": "Installer",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "Apri un browser",
"input-attach-manage-browsers": "Gestisci i browser",
"input-attach-menu-trigger": "Aggiungi file o foto o apri Skill, Connettori o Browser dal menu",
"input-attach-connectors": "Connettori",
"input-add-connector": "Aggiungi connettori",
"input-add-skill": "Aggiungi skill",
"no-connectors-added": "Nessun connettore aggiunto",
"no-skills-added": "Nessuna skill aggiunta",
"subtasks-planning": "Pianificazione delle sottoattività",
"expand-plan": "Espandi piano",
"minimize-plan": "Riduci piano",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "Righe",
"onboarding-setup-bg-dotted": "Punteggiato",
"onboarding-setup-bg-dashed": "Tratteggiato",
"onboarding-setup-get-started": "Inizia"
"onboarding-setup-get-started": "Inizia",
"thinking-effort-label": "Impegno di ragionamento",
"thinking-effort-light": "Basso",
"thinking-effort-medium": "Medio",
"thinking-effort-high": "Alto",
"thinking-effort-extra_high": "Molto alto",
"thinking-effort-ultra": "Ultra",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

View file

@ -300,6 +300,9 @@
"api-key-setting": "Impostazione chiave API",
"api-host-setting": "Impostazione host API",
"model-type-setting": "Impostazione tipo di modello",
"model-parameters-setting": "Parametri del modello (JSON, facoltativo)",
"model-parameters-placeholder": "Inserisci i parametri del modello come oggetto JSON, ad es. {\"temperature\": 0.7, \"top_p\": 1, \"max_tokens\": 4096}",
"model-parameters-must-be-valid-json-object": "I parametri del modello devono essere un oggetto JSON valido.",
"please-select": "Seleziona",
"configuring": "Configurazione in corso...",

View file

@ -8,5 +8,8 @@
"complete": "Completo",
"download-completed": "Download completato",
"click-to-install-update": "Clicca per installare l'aggiornamento",
"install": "Installa"
"install": "Installa",
"downloading": "Downloading update",
"launch-new-version": "Launch new version",
"update-failed-retry": "Update failed — click to retry"
}

View file

@ -103,6 +103,11 @@
"input-attach-open-browser": "ブラウザを開く",
"input-attach-manage-browsers": "ブラウザを管理",
"input-attach-menu-trigger": "ファイル・写真の追加、またはメニューからスキル・コネクタ・ブラウザを開く",
"input-attach-connectors": "コネクタ",
"input-add-connector": "コネクタを追加",
"input-add-skill": "スキルを追加",
"no-connectors-added": "コネクタはまだ追加されていません",
"no-skills-added": "スキルはまだ追加されていません",
"subtasks-planning": "サブタスクの計画",
"expand-plan": "計画を展開",
"minimize-plan": "計画を最小化",

View file

@ -514,5 +514,20 @@
"onboarding-setup-bg-ruled": "罫線",
"onboarding-setup-bg-dotted": "点線",
"onboarding-setup-bg-dashed": "破線",
"onboarding-setup-get-started": "はじめる"
"onboarding-setup-get-started": "はじめる",
"thinking-effort-label": "思考強度",
"thinking-effort-light": "軽度",
"thinking-effort-medium": "中程度",
"thinking-effort-high": "高度",
"thinking-effort-extra_high": "非常に高い",
"thinking-effort-ultra": "ウルトラ",
"support-logs-heading": "Download logs",
"support-eigent-log": "Eigent log",
"support-eigent-log-desc": "Desktop app logs",
"support-camel-log": "Camel log",
"support-camel-log-desc": "Agent task logs (CAMEL backend)",
"support-download": "Download",
"support-log-saved": "Log saved to {{path}}",
"support-log-empty": "No log files found yet.",
"support-log-export-failed": "Could not export the log."
}

Some files were not shown because too many files have changed in this diff Show more