mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 14:28:38 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40e95ba0da |
+103
-2045
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,804 @@
|
|||||||
|
"""Capability settings domain logic for Web, media, network, and API features."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypedDict
|
||||||
|
|
||||||
|
from nanobot.agent.tools.web import SEARCH_PROVIDER_OPTIONS
|
||||||
|
from nanobot.api.runtime import ApiRuntime, ApiStartOptions
|
||||||
|
from nanobot.audio.transcription import resolve_transcription_config
|
||||||
|
from nanobot.audio.transcription_registry import (
|
||||||
|
resolve_transcription_provider,
|
||||||
|
transcription_provider_names,
|
||||||
|
)
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.optional_features import (
|
||||||
|
OptionalFeatureError,
|
||||||
|
extra_installed,
|
||||||
|
optional_dependency_groups,
|
||||||
|
)
|
||||||
|
from nanobot.providers.image_generation import (
|
||||||
|
get_image_gen_provider,
|
||||||
|
image_gen_provider_names,
|
||||||
|
)
|
||||||
|
from nanobot.providers.registry import find_by_name
|
||||||
|
from nanobot.security.network import is_loopback_host
|
||||||
|
from nanobot.webui.settings_contracts import (
|
||||||
|
QueryParams,
|
||||||
|
SettingsRequest,
|
||||||
|
SettingsRouteResult,
|
||||||
|
WebUISettingsError,
|
||||||
|
parse_bool,
|
||||||
|
query_first,
|
||||||
|
query_first_alias,
|
||||||
|
)
|
||||||
|
from nanobot.webui.settings_models import (
|
||||||
|
OAuthStatusReader,
|
||||||
|
mask_secret_hint,
|
||||||
|
provider_configured_for_settings,
|
||||||
|
)
|
||||||
|
from nanobot.webui.workspaces import (
|
||||||
|
read_webui_default_access_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
|
SettingsOperation = Callable[..., dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CapabilitySettingsOperations:
|
||||||
|
update_web_search: SettingsOperation
|
||||||
|
update_api: SettingsOperation
|
||||||
|
update_image: SettingsOperation
|
||||||
|
update_transcription: SettingsOperation
|
||||||
|
update_network: SettingsOperation
|
||||||
|
nanobot_features_action: SettingsOperation
|
||||||
|
api_runtime: Callable[[], ApiRuntime]
|
||||||
|
reload_image: Callable[[], Awaitable[dict[str, Any]]]
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilitySettingsPayload(TypedDict):
|
||||||
|
web_search: dict[str, Any]
|
||||||
|
web: dict[str, Any]
|
||||||
|
api: dict[str, Any]
|
||||||
|
observability: dict[str, Any]
|
||||||
|
image_generation: dict[str, Any]
|
||||||
|
transcription: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
_WEB_SEARCH_PROVIDER_OPTIONS = SEARCH_PROVIDER_OPTIONS
|
||||||
|
_WEB_SEARCH_PROVIDER_BY_NAME = {
|
||||||
|
provider["name"]: provider for provider in _WEB_SEARCH_PROVIDER_OPTIONS
|
||||||
|
}
|
||||||
|
_IMAGE_GENERATION_ASPECT_RATIOS = {
|
||||||
|
"1:1",
|
||||||
|
"3:4",
|
||||||
|
"9:16",
|
||||||
|
"4:3",
|
||||||
|
"16:9",
|
||||||
|
"3:2",
|
||||||
|
"2:3",
|
||||||
|
"21:9",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _image_generation_provider_rows(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
oauth_status: OAuthStatusReader,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for name in image_gen_provider_names():
|
||||||
|
image_provider = get_image_gen_provider(name)
|
||||||
|
spec = find_by_name(name)
|
||||||
|
provider_config = getattr(config.providers, name, None)
|
||||||
|
configured = (
|
||||||
|
provider_configured_for_settings(spec, provider_config, oauth_status)
|
||||||
|
if spec is not None and provider_config is not None
|
||||||
|
else bool(getattr(provider_config, "api_key", None))
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"label": spec.label if spec is not None else name,
|
||||||
|
"configured": configured,
|
||||||
|
"auth_type": "oauth" if spec is not None and spec.is_oauth else "api_key",
|
||||||
|
"api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)),
|
||||||
|
"api_base": getattr(provider_config, "api_base", None),
|
||||||
|
"default_api_base": (
|
||||||
|
spec.default_api_base if spec and spec.default_api_base else None
|
||||||
|
),
|
||||||
|
"models": list(image_provider.model_options) if image_provider else [],
|
||||||
|
"default_model": (
|
||||||
|
image_provider.model_options[0]
|
||||||
|
if image_provider and image_provider.model_options
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _transcription_provider_rows(config: Config) -> list[dict[str, Any]]:
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for name in transcription_provider_names():
|
||||||
|
spec = find_by_name(name)
|
||||||
|
provider_config = getattr(config.providers, name, None)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"label": spec.label if spec is not None else name,
|
||||||
|
"configured": bool(getattr(provider_config, "api_key", None)),
|
||||||
|
"api_key_hint": mask_secret_hint(getattr(provider_config, "api_key", None)),
|
||||||
|
"api_base": getattr(provider_config, "api_base", None),
|
||||||
|
"default_api_base": (
|
||||||
|
spec.default_api_base if spec and spec.default_api_base else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def capability_settings_payload(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
oauth_status: OAuthStatusReader,
|
||||||
|
) -> CapabilitySettingsPayload:
|
||||||
|
search_config = config.tools.web.search
|
||||||
|
image_config = config.tools.image_generation
|
||||||
|
transcription = resolve_transcription_config(config)
|
||||||
|
search_provider = (
|
||||||
|
search_config.provider
|
||||||
|
if search_config.provider in _WEB_SEARCH_PROVIDER_BY_NAME
|
||||||
|
else "duckduckgo"
|
||||||
|
)
|
||||||
|
image_providers = _image_generation_provider_rows(config, oauth_status=oauth_status)
|
||||||
|
selected_image_provider = next(
|
||||||
|
(
|
||||||
|
provider
|
||||||
|
for provider in image_providers
|
||||||
|
if provider["name"] == image_config.provider
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"web_search": {
|
||||||
|
"provider": search_provider,
|
||||||
|
"api_key_hint": mask_secret_hint(search_config.api_key),
|
||||||
|
"base_url": search_config.base_url or None,
|
||||||
|
"max_results": search_config.max_results,
|
||||||
|
"timeout": search_config.timeout,
|
||||||
|
"providers": list(_WEB_SEARCH_PROVIDER_OPTIONS),
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"enable": config.tools.web.enable,
|
||||||
|
"proxy": config.tools.web.proxy,
|
||||||
|
"user_agent": config.tools.web.user_agent,
|
||||||
|
"search": {
|
||||||
|
"max_results": search_config.max_results,
|
||||||
|
"timeout": search_config.timeout,
|
||||||
|
},
|
||||||
|
"fetch": {
|
||||||
|
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"api": {
|
||||||
|
"host": config.api.host,
|
||||||
|
"port": config.api.port,
|
||||||
|
"timeout": config.api.timeout,
|
||||||
|
"api_key_hint": mask_secret_hint(config.api.api_key),
|
||||||
|
},
|
||||||
|
"observability": {
|
||||||
|
"provider": "langfuse",
|
||||||
|
"configured": bool(
|
||||||
|
os.environ.get("LANGFUSE_SECRET_KEY")
|
||||||
|
and os.environ.get("LANGFUSE_PUBLIC_KEY")
|
||||||
|
),
|
||||||
|
"base_url": os.environ.get("LANGFUSE_BASE_URL")
|
||||||
|
or "https://cloud.langfuse.com",
|
||||||
|
},
|
||||||
|
"image_generation": {
|
||||||
|
"enabled": image_config.enabled,
|
||||||
|
"provider": image_config.provider,
|
||||||
|
"provider_configured": bool(
|
||||||
|
selected_image_provider and selected_image_provider["configured"]
|
||||||
|
),
|
||||||
|
"model": image_config.model,
|
||||||
|
"default_aspect_ratio": image_config.default_aspect_ratio,
|
||||||
|
"default_image_size": image_config.default_image_size,
|
||||||
|
"max_images_per_turn": image_config.max_images_per_turn,
|
||||||
|
"save_dir": image_config.save_dir,
|
||||||
|
"providers": image_providers,
|
||||||
|
},
|
||||||
|
"transcription": {
|
||||||
|
"enabled": transcription.enabled,
|
||||||
|
"provider": transcription.provider,
|
||||||
|
"provider_configured": transcription.configured,
|
||||||
|
"model": transcription.model,
|
||||||
|
"language": transcription.language,
|
||||||
|
"max_duration_sec": transcription.max_duration_sec,
|
||||||
|
"max_upload_mb": transcription.max_upload_mb,
|
||||||
|
"providers": _transcription_provider_rows(config),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_network_safety_settings(
|
||||||
|
config: Config,
|
||||||
|
query: QueryParams,
|
||||||
|
) -> tuple[bool, str | None]:
|
||||||
|
raw_allow = (
|
||||||
|
query_first_alias(
|
||||||
|
query,
|
||||||
|
"webui_allow_local_service_access",
|
||||||
|
"webuiAllowLocalServiceAccess",
|
||||||
|
)
|
||||||
|
or query_first_alias(
|
||||||
|
query,
|
||||||
|
"allow_local_preview_access",
|
||||||
|
"allowLocalPreviewAccess",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raw_default_access_mode = query_first_alias(
|
||||||
|
query,
|
||||||
|
"webui_default_access_mode",
|
||||||
|
"webuiDefaultAccessMode",
|
||||||
|
)
|
||||||
|
if raw_allow is None and raw_default_access_mode is None:
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"webui_allow_local_service_access or webui_default_access_mode is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
if raw_allow is not None:
|
||||||
|
allow_local = parse_bool(raw_allow, "webui_allow_local_service_access")
|
||||||
|
if config.tools.webui_allow_local_service_access != allow_local:
|
||||||
|
config.tools.webui_allow_local_service_access = allow_local
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
default_access_mode: str | None = None
|
||||||
|
if raw_default_access_mode is not None:
|
||||||
|
default_access_mode = raw_default_access_mode.strip().lower()
|
||||||
|
if default_access_mode == "restricted":
|
||||||
|
default_access_mode = "default"
|
||||||
|
if default_access_mode not in {"default", "full"}:
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"webui_default_access_mode must be default or full"
|
||||||
|
)
|
||||||
|
return changed, default_access_mode
|
||||||
|
|
||||||
|
|
||||||
|
def update_web_search_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
|
||||||
|
provider_name = (query_first(query, "provider") or "").strip().lower()
|
||||||
|
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
||||||
|
if provider_option is None:
|
||||||
|
raise WebUISettingsError("unknown web search provider")
|
||||||
|
|
||||||
|
search_config = config.tools.web.search
|
||||||
|
web_config = config.tools.web
|
||||||
|
previous_provider = search_config.provider
|
||||||
|
changed = False
|
||||||
|
restart_required = False
|
||||||
|
|
||||||
|
def set_search_value(attr: str, value: object) -> None:
|
||||||
|
nonlocal changed
|
||||||
|
if getattr(search_config, attr) != value:
|
||||||
|
setattr(search_config, attr, value)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
def set_fetch_value(attr: str, value: object) -> None:
|
||||||
|
nonlocal changed
|
||||||
|
if getattr(web_config.fetch, attr) != value:
|
||||||
|
setattr(web_config.fetch, attr, value)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if search_config.provider != provider_name:
|
||||||
|
search_config.provider = provider_name
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
credential = provider_option["credential"]
|
||||||
|
if credential == "none":
|
||||||
|
set_search_value("api_key", "")
|
||||||
|
set_search_value("base_url", "")
|
||||||
|
elif credential == "base_url":
|
||||||
|
base_url = query_first_alias(query, "base_url", "baseUrl")
|
||||||
|
base_url = base_url.strip() if base_url is not None else None
|
||||||
|
if not base_url and previous_provider == provider_name and search_config.base_url:
|
||||||
|
base_url = search_config.base_url
|
||||||
|
if not base_url:
|
||||||
|
raise WebUISettingsError("base_url is required")
|
||||||
|
set_search_value("base_url", base_url)
|
||||||
|
set_search_value("api_key", "")
|
||||||
|
elif credential in {"api_key", "optional_api_key"}:
|
||||||
|
raw_api_key = query_first_alias(query, "api_key", "apiKey")
|
||||||
|
api_key = raw_api_key.strip() if raw_api_key is not None else None
|
||||||
|
if api_key is None and previous_provider == provider_name and search_config.api_key:
|
||||||
|
api_key = search_config.api_key
|
||||||
|
if credential == "api_key" and not api_key:
|
||||||
|
raise WebUISettingsError("api_key is required")
|
||||||
|
set_search_value("api_key", api_key or "")
|
||||||
|
set_search_value("base_url", "")
|
||||||
|
else:
|
||||||
|
raise WebUISettingsError("unknown web search credential type")
|
||||||
|
|
||||||
|
max_results = query_first_alias(query, "max_results", "maxResults")
|
||||||
|
if max_results is not None:
|
||||||
|
try:
|
||||||
|
parsed = int(max_results)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError("max_results must be an integer") from None
|
||||||
|
if parsed < 1 or parsed > 10:
|
||||||
|
raise WebUISettingsError("max_results must be between 1 and 10")
|
||||||
|
set_search_value("max_results", parsed)
|
||||||
|
|
||||||
|
timeout = query_first(query, "timeout")
|
||||||
|
if timeout is not None:
|
||||||
|
try:
|
||||||
|
parsed_timeout = int(timeout)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError("timeout must be an integer") from None
|
||||||
|
if parsed_timeout < 1 or parsed_timeout > 120:
|
||||||
|
raise WebUISettingsError("timeout must be between 1 and 120")
|
||||||
|
set_search_value("timeout", parsed_timeout)
|
||||||
|
|
||||||
|
use_jina_reader = query_first_alias(query, "use_jina_reader", "useJinaReader")
|
||||||
|
if use_jina_reader is not None:
|
||||||
|
previous_jina_reader = web_config.fetch.use_jina_reader
|
||||||
|
set_fetch_value("use_jina_reader", parse_bool(use_jina_reader, "use_jina_reader"))
|
||||||
|
if web_config.fetch.use_jina_reader != previous_jina_reader:
|
||||||
|
restart_required = True
|
||||||
|
return changed, restart_required
|
||||||
|
|
||||||
|
|
||||||
|
def update_api_settings(config: Config, query: QueryParams) -> None:
|
||||||
|
"""Update the managed OpenAI-compatible API configuration."""
|
||||||
|
api = config.api
|
||||||
|
host = query_first(query, "host")
|
||||||
|
if host is not None:
|
||||||
|
host = host.strip()
|
||||||
|
if not host:
|
||||||
|
raise WebUISettingsError("host is required")
|
||||||
|
api.host = host
|
||||||
|
|
||||||
|
port = query_first(query, "port")
|
||||||
|
if port is not None:
|
||||||
|
try:
|
||||||
|
parsed_port = int(port)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError("port must be an integer") from None
|
||||||
|
if parsed_port < 1 or parsed_port > 65535:
|
||||||
|
raise WebUISettingsError("port must be between 1 and 65535")
|
||||||
|
api.port = parsed_port
|
||||||
|
|
||||||
|
timeout = query_first(query, "timeout")
|
||||||
|
if timeout is not None:
|
||||||
|
try:
|
||||||
|
parsed_timeout = float(timeout)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError("timeout must be a number") from None
|
||||||
|
if parsed_timeout < 1 or parsed_timeout > 3600:
|
||||||
|
raise WebUISettingsError("timeout must be between 1 and 3600")
|
||||||
|
api.timeout = parsed_timeout
|
||||||
|
|
||||||
|
api_key = query_first_alias(query, "api_key", "apiKey")
|
||||||
|
if api_key is not None:
|
||||||
|
api.api_key = api_key.strip()
|
||||||
|
if not is_loopback_host(api.host) and not api.api_key.strip():
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"an API key is required when the API is available on the network"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_image_generation_settings(
|
||||||
|
config: Config,
|
||||||
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
oauth_status: OAuthStatusReader,
|
||||||
|
) -> bool:
|
||||||
|
image_config = config.tools.image_generation
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
provider_name = query_first(query, "provider")
|
||||||
|
if provider_name is not None:
|
||||||
|
provider_name = provider_name.strip().lower()
|
||||||
|
if not provider_name:
|
||||||
|
raise WebUISettingsError("image generation provider is required")
|
||||||
|
if get_image_gen_provider(provider_name) is None:
|
||||||
|
raise WebUISettingsError("unknown image generation provider")
|
||||||
|
if image_config.provider != provider_name:
|
||||||
|
image_config.provider = provider_name
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
enabled = query_first(query, "enabled")
|
||||||
|
if enabled is not None:
|
||||||
|
parsed_enabled = parse_bool(enabled, "enabled")
|
||||||
|
if image_config.enabled != parsed_enabled:
|
||||||
|
image_config.enabled = parsed_enabled
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
model = query_first(query, "model")
|
||||||
|
if model is not None:
|
||||||
|
model = model.strip()
|
||||||
|
if not model:
|
||||||
|
raise WebUISettingsError("image generation model is required")
|
||||||
|
if len(model) > 200:
|
||||||
|
raise WebUISettingsError("image generation model is too long")
|
||||||
|
if image_config.model != model:
|
||||||
|
image_config.model = model
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
default_aspect_ratio = query_first_alias(
|
||||||
|
query,
|
||||||
|
"default_aspect_ratio",
|
||||||
|
"defaultAspectRatio",
|
||||||
|
)
|
||||||
|
if default_aspect_ratio is not None:
|
||||||
|
default_aspect_ratio = default_aspect_ratio.strip()
|
||||||
|
if default_aspect_ratio not in _IMAGE_GENERATION_ASPECT_RATIOS:
|
||||||
|
raise WebUISettingsError("unsupported image generation aspect ratio")
|
||||||
|
if image_config.default_aspect_ratio != default_aspect_ratio:
|
||||||
|
image_config.default_aspect_ratio = default_aspect_ratio
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
default_image_size = query_first_alias(
|
||||||
|
query,
|
||||||
|
"default_image_size",
|
||||||
|
"defaultImageSize",
|
||||||
|
)
|
||||||
|
if default_image_size is not None:
|
||||||
|
default_image_size = default_image_size.strip()
|
||||||
|
if not default_image_size:
|
||||||
|
raise WebUISettingsError("default image size is required")
|
||||||
|
if len(default_image_size) > 32 or not all(
|
||||||
|
char.isascii() and (char.isalnum() or char in {"x", "X", ":", "-", "_"})
|
||||||
|
for char in default_image_size
|
||||||
|
):
|
||||||
|
raise WebUISettingsError("unsupported image generation size")
|
||||||
|
if image_config.default_image_size != default_image_size:
|
||||||
|
image_config.default_image_size = default_image_size
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
max_images_per_turn = query_first_alias(
|
||||||
|
query,
|
||||||
|
"max_images_per_turn",
|
||||||
|
"maxImagesPerTurn",
|
||||||
|
)
|
||||||
|
if max_images_per_turn is not None:
|
||||||
|
try:
|
||||||
|
parsed_max = int(max_images_per_turn)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError("max_images_per_turn must be an integer") from None
|
||||||
|
if parsed_max < 1 or parsed_max > 8:
|
||||||
|
raise WebUISettingsError("max_images_per_turn must be between 1 and 8")
|
||||||
|
if image_config.max_images_per_turn != parsed_max:
|
||||||
|
image_config.max_images_per_turn = parsed_max
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if image_config.enabled:
|
||||||
|
selected_provider = next(
|
||||||
|
(
|
||||||
|
provider
|
||||||
|
for provider in _image_generation_provider_rows(
|
||||||
|
config,
|
||||||
|
oauth_status=oauth_status,
|
||||||
|
)
|
||||||
|
if provider["name"] == image_config.provider
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not selected_provider or not selected_provider["configured"]:
|
||||||
|
raise WebUISettingsError("image generation provider is not configured")
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def update_transcription_settings(config: Config, query: QueryParams) -> bool:
|
||||||
|
transcription = config.transcription
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
enabled = query_first(query, "enabled")
|
||||||
|
if enabled is not None:
|
||||||
|
parsed_enabled = parse_bool(enabled, "enabled")
|
||||||
|
if transcription.enabled != parsed_enabled:
|
||||||
|
transcription.enabled = parsed_enabled
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
provider = query_first(query, "provider")
|
||||||
|
if provider is not None:
|
||||||
|
provider = provider.strip().lower()
|
||||||
|
provider_spec = resolve_transcription_provider(provider)
|
||||||
|
if provider_spec is None:
|
||||||
|
raise WebUISettingsError("unknown transcription provider")
|
||||||
|
provider = provider_spec.name
|
||||||
|
if transcription.provider != provider:
|
||||||
|
transcription.provider = provider
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
model = query_first(query, "model")
|
||||||
|
if model is not None:
|
||||||
|
model = model.strip() or None
|
||||||
|
if model is not None and len(model) > 200:
|
||||||
|
raise WebUISettingsError("transcription model is too long")
|
||||||
|
if transcription.model != model:
|
||||||
|
transcription.model = model
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
language = query_first(query, "language")
|
||||||
|
if language is not None:
|
||||||
|
language = language.strip().lower() or None
|
||||||
|
if language is not None and not re.fullmatch(r"[a-z]{2,3}", language):
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"transcription language must be 2-3 lowercase letters"
|
||||||
|
)
|
||||||
|
if transcription.language != language:
|
||||||
|
transcription.language = language
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
max_duration_sec = query_first_alias(query, "max_duration_sec", "maxDurationSec")
|
||||||
|
if max_duration_sec is not None:
|
||||||
|
try:
|
||||||
|
parsed_duration = int(max_duration_sec)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError("max_duration_sec must be an integer") from None
|
||||||
|
if parsed_duration < 1 or parsed_duration > 600:
|
||||||
|
raise WebUISettingsError("max_duration_sec must be between 1 and 600")
|
||||||
|
if transcription.max_duration_sec != parsed_duration:
|
||||||
|
transcription.max_duration_sec = parsed_duration
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
max_upload_mb = query_first_alias(query, "max_upload_mb", "maxUploadMb")
|
||||||
|
if max_upload_mb is not None:
|
||||||
|
try:
|
||||||
|
parsed_upload = int(max_upload_mb)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError("max_upload_mb must be an integer") from None
|
||||||
|
if parsed_upload < 1 or parsed_upload > 100:
|
||||||
|
raise WebUISettingsError("max_upload_mb must be between 1 and 100")
|
||||||
|
if transcription.max_upload_mb != parsed_upload:
|
||||||
|
transcription.max_upload_mb = parsed_upload
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def network_safety_payload(config: Config) -> dict[str, Any]:
|
||||||
|
"""Return the network-related fields embedded in the advanced DTO."""
|
||||||
|
return {
|
||||||
|
"webui_allow_local_service_access": config.tools.webui_allow_local_service_access,
|
||||||
|
"allow_local_preview_access": config.tools.webui_allow_local_service_access,
|
||||||
|
"webui_default_access_mode": read_webui_default_access_mode(),
|
||||||
|
"private_service_protection_enabled": True,
|
||||||
|
"ssrf_whitelist_count": len(config.tools.ssrf_whitelist),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def masked_api_secret(value: str) -> str | None:
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return f"{value[:3]}...{value[-4:]}" if len(value) > 8 else "configured"
|
||||||
|
|
||||||
|
|
||||||
|
def api_runtime_message(message: str) -> str:
|
||||||
|
known = {
|
||||||
|
"api_exited_during_startup": "API server exited during startup. Check its log for details.",
|
||||||
|
"api_stop_timeout": "API server did not stop in time.",
|
||||||
|
"api_state_stale": "API server state was stale; try starting it again.",
|
||||||
|
}
|
||||||
|
if message in known:
|
||||||
|
return known[message]
|
||||||
|
if message.startswith("api_"):
|
||||||
|
return f"API server {message.removeprefix('api_').replace('_', ' ')}"
|
||||||
|
return message.replace("_", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def api_service_payload(
|
||||||
|
settings: WebUISettingsServices,
|
||||||
|
runtime: ApiRuntime,
|
||||||
|
*,
|
||||||
|
last_action: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
config = settings.config.load()
|
||||||
|
status = runtime.status()
|
||||||
|
extras = optional_dependency_groups()
|
||||||
|
connect_host = (
|
||||||
|
"127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"installed": extra_installed("api", extras.get("api")),
|
||||||
|
"running": status.running,
|
||||||
|
"managed": status.running,
|
||||||
|
"host": config.api.host,
|
||||||
|
"port": config.api.port,
|
||||||
|
"timeout": config.api.timeout,
|
||||||
|
"api_key_hint": masked_api_secret(config.api.api_key),
|
||||||
|
"endpoint": f"http://{connect_host}:{config.api.port}/v1",
|
||||||
|
"command": "nanobot serve",
|
||||||
|
"log_path": str(status.log_path),
|
||||||
|
}
|
||||||
|
if last_action:
|
||||||
|
payload["last_action"] = last_action
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilitySettingsHandler:
|
||||||
|
"""Handle capability commands after transport authentication and decoding."""
|
||||||
|
|
||||||
|
def __init__(self, settings: WebUISettingsServices, logger: Any) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
async def handle(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
request: SettingsRequest,
|
||||||
|
operations: CapabilitySettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
if action == "api-status":
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
api_service_payload(self.settings, operations.api_runtime())
|
||||||
|
)
|
||||||
|
if action == "api-start":
|
||||||
|
return await self._start_api(request, operations)
|
||||||
|
if action == "api-stop":
|
||||||
|
return await self._stop_api(operations)
|
||||||
|
|
||||||
|
mutation = {
|
||||||
|
"web-search-update": (
|
||||||
|
operations.update_web_search,
|
||||||
|
"browser",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"transcription-update": (
|
||||||
|
operations.update_transcription,
|
||||||
|
None,
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"network-update": (
|
||||||
|
operations.update_network,
|
||||||
|
"runtime",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
"image-update": (
|
||||||
|
operations.update_image,
|
||||||
|
"image",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
}.get(action)
|
||||||
|
if mutation is None:
|
||||||
|
return SettingsRouteResult.failure(404, "unknown settings action")
|
||||||
|
|
||||||
|
operation, section, apply_image_reload = mutation
|
||||||
|
try:
|
||||||
|
payload = self.settings.mutate(operation, request.query)
|
||||||
|
except WebUISettingsError as exc:
|
||||||
|
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||||
|
if apply_image_reload:
|
||||||
|
payload, image_restart_cleared = await self.apply_image_runtime_change(
|
||||||
|
payload,
|
||||||
|
operations.reload_image,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
image_restart_cleared = False
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
payload,
|
||||||
|
decorate_restart=True,
|
||||||
|
restart_section=section,
|
||||||
|
clear_restart_section=("image" if image_restart_cleared else None),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def apply_image_runtime_change(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
reload_image: Callable[[], Awaitable[dict[str, Any]]],
|
||||||
|
) -> tuple[dict[str, Any], bool]:
|
||||||
|
"""Hot-apply image settings, preserving restart fallback on failure."""
|
||||||
|
if not payload.get("requires_restart"):
|
||||||
|
return payload, False
|
||||||
|
try:
|
||||||
|
result = await reload_image()
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to hot-reload image generation settings")
|
||||||
|
return payload, False
|
||||||
|
|
||||||
|
applied = bool(result.get("ok")) and not result.get("requires_restart")
|
||||||
|
updated = dict(payload)
|
||||||
|
updated["requires_restart"] = not applied
|
||||||
|
if not applied:
|
||||||
|
self.logger.warning(
|
||||||
|
"image generation settings were saved but require restart: {}",
|
||||||
|
result.get("message") or "hot reload failed",
|
||||||
|
)
|
||||||
|
return updated, applied
|
||||||
|
|
||||||
|
async def _start_api(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
operations: CapabilitySettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
api_key = (request.payload or {}).get("api_key")
|
||||||
|
if api_key is not None and not isinstance(api_key, str):
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
400,
|
||||||
|
"API service API key must be a string",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self.settings.mutate,
|
||||||
|
operations.nanobot_features_action,
|
||||||
|
"enable",
|
||||||
|
{"name": ["api"]},
|
||||||
|
allow_install=self._allow_feature_package_install(request),
|
||||||
|
)
|
||||||
|
self.settings.mutate(operations.update_api, request.query)
|
||||||
|
config = self.settings.config.load()
|
||||||
|
runtime = operations.api_runtime()
|
||||||
|
options = ApiStartOptions(
|
||||||
|
host=config.api.host,
|
||||||
|
port=config.api.port,
|
||||||
|
workspace=str(config.workspace_path),
|
||||||
|
config_path=str(self.settings.config.path),
|
||||||
|
)
|
||||||
|
current = runtime.status()
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
runtime.restart if current.running else runtime.start_background,
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
if not result.ok:
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
500,
|
||||||
|
api_runtime_message(result.message),
|
||||||
|
)
|
||||||
|
except (WebUISettingsError, OptionalFeatureError) as exc:
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
getattr(exc, "status", 400),
|
||||||
|
getattr(exc, "message", str(exc)),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.exception("failed to start managed API service")
|
||||||
|
return SettingsRouteResult.failure(500, str(exc))
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
api_service_payload(
|
||||||
|
self.settings,
|
||||||
|
operations.api_runtime(),
|
||||||
|
last_action="started",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _stop_api(
|
||||||
|
self,
|
||||||
|
operations: CapabilitySettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
runtime = operations.api_runtime()
|
||||||
|
try:
|
||||||
|
result = await asyncio.to_thread(runtime.stop)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.exception("failed to stop managed API service")
|
||||||
|
return SettingsRouteResult.failure(500, str(exc))
|
||||||
|
if not result.ok and result.message != "api_not_running":
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
500,
|
||||||
|
api_runtime_message(result.message),
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
api_service_payload(
|
||||||
|
self.settings,
|
||||||
|
operations.api_runtime(),
|
||||||
|
last_action="stopped",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _allow_feature_package_install(self, request: SettingsRequest) -> bool:
|
||||||
|
if request.local_browser:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return bool(
|
||||||
|
self.settings.config.load().tools.webui_allow_remote_package_install
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to load remote package install policy")
|
||||||
|
return False
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Stable request and error contracts shared by WebUI settings domains."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SettingsRequest:
|
||||||
|
"""Transport-neutral input decoded by the settings route facade."""
|
||||||
|
|
||||||
|
query: QueryParams
|
||||||
|
payload: dict[str, Any] | None = None
|
||||||
|
local_browser: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SettingsRouteResult:
|
||||||
|
"""Transport-neutral result returned by a settings domain handler."""
|
||||||
|
|
||||||
|
payload: dict[str, Any] | None = None
|
||||||
|
status: int = 200
|
||||||
|
error: str | None = None
|
||||||
|
decorate_restart: bool = False
|
||||||
|
restart_section: str | None = None
|
||||||
|
clear_restart_section: str | None = None
|
||||||
|
restart_payload_key: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def success(
|
||||||
|
cls,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
decorate_restart: bool = False,
|
||||||
|
restart_section: str | None = None,
|
||||||
|
clear_restart_section: str | None = None,
|
||||||
|
restart_payload_key: str | None = None,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
return cls(
|
||||||
|
payload=payload,
|
||||||
|
decorate_restart=decorate_restart,
|
||||||
|
restart_section=restart_section,
|
||||||
|
clear_restart_section=clear_restart_section,
|
||||||
|
restart_payload_key=restart_payload_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def failure(cls, status: int, error: str) -> SettingsRouteResult:
|
||||||
|
return cls(status=status, error=error)
|
||||||
|
|
||||||
|
|
||||||
|
class WebUISettingsError(ValueError):
|
||||||
|
"""User-facing settings validation failure."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status: int = 400) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
def query_first(query: QueryParams, key: str) -> str | None:
|
||||||
|
values = query.get(key)
|
||||||
|
return values[0] if values else None
|
||||||
|
|
||||||
|
|
||||||
|
def query_first_alias(query: QueryParams, snake: str, camel: str) -> str | None:
|
||||||
|
value = query_first(query, snake)
|
||||||
|
return query_first(query, camel) if value is None else value
|
||||||
|
|
||||||
|
|
||||||
|
def query_has_alias(query: QueryParams, snake: str, camel: str) -> bool:
|
||||||
|
return snake in query or camel in query
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bool(value: str, field: str) -> bool:
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
|
||||||
|
raise WebUISettingsError(f"{field} must be boolean")
|
||||||
|
return normalized in {"1", "true", "yes"}
|
||||||
File diff suppressed because it is too large
Load Diff
+291
-979
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,957 @@
|
|||||||
|
"""System and channel settings domain logic."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from nanobot.channels._setup import channel_setup_spec
|
||||||
|
from nanobot.channels.connect import ChannelConnectError
|
||||||
|
from nanobot.channels.contracts import (
|
||||||
|
RouteFieldType,
|
||||||
|
channel_instance_config,
|
||||||
|
channel_update_instance_config,
|
||||||
|
)
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.optional_features import OptionalFeatureError, with_channel_runtime_status
|
||||||
|
from nanobot.security.workspace_access import workspace_sandbox_status
|
||||||
|
from nanobot.webui.settings_capabilities import network_safety_payload
|
||||||
|
from nanobot.webui.settings_contracts import (
|
||||||
|
QueryParams,
|
||||||
|
SettingsRequest,
|
||||||
|
SettingsRouteResult,
|
||||||
|
WebUISettingsError,
|
||||||
|
query_first,
|
||||||
|
query_first_alias,
|
||||||
|
)
|
||||||
|
from nanobot.webui.token_usage import token_usage_payload
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
|
LoadChannelPlugin = Callable[[str], Any]
|
||||||
|
ListPendingPairings = Callable[[], Iterable[dict[str, Any]]]
|
||||||
|
SettingsOperation = Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemSettingsOperations:
|
||||||
|
cli_apps_payload: SettingsOperation
|
||||||
|
cli_apps_action: SettingsOperation
|
||||||
|
nanobot_features_payload: SettingsOperation
|
||||||
|
nanobot_features_action: SettingsOperation
|
||||||
|
nanobot_feature_instance_target: SettingsOperation
|
||||||
|
validate_channel_config: SettingsOperation
|
||||||
|
load_channel_plugin: LoadChannelPlugin
|
||||||
|
list_pending: ListPendingPairings
|
||||||
|
approve_code: SettingsOperation
|
||||||
|
deny_code: SettingsOperation
|
||||||
|
mcp_presets_action: SettingsOperation
|
||||||
|
reload_mcp: SettingsOperation
|
||||||
|
check_for_update: SettingsOperation
|
||||||
|
channel_feature_action: SettingsOperation | None = None
|
||||||
|
channel_runtime_status: Callable[[], dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSettingsPayload(TypedDict):
|
||||||
|
runtime: dict[str, Any]
|
||||||
|
usage: dict[str, Any]
|
||||||
|
advanced: dict[str, Any]
|
||||||
|
version: dict[str, Any]
|
||||||
|
docs: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
_DOCS_STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:\.post\d+)?$")
|
||||||
|
_DOCS_LATEST_URL = "https://nanobot.wiki/docs/latest"
|
||||||
|
_SKIP_FIELD = object()
|
||||||
|
|
||||||
|
|
||||||
|
def docs_version(version: str) -> str:
|
||||||
|
"""Map package versions to the matching public docs path."""
|
||||||
|
normalized = version.strip()
|
||||||
|
if _DOCS_STABLE_VERSION_RE.fullmatch(normalized):
|
||||||
|
return normalized
|
||||||
|
return "latest"
|
||||||
|
|
||||||
|
|
||||||
|
def docs_payload(version: str) -> dict[str, Any]:
|
||||||
|
selected_version = docs_version(version)
|
||||||
|
base_url = f"https://nanobot.wiki/docs/{selected_version}"
|
||||||
|
return {
|
||||||
|
"version": selected_version,
|
||||||
|
"base_url": base_url,
|
||||||
|
"chat_apps_url": f"{base_url}/getting-started/chat-apps",
|
||||||
|
"latest_url": _DOCS_LATEST_URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def system_settings_payload(
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
config_path: Path,
|
||||||
|
version: str,
|
||||||
|
) -> SystemSettingsPayload:
|
||||||
|
defaults = config.agents.defaults
|
||||||
|
exec_config = config.tools.exec
|
||||||
|
sandbox_status = workspace_sandbox_status(
|
||||||
|
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||||
|
workspace=config.workspace_path,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"runtime": {
|
||||||
|
"config_path": str(config_path.expanduser()),
|
||||||
|
"workspace_path": str(config.workspace_path),
|
||||||
|
"gateway_host": config.gateway.host,
|
||||||
|
"gateway_port": config.gateway.port,
|
||||||
|
"heartbeat": {
|
||||||
|
"enabled": config.gateway.heartbeat.enabled,
|
||||||
|
"interval_s": config.gateway.heartbeat.interval_s,
|
||||||
|
"keep_recent_messages": config.gateway.heartbeat.keep_recent_messages,
|
||||||
|
},
|
||||||
|
"dream": {
|
||||||
|
"schedule": defaults.dream.describe_schedule(),
|
||||||
|
},
|
||||||
|
"unified_session": defaults.unified_session,
|
||||||
|
},
|
||||||
|
"usage": token_usage_payload(timezone_name=defaults.timezone),
|
||||||
|
"advanced": {
|
||||||
|
"restrict_to_workspace": config.tools.restrict_to_workspace,
|
||||||
|
"workspace_sandbox": sandbox_status.as_dict(),
|
||||||
|
**network_safety_payload(config),
|
||||||
|
"mcp_server_count": len(config.tools.mcp_servers),
|
||||||
|
"exec_enabled": exec_config.enable,
|
||||||
|
"exec_sandbox": exec_config.sandbox or None,
|
||||||
|
"exec_path_prepend_set": bool(exec_config.path_prepend),
|
||||||
|
"exec_path_append_set": bool(exec_config.path_append),
|
||||||
|
},
|
||||||
|
"version": {"current": version},
|
||||||
|
"docs": docs_payload(version),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def settings_usage_payload(config: Config) -> dict[str, Any]:
|
||||||
|
"""Return the lightweight token usage slice for Overview refreshes."""
|
||||||
|
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
||||||
|
|
||||||
|
|
||||||
|
def update_agent_system_settings(config: Config, query: QueryParams) -> tuple[bool, bool]:
|
||||||
|
defaults = config.agents.defaults
|
||||||
|
changed = False
|
||||||
|
restart_required = False
|
||||||
|
|
||||||
|
timezone = query_first(query, "timezone")
|
||||||
|
if timezone is not None:
|
||||||
|
timezone = timezone.strip()
|
||||||
|
if not timezone:
|
||||||
|
raise WebUISettingsError("timezone is required")
|
||||||
|
try:
|
||||||
|
ZoneInfo(timezone)
|
||||||
|
except Exception:
|
||||||
|
raise WebUISettingsError("invalid timezone") from None
|
||||||
|
timezone_changed = defaults.timezone != timezone
|
||||||
|
if timezone_changed or defaults.timezone_mode != "manual":
|
||||||
|
defaults.timezone = timezone
|
||||||
|
defaults.timezone_mode = "manual"
|
||||||
|
changed = True
|
||||||
|
restart_required = timezone_changed
|
||||||
|
|
||||||
|
tool_hint_max_length = query_first_alias(
|
||||||
|
query,
|
||||||
|
"tool_hint_max_length",
|
||||||
|
"toolHintMaxLength",
|
||||||
|
)
|
||||||
|
if tool_hint_max_length is not None:
|
||||||
|
try:
|
||||||
|
parsed = int(tool_hint_max_length)
|
||||||
|
except ValueError:
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"tool_hint_max_length must be an integer"
|
||||||
|
) from None
|
||||||
|
if parsed < 20 or parsed > 500:
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"tool_hint_max_length must be between 20 and 500"
|
||||||
|
)
|
||||||
|
if defaults.tool_hint_max_length != parsed:
|
||||||
|
defaults.tool_hint_max_length = parsed
|
||||||
|
changed = True
|
||||||
|
restart_required = True
|
||||||
|
return changed, restart_required
|
||||||
|
|
||||||
|
|
||||||
|
def save_channel_config_values(
|
||||||
|
config: Config,
|
||||||
|
name: str,
|
||||||
|
raw_values: dict[str, Any],
|
||||||
|
instance_id: str = "default",
|
||||||
|
*,
|
||||||
|
load_channel_plugin: LoadChannelPlugin,
|
||||||
|
) -> list[str]:
|
||||||
|
if not name:
|
||||||
|
raise WebUISettingsError("missing channel name")
|
||||||
|
try:
|
||||||
|
plugin = load_channel_plugin(name)
|
||||||
|
except ImportError:
|
||||||
|
raise WebUISettingsError(f"unknown channel '{name}'", status=404) from None
|
||||||
|
setup_spec = channel_setup_spec(name, plugin=plugin)
|
||||||
|
if setup_spec is None:
|
||||||
|
raise WebUISettingsError(
|
||||||
|
f"channel '{name}' cannot be configured from WebUI",
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
field_types = setup_spec.route_field_types
|
||||||
|
if not raw_values:
|
||||||
|
return []
|
||||||
|
|
||||||
|
section = getattr(config.channels, name, None)
|
||||||
|
channel_config = channel_instance_config(
|
||||||
|
plugin,
|
||||||
|
section,
|
||||||
|
instance_id=instance_id,
|
||||||
|
)
|
||||||
|
saved: list[str] = []
|
||||||
|
prefix = f"channels.{name}."
|
||||||
|
for raw_key, raw_value in raw_values.items():
|
||||||
|
if not raw_key:
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"channel settings payload contains an invalid key"
|
||||||
|
)
|
||||||
|
field = raw_key[len(prefix) :] if raw_key.startswith(prefix) else raw_key
|
||||||
|
value_type = field_types.get(field)
|
||||||
|
if value_type is None:
|
||||||
|
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
|
||||||
|
value = coerce_channel_value(raw_key, raw_value, value_type)
|
||||||
|
if value is _SKIP_FIELD:
|
||||||
|
continue
|
||||||
|
assign_channel_config_value(channel_config, field, value)
|
||||||
|
saved.append(raw_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated_section = channel_update_instance_config(
|
||||||
|
plugin,
|
||||||
|
section,
|
||||||
|
channel_config,
|
||||||
|
instance_id=instance_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise WebUISettingsError(
|
||||||
|
f"Invalid {name} configuration: {exc}",
|
||||||
|
status=400,
|
||||||
|
) from exc
|
||||||
|
setattr(config.channels, name, updated_section)
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_channel_value(
|
||||||
|
raw_key: str,
|
||||||
|
raw_value: Any,
|
||||||
|
value_type: RouteFieldType,
|
||||||
|
) -> Any:
|
||||||
|
if isinstance(value_type, tuple):
|
||||||
|
kind = value_type[0]
|
||||||
|
allowed = value_type[1]
|
||||||
|
else:
|
||||||
|
kind = value_type
|
||||||
|
allowed = None
|
||||||
|
|
||||||
|
if kind in {"string", "secret"}:
|
||||||
|
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
|
||||||
|
if kind == "secret" and not value:
|
||||||
|
return _SKIP_FIELD
|
||||||
|
return value
|
||||||
|
|
||||||
|
if kind == "list":
|
||||||
|
if raw_value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw_value, str):
|
||||||
|
return [item.strip() for item in raw_value.split(",") if item.strip()]
|
||||||
|
if isinstance(raw_value, list):
|
||||||
|
return [
|
||||||
|
str(item).strip()
|
||||||
|
for item in cast(list[Any], raw_value)
|
||||||
|
if str(item).strip()
|
||||||
|
]
|
||||||
|
raise WebUISettingsError(f"'{raw_key}' must be a comma-separated list")
|
||||||
|
|
||||||
|
if kind == "int":
|
||||||
|
if raw_value in (None, ""):
|
||||||
|
return _SKIP_FIELD
|
||||||
|
try:
|
||||||
|
return int(raw_value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise WebUISettingsError(f"'{raw_key}' must be a number") from exc
|
||||||
|
|
||||||
|
if kind == "bool":
|
||||||
|
if isinstance(raw_value, bool):
|
||||||
|
return raw_value
|
||||||
|
value = str(raw_value).strip().lower()
|
||||||
|
if value in {"true", "1", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if value in {"false", "0", "no", "off"}:
|
||||||
|
return False
|
||||||
|
raise WebUISettingsError(f"'{raw_key}' must be true or false")
|
||||||
|
|
||||||
|
if kind == "enum":
|
||||||
|
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
|
||||||
|
if not value:
|
||||||
|
return _SKIP_FIELD
|
||||||
|
if allowed is None or value not in allowed:
|
||||||
|
options = ", ".join(sorted(allowed or ()))
|
||||||
|
raise WebUISettingsError(f"'{raw_key}' must be one of: {options}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
raise WebUISettingsError(f"'{raw_key}' has an unsupported field type")
|
||||||
|
|
||||||
|
|
||||||
|
def assign_channel_config_value(
|
||||||
|
channel_config: dict[str, Any],
|
||||||
|
field: str,
|
||||||
|
value: Any,
|
||||||
|
) -> None:
|
||||||
|
target = channel_config
|
||||||
|
parts = field.split(".")
|
||||||
|
for part in parts[:-1]:
|
||||||
|
current: object = target.get(part)
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
current = {}
|
||||||
|
target[part] = current
|
||||||
|
target = cast(dict[str, Any], current)
|
||||||
|
target[parts[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
|
def pairing_payload(
|
||||||
|
list_pending: ListPendingPairings,
|
||||||
|
last_action: dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
now: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
current_time = time.time() if now is None else now
|
||||||
|
requests: list[dict[str, Any]] = []
|
||||||
|
for item in list_pending():
|
||||||
|
expires_at = float(item.get("expires_at", 0) or 0)
|
||||||
|
created_at = float(item.get("created_at", 0) or 0)
|
||||||
|
requests.append(
|
||||||
|
{
|
||||||
|
"code": str(item.get("code", "")),
|
||||||
|
"channel": str(item.get("channel", "")),
|
||||||
|
"sender_id": str(item.get("sender_id", "")),
|
||||||
|
"created_at_ms": int(created_at * 1000) if created_at else None,
|
||||||
|
"expires_at_ms": int(expires_at * 1000) if expires_at else None,
|
||||||
|
"expires_in_seconds": (
|
||||||
|
max(0, int(expires_at - current_time)) if expires_at else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload: dict[str, Any] = {"requests": requests}
|
||||||
|
if last_action is not None:
|
||||||
|
payload["last_action"] = last_action
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSettingsHandler:
|
||||||
|
"""Handle channel and system commands behind a transport-neutral request DTO."""
|
||||||
|
|
||||||
|
def __init__(self, settings: WebUISettingsServices, logger: Any) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self.logger = logger
|
||||||
|
self._channel_connectors: dict[str, Any] = {}
|
||||||
|
|
||||||
|
async def handle(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
request: SettingsRequest,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
*,
|
||||||
|
channel_name: str | None = None,
|
||||||
|
connect_action: str | None = None,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
if action == "cli-list":
|
||||||
|
return await self._cli_apps(request, operations)
|
||||||
|
if action.startswith("cli-"):
|
||||||
|
return await self._cli_apps_action(
|
||||||
|
request,
|
||||||
|
action.removeprefix("cli-"),
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
if action == "features-list":
|
||||||
|
return await self._features(operations)
|
||||||
|
if action in {"features-enable", "features-disable"}:
|
||||||
|
return await self._features_action(
|
||||||
|
request,
|
||||||
|
action.removeprefix("features-"),
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
if action == "channel-validate":
|
||||||
|
return await self._channel_validate(request, operations)
|
||||||
|
if action == "channel-configure":
|
||||||
|
return await self._channel_configure(request, operations)
|
||||||
|
if action == "channel-connect" and channel_name and connect_action:
|
||||||
|
return await self._channel_connect(
|
||||||
|
request,
|
||||||
|
channel_name,
|
||||||
|
connect_action,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
if action == "pairing-list":
|
||||||
|
return SettingsRouteResult.success(pairing_payload(operations.list_pending))
|
||||||
|
if action in {"pairing-approve", "pairing-deny"}:
|
||||||
|
return self._pairing_action(
|
||||||
|
request,
|
||||||
|
action.removeprefix("pairing-"),
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
if action == "mcp-list":
|
||||||
|
return await self._mcp_presets(request, None, operations)
|
||||||
|
if action.startswith("mcp-"):
|
||||||
|
return await self._mcp_presets(
|
||||||
|
request,
|
||||||
|
action.removeprefix("mcp-"),
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
if action == "version-check":
|
||||||
|
return await self._version_check(operations)
|
||||||
|
return SettingsRouteResult.failure(404, "unknown settings action")
|
||||||
|
|
||||||
|
async def _cli_apps(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
installed_only = (query_first(request.query, "installed_only") or "").lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
payload = await operations.cli_apps_payload(
|
||||||
|
installed_only=installed_only,
|
||||||
|
config_path=self.settings.config.path,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to load CLI Apps payload")
|
||||||
|
return SettingsRouteResult.failure(500, "failed to load CLI Apps")
|
||||||
|
return SettingsRouteResult.success(payload)
|
||||||
|
|
||||||
|
async def _cli_apps_action(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
action: str,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
try:
|
||||||
|
payload = await asyncio.to_thread(
|
||||||
|
operations.cli_apps_action,
|
||||||
|
action,
|
||||||
|
request.query,
|
||||||
|
config_path=self.settings.config.path,
|
||||||
|
)
|
||||||
|
except WebUISettingsError as exc:
|
||||||
|
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||||
|
except Exception as exc:
|
||||||
|
status = getattr(exc, "status", 500)
|
||||||
|
message = getattr(exc, "message", str(exc))
|
||||||
|
if status >= 500:
|
||||||
|
self.logger.exception("CLI Apps action '{}' failed", action)
|
||||||
|
return SettingsRouteResult.failure(status, message)
|
||||||
|
return SettingsRouteResult.success(payload)
|
||||||
|
|
||||||
|
async def _features(
|
||||||
|
self,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
try:
|
||||||
|
payload = await asyncio.to_thread(
|
||||||
|
operations.nanobot_features_payload,
|
||||||
|
config_path=self.settings.config.path,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to load nanobot features")
|
||||||
|
return SettingsRouteResult.failure(500, "failed to load nanobot features")
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
self._with_channel_runtime_status(payload, operations)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _nanobot_features_payload(
|
||||||
|
self,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return operations.nanobot_features_payload(config_path=self.settings.config.path)
|
||||||
|
|
||||||
|
def _nanobot_features_action(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
query: QueryParams,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
*,
|
||||||
|
allow_install: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return self.settings.mutate(
|
||||||
|
operations.nanobot_features_action,
|
||||||
|
action,
|
||||||
|
query,
|
||||||
|
allow_install=allow_install,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _features_action(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
action: str,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
try:
|
||||||
|
payload = await asyncio.to_thread(
|
||||||
|
self._nanobot_features_action,
|
||||||
|
action,
|
||||||
|
request.query,
|
||||||
|
operations,
|
||||||
|
allow_install=(
|
||||||
|
action != "enable"
|
||||||
|
or self.allow_feature_package_install(request)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except OptionalFeatureError as exc:
|
||||||
|
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||||
|
except Exception as exc:
|
||||||
|
status = getattr(exc, "status", 500)
|
||||||
|
message = getattr(exc, "message", str(exc))
|
||||||
|
if status >= 500:
|
||||||
|
self.logger.exception(
|
||||||
|
"nanobot feature action '{}' failed",
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.failure(status, message)
|
||||||
|
payload = await self._apply_feature_runtime_change(
|
||||||
|
action,
|
||||||
|
request.query,
|
||||||
|
payload,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
payload = self._with_channel_runtime_status(payload, operations)
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
payload,
|
||||||
|
decorate_restart=True,
|
||||||
|
restart_section="runtime",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _with_channel_runtime_status(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if operations.channel_runtime_status is None:
|
||||||
|
return payload
|
||||||
|
try:
|
||||||
|
return with_channel_runtime_status(
|
||||||
|
payload,
|
||||||
|
operations.channel_runtime_status(),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to load channel runtime status")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def _apply_feature_runtime_change(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
query: QueryParams,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if operations.channel_feature_action is None:
|
||||||
|
return payload
|
||||||
|
name = (query_first(query, "name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return payload
|
||||||
|
try:
|
||||||
|
instance_id = operations.nanobot_feature_instance_target(query)
|
||||||
|
result = operations.channel_feature_action(action, name, instance_id)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
result = await result
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.exception("failed to apply channel '{}' without restart", name)
|
||||||
|
return self.feature_runtime_fallback(
|
||||||
|
payload,
|
||||||
|
message=(
|
||||||
|
f"{name} channel config was saved, but hot reload failed: {exc}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return payload
|
||||||
|
result = cast(dict[str, Any], result)
|
||||||
|
if not result.get("handled"):
|
||||||
|
return payload
|
||||||
|
|
||||||
|
updated = dict(payload)
|
||||||
|
updated["requires_restart"] = bool(result.get("requires_restart"))
|
||||||
|
message = result.get("message")
|
||||||
|
if isinstance(message, str) and message:
|
||||||
|
last_action = dict(updated.get("last_action") or {})
|
||||||
|
previous = last_action.get("message")
|
||||||
|
last_action["message"] = (
|
||||||
|
f"{previous}. {message}"
|
||||||
|
if isinstance(previous, str) and previous
|
||||||
|
else message
|
||||||
|
)
|
||||||
|
last_action["hot_reload"] = not updated["requires_restart"]
|
||||||
|
if "ok" in result:
|
||||||
|
last_action["ok"] = bool(result["ok"])
|
||||||
|
updated["last_action"] = last_action
|
||||||
|
return updated
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def feature_runtime_fallback(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
message: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
updated = dict(payload)
|
||||||
|
updated["requires_restart"] = True
|
||||||
|
last_action = dict(updated.get("last_action") or {})
|
||||||
|
previous = last_action.get("message")
|
||||||
|
last_action["message"] = (
|
||||||
|
f"{previous}. {message}"
|
||||||
|
if isinstance(previous, str) and previous
|
||||||
|
else message
|
||||||
|
)
|
||||||
|
last_action["hot_reload"] = False
|
||||||
|
updated["last_action"] = last_action
|
||||||
|
return updated
|
||||||
|
|
||||||
|
async def _channel_configure(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
name = (query_first(request.query, "name") or "").strip()
|
||||||
|
instance_id = (
|
||||||
|
query_first(request.query, "instance_id") or "default"
|
||||||
|
).strip()
|
||||||
|
enable = (query_first(request.query, "enable") or "").strip().lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
saved = await asyncio.to_thread(
|
||||||
|
self._save_channel_config_values,
|
||||||
|
name,
|
||||||
|
self.parse_channel_values(request),
|
||||||
|
instance_id,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
except WebUISettingsError as exc:
|
||||||
|
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to save channel '{}' settings", name)
|
||||||
|
return SettingsRouteResult.failure(500, "failed to save channel settings")
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"saved": True,
|
||||||
|
"saved_keys": saved,
|
||||||
|
}
|
||||||
|
if not enable:
|
||||||
|
features = await asyncio.to_thread(
|
||||||
|
self._nanobot_features_payload,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
payload["nanobot_features"] = self._with_channel_runtime_status(
|
||||||
|
features,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
payload,
|
||||||
|
decorate_restart=True,
|
||||||
|
restart_section="runtime",
|
||||||
|
restart_payload_key="nanobot_features",
|
||||||
|
)
|
||||||
|
|
||||||
|
feature_query = {"name": [name]}
|
||||||
|
if instance_id:
|
||||||
|
feature_query["instance_id"] = [instance_id]
|
||||||
|
try:
|
||||||
|
features = await asyncio.to_thread(
|
||||||
|
self._nanobot_features_action,
|
||||||
|
"enable",
|
||||||
|
feature_query,
|
||||||
|
operations,
|
||||||
|
allow_install=self.allow_feature_package_install(request),
|
||||||
|
)
|
||||||
|
except OptionalFeatureError as exc:
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
exc.status,
|
||||||
|
f"Settings saved, but {exc.message}",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.exception(
|
||||||
|
"failed to enable channel '{}' after settings save",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
500,
|
||||||
|
f"Settings saved, but enabling {name} failed: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
features = await self._apply_feature_runtime_change(
|
||||||
|
"enable",
|
||||||
|
feature_query,
|
||||||
|
features,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
payload["nanobot_features"] = self._with_channel_runtime_status(
|
||||||
|
features,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
payload,
|
||||||
|
decorate_restart=True,
|
||||||
|
restart_section="runtime",
|
||||||
|
restart_payload_key="nanobot_features",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _channel_validate(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
name = (query_first(request.query, "name") or "").strip()
|
||||||
|
instance_id = (
|
||||||
|
query_first(request.query, "instance_id") or "default"
|
||||||
|
).strip()
|
||||||
|
try:
|
||||||
|
payload = await asyncio.to_thread(
|
||||||
|
operations.validate_channel_config,
|
||||||
|
name,
|
||||||
|
self.parse_channel_values(request),
|
||||||
|
instance_id=instance_id,
|
||||||
|
)
|
||||||
|
except WebUISettingsError as exc:
|
||||||
|
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to validate channel '{}' settings", name)
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
500,
|
||||||
|
"failed to validate channel settings",
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.success(payload)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_channel_values(request: SettingsRequest) -> dict[str, Any]:
|
||||||
|
if request.payload is None or "values" not in request.payload:
|
||||||
|
return {}
|
||||||
|
values = request.payload.get("values")
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
raise WebUISettingsError(
|
||||||
|
"channel settings payload must be a JSON object"
|
||||||
|
)
|
||||||
|
return cast(dict[str, Any], values)
|
||||||
|
|
||||||
|
def _save_channel_config_values(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
raw_values: dict[str, Any],
|
||||||
|
instance_id: str,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> list[str]:
|
||||||
|
return self.settings.config.update(
|
||||||
|
lambda config: save_channel_config_values(
|
||||||
|
config,
|
||||||
|
name,
|
||||||
|
raw_values,
|
||||||
|
instance_id,
|
||||||
|
load_channel_plugin=operations.load_channel_plugin,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _channel_connect(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
channel_name: str,
|
||||||
|
action: str,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
try:
|
||||||
|
connector = self._channel_connectors.get(channel_name)
|
||||||
|
if connector is None:
|
||||||
|
plugin = operations.load_channel_plugin(channel_name)
|
||||||
|
connector = plugin.load_connector()
|
||||||
|
self._channel_connectors[channel_name] = connector
|
||||||
|
except ImportError:
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
404,
|
||||||
|
f"channel '{channel_name}' does not support connect",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await connector.handle(action, request.query)
|
||||||
|
except ChannelConnectError as exc:
|
||||||
|
return SettingsRouteResult.failure(exc.status, exc.message)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception(
|
||||||
|
"failed to run {} WebUI connect action for {}",
|
||||||
|
action,
|
||||||
|
channel_name,
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
500,
|
||||||
|
f"failed to {action} {channel_name} connection",
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.get("status") != "succeeded":
|
||||||
|
return SettingsRouteResult.success(payload)
|
||||||
|
payload = await self._with_channel_connect_success(
|
||||||
|
request,
|
||||||
|
channel_name,
|
||||||
|
payload,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
payload,
|
||||||
|
decorate_restart=True,
|
||||||
|
restart_section="runtime",
|
||||||
|
restart_payload_key="nanobot_features",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _with_channel_connect_success(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
channel_name: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
target = {"name": [channel_name]}
|
||||||
|
if payload.get("instance_id"):
|
||||||
|
target["instance_id"] = [str(payload["instance_id"])]
|
||||||
|
try:
|
||||||
|
features = await asyncio.to_thread(
|
||||||
|
self._nanobot_features_action,
|
||||||
|
"enable",
|
||||||
|
target,
|
||||||
|
operations,
|
||||||
|
allow_install=self.allow_feature_package_install(request),
|
||||||
|
)
|
||||||
|
except OptionalFeatureError as exc:
|
||||||
|
features = self.feature_runtime_fallback(
|
||||||
|
self._nanobot_features_payload(operations),
|
||||||
|
message=(
|
||||||
|
f"{channel_name} connected, but enabling channel support failed: "
|
||||||
|
f"{exc.message}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
features = await self._apply_feature_runtime_change(
|
||||||
|
"enable",
|
||||||
|
target,
|
||||||
|
features,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
updated = dict(payload)
|
||||||
|
updated["nanobot_features"] = self._with_channel_runtime_status(
|
||||||
|
features,
|
||||||
|
operations,
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def allow_feature_package_install(self, request: SettingsRequest) -> bool:
|
||||||
|
if request.local_browser:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return bool(
|
||||||
|
self.settings.config.load().tools.webui_allow_remote_package_install
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("failed to load remote package install policy")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _pairing_action(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
action: str,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
code = (query_first(request.query, "code") or "").strip()
|
||||||
|
if not code:
|
||||||
|
return SettingsRouteResult.failure(400, "Missing pairing code")
|
||||||
|
if action == "approve":
|
||||||
|
result = operations.approve_code(code)
|
||||||
|
if result is None:
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
404,
|
||||||
|
"Pairing code not found or expired",
|
||||||
|
)
|
||||||
|
channel, sender_id = result
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
pairing_payload(
|
||||||
|
operations.list_pending,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"action": "approve",
|
||||||
|
"message": f"Approved {sender_id} for {channel}",
|
||||||
|
"channel": channel,
|
||||||
|
"sender_id": sender_id,
|
||||||
|
"code": code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not operations.deny_code(code):
|
||||||
|
return SettingsRouteResult.failure(
|
||||||
|
404,
|
||||||
|
"Pairing code not found or expired",
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
pairing_payload(
|
||||||
|
operations.list_pending,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"action": "deny",
|
||||||
|
"message": f"Denied pairing code {code}",
|
||||||
|
"code": code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _mcp_presets(
|
||||||
|
self,
|
||||||
|
request: SettingsRequest,
|
||||||
|
action: str | None,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
try:
|
||||||
|
payload = await operations.mcp_presets_action(
|
||||||
|
action,
|
||||||
|
request.query,
|
||||||
|
reload_mcp=operations.reload_mcp,
|
||||||
|
config=self.settings.config,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
status = getattr(exc, "status", 500)
|
||||||
|
message = getattr(exc, "message", str(exc))
|
||||||
|
if status >= 500:
|
||||||
|
self.logger.exception(
|
||||||
|
"MCP preset action '{}' failed",
|
||||||
|
action or "list",
|
||||||
|
)
|
||||||
|
return SettingsRouteResult.failure(status, message)
|
||||||
|
return SettingsRouteResult.success(
|
||||||
|
payload,
|
||||||
|
decorate_restart=action is not None,
|
||||||
|
restart_section="runtime" if action is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _version_check(
|
||||||
|
self,
|
||||||
|
operations: SystemSettingsOperations,
|
||||||
|
) -> SettingsRouteResult:
|
||||||
|
try:
|
||||||
|
update_info = await asyncio.to_thread(operations.check_for_update)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("version check failed")
|
||||||
|
return SettingsRouteResult.failure(500, "version check failed")
|
||||||
|
return SettingsRouteResult.success({"updateAvailable": update_info})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.webui.settings_capabilities import (
|
||||||
|
capability_settings_payload,
|
||||||
|
update_api_settings,
|
||||||
|
update_image_generation_settings,
|
||||||
|
update_network_safety_settings,
|
||||||
|
update_transcription_settings,
|
||||||
|
update_web_search_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_status(_spec: Any) -> dict[str, Any]:
|
||||||
|
return {"configured": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_capability_domain_updates_representative_settings() -> None:
|
||||||
|
config = Config()
|
||||||
|
config.providers.openrouter.api_key = "sk-test"
|
||||||
|
|
||||||
|
web_changed, web_restart = update_web_search_settings(
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
"provider": ["duckduckgo"],
|
||||||
|
"max_results": ["7"],
|
||||||
|
"use_jina_reader": ["false"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
update_api_settings(
|
||||||
|
config,
|
||||||
|
{"host": ["127.0.0.2"], "port": ["8900"], "timeout": ["90"]},
|
||||||
|
)
|
||||||
|
image_changed = update_image_generation_settings(
|
||||||
|
config,
|
||||||
|
{"enabled": ["true"], "provider": ["openrouter"]},
|
||||||
|
oauth_status=_oauth_status,
|
||||||
|
)
|
||||||
|
transcription_changed = update_transcription_settings(
|
||||||
|
config,
|
||||||
|
{"provider": ["openrouter"], "model": ["openai/whisper-large-v3"]},
|
||||||
|
)
|
||||||
|
network_changed, access_mode = update_network_safety_settings(
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
"webui_allow_local_service_access": ["false"],
|
||||||
|
"webui_default_access_mode": ["restricted"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payload = capability_settings_payload(config, oauth_status=_oauth_status)
|
||||||
|
|
||||||
|
assert (web_changed, web_restart) == (True, True)
|
||||||
|
assert image_changed is True
|
||||||
|
assert transcription_changed is True
|
||||||
|
assert (network_changed, access_mode) == (True, "default")
|
||||||
|
assert payload["web_search"]["max_results"] == 7
|
||||||
|
assert payload["api"]["host"] == "127.0.0.2"
|
||||||
|
assert payload["api"]["port"] == 8900
|
||||||
|
assert payload["image_generation"]["enabled"] is True
|
||||||
|
assert payload["transcription"]["provider"] == "openrouter"
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.webui.settings_models import (
|
||||||
|
model_settings_payload,
|
||||||
|
update_agent_model_settings,
|
||||||
|
update_provider_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_status(_spec: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"configured": False,
|
||||||
|
"account": None,
|
||||||
|
"expires_at": None,
|
||||||
|
"login_supported": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_domain_owns_dto_and_config_updates() -> None:
|
||||||
|
config = Config()
|
||||||
|
config.providers.openrouter.api_key = "sk-before"
|
||||||
|
|
||||||
|
agent_changed = update_agent_model_settings(
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
"model": ["openai/gpt-5.4"],
|
||||||
|
"provider": ["openrouter"],
|
||||||
|
"context_window_tokens": ["200000"],
|
||||||
|
},
|
||||||
|
oauth_status=_oauth_status,
|
||||||
|
)
|
||||||
|
provider_changed, restart_required = update_provider_settings(
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
"provider": ["openrouter"],
|
||||||
|
"api_key": ["sk-after"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payload = model_settings_payload(config, oauth_status=_oauth_status)
|
||||||
|
|
||||||
|
assert agent_changed is True
|
||||||
|
assert provider_changed is True
|
||||||
|
assert restart_required is False
|
||||||
|
assert config.agents.defaults.model == "openai/gpt-5.4"
|
||||||
|
assert config.agents.defaults.provider == "openrouter"
|
||||||
|
assert config.agents.defaults.context_window_tokens == 200_000
|
||||||
|
assert config.providers.openrouter.api_key == "sk-after"
|
||||||
|
assert set(payload) == {
|
||||||
|
"agent",
|
||||||
|
"model_presets",
|
||||||
|
"model_call_order",
|
||||||
|
"model_call_order_editable",
|
||||||
|
"providers",
|
||||||
|
}
|
||||||
|
assert payload["agent"]["model"] == "openai/gpt-5.4"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.webui.settings_system import (
|
||||||
|
coerce_channel_value,
|
||||||
|
system_settings_payload,
|
||||||
|
update_agent_system_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_domain_owns_runtime_dto_and_agent_updates(tmp_path) -> None:
|
||||||
|
config = Config()
|
||||||
|
|
||||||
|
changed, restart_required = update_agent_system_settings(
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
"timezone": ["Asia/Shanghai"],
|
||||||
|
"tool_hint_max_length": ["120"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payload = system_settings_payload(
|
||||||
|
config,
|
||||||
|
config_path=tmp_path / "config.json",
|
||||||
|
version="0.3.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert changed is True
|
||||||
|
assert restart_required is True
|
||||||
|
assert config.agents.defaults.timezone == "Asia/Shanghai"
|
||||||
|
assert config.agents.defaults.timezone_mode == "manual"
|
||||||
|
assert config.agents.defaults.tool_hint_max_length == 120
|
||||||
|
assert payload["runtime"]["config_path"] == str(tmp_path / "config.json")
|
||||||
|
assert payload["version"] == {"current": "0.3.0"}
|
||||||
|
assert payload["docs"]["version"] == "0.3.0"
|
||||||
|
assert set(payload) == {"runtime", "usage", "advanced", "version", "docs"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_domain_validates_channel_field_values() -> None:
|
||||||
|
assert coerce_channel_value("allow_from", "alice, bob", "list") == [
|
||||||
|
"alice",
|
||||||
|
"bob",
|
||||||
|
]
|
||||||
|
assert coerce_channel_value("enabled", "yes", "bool") is True
|
||||||
|
assert coerce_channel_value("port", "8765", "int") == 8765
|
||||||
+35
-356
@@ -12,26 +12,8 @@ import { Eye, EyeOff, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react"
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||||
import { Sidebar } from "@/components/Sidebar";
|
import { Sidebar } from "@/components/Sidebar";
|
||||||
import type { SidebarDeleteItem } from "@/components/ChatList";
|
|
||||||
import type { SettingsSectionKey } from "@/components/settings/SettingsView";
|
import type { SettingsSectionKey } from "@/components/settings/SettingsView";
|
||||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||||
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
|
|
||||||
import {
|
|
||||||
WORKBENCH_STORAGE_KEY,
|
|
||||||
MAX_WORKBENCH_PANES,
|
|
||||||
addWorkbenchPane,
|
|
||||||
attachWorkbenchPane,
|
|
||||||
detachWorkbenchPane,
|
|
||||||
ensureWorkbenchTab,
|
|
||||||
focusWorkbenchPane,
|
|
||||||
parseWorkbenchState,
|
|
||||||
promoteWorkbenchPane,
|
|
||||||
reconcileWorkbench,
|
|
||||||
setWorkbenchLayout,
|
|
||||||
workbenchChildPaneKeys,
|
|
||||||
workbenchTab,
|
|
||||||
type WorkbenchState,
|
|
||||||
} from "@/components/workbench/workbench-model";
|
|
||||||
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
|
import { floatingSurfaceElevationClassName } from "@/components/ui/floating-surface";
|
||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
|
||||||
@@ -138,14 +120,6 @@ const RenameChatDialog = lazy(async () => {
|
|||||||
return { default: module.RenameChatDialog };
|
return { default: module.RenameChatDialog };
|
||||||
});
|
});
|
||||||
|
|
||||||
function readWorkbenchState(): WorkbenchState {
|
|
||||||
try {
|
|
||||||
return parseWorkbenchState(window.localStorage.getItem(WORKBENCH_STORAGE_KEY));
|
|
||||||
} catch {
|
|
||||||
return parseWorkbenchState(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function SurfaceLoadingFallback() {
|
function SurfaceLoadingFallback() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
@@ -1060,18 +1034,9 @@ function Shell({
|
|||||||
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
|
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
|
const [sessionSearchOpen, setSessionSearchOpen] = useState(false);
|
||||||
const [workbenchState, setWorkbenchState] = useState(readWorkbenchState);
|
|
||||||
const [creatingPane, setCreatingPane] = useState(false);
|
|
||||||
const childPaneKeys = useMemo(
|
|
||||||
() => workbenchChildPaneKeys(workbenchState),
|
|
||||||
[workbenchState],
|
|
||||||
);
|
|
||||||
const topicSessions = useMemo(
|
|
||||||
() => sessions.filter((session) => !childPaneKeys.has(session.key)),
|
|
||||||
[childPaneKeys, sessions],
|
|
||||||
);
|
|
||||||
const [pendingDelete, setPendingDelete] = useState<{
|
const [pendingDelete, setPendingDelete] = useState<{
|
||||||
items: SidebarDeleteItem[];
|
key: string;
|
||||||
|
label: string;
|
||||||
automations?: SessionAutomationJob[];
|
automations?: SessionAutomationJob[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingRename, setPendingRename] = useState<{
|
const [pendingRename, setPendingRename] = useState<{
|
||||||
@@ -1192,17 +1157,6 @@ function Shell({
|
|||||||
}
|
}
|
||||||
}, [hostSidebarOpen]);
|
}, [hostSidebarOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
WORKBENCH_STORAGE_KEY,
|
|
||||||
JSON.stringify(workbenchState),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// ignore storage errors (private mode, etc.)
|
|
||||||
}
|
|
||||||
}, [workbenchState]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeSessionUpdateChatIds(updatedChatIds);
|
writeSessionUpdateChatIds(updatedChatIds);
|
||||||
}, [updatedChatIds]);
|
}, [updatedChatIds]);
|
||||||
@@ -1266,19 +1220,9 @@ function Shell({
|
|||||||
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
||||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||||
}, [sessions, activeKey, temporarySessions]);
|
}, [sessions, activeKey, temporarySessions]);
|
||||||
const activeTabState = useMemo(() => (
|
|
||||||
activeKey && !temporarySessions[activeKey]
|
|
||||||
? workbenchTab(workbenchState, activeKey)
|
|
||||||
: null
|
|
||||||
), [activeKey, temporarySessions, workbenchState]);
|
|
||||||
const activePaneSession = useMemo<ChatSummary | null>(() => {
|
|
||||||
if (!activeTabState) return activeSession;
|
|
||||||
return sessions.find((session) => session.key === activeTabState.activePaneKey)
|
|
||||||
?? activeSession;
|
|
||||||
}, [activeSession, activeTabState, sessions]);
|
|
||||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||||
const activeChatId = activePaneSession?.chatId ?? null;
|
const activeChatId = activeSession?.chatId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeChatIdRef.current = activeChatId;
|
activeChatIdRef.current = activeChatId;
|
||||||
if (!activeChatId) return;
|
if (!activeChatId) return;
|
||||||
@@ -1298,13 +1242,13 @@ function Shell({
|
|||||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||||
return workspaceOverrides[activeChatId];
|
return workspaceOverrides[activeChatId];
|
||||||
}
|
}
|
||||||
if (activePaneSession?.workspaceScope) {
|
if (activeSession?.workspaceScope) {
|
||||||
return activePaneSession.workspaceScope;
|
return activeSession.workspaceScope;
|
||||||
}
|
}
|
||||||
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
|
return draftWorkspaceScope ?? workspaces?.default_scope ?? null;
|
||||||
}, [
|
}, [
|
||||||
activeChatId,
|
activeChatId,
|
||||||
activePaneSession?.workspaceScope,
|
activeSession?.workspaceScope,
|
||||||
draftWorkspaceScope,
|
draftWorkspaceScope,
|
||||||
temporaryChatRequested,
|
temporaryChatRequested,
|
||||||
workspaceOverrides,
|
workspaceOverrides,
|
||||||
@@ -1340,18 +1284,6 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, [loading, sessions]);
|
}, [loading, sessions]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (loading) return;
|
|
||||||
const validKeys = new Set(sessions.map((session) => session.key));
|
|
||||||
setWorkbenchState((current) => {
|
|
||||||
const reconciled = reconcileWorkbench(current, validKeys);
|
|
||||||
if (!activeKey || temporarySessions[activeKey] || !validKeys.has(activeKey)) {
|
|
||||||
return reconciled;
|
|
||||||
}
|
|
||||||
return ensureWorkbenchTab(reconciled, activeKey);
|
|
||||||
});
|
|
||||||
}, [activeKey, loading, sessions, temporarySessions]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
|
const pendingCreatedKey = pendingCreatedSessionKeyRef.current;
|
||||||
@@ -1856,7 +1788,7 @@ function Shell({
|
|||||||
});
|
});
|
||||||
if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
|
if (activeKey === key && !sidebarState.archived_keys.includes(key)) {
|
||||||
const archived = new Set([...sidebarState.archived_keys, key]);
|
const archived = new Set([...sidebarState.archived_keys, key]);
|
||||||
const next = topicSessions.find((session) => !archived.has(session.key));
|
const next = sessions.find((session) => !archived.has(session.key));
|
||||||
navigate({
|
navigate({
|
||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: next?.key ?? null,
|
activeKey: next?.key ?? null,
|
||||||
@@ -1864,7 +1796,7 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeKey, navigate, sidebarState.archived_keys, topicSessions, updateSidebarState],
|
[activeKey, navigate, sessions, sidebarState.archived_keys, updateSidebarState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onReorderSessions = useCallback(
|
const onReorderSessions = useCallback(
|
||||||
@@ -1893,47 +1825,6 @@ function Shell({
|
|||||||
setSessionSearchOpen(true);
|
setSessionSearchOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onAddPane = useCallback(async () => {
|
|
||||||
const tabKey = activeKey;
|
|
||||||
if (
|
|
||||||
!tabKey
|
|
||||||
|| !activeSession
|
|
||||||
|| creatingPane
|
|
||||||
|| (activeTabState?.paneKeys.length ?? 0) >= MAX_WORKBENCH_PANES
|
|
||||||
|| temporarySessionsRef.current[tabKey]
|
|
||||||
) return;
|
|
||||||
setMobileSidebarOpen(false);
|
|
||||||
setSessionSearchOpen(false);
|
|
||||||
setCreatingPane(true);
|
|
||||||
try {
|
|
||||||
const scope = activeWorkspaceScope;
|
|
||||||
const chatId = await createChat(scope);
|
|
||||||
const paneKey = `websocket:${chatId}`;
|
|
||||||
setWorkbenchState((current) => addWorkbenchPane(current, tabKey, paneKey));
|
|
||||||
if (scope) {
|
|
||||||
setWorkspaceOverrides((current) => ({
|
|
||||||
...current,
|
|
||||||
[chatId]: normalizeWorkspaceScope(scope),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to create pane", error);
|
|
||||||
if (error instanceof Error && error.message.startsWith("workspace_scope_rejected:")) {
|
|
||||||
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setCreatingPane(false);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
activeKey,
|
|
||||||
activeSession,
|
|
||||||
activeTabState,
|
|
||||||
activeWorkspaceScope,
|
|
||||||
createChat,
|
|
||||||
creatingPane,
|
|
||||||
t,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||||
if (event.defaultPrevented) return;
|
if (event.defaultPrevented) return;
|
||||||
@@ -2011,15 +1902,15 @@ function Shell({
|
|||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
const nextKey = (() => {
|
const nextKey = (() => {
|
||||||
if (!activeKey) return null;
|
if (!activeKey) return null;
|
||||||
if (topicSessions.some((session) => session.key === activeKey)) return activeKey;
|
if (sessions.some((session) => session.key === activeKey)) return activeKey;
|
||||||
return topicSessions[0]?.key ?? null;
|
return sessions[0]?.key ?? null;
|
||||||
})();
|
})();
|
||||||
navigate({
|
navigate({
|
||||||
view: "chat",
|
view: "chat",
|
||||||
activeKey: nextKey,
|
activeKey: nextKey,
|
||||||
settingsSection: "overview",
|
settingsSection: "overview",
|
||||||
});
|
});
|
||||||
}, [activeKey, navigate, topicSessions]);
|
}, [activeKey, navigate, sessions]);
|
||||||
|
|
||||||
const onRestart = useCallback(() => {
|
const onRestart = useCallback(() => {
|
||||||
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
||||||
@@ -2126,43 +2017,31 @@ function Shell({
|
|||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const onTurnEnd = useDeferredTitleRefresh(
|
const onTurnEnd = useDeferredTitleRefresh(
|
||||||
temporaryChatActive ? null : activePaneSession,
|
temporaryChatActive ? null : activeSession,
|
||||||
refresh,
|
refresh,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onConfirmDelete = useCallback(async () => {
|
const onConfirmDelete = useCallback(async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
const items = pendingDelete.items;
|
const key = pendingDelete.key;
|
||||||
const deletingKeys = new Set(items.map((item) => item.key));
|
|
||||||
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
|
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
|
||||||
const deletingActive = activeKey !== null && deletingKeys.has(activeKey);
|
const deletingActive = activeKey === key;
|
||||||
const currentIndex = topicSessions.findIndex((s) => s.key === activeKey);
|
const currentIndex = sessions.findIndex((s) => s.key === key);
|
||||||
const fallbackKey = deletingActive
|
const fallbackKey = deletingActive
|
||||||
? (
|
? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null)
|
||||||
topicSessions.slice(currentIndex + 1).find((session) => (
|
|
||||||
!deletingKeys.has(session.key)
|
|
||||||
))?.key
|
|
||||||
?? topicSessions.slice(0, Math.max(0, currentIndex)).reverse().find((session) => (
|
|
||||||
!deletingKeys.has(session.key)
|
|
||||||
))?.key
|
|
||||||
?? null
|
|
||||||
)
|
|
||||||
: activeKey;
|
: activeKey;
|
||||||
try {
|
try {
|
||||||
for (let index = 0; index < items.length; index += 1) {
|
|
||||||
const item = items[index];
|
|
||||||
const result = await deleteChat(
|
const result = await deleteChat(
|
||||||
item.key,
|
key,
|
||||||
hasAutomations ? { deleteAutomations: true } : undefined,
|
hasAutomations ? { deleteAutomations: true } : undefined,
|
||||||
);
|
);
|
||||||
if (result.blocked_by_automations) {
|
if (result.blocked_by_automations) {
|
||||||
setPendingDelete({
|
setPendingDelete({
|
||||||
items: items.slice(index),
|
...pendingDelete,
|
||||||
automations: result.automations ?? [],
|
automations: result.automations ?? [],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
if (deletingActive) {
|
if (deletingActive) {
|
||||||
navigate({
|
navigate({
|
||||||
@@ -2174,24 +2053,18 @@ function Shell({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to delete session", e);
|
console.error("Failed to delete session", e);
|
||||||
}
|
}
|
||||||
}, [pendingDelete, deleteChat, activeKey, navigate, topicSessions]);
|
}, [pendingDelete, deleteChat, activeKey, navigate, sessions]);
|
||||||
|
|
||||||
const onRequestDeleteMany = useCallback(async (items: SidebarDeleteItem[]) => {
|
const onRequestDelete = useCallback(async (key: string, label: string) => {
|
||||||
const uniqueItems = Array.from(new Map(items.map((item) => [item.key, item])).values());
|
let automations: SessionAutomationJob[] = [];
|
||||||
if (uniqueItems.length === 0) return;
|
try {
|
||||||
const automationResults = await Promise.allSettled(
|
automations = await getSessionAutomations(key);
|
||||||
uniqueItems.map((item) => getSessionAutomations(item.key)),
|
} catch {
|
||||||
);
|
// Delete remains protected by the backend block; prefetch only improves the first prompt.
|
||||||
const automations = automationResults.flatMap((result) => (
|
}
|
||||||
result.status === "fulfilled" ? result.value : []
|
setPendingDelete({ key, label, automations });
|
||||||
));
|
|
||||||
setPendingDelete({ items: uniqueItems, automations });
|
|
||||||
}, [getSessionAutomations]);
|
}, [getSessionAutomations]);
|
||||||
|
|
||||||
const onRequestDelete = useCallback((key: string, label: string) => {
|
|
||||||
void onRequestDeleteMany([{ key, label }]);
|
|
||||||
}, [onRequestDeleteMany]);
|
|
||||||
|
|
||||||
const visiblePairingRequests = useMemo(
|
const visiblePairingRequests = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -2236,117 +2109,13 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const titleForSession = useCallback((session: ChatSummary) => (
|
|
||||||
sidebarState.title_overrides[session.key]
|
|
||||||
|| session.title
|
|
||||||
|| deriveTitle(session.preview, t("chat.newChat"))
|
|
||||||
), [sidebarState.title_overrides, t]);
|
|
||||||
|
|
||||||
const headerTitle = temporaryChatActive
|
const headerTitle = temporaryChatActive
|
||||||
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
||||||
: activeSession
|
: activeSession
|
||||||
? titleForSession(activeSession)
|
? sidebarState.title_overrides[activeSession.key] ||
|
||||||
|
activeSession.title ||
|
||||||
|
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||||
: t("app.brand");
|
: t("app.brand");
|
||||||
const workbenchPaneSessions = useMemo(() => {
|
|
||||||
if (!activeTabState) return [];
|
|
||||||
const byKey = new Map(sessions.map((session) => [session.key, session]));
|
|
||||||
return activeTabState.paneKeys
|
|
||||||
.map((key) => byKey.get(key))
|
|
||||||
.filter((session): session is ChatSummary => session !== undefined);
|
|
||||||
}, [activeTabState, sessions]);
|
|
||||||
const paneChromeEnabled = Boolean(
|
|
||||||
activeKey && activeSession && !temporaryChatActive && activeTabState,
|
|
||||||
);
|
|
||||||
const renderedWorkbenchPanes = useMemo(() => {
|
|
||||||
if (paneChromeEnabled && activeKey) {
|
|
||||||
return workbenchPaneSessions.map((session) => ({
|
|
||||||
key: session.key,
|
|
||||||
reactKey: session.key === activeKey ? "tab-root" : `pane:${session.key}`,
|
|
||||||
title: titleForSession(session),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [{
|
|
||||||
key: activeKey ?? "new-topic",
|
|
||||||
reactKey: "tab-root",
|
|
||||||
title: headerTitle,
|
|
||||||
}];
|
|
||||||
}, [activeKey, headerTitle, paneChromeEnabled, titleForSession, workbenchPaneSessions]);
|
|
||||||
const renderedActivePaneKey = paneChromeEnabled && activeTabState
|
|
||||||
? activeTabState.activePaneKey
|
|
||||||
: renderedWorkbenchPanes[0].key;
|
|
||||||
const renderedWorkbenchLayout = paneChromeEnabled && activeTabState
|
|
||||||
? activeTabState.layout
|
|
||||||
: "columns";
|
|
||||||
const sidebarPaneGroups = useMemo(() => {
|
|
||||||
const sessionsByKey = new Map(sessions.map((session) => [session.key, session]));
|
|
||||||
return Object.fromEntries(topicSessions.map((topic) => {
|
|
||||||
const tab = workbenchTab(workbenchState, topic.key);
|
|
||||||
const panes = tab.paneKeys
|
|
||||||
.map((key) => sessionsByKey.get(key))
|
|
||||||
.filter((session): session is ChatSummary => session !== undefined)
|
|
||||||
.map((session) => ({
|
|
||||||
key: session.key,
|
|
||||||
chatId: session.chatId,
|
|
||||||
title: titleForSession(session),
|
|
||||||
}));
|
|
||||||
return [topic.key, {
|
|
||||||
topicKey: topic.key,
|
|
||||||
activePaneKey: tab.activePaneKey,
|
|
||||||
panes,
|
|
||||||
}];
|
|
||||||
}));
|
|
||||||
}, [sessions, titleForSession, topicSessions, workbenchState]);
|
|
||||||
const attachableTabKeys = useMemo(() => (
|
|
||||||
topicSessions
|
|
||||||
.filter((session) => workbenchTab(workbenchState, session.key).paneKeys.length === 1)
|
|
||||||
.map((session) => session.key)
|
|
||||||
), [topicSessions, workbenchState]);
|
|
||||||
const paneAcceptingTabKeys = useMemo(() => (
|
|
||||||
topicSessions
|
|
||||||
.filter((session) => (
|
|
||||||
workbenchTab(workbenchState, session.key).paneKeys.length < MAX_WORKBENCH_PANES
|
|
||||||
))
|
|
||||||
.map((session) => session.key)
|
|
||||||
), [topicSessions, workbenchState]);
|
|
||||||
const activePaneLimitReached = Boolean(
|
|
||||||
activeTabState && activeTabState.paneKeys.length >= MAX_WORKBENCH_PANES,
|
|
||||||
);
|
|
||||||
|
|
||||||
const onActivateWorkbenchPane = useCallback((paneKey: string) => {
|
|
||||||
if (!activeKey) return;
|
|
||||||
setWorkbenchState((current) => focusWorkbenchPane(current, activeKey, paneKey));
|
|
||||||
}, [activeKey]);
|
|
||||||
|
|
||||||
const onSelectSidebarPane = useCallback((tabKey: string, paneKey: string) => {
|
|
||||||
setWorkbenchState((current) => focusWorkbenchPane(current, tabKey, paneKey));
|
|
||||||
if (activeKey !== tabKey) {
|
|
||||||
navigate({
|
|
||||||
view: "chat",
|
|
||||||
activeKey: tabKey,
|
|
||||||
settingsSection: "overview",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [activeKey, navigate]);
|
|
||||||
|
|
||||||
const onDetachWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
|
|
||||||
setWorkbenchState((current) => detachWorkbenchPane(current, tabKey, paneKey));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onPromoteWorkbenchPane = useCallback((tabKey: string, paneKey: string) => {
|
|
||||||
setWorkbenchState((current) => promoteWorkbenchPane(current, tabKey, paneKey));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onAttachWorkbenchPane = useCallback((paneKey: string, tabKey: string) => {
|
|
||||||
if (paneKey === tabKey) return;
|
|
||||||
setWorkbenchState((current) => attachWorkbenchPane(current, tabKey, paneKey));
|
|
||||||
if (activeKey === paneKey) {
|
|
||||||
navigate({
|
|
||||||
view: "chat",
|
|
||||||
activeKey: tabKey,
|
|
||||||
settingsSection: "overview",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [activeKey, navigate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view === "settings") {
|
if (view === "settings") {
|
||||||
@@ -2379,7 +2148,7 @@ function Shell({
|
|||||||
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
||||||
|
|
||||||
const sidebarProps = {
|
const sidebarProps = {
|
||||||
sessions: topicSessions,
|
sessions,
|
||||||
temporarySessions: temporarySessionList,
|
temporarySessions: temporarySessionList,
|
||||||
activeKey: view === "chat" ? activeKey : null,
|
activeKey: view === "chat" ? activeKey : null,
|
||||||
loading,
|
loading,
|
||||||
@@ -2388,17 +2157,9 @@ function Shell({
|
|||||||
onSelect: onSelectChat,
|
onSelect: onSelectChat,
|
||||||
onCloseTemporaryChat,
|
onCloseTemporaryChat,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
onRequestDeleteMany,
|
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onRequestRename,
|
onRequestRename,
|
||||||
onToggleArchive,
|
onToggleArchive,
|
||||||
paneGroups: sidebarPaneGroups,
|
|
||||||
onSelectPane: onSelectSidebarPane,
|
|
||||||
onDetachPane: onDetachWorkbenchPane,
|
|
||||||
onPromotePane: onPromoteWorkbenchPane,
|
|
||||||
attachableTabKeys,
|
|
||||||
paneAcceptingTabKeys,
|
|
||||||
onAttachPane: onAttachWorkbenchPane,
|
|
||||||
onReorderSessions,
|
onReorderSessions,
|
||||||
onToggleGroup,
|
onToggleGroup,
|
||||||
onRequestRenameProject,
|
onRequestRenameProject,
|
||||||
@@ -2421,9 +2182,7 @@ function Shell({
|
|||||||
updatedChatIds: updatedChatIdList,
|
updatedChatIds: updatedChatIdList,
|
||||||
viewState: sidebarState.view,
|
viewState: sidebarState.view,
|
||||||
showArchived: sidebarState.view.show_archived,
|
showArchived: sidebarState.view.show_archived,
|
||||||
archivedCount: topicSessions.filter(
|
archivedCount: sidebarState.archived_keys.length,
|
||||||
(session) => sidebarState.archived_keys.includes(session.key),
|
|
||||||
).length,
|
|
||||||
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
defaultWorkspacePath: workspaces?.default_scope.project_path ?? null,
|
||||||
};
|
};
|
||||||
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
const hostSidebarCollapsed = showHostChrome && !hostSidebarOpen;
|
||||||
@@ -2559,7 +2318,7 @@ function Shell({
|
|||||||
<SessionSearchDialog
|
<SessionSearchDialog
|
||||||
open
|
open
|
||||||
onOpenChange={setSessionSearchOpen}
|
onOpenChange={setSessionSearchOpen}
|
||||||
sessions={topicSessions}
|
sessions={sessions}
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
titleOverrides={sidebarState.title_overrides}
|
titleOverrides={sidebarState.title_overrides}
|
||||||
@@ -2578,23 +2337,6 @@ function Shell({
|
|||||||
view !== "chat" && "hidden",
|
view !== "chat" && "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PaneWorkbench
|
|
||||||
panes={renderedWorkbenchPanes}
|
|
||||||
activePaneKey={renderedActivePaneKey}
|
|
||||||
layout={renderedWorkbenchLayout}
|
|
||||||
chrome={paneChromeEnabled}
|
|
||||||
addPaneDisabled={creatingPane || activePaneLimitReached}
|
|
||||||
onActivatePane={onActivateWorkbenchPane}
|
|
||||||
onAddPane={onAddPane}
|
|
||||||
onLayoutChange={(layout) => {
|
|
||||||
if (!activeKey) return;
|
|
||||||
setWorkbenchState((current) => (
|
|
||||||
setWorkbenchLayout(current, activeKey, layout)
|
|
||||||
));
|
|
||||||
}}
|
|
||||||
renderPane={(pane, context) => {
|
|
||||||
if (!paneChromeEnabled) {
|
|
||||||
return (
|
|
||||||
<ThreadShell
|
<ThreadShell
|
||||||
session={activeSession}
|
session={activeSession}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
@@ -2607,9 +2349,7 @@ function Shell({
|
|||||||
}
|
}
|
||||||
onToggleSidebar={toggleSidebar}
|
onToggleSidebar={toggleSidebar}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
onCreateChat={
|
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat}
|
||||||
temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat
|
|
||||||
}
|
|
||||||
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
||||||
onTurnEnd={onTurnEnd}
|
onTurnEnd={onTurnEnd}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
@@ -2627,66 +2367,6 @@ function Shell({
|
|||||||
onOpenModelSettings={onOpenModelSettings}
|
onOpenModelSettings={onOpenModelSettings}
|
||||||
skills={skills}
|
skills={skills}
|
||||||
/>
|
/>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const paneSession = workbenchPaneSessions.find(
|
|
||||||
(session) => session.key === pane.key,
|
|
||||||
);
|
|
||||||
if (!paneSession) return null;
|
|
||||||
const paneScope = workspaceOverrides[paneSession.chatId]
|
|
||||||
?? paneSession.workspaceScope
|
|
||||||
?? workspaces?.default_scope
|
|
||||||
?? null;
|
|
||||||
const paneRunning = runningChatIds.has(paneSession.chatId);
|
|
||||||
return (
|
|
||||||
<ThreadShell
|
|
||||||
session={paneSession}
|
|
||||||
sessions={sessions}
|
|
||||||
title={pane.title}
|
|
||||||
onToggleSidebar={toggleSidebar}
|
|
||||||
onNewChat={onNewChat}
|
|
||||||
onCreateChat={onCreateChat}
|
|
||||||
onForkChat={onForkChat}
|
|
||||||
onTurnEnd={context.active ? onTurnEnd : () => void refresh()}
|
|
||||||
theme={theme}
|
|
||||||
onToggleTheme={toggle}
|
|
||||||
hideSidebarToggle={!context.active}
|
|
||||||
hideSidebarToggleForHostChrome={context.active}
|
|
||||||
hostChromeTitleInset={hostSidebarCollapsed}
|
|
||||||
hideThemeButton={!context.active}
|
|
||||||
hideHeaderTitle
|
|
||||||
headerActions={context.headerActions}
|
|
||||||
headerPortalTarget={context.headerPortalTarget}
|
|
||||||
headerActive={context.active}
|
|
||||||
composerPortalTarget={context.composerPortalTarget}
|
|
||||||
composerActive={context.active}
|
|
||||||
composerInputAriaLabel={t("workbench.composerAria", {
|
|
||||||
defaultValue: "Message {{title}}",
|
|
||||||
title: pane.title,
|
|
||||||
})}
|
|
||||||
workspaceScope={paneScope}
|
|
||||||
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
|
||||||
workspaceControls={workspaces?.controls ?? null}
|
|
||||||
workspaceScopeDisabled={paneRunning}
|
|
||||||
workspaceError={context.active ? workspaceError : null}
|
|
||||||
onWorkspaceScopeChange={(scope) => {
|
|
||||||
if (paneRunning) return;
|
|
||||||
const next = normalizeWorkspaceScope(scope);
|
|
||||||
setWorkspaceError(null);
|
|
||||||
setWorkspaceOverrides((current) => ({
|
|
||||||
...current,
|
|
||||||
[paneSession.chatId]: next,
|
|
||||||
}));
|
|
||||||
client.setWorkspaceScope(paneSession.chatId, next);
|
|
||||||
}}
|
|
||||||
settingsSnapshot={settingsSnapshot}
|
|
||||||
onOpenModelSettings={onOpenModelSettings}
|
|
||||||
skills={skills}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{view !== "chat" && (
|
{view !== "chat" && (
|
||||||
<div className="absolute inset-0 flex flex-col">
|
<div className="absolute inset-0 flex flex-col">
|
||||||
@@ -2718,8 +2398,7 @@ function Shell({
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<DeleteConfirm
|
<DeleteConfirm
|
||||||
open
|
open
|
||||||
title={pendingDelete.items[0]?.label ?? ""}
|
title={pendingDelete.label}
|
||||||
count={pendingDelete.items.length}
|
|
||||||
automations={pendingDelete.automations}
|
automations={pendingDelete.automations}
|
||||||
onCancel={() => setPendingDelete(null)}
|
onCancel={() => setPendingDelete(null)}
|
||||||
onConfirm={onConfirmDelete}
|
onConfirm={onConfirmDelete}
|
||||||
|
|||||||
@@ -1,33 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type DragEvent,
|
|
||||||
type RefObject,
|
type RefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
BringToFront,
|
|
||||||
CornerDownRight,
|
|
||||||
Folder,
|
Folder,
|
||||||
ListChecks,
|
|
||||||
MessageCircleDashed,
|
MessageCircleDashed,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
PanelsTopLeft,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
Pin,
|
Pin,
|
||||||
PinOff,
|
PinOff,
|
||||||
Plus,
|
Plus,
|
||||||
Square,
|
|
||||||
SquareCheckBig,
|
|
||||||
SquareMinus,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Unplug,
|
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -36,9 +25,6 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
@@ -57,13 +43,7 @@ import {
|
|||||||
visibleSessionsForGroup,
|
visibleSessionsForGroup,
|
||||||
type ChatGroupLabels,
|
type ChatGroupLabels,
|
||||||
} from "@/lib/chat-groups";
|
} from "@/lib/chat-groups";
|
||||||
import {
|
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
|
||||||
clearDraggedSession,
|
|
||||||
writeDraggedPane,
|
|
||||||
writeDraggedSession,
|
|
||||||
type DraggedPane,
|
|
||||||
} from "@/lib/session-drag";
|
|
||||||
import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model";
|
|
||||||
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
|
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
||||||
@@ -72,21 +52,6 @@ const INITIAL_VISIBLE_SESSIONS = 160;
|
|||||||
const VISIBLE_SESSIONS_INCREMENT = 160;
|
const VISIBLE_SESSIONS_INCREMENT = 160;
|
||||||
const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]";
|
const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]";
|
||||||
|
|
||||||
export interface SidebarPaneGroup {
|
|
||||||
topicKey: string;
|
|
||||||
activePaneKey: string;
|
|
||||||
panes: Array<{
|
|
||||||
key: string;
|
|
||||||
chatId: string;
|
|
||||||
title: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarDeleteItem {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatListProps {
|
interface ChatListProps {
|
||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
temporarySessions?: ChatSummary[];
|
temporarySessions?: ChatSummary[];
|
||||||
@@ -94,17 +59,9 @@ interface ChatListProps {
|
|||||||
onSelect: (key: string) => void;
|
onSelect: (key: string) => void;
|
||||||
onCloseTemporaryChat?: (key: string) => void;
|
onCloseTemporaryChat?: (key: string) => void;
|
||||||
onRequestDelete: (key: string, label: string) => void;
|
onRequestDelete: (key: string, label: string) => void;
|
||||||
onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void;
|
|
||||||
onTogglePin: (key: string) => void;
|
onTogglePin: (key: string) => void;
|
||||||
onRequestRename: (key: string, label: string) => void;
|
onRequestRename: (key: string, label: string) => void;
|
||||||
onToggleArchive: (key: string) => void;
|
onToggleArchive: (key: string) => void;
|
||||||
paneGroups?: Record<string, SidebarPaneGroup>;
|
|
||||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
onPromotePane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
attachableTabKeys?: string[];
|
|
||||||
paneAcceptingTabKeys?: string[];
|
|
||||||
onAttachPane?: (paneKey: string, tabKey: string) => void;
|
|
||||||
onReorderSessions?: (keys: string[]) => void;
|
onReorderSessions?: (keys: string[]) => void;
|
||||||
onToggleGroup?: (groupId: string) => void;
|
onToggleGroup?: (groupId: string) => void;
|
||||||
onRequestRenameProject?: (projectKey: string, label: string) => void;
|
onRequestRenameProject?: (projectKey: string, label: string) => void;
|
||||||
@@ -135,17 +92,9 @@ export const ChatList = memo(function ChatList({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onCloseTemporaryChat,
|
onCloseTemporaryChat,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
onRequestDeleteMany,
|
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onRequestRename,
|
onRequestRename,
|
||||||
onToggleArchive,
|
onToggleArchive,
|
||||||
paneGroups = {},
|
|
||||||
onSelectPane,
|
|
||||||
onDetachPane,
|
|
||||||
onPromotePane,
|
|
||||||
attachableTabKeys = [],
|
|
||||||
paneAcceptingTabKeys = [],
|
|
||||||
onAttachPane,
|
|
||||||
onReorderSessions,
|
onReorderSessions,
|
||||||
onToggleGroup,
|
onToggleGroup,
|
||||||
onRequestRenameProject,
|
onRequestRenameProject,
|
||||||
@@ -175,49 +124,7 @@ export const ChatList = memo(function ChatList({
|
|||||||
edge: "before" | "after";
|
edge: "before" | "after";
|
||||||
key: string;
|
key: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [draggedSessionHeight, setDraggedSessionHeight] = useState(0);
|
|
||||||
const [draggedPane, setDraggedPane] = useState<DraggedPane | null>(null);
|
|
||||||
const [tabAttachTargetKey, setTabAttachTargetKey] = useState<string | null>(null);
|
|
||||||
const tabAttachTargetRef = useRef<string | null>(null);
|
|
||||||
const tabRowRefs = useRef(new Map<string, HTMLLIElement>());
|
|
||||||
const pendingTabRectsRef = useRef<Map<string, DOMRect> | null>(null);
|
|
||||||
const tabLayoutAnimationsRef = useRef(new Map<string, Animation>());
|
|
||||||
const [deleteSelectionMode, setDeleteSelectionMode] = useState(false);
|
|
||||||
const [selectedDeleteKeys, setSelectedDeleteKeys] = useState<Set<string>>(
|
|
||||||
() => new Set(),
|
|
||||||
);
|
|
||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
const selectedPaneGroup = activeKey ? paneGroups[activeKey] : undefined;
|
|
||||||
const selectedRowKey = selectedPaneGroup
|
|
||||||
? selectedPaneGroup.activePaneKey
|
|
||||||
: activeKey;
|
|
||||||
const attachableTabs = useMemo(() => new Set(attachableTabKeys), [attachableTabKeys]);
|
|
||||||
const paneAcceptingTabs = useMemo(
|
|
||||||
() => new Set(paneAcceptingTabKeys),
|
|
||||||
[paneAcceptingTabKeys],
|
|
||||||
);
|
|
||||||
const deleteItemsByKey = useMemo(() => {
|
|
||||||
const items = new Map<string, SidebarDeleteItem>();
|
|
||||||
for (const group of Object.values(paneGroups)) {
|
|
||||||
for (const pane of group.panes) {
|
|
||||||
items.set(pane.key, { key: pane.key, label: pane.title });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const session of sessions) {
|
|
||||||
if (items.has(session.key)) continue;
|
|
||||||
items.set(session.key, {
|
|
||||||
key: session.key,
|
|
||||||
label: displayTitle(session, titleOverrides, t("chat.newChat")),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}, [paneGroups, sessions, t, titleOverrides]);
|
|
||||||
const paneMoveTargets = useMemo(() => sessions
|
|
||||||
.filter((session) => paneAcceptingTabs.has(session.key))
|
|
||||||
.map((session) => ({
|
|
||||||
key: session.key,
|
|
||||||
title: deleteItemsByKey.get(session.key)?.label ?? session.title ?? session.chatId,
|
|
||||||
})), [deleteItemsByKey, paneAcceptingTabs, sessions]);
|
|
||||||
const labels = useMemo<ChatGroupLabels>(() => ({
|
const labels = useMemo<ChatGroupLabels>(() => ({
|
||||||
pinned: t("chat.groups.pinned"),
|
pinned: t("chat.groups.pinned"),
|
||||||
all: t("chat.groups.all"),
|
all: t("chat.groups.all"),
|
||||||
@@ -289,80 +196,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
||||||
}, [showArchived, sort]);
|
}, [showArchived, sort]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!deleteSelectionMode) return;
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key !== "Escape") return;
|
|
||||||
setDeleteSelectionMode(false);
|
|
||||||
setSelectedDeleteKeys(new Set());
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
|
||||||
}, [deleteSelectionMode]);
|
|
||||||
|
|
||||||
const measureTabRows = useCallback(() => {
|
|
||||||
const rects = new Map<string, DOMRect>();
|
|
||||||
for (const [key, row] of tabRowRefs.current) {
|
|
||||||
rects.set(key, row.getBoundingClientRect());
|
|
||||||
}
|
|
||||||
return rects;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateTabAttachTarget = useCallback((next: string | null) => {
|
|
||||||
if (tabAttachTargetRef.current === next) return;
|
|
||||||
for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel();
|
|
||||||
tabLayoutAnimationsRef.current.clear();
|
|
||||||
pendingTabRectsRef.current = measureTabRows();
|
|
||||||
tabAttachTargetRef.current = next;
|
|
||||||
setTabAttachTargetKey(next);
|
|
||||||
}, [measureTabRows]);
|
|
||||||
|
|
||||||
const resetDragState = useCallback(() => {
|
|
||||||
clearDraggedSession();
|
|
||||||
setDraggedSessionKey(null);
|
|
||||||
setDraggedPane(null);
|
|
||||||
setSessionDropTarget(null);
|
|
||||||
updateTabAttachTarget(null);
|
|
||||||
setDraggedSessionHeight(0);
|
|
||||||
}, [updateTabAttachTarget]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const previousRects = pendingTabRectsRef.current;
|
|
||||||
if (!previousRects) return;
|
|
||||||
pendingTabRectsRef.current = null;
|
|
||||||
const nextRects = measureTabRows();
|
|
||||||
const reduceMotion = typeof window.matchMedia === "function"
|
|
||||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
||||||
if (reduceMotion) return;
|
|
||||||
for (const [key, nextRect] of nextRects) {
|
|
||||||
const previousRect = previousRects.get(key);
|
|
||||||
const row = tabRowRefs.current.get(key);
|
|
||||||
if (!previousRect || !row || typeof row.animate !== "function") continue;
|
|
||||||
const deltaY = previousRect.top - nextRect.top;
|
|
||||||
if (Math.abs(deltaY) < 0.5) continue;
|
|
||||||
const animation = row.animate(
|
|
||||||
[
|
|
||||||
{ transform: `translateY(${deltaY}px)` },
|
|
||||||
{ transform: "translateY(0)" },
|
|
||||||
],
|
|
||||||
{
|
|
||||||
duration: 180,
|
|
||||||
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
tabLayoutAnimationsRef.current.set(key, animation);
|
|
||||||
animation.addEventListener("finish", () => {
|
|
||||||
if (tabLayoutAnimationsRef.current.get(key) === animation) {
|
|
||||||
tabLayoutAnimationsRef.current.delete(key);
|
|
||||||
}
|
|
||||||
}, { once: true });
|
|
||||||
}
|
|
||||||
}, [measureTabRows, tabAttachTargetKey]);
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
for (const animation of tabLayoutAnimationsRef.current.values()) animation.cancel();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading && sessions.length === 0 && temporarySessions.length === 0) {
|
if (loading && sessions.length === 0 && temporarySessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
||||||
@@ -385,48 +218,10 @@ export const ChatList = memo(function ChatList({
|
|||||||
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
||||||
|
|
||||||
const canReorderSession = (targetKey: string) => (
|
const canReorderSession = (targetKey: string) => (
|
||||||
!deleteSelectionMode
|
!!draggedSessionKey
|
||||||
&& !!draggedSessionKey
|
|
||||||
&& draggedSessionKey !== targetKey
|
&& draggedSessionKey !== targetKey
|
||||||
&& sessionLanes.get(draggedSessionKey) === sessionLanes.get(targetKey)
|
&& sessionLanes.get(draggedSessionKey) === sessionLanes.get(targetKey)
|
||||||
);
|
);
|
||||||
const beginDeleteSelection = (keys: string[]) => {
|
|
||||||
setDeleteSelectionMode(true);
|
|
||||||
setSelectedDeleteKeys(new Set(keys.filter((key) => deleteItemsByKey.has(key))));
|
|
||||||
};
|
|
||||||
const toggleDeleteSelection = (keys: string[]) => {
|
|
||||||
setSelectedDeleteKeys((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
const validKeys = keys.filter((key) => deleteItemsByKey.has(key));
|
|
||||||
const remove = validKeys.length > 0 && validKeys.every((key) => next.has(key));
|
|
||||||
for (const key of validKeys) {
|
|
||||||
if (remove) next.delete(key);
|
|
||||||
else next.add(key);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const closeDeleteSelection = () => {
|
|
||||||
setDeleteSelectionMode(false);
|
|
||||||
setSelectedDeleteKeys(new Set());
|
|
||||||
};
|
|
||||||
const requestDeleteItems = (items: SidebarDeleteItem[]) => {
|
|
||||||
if (items.length === 0) return;
|
|
||||||
if (onRequestDeleteMany) onRequestDeleteMany(items);
|
|
||||||
else if (items.length === 1) onRequestDelete(items[0].key, items[0].label);
|
|
||||||
};
|
|
||||||
const requestDeleteKeys = (keys: string[]) => {
|
|
||||||
requestDeleteItems(keys
|
|
||||||
.map((key) => deleteItemsByKey.get(key))
|
|
||||||
.filter((item): item is SidebarDeleteItem => item !== undefined));
|
|
||||||
};
|
|
||||||
const confirmDeleteSelection = () => {
|
|
||||||
requestDeleteKeys(Array.from(selectedDeleteKeys));
|
|
||||||
closeDeleteSelection();
|
|
||||||
};
|
|
||||||
const draggedItemTitle = draggedPane
|
|
||||||
? deleteItemsByKey.get(draggedPane.paneKey)?.label
|
|
||||||
: draggedSessionKey ? deleteItemsByKey.get(draggedSessionKey)?.label : undefined;
|
|
||||||
const reorderSession = (targetKey: string, edge: "before" | "after") => {
|
const reorderSession = (targetKey: string, edge: "before" | "after") => {
|
||||||
if (!draggedSessionKey || !canReorderSession(targetKey) || !onReorderSessions) return;
|
if (!draggedSessionKey || !canReorderSession(targetKey) || !onReorderSessions) return;
|
||||||
const keys = groups.flatMap((group) => group.sessions.map((session) => session.key));
|
const keys = groups.flatMap((group) => group.sessions.map((session) => session.key));
|
||||||
@@ -445,7 +240,7 @@ export const ChatList = memo(function ChatList({
|
|||||||
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
|
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
|
||||||
<SidebarSelectionHighlight
|
<SidebarSelectionHighlight
|
||||||
targetRef={activeRowRef}
|
targetRef={activeRowRef}
|
||||||
activeId={draggedSessionKey || draggedPane ? null : selectedRowKey}
|
activeId={activeKey}
|
||||||
scope="sessions"
|
scope="sessions"
|
||||||
data-chat-list-content
|
data-chat-list-content
|
||||||
className="relative min-w-0 space-y-3 px-2 py-1.5"
|
className="relative min-w-0 space-y-3 px-2 py-1.5"
|
||||||
@@ -470,12 +265,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
);
|
);
|
||||||
const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length);
|
const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length);
|
||||||
const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
|
const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
|
||||||
const reorderOffsets = sessionReorderOffsets(
|
|
||||||
visibleSessions.map((session) => session.key),
|
|
||||||
draggedSessionKey,
|
|
||||||
sessionDropTarget,
|
|
||||||
draggedSessionHeight,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section key={group.id} aria-label={group.label} className="relative z-[1]">
|
<section key={group.id} aria-label={group.label} className="relative z-[1]">
|
||||||
@@ -509,28 +298,12 @@ export const ChatList = memo(function ChatList({
|
|||||||
{group.kind === "project" && collapsedGroups[group.id] ? null : (
|
{group.kind === "project" && collapsedGroups[group.id] ? null : (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{visibleSessions.map((s) => {
|
{visibleSessions.map((s) => {
|
||||||
const topicActive = s.key === activeKey;
|
const active = s.key === activeKey;
|
||||||
const paneGroup = paneGroups[s.key];
|
|
||||||
const fallbackTitle = t("chat.fallbackTitle", {
|
const fallbackTitle = t("chat.fallbackTitle", {
|
||||||
id: s.chatId.slice(0, 6),
|
id: s.chatId.slice(0, 6),
|
||||||
});
|
});
|
||||||
const generatedTitle = s.title?.trim() || "";
|
const generatedTitle = s.title?.trim() || "";
|
||||||
const title = displayTitle(s, titleOverrides, t("chat.newChat"));
|
const title = displayTitle(s, titleOverrides, t("chat.newChat"));
|
||||||
const resolvedPaneGroup = paneGroup ?? {
|
|
||||||
topicKey: s.key,
|
|
||||||
activePaneKey: s.key,
|
|
||||||
panes: [{ key: s.key, chatId: s.chatId, title }],
|
|
||||||
};
|
|
||||||
const active = topicActive && resolvedPaneGroup.activePaneKey === s.key;
|
|
||||||
const paneCount = resolvedPaneGroup.panes.length;
|
|
||||||
const tabDeleteKeys = resolvedPaneGroup.panes.map((pane) => pane.key);
|
|
||||||
const tabSelected = tabDeleteKeys.every((key) => (
|
|
||||||
selectedDeleteKeys.has(key)
|
|
||||||
));
|
|
||||||
const tabPartiallySelected = !tabSelected && tabDeleteKeys.some((key) => (
|
|
||||||
selectedDeleteKeys.has(key)
|
|
||||||
));
|
|
||||||
const isAttachTarget = tabAttachTargetKey === s.key;
|
|
||||||
const tooltipTitle =
|
const tooltipTitle =
|
||||||
titleOverrides[s.key]?.trim() ||
|
titleOverrides[s.key]?.trim() ||
|
||||||
generatedTitle ||
|
generatedTitle ||
|
||||||
@@ -545,83 +318,24 @@ export const ChatList = memo(function ChatList({
|
|||||||
const projectMode = group.kind === "project";
|
const projectMode = group.kind === "project";
|
||||||
const activityState = running.has(s.chatId)
|
const activityState = running.has(s.chatId)
|
||||||
? "running"
|
? "running"
|
||||||
: updated.has(s.chatId) && !topicActive
|
: updated.has(s.chatId) && !active
|
||||||
? "updated"
|
? "updated"
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={s.key}
|
key={s.key}
|
||||||
ref={(element) => {
|
className="relative min-w-0"
|
||||||
if (element) tabRowRefs.current.set(s.key, element);
|
|
||||||
else tabRowRefs.current.delete(s.key);
|
|
||||||
}}
|
|
||||||
data-session-dragging={draggedSessionKey === s.key ? "true" : undefined}
|
|
||||||
data-session-displaced={reorderOffsets.has(s.key) ? "true" : undefined}
|
|
||||||
data-tab-attach-target={tabAttachTargetKey === s.key ? "true" : undefined}
|
|
||||||
className={cn(
|
|
||||||
"relative min-w-0 rounded-xl transition-[transform,opacity,background-color,box-shadow] duration-200 [transition-timing-function:cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none",
|
|
||||||
draggedSessionKey === s.key && "opacity-0",
|
|
||||||
isAttachTarget
|
|
||||||
&& "bg-sidebar-accent/35 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
transform: reorderOffsets.has(s.key)
|
|
||||||
? `translateY(${reorderOffsets.get(s.key)}px)`
|
|
||||||
: undefined,
|
|
||||||
}}
|
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
|
||||||
const relativeY = rect.height > 0
|
|
||||||
? (event.clientY - rect.top) / rect.height
|
|
||||||
: 0.5;
|
|
||||||
const paneCanAttach = Boolean(
|
|
||||||
!deleteSelectionMode
|
|
||||||
&& draggedPane
|
|
||||||
&& draggedPane.sourceTabKey !== s.key
|
|
||||||
&& paneAcceptingTabs.has(s.key)
|
|
||||||
&& onAttachPane,
|
|
||||||
);
|
|
||||||
const tabCanAttach = Boolean(
|
|
||||||
!deleteSelectionMode
|
|
||||||
&& draggedSessionKey
|
|
||||||
&& draggedSessionKey !== s.key
|
|
||||||
&& attachableTabs.has(draggedSessionKey)
|
|
||||||
&& paneAcceptingTabs.has(s.key)
|
|
||||||
&& relativeY >= 0.25
|
|
||||||
&& relativeY <= 0.75
|
|
||||||
&& onAttachPane,
|
|
||||||
);
|
|
||||||
if (paneCanAttach || tabCanAttach) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.dataTransfer.dropEffect = "move";
|
|
||||||
setSessionDropTarget(null);
|
|
||||||
updateTabAttachTarget(s.key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updateTabAttachTarget(null);
|
|
||||||
if (!canReorderSession(s.key)) return;
|
if (!canReorderSession(s.key)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = "move";
|
event.dataTransfer.dropEffect = "move";
|
||||||
const nextTarget = {
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
setSessionDropTarget({
|
||||||
key: s.key,
|
key: s.key,
|
||||||
edge: event.clientY < rect.top + rect.height / 2 ? "before" : "after",
|
edge: event.clientY < rect.top + rect.height / 2 ? "before" : "after",
|
||||||
} as const;
|
});
|
||||||
setSessionDropTarget((current) => (
|
|
||||||
current?.key === nextTarget.key && current.edge === nextTarget.edge
|
|
||||||
? current
|
|
||||||
: nextTarget
|
|
||||||
));
|
|
||||||
}}
|
}}
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
if (tabAttachTargetKey === s.key && onAttachPane) {
|
|
||||||
const paneKey = draggedPane?.paneKey ?? draggedSessionKey;
|
|
||||||
if (paneKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
onAttachPane(paneKey, s.key);
|
|
||||||
}
|
|
||||||
resetDragState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!canReorderSession(s.key)) return;
|
if (!canReorderSession(s.key)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
@@ -629,13 +343,23 @@ export const ChatList = memo(function ChatList({
|
|||||||
? "before"
|
? "before"
|
||||||
: "after";
|
: "after";
|
||||||
reorderSession(s.key, edge);
|
reorderSession(s.key, edge);
|
||||||
resetDragState();
|
setDraggedSessionKey(null);
|
||||||
|
setSessionDropTarget(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{sessionDropTarget?.key === s.key ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-session-drop-edge={sessionDropTarget.edge}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute inset-x-2 z-20 h-0.5 rounded-full bg-primary",
|
||||||
|
sessionDropTarget.edge === "before" ? "-top-px" : "-bottom-px",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div
|
<div
|
||||||
ref={active ? activeRowRef : undefined}
|
ref={active ? activeRowRef : undefined}
|
||||||
data-chat-row={s.key}
|
data-chat-row={s.key}
|
||||||
data-sidebar-tab={s.key}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
|
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
|
||||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||||
@@ -643,75 +367,36 @@ export const ChatList = memo(function ChatList({
|
|||||||
active
|
active
|
||||||
? "text-sidebar-accent-foreground"
|
? "text-sidebar-accent-foreground"
|
||||||
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
||||||
isAttachTarget
|
|
||||||
&& "bg-sidebar-accent/65 text-sidebar-accent-foreground",
|
|
||||||
deleteSelectionMode && (tabSelected || tabPartiallySelected)
|
|
||||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => onSelect(s.key)}
|
||||||
if (deleteSelectionMode) {
|
draggable
|
||||||
toggleDeleteSelection(tabDeleteKeys);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (topicActive && paneGroup && onSelectPane) {
|
|
||||||
onSelectPane(s.key, s.key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSelect(s.key);
|
|
||||||
}}
|
|
||||||
draggable={!deleteSelectionMode}
|
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
setDraggedSessionKey(s.key);
|
setDraggedSessionKey(s.key);
|
||||||
setDraggedPane(null);
|
|
||||||
setSessionDropTarget(null);
|
setSessionDropTarget(null);
|
||||||
updateTabAttachTarget(null);
|
|
||||||
setDraggedSessionHeight(
|
|
||||||
event.currentTarget.closest("li")?.getBoundingClientRect().height
|
|
||||||
?? event.currentTarget.getBoundingClientRect().height,
|
|
||||||
);
|
|
||||||
writeDraggedSession(event.dataTransfer, s.key);
|
writeDraggedSession(event.dataTransfer, s.key);
|
||||||
}}
|
}}
|
||||||
onDragEnd={resetDragState}
|
onDragEnd={() => {
|
||||||
|
clearDraggedSession();
|
||||||
|
setDraggedSessionKey(null);
|
||||||
|
setSessionDropTarget(null);
|
||||||
|
}}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
aria-pressed={deleteSelectionMode ? tabSelected : undefined}
|
|
||||||
title={tooltipTitle}
|
title={tooltipTitle}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-w-0 flex-1 items-center gap-2 overflow-hidden text-left",
|
"min-w-0 flex-1 overflow-hidden text-left",
|
||||||
deleteSelectionMode
|
"cursor-grab active:cursor-grabbing",
|
||||||
? "cursor-default"
|
|
||||||
: "cursor-grab active:cursor-grabbing",
|
|
||||||
compact ? "py-1" : "py-1.5",
|
compact ? "py-1" : "py-1.5",
|
||||||
projectMode && "pl-7",
|
projectMode && "pl-7",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{deleteSelectionMode ? (
|
|
||||||
<SelectionIndicator
|
|
||||||
checked={tabSelected}
|
|
||||||
partial={tabPartiallySelected}
|
|
||||||
/>
|
|
||||||
) : paneCount > 1 || isAttachTarget ? (
|
|
||||||
<PanelsTopLeft
|
|
||||||
aria-hidden
|
|
||||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<span className="min-w-0 flex-1 overflow-hidden">
|
|
||||||
{projectMode ? (
|
{projectMode ? (
|
||||||
<span className="flex w-full min-w-0 items-baseline gap-2">
|
<span className="flex w-full min-w-0 items-baseline gap-2">
|
||||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{paneCount > 1 ? (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="shrink-0 text-[10.5px] tabular-nums text-muted-foreground/55"
|
|
||||||
>
|
|
||||||
{paneCount}/{MAX_WORKBENCH_PANES}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null}
|
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null}
|
||||||
{timestamp ? (
|
{timestamp ? (
|
||||||
<span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
|
<span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
|
||||||
@@ -724,14 +409,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{paneCount > 1 ? (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="shrink-0 text-[10.5px] tabular-nums text-muted-foreground/55"
|
|
||||||
>
|
|
||||||
{paneCount}/{MAX_WORKBENCH_PANES}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null}
|
{isPinned ? <PinnedChatIndicator label={labels.pinned} /> : null}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -745,16 +422,15 @@ export const ChatList = memo(function ChatList({
|
|||||||
{timestamp}
|
{timestamp}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
<SessionActivityIndicator state={activityState} />
|
<SessionActivityIndicator state={activityState} />
|
||||||
{!deleteSelectionMode ? <DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity",
|
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity",
|
||||||
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
|
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
|
||||||
"focus-visible:opacity-100",
|
"focus-visible:opacity-100",
|
||||||
topicActive && "opacity-100",
|
active && "opacity-100",
|
||||||
)}
|
)}
|
||||||
aria-label={t("chat.actions", { title })}
|
aria-label={t("chat.actions", { title })}
|
||||||
>
|
>
|
||||||
@@ -766,17 +442,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
portalContainer={actionMenuPortalContainer}
|
portalContainer={actionMenuPortalContainer}
|
||||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
{paneGroup
|
|
||||||
&& paneGroup.panes.findIndex((pane) => pane.key === s.key) > 0
|
|
||||||
&& onPromotePane ? (
|
|
||||||
<DropdownMenuItem onSelect={() => onPromotePane(s.key, s.key)}>
|
|
||||||
<BringToFront className="h-4 w-4 shrink-0" />
|
|
||||||
{t("workbench.promotePane", {
|
|
||||||
defaultValue: "Make {{title}} the primary pane",
|
|
||||||
title,
|
|
||||||
})}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => onTogglePin(s.key)}
|
onSelect={() => onTogglePin(s.key)}
|
||||||
>
|
>
|
||||||
@@ -803,71 +468,18 @@ export const ChatList = memo(function ChatList({
|
|||||||
)}
|
)}
|
||||||
{isArchived ? t("chat.unarchive") : t("chat.archive")}
|
{isArchived ? t("chat.unarchive") : t("chat.archive")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{attachableTabs.has(s.key) && onAttachPane ? (
|
|
||||||
<MoveToTabSubmenu
|
|
||||||
targets={paneMoveTargets.filter((target) => target.key !== s.key)}
|
|
||||||
onMove={(targetKey) => onAttachPane(s.key, targetKey)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => beginDeleteSelection(tabDeleteKeys)}
|
|
||||||
>
|
|
||||||
<ListChecks className="h-4 w-4 shrink-0" />
|
|
||||||
{t("chat.select", { defaultValue: "Select" })}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
tone="destructive"
|
tone="destructive"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
window.setTimeout(() => requestDeleteKeys(tabDeleteKeys), 0);
|
window.setTimeout(() => onRequestDelete(s.key, title), 0);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 shrink-0" />
|
<Trash2 className="h-4 w-4 shrink-0" />
|
||||||
{t("chat.delete")}
|
{t("chat.delete")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu> : null}
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
{paneCount > 1 || isAttachTarget ? (
|
|
||||||
<ActivePaneRows
|
|
||||||
group={resolvedPaneGroup}
|
|
||||||
tabTitle={title}
|
|
||||||
tabActive={topicActive}
|
|
||||||
activeRowRef={activeRowRef}
|
|
||||||
running={running}
|
|
||||||
updated={updated}
|
|
||||||
onSelectPane={onSelectPane}
|
|
||||||
onRequestDelete={onRequestDelete}
|
|
||||||
onRequestRename={onRequestRename}
|
|
||||||
onDetachPane={onDetachPane}
|
|
||||||
onPromotePane={onPromotePane}
|
|
||||||
moveTargets={paneMoveTargets.filter((target) => (
|
|
||||||
target.key !== resolvedPaneGroup.topicKey
|
|
||||||
))}
|
|
||||||
onAttachPane={onAttachPane}
|
|
||||||
deleteSelectionMode={deleteSelectionMode}
|
|
||||||
selectedDeleteKeys={selectedDeleteKeys}
|
|
||||||
onToggleDeleteSelection={toggleDeleteSelection}
|
|
||||||
onBeginDeleteSelection={beginDeleteSelection}
|
|
||||||
dropPreview={isAttachTarget && draggedItemTitle ? {
|
|
||||||
paneTitle: draggedItemTitle,
|
|
||||||
targetTitle: title,
|
|
||||||
} : null}
|
|
||||||
draggedPaneKey={draggedPane?.paneKey ?? null}
|
|
||||||
onPaneDragStart={(event, pane) => {
|
|
||||||
setDraggedPane(pane);
|
|
||||||
setDraggedSessionKey(null);
|
|
||||||
setSessionDropTarget(null);
|
|
||||||
updateTabAttachTarget(null);
|
|
||||||
setDraggedSessionHeight(
|
|
||||||
event.currentTarget.closest("li")?.getBoundingClientRect().height
|
|
||||||
?? event.currentTarget.getBoundingClientRect().height,
|
|
||||||
);
|
|
||||||
writeDraggedPane(event.dataTransfer, pane);
|
|
||||||
}}
|
|
||||||
onPaneDragEnd={resetDragState}
|
|
||||||
actionMenuPortalContainer={actionMenuPortalContainer}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -898,329 +510,11 @@ export const ChatList = memo(function ChatList({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{deleteSelectionMode ? (
|
|
||||||
<div
|
|
||||||
data-testid="delete-selection-bar"
|
|
||||||
className="sticky bottom-2 z-30 mx-1 mt-3 flex min-h-11 items-center gap-2 rounded-2xl border border-sidebar-border/80 bg-popover/95 p-1.5 pl-2 shadow-[0_10px_30px_rgba(15,23,42,0.14)] backdrop-blur-xl"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeDeleteSelection}
|
|
||||||
aria-label={t("chat.cancelSelection", {
|
|
||||||
defaultValue: "Cancel selection",
|
|
||||||
})}
|
|
||||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" aria-hidden />
|
|
||||||
</button>
|
|
||||||
<span className="min-w-0 flex-1 truncate px-1 text-[12.5px] font-medium text-foreground/85">
|
|
||||||
{t("chat.selectedCount", {
|
|
||||||
defaultValue: "{{count}} selected",
|
|
||||||
count: selectedDeleteKeys.size,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={selectedDeleteKeys.size === 0}
|
|
||||||
onClick={confirmDeleteSelection}
|
|
||||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full bg-destructive px-3 text-[12px] font-semibold text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:pointer-events-none disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
{t("chat.deleteSelected", { defaultValue: "Delete" })}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</SidebarSelectionHighlight>
|
</SidebarSelectionHighlight>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function sessionReorderOffsets(
|
|
||||||
keys: string[],
|
|
||||||
draggedKey: string | null,
|
|
||||||
target: { edge: "before" | "after"; key: string } | null,
|
|
||||||
draggedHeight: number,
|
|
||||||
): Map<string, number> {
|
|
||||||
const offsets = new Map<string, number>();
|
|
||||||
if (!draggedKey || !target || draggedHeight <= 0) return offsets;
|
|
||||||
const sourceIndex = keys.indexOf(draggedKey);
|
|
||||||
if (sourceIndex < 0 || target.key === draggedKey) return offsets;
|
|
||||||
const remaining = keys.filter((key) => key !== draggedKey);
|
|
||||||
const targetIndex = remaining.indexOf(target.key);
|
|
||||||
if (targetIndex < 0) return offsets;
|
|
||||||
const finalIndex = targetIndex + (target.edge === "after" ? 1 : 0);
|
|
||||||
|
|
||||||
if (sourceIndex < finalIndex) {
|
|
||||||
for (let index = sourceIndex + 1; index <= finalIndex; index += 1) {
|
|
||||||
offsets.set(keys[index], -draggedHeight);
|
|
||||||
}
|
|
||||||
} else if (sourceIndex > finalIndex) {
|
|
||||||
for (let index = finalIndex; index < sourceIndex; index += 1) {
|
|
||||||
offsets.set(keys[index], draggedHeight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return offsets;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActivePaneRows({
|
|
||||||
group,
|
|
||||||
tabTitle,
|
|
||||||
tabActive,
|
|
||||||
activeRowRef,
|
|
||||||
running,
|
|
||||||
updated,
|
|
||||||
onSelectPane,
|
|
||||||
onRequestDelete,
|
|
||||||
onRequestRename,
|
|
||||||
onDetachPane,
|
|
||||||
onPromotePane,
|
|
||||||
moveTargets,
|
|
||||||
onAttachPane,
|
|
||||||
deleteSelectionMode,
|
|
||||||
selectedDeleteKeys,
|
|
||||||
onToggleDeleteSelection,
|
|
||||||
onBeginDeleteSelection,
|
|
||||||
dropPreview,
|
|
||||||
draggedPaneKey,
|
|
||||||
onPaneDragStart,
|
|
||||||
onPaneDragEnd,
|
|
||||||
actionMenuPortalContainer,
|
|
||||||
}: {
|
|
||||||
group: SidebarPaneGroup;
|
|
||||||
tabTitle: string;
|
|
||||||
tabActive: boolean;
|
|
||||||
activeRowRef: RefObject<HTMLDivElement>;
|
|
||||||
running: ReadonlySet<string>;
|
|
||||||
updated: ReadonlySet<string>;
|
|
||||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
onRequestDelete: (key: string, label: string) => void;
|
|
||||||
onRequestRename: (key: string, label: string) => void;
|
|
||||||
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
onPromotePane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
moveTargets: Array<{ key: string; title: string }>;
|
|
||||||
onAttachPane?: (paneKey: string, tabKey: string) => void;
|
|
||||||
deleteSelectionMode: boolean;
|
|
||||||
selectedDeleteKeys: ReadonlySet<string>;
|
|
||||||
onToggleDeleteSelection: (keys: string[]) => void;
|
|
||||||
onBeginDeleteSelection: (keys: string[]) => void;
|
|
||||||
dropPreview: { paneTitle: string; targetTitle: string } | null;
|
|
||||||
draggedPaneKey: string | null;
|
|
||||||
onPaneDragStart: (event: DragEvent<HTMLButtonElement>, pane: DraggedPane) => void;
|
|
||||||
onPaneDragEnd: () => void;
|
|
||||||
actionMenuPortalContainer?: HTMLElement | null;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const childPanes = group.panes.filter((pane) => pane.key !== group.topicKey);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul
|
|
||||||
aria-label={t("workbench.panesInTab", {
|
|
||||||
defaultValue: "Panes in {{title}}",
|
|
||||||
title: tabTitle,
|
|
||||||
})}
|
|
||||||
className={cn(
|
|
||||||
"relative ml-5 mr-1 mt-0.5 space-y-0.5 rounded-bl-lg border-l border-sidebar-border/60 py-0.5 pl-2 pr-0.5",
|
|
||||||
dropPreview && "pb-1",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{childPanes.map((pane) => {
|
|
||||||
const index = group.panes.findIndex((candidate) => candidate.key === pane.key);
|
|
||||||
const active = tabActive && pane.key === group.activePaneKey;
|
|
||||||
const activityState = running.has(pane.chatId)
|
|
||||||
? "running"
|
|
||||||
: updated.has(pane.chatId) && !active
|
|
||||||
? "updated"
|
|
||||||
: null;
|
|
||||||
const paneActionsLabel = t("workbench.paneActions", {
|
|
||||||
defaultValue: "{{title}} pane actions",
|
|
||||||
title: pane.title,
|
|
||||||
});
|
|
||||||
const selected = selectedDeleteKeys.has(pane.key);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={pane.key}
|
|
||||||
data-pane-dragging={draggedPaneKey === pane.key ? "true" : undefined}
|
|
||||||
className="relative min-w-0 before:absolute before:-left-2 before:top-1/2 before:h-px before:w-2 before:bg-sidebar-border/45"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={active ? activeRowRef : undefined}
|
|
||||||
data-chat-row={pane.key}
|
|
||||||
data-sidebar-pane={pane.key}
|
|
||||||
className={cn(
|
|
||||||
"group/pane flex min-h-7 min-w-0 items-center gap-1 rounded-lg px-2 text-[12.5px]",
|
|
||||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
|
||||||
active
|
|
||||||
? "text-sidebar-accent-foreground"
|
|
||||||
: "text-sidebar-foreground/72 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
|
||||||
deleteSelectionMode && selected
|
|
||||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (deleteSelectionMode) {
|
|
||||||
onToggleDeleteSelection([pane.key]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSelectPane?.(group.topicKey, pane.key);
|
|
||||||
}}
|
|
||||||
draggable={!deleteSelectionMode}
|
|
||||||
onDragStart={(event) => onPaneDragStart(event, {
|
|
||||||
paneKey: pane.key,
|
|
||||||
sourceTabKey: group.topicKey,
|
|
||||||
})}
|
|
||||||
onDragEnd={onPaneDragEnd}
|
|
||||||
aria-current={active ? "true" : undefined}
|
|
||||||
aria-pressed={deleteSelectionMode ? selected : undefined}
|
|
||||||
title={pane.title}
|
|
||||||
className={cn(
|
|
||||||
"flex min-w-0 flex-1 items-center gap-2 py-1 text-left font-medium leading-5",
|
|
||||||
deleteSelectionMode ? "cursor-default" : "cursor-grab active:cursor-grabbing",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{deleteSelectionMode ? (
|
|
||||||
<SelectionIndicator checked={selected} partial={false} />
|
|
||||||
) : null}
|
|
||||||
<span className="min-w-0 flex-1 truncate">{pane.title}</span>
|
|
||||||
</button>
|
|
||||||
<SessionActivityIndicator state={activityState} />
|
|
||||||
{!deleteSelectionMode ? <DropdownMenu modal={false}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
className={cn(
|
|
||||||
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity",
|
|
||||||
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover/pane:opacity-100",
|
|
||||||
"focus-visible:opacity-100",
|
|
||||||
active && "opacity-100",
|
|
||||||
)}
|
|
||||||
aria-label={paneActionsLabel}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
className={ACTION_MENU_CONTENT_CLASS}
|
|
||||||
portalContainer={actionMenuPortalContainer}
|
|
||||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
{index > 0 && onPromotePane ? (
|
|
||||||
<DropdownMenuItem onSelect={() => onPromotePane(group.topicKey, pane.key)}>
|
|
||||||
<BringToFront className="h-4 w-4 shrink-0" />
|
|
||||||
{t("workbench.promotePane", {
|
|
||||||
defaultValue: "Make {{title}} the primary pane",
|
|
||||||
title: pane.title,
|
|
||||||
})}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => onRequestRename(pane.key, pane.title)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4 shrink-0" />
|
|
||||||
{t("chat.rename")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{onDetachPane ? (
|
|
||||||
<DropdownMenuItem onSelect={() => onDetachPane(group.topicKey, pane.key)}>
|
|
||||||
<Unplug className="h-4 w-4 shrink-0" />
|
|
||||||
{t("workbench.detachPane", {
|
|
||||||
defaultValue: "Move {{title}} to its own topic",
|
|
||||||
title: pane.title,
|
|
||||||
})}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{onAttachPane ? (
|
|
||||||
<MoveToTabSubmenu
|
|
||||||
targets={moveTargets}
|
|
||||||
onMove={(targetKey) => onAttachPane(pane.key, targetKey)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => onBeginDeleteSelection([pane.key])}
|
|
||||||
>
|
|
||||||
<ListChecks className="h-4 w-4 shrink-0" />
|
|
||||||
{t("chat.select", { defaultValue: "Select" })}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
tone="destructive"
|
|
||||||
onSelect={() => {
|
|
||||||
window.setTimeout(() => onRequestDelete(pane.key, pane.title), 0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 shrink-0" />
|
|
||||||
{t("chat.delete")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu> : null}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{dropPreview ? (
|
|
||||||
<li
|
|
||||||
data-pane-drop-preview
|
|
||||||
role="status"
|
|
||||||
aria-label={t("workbench.dropPane", {
|
|
||||||
defaultValue: "Move {{pane}} into {{tab}}",
|
|
||||||
pane: dropPreview.paneTitle,
|
|
||||||
tab: dropPreview.targetTitle,
|
|
||||||
})}
|
|
||||||
className="relative min-w-0 before:absolute before:-left-2 before:top-1/2 before:h-px before:w-2 before:bg-primary/45"
|
|
||||||
>
|
|
||||||
<div className="flex min-h-7 items-center gap-2 rounded-lg border border-primary/30 bg-primary/[0.07] px-2 text-[12.5px] font-medium text-foreground/80 shadow-[inset_0_0_0_1px_hsl(var(--background)/0.5)] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-150">
|
|
||||||
<CornerDownRight className="h-3.5 w-3.5 shrink-0 text-primary/75" aria-hidden />
|
|
||||||
<span className="min-w-0 flex-1 truncate">{dropPreview.paneTitle}</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectionIndicator({
|
|
||||||
checked,
|
|
||||||
partial,
|
|
||||||
}: {
|
|
||||||
checked: boolean;
|
|
||||||
partial: boolean;
|
|
||||||
}) {
|
|
||||||
const Icon = partial ? SquareMinus : checked ? SquareCheckBig : Square;
|
|
||||||
return (
|
|
||||||
<Icon
|
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 shrink-0",
|
|
||||||
checked || partial ? "text-primary" : "text-muted-foreground/55",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MoveToTabSubmenu({
|
|
||||||
targets,
|
|
||||||
onMove,
|
|
||||||
}: {
|
|
||||||
targets: Array<{ key: string; title: string }>;
|
|
||||||
onMove: (targetKey: string) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
if (targets.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger>
|
|
||||||
<PanelsTopLeft className="h-4 w-4 shrink-0" aria-hidden />
|
|
||||||
{t("workbench.moveToTab", { defaultValue: "Move to tab" })}
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent>
|
|
||||||
{targets.map((target) => (
|
|
||||||
<DropdownMenuItem key={target.key} onSelect={() => onMove(target.key)}>
|
|
||||||
<span className="max-w-56 truncate">{target.title}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TemporaryChatSection({
|
function TemporaryChatSection({
|
||||||
sessions,
|
sessions,
|
||||||
activeKey,
|
activeKey,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import type { SessionAutomationJob } from "@/lib/types";
|
|||||||
interface DeleteConfirmProps {
|
interface DeleteConfirmProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
count?: number;
|
|
||||||
automations?: SessionAutomationJob[];
|
automations?: SessionAutomationJob[];
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
@@ -27,7 +26,6 @@ interface DeleteConfirmProps {
|
|||||||
export function DeleteConfirm({
|
export function DeleteConfirm({
|
||||||
open,
|
open,
|
||||||
title,
|
title,
|
||||||
count = 1,
|
|
||||||
automations = [],
|
automations = [],
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
@@ -35,7 +33,6 @@ export function DeleteConfirm({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const locale = currentLocale();
|
const locale = currentLocale();
|
||||||
const hasAutomations = automations.length > 0;
|
const hasAutomations = automations.length > 0;
|
||||||
const multiple = count > 1;
|
|
||||||
const visibleAutomations = automations.slice(0, 4);
|
const visibleAutomations = automations.slice(0, 4);
|
||||||
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
|
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
|
||||||
return (
|
return (
|
||||||
@@ -50,24 +47,11 @@ export function DeleteConfirm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
<AlertDialogTitle className="text-center text-[20px] font-semibold leading-tight tracking-[-0.02em] text-foreground">
|
||||||
{multiple
|
{t("deleteConfirm.title", { title })}
|
||||||
? t("deleteConfirm.titleMany", {
|
|
||||||
defaultValue: "Delete {{count}} topics and panes?",
|
|
||||||
count,
|
|
||||||
})
|
|
||||||
: t("deleteConfirm.title", { title })}
|
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
|
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
|
||||||
{hasAutomations
|
{hasAutomations
|
||||||
? multiple
|
? t("deleteConfirm.automationsDescription")
|
||||||
? t("deleteConfirm.automationsDescriptionMany", {
|
|
||||||
defaultValue: "Linked automations will also be deleted.",
|
|
||||||
})
|
|
||||||
: t("deleteConfirm.automationsDescription")
|
|
||||||
: multiple
|
|
||||||
? t("deleteConfirm.descriptionMany", {
|
|
||||||
defaultValue: "This action cannot be undone.",
|
|
||||||
})
|
|
||||||
: t("deleteConfirm.description")}
|
: t("deleteConfirm.description")}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
{hasAutomations ? (
|
{hasAutomations ? (
|
||||||
|
|||||||
@@ -16,11 +16,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import { ChatList } from "@/components/ChatList";
|
||||||
ChatList,
|
|
||||||
type SidebarDeleteItem,
|
|
||||||
type SidebarPaneGroup,
|
|
||||||
} from "@/components/ChatList";
|
|
||||||
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
||||||
import {
|
import {
|
||||||
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
|
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
|
||||||
@@ -43,17 +39,9 @@ interface SidebarProps {
|
|||||||
onSelect: (key: string) => void;
|
onSelect: (key: string) => void;
|
||||||
onCloseTemporaryChat?: (key: string) => void;
|
onCloseTemporaryChat?: (key: string) => void;
|
||||||
onRequestDelete: (key: string, label: string) => void;
|
onRequestDelete: (key: string, label: string) => void;
|
||||||
onRequestDeleteMany?: (items: SidebarDeleteItem[]) => void;
|
|
||||||
onTogglePin: (key: string) => void;
|
onTogglePin: (key: string) => void;
|
||||||
onRequestRename: (key: string, label: string) => void;
|
onRequestRename: (key: string, label: string) => void;
|
||||||
onToggleArchive: (key: string) => void;
|
onToggleArchive: (key: string) => void;
|
||||||
paneGroups?: Record<string, SidebarPaneGroup>;
|
|
||||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
onDetachPane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
onPromotePane?: (tabKey: string, paneKey: string) => void;
|
|
||||||
attachableTabKeys?: string[];
|
|
||||||
paneAcceptingTabKeys?: string[];
|
|
||||||
onAttachPane?: (paneKey: string, tabKey: string) => void;
|
|
||||||
onReorderSessions: (keys: string[]) => void;
|
onReorderSessions: (keys: string[]) => void;
|
||||||
onToggleGroup: (groupId: string) => void;
|
onToggleGroup: (groupId: string) => void;
|
||||||
onRequestRenameProject: (projectKey: string, label: string) => void;
|
onRequestRenameProject: (projectKey: string, label: string) => void;
|
||||||
@@ -242,17 +230,9 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
onSelect={props.onSelect}
|
onSelect={props.onSelect}
|
||||||
onCloseTemporaryChat={props.onCloseTemporaryChat}
|
onCloseTemporaryChat={props.onCloseTemporaryChat}
|
||||||
onRequestDelete={props.onRequestDelete}
|
onRequestDelete={props.onRequestDelete}
|
||||||
onRequestDeleteMany={props.onRequestDeleteMany}
|
|
||||||
onTogglePin={props.onTogglePin}
|
onTogglePin={props.onTogglePin}
|
||||||
onRequestRename={props.onRequestRename}
|
onRequestRename={props.onRequestRename}
|
||||||
onToggleArchive={props.onToggleArchive}
|
onToggleArchive={props.onToggleArchive}
|
||||||
paneGroups={props.paneGroups}
|
|
||||||
onSelectPane={props.onSelectPane}
|
|
||||||
onDetachPane={props.onDetachPane}
|
|
||||||
onPromotePane={props.onPromotePane}
|
|
||||||
attachableTabKeys={props.attachableTabKeys}
|
|
||||||
paneAcceptingTabKeys={props.paneAcceptingTabKeys}
|
|
||||||
onAttachPane={props.onAttachPane}
|
|
||||||
onReorderSessions={props.onReorderSessions}
|
onReorderSessions={props.onReorderSessions}
|
||||||
onToggleGroup={props.onToggleGroup}
|
onToggleGroup={props.onToggleGroup}
|
||||||
onRequestRenameProject={props.onRequestRenameProject}
|
onRequestRenameProject={props.onRequestRenameProject}
|
||||||
|
|||||||
@@ -186,7 +186,6 @@ interface ThreadComposerProps {
|
|||||||
) => boolean | void | Promise<boolean | void>;
|
) => boolean | void | Promise<boolean | void>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
inputAriaLabel?: string;
|
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
modelLabel?: string | null;
|
modelLabel?: string | null;
|
||||||
modelDetail?: string | null;
|
modelDetail?: string | null;
|
||||||
@@ -941,7 +940,6 @@ export function ThreadComposer({
|
|||||||
onSend,
|
onSend,
|
||||||
disabled,
|
disabled,
|
||||||
placeholder,
|
placeholder,
|
||||||
inputAriaLabel,
|
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
modelLabel = null,
|
modelLabel = null,
|
||||||
modelDetail = null,
|
modelDetail = null,
|
||||||
@@ -2402,7 +2400,7 @@ export function ThreadComposer({
|
|||||||
rows={1}
|
rows={1}
|
||||||
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
|
||||||
disabled={interactionDisabled}
|
disabled={interactionDisabled}
|
||||||
aria-label={inputAriaLabel ?? t("thread.composer.inputAria")}
|
aria-label={t("thread.composer.inputAria")}
|
||||||
className={cn(
|
className={cn(
|
||||||
inputTextClasses,
|
inputTextClasses,
|
||||||
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
|
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
|
||||||
|
|||||||
@@ -17,11 +17,8 @@ interface ThreadHeaderProps {
|
|||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
hideSidebarToggleForHostChrome?: boolean;
|
hideSidebarToggleForHostChrome?: boolean;
|
||||||
hideSidebarToggle?: boolean;
|
|
||||||
hostChromeTitleInset?: boolean;
|
hostChromeTitleInset?: boolean;
|
||||||
hideThemeButton?: boolean;
|
hideThemeButton?: boolean;
|
||||||
hideTitle?: boolean;
|
|
||||||
actions?: ReactNode;
|
|
||||||
minimal?: boolean;
|
minimal?: boolean;
|
||||||
promptNavigatorAction?: ReactNode;
|
promptNavigatorAction?: ReactNode;
|
||||||
sessionInfoAction?: ReactNode;
|
sessionInfoAction?: ReactNode;
|
||||||
@@ -36,11 +33,8 @@ export function ThreadHeader({
|
|||||||
theme,
|
theme,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
hideSidebarToggleForHostChrome = false,
|
hideSidebarToggleForHostChrome = false,
|
||||||
hideSidebarToggle = false,
|
|
||||||
hostChromeTitleInset = false,
|
hostChromeTitleInset = false,
|
||||||
hideThemeButton = false,
|
hideThemeButton = false,
|
||||||
hideTitle = false,
|
|
||||||
actions,
|
|
||||||
minimal = false,
|
minimal = false,
|
||||||
promptNavigatorAction,
|
promptNavigatorAction,
|
||||||
sessionInfoAction,
|
sessionInfoAction,
|
||||||
@@ -60,7 +54,6 @@ export function ThreadHeader({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative flex min-w-0 items-center gap-2">
|
<div className="relative flex min-w-0 items-center gap-2">
|
||||||
{!hideSidebarToggle ? (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -73,8 +66,7 @@ export function ThreadHeader({
|
|||||||
>
|
>
|
||||||
<Menu className="h-3.5 w-3.5" />
|
<Menu className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
{!minimal ? (
|
||||||
{!minimal && !hideTitle ? (
|
|
||||||
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||||
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +76,6 @@ export function ThreadHeader({
|
|||||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||||
{sessionInfoAction}
|
{sessionInfoAction}
|
||||||
{promptNavigatorAction}
|
{promptNavigatorAction}
|
||||||
{actions}
|
|
||||||
{onTemporaryChatEnabledChange ? (
|
{onTemporaryChatEnabledChange ? (
|
||||||
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
|
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||||
@@ -312,17 +311,9 @@ interface ThreadShellProps {
|
|||||||
theme?: "light" | "dark";
|
theme?: "light" | "dark";
|
||||||
onToggleTheme?: () => void;
|
onToggleTheme?: () => void;
|
||||||
hideSidebarToggleForHostChrome?: boolean;
|
hideSidebarToggleForHostChrome?: boolean;
|
||||||
hideSidebarToggle?: boolean;
|
|
||||||
hostChromeTitleInset?: boolean;
|
hostChromeTitleInset?: boolean;
|
||||||
hideThemeButton?: boolean;
|
hideThemeButton?: boolean;
|
||||||
hideHeaderTitle?: boolean;
|
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
headerActions?: ReactNode;
|
|
||||||
headerPortalTarget?: HTMLElement | null;
|
|
||||||
headerActive?: boolean;
|
|
||||||
composerPortalTarget?: HTMLElement | null;
|
|
||||||
composerActive?: boolean;
|
|
||||||
composerInputAriaLabel?: string;
|
|
||||||
workspaceScope?: WorkspaceScopePayload | null;
|
workspaceScope?: WorkspaceScopePayload | null;
|
||||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||||
@@ -607,17 +598,9 @@ export function ThreadShell({
|
|||||||
theme = "light",
|
theme = "light",
|
||||||
onToggleTheme = () => {},
|
onToggleTheme = () => {},
|
||||||
hideSidebarToggleForHostChrome = false,
|
hideSidebarToggleForHostChrome = false,
|
||||||
hideSidebarToggle = false,
|
|
||||||
hostChromeTitleInset = false,
|
hostChromeTitleInset = false,
|
||||||
hideThemeButton = false,
|
hideThemeButton = false,
|
||||||
hideHeaderTitle = false,
|
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
headerActions,
|
|
||||||
headerPortalTarget,
|
|
||||||
headerActive = true,
|
|
||||||
composerPortalTarget,
|
|
||||||
composerActive = true,
|
|
||||||
composerInputAriaLabel,
|
|
||||||
workspaceScope = null,
|
workspaceScope = null,
|
||||||
workspaceDefaultScope = null,
|
workspaceDefaultScope = null,
|
||||||
workspaceControls = null,
|
workspaceControls = null,
|
||||||
@@ -1422,7 +1405,6 @@ export function ThreadShell({
|
|||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={handleThreadSend}
|
onSend={handleThreadSend}
|
||||||
disabled={!chatId}
|
disabled={!chatId}
|
||||||
inputAriaLabel={composerInputAriaLabel}
|
|
||||||
isStreaming={turnActive}
|
isStreaming={turnActive}
|
||||||
placeholder={
|
placeholder={
|
||||||
showHeroComposer
|
showHeroComposer
|
||||||
@@ -1467,7 +1449,6 @@ export function ThreadShell({
|
|||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={handleWelcomeSend}
|
onSend={handleWelcomeSend}
|
||||||
disabled={booting}
|
disabled={booting}
|
||||||
inputAriaLabel={composerInputAriaLabel}
|
|
||||||
isStreaming={turnActive}
|
isStreaming={turnActive}
|
||||||
placeholder={
|
placeholder={
|
||||||
booting
|
booting
|
||||||
@@ -1527,18 +1508,18 @@ export function ThreadShell({
|
|||||||
/>
|
/>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
const threadHeader = !hideHeader ? (
|
return (
|
||||||
|
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
|
{!hideHeader ? (
|
||||||
<ThreadHeader
|
<ThreadHeader
|
||||||
title={title}
|
title={title}
|
||||||
onToggleSidebar={onToggleSidebar}
|
onToggleSidebar={onToggleSidebar}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={onToggleTheme}
|
onToggleTheme={onToggleTheme}
|
||||||
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
hideSidebarToggleForHostChrome={hideSidebarToggleForHostChrome}
|
||||||
hideSidebarToggle={hideSidebarToggle}
|
|
||||||
hostChromeTitleInset={hostChromeTitleInset}
|
hostChromeTitleInset={hostChromeTitleInset}
|
||||||
hideThemeButton={hideThemeButton}
|
hideThemeButton={hideThemeButton}
|
||||||
hideTitle={hideHeaderTitle}
|
|
||||||
actions={headerActions}
|
|
||||||
minimal={!session && !loading}
|
minimal={!session && !loading}
|
||||||
promptNavigatorAction={promptNavigatorAction}
|
promptNavigatorAction={promptNavigatorAction}
|
||||||
sessionInfoAction={sessionInfoAction}
|
sessionInfoAction={sessionInfoAction}
|
||||||
@@ -1548,12 +1529,7 @@ export function ThreadShell({
|
|||||||
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null}
|
||||||
|
|
||||||
return (
|
|
||||||
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
|
|
||||||
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
||||||
{headerPortalTarget === undefined ? threadHeader : null}
|
|
||||||
<FilePreviewAvailabilityProvider
|
<FilePreviewAvailabilityProvider
|
||||||
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
|
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
|
||||||
>
|
>
|
||||||
@@ -1563,7 +1539,7 @@ export function ThreadShell({
|
|||||||
temporary={temporary}
|
temporary={temporary}
|
||||||
isStreaming={turnActive}
|
isStreaming={turnActive}
|
||||||
emptyState={emptyState}
|
emptyState={emptyState}
|
||||||
composer={composerPortalTarget === undefined ? composer : null}
|
composer={composer}
|
||||||
activeTurnId={viewportTurnId}
|
activeTurnId={viewportTurnId}
|
||||||
activeTurnStartedHere={activeTurnStartedHere}
|
activeTurnStartedHere={activeTurnStartedHere}
|
||||||
conversationKey={historyKey}
|
conversationKey={historyKey}
|
||||||
@@ -1583,19 +1559,6 @@ export function ThreadShell({
|
|||||||
/>
|
/>
|
||||||
</FilePreviewAvailabilityProvider>
|
</FilePreviewAvailabilityProvider>
|
||||||
</div>
|
</div>
|
||||||
{headerPortalTarget && headerActive
|
|
||||||
? createPortal(threadHeader, headerPortalTarget)
|
|
||||||
: null}
|
|
||||||
{composerPortalTarget ? createPortal(
|
|
||||||
<div
|
|
||||||
hidden={!composerActive}
|
|
||||||
aria-hidden={!composerActive}
|
|
||||||
data-testid={composerActive ? "active-pane-composer" : undefined}
|
|
||||||
>
|
|
||||||
{composer}
|
|
||||||
</div>,
|
|
||||||
composerPortalTarget,
|
|
||||||
) : null}
|
|
||||||
{filePreviewPath && historyKey ? (
|
{filePreviewPath && historyKey ? (
|
||||||
<FilePreviewPanel
|
<FilePreviewPanel
|
||||||
sessionKey={historyKey}
|
sessionKey={historyKey}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ interface ThreadViewportProps {
|
|||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
temporary?: boolean;
|
temporary?: boolean;
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
composer?: ReactNode;
|
composer: ReactNode;
|
||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
scrollToBottomSignal?: number;
|
scrollToBottomSignal?: number;
|
||||||
activeTurnId?: string | null;
|
activeTurnId?: string | null;
|
||||||
@@ -61,7 +61,6 @@ interface ThreadViewportProps {
|
|||||||
const NEAR_BOTTOM_PX = 48;
|
const NEAR_BOTTOM_PX = 48;
|
||||||
const NEAR_TOP_PX = 96;
|
const NEAR_TOP_PX = 96;
|
||||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||||
const EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX = 16;
|
|
||||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||||
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
||||||
export const INITIAL_HISTORY_WINDOW = 160;
|
export const INITIAL_HISTORY_WINDOW = 160;
|
||||||
@@ -267,14 +266,11 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
||||||
? forkBoundaryMessageCount - hiddenMessageCount
|
? forkBoundaryMessageCount - hiddenMessageCount
|
||||||
: null;
|
: null;
|
||||||
const hasComposer = composer !== null && composer !== undefined;
|
|
||||||
const scrollButtonBottom =
|
const scrollButtonBottom =
|
||||||
keyboardInsetBottom
|
keyboardInsetBottom
|
||||||
+ (composerDockHeight > 0
|
+ (composerDockHeight > 0
|
||||||
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
||||||
: hasComposer
|
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
|
||||||
? DEFAULT_SCROLL_BUTTON_BOTTOM_PX
|
|
||||||
: EXTERNAL_COMPOSER_SCROLL_BUTTON_BOTTOM_PX);
|
|
||||||
const scrollViewportStyle =
|
const scrollViewportStyle =
|
||||||
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
||||||
|
|
||||||
@@ -665,7 +661,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
|
data-testid={!hasMessages ? "thread-welcome-layout" : undefined}
|
||||||
data-layout={hasComposer ? (hasMessages ? "thread" : "hero") : "external"}
|
data-layout={hasMessages ? "thread" : "hero"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"thread-layout mx-auto grid min-h-full w-full",
|
"thread-layout mx-auto grid min-h-full w-full",
|
||||||
hasMessages
|
hasMessages
|
||||||
@@ -703,17 +699,11 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
|
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8">
|
||||||
className={cn(
|
|
||||||
"row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center",
|
|
||||||
hasComposer && "sm:items-end sm:pb-8",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{emptyState}
|
{emptyState}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasComposer ? (
|
|
||||||
<div
|
<div
|
||||||
ref={composerDockRef}
|
ref={composerDockRef}
|
||||||
data-testid="thread-composer-dock"
|
data-testid="thread-composer-dock"
|
||||||
@@ -756,14 +746,11 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{hasComposer ? (
|
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
|
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
{!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null}
|
{!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
import { ChevronRight, Circle } from "lucide-react";
|
import { Circle } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
floatingItemClassName,
|
floatingItemClassName,
|
||||||
@@ -13,7 +13,6 @@ import { cn } from "@/lib/utils";
|
|||||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
|
||||||
|
|
||||||
const menuItemClassName =
|
const menuItemClassName =
|
||||||
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
|
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
|
||||||
@@ -116,41 +115,6 @@ const DropdownMenuSeparator = React.forwardRef<
|
|||||||
));
|
));
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean;
|
|
||||||
}
|
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(menuItemClassName, inset && "pl-8", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
|
||||||
));
|
|
||||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
|
||||||
>(({ className, sideOffset = 6, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.SubContent
|
|
||||||
ref={ref}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
floatingSurfaceClassName,
|
|
||||||
floatingSurfaceMotionClassName,
|
|
||||||
"max-h-[min(var(--radix-dropdown-menu-content-available-height),22rem)] min-w-[11rem] overflow-y-auto",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
|
||||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -159,8 +123,5 @@ export {
|
|||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,428 +0,0 @@
|
|||||||
import {
|
|
||||||
Columns2,
|
|
||||||
Grid2X2,
|
|
||||||
PanelLeft,
|
|
||||||
Plus,
|
|
||||||
Rows2,
|
|
||||||
Square,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
type CSSProperties,
|
|
||||||
type FocusEvent,
|
|
||||||
type PointerEvent,
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import type { WorkbenchLayout } from "@/components/workbench/workbench-model";
|
|
||||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export interface WorkbenchPane {
|
|
||||||
key: string;
|
|
||||||
reactKey?: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PaneRenderContext {
|
|
||||||
active: boolean;
|
|
||||||
headerPortalTarget: HTMLElement | null | undefined;
|
|
||||||
composerPortalTarget: HTMLElement | null | undefined;
|
|
||||||
headerActions: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PaneWorkbenchProps {
|
|
||||||
panes: WorkbenchPane[];
|
|
||||||
activePaneKey: string;
|
|
||||||
layout: WorkbenchLayout;
|
|
||||||
chrome?: boolean;
|
|
||||||
addPaneDisabled?: boolean;
|
|
||||||
onActivatePane: (key: string) => void;
|
|
||||||
onAddPane: () => void;
|
|
||||||
onLayoutChange: (layout: WorkbenchLayout) => void;
|
|
||||||
renderPane: (pane: WorkbenchPane, context: PaneRenderContext) => ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LAYOUT_MOTION_DURATION_MS = 260;
|
|
||||||
const LAYOUT_MOTION_EASING = "cubic-bezier(0.2, 0, 0, 1)";
|
|
||||||
|
|
||||||
const LAYOUT_CONTROLS: Array<{
|
|
||||||
icon: LucideIcon;
|
|
||||||
layout: WorkbenchLayout;
|
|
||||||
label: string;
|
|
||||||
}> = [
|
|
||||||
{ icon: Columns2, layout: "columns", label: "Columns" },
|
|
||||||
{ icon: Rows2, layout: "rows", label: "Rows" },
|
|
||||||
{ icon: Grid2X2, layout: "grid", label: "Grid" },
|
|
||||||
{ icon: PanelLeft, layout: "main-stack", label: "Main and stack" },
|
|
||||||
{ icon: Square, layout: "monocle", label: "Monocle" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function paneGridStyle(layout: WorkbenchLayout, paneCount: number): CSSProperties {
|
|
||||||
const count = Math.max(1, paneCount);
|
|
||||||
switch (layout) {
|
|
||||||
case "columns":
|
|
||||||
return {
|
|
||||||
gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))`,
|
|
||||||
gridTemplateRows: "minmax(0, 1fr)",
|
|
||||||
};
|
|
||||||
case "rows":
|
|
||||||
return {
|
|
||||||
gridTemplateColumns: "minmax(0, 1fr)",
|
|
||||||
gridTemplateRows: `repeat(${count}, minmax(0, 1fr))`,
|
|
||||||
};
|
|
||||||
case "grid": {
|
|
||||||
const columns = Math.ceil(Math.sqrt(count));
|
|
||||||
const rows = Math.ceil(count / columns);
|
|
||||||
return {
|
|
||||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
||||||
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "main-stack":
|
|
||||||
return count === 1
|
|
||||||
? {
|
|
||||||
gridTemplateColumns: "minmax(0, 1fr)",
|
|
||||||
gridTemplateRows: "minmax(0, 1fr)",
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
gridTemplateColumns: "minmax(0, 1.65fr) minmax(0, 1fr)",
|
|
||||||
gridTemplateRows: `repeat(${count - 1}, minmax(0, 1fr))`,
|
|
||||||
};
|
|
||||||
case "monocle":
|
|
||||||
return {
|
|
||||||
gridTemplateColumns: "minmax(0, 1fr)",
|
|
||||||
gridTemplateRows: "minmax(0, 1fr)",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function paneCellStyle(
|
|
||||||
layout: WorkbenchLayout,
|
|
||||||
paneCount: number,
|
|
||||||
index: number,
|
|
||||||
): CSSProperties | undefined {
|
|
||||||
if (layout !== "main-stack" || paneCount < 2) return undefined;
|
|
||||||
return index === 0
|
|
||||||
? { gridColumn: 1, gridRow: `1 / span ${paneCount - 1}` }
|
|
||||||
: { gridColumn: 2, gridRow: index };
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPaneAction(target: EventTarget | null): boolean {
|
|
||||||
return target instanceof Element
|
|
||||||
&& target.closest("[data-workbench-pane-action]") !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HeaderIconButton({
|
|
||||||
disabled,
|
|
||||||
icon: Icon,
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
disabled?: boolean;
|
|
||||||
icon: LucideIcon;
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={label}
|
|
||||||
onClick={onClick}
|
|
||||||
className="host-no-drag h-8 w-8 shrink-0 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaneWorkbench({
|
|
||||||
panes,
|
|
||||||
activePaneKey,
|
|
||||||
layout,
|
|
||||||
chrome = true,
|
|
||||||
addPaneDisabled = false,
|
|
||||||
onActivatePane,
|
|
||||||
onAddPane,
|
|
||||||
onLayoutChange,
|
|
||||||
renderPane,
|
|
||||||
}: PaneWorkbenchProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const compact = useMediaQuery("(max-width: 767px)");
|
|
||||||
const effectiveLayout = compact ? "monocle" : layout;
|
|
||||||
const [headerPortalTarget, setHeaderPortalTarget] = useState<HTMLElement | null>(null);
|
|
||||||
const [composerPortalTarget, setComposerPortalTarget] = useState<HTMLElement | null>(null);
|
|
||||||
const paneRefs = useRef(new Map<string, HTMLElement>());
|
|
||||||
const lastRectsRef = useRef(new Map<string, DOMRect>());
|
|
||||||
const pendingRectsRef = useRef<Map<string, DOMRect> | null>(null);
|
|
||||||
const animationsRef = useRef(new Map<string, Animation>());
|
|
||||||
const paneOrder = useMemo(() => panes.map((pane) => pane.key).join("\u0000"), [panes]);
|
|
||||||
|
|
||||||
const measurePanes = useCallback(() => {
|
|
||||||
const rects = new Map<string, DOMRect>();
|
|
||||||
for (const [key, element] of paneRefs.current) {
|
|
||||||
if (!element.hidden) rects.set(key, element.getBoundingClientRect());
|
|
||||||
}
|
|
||||||
return rects;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const captureLayout = useCallback(() => {
|
|
||||||
pendingRectsRef.current = measurePanes();
|
|
||||||
for (const animation of animationsRef.current.values()) animation.cancel();
|
|
||||||
animationsRef.current.clear();
|
|
||||||
}, [measurePanes]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const previousRects = pendingRectsRef.current ?? lastRectsRef.current;
|
|
||||||
pendingRectsRef.current = null;
|
|
||||||
const nextRects = measurePanes();
|
|
||||||
const reduceMotion = typeof window.matchMedia === "function"
|
|
||||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
||||||
|
|
||||||
if (!reduceMotion) {
|
|
||||||
for (const [key, nextRect] of nextRects) {
|
|
||||||
const previousRect = previousRects.get(key);
|
|
||||||
const element = paneRefs.current.get(key);
|
|
||||||
if (!element) continue;
|
|
||||||
if (!previousRect) {
|
|
||||||
if (previousRects.size === 0 || typeof element.animate !== "function") continue;
|
|
||||||
const animation = element.animate(
|
|
||||||
[
|
|
||||||
{ opacity: 0, transform: "translateY(5px) scale(0.995)" },
|
|
||||||
{ opacity: 1, transform: "translateY(0) scale(1)" },
|
|
||||||
],
|
|
||||||
{
|
|
||||||
duration: 180,
|
|
||||||
easing: LAYOUT_MOTION_EASING,
|
|
||||||
fill: "backwards",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
animationsRef.current.set(key, animation);
|
|
||||||
animation.addEventListener("finish", () => {
|
|
||||||
if (animationsRef.current.get(key) === animation) {
|
|
||||||
animationsRef.current.delete(key);
|
|
||||||
}
|
|
||||||
}, { once: true });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (previousRect.width === 0 || previousRect.height === 0) continue;
|
|
||||||
const deltaX = previousRect.left - nextRect.left;
|
|
||||||
const deltaY = previousRect.top - nextRect.top;
|
|
||||||
const scaleX = previousRect.width / nextRect.width;
|
|
||||||
const scaleY = previousRect.height / nextRect.height;
|
|
||||||
if (
|
|
||||||
Math.abs(deltaX) < 0.5
|
|
||||||
&& Math.abs(deltaY) < 0.5
|
|
||||||
&& Math.abs(scaleX - 1) < 0.002
|
|
||||||
&& Math.abs(scaleY - 1) < 0.002
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (typeof element.animate !== "function") continue;
|
|
||||||
const animation = element.animate(
|
|
||||||
[
|
|
||||||
{ transform: `translate(${deltaX}px, ${deltaY}px) scale(${scaleX}, ${scaleY})` },
|
|
||||||
{ transform: "translate(0, 0) scale(1, 1)" },
|
|
||||||
],
|
|
||||||
{
|
|
||||||
duration: LAYOUT_MOTION_DURATION_MS,
|
|
||||||
easing: LAYOUT_MOTION_EASING,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
animationsRef.current.set(key, animation);
|
|
||||||
animation.addEventListener("finish", () => {
|
|
||||||
if (animationsRef.current.get(key) === animation) {
|
|
||||||
animationsRef.current.delete(key);
|
|
||||||
}
|
|
||||||
}, { once: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastRectsRef.current = nextRects;
|
|
||||||
}, [activePaneKey, effectiveLayout, measurePanes, paneOrder]);
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
for (const animation of animationsRef.current.values()) animation.cancel();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const activatePane = useCallback((key: string, target: EventTarget | null) => {
|
|
||||||
if (key === activePaneKey || isPaneAction(target)) return;
|
|
||||||
captureLayout();
|
|
||||||
onActivatePane(key);
|
|
||||||
}, [activePaneKey, captureLayout, onActivatePane]);
|
|
||||||
|
|
||||||
const handlePanePointerDown = useCallback((
|
|
||||||
key: string,
|
|
||||||
event: PointerEvent<HTMLElement>,
|
|
||||||
) => {
|
|
||||||
activatePane(key, event.target);
|
|
||||||
}, [activatePane]);
|
|
||||||
|
|
||||||
const handlePaneFocus = useCallback((key: string, event: FocusEvent<HTMLElement>) => {
|
|
||||||
activatePane(key, event.target);
|
|
||||||
}, [activatePane]);
|
|
||||||
|
|
||||||
const gridStyle = paneGridStyle(effectiveLayout, panes.length);
|
|
||||||
const currentLayout = LAYOUT_CONTROLS.find((control) => control.layout === layout)
|
|
||||||
?? LAYOUT_CONTROLS[0];
|
|
||||||
const headerActions = chrome ? (
|
|
||||||
<div
|
|
||||||
data-workbench-pane-action
|
|
||||||
className="host-no-drag flex items-center gap-0.5"
|
|
||||||
>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={t("workbench.layout", {
|
|
||||||
defaultValue: "Pane layout",
|
|
||||||
})}
|
|
||||||
className="host-no-drag h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
|
|
||||||
>
|
|
||||||
<currentLayout.icon className="h-4 w-4" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
{t("workbench.layout", { defaultValue: "Pane layout" })}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
value={layout}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const next = value as WorkbenchLayout;
|
|
||||||
if (next === layout) return;
|
|
||||||
captureLayout();
|
|
||||||
onLayoutChange(next);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{LAYOUT_CONTROLS.map((control) => (
|
|
||||||
<DropdownMenuRadioItem
|
|
||||||
key={control.layout}
|
|
||||||
value={control.layout}
|
|
||||||
>
|
|
||||||
<control.icon aria-hidden />
|
|
||||||
{t(`workbench.layouts.${control.layout}`, {
|
|
||||||
defaultValue: control.label,
|
|
||||||
})}
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<HeaderIconButton
|
|
||||||
disabled={addPaneDisabled}
|
|
||||||
icon={Plus}
|
|
||||||
label={t("workbench.addPane", { defaultValue: "Add pane" })}
|
|
||||||
onClick={() => {
|
|
||||||
captureLayout();
|
|
||||||
onAddPane();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
aria-label={t("workbench.aria", { defaultValue: "Conversation workbench" })}
|
|
||||||
className="flex h-full min-h-0 flex-col overflow-hidden bg-background"
|
|
||||||
>
|
|
||||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
|
||||||
{chrome ? (
|
|
||||||
<header className="shrink-0 bg-background">
|
|
||||||
<div
|
|
||||||
ref={setHeaderPortalTarget}
|
|
||||||
data-testid="workbench-header-host"
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
) : null}
|
|
||||||
<div className="min-h-0 flex-1 bg-background">
|
|
||||||
<div
|
|
||||||
data-testid="pane-grid"
|
|
||||||
data-layout={effectiveLayout}
|
|
||||||
className={cn(
|
|
||||||
"grid h-full min-h-0 min-w-0 overflow-hidden",
|
|
||||||
chrome && panes.length > 1 && "gap-px bg-border/55",
|
|
||||||
)}
|
|
||||||
style={gridStyle}
|
|
||||||
>
|
|
||||||
{panes.map((pane, index) => {
|
|
||||||
const active = pane.key === activePaneKey;
|
|
||||||
const hidden = effectiveLayout === "monocle" && !active;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
key={pane.reactKey ?? pane.key}
|
|
||||||
ref={(element) => {
|
|
||||||
if (element) paneRefs.current.set(pane.key, element);
|
|
||||||
else paneRefs.current.delete(pane.key);
|
|
||||||
}}
|
|
||||||
hidden={hidden}
|
|
||||||
aria-label={pane.title}
|
|
||||||
data-active={active ? "true" : "false"}
|
|
||||||
data-testid={`workbench-pane-${pane.key}`}
|
|
||||||
onPointerDownCapture={(event) => handlePanePointerDown(pane.key, event)}
|
|
||||||
onFocusCapture={(event) => handlePaneFocus(pane.key, event)}
|
|
||||||
className="workbench-pane relative flex min-h-0 min-w-0 overflow-hidden bg-background"
|
|
||||||
style={paneCellStyle(effectiveLayout, panes.length, index)}
|
|
||||||
>
|
|
||||||
{renderPane(pane, {
|
|
||||||
active,
|
|
||||||
headerPortalTarget: chrome ? headerPortalTarget : undefined,
|
|
||||||
composerPortalTarget: chrome ? composerPortalTarget : undefined,
|
|
||||||
headerActions,
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{chrome ? (
|
|
||||||
<footer className="shrink-0 bg-background px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
|
|
||||||
<div
|
|
||||||
ref={setComposerPortalTarget}
|
|
||||||
data-testid="workbench-composer-host"
|
|
||||||
className="mx-auto w-full max-w-[58rem]"
|
|
||||||
/>
|
|
||||||
</footer>
|
|
||||||
) : null}
|
|
||||||
</TooltipProvider>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
export const WORKBENCH_STORAGE_KEY = "nanobot.webui.workbench.v2";
|
|
||||||
export const MAX_WORKBENCH_PANES = 4;
|
|
||||||
|
|
||||||
export const WORKBENCH_LAYOUTS = [
|
|
||||||
"columns",
|
|
||||||
"rows",
|
|
||||||
"grid",
|
|
||||||
"main-stack",
|
|
||||||
"monocle",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type WorkbenchLayout = (typeof WORKBENCH_LAYOUTS)[number];
|
|
||||||
|
|
||||||
export interface WorkbenchTabState {
|
|
||||||
paneKeys: string[];
|
|
||||||
activePaneKey: string;
|
|
||||||
layout: WorkbenchLayout;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkbenchState {
|
|
||||||
version: 2;
|
|
||||||
tabs: Record<string, WorkbenchTabState>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EMPTY_WORKBENCH_STATE: WorkbenchState = {
|
|
||||||
version: 2,
|
|
||||||
tabs: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
function isLayout(value: unknown): value is WorkbenchLayout {
|
|
||||||
return typeof value === "string"
|
|
||||||
&& (WORKBENCH_LAYOUTS as readonly string[]).includes(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueKeys(value: unknown): string[] {
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
return Array.from(new Set(
|
|
||||||
value.filter((key): key is string => typeof key === "string" && key.length > 0),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTab(value: unknown, tabKey: string): WorkbenchTabState {
|
|
||||||
const candidate = value && typeof value === "object"
|
|
||||||
? value as Partial<WorkbenchTabState>
|
|
||||||
: {};
|
|
||||||
const paneKeys = uniqueKeys(candidate.paneKeys);
|
|
||||||
const normalizedPaneKeys = (paneKeys.includes(tabKey)
|
|
||||||
? paneKeys
|
|
||||||
: [tabKey, ...paneKeys]).slice(0, MAX_WORKBENCH_PANES);
|
|
||||||
return {
|
|
||||||
paneKeys: normalizedPaneKeys,
|
|
||||||
activePaneKey:
|
|
||||||
typeof candidate.activePaneKey === "string"
|
|
||||||
&& normalizedPaneKeys.includes(candidate.activePaneKey)
|
|
||||||
? candidate.activePaneKey
|
|
||||||
: normalizedPaneKeys[0],
|
|
||||||
layout: isLayout(candidate.layout) ? candidate.layout : "columns",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseWorkbenchState(serialized: string | null): WorkbenchState {
|
|
||||||
if (!serialized) return EMPTY_WORKBENCH_STATE;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(serialized) as { version?: unknown; tabs?: unknown };
|
|
||||||
if (
|
|
||||||
parsed.version !== 2
|
|
||||||
|| !parsed.tabs
|
|
||||||
|| typeof parsed.tabs !== "object"
|
|
||||||
|| Array.isArray(parsed.tabs)
|
|
||||||
) {
|
|
||||||
return EMPTY_WORKBENCH_STATE;
|
|
||||||
}
|
|
||||||
const tabs = Object.fromEntries(
|
|
||||||
Object.entries(parsed.tabs).map(([tabKey, tab]) => [tabKey, normalizeTab(tab, tabKey)]),
|
|
||||||
);
|
|
||||||
return { version: 2, tabs };
|
|
||||||
} catch {
|
|
||||||
return EMPTY_WORKBENCH_STATE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultWorkbenchTab(tabKey: string): WorkbenchTabState {
|
|
||||||
return {
|
|
||||||
paneKeys: [tabKey],
|
|
||||||
activePaneKey: tabKey,
|
|
||||||
layout: "columns",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function workbenchTab(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
): WorkbenchTabState {
|
|
||||||
return state.tabs[tabKey] ?? defaultWorkbenchTab(tabKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTab(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
update: (tab: WorkbenchTabState) => WorkbenchTabState,
|
|
||||||
): WorkbenchState {
|
|
||||||
const current = workbenchTab(state, tabKey);
|
|
||||||
const next = update(current);
|
|
||||||
if (state.tabs[tabKey] === next) return state;
|
|
||||||
return {
|
|
||||||
version: 2,
|
|
||||||
tabs: {
|
|
||||||
...state.tabs,
|
|
||||||
[tabKey]: next,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureWorkbenchTab(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
): WorkbenchState {
|
|
||||||
if (state.tabs[tabKey]) return state;
|
|
||||||
return updateTab(state, tabKey, (tab) => tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addWorkbenchPane(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
paneKey: string,
|
|
||||||
): WorkbenchState {
|
|
||||||
return updateTab(state, tabKey, (tab) => {
|
|
||||||
if (tab.paneKeys.includes(paneKey)) {
|
|
||||||
if (tab.activePaneKey === paneKey) return tab;
|
|
||||||
return { ...tab, activePaneKey: paneKey };
|
|
||||||
}
|
|
||||||
if (tab.paneKeys.length >= MAX_WORKBENCH_PANES) return tab;
|
|
||||||
return {
|
|
||||||
...tab,
|
|
||||||
paneKeys: [...tab.paneKeys, paneKey],
|
|
||||||
activePaneKey: paneKey,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function focusWorkbenchPane(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
paneKey: string,
|
|
||||||
): WorkbenchState {
|
|
||||||
return updateTab(state, tabKey, (tab) => (
|
|
||||||
tab.paneKeys.includes(paneKey) && tab.activePaneKey !== paneKey
|
|
||||||
? { ...tab, activePaneKey: paneKey }
|
|
||||||
: tab
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function detachWorkbenchPane(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
paneKey: string,
|
|
||||||
): WorkbenchState {
|
|
||||||
return updateTab(state, tabKey, (tab) => {
|
|
||||||
const index = tab.paneKeys.indexOf(paneKey);
|
|
||||||
if (index < 0 || paneKey === tabKey || tab.paneKeys.length === 1) return tab;
|
|
||||||
const paneKeys = tab.paneKeys.filter((key) => key !== paneKey);
|
|
||||||
const activePaneKey = tab.activePaneKey === paneKey
|
|
||||||
? paneKeys[Math.min(index, paneKeys.length - 1)]
|
|
||||||
: tab.activePaneKey;
|
|
||||||
return { ...tab, paneKeys, activePaneKey };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function attachWorkbenchPane(
|
|
||||||
state: WorkbenchState,
|
|
||||||
targetTabKey: string,
|
|
||||||
paneKey: string,
|
|
||||||
): WorkbenchState {
|
|
||||||
if (!targetTabKey || !paneKey || targetTabKey === paneKey) return state;
|
|
||||||
|
|
||||||
const sourceEntry = Object.entries(state.tabs).find(([, tab]) => (
|
|
||||||
tab.paneKeys.includes(paneKey)
|
|
||||||
));
|
|
||||||
const sourceTabKey = sourceEntry?.[0];
|
|
||||||
const sourceTab = sourceEntry?.[1];
|
|
||||||
if (sourceTabKey === targetTabKey) {
|
|
||||||
return focusWorkbenchPane(state, targetTabKey, paneKey);
|
|
||||||
}
|
|
||||||
if (sourceTabKey === paneKey && sourceTab && sourceTab.paneKeys.length > 1) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
const targetBeforeMove = state.tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey);
|
|
||||||
if (
|
|
||||||
!targetBeforeMove.paneKeys.includes(paneKey)
|
|
||||||
&& targetBeforeMove.paneKeys.length >= MAX_WORKBENCH_PANES
|
|
||||||
) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabs = { ...state.tabs };
|
|
||||||
if (sourceTabKey && sourceTab) {
|
|
||||||
if (sourceTabKey === paneKey) {
|
|
||||||
delete tabs[sourceTabKey];
|
|
||||||
} else {
|
|
||||||
const index = sourceTab.paneKeys.indexOf(paneKey);
|
|
||||||
const paneKeys = sourceTab.paneKeys.filter((key) => key !== paneKey);
|
|
||||||
tabs[sourceTabKey] = {
|
|
||||||
...sourceTab,
|
|
||||||
paneKeys,
|
|
||||||
activePaneKey: sourceTab.activePaneKey === paneKey
|
|
||||||
? paneKeys[Math.min(index, paneKeys.length - 1)]
|
|
||||||
: sourceTab.activePaneKey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetTab = tabs[targetTabKey] ?? defaultWorkbenchTab(targetTabKey);
|
|
||||||
tabs[targetTabKey] = targetTab.paneKeys.includes(paneKey)
|
|
||||||
? { ...targetTab, activePaneKey: paneKey }
|
|
||||||
: {
|
|
||||||
...targetTab,
|
|
||||||
paneKeys: [...targetTab.paneKeys, paneKey],
|
|
||||||
activePaneKey: paneKey,
|
|
||||||
};
|
|
||||||
return { version: 2, tabs };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function promoteWorkbenchPane(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
paneKey: string,
|
|
||||||
): WorkbenchState {
|
|
||||||
return updateTab(state, tabKey, (tab) => {
|
|
||||||
const index = tab.paneKeys.indexOf(paneKey);
|
|
||||||
if (index <= 0) return tab;
|
|
||||||
return {
|
|
||||||
...tab,
|
|
||||||
paneKeys: [paneKey, ...tab.paneKeys.filter((key) => key !== paneKey)],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setWorkbenchLayout(
|
|
||||||
state: WorkbenchState,
|
|
||||||
tabKey: string,
|
|
||||||
layout: WorkbenchLayout,
|
|
||||||
): WorkbenchState {
|
|
||||||
return updateTab(state, tabKey, (tab) => (
|
|
||||||
tab.layout === layout ? tab : { ...tab, layout }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reconcileWorkbench(
|
|
||||||
state: WorkbenchState,
|
|
||||||
validKeys: ReadonlySet<string>,
|
|
||||||
): WorkbenchState {
|
|
||||||
const tabs: Record<string, WorkbenchTabState> = {};
|
|
||||||
for (const [tabKey, tab] of Object.entries(state.tabs)) {
|
|
||||||
if (!validKeys.has(tabKey)) continue;
|
|
||||||
const paneKeys = tab.paneKeys.filter((key) => validKeys.has(key));
|
|
||||||
const normalizedPaneKeys = (paneKeys.includes(tabKey)
|
|
||||||
? paneKeys
|
|
||||||
: [tabKey, ...paneKeys]).slice(0, MAX_WORKBENCH_PANES);
|
|
||||||
tabs[tabKey] = {
|
|
||||||
...tab,
|
|
||||||
paneKeys: normalizedPaneKeys,
|
|
||||||
activePaneKey: normalizedPaneKeys.includes(tab.activePaneKey)
|
|
||||||
? tab.activePaneKey
|
|
||||||
: normalizedPaneKeys[0],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const serializedCurrent = JSON.stringify(state.tabs);
|
|
||||||
const serializedNext = JSON.stringify(tabs);
|
|
||||||
return serializedCurrent === serializedNext ? state : { version: 2, tabs };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function workbenchChildPaneKeys(state: WorkbenchState): Set<string> {
|
|
||||||
const childKeys = new Set<string>();
|
|
||||||
for (const [tabKey, tab] of Object.entries(state.tabs)) {
|
|
||||||
for (const paneKey of tab.paneKeys) {
|
|
||||||
if (paneKey !== tabKey) childKeys.add(paneKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return childKeys;
|
|
||||||
}
|
|
||||||
@@ -360,9 +360,6 @@
|
|||||||
.thread-layout[data-layout="thread"] {
|
.thread-layout[data-layout="thread"] {
|
||||||
grid-template-rows: minmax(0, 1fr) auto 0fr;
|
grid-template-rows: minmax(0, 1fr) auto 0fr;
|
||||||
}
|
}
|
||||||
.thread-layout[data-layout="external"] {
|
|
||||||
grid-template-rows: minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.thread-layout[data-layout="hero"] {
|
.thread-layout[data-layout="hero"] {
|
||||||
grid-template-rows: minmax(min-content, 1fr) auto 1fr;
|
grid-template-rows: minmax(min-content, 1fr) auto 1fr;
|
||||||
@@ -567,10 +564,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-pane {
|
|
||||||
transform-origin: top left;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */
|
/** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */
|
||||||
@keyframes goal-shell-glow-breathe {
|
@keyframes goal-shell-glow-breathe {
|
||||||
0%,
|
0%,
|
||||||
|
|||||||
@@ -965,10 +965,6 @@
|
|||||||
"unarchive": "Unarchive",
|
"unarchive": "Unarchive",
|
||||||
"showArchived": "Show archived",
|
"showArchived": "Show archived",
|
||||||
"hideArchived": "Hide archived",
|
"hideArchived": "Hide archived",
|
||||||
"select": "Select",
|
|
||||||
"cancelSelection": "Cancel selection",
|
|
||||||
"selectedCount": "{{count}} selected",
|
|
||||||
"deleteSelected": "Delete",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"newChat": "New topic",
|
"newChat": "New topic",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -983,13 +979,10 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "Delete this topic?",
|
"title": "Delete this topic?",
|
||||||
"titleMany": "Delete {{count}} topics and panes?",
|
|
||||||
"description": "This action cannot be undone.",
|
"description": "This action cannot be undone.",
|
||||||
"descriptionMany": "This action cannot be undone.",
|
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Delete",
|
"confirm": "Delete",
|
||||||
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
|
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
|
||||||
"automationsDescriptionMany": "Linked automations will also be deleted.",
|
|
||||||
"moreAutomations": "+ {{count}} more",
|
"moreAutomations": "+ {{count}} more",
|
||||||
"confirmWithAutomations": "Delete",
|
"confirmWithAutomations": "Delete",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1385,26 +1378,6 @@
|
|||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied"
|
"copied": "Copied"
|
||||||
},
|
},
|
||||||
"workbench": {
|
|
||||||
"aria": "Conversation workbench",
|
|
||||||
"panes": "Panes",
|
|
||||||
"panesInTab": "Panes in {{title}}",
|
|
||||||
"dropPane": "Move {{pane}} into {{tab}}",
|
|
||||||
"moveToTab": "Move to tab",
|
|
||||||
"layout": "Pane layout",
|
|
||||||
"addPane": "Add pane",
|
|
||||||
"promotePane": "Make {{title}} the primary pane",
|
|
||||||
"paneActions": "{{title}} pane actions",
|
|
||||||
"detachPane": "Move {{title}} to its own topic",
|
|
||||||
"composerAria": "Message {{title}}",
|
|
||||||
"layouts": {
|
|
||||||
"columns": "Columns",
|
|
||||||
"rows": "Rows",
|
|
||||||
"grid": "Grid",
|
|
||||||
"main-stack": "Main and stack",
|
|
||||||
"monocle": "Monocle"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
|||||||
@@ -952,10 +952,6 @@
|
|||||||
"unarchive": "Desarchivar",
|
"unarchive": "Desarchivar",
|
||||||
"showArchived": "Mostrar archivados",
|
"showArchived": "Mostrar archivados",
|
||||||
"hideArchived": "Ocultar archivados",
|
"hideArchived": "Ocultar archivados",
|
||||||
"select": "Seleccionar",
|
|
||||||
"cancelSelection": "Cancelar selección",
|
|
||||||
"selectedCount": "{{count}} seleccionados",
|
|
||||||
"deleteSelected": "Eliminar",
|
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"newChat": "Nuevo tema",
|
"newChat": "Nuevo tema",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -970,13 +966,10 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "¿Eliminar este chat?",
|
"title": "¿Eliminar este chat?",
|
||||||
"titleMany": "¿Eliminar {{count}} chats y paneles?",
|
|
||||||
"description": "Esta acción no se puede deshacer.",
|
"description": "Esta acción no se puede deshacer.",
|
||||||
"descriptionMany": "Esta acción no se puede deshacer.",
|
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"confirm": "Eliminar",
|
"confirm": "Eliminar",
|
||||||
"automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.",
|
"automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.",
|
||||||
"automationsDescriptionMany": "También se eliminarán las automatizaciones vinculadas.",
|
|
||||||
"moreAutomations": "+ {{count}} más",
|
"moreAutomations": "+ {{count}} más",
|
||||||
"confirmWithAutomations": "Eliminar",
|
"confirmWithAutomations": "Eliminar",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1372,26 +1365,6 @@
|
|||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"copied": "Copiado"
|
"copied": "Copiado"
|
||||||
},
|
},
|
||||||
"workbench": {
|
|
||||||
"aria": "Área de conversaciones",
|
|
||||||
"panes": "Paneles",
|
|
||||||
"panesInTab": "Paneles de {{title}}",
|
|
||||||
"dropPane": "Mover {{pane}} a {{tab}}",
|
|
||||||
"moveToTab": "Mover a una pestaña",
|
|
||||||
"layout": "Diseño de paneles",
|
|
||||||
"addPane": "Añadir panel",
|
|
||||||
"promotePane": "Convertir {{title}} en el panel principal",
|
|
||||||
"paneActions": "Acciones del panel {{title}}",
|
|
||||||
"detachPane": "Mover {{title}} a su propio tema",
|
|
||||||
"composerAria": "Mensaje para {{title}}",
|
|
||||||
"layouts": {
|
|
||||||
"columns": "Columnas",
|
|
||||||
"rows": "Filas",
|
|
||||||
"grid": "Cuadrícula",
|
|
||||||
"main-stack": "Principal y pila",
|
|
||||||
"monocle": "Monóculo"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Cerrar",
|
"dismiss": "Cerrar",
|
||||||
"close": "Cerrar",
|
"close": "Cerrar",
|
||||||
|
|||||||
@@ -951,10 +951,6 @@
|
|||||||
"unarchive": "Désarchiver",
|
"unarchive": "Désarchiver",
|
||||||
"showArchived": "Afficher les archives",
|
"showArchived": "Afficher les archives",
|
||||||
"hideArchived": "Masquer les archives",
|
"hideArchived": "Masquer les archives",
|
||||||
"select": "Sélectionner",
|
|
||||||
"cancelSelection": "Annuler la sélection",
|
|
||||||
"selectedCount": "{{count}} sélectionnés",
|
|
||||||
"deleteSelected": "Supprimer",
|
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"newChat": "Nouveau sujet",
|
"newChat": "Nouveau sujet",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -969,13 +965,10 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "Supprimer cette discussion ?",
|
"title": "Supprimer cette discussion ?",
|
||||||
"titleMany": "Supprimer {{count}} discussions et volets ?",
|
|
||||||
"description": "Cette action est irréversible.",
|
"description": "Cette action est irréversible.",
|
||||||
"descriptionMany": "Cette action est irréversible.",
|
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"confirm": "Supprimer",
|
"confirm": "Supprimer",
|
||||||
"automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.",
|
"automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.",
|
||||||
"automationsDescriptionMany": "Les automatisations liées seront également supprimées.",
|
|
||||||
"moreAutomations": "+ {{count}} autres",
|
"moreAutomations": "+ {{count}} autres",
|
||||||
"confirmWithAutomations": "Supprimer",
|
"confirmWithAutomations": "Supprimer",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1371,26 +1364,6 @@
|
|||||||
"copy": "Copier",
|
"copy": "Copier",
|
||||||
"copied": "Copié"
|
"copied": "Copié"
|
||||||
},
|
},
|
||||||
"workbench": {
|
|
||||||
"aria": "Espace de conversations",
|
|
||||||
"panes": "Volets",
|
|
||||||
"panesInTab": "Volets dans {{title}}",
|
|
||||||
"dropPane": "Déplacer {{pane}} dans {{tab}}",
|
|
||||||
"moveToTab": "Déplacer vers un onglet",
|
|
||||||
"layout": "Disposition des volets",
|
|
||||||
"addPane": "Ajouter un volet",
|
|
||||||
"promotePane": "Définir {{title}} comme volet principal",
|
|
||||||
"paneActions": "Actions du volet {{title}}",
|
|
||||||
"detachPane": "Déplacer {{title}} vers son propre sujet",
|
|
||||||
"composerAria": "Message à {{title}}",
|
|
||||||
"layouts": {
|
|
||||||
"columns": "Colonnes",
|
|
||||||
"rows": "Lignes",
|
|
||||||
"grid": "Grille",
|
|
||||||
"main-stack": "Principal et pile",
|
|
||||||
"monocle": "Monocle"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Fermer",
|
"dismiss": "Fermer",
|
||||||
"close": "Fermer",
|
"close": "Fermer",
|
||||||
|
|||||||
@@ -951,10 +951,6 @@
|
|||||||
"unarchive": "Batalkan arsip",
|
"unarchive": "Batalkan arsip",
|
||||||
"showArchived": "Tampilkan yang diarsipkan",
|
"showArchived": "Tampilkan yang diarsipkan",
|
||||||
"hideArchived": "Sembunyikan yang diarsipkan",
|
"hideArchived": "Sembunyikan yang diarsipkan",
|
||||||
"select": "Pilih",
|
|
||||||
"cancelSelection": "Batalkan pilihan",
|
|
||||||
"selectedCount": "{{count}} dipilih",
|
|
||||||
"deleteSelected": "Hapus",
|
|
||||||
"delete": "Hapus",
|
"delete": "Hapus",
|
||||||
"newChat": "Topik baru",
|
"newChat": "Topik baru",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -969,13 +965,10 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "Hapus obrolan ini?",
|
"title": "Hapus obrolan ini?",
|
||||||
"titleMany": "Hapus {{count}} obrolan dan panel?",
|
|
||||||
"description": "Tindakan ini tidak dapat dibatalkan.",
|
"description": "Tindakan ini tidak dapat dibatalkan.",
|
||||||
"descriptionMany": "Tindakan ini tidak dapat dibatalkan.",
|
|
||||||
"cancel": "Batal",
|
"cancel": "Batal",
|
||||||
"confirm": "Hapus",
|
"confirm": "Hapus",
|
||||||
"automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.",
|
"automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.",
|
||||||
"automationsDescriptionMany": "Automasi terkait juga akan dihapus.",
|
|
||||||
"moreAutomations": "+ {{count}} lagi",
|
"moreAutomations": "+ {{count}} lagi",
|
||||||
"confirmWithAutomations": "Hapus",
|
"confirmWithAutomations": "Hapus",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1371,26 +1364,6 @@
|
|||||||
"copy": "Salin",
|
"copy": "Salin",
|
||||||
"copied": "Tersalin"
|
"copied": "Tersalin"
|
||||||
},
|
},
|
||||||
"workbench": {
|
|
||||||
"aria": "Ruang kerja percakapan",
|
|
||||||
"panes": "Panel",
|
|
||||||
"panesInTab": "Panel di {{title}}",
|
|
||||||
"dropPane": "Pindahkan {{pane}} ke {{tab}}",
|
|
||||||
"moveToTab": "Pindahkan ke tab",
|
|
||||||
"layout": "Tata letak panel",
|
|
||||||
"addPane": "Tambah panel",
|
|
||||||
"promotePane": "Jadikan {{title}} panel utama",
|
|
||||||
"paneActions": "Tindakan panel {{title}}",
|
|
||||||
"detachPane": "Pindahkan {{title}} ke topik tersendiri",
|
|
||||||
"composerAria": "Pesan untuk {{title}}",
|
|
||||||
"layouts": {
|
|
||||||
"columns": "Kolom",
|
|
||||||
"rows": "Baris",
|
|
||||||
"grid": "Kisi",
|
|
||||||
"main-stack": "Utama dan tumpukan",
|
|
||||||
"monocle": "Panel tunggal"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Tutup",
|
"dismiss": "Tutup",
|
||||||
"close": "Tutup",
|
"close": "Tutup",
|
||||||
|
|||||||
@@ -951,10 +951,6 @@
|
|||||||
"unarchive": "アーカイブを解除",
|
"unarchive": "アーカイブを解除",
|
||||||
"showArchived": "アーカイブ済みを表示",
|
"showArchived": "アーカイブ済みを表示",
|
||||||
"hideArchived": "アーカイブ済みを隠す",
|
"hideArchived": "アーカイブ済みを隠す",
|
||||||
"select": "選択",
|
|
||||||
"cancelSelection": "選択を解除",
|
|
||||||
"selectedCount": "{{count}} 件を選択中",
|
|
||||||
"deleteSelected": "削除",
|
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"newChat": "新しいトピック",
|
"newChat": "新しいトピック",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -969,13 +965,10 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "このチャットを削除しますか?",
|
"title": "このチャットを削除しますか?",
|
||||||
"titleMany": "{{count}} 件のチャットとペインを削除しますか?",
|
|
||||||
"description": "この操作は元に戻せません。",
|
"description": "この操作は元に戻せません。",
|
||||||
"descriptionMany": "この操作は元に戻せません。",
|
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"confirm": "削除",
|
"confirm": "削除",
|
||||||
"automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。",
|
"automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。",
|
||||||
"automationsDescriptionMany": "関連する自動タスクも削除されます。",
|
|
||||||
"moreAutomations": "他 {{count}} 件",
|
"moreAutomations": "他 {{count}} 件",
|
||||||
"confirmWithAutomations": "削除",
|
"confirmWithAutomations": "削除",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1371,26 +1364,6 @@
|
|||||||
"copy": "コピー",
|
"copy": "コピー",
|
||||||
"copied": "コピーしました"
|
"copied": "コピーしました"
|
||||||
},
|
},
|
||||||
"workbench": {
|
|
||||||
"aria": "会話ワークベンチ",
|
|
||||||
"panes": "ペイン",
|
|
||||||
"panesInTab": "{{title}} のペイン",
|
|
||||||
"dropPane": "{{pane}} を {{tab}} に移動",
|
|
||||||
"moveToTab": "タブへ移動",
|
|
||||||
"layout": "ペインレイアウト",
|
|
||||||
"addPane": "ペインを追加",
|
|
||||||
"promotePane": "{{title}} をメインペインにする",
|
|
||||||
"paneActions": "{{title}} ペインの操作",
|
|
||||||
"detachPane": "{{title}} を独立したトピックに移動",
|
|
||||||
"composerAria": "{{title}} へのメッセージ",
|
|
||||||
"layouts": {
|
|
||||||
"columns": "列",
|
|
||||||
"rows": "行",
|
|
||||||
"grid": "グリッド",
|
|
||||||
"main-stack": "メインとスタック",
|
|
||||||
"monocle": "モノクル"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "閉じる",
|
"dismiss": "閉じる",
|
||||||
"close": "閉じる",
|
"close": "閉じる",
|
||||||
|
|||||||
@@ -951,10 +951,6 @@
|
|||||||
"unarchive": "보관 해제",
|
"unarchive": "보관 해제",
|
||||||
"showArchived": "보관된 항목 표시",
|
"showArchived": "보관된 항목 표시",
|
||||||
"hideArchived": "보관된 항목 숨기기",
|
"hideArchived": "보관된 항목 숨기기",
|
||||||
"select": "선택",
|
|
||||||
"cancelSelection": "선택 취소",
|
|
||||||
"selectedCount": "{{count}}개 선택됨",
|
|
||||||
"deleteSelected": "삭제",
|
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"newChat": "새 주제",
|
"newChat": "새 주제",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -969,13 +965,10 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "이 채팅을 삭제할까요?",
|
"title": "이 채팅을 삭제할까요?",
|
||||||
"titleMany": "채팅과 창 {{count}}개를 삭제할까요?",
|
|
||||||
"description": "이 작업은 되돌릴 수 없습니다.",
|
"description": "이 작업은 되돌릴 수 없습니다.",
|
||||||
"descriptionMany": "이 작업은 되돌릴 수 없습니다.",
|
|
||||||
"cancel": "취소",
|
"cancel": "취소",
|
||||||
"confirm": "삭제",
|
"confirm": "삭제",
|
||||||
"automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.",
|
"automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.",
|
||||||
"automationsDescriptionMany": "연결된 자동화도 함께 삭제됩니다.",
|
|
||||||
"moreAutomations": "+ {{count}}개 더",
|
"moreAutomations": "+ {{count}}개 더",
|
||||||
"confirmWithAutomations": "삭제",
|
"confirmWithAutomations": "삭제",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1371,26 +1364,6 @@
|
|||||||
"copy": "복사",
|
"copy": "복사",
|
||||||
"copied": "복사됨"
|
"copied": "복사됨"
|
||||||
},
|
},
|
||||||
"workbench": {
|
|
||||||
"aria": "대화 워크벤치",
|
|
||||||
"panes": "창",
|
|
||||||
"panesInTab": "{{title}}의 창",
|
|
||||||
"dropPane": "{{pane}}을(를) {{tab}}으로 이동",
|
|
||||||
"moveToTab": "탭으로 이동",
|
|
||||||
"layout": "창 레이아웃",
|
|
||||||
"addPane": "창 추가",
|
|
||||||
"promotePane": "{{title}}을(를) 기본 창으로 설정",
|
|
||||||
"paneActions": "{{title}} 창 작업",
|
|
||||||
"detachPane": "{{title}}을(를) 별도 주제로 이동",
|
|
||||||
"composerAria": "{{title}}에 메시지 보내기",
|
|
||||||
"layouts": {
|
|
||||||
"columns": "열",
|
|
||||||
"rows": "행",
|
|
||||||
"grid": "그리드",
|
|
||||||
"main-stack": "기본 창과 스택",
|
|
||||||
"monocle": "단일 창"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "닫기",
|
"dismiss": "닫기",
|
||||||
"close": "닫기",
|
"close": "닫기",
|
||||||
|
|||||||
@@ -965,10 +965,6 @@
|
|||||||
"unarchive": "Desarquivar",
|
"unarchive": "Desarquivar",
|
||||||
"showArchived": "Mostrar arquivadas",
|
"showArchived": "Mostrar arquivadas",
|
||||||
"hideArchived": "Ocultar arquivadas",
|
"hideArchived": "Ocultar arquivadas",
|
||||||
"select": "Selecionar",
|
|
||||||
"cancelSelection": "Cancelar seleção",
|
|
||||||
"selectedCount": "{{count}} selecionados",
|
|
||||||
"deleteSelected": "Excluir",
|
|
||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"newChat": "Novo tópico",
|
"newChat": "Novo tópico",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -983,13 +979,10 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "Excluir esta conversa?",
|
"title": "Excluir esta conversa?",
|
||||||
"titleMany": "Excluir {{count}} conversas e painéis?",
|
|
||||||
"description": "Esta ação não pode ser desfeita.",
|
"description": "Esta ação não pode ser desfeita.",
|
||||||
"descriptionMany": "Esta ação não pode ser desfeita.",
|
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"confirm": "Excluir",
|
"confirm": "Excluir",
|
||||||
"automationsDescription": "Esta conversa possui automações agendadas. Excluí-la também excluirá essas automações.",
|
"automationsDescription": "Esta conversa possui automações agendadas. Excluí-la também excluirá essas automações.",
|
||||||
"automationsDescriptionMany": "As automações vinculadas também serão excluídas.",
|
|
||||||
"moreAutomations": "+ {{count}} a mais",
|
"moreAutomations": "+ {{count}} a mais",
|
||||||
"confirmWithAutomations": "Excluir",
|
"confirmWithAutomations": "Excluir",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1385,26 +1378,6 @@
|
|||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"copied": "Copiado"
|
"copied": "Copiado"
|
||||||
},
|
},
|
||||||
"workbench": {
|
|
||||||
"aria": "Área de conversas",
|
|
||||||
"panes": "Painéis",
|
|
||||||
"panesInTab": "Painéis em {{title}}",
|
|
||||||
"dropPane": "Mover {{pane}} para {{tab}}",
|
|
||||||
"moveToTab": "Mover para uma aba",
|
|
||||||
"layout": "Layout de painéis",
|
|
||||||
"addPane": "Adicionar painel",
|
|
||||||
"promotePane": "Tornar {{title}} o painel principal",
|
|
||||||
"paneActions": "Ações do painel {{title}}",
|
|
||||||
"detachPane": "Mover {{title}} para seu próprio tópico",
|
|
||||||
"composerAria": "Mensagem para {{title}}",
|
|
||||||
"layouts": {
|
|
||||||
"columns": "Colunas",
|
|
||||||
"rows": "Linhas",
|
|
||||||
"grid": "Grade",
|
|
||||||
"main-stack": "Principal e pilha",
|
|
||||||
"monocle": "Monóculo"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Descartar",
|
"dismiss": "Descartar",
|
||||||
"close": "Fechar",
|
"close": "Fechar",
|
||||||
|
|||||||
@@ -951,10 +951,6 @@
|
|||||||
"unarchive": "Bỏ lưu trữ",
|
"unarchive": "Bỏ lưu trữ",
|
||||||
"showArchived": "Hiện mục đã lưu trữ",
|
"showArchived": "Hiện mục đã lưu trữ",
|
||||||
"hideArchived": "Ẩn mục đã lưu trữ",
|
"hideArchived": "Ẩn mục đã lưu trữ",
|
||||||
"select": "Chọn",
|
|
||||||
"cancelSelection": "Hủy chọn",
|
|
||||||
"selectedCount": "Đã chọn {{count}} mục",
|
|
||||||
"deleteSelected": "Xóa",
|
|
||||||
"delete": "Xóa",
|
"delete": "Xóa",
|
||||||
"newChat": "Chủ đề mới",
|
"newChat": "Chủ đề mới",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -969,13 +965,10 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "Xóa cuộc trò chuyện này?",
|
"title": "Xóa cuộc trò chuyện này?",
|
||||||
"titleMany": "Xóa {{count}} cuộc trò chuyện và khung?",
|
|
||||||
"description": "Không thể hoàn tác thao tác này.",
|
"description": "Không thể hoàn tác thao tác này.",
|
||||||
"descriptionMany": "Không thể hoàn tác thao tác này.",
|
|
||||||
"cancel": "Hủy",
|
"cancel": "Hủy",
|
||||||
"confirm": "Xóa",
|
"confirm": "Xóa",
|
||||||
"automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.",
|
"automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.",
|
||||||
"automationsDescriptionMany": "Các tự động hóa liên kết cũng sẽ bị xóa.",
|
|
||||||
"moreAutomations": "+ {{count}} mục nữa",
|
"moreAutomations": "+ {{count}} mục nữa",
|
||||||
"confirmWithAutomations": "Xóa",
|
"confirmWithAutomations": "Xóa",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1371,26 +1364,6 @@
|
|||||||
"copy": "Sao chép",
|
"copy": "Sao chép",
|
||||||
"copied": "Đã sao chép"
|
"copied": "Đã sao chép"
|
||||||
},
|
},
|
||||||
"workbench": {
|
|
||||||
"aria": "Không gian hội thoại",
|
|
||||||
"panes": "Khung",
|
|
||||||
"panesInTab": "Các khung trong {{title}}",
|
|
||||||
"dropPane": "Di chuyển {{pane}} vào {{tab}}",
|
|
||||||
"moveToTab": "Di chuyển vào thẻ",
|
|
||||||
"layout": "Bố cục khung",
|
|
||||||
"addPane": "Thêm khung",
|
|
||||||
"promotePane": "Đặt {{title}} làm khung chính",
|
|
||||||
"paneActions": "Thao tác cho khung {{title}}",
|
|
||||||
"detachPane": "Chuyển {{title}} thành chủ đề riêng",
|
|
||||||
"composerAria": "Nhắn tin cho {{title}}",
|
|
||||||
"layouts": {
|
|
||||||
"columns": "Cột",
|
|
||||||
"rows": "Hàng",
|
|
||||||
"grid": "Lưới",
|
|
||||||
"main-stack": "Khung chính và ngăn xếp",
|
|
||||||
"monocle": "Một khung"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "Đóng",
|
"dismiss": "Đóng",
|
||||||
"close": "Đóng",
|
"close": "Đóng",
|
||||||
|
|||||||
@@ -965,10 +965,6 @@
|
|||||||
"unarchive": "取消归档",
|
"unarchive": "取消归档",
|
||||||
"showArchived": "显示归档",
|
"showArchived": "显示归档",
|
||||||
"hideArchived": "隐藏归档",
|
"hideArchived": "隐藏归档",
|
||||||
"select": "选择",
|
|
||||||
"cancelSelection": "取消选择",
|
|
||||||
"selectedCount": "已选择 {{count}} 项",
|
|
||||||
"deleteSelected": "删除",
|
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"newChat": "新建话题",
|
"newChat": "新建话题",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -983,13 +979,10 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "删除这个话题?",
|
"title": "删除这个话题?",
|
||||||
"titleMany": "删除这 {{count}} 个话题和窗格?",
|
|
||||||
"description": "此操作无法撤销。",
|
"description": "此操作无法撤销。",
|
||||||
"descriptionMany": "此操作无法撤销。",
|
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "删除",
|
"confirm": "删除",
|
||||||
"automationsDescription": "这个话题有关联的自动任务。删除话题也会删除这些自动任务。",
|
"automationsDescription": "这个话题有关联的自动任务。删除话题也会删除这些自动任务。",
|
||||||
"automationsDescriptionMany": "关联的自动任务也会一并删除。",
|
|
||||||
"moreAutomations": "另有 {{count}} 个",
|
"moreAutomations": "另有 {{count}} 个",
|
||||||
"confirmWithAutomations": "删除",
|
"confirmWithAutomations": "删除",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1385,26 +1378,6 @@
|
|||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"copied": "已复制"
|
"copied": "已复制"
|
||||||
},
|
},
|
||||||
"workbench": {
|
|
||||||
"aria": "会话工作台",
|
|
||||||
"panes": "窗格",
|
|
||||||
"panesInTab": "{{title}} 中的窗格",
|
|
||||||
"dropPane": "将 {{pane}} 移入 {{tab}}",
|
|
||||||
"moveToTab": "移动到标签页",
|
|
||||||
"layout": "窗格布局",
|
|
||||||
"addPane": "添加窗格",
|
|
||||||
"promotePane": "将 {{title}} 设为主窗格",
|
|
||||||
"paneActions": "{{title}} 窗格操作",
|
|
||||||
"detachPane": "将 {{title}} 移至独立主题",
|
|
||||||
"composerAria": "向 {{title}} 发送消息",
|
|
||||||
"layouts": {
|
|
||||||
"columns": "列布局",
|
|
||||||
"rows": "行布局",
|
|
||||||
"grid": "网格",
|
|
||||||
"main-stack": "主窗格与堆栈",
|
|
||||||
"monocle": "单窗格"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "关闭",
|
"dismiss": "关闭",
|
||||||
"close": "关闭",
|
"close": "关闭",
|
||||||
|
|||||||
@@ -951,10 +951,6 @@
|
|||||||
"unarchive": "取消封存",
|
"unarchive": "取消封存",
|
||||||
"showArchived": "顯示封存",
|
"showArchived": "顯示封存",
|
||||||
"hideArchived": "隱藏封存",
|
"hideArchived": "隱藏封存",
|
||||||
"select": "選取",
|
|
||||||
"cancelSelection": "取消選取",
|
|
||||||
"selectedCount": "已選取 {{count}} 項",
|
|
||||||
"deleteSelected": "刪除",
|
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"newChat": "新增話題",
|
"newChat": "新增話題",
|
||||||
"groups": {
|
"groups": {
|
||||||
@@ -969,13 +965,10 @@
|
|||||||
},
|
},
|
||||||
"deleteConfirm": {
|
"deleteConfirm": {
|
||||||
"title": "刪除這個話題?",
|
"title": "刪除這個話題?",
|
||||||
"titleMany": "刪除這 {{count}} 個話題和窗格?",
|
|
||||||
"description": "此操作無法復原。",
|
"description": "此操作無法復原。",
|
||||||
"descriptionMany": "此操作無法復原。",
|
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "刪除",
|
"confirm": "刪除",
|
||||||
"automationsDescription": "這個話題含有已排程的自動任務。刪除話題時也會一併刪除這些任務。",
|
"automationsDescription": "這個話題含有已排程的自動任務。刪除話題時也會一併刪除這些任務。",
|
||||||
"automationsDescriptionMany": "關聯的自動任務也會一併刪除。",
|
|
||||||
"moreAutomations": "另有 {{count}} 個",
|
"moreAutomations": "另有 {{count}} 個",
|
||||||
"confirmWithAutomations": "刪除",
|
"confirmWithAutomations": "刪除",
|
||||||
"schedule": {
|
"schedule": {
|
||||||
@@ -1371,26 +1364,6 @@
|
|||||||
"copy": "複製",
|
"copy": "複製",
|
||||||
"copied": "已複製"
|
"copied": "已複製"
|
||||||
},
|
},
|
||||||
"workbench": {
|
|
||||||
"aria": "對話工作台",
|
|
||||||
"panes": "窗格",
|
|
||||||
"panesInTab": "{{title}} 中的窗格",
|
|
||||||
"dropPane": "將 {{pane}} 移入 {{tab}}",
|
|
||||||
"moveToTab": "移動到分頁",
|
|
||||||
"layout": "窗格佈局",
|
|
||||||
"addPane": "新增窗格",
|
|
||||||
"promotePane": "將 {{title}} 設為主窗格",
|
|
||||||
"paneActions": "{{title}} 窗格操作",
|
|
||||||
"detachPane": "將 {{title}} 移至獨立主題",
|
|
||||||
"composerAria": "傳送訊息給 {{title}}",
|
|
||||||
"layouts": {
|
|
||||||
"columns": "欄佈局",
|
|
||||||
"rows": "列佈局",
|
|
||||||
"grid": "網格",
|
|
||||||
"main-stack": "主窗格與堆疊",
|
|
||||||
"monocle": "單窗格"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"common": {
|
"common": {
|
||||||
"dismiss": "關閉",
|
"dismiss": "關閉",
|
||||||
"close": "關閉",
|
"close": "關閉",
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key";
|
export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key";
|
||||||
export const PANE_DRAG_TYPE = "application/x-nanobot-pane";
|
|
||||||
|
|
||||||
export interface DraggedPane {
|
|
||||||
paneKey: string;
|
|
||||||
sourceTabKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let activeSessionKey: string | null = null;
|
let activeSessionKey: string | null = null;
|
||||||
let activePane: DraggedPane | null = null;
|
|
||||||
|
|
||||||
export function hasDraggedSession(dataTransfer: DataTransfer): boolean {
|
export function hasDraggedSession(dataTransfer: DataTransfer): boolean {
|
||||||
return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE);
|
return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE);
|
||||||
@@ -20,7 +13,6 @@ export function readDraggedSession(dataTransfer: DataTransfer): string | null {
|
|||||||
|
|
||||||
export function clearDraggedSession(): void {
|
export function clearDraggedSession(): void {
|
||||||
activeSessionKey = null;
|
activeSessionKey = null;
|
||||||
activePane = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeDraggedSession(
|
export function writeDraggedSession(
|
||||||
@@ -31,27 +23,3 @@ export function writeDraggedSession(
|
|||||||
dataTransfer.effectAllowed = "copyMove";
|
dataTransfer.effectAllowed = "copyMove";
|
||||||
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
|
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readDraggedPane(dataTransfer: DataTransfer): DraggedPane | null {
|
|
||||||
const serialized = dataTransfer.getData(PANE_DRAG_TYPE).trim();
|
|
||||||
if (serialized) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(serialized) as Partial<DraggedPane>;
|
|
||||||
if (parsed.paneKey && parsed.sourceTabKey) {
|
|
||||||
return { paneKey: parsed.paneKey, sourceTabKey: parsed.sourceTabKey };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to the in-memory payload used while the native drag is active.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return activePane;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeDraggedPane(
|
|
||||||
dataTransfer: DataTransfer,
|
|
||||||
pane: DraggedPane,
|
|
||||||
): void {
|
|
||||||
activePane = pane;
|
|
||||||
writeDraggedSession(dataTransfer, pane.paneKey);
|
|
||||||
dataTransfer.setData(PANE_DRAG_TYPE, JSON.stringify(pane));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -164,24 +164,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
refresh: refreshSpy,
|
refresh: refreshSpy,
|
||||||
createChat: async (scope?: WorkspaceScopePayload | null) => {
|
createChat: createChatSpy,
|
||||||
const chatId = await createChatSpy(scope);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
setSessions((prev: ChatSummary[]) => [
|
|
||||||
{
|
|
||||||
key: `websocket:${chatId}`,
|
|
||||||
channel: "websocket",
|
|
||||||
chatId,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
title: "",
|
|
||||||
preview: "",
|
|
||||||
workspaceScope: scope ?? null,
|
|
||||||
},
|
|
||||||
...prev.filter((session) => session.chatId !== chatId),
|
|
||||||
]);
|
|
||||||
return chatId;
|
|
||||||
},
|
|
||||||
forkChat: async () => "fork-chat",
|
forkChat: async () => "fork-chat",
|
||||||
getSessionAutomations: getSessionAutomationsSpy,
|
getSessionAutomations: getSessionAutomationsSpy,
|
||||||
deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
|
deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
|
||||||
@@ -307,8 +290,6 @@ describe("App layout", () => {
|
|||||||
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
|
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
|
||||||
localStorage.removeItem("nanobot-webui.restartStartedAt");
|
localStorage.removeItem("nanobot-webui.restartStartedAt");
|
||||||
localStorage.removeItem("nanobot-webui.restartRoute");
|
localStorage.removeItem("nanobot-webui.restartRoute");
|
||||||
localStorage.removeItem("nanobot.webui.workbench.v1");
|
|
||||||
localStorage.removeItem("nanobot.webui.workbench.v2");
|
|
||||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||||
token: "tok",
|
token: "tok",
|
||||||
api_token: "api-tok",
|
api_token: "api-tok",
|
||||||
@@ -504,9 +485,8 @@ describe("App layout", () => {
|
|||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
const firstMessage = "keep this first turn visible";
|
|
||||||
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
|
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
|
||||||
target: { value: firstMessage },
|
target: { value: "/model" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||||
|
|
||||||
@@ -516,7 +496,6 @@ describe("App layout", () => {
|
|||||||
`#/chat/${encodeURIComponent("websocket:chat-1")}`,
|
`#/chat/${encodeURIComponent("websocket:chat-1")}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(await screen.findByText(firstMessage)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a new temporary chat from the hero each time", async () => {
|
it("creates a new temporary chat from the hero each time", async () => {
|
||||||
@@ -1670,60 +1649,6 @@ describe("App layout", () => {
|
|||||||
expect(document.body.style.pointerEvents).not.toBe("none");
|
expect(document.body.style.pointerEvents).not.toBe("none");
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
|
|
||||||
it("deletes multiple selected topics through one confirmation", async () => {
|
|
||||||
mockSessions = [
|
|
||||||
{
|
|
||||||
key: "websocket:chat-a",
|
|
||||||
channel: "websocket",
|
|
||||||
chatId: "chat-a",
|
|
||||||
createdAt: "2026-04-16T10:00:00Z",
|
|
||||||
updatedAt: "2026-04-16T10:00:00Z",
|
|
||||||
preview: "First chat",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "websocket:chat-b",
|
|
||||||
channel: "websocket",
|
|
||||||
chatId: "chat-b",
|
|
||||||
createdAt: "2026-04-16T11:00:00Z",
|
|
||||||
updatedAt: "2026-04-16T11:00:00Z",
|
|
||||||
preview: "Second chat",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "websocket:chat-c",
|
|
||||||
channel: "websocket",
|
|
||||||
chatId: "chat-c",
|
|
||||||
createdAt: "2026-04-16T12:00:00Z",
|
|
||||||
updatedAt: "2026-04-16T12:00:00Z",
|
|
||||||
preview: "Third chat",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
render(<App />);
|
|
||||||
|
|
||||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
||||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
||||||
fireEvent.pointerDown(within(sidebar).getByLabelText(
|
|
||||||
"Topic actions for First chat",
|
|
||||||
), { button: 0 });
|
|
||||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
|
|
||||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Second chat" }));
|
|
||||||
expect(within(sidebar).getByText("2 selected")).toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Delete" }));
|
|
||||||
expect(await screen.findByText("Delete 2 topics and panes?")).toBeInTheDocument();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(deleteChatSpy).toHaveBeenCalledTimes(2));
|
|
||||||
expect(deleteChatSpy.mock.calls.map(([key]) => key)).toEqual([
|
|
||||||
"websocket:chat-a",
|
|
||||||
"websocket:chat-b",
|
|
||||||
]);
|
|
||||||
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-a");
|
|
||||||
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-b");
|
|
||||||
expect(within(sidebar).getByRole("button", { name: "Third chat" }))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
}, 15_000);
|
|
||||||
|
|
||||||
it("shows localized bound automations in the first delete confirmation", async () => {
|
it("shows localized bound automations in the first delete confirmation", async () => {
|
||||||
mockSessions = [
|
mockSessions = [
|
||||||
{
|
{
|
||||||
@@ -3018,109 +2943,6 @@ describe("App layout", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps panes and layout scoped to the current topic tab", async () => {
|
|
||||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
|
||||||
matches: false,
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addListener: vi.fn(),
|
|
||||||
removeListener: vi.fn(),
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
})));
|
|
||||||
createChatSpy.mockResolvedValueOnce("chat-pane");
|
|
||||||
mockSessions = [
|
|
||||||
{
|
|
||||||
key: "websocket:chat-alpha",
|
|
||||||
channel: "websocket",
|
|
||||||
chatId: "chat-alpha",
|
|
||||||
createdAt: "2026-04-16T10:00:00Z",
|
|
||||||
updatedAt: "2026-04-16T10:00:00Z",
|
|
||||||
title: "Alpha",
|
|
||||||
preview: "Alpha notes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "websocket:chat-beta",
|
|
||||||
channel: "websocket",
|
|
||||||
chatId: "chat-beta",
|
|
||||||
createdAt: "2026-04-16T11:00:00Z",
|
|
||||||
updatedAt: "2026-04-16T11:00:00Z",
|
|
||||||
title: "Beta",
|
|
||||||
preview: "Beta notes",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
window.history.replaceState(
|
|
||||||
null,
|
|
||||||
"",
|
|
||||||
"/#/chat/websocket%3Achat-alpha",
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<App />);
|
|
||||||
|
|
||||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
|
||||||
const grid = await screen.findByTestId("pane-grid");
|
|
||||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
|
||||||
.toEqual(["Alpha"]);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Add pane" }));
|
|
||||||
expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument();
|
|
||||||
await waitFor(() => expect(createChatSpy).toHaveBeenCalledTimes(1));
|
|
||||||
|
|
||||||
await waitFor(() => expect(grid.children).toHaveLength(2));
|
|
||||||
expect(window.location.hash).toBe("#/chat/websocket%3Achat-alpha");
|
|
||||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
|
||||||
.toEqual(["Alpha", "New topic"]);
|
|
||||||
|
|
||||||
const activeComposer = screen.getByTestId("active-pane-composer");
|
|
||||||
const paneInput = within(activeComposer).getByRole("textbox", {
|
|
||||||
name: "Message New topic",
|
|
||||||
});
|
|
||||||
fireEvent.change(paneInput, { target: { value: "route this to the new pane" } });
|
|
||||||
fireEvent.keyDown(paneInput, { key: "Enter" });
|
|
||||||
await waitFor(() => expect(sendMessageSpy).toHaveBeenCalled());
|
|
||||||
expect(sendMessageSpy.mock.calls.at(-1)?.[0]).toBe("chat-pane");
|
|
||||||
|
|
||||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Pane layout" }), {
|
|
||||||
button: 0,
|
|
||||||
ctrlKey: false,
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
|
|
||||||
expect(grid).toHaveAttribute("data-layout", "rows");
|
|
||||||
|
|
||||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
|
||||||
const paneTopicButton = within(sidebar)
|
|
||||||
.getAllByRole("button", { name: "New topic" })
|
|
||||||
.find((button) => button.closest("[data-sidebar-pane]"));
|
|
||||||
expect(paneTopicButton).toBeDefined();
|
|
||||||
expect(paneTopicButton?.closest("[data-sidebar-pane]"))
|
|
||||||
.toHaveAttribute("data-sidebar-pane", "websocket:chat-pane");
|
|
||||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Beta" }));
|
|
||||||
await waitFor(() => {
|
|
||||||
const nextGrid = screen.getByTestId("pane-grid");
|
|
||||||
expect(Array.from(nextGrid.children).map((pane) => pane.getAttribute("aria-label")))
|
|
||||||
.toEqual(["Beta"]);
|
|
||||||
expect(nextGrid).toHaveAttribute("data-layout", "columns");
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Alpha" }));
|
|
||||||
await waitFor(() => {
|
|
||||||
const restoredGrid = screen.getByTestId("pane-grid");
|
|
||||||
expect(Array.from(restoredGrid.children).map((pane) => pane.getAttribute("aria-label")))
|
|
||||||
.toEqual(["Alpha", "New topic"]);
|
|
||||||
expect(restoredGrid).toHaveAttribute("data-layout", "rows");
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.pointerDown(within(sidebar).getByRole("button", {
|
|
||||||
name: "New topic pane actions",
|
|
||||||
}), { button: 0, ctrlKey: false });
|
|
||||||
fireEvent.click(screen.getByRole("menuitem", {
|
|
||||||
name: "Move New topic to its own topic",
|
|
||||||
}));
|
|
||||||
await waitFor(() => expect(screen.getByTestId("pane-grid").children).toHaveLength(1));
|
|
||||||
expect(within(sidebar).getAllByRole("button", { name: "New topic" })).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens search from the keyboard shortcut", async () => {
|
it("opens search from the keyboard shortcut", async () => {
|
||||||
mockSessions = [
|
mockSessions = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createEvent, fireEvent, render, screen, within } from "@testing-library/react";
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { ChatList } from "@/components/ChatList";
|
import { ChatList } from "@/components/ChatList";
|
||||||
import { PANE_DRAG_TYPE, SESSION_DRAG_TYPE } from "@/lib/session-drag";
|
import { SESSION_DRAG_TYPE } from "@/lib/session-drag";
|
||||||
import type { ChatSummary } from "@/lib/types";
|
import type { ChatSummary } from "@/lib/types";
|
||||||
|
|
||||||
function session(overrides: Partial<ChatSummary>): ChatSummary {
|
function session(overrides: Partial<ChatSummary>): ChatSummary {
|
||||||
@@ -42,26 +42,6 @@ function rect({
|
|||||||
} as DOMRect;
|
} as DOMRect;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dragOverAt(
|
|
||||||
element: Element,
|
|
||||||
clientY: number,
|
|
||||||
dataTransfer: Record<string, unknown>,
|
|
||||||
): void {
|
|
||||||
const event = createEvent.dragOver(element, { dataTransfer });
|
|
||||||
Object.defineProperty(event, "clientY", { value: clientY });
|
|
||||||
fireEvent(element, event);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dropAt(
|
|
||||||
element: Element,
|
|
||||||
clientY: number,
|
|
||||||
dataTransfer: Record<string, unknown>,
|
|
||||||
): void {
|
|
||||||
const event = createEvent.drop(element, { dataTransfer });
|
|
||||||
Object.defineProperty(event, "clientY", { value: clientY });
|
|
||||||
fireEvent(element, event);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ChatList", () => {
|
describe("ChatList", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -102,13 +82,7 @@ describe("ChatList", () => {
|
|||||||
fireEvent.dragEnd(reference, { dataTransfer });
|
fireEvent.dragEnd(reference, { dataTransfer });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reorders topics with a displaced-neighbor preview instead of an insertion line", () => {
|
it("reorders chats around a Codex-style insertion line", () => {
|
||||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
width: 284,
|
|
||||||
height: 32,
|
|
||||||
}));
|
|
||||||
const onReorderSessions = vi.fn();
|
const onReorderSessions = vi.fn();
|
||||||
const sessions = [
|
const sessions = [
|
||||||
session({ chatId: "alpha", title: "Alpha" }),
|
session({ chatId: "alpha", title: "Alpha" }),
|
||||||
@@ -138,14 +112,10 @@ describe("ChatList", () => {
|
|||||||
};
|
};
|
||||||
fireEvent.dragStart(screen.getByRole("button", { name: "Alpha" }), { dataTransfer });
|
fireEvent.dragStart(screen.getByRole("button", { name: "Alpha" }), { dataTransfer });
|
||||||
const charlieRow = screen.getByRole("button", { name: "Charlie" }).closest("li")!;
|
const charlieRow = screen.getByRole("button", { name: "Charlie" }).closest("li")!;
|
||||||
dragOverAt(charlieRow, 24, dataTransfer);
|
fireEvent.dragOver(charlieRow, { clientY: 1, dataTransfer });
|
||||||
expect(document.querySelector("[data-session-drop-edge]")).not.toBeInTheDocument();
|
expect(charlieRow.querySelector("[data-session-drop-edge='after']"))
|
||||||
expect(screen.getByRole("button", { name: "Bravo" }).closest("li"))
|
.toBeInTheDocument();
|
||||||
.toHaveAttribute("data-session-displaced", "true");
|
fireEvent.drop(charlieRow, { clientY: 1, dataTransfer });
|
||||||
expect(charlieRow).toHaveStyle({ transform: "translateY(-32px)" });
|
|
||||||
expect(screen.getByRole("button", { name: "Alpha" }).closest("li"))
|
|
||||||
.toHaveAttribute("data-session-dragging", "true");
|
|
||||||
dropAt(charlieRow, 24, dataTransfer);
|
|
||||||
|
|
||||||
expect(onReorderSessions).toHaveBeenCalledWith([
|
expect(onReorderSessions).toHaveBeenCalledWith([
|
||||||
"websocket:bravo",
|
"websocket:bravo",
|
||||||
@@ -182,228 +152,6 @@ describe("ChatList", () => {
|
|||||||
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
|
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows every tab's pane membership in the sidebar tree", async () => {
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
const onSelectPane = vi.fn();
|
|
||||||
const onDetachPane = vi.fn();
|
|
||||||
const onPromotePane = vi.fn();
|
|
||||||
const onRequestRename = vi.fn();
|
|
||||||
const onAttachPane = vi.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ChatList
|
|
||||||
sessions={[
|
|
||||||
session({ chatId: "root", title: "Root topic" }),
|
|
||||||
session({ chatId: "target", title: "Target tab" }),
|
|
||||||
]}
|
|
||||||
activeKey="websocket:root"
|
|
||||||
paneGroups={{
|
|
||||||
"websocket:root": {
|
|
||||||
topicKey: "websocket:root",
|
|
||||||
activePaneKey: "websocket:child",
|
|
||||||
panes: [
|
|
||||||
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
|
||||||
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"websocket:target": {
|
|
||||||
topicKey: "websocket:target",
|
|
||||||
activePaneKey: "websocket:target-child",
|
|
||||||
panes: [
|
|
||||||
{ key: "websocket:target", chatId: "target", title: "Target tab" },
|
|
||||||
{
|
|
||||||
key: "websocket:target-child",
|
|
||||||
chatId: "target-child",
|
|
||||||
title: "Target research",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onSelect={onSelect}
|
|
||||||
onSelectPane={onSelectPane}
|
|
||||||
onDetachPane={onDetachPane}
|
|
||||||
onPromotePane={onPromotePane}
|
|
||||||
paneAcceptingTabKeys={["websocket:target"]}
|
|
||||||
onAttachPane={onAttachPane}
|
|
||||||
onRequestDelete={vi.fn()}
|
|
||||||
onTogglePin={vi.fn()}
|
|
||||||
onRequestRename={onRequestRename}
|
|
||||||
onToggleArchive={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const child = screen.getByRole("button", { name: "Research pane" });
|
|
||||||
expect(child.closest("[data-sidebar-pane]"))
|
|
||||||
.toHaveAttribute("data-sidebar-pane", "websocket:child");
|
|
||||||
expect(child).toHaveAttribute("aria-current", "true");
|
|
||||||
const targetTabRow = screen.getByRole("button", { name: "Target tab" }).closest("li")!;
|
|
||||||
const targetChild = within(targetTabRow).getByRole("button", {
|
|
||||||
name: "Target research",
|
|
||||||
});
|
|
||||||
expect(targetChild.closest("[data-sidebar-pane]"))
|
|
||||||
.toHaveAttribute("data-sidebar-pane", "websocket:target-child");
|
|
||||||
expect(targetChild).not.toHaveAttribute("aria-current");
|
|
||||||
fireEvent.click(targetChild);
|
|
||||||
expect(onSelectPane).toHaveBeenCalledWith(
|
|
||||||
"websocket:target",
|
|
||||||
"websocket:target-child",
|
|
||||||
);
|
|
||||||
fireEvent.click(child);
|
|
||||||
expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:child");
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Root topic" }));
|
|
||||||
expect(onSelectPane).toHaveBeenCalledWith("websocket:root", "websocket:root");
|
|
||||||
expect(onSelect).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
fireEvent.pointerDown(screen.getByRole("button", {
|
|
||||||
name: "Research pane pane actions",
|
|
||||||
}), { button: 0, ctrlKey: false });
|
|
||||||
const moveToTab = await screen.findByRole("menuitem", { name: "Move to tab" });
|
|
||||||
fireEvent.pointerMove(moveToTab, { pointerType: "mouse" });
|
|
||||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Target tab" }));
|
|
||||||
expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target");
|
|
||||||
|
|
||||||
fireEvent.pointerDown(screen.getByRole("button", {
|
|
||||||
name: "Research pane pane actions",
|
|
||||||
}), { button: 0, ctrlKey: false });
|
|
||||||
fireEvent.click(await screen.findByRole("menuitem", {
|
|
||||||
name: "Move Research pane to its own topic",
|
|
||||||
}));
|
|
||||||
expect(onDetachPane).toHaveBeenCalledWith("websocket:root", "websocket:child");
|
|
||||||
|
|
||||||
const dataTransfer = {
|
|
||||||
effectAllowed: "",
|
|
||||||
dropEffect: "",
|
|
||||||
setData: vi.fn(),
|
|
||||||
};
|
|
||||||
fireEvent.dragStart(child, { dataTransfer });
|
|
||||||
expect(child.closest("li")).toHaveAttribute("data-pane-dragging", "true");
|
|
||||||
expect(child.closest("li")).not.toHaveClass("opacity-0");
|
|
||||||
const targetTab = screen.getByRole("button", { name: "Target tab" });
|
|
||||||
dragOverAt(targetTab.closest("li")!, 0, dataTransfer);
|
|
||||||
expect(targetTab.closest("li"))
|
|
||||||
.toHaveAttribute("data-tab-attach-target", "true");
|
|
||||||
expect(within(targetTab.closest("li")!).getByRole("status", {
|
|
||||||
name: "Move Research pane into Target tab",
|
|
||||||
})).toHaveTextContent("Research pane");
|
|
||||||
dropAt(targetTab.closest("li")!, 0, dataTransfer);
|
|
||||||
expect(dataTransfer.setData).toHaveBeenCalledWith(
|
|
||||||
PANE_DRAG_TYPE,
|
|
||||||
JSON.stringify({
|
|
||||||
paneKey: "websocket:child",
|
|
||||||
sourceTabKey: "websocket:root",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(onAttachPane).toHaveBeenCalledWith("websocket:child", "websocket:target");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selects a whole tab or individual panes for one bulk delete", async () => {
|
|
||||||
const onRequestDeleteMany = vi.fn();
|
|
||||||
render(
|
|
||||||
<ChatList
|
|
||||||
sessions={[
|
|
||||||
session({ chatId: "root", title: "Root topic" }),
|
|
||||||
session({ chatId: "target", title: "Target tab" }),
|
|
||||||
]}
|
|
||||||
activeKey="websocket:root"
|
|
||||||
paneGroups={{
|
|
||||||
"websocket:root": {
|
|
||||||
topicKey: "websocket:root",
|
|
||||||
activePaneKey: "websocket:root",
|
|
||||||
panes: [
|
|
||||||
{ key: "websocket:root", chatId: "root", title: "Root topic" },
|
|
||||||
{ key: "websocket:child", chatId: "child", title: "Research pane" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onSelect={vi.fn()}
|
|
||||||
onRequestDelete={vi.fn()}
|
|
||||||
onRequestDeleteMany={onRequestDeleteMany}
|
|
||||||
onTogglePin={vi.fn()}
|
|
||||||
onRequestRename={vi.fn()}
|
|
||||||
onToggleArchive={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.pointerDown(screen.getByRole("button", {
|
|
||||||
name: "Topic actions for Root topic",
|
|
||||||
}), { button: 0, ctrlKey: false });
|
|
||||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Select" }));
|
|
||||||
|
|
||||||
expect(screen.getByRole("button", { name: "Root topic" }))
|
|
||||||
.toHaveAttribute("aria-pressed", "true");
|
|
||||||
expect(screen.getByRole("button", { name: "Research pane" }))
|
|
||||||
.toHaveAttribute("aria-pressed", "true");
|
|
||||||
expect(screen.getByText("2 selected")).toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Target tab" }));
|
|
||||||
expect(screen.getByText("3 selected")).toBeInTheDocument();
|
|
||||||
fireEvent.click(within(screen.getByTestId("delete-selection-bar")).getByRole("button", {
|
|
||||||
name: "Delete",
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(onRequestDeleteMany).toHaveBeenCalledWith([
|
|
||||||
{ key: "websocket:root", label: "Root topic" },
|
|
||||||
{ key: "websocket:child", label: "Research pane" },
|
|
||||||
{ key: "websocket:target", label: "Target tab" },
|
|
||||||
]);
|
|
||||||
expect(screen.queryByTestId("delete-selection-bar")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reattaches a one-pane tab through the center of another tab", () => {
|
|
||||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue(rect({
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
width: 284,
|
|
||||||
height: 32,
|
|
||||||
}));
|
|
||||||
const onAttachPane = vi.fn();
|
|
||||||
const onReorderSessions = vi.fn();
|
|
||||||
const dataTransfer = {
|
|
||||||
effectAllowed: "",
|
|
||||||
dropEffect: "",
|
|
||||||
setData: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
render(
|
|
||||||
<ChatList
|
|
||||||
sessions={[
|
|
||||||
session({ chatId: "detached", title: "Detached pane" }),
|
|
||||||
session({ chatId: "target", title: "Target tab" }),
|
|
||||||
]}
|
|
||||||
activeKey={null}
|
|
||||||
attachableTabKeys={["websocket:detached", "websocket:target"]}
|
|
||||||
paneAcceptingTabKeys={["websocket:detached", "websocket:target"]}
|
|
||||||
onAttachPane={onAttachPane}
|
|
||||||
onSelect={vi.fn()}
|
|
||||||
onRequestDelete={vi.fn()}
|
|
||||||
onTogglePin={vi.fn()}
|
|
||||||
onRequestRename={vi.fn()}
|
|
||||||
onToggleArchive={vi.fn()}
|
|
||||||
onReorderSessions={onReorderSessions}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const detached = screen.getByRole("button", { name: "Detached pane" });
|
|
||||||
fireEvent.dragStart(detached, {
|
|
||||||
dataTransfer,
|
|
||||||
});
|
|
||||||
expect(detached.closest("li"))
|
|
||||||
.toHaveAttribute("data-session-dragging", "true");
|
|
||||||
const target = screen.getByRole("button", { name: "Target tab" }).closest("li")!;
|
|
||||||
dragOverAt(target, 16, dataTransfer);
|
|
||||||
expect(target).toHaveAttribute("data-tab-attach-target", "true");
|
|
||||||
expect(document.querySelector("[data-session-displaced='true']"))
|
|
||||||
.not.toBeInTheDocument();
|
|
||||||
dropAt(target, 16, dataTransfer);
|
|
||||||
|
|
||||||
expect(onAttachPane).toHaveBeenCalledWith(
|
|
||||||
"websocket:detached",
|
|
||||||
"websocket:target",
|
|
||||||
);
|
|
||||||
expect(onReorderSessions).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows temporary chats separately and lets the user reopen or close them", async () => {
|
it("shows temporary chats separately and lets the user reopen or close them", async () => {
|
||||||
const temporarySession = session({
|
const temporarySession = session({
|
||||||
key: "temporary:temporary-one",
|
key: "temporary:temporary-one",
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { PaneWorkbench } from "@/components/workbench/PaneWorkbench";
|
|
||||||
import {
|
|
||||||
EMPTY_WORKBENCH_STATE,
|
|
||||||
addWorkbenchPane,
|
|
||||||
focusWorkbenchPane,
|
|
||||||
setWorkbenchLayout,
|
|
||||||
workbenchTab,
|
|
||||||
} from "@/components/workbench/workbench-model";
|
|
||||||
|
|
||||||
function rect(left: number, top: number, width: number, height: number): DOMRect {
|
|
||||||
return {
|
|
||||||
x: left,
|
|
||||||
y: top,
|
|
||||||
left,
|
|
||||||
top,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
right: left + width,
|
|
||||||
bottom: top + height,
|
|
||||||
toJSON: () => ({}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkbenchHarness() {
|
|
||||||
const [state, setState] = useState(() => (
|
|
||||||
addWorkbenchPane(EMPTY_WORKBENCH_STATE, "alpha", "beta")
|
|
||||||
));
|
|
||||||
const tab = workbenchTab(state, "alpha");
|
|
||||||
const titles: Record<string, string> = { alpha: "Alpha", beta: "Beta" };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PaneWorkbench
|
|
||||||
panes={tab.paneKeys.map((key) => ({ key, title: titles[key] }))}
|
|
||||||
activePaneKey={tab.activePaneKey}
|
|
||||||
layout={tab.layout}
|
|
||||||
onActivatePane={(key) => setState((current) => (
|
|
||||||
focusWorkbenchPane(current, "alpha", key)
|
|
||||||
))}
|
|
||||||
onAddPane={vi.fn()}
|
|
||||||
onLayoutChange={(layout) => setState((current) => (
|
|
||||||
setWorkbenchLayout(current, "alpha", layout)
|
|
||||||
))}
|
|
||||||
renderPane={(pane, context) => (
|
|
||||||
<>
|
|
||||||
<button type="button">Focus {pane.title}</button>
|
|
||||||
{context.headerPortalTarget && context.active ? createPortal(
|
|
||||||
context.headerActions,
|
|
||||||
context.headerPortalTarget,
|
|
||||||
) : null}
|
|
||||||
{context.composerPortalTarget ? createPortal(
|
|
||||||
<div hidden={!context.active}>
|
|
||||||
<textarea aria-label={`Composer ${pane.title}`} />
|
|
||||||
</div>,
|
|
||||||
context.composerPortalTarget,
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("PaneWorkbench", () => {
|
|
||||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
|
||||||
const originalAnimate = HTMLElement.prototype.animate;
|
|
||||||
const animate = vi.fn(() => ({
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
cancel: vi.fn(),
|
|
||||||
}) as unknown as Animation);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
|
|
||||||
matches: false,
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addListener: vi.fn(),
|
|
||||||
removeListener: vi.fn(),
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
})));
|
|
||||||
HTMLElement.prototype.animate = animate;
|
|
||||||
HTMLElement.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
|
||||||
if (!this.classList.contains("workbench-pane")) {
|
|
||||||
return originalGetBoundingClientRect.call(this);
|
|
||||||
}
|
|
||||||
const layout = this.parentElement?.dataset.layout;
|
|
||||||
const index = Array.from(this.parentElement?.children ?? []).indexOf(this);
|
|
||||||
return layout === "rows"
|
|
||||||
? rect(0, index * 500, 1000, 500)
|
|
||||||
: rect(index * 500, 0, 500, 1000);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
HTMLElement.prototype.animate = originalAnimate;
|
|
||||||
HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("focuses without reordering and keeps only the focused composer visible", () => {
|
|
||||||
render(<WorkbenchHarness />);
|
|
||||||
|
|
||||||
const grid = screen.getByTestId("pane-grid");
|
|
||||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
|
||||||
.toEqual(["Alpha", "Beta"]);
|
|
||||||
expect(screen.getByLabelText("Composer Beta")).toBeVisible();
|
|
||||||
expect(screen.getByLabelText("Composer Alpha")).not.toBeVisible();
|
|
||||||
|
|
||||||
fireEvent.pointerDown(
|
|
||||||
within(screen.getByRole("region", { name: "Alpha" }))
|
|
||||||
.getByRole("button", { name: "Focus Alpha" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(Array.from(grid.children).map((pane) => pane.getAttribute("aria-label")))
|
|
||||||
.toEqual(["Alpha", "Beta"]);
|
|
||||||
expect(screen.getByLabelText("Composer Alpha")).toBeVisible();
|
|
||||||
expect(screen.getByLabelText("Composer Beta")).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps one shared layout control and animates geometry changes", async () => {
|
|
||||||
render(<WorkbenchHarness />);
|
|
||||||
|
|
||||||
const header = screen.getByTestId("workbench-header-host");
|
|
||||||
expect(within(header).getAllByRole("button", { name: "Pane layout" })).toHaveLength(1);
|
|
||||||
fireEvent.pointerDown(within(header).getByRole("button", { name: "Pane layout" }), {
|
|
||||||
button: 0,
|
|
||||||
ctrlKey: false,
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole("menuitemradio", { name: "Rows" }));
|
|
||||||
expect(screen.getByTestId("pane-grid")).toHaveAttribute("data-layout", "rows");
|
|
||||||
await waitFor(() => expect(animate).toHaveBeenCalledTimes(2));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
EMPTY_WORKBENCH_STATE,
|
|
||||||
MAX_WORKBENCH_PANES,
|
|
||||||
addWorkbenchPane,
|
|
||||||
attachWorkbenchPane,
|
|
||||||
detachWorkbenchPane,
|
|
||||||
ensureWorkbenchTab,
|
|
||||||
focusWorkbenchPane,
|
|
||||||
parseWorkbenchState,
|
|
||||||
promoteWorkbenchPane,
|
|
||||||
reconcileWorkbench,
|
|
||||||
setWorkbenchLayout,
|
|
||||||
workbenchChildPaneKeys,
|
|
||||||
workbenchTab,
|
|
||||||
} from "@/components/workbench/workbench-model";
|
|
||||||
|
|
||||||
describe("workbench model", () => {
|
|
||||||
it("gives every topic its own one-pane tab by default", () => {
|
|
||||||
const state = ensureWorkbenchTab(EMPTY_WORKBENCH_STATE, "topic-a");
|
|
||||||
|
|
||||||
expect(workbenchTab(state, "topic-a")).toEqual({
|
|
||||||
paneKeys: ["topic-a"],
|
|
||||||
activePaneKey: "topic-a",
|
|
||||||
layout: "columns",
|
|
||||||
});
|
|
||||||
expect(state.tabs["topic-b"]).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps pane membership, focus, and layout scoped to a tab", () => {
|
|
||||||
let state = ensureWorkbenchTab(EMPTY_WORKBENCH_STATE, "topic-a");
|
|
||||||
state = ensureWorkbenchTab(state, "topic-b");
|
|
||||||
state = addWorkbenchPane(state, "topic-a", "topic-c");
|
|
||||||
state = setWorkbenchLayout(state, "topic-a", "main-stack");
|
|
||||||
|
|
||||||
expect(workbenchTab(state, "topic-a")).toEqual({
|
|
||||||
paneKeys: ["topic-a", "topic-c"],
|
|
||||||
activePaneKey: "topic-c",
|
|
||||||
layout: "main-stack",
|
|
||||||
});
|
|
||||||
expect(workbenchTab(state, "topic-b")).toEqual({
|
|
||||||
paneKeys: ["topic-b"],
|
|
||||||
activePaneKey: "topic-b",
|
|
||||||
layout: "columns",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("focuses without reordering and promotes only when asked", () => {
|
|
||||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "topic-b");
|
|
||||||
state = addWorkbenchPane(state, "topic-a", "topic-c");
|
|
||||||
state = focusWorkbenchPane(state, "topic-a", "topic-b");
|
|
||||||
|
|
||||||
expect(workbenchTab(state, "topic-a").paneKeys).toEqual([
|
|
||||||
"topic-a",
|
|
||||||
"topic-b",
|
|
||||||
"topic-c",
|
|
||||||
]);
|
|
||||||
|
|
||||||
state = promoteWorkbenchPane(state, "topic-a", "topic-b");
|
|
||||||
expect(workbenchTab(state, "topic-a")).toMatchObject({
|
|
||||||
paneKeys: ["topic-b", "topic-a", "topic-c"],
|
|
||||||
activePaneKey: "topic-b",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("detaches child panes, keeps the root, and chooses the adjacent focus", () => {
|
|
||||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "topic-b");
|
|
||||||
state = addWorkbenchPane(state, "topic-a", "topic-c");
|
|
||||||
state = focusWorkbenchPane(state, "topic-a", "topic-b");
|
|
||||||
state = detachWorkbenchPane(state, "topic-a", "topic-b");
|
|
||||||
|
|
||||||
expect(workbenchTab(state, "topic-a")).toMatchObject({
|
|
||||||
paneKeys: ["topic-a", "topic-c"],
|
|
||||||
activePaneKey: "topic-c",
|
|
||||||
});
|
|
||||||
|
|
||||||
state = detachWorkbenchPane(state, "topic-a", "topic-c");
|
|
||||||
state = detachWorkbenchPane(state, "topic-a", "topic-a");
|
|
||||||
expect(workbenchTab(state, "topic-a").paneKeys).toEqual(["topic-a"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moves a pane between tabs and can reattach a one-pane tab", () => {
|
|
||||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a");
|
|
||||||
state = ensureWorkbenchTab(state, "topic-b");
|
|
||||||
state = attachWorkbenchPane(state, "topic-b", "pane-a");
|
|
||||||
|
|
||||||
expect(workbenchTab(state, "topic-a")).toMatchObject({
|
|
||||||
paneKeys: ["topic-a"],
|
|
||||||
activePaneKey: "topic-a",
|
|
||||||
});
|
|
||||||
expect(workbenchTab(state, "topic-b")).toMatchObject({
|
|
||||||
paneKeys: ["topic-b", "pane-a"],
|
|
||||||
activePaneKey: "pane-a",
|
|
||||||
});
|
|
||||||
|
|
||||||
state = ensureWorkbenchTab(state, "topic-c");
|
|
||||||
state = attachWorkbenchPane(state, "topic-b", "topic-c");
|
|
||||||
expect(state.tabs["topic-c"]).toBeUndefined();
|
|
||||||
expect(workbenchTab(state, "topic-b").paneKeys).toEqual([
|
|
||||||
"topic-b",
|
|
||||||
"pane-a",
|
|
||||||
"topic-c",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not collapse a multi-pane tab into another tab", () => {
|
|
||||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a");
|
|
||||||
state = ensureWorkbenchTab(state, "topic-b");
|
|
||||||
|
|
||||||
expect(attachWorkbenchPane(state, "topic-b", "topic-a")).toBe(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caps every tab at four panes", () => {
|
|
||||||
let state = EMPTY_WORKBENCH_STATE;
|
|
||||||
for (let index = 1; index <= MAX_WORKBENCH_PANES; index += 1) {
|
|
||||||
state = addWorkbenchPane(state, "topic-a", `pane-${index}`);
|
|
||||||
}
|
|
||||||
expect(workbenchTab(state, "topic-a").paneKeys).toEqual([
|
|
||||||
"topic-a",
|
|
||||||
"pane-1",
|
|
||||||
"pane-2",
|
|
||||||
"pane-3",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const beforeAttach = state;
|
|
||||||
state = attachWorkbenchPane(state, "topic-a", "standalone");
|
|
||||||
expect(state).toBe(beforeAttach);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("identifies only sessions attached beneath another topic", () => {
|
|
||||||
let state = addWorkbenchPane(EMPTY_WORKBENCH_STATE, "topic-a", "pane-a");
|
|
||||||
state = addWorkbenchPane(state, "topic-b", "pane-b");
|
|
||||||
|
|
||||||
expect(workbenchChildPaneKeys(state)).toEqual(new Set(["pane-a", "pane-b"]));
|
|
||||||
|
|
||||||
state = detachWorkbenchPane(state, "topic-a", "pane-a");
|
|
||||||
expect(workbenchChildPaneKeys(state)).toEqual(new Set(["pane-b"]));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("repairs persisted state and removes deleted sessions", () => {
|
|
||||||
const parsed = parseWorkbenchState(JSON.stringify({
|
|
||||||
version: 2,
|
|
||||||
tabs: {
|
|
||||||
"topic-a": {
|
|
||||||
paneKeys: ["topic-a", "topic-b", "topic-b", 9],
|
|
||||||
activePaneKey: "missing",
|
|
||||||
layout: "unknown",
|
|
||||||
},
|
|
||||||
deleted: {
|
|
||||||
paneKeys: ["deleted"],
|
|
||||||
activePaneKey: "deleted",
|
|
||||||
layout: "grid",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
const reconciled = reconcileWorkbench(parsed, new Set(["topic-a"]));
|
|
||||||
|
|
||||||
expect(reconciled).toEqual({
|
|
||||||
version: 2,
|
|
||||||
tabs: {
|
|
||||||
"topic-a": {
|
|
||||||
paneKeys: ["topic-a"],
|
|
||||||
activePaneKey: "topic-a",
|
|
||||||
layout: "columns",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(parseWorkbenchState(JSON.stringify({ version: 1, tabs: {} })))
|
|
||||||
.toEqual(EMPTY_WORKBENCH_STATE);
|
|
||||||
expect(parseWorkbenchState("not-json")).toEqual(EMPTY_WORKBENCH_STATE);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user