mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-02 13:03:44 +00:00
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:
commit
ff57b79bdf
146 changed files with 8101 additions and 984 deletions
14
.github/PULL_REQUEST_TEMPLATE.md
vendored
14
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
8
backend/uv.lock
generated
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
23
electron/main/utils/filePath.ts
Normal file
23
electron/main/utils/filePath.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ class LoginByPasswordIn(BaseModel):
|
|||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
email: EmailStr
|
||||
user_id: int | None = None
|
||||
redirect_url: str | None = None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@
|
|||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestChatShareSecretKey:
|
||||
"""Tests for ChatShare secret key generation.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
173
src/components/ChatBox/BottomBox/ApprovalModeSelect.tsx
Normal file
173
src/components/ChatBox/BottomBox/ApprovalModeSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/components/ChatBox/BottomBox/BoxFooter.tsx
Normal file
80
src/components/ChatBox/BottomBox/BoxFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
358
src/components/ChatBox/BottomBox/PickerPanel.tsx
Normal file
358
src/components/ChatBox/BottomBox/PickerPanel.tsx
Normal 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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
147
src/components/ChatBox/BottomBox/ThinkingEffortSelect.tsx
Normal file
147
src/components/ChatBox/BottomBox/ThinkingEffortSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
287
src/components/Session/PreviewPanel/index.tsx
Normal file
287
src/components/Session/PreviewPanel/index.tsx
Normal 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;
|
||||
77
src/components/Session/PreviewPanel/tabKinds.tsx
Normal file
77
src/components/Session/PreviewPanel/tabKinds.tsx
Normal 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];
|
||||
}
|
||||
91
src/components/Session/PreviewPanel/tabs/CanvasTab.tsx
Normal file
91
src/components/Session/PreviewPanel/tabs/CanvasTab.tsx
Normal 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;
|
||||
84
src/components/Session/PreviewPanel/tabs/ChooserTab.tsx
Normal file
84
src/components/Session/PreviewPanel/tabs/ChooserTab.tsx
Normal 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;
|
||||
35
src/components/Session/PreviewPanel/tabs/FileTab.tsx
Normal file
35
src/components/Session/PreviewPanel/tabs/FileTab.tsx
Normal 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;
|
||||
25
src/components/Session/PreviewPanel/tabs/ReviewTab.tsx
Normal file
25
src/components/Session/PreviewPanel/tabs/ReviewTab.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
297
src/components/Session/PreviewPanel/tabs/browser/BrowserTab.tsx
Normal file
297
src/components/Session/PreviewPanel/tabs/browser/BrowserTab.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
190
src/components/TopBar/UpdateButton.tsx
Normal file
190
src/components/TopBar/UpdateButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
43
src/hooks/useIsCompactWidth.ts
Normal file
43
src/hooks/useIsCompactWidth.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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": "تصغير الخطة",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "جارٍ التكوين...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 d’objet 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...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "計画を最小化",
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue