mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f770e4f53c | ||
|
|
8b134a13d2 | ||
|
|
cdb75f8e7d | ||
|
|
971b977a84 | ||
|
|
54650332fb | ||
|
|
172fe4f991 | ||
|
|
dda9b61b1e |
@@ -49,6 +49,13 @@ Use `/model` to inspect the current runtime model:
|
||||
|
||||
The response shows the current session's model and preset, plus the available preset names. Named presets come from the top-level `modelPresets` config and are the recommended way to configure model choices. `default` is always available and represents the model settings from direct `agents.defaults.*` fields.
|
||||
|
||||
`/model <preset>` expects one of those preset names, not a provider model ID or
|
||||
the preset's display label. For example, if `modelPresets.local` uses the Ollama
|
||||
model `llama3.2`, run `/model local`, not `/model llama3.2`. If a model is currently
|
||||
configured only as an inline fallback, save it as a named preset before selecting
|
||||
it manually. Fallback order controls automatic failover; it is not a list of raw
|
||||
model IDs accepted by `/model`.
|
||||
|
||||
To switch presets for future turns:
|
||||
|
||||
```text
|
||||
|
||||
@@ -356,8 +356,7 @@ Providers that use the Responses API can keep reasoning context across a
|
||||
conversation, which helps with multi-step tasks. Supported providers can also
|
||||
compact long conversations automatically.
|
||||
|
||||
nanobot preserves Responses conversation state automatically for OpenAI
|
||||
Responses, OpenAI Codex, Azure OpenAI, and compatible GitHub Copilot models.
|
||||
nanobot preserves Responses conversation state automatically for OpenAI Responses, OpenAI Codex, Azure OpenAI, DeepSeek V4 Flash, and compatible GitHub Copilot models.
|
||||
Native compaction is also automatic when the provider supports it. The
|
||||
threshold is derived from the active model's context window and reserved output
|
||||
headroom; no provider configuration is required.
|
||||
|
||||
@@ -231,6 +231,8 @@ Arbitrary custom provider names are OpenAI-compatible only; they do not use the
|
||||
|
||||
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it.
|
||||
|
||||
DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` automatically uses DeepSeek's native Responses API, while `deepseek-v4-pro` remains on Chat Completions.
|
||||
|
||||
### Custom OpenAI-Compatible Endpoint
|
||||
|
||||
The `custom` provider fits one OpenAI-compatible endpoint that is not represented by a named provider.
|
||||
|
||||
@@ -147,6 +147,19 @@ transcription is configured, slash commands, and `@` mentions for installed Apps
|
||||
or MCP presets. The model badge shows the current model or preset and links back
|
||||
to model settings when setup is incomplete.
|
||||
|
||||
When two or more named model presets are configured, the badge shows a dropdown
|
||||
indicator and acts as a preset selector. Click or tap it, then choose the preset
|
||||
you want from the menu. For keyboard access, focus the badge and press
|
||||
<kbd>Enter</kbd> or <kbd>Space</kbd> to open the menu, use the arrow keys to move,
|
||||
and press <kbd>Enter</kbd> to select.
|
||||
|
||||
The selection applies to future turns in the current session and persists with
|
||||
that session; it does not change the default for other sessions. Only named
|
||||
presets from **Settings → Models** are selectable. An inline fallback model that
|
||||
has not been saved as a named preset is not a separate manual choice. Save it as
|
||||
a named preset to make it selectable. The same switch is available in chat with
|
||||
`/model <preset>`; see [Chat Commands: Model Presets](./chat-commands.md#model-presets).
|
||||
|
||||
For image generation, configure an image provider first and then use the WebUI
|
||||
image mode from the composer. See [`image-generation.md`](./image-generation.md)
|
||||
for provider setup and output behavior.
|
||||
|
||||
@@ -493,12 +493,11 @@ class SlackChannel(BaseChannel):
|
||||
except Exception as e:
|
||||
self.logger.debug("reactions_add failed: {}", e)
|
||||
|
||||
# Thread-scoped session key whenever the user is in a real thread
|
||||
# (raw_thread_ts is set). DM threads get their own session, separate
|
||||
# from the DM root, so context doesn't bleed across thread boundaries.
|
||||
session_key = (
|
||||
f"slack:{chat_id}:{thread_ts}" if thread_ts and raw_thread_ts else None
|
||||
)
|
||||
# Thread-scoped session key whenever the turn lives in a thread: either the
|
||||
# message arrived inside one (raw_thread_ts) or reply_in_thread opens a new
|
||||
# thread for this channel message. DM roots have no thread_ts and keep the
|
||||
# default per-chat session, so context doesn't bleed across thread boundaries.
|
||||
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts else None
|
||||
media_paths: list[str] = []
|
||||
file_markers: list[str] = []
|
||||
for file_info in _as_json_list(event.get("files")) or []:
|
||||
|
||||
@@ -555,6 +555,113 @@ async def test_dm_thread_message_keeps_thread_ts_and_threaded_session() -> None:
|
||||
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
|
||||
|
||||
|
||||
def _channel_mention_request(envelope_id: str, ts: str) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
type="events_api",
|
||||
envelope_id=envelope_id,
|
||||
payload={
|
||||
"event": {
|
||||
"type": "app_mention",
|
||||
"user": "U1",
|
||||
"channel": "C123",
|
||||
"text": "<@UBOT> hello",
|
||||
"ts": ts,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_root_message_uses_thread_scoped_session() -> None:
|
||||
"""A channel mention that opens a thread belongs to that thread's session."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._web_client = _FakeAsyncWebClient()
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
|
||||
req = _channel_mention_request("env-c1", "1700000000.000100")
|
||||
|
||||
await channel._on_socket_request(client, req)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
kwargs = channel._handle_message.await_args.kwargs
|
||||
assert kwargs["session_key"] == "slack:C123:1700000000.000100"
|
||||
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_root_messages_do_not_share_one_session() -> None:
|
||||
"""Two threads opened in the same channel must not collapse into one session."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._web_client = _FakeAsyncWebClient()
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
|
||||
first = _channel_mention_request("env-c1", "1700000000.000100")
|
||||
second = _channel_mention_request("env-c2", "1700000000.000200")
|
||||
|
||||
await channel._on_socket_request(client, first)
|
||||
await channel._on_socket_request(client, second)
|
||||
|
||||
session_keys = [call.kwargs["session_key"] for call in channel._handle_message.await_args_list]
|
||||
assert session_keys == [
|
||||
"slack:C123:1700000000.000100",
|
||||
"slack:C123:1700000000.000200",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_root_message_without_reply_in_thread_uses_channel_session() -> None:
|
||||
"""With reply_in_thread disabled no thread is opened, so the channel session is used."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True, reply_in_thread=False), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._web_client = _FakeAsyncWebClient()
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
|
||||
req = _channel_mention_request("env-c3", "1700000000.000300")
|
||||
|
||||
await channel._on_socket_request(client, req)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
kwargs = channel._handle_message.await_args.kwargs
|
||||
assert kwargs["session_key"] is None
|
||||
assert kwargs["metadata"]["slack"]["thread_ts"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_thread_reply_keeps_thread_session() -> None:
|
||||
"""A reply inside a channel thread stays in the session opened by the root message."""
|
||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
||||
channel._bot_user_id = "UBOT"
|
||||
channel._web_client = _FakeAsyncWebClient()
|
||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
||||
channel._with_thread_context = AsyncMock(return_value="hello") # type: ignore[method-assign]
|
||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
||||
req = SimpleNamespace(
|
||||
type="events_api",
|
||||
envelope_id="env-c4",
|
||||
payload={
|
||||
"event": {
|
||||
"type": "app_mention",
|
||||
"user": "U1",
|
||||
"channel": "C123",
|
||||
"text": "<@UBOT> follow up",
|
||||
"ts": "1700000000.000400",
|
||||
"thread_ts": "1700000000.000100",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await channel._on_socket_request(client, req)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
kwargs = channel._handle_message.await_args.kwargs
|
||||
assert kwargs["session_key"] == "slack:C123:1700000000.000100"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slack_slash_command_skips_thread_context() -> None:
|
||||
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
|
||||
|
||||
@@ -230,9 +230,30 @@ class WeixinChannel(BaseChannel):
|
||||
self.logger.error("Failed to load Weixin account state", exc_info=True)
|
||||
return False
|
||||
|
||||
def _save_state(self) -> None:
|
||||
def _save_state(self, *, force: bool = False) -> None:
|
||||
state_file = self._get_state_dir() / "account.json"
|
||||
with suppress(Exception):
|
||||
if not force and state_file.exists():
|
||||
persisted: object = None
|
||||
try:
|
||||
persisted = json.loads(state_file.read_text())
|
||||
except Exception:
|
||||
persisted = None
|
||||
persisted_token = ""
|
||||
if isinstance(persisted, dict):
|
||||
persisted_mapping = cast(dict[str, object], persisted)
|
||||
persisted_token = str(persisted_mapping.get("token", "") or "")
|
||||
configured_token_is_authoritative: bool = bool(self.config.token) and (
|
||||
self._token == self.config.token
|
||||
)
|
||||
if (
|
||||
persisted_token
|
||||
and persisted_token != self._token
|
||||
and not configured_token_is_authoritative
|
||||
):
|
||||
# A concurrent QR login may have committed a newer token.
|
||||
# Never let an older runtime snapshot overwrite it.
|
||||
return
|
||||
data = {
|
||||
"token": self._token,
|
||||
"get_updates_buf": self._get_updates_buf,
|
||||
@@ -489,7 +510,7 @@ class WeixinChannel(BaseChannel):
|
||||
self._token = token
|
||||
if base_url:
|
||||
self.config.base_url = base_url
|
||||
self._save_state()
|
||||
self._save_state(force=True)
|
||||
|
||||
async def connect_close_client(self) -> None:
|
||||
self._running = False
|
||||
@@ -613,6 +634,8 @@ class WeixinChannel(BaseChannel):
|
||||
remaining = self._session_pause_remaining_s()
|
||||
if remaining > 0:
|
||||
await asyncio.sleep(remaining)
|
||||
if not self.config.token:
|
||||
self._load_state()
|
||||
return
|
||||
|
||||
body: dict[str, Any] = {
|
||||
|
||||
@@ -98,6 +98,80 @@ def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
|
||||
assert restored._context_tokens == {"wx-user": "ctx-1"}
|
||||
|
||||
|
||||
def test_save_state_preserves_token_committed_by_another_instance(tmp_path) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._token = "old-token"
|
||||
channel._save_state()
|
||||
|
||||
replacement = {
|
||||
"token": "new-token",
|
||||
"base_url": "https://new.example",
|
||||
"get_updates_buf": "",
|
||||
"context_tokens": {},
|
||||
"typing_tickets": {},
|
||||
}
|
||||
(tmp_path / "account.json").write_text(json.dumps(replacement), encoding="utf-8")
|
||||
|
||||
channel._get_updates_buf = "stale-cursor"
|
||||
channel._save_state()
|
||||
|
||||
assert json.loads((tmp_path / "account.json").read_text()) == replacement
|
||||
|
||||
|
||||
def test_save_state_force_overwrites_replaced_token(tmp_path) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||
MessageBus(),
|
||||
)
|
||||
(tmp_path / "account.json").write_text(json.dumps({"token": "old-token"}), encoding="utf-8")
|
||||
|
||||
channel.connect_commit_account(token="new-token", base_url="https://new.example")
|
||||
|
||||
saved = json.loads((tmp_path / "account.json").read_text())
|
||||
assert saved["token"] == "new-token"
|
||||
assert saved["base_url"] == "https://new.example"
|
||||
|
||||
|
||||
def test_save_state_persists_explicit_config_token_over_stale_state(tmp_path) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(
|
||||
enabled=True,
|
||||
allow_from=["*"],
|
||||
token="configured-token",
|
||||
state_dir=str(tmp_path),
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._token = "configured-token"
|
||||
channel._get_updates_buf = "current-cursor"
|
||||
(tmp_path / "account.json").write_text(
|
||||
json.dumps({"token": "stale-token", "get_updates_buf": "stale-cursor"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
channel._save_state()
|
||||
|
||||
saved = json.loads((tmp_path / "account.json").read_text())
|
||||
assert saved["token"] == "configured-token"
|
||||
assert saved["get_updates_buf"] == "current-cursor"
|
||||
|
||||
|
||||
def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_path) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||
MessageBus(),
|
||||
)
|
||||
persisted = {"token": "persisted-token", "get_updates_buf": "persisted-cursor"}
|
||||
(tmp_path / "account.json").write_text(json.dumps(persisted), encoding="utf-8")
|
||||
|
||||
channel._save_state()
|
||||
|
||||
assert json.loads((tmp_path / "account.json").read_text()) == persisted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_deduplicates_inbound_ids() -> None:
|
||||
channel, bus = _make_channel()
|
||||
@@ -462,6 +536,56 @@ async def test_poll_once_pauses_session_on_expired_errcode() -> None:
|
||||
assert channel._session_pause_remaining_s() > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_reloads_refreshed_state_after_session_pause(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._token = "old-token"
|
||||
channel._save_state()
|
||||
(tmp_path / "account.json").write_text(
|
||||
json.dumps({"token": "new-token", "base_url": "https://new.example"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
channel._session_pause_until = time.time() + 10
|
||||
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
|
||||
|
||||
await channel._poll_once()
|
||||
|
||||
assert channel._token == "new-token"
|
||||
assert channel.config.base_url == "https://new.example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_once_keeps_explicit_token_after_session_pause(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
channel = WeixinChannel(
|
||||
WeixinConfig(
|
||||
enabled=True,
|
||||
allow_from=["*"],
|
||||
token="configured-token",
|
||||
state_dir=str(tmp_path),
|
||||
),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._token = "configured-token"
|
||||
(tmp_path / "account.json").write_text(
|
||||
json.dumps({"token": "stale-token", "base_url": "https://stale.example"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
channel._session_pause_until = time.time() + 10
|
||||
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
|
||||
|
||||
await channel._poll_once()
|
||||
|
||||
assert channel._token == "configured-token"
|
||||
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
|
||||
no_qr_poll_delay,
|
||||
|
||||
@@ -958,22 +958,34 @@ class OpenAICompatProvider(LLMProvider):
|
||||
model: str | None,
|
||||
reasoning_effort: str | None,
|
||||
) -> bool:
|
||||
"""Use Responses API only for direct OpenAI requests that benefit from it."""
|
||||
"""Choose Responses for providers/models that explicitly support it."""
|
||||
if self._api_type == "chat_completions":
|
||||
return False
|
||||
if self._spec and self._spec.name not in ("openai", "github_copilot"):
|
||||
spec_name = self._spec.name if self._spec is not None else None
|
||||
model_name = self._request_model_name(model or self.default_model).lower()
|
||||
supported_models = {
|
||||
supported.lower()
|
||||
for supported in getattr(self._spec, "responses_models", ())
|
||||
}
|
||||
model_responses = any(
|
||||
model_name == supported or model_name.endswith(f"/{supported}")
|
||||
for supported in supported_models
|
||||
)
|
||||
provider_responses = spec_name in ("openai", "github_copilot")
|
||||
if not provider_responses and not model_responses:
|
||||
return False
|
||||
if self._api_type == "responses":
|
||||
# Explicit configuration means Responses is mandatory; do not
|
||||
# consult the circuit breaker or fall back to Chat Completions.
|
||||
return True
|
||||
if self._spec is None or self._spec.name != "github_copilot":
|
||||
if provider_responses and (self._spec is None or self._spec.name != "github_copilot"):
|
||||
if not _is_direct_openai_base(self._effective_base):
|
||||
return False
|
||||
|
||||
model_name = (model or self.default_model).lower()
|
||||
wants = False
|
||||
if reasoning_effort and reasoning_effort.lower() != "none":
|
||||
if model_responses:
|
||||
wants = True
|
||||
elif reasoning_effort and reasoning_effort.lower() != "none":
|
||||
wants = True
|
||||
elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
|
||||
wants = True
|
||||
@@ -1099,11 +1111,13 @@ class OpenAICompatProvider(LLMProvider):
|
||||
self._sanitize_empty_content(sanitized_state.pending_messages)
|
||||
)
|
||||
)
|
||||
preserve_reasoning = bool(self._spec and self._spec.name == "deepseek")
|
||||
instructions, input_items, replayed = prepare_responses_input(
|
||||
sanitized_messages,
|
||||
state=sanitized_state,
|
||||
provider=self._responses_state_provider(),
|
||||
model=model_name,
|
||||
preserve_reasoning=preserve_reasoning,
|
||||
)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
@@ -1131,7 +1145,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if self._supports_temperature(model_name, reasoning_effort):
|
||||
body["temperature"] = temperature
|
||||
|
||||
if not self._supports_temperature(model_name, reasoning_effort):
|
||||
if not self._supports_temperature(model_name, reasoning_effort) and not preserve_reasoning:
|
||||
body["include"] = ["reasoning.encrypted_content"]
|
||||
if reasoning_effort and reasoning_effort.lower() != "none":
|
||||
body["reasoning"] = {"effort": reasoning_effort}
|
||||
@@ -1827,6 +1841,7 @@ class OpenAICompatProvider(LLMProvider):
|
||||
_timed_stream(),
|
||||
on_content_delta,
|
||||
on_tool_call_delta=on_tool_call_delta,
|
||||
on_reasoning_delta=on_thinking_delta,
|
||||
capture=capture,
|
||||
)
|
||||
self._record_responses_success(model, reasoning_effort)
|
||||
|
||||
@@ -12,7 +12,11 @@ def _as_json_object(value: object) -> dict[str, Any] | None:
|
||||
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||
def convert_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
preserve_reasoning: bool = False,
|
||||
) -> tuple[str, list[dict[str, Any]]]:
|
||||
"""Convert Chat Completions messages to Responses API input items.
|
||||
|
||||
Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted
|
||||
@@ -36,6 +40,13 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
if preserve_reasoning:
|
||||
reasoning = msg.get("reasoning_content")
|
||||
if isinstance(reasoning, str) and reasoning:
|
||||
input_items.append({
|
||||
"type": "reasoning",
|
||||
"content": reasoning,
|
||||
})
|
||||
if isinstance(content, str) and content:
|
||||
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
|
||||
input_items.append({
|
||||
|
||||
@@ -69,7 +69,9 @@ def _response_object(value: object) -> dict[str, Any] | None:
|
||||
return object_value
|
||||
dump = getattr(value, "model_dump", None)
|
||||
if callable(dump):
|
||||
return _as_json_object(dump())
|
||||
dumped = _as_json_object(dump())
|
||||
if dumped is not None:
|
||||
return dumped
|
||||
try:
|
||||
return _as_json_object(vars(value))
|
||||
except TypeError:
|
||||
@@ -444,6 +446,14 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
|
||||
for item in _response_object_list(output):
|
||||
if item.get("type") != "reasoning":
|
||||
continue
|
||||
content = item.get("content")
|
||||
if isinstance(content, str) and content:
|
||||
parts.append(content)
|
||||
elif isinstance(content, list):
|
||||
for block in _response_object_list(cast(list[object], content)):
|
||||
text = block.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
parts.append(text)
|
||||
for summary in _response_object_list(item.get("summary")):
|
||||
if summary.get("type") == "summary_text" and summary.get("text"):
|
||||
text = summary.get("text")
|
||||
@@ -483,11 +493,9 @@ def parse_response_output(
|
||||
if isinstance(refusal, str):
|
||||
content_parts.append(refusal)
|
||||
elif item_type == "reasoning":
|
||||
for s in _response_object_list(item.get("summary")):
|
||||
if s.get("type") == "summary_text" and s.get("text"):
|
||||
text = s.get("text")
|
||||
if isinstance(text, str):
|
||||
reasoning_content = (reasoning_content or "") + text
|
||||
text = _extract_reasoning_summary_from_output([item])
|
||||
if text:
|
||||
reasoning_content = (reasoning_content or "") + text
|
||||
elif item_type == "function_call":
|
||||
call_id = item.get("call_id") or ""
|
||||
item_id = item.get("id") or "fc_0"
|
||||
@@ -532,6 +540,7 @@ async def consume_sdk_stream(
|
||||
stream: Any,
|
||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||
capture: ResponsesStreamCapture | None = None,
|
||||
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
||||
"""Consume an SDK async stream from ``client.responses.create(stream=True)``."""
|
||||
@@ -542,6 +551,7 @@ async def consume_sdk_stream(
|
||||
finish_reason = "stop"
|
||||
usage: dict[str, int] = {}
|
||||
reasoning_content: str | None = None
|
||||
streamed_reasoning = False
|
||||
refusal_seen = False
|
||||
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
|
||||
emitted_refusal_text = ""
|
||||
@@ -572,6 +582,19 @@ async def consume_sdk_stream(
|
||||
content += delta_text
|
||||
if on_content_delta and delta_text:
|
||||
await on_content_delta(delta_text)
|
||||
elif event_type == "response.reasoning_text.delta":
|
||||
delta_text = getattr(event, "delta", "") or ""
|
||||
if delta_text:
|
||||
reasoning_content = (reasoning_content or "") + delta_text
|
||||
streamed_reasoning = True
|
||||
if on_reasoning_delta:
|
||||
await on_reasoning_delta(delta_text)
|
||||
elif event_type == "response.reasoning_text.done":
|
||||
text = getattr(event, "text", "") or ""
|
||||
if text and not streamed_reasoning and not reasoning_content:
|
||||
reasoning_content = text
|
||||
if on_reasoning_delta:
|
||||
await on_reasoning_delta(text)
|
||||
elif event_type == "response.refusal.delta":
|
||||
refusal_seen = True
|
||||
delta_text = getattr(event, "delta", None)
|
||||
@@ -689,13 +712,12 @@ async def consume_sdk_stream(
|
||||
"completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0),
|
||||
"total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0),
|
||||
}
|
||||
for out_item in cast(list[Any], getattr(resp, "output", None) or []):
|
||||
if getattr(out_item, "type", None) == "reasoning":
|
||||
for s in cast(list[Any], getattr(out_item, "summary", None) or []):
|
||||
if getattr(s, "type", None) == "summary_text":
|
||||
text = getattr(s, "text", None)
|
||||
if text:
|
||||
reasoning_content = (reasoning_content or "") + text
|
||||
if not reasoning_content:
|
||||
reasoning_content = _extract_reasoning_summary_from_output(
|
||||
getattr(resp, "output", None)
|
||||
)
|
||||
if reasoning_content and on_reasoning_delta:
|
||||
await on_reasoning_delta(reasoning_content)
|
||||
elif event_type in {"error", "response.failed"}:
|
||||
detail = getattr(event, "error", None) or getattr(event, "message", None) or event
|
||||
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
|
||||
|
||||
@@ -43,6 +43,7 @@ def prepare_responses_input(
|
||||
state: ProviderConversationState | None,
|
||||
provider: str,
|
||||
model: str,
|
||||
preserve_reasoning: bool = False,
|
||||
) -> tuple[str, list[dict[str, Any]], bool]:
|
||||
"""Build a request from exact prior items plus only newly appended messages.
|
||||
|
||||
@@ -50,7 +51,10 @@ def prepare_responses_input(
|
||||
When no compatible state exists, it is converted normally as a safe
|
||||
fallback.
|
||||
"""
|
||||
instructions, fallback_items = convert_messages(messages)
|
||||
instructions, fallback_items = convert_messages(
|
||||
messages,
|
||||
preserve_reasoning=preserve_reasoning,
|
||||
)
|
||||
if state is None or not responses_state_matches(
|
||||
state,
|
||||
provider=provider,
|
||||
@@ -62,7 +66,10 @@ def prepare_responses_input(
|
||||
if prior_items is None:
|
||||
return instructions, fallback_items, False
|
||||
|
||||
_, delta_items = convert_messages(state.pending_messages)
|
||||
_, delta_items = convert_messages(
|
||||
state.pending_messages,
|
||||
preserve_reasoning=preserve_reasoning,
|
||||
)
|
||||
logger.debug(
|
||||
"Replaying Responses state: prior_items={} pending_messages={}",
|
||||
len(prior_items),
|
||||
|
||||
@@ -111,6 +111,11 @@ class ProviderSpec:
|
||||
# Substring match against the wire model name (lowercased).
|
||||
implicit_reasoning_models: tuple[str, ...] = ()
|
||||
|
||||
# Models that expose the OpenAI Responses wire format. This is model-level
|
||||
# because providers may add Responses support incrementally (DeepSeek V4
|
||||
# Flash is supported before V4 Pro).
|
||||
responses_models: tuple[str, ...] = ()
|
||||
|
||||
# When the model returns content as a list of {"type":"thinking",...} +
|
||||
# {"type":"text",...} blocks, extract the thinking text into
|
||||
# reasoning_content. Mistral's Magistral / reasoning-enabled responses use
|
||||
@@ -461,6 +466,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
backend="openai_compat",
|
||||
default_api_base="https://api.deepseek.com",
|
||||
thinking_style="thinking_type",
|
||||
responses_models=("deepseek-v4-flash",),
|
||||
),
|
||||
# Gemini: Google's OpenAI-compatible endpoint
|
||||
ProviderSpec(
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ dependencies = [
|
||||
"filelock>=3.25.2",
|
||||
"watchfiles>=1.1.1,<2.0.0",
|
||||
"packaging>=24.0",
|
||||
"tzdata>=2025.2; sys_platform == 'win32'",
|
||||
"tzdata>=2025.2",
|
||||
"defusedxml>=0.7.1,<1.0.0",
|
||||
"pypdf>=5.0.0,<6.0.0",
|
||||
"python-docx>=1.1.0,<2.0.0",
|
||||
|
||||
@@ -2558,7 +2558,7 @@ def test_optional_dependency_metadata_for_enable():
|
||||
):
|
||||
assert not any(dep.startswith(dep_name) for dep in required)
|
||||
for dependency in (
|
||||
"tzdata>=2025.2; sys_platform == 'win32'",
|
||||
"tzdata>=2025.2",
|
||||
"defusedxml>=0.7.1,<1.0.0",
|
||||
"pypdf>=5.0.0,<6.0.0",
|
||||
"python-docx>=1.1.0,<2.0.0",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
@@ -42,6 +46,32 @@ def test_agent_timezone_rejects_unknown_iana_name() -> None:
|
||||
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}})
|
||||
|
||||
|
||||
def test_agent_timezones_use_packaged_data_without_system_database() -> None:
|
||||
script = textwrap.dedent(
|
||||
"""\
|
||||
from zoneinfo import TZPATH
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
assert not TZPATH
|
||||
for name in ("UTC", "Asia/Shanghai"):
|
||||
config = Config.model_validate({"agents": {"defaults": {"timezone": name}}})
|
||||
serialized = config.model_dump(mode="json", by_alias=True)
|
||||
restored = Config.model_validate(serialized)
|
||||
assert restored.agents.defaults.timezone == name
|
||||
"""
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
env=os.environ | {"PYTHONTZPATH": ""},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_provider_api_type_accepts_exact_values_only() -> None:
|
||||
config = Config.model_validate({
|
||||
"providers": {
|
||||
|
||||
@@ -150,6 +150,22 @@ class TestConvertMessages:
|
||||
assert items[0]["content"][0]["type"] == "output_text"
|
||||
assert items[0]["content"][0]["text"] == "I'll help"
|
||||
|
||||
def test_preserves_deepseek_reasoning_content(self):
|
||||
_, items = convert_messages([
|
||||
{"role": "assistant", "reasoning_content": "think first", "content": "answer"},
|
||||
], preserve_reasoning=True)
|
||||
|
||||
assert items == [
|
||||
{"type": "reasoning", "content": "think first"},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "answer"}],
|
||||
"status": "completed",
|
||||
"id": "msg_0",
|
||||
},
|
||||
]
|
||||
|
||||
def test_assistant_empty_content_skipped(self):
|
||||
_, items = convert_messages([{"role": "assistant", "content": ""}])
|
||||
assert len(items) == 0
|
||||
@@ -539,6 +555,22 @@ class TestParseResponseOutput:
|
||||
assert result.content == "42"
|
||||
assert result.reasoning_content == "I think therefore I am."
|
||||
|
||||
def test_deepseek_reasoning_content_extracted(self):
|
||||
resp = {
|
||||
"output": [
|
||||
{"type": "reasoning", "content": "think first"},
|
||||
{"type": "message", "content": [
|
||||
{"type": "output_text", "text": "answer"},
|
||||
]},
|
||||
],
|
||||
"status": "completed", "usage": {},
|
||||
}
|
||||
|
||||
result = parse_response_output(resp)
|
||||
|
||||
assert result.content == "answer"
|
||||
assert result.reasoning_content == "think first"
|
||||
|
||||
def test_empty_output(self):
|
||||
resp = {"output": [], "status": "completed", "usage": {}}
|
||||
result = parse_response_output(resp)
|
||||
@@ -1633,6 +1665,30 @@ class TestConsumeSdkStream:
|
||||
_, _, _, _, reasoning = await consume_sdk_stream(stream())
|
||||
assert reasoning == "thinking..."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_reasoning_text_streamed(self):
|
||||
events = [
|
||||
MagicMock(type="response.reasoning_text.delta", delta="step 1 "),
|
||||
MagicMock(type="response.reasoning_text.delta", delta="step 2"),
|
||||
MagicMock(type="response.reasoning_text.done", text="step 1 step 2"),
|
||||
]
|
||||
emitted: list[str] = []
|
||||
|
||||
async def stream():
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
async def on_reasoning_delta(delta: str) -> None:
|
||||
emitted.append(delta)
|
||||
|
||||
_, _, _, _, reasoning = await consume_sdk_stream(
|
||||
stream(),
|
||||
on_reasoning_delta=on_reasoning_delta,
|
||||
)
|
||||
|
||||
assert reasoning == "step 1 step 2"
|
||||
assert emitted == ["step 1 ", "step 2"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_event_raises(self):
|
||||
ev = MagicMock(type="error", error="rate_limit_exceeded")
|
||||
|
||||
@@ -29,6 +29,32 @@ def test_responses_api_available_by_default(provider):
|
||||
assert provider._should_use_responses_api("gpt-5", None) is True
|
||||
|
||||
|
||||
def test_deepseek_v4_flash_uses_responses_by_model(provider):
|
||||
provider._spec = type("Spec", (), {
|
||||
"name": "deepseek",
|
||||
"responses_models": ("deepseek-v4-flash",),
|
||||
"strip_model_prefix": False,
|
||||
"strip_model_prefixes": (),
|
||||
})()
|
||||
provider._effective_base = "https://api.deepseek.com"
|
||||
provider.default_model = "deepseek-v4-flash"
|
||||
|
||||
assert provider._should_use_responses_api("deepseek-v4-flash", None) is True
|
||||
assert provider._should_use_responses_api("deepseek-v4-pro", None) is False
|
||||
|
||||
|
||||
def test_deepseek_v4_flash_matches_provider_prefixed_model(provider):
|
||||
provider._spec = type("Spec", (), {
|
||||
"name": "deepseek",
|
||||
"responses_models": ("deepseek-v4-flash",),
|
||||
"strip_model_prefix": False,
|
||||
"strip_model_prefixes": (),
|
||||
})()
|
||||
provider._effective_base = "https://api.deepseek.com"
|
||||
|
||||
assert provider._should_use_responses_api("deepseek/deepseek-v4-flash", None) is True
|
||||
|
||||
|
||||
def test_direct_openai_enables_server_compaction(provider):
|
||||
provider._extra_body = {}
|
||||
|
||||
|
||||
+9
-64
@@ -37,12 +37,6 @@ import {
|
||||
import { displayTitle } from "@/lib/chat-groups";
|
||||
import { deriveTitle } from "@/lib/format";
|
||||
import { NanobotClient } from "@/lib/nanobot-client";
|
||||
import {
|
||||
isQuickChatKey,
|
||||
QUICK_CHAT_ID,
|
||||
QUICK_CHAT_KEY,
|
||||
quickChatSession,
|
||||
} from "@/lib/quick-chat";
|
||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||
import type {
|
||||
BootstrapResponse,
|
||||
@@ -231,9 +225,6 @@ function readShellRoute(): ShellRoute {
|
||||
if (path === "/skills") {
|
||||
return { view: "skills", activeKey, settingsSection: "skills" };
|
||||
}
|
||||
if (path === "/quick-chat") {
|
||||
return { view: "chat", activeKey: QUICK_CHAT_KEY, settingsSection: "overview" };
|
||||
}
|
||||
if (path.startsWith("/chat/")) {
|
||||
const encoded = path.slice("/chat/".length);
|
||||
try {
|
||||
@@ -250,7 +241,6 @@ function readShellRoute(): ShellRoute {
|
||||
|
||||
function shellRouteHash(route: ShellRoute): string {
|
||||
if (route.view === "chat") {
|
||||
if (isQuickChatKey(route.activeKey)) return "#/quick-chat";
|
||||
return route.activeKey
|
||||
? `#/chat/${encodeURIComponent(route.activeKey)}`
|
||||
: "#/new";
|
||||
@@ -957,16 +947,8 @@ function Shell({
|
||||
deleteChat,
|
||||
getSessionAutomations,
|
||||
} = useSessions();
|
||||
const regularSessions = useMemo(
|
||||
() => sessions.filter((session) => !isQuickChatKey(session.key)),
|
||||
[sessions],
|
||||
);
|
||||
const quickSession = useMemo(
|
||||
() => quickChatSession(sessions.find((session) => isQuickChatKey(session.key))),
|
||||
[sessions],
|
||||
);
|
||||
const { state: sidebarState, update: updateSidebarState } =
|
||||
useSidebarState(regularSessions, !loading);
|
||||
useSidebarState(sessions, !loading);
|
||||
const initialRouteRef = useRef<ShellRoute | null>(null);
|
||||
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
|
||||
const [activeKey, setActiveKey] = useState<string | null>(
|
||||
@@ -1132,10 +1114,8 @@ function Shell({
|
||||
|
||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||
if (!activeKey) return null;
|
||||
if (isQuickChatKey(activeKey)) return quickSession;
|
||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||
}, [sessions, activeKey, quickSession]);
|
||||
const quickChatActive = isQuickChatKey(activeKey);
|
||||
}, [sessions, activeKey]);
|
||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||
const activeChatId = activeSession?.chatId ?? null;
|
||||
@@ -1150,9 +1130,6 @@ function Shell({
|
||||
});
|
||||
}, [activeChatId]);
|
||||
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
||||
if (quickChatActive) {
|
||||
return workspaces?.default_scope ?? null;
|
||||
}
|
||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||
return workspaceOverrides[activeChatId];
|
||||
}
|
||||
@@ -1164,7 +1141,6 @@ function Shell({
|
||||
activeChatId,
|
||||
activeSession?.workspaceScope,
|
||||
draftWorkspaceScope,
|
||||
quickChatActive,
|
||||
workspaceOverrides,
|
||||
workspaces?.default_scope,
|
||||
]);
|
||||
@@ -1185,10 +1161,7 @@ function Shell({
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const knownChatIds = new Set([
|
||||
QUICK_CHAT_ID,
|
||||
...sessions.map((session) => session.chatId),
|
||||
]);
|
||||
const knownChatIds = new Set(sessions.map((session) => session.chatId));
|
||||
setUpdatedChatIds((current) => {
|
||||
const next = new Set(
|
||||
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
|
||||
@@ -1203,7 +1176,6 @@ function Shell({
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !activeKey) return;
|
||||
if (isQuickChatKey(activeKey)) return;
|
||||
if (sessions.some((session) => session.key === activeKey)) return;
|
||||
const currentRoute = readShellRoute();
|
||||
navigate(
|
||||
@@ -1445,18 +1417,6 @@ function Shell({
|
||||
setMobileSidebarOpen(false);
|
||||
}, [navigate]);
|
||||
|
||||
const onOpenQuickChat = useCallback(() => {
|
||||
setDraftWorkspaceScope(null);
|
||||
setWorkspaceError(null);
|
||||
setSessionSearchOpen(false);
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: QUICK_CHAT_KEY,
|
||||
settingsSection: "overview",
|
||||
});
|
||||
setMobileSidebarOpen(false);
|
||||
}, [navigate]);
|
||||
|
||||
const onNewChatInProject = useCallback(
|
||||
(projectPath: string, projectName: string) => {
|
||||
const base = workspaces?.default_scope ?? activeWorkspaceScope;
|
||||
@@ -1722,7 +1682,6 @@ function Shell({
|
||||
setMobileSidebarOpen(false);
|
||||
const nextKey = (() => {
|
||||
if (!activeKey) return null;
|
||||
if (isQuickChatKey(activeKey)) return activeKey;
|
||||
if (sessions.some((session) => session.key === activeKey)) return activeKey;
|
||||
return sessions[0]?.key ?? null;
|
||||
})();
|
||||
@@ -1814,10 +1773,7 @@ function Shell({
|
||||
});
|
||||
}, [client, t]);
|
||||
|
||||
const onTurnEnd = useDeferredTitleRefresh(
|
||||
quickChatActive ? null : activeSession,
|
||||
refresh,
|
||||
);
|
||||
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
if (!pendingDelete) return;
|
||||
@@ -1907,9 +1863,7 @@ function Shell({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const headerTitle = quickChatActive
|
||||
? t("sidebar.quickChat")
|
||||
: activeSession
|
||||
const headerTitle = activeSession
|
||||
? sidebarState.title_overrides[activeSession.key] ||
|
||||
activeSession.title ||
|
||||
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||
@@ -1946,11 +1900,9 @@ function Shell({
|
||||
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
||||
|
||||
const sidebarProps = {
|
||||
sessions: regularSessions,
|
||||
sessions,
|
||||
activeKey,
|
||||
loading,
|
||||
quickChatActive,
|
||||
onOpenQuickChat,
|
||||
onNewChat,
|
||||
onSelect: onSelectChat,
|
||||
onRequestDelete,
|
||||
@@ -2113,7 +2065,7 @@ function Shell({
|
||||
<SessionSearchDialog
|
||||
open
|
||||
onOpenChange={setSessionSearchOpen}
|
||||
sessions={regularSessions}
|
||||
sessions={sessions}
|
||||
activeKey={activeKey}
|
||||
loading={loading}
|
||||
titleOverrides={sidebarState.title_overrides}
|
||||
@@ -2138,7 +2090,7 @@ function Shell({
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={onCreateChat}
|
||||
onForkChat={quickChatActive ? undefined : onForkChat}
|
||||
onForkChat={onForkChat}
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
@@ -2147,20 +2099,13 @@ function Shell({
|
||||
hideHeader={false}
|
||||
workspaceScope={activeWorkspaceScope}
|
||||
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
||||
workspaceControls={
|
||||
quickChatActive ? null : (workspaces?.controls ?? null)
|
||||
}
|
||||
workspaceControls={workspaces?.controls ?? null}
|
||||
workspaceScopeDisabled={activeChatRunning}
|
||||
workspaceError={workspaceError}
|
||||
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||
settingsSnapshot={settingsSnapshot}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
skills={skills}
|
||||
allowConversationReset={!quickChatActive}
|
||||
showSessionInfo={!quickChatActive}
|
||||
emptyStateGreeting={
|
||||
quickChatActive ? t("quickChat.greeting") : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{view !== "chat" && (
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Archive,
|
||||
Brain,
|
||||
CalendarClock,
|
||||
MessageCircle,
|
||||
Menu,
|
||||
Search,
|
||||
Settings,
|
||||
@@ -25,8 +24,6 @@ interface SidebarProps {
|
||||
sessions: ChatSummary[];
|
||||
activeKey: string | null;
|
||||
loading: boolean;
|
||||
quickChatActive: boolean;
|
||||
onOpenQuickChat: () => void;
|
||||
onNewChat: () => void;
|
||||
onSelect: (key: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
@@ -142,13 +139,6 @@ export function Sidebar(props: SidebarProps) {
|
||||
collapsed && "flex w-14 flex-col items-center px-0",
|
||||
)}
|
||||
>
|
||||
<SidebarActionButton
|
||||
collapsed={collapsed}
|
||||
label={t("sidebar.quickChat")}
|
||||
onClick={props.onOpenQuickChat}
|
||||
active={props.quickChatActive}
|
||||
icon={<MessageCircle className="h-4 w-4" />}
|
||||
/>
|
||||
<SidebarActionButton
|
||||
collapsed={collapsed}
|
||||
label={t("sidebar.newChat")}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
} from "react";
|
||||
import { CircleHelp, Sparkles } from "lucide-react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, CircleHelp, Sparkles } from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -33,54 +33,6 @@ interface ModelPresetBadgeProps {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface PresetGesture {
|
||||
active: boolean;
|
||||
baseIndex: number;
|
||||
latestY: number;
|
||||
pointerId: number;
|
||||
startY: number;
|
||||
step: number;
|
||||
target: HTMLElement;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
interface PresetMotion {
|
||||
index: number;
|
||||
remainder: number;
|
||||
settling: boolean;
|
||||
}
|
||||
|
||||
const LONG_PRESS_MS = 400;
|
||||
const PRESS_SLOP_PX = 8;
|
||||
const PILL_GAP_PX = 4;
|
||||
const PILL_OFFSETS = [-2, -1, 0, 1, 2] as const;
|
||||
const HANDOFF_THRESHOLD = 0.56;
|
||||
const DOCK_MAX_SCALE = 1.08;
|
||||
const DOCK_RADIUS = 1.5;
|
||||
const SETTLE_MS = 180;
|
||||
|
||||
function wrapIndex(index: number, length: number): number {
|
||||
return ((index % length) + length) % length;
|
||||
}
|
||||
|
||||
function dockScale(distanceFromFocus: number): number {
|
||||
const distance = Math.abs(distanceFromFocus);
|
||||
if (distance >= DOCK_RADIUS) return 1;
|
||||
const influence = (1 + Math.cos(Math.PI * distance / DOCK_RADIUS)) / 2;
|
||||
return 1 + (DOCK_MAX_SCALE - 1) * influence;
|
||||
}
|
||||
|
||||
function stepWithHysteresis(raw: number, current: number): number {
|
||||
let next = current;
|
||||
while (raw > next + HANDOFF_THRESHOLD) next += 1;
|
||||
while (raw < next - HANDOFF_THRESHOLD) next -= 1;
|
||||
return next;
|
||||
}
|
||||
|
||||
function preventTouchScroll(event: TouchEvent) {
|
||||
if (event.cancelable) event.preventDefault();
|
||||
}
|
||||
|
||||
export function ModelPresetBadge({
|
||||
label,
|
||||
modelDetail,
|
||||
@@ -110,204 +62,94 @@ export function ModelPresetBadge({
|
||||
: modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset);
|
||||
const interactive = Boolean(onClick);
|
||||
const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1;
|
||||
const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName));
|
||||
const pillHeight = isHero ? 32 : 36;
|
||||
const pillStride = pillHeight + PILL_GAP_PX;
|
||||
const [motion, setMotion] = useState<PresetMotion | null>(null);
|
||||
const gestureRef = useRef<PresetGesture | null>(null);
|
||||
const badgeClassName = cn(
|
||||
"thread-composer-model-badge group/model-badge relative inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] justify-end appearance-none border-0 bg-transparent p-0 shadow-none",
|
||||
(interactive || canSwitch) && "cursor-pointer focus-visible:outline-none",
|
||||
isHero ? "h-8" : "h-9",
|
||||
);
|
||||
const badgeContent = (
|
||||
<PresetPill
|
||||
label={label}
|
||||
modelDetail={modelDetail}
|
||||
provider={provider}
|
||||
providerLabel={providerLabel}
|
||||
needsSetup={needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
isHero={isHero}
|
||||
showPicker={canSwitch}
|
||||
/>
|
||||
);
|
||||
|
||||
function clearGesture() {
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture?.timer) clearTimeout(gesture.timer);
|
||||
if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll);
|
||||
gestureRef.current = null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSwitch) {
|
||||
clearGesture();
|
||||
setMotion(null);
|
||||
}
|
||||
return clearGesture;
|
||||
}, [canSwitch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!motion?.settling) return;
|
||||
const timer = setTimeout(() => setMotion(null), SETTLE_MS + 80);
|
||||
return () => clearTimeout(timer);
|
||||
}, [motion?.settling]);
|
||||
|
||||
function updateMotion(gesture: PresetGesture, clientY: number) {
|
||||
const raw = -(clientY - gesture.startY) / pillStride;
|
||||
gesture.step = stepWithHysteresis(raw, gesture.step);
|
||||
setMotion({ index: gesture.baseIndex + gesture.step, remainder: raw - gesture.step, settling: false });
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLElement>) {
|
||||
if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) return;
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
const gesture: PresetGesture = {
|
||||
active: false,
|
||||
baseIndex: currentIndex,
|
||||
latestY: event.clientY,
|
||||
pointerId: event.pointerId,
|
||||
startY: event.clientY,
|
||||
step: 0,
|
||||
target: event.currentTarget,
|
||||
timer: null,
|
||||
};
|
||||
gesture.timer = setTimeout(() => {
|
||||
if (gestureRef.current !== gesture) return;
|
||||
gesture.active = true;
|
||||
updateMotion(gesture, gesture.latestY);
|
||||
gesture.target.addEventListener("touchmove", preventTouchScroll, { passive: false });
|
||||
try {
|
||||
gesture.target.setPointerCapture(gesture.pointerId);
|
||||
} catch { /* The pointer may already have ended. */ }
|
||||
}, LONG_PRESS_MS);
|
||||
gestureRef.current = gesture;
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent<HTMLElement>) {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
gesture.latestY = event.clientY;
|
||||
if (!gesture.active) {
|
||||
if (Math.abs(event.clientY - gesture.startY) > PRESS_SLOP_PX) clearGesture();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
updateMotion(gesture, event.clientY);
|
||||
}
|
||||
|
||||
function finishGesture(event: PointerEvent<HTMLElement>, commit: boolean) {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
clearGesture();
|
||||
if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture?.(gesture.pointerId);
|
||||
}
|
||||
if (!commit || !gesture.active) {
|
||||
setMotion(null);
|
||||
return;
|
||||
}
|
||||
const selected = presets[wrapIndex(gesture.baseIndex + gesture.step, presets.length)];
|
||||
setMotion((current) => current && { ...current, remainder: 0, settling: true });
|
||||
if (selected && selected.name !== activeName) onPresetChange?.(selected.name);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
|
||||
if (!canSwitch) return;
|
||||
const targetByKey: Record<string, number> = {
|
||||
ArrowUp: currentIndex - 1,
|
||||
ArrowDown: currentIndex + 1,
|
||||
Home: 0,
|
||||
End: presets.length - 1,
|
||||
};
|
||||
const target = targetByKey[event.key];
|
||||
if (target === undefined) return;
|
||||
event.preventDefault();
|
||||
const next = presets[wrapIndex(target, presets.length)];
|
||||
if (next?.name !== activeName) onPresetChange?.(next.name);
|
||||
}
|
||||
|
||||
const previewIndex = wrapIndex(motion?.index ?? currentIndex, presets.length);
|
||||
const previewPreset = presets[previewIndex];
|
||||
const Container = interactive || canSwitch ? "button" : "span";
|
||||
const trackOffset = motion ? -pillStride * (2 + motion.remainder) : 0;
|
||||
|
||||
return (
|
||||
<Container
|
||||
data-switching={motion ? "true" : undefined}
|
||||
data-settling={motion?.settling ? "true" : undefined}
|
||||
aria-label={label}
|
||||
aria-orientation={canSwitch ? "vertical" : undefined}
|
||||
aria-valuemax={canSwitch ? presets.length - 1 : undefined}
|
||||
aria-valuemin={canSwitch ? 0 : undefined}
|
||||
aria-valuenow={canSwitch ? previewIndex : undefined}
|
||||
aria-valuetext={canSwitch ? previewPreset?.label || label : undefined}
|
||||
role={canSwitch ? "spinbutton" : undefined}
|
||||
type={interactive || canSwitch ? "button" : undefined}
|
||||
onClick={interactive ? onClick : undefined}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={(event) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture && gesture.pointerId === event.pointerId && !gesture.active) clearGesture();
|
||||
}}
|
||||
onPointerUp={(event) => finishGesture(event, true)}
|
||||
onPointerCancel={(event) => finishGesture(event, false)}
|
||||
onLostPointerCapture={(event) => finishGesture(event, false)}
|
||||
onContextMenu={(event) => {
|
||||
if (gestureRef.current?.active) event.preventDefault();
|
||||
}}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
style={{ touchAction: canSwitch ? "manipulation" : undefined }}
|
||||
className={cn(
|
||||
"thread-composer-model-badge group/model-badge relative inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] justify-end appearance-none border-0 bg-transparent p-0 shadow-none",
|
||||
interactive && "cursor-pointer",
|
||||
canSwitch && "cursor-grab select-none focus-visible:outline-none",
|
||||
motion && "z-10 cursor-grabbing",
|
||||
isHero ? "h-8" : "h-9",
|
||||
)}
|
||||
>
|
||||
<PresetPill
|
||||
className={motion && "invisible"}
|
||||
label={label}
|
||||
modelDetail={modelDetail}
|
||||
provider={provider}
|
||||
providerLabel={providerLabel}
|
||||
needsSetup={needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
isHero={isHero}
|
||||
/>
|
||||
{motion ? (
|
||||
<span
|
||||
data-testid="composer-model-pill-viewport"
|
||||
className={cn(
|
||||
"composer-model-pill-viewport pointer-events-none absolute right-0 w-max max-w-[calc(44vw+0.5rem)] overflow-hidden bg-transparent pl-2 sm:max-w-[18.5rem]",
|
||||
isHero ? "-bottom-2.5 -top-2.5" : "-bottom-3 -top-3",
|
||||
)}
|
||||
aria-hidden
|
||||
if (canSwitch) {
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" aria-label={label} className={badgeClassName}>
|
||||
{badgeContent}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
className="w-[min(20rem,calc(100vw-2rem))] rounded-[18px]"
|
||||
>
|
||||
<span
|
||||
data-testid="composer-model-pill-track"
|
||||
data-settling={motion.settling ? "true" : undefined}
|
||||
className="composer-model-pill-track ml-auto flex w-max max-w-full flex-col items-end gap-1 will-change-transform"
|
||||
onTransitionEnd={(event) => {
|
||||
if (motion.settling && event.currentTarget === event.target) setMotion(null);
|
||||
}}
|
||||
style={{
|
||||
paddingTop: isHero ? "10px" : "12px",
|
||||
transform: `translate3d(0, ${trackOffset}px, 0)`,
|
||||
<DropdownMenuRadioGroup
|
||||
value={activeName}
|
||||
onValueChange={(name) => {
|
||||
if (name !== activeName) onPresetChange?.(name);
|
||||
}}
|
||||
>
|
||||
{PILL_OFFSETS.map((offset) => {
|
||||
const virtualIndex = motion.index + offset;
|
||||
const preset = presets[wrapIndex(virtualIndex, presets.length)];
|
||||
const scale = motion.settling ? 1 : dockScale(offset - motion.remainder);
|
||||
{presets.map((preset) => {
|
||||
const detail = [...new Set([preset.model, preset.provider].filter(Boolean))]
|
||||
.join(" · ");
|
||||
return (
|
||||
<PresetPill
|
||||
key={virtualIndex}
|
||||
label={preset.label || preset.name}
|
||||
modelDetail={preset.model}
|
||||
provider={preset.provider}
|
||||
isHero={isHero}
|
||||
offset={offset}
|
||||
scale={scale}
|
||||
/>
|
||||
<DropdownMenuRadioItem
|
||||
key={preset.name}
|
||||
value={preset.name}
|
||||
className="min-h-[46px] items-start rounded-[14px] py-2.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-foreground">
|
||||
{preset.label || preset.name}
|
||||
</span>
|
||||
{detail ? (
|
||||
<span className="mt-0.5 block truncate text-[11.5px] text-muted-foreground">
|
||||
{detail}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</Container>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
if (interactive) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={badgeClassName}
|
||||
>
|
||||
{badgeContent}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span aria-label={label} className={badgeClassName}>
|
||||
{badgeContent}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetPill({
|
||||
className,
|
||||
label,
|
||||
modelDetail,
|
||||
provider,
|
||||
@@ -315,10 +157,8 @@ function PresetPill({
|
||||
needsSetup = false,
|
||||
fallbackModelName,
|
||||
isHero,
|
||||
offset,
|
||||
scale,
|
||||
showPicker = false,
|
||||
}: {
|
||||
className?: string | false | null;
|
||||
label: string;
|
||||
modelDetail?: string | null;
|
||||
provider?: string | null;
|
||||
@@ -326,8 +166,7 @@ function PresetPill({
|
||||
needsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
isHero: boolean;
|
||||
offset?: number;
|
||||
scale?: number;
|
||||
showPicker?: boolean;
|
||||
}) {
|
||||
const labelRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [labelOverflows, setLabelOverflows] = useState(false);
|
||||
@@ -337,11 +176,9 @@ function PresetPill({
|
||||
const brand = providerBrand(inferredProvider);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||
const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
|
||||
const logoTestId = offset !== undefined
|
||||
? undefined
|
||||
: needsSetup
|
||||
? "composer-model-setup-icon"
|
||||
: `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`;
|
||||
const logoTestId = needsSetup
|
||||
? "composer-model-setup-icon"
|
||||
: `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = labelRef.current;
|
||||
@@ -356,22 +193,15 @@ function PresetPill({
|
||||
return (
|
||||
<span
|
||||
data-fallback={fallbackModelName ? "true" : undefined}
|
||||
data-preset-offset={offset}
|
||||
title={fallbackModelName || title || undefined}
|
||||
className={cn(
|
||||
"composer-model-badge composer-model-pill inline-flex h-full w-fit max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70",
|
||||
offset === undefined && "shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
|
||||
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
|
||||
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible/model-badge:ring-2 group-focus-visible/model-badge:ring-ring/45",
|
||||
showPicker && "group-hover/model-badge:border-border group-hover/model-badge:text-foreground/85",
|
||||
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
|
||||
isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]",
|
||||
offset !== undefined && "composer-model-pill-dock",
|
||||
className,
|
||||
)}
|
||||
style={scale === undefined ? undefined : {
|
||||
height: `${isHero ? 32 : 36}px`,
|
||||
transform: `scale(${scale.toFixed(4)})`,
|
||||
zIndex: Math.round(scale * 100),
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-testid={logoTestId}
|
||||
@@ -422,6 +252,12 @@ function PresetPill({
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{showPicker ? (
|
||||
<ChevronDown
|
||||
className="thread-composer-model-chevron h-3.5 w-3.5 shrink-0 text-muted-foreground/75"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,9 +315,6 @@ interface ThreadShellProps {
|
||||
settingsSnapshot?: SettingsPayload | null;
|
||||
onOpenModelSettings?: () => void;
|
||||
skills?: SkillSummary[];
|
||||
allowConversationReset?: boolean;
|
||||
showSessionInfo?: boolean;
|
||||
emptyStateGreeting?: string;
|
||||
}
|
||||
|
||||
function toModelBadgeLabel(modelName: string | null): string | null {
|
||||
@@ -600,9 +597,6 @@ export function ThreadShell({
|
||||
settingsSnapshot = null,
|
||||
onOpenModelSettings,
|
||||
skills = [],
|
||||
allowConversationReset = true,
|
||||
showSessionInfo = true,
|
||||
emptyStateGreeting,
|
||||
}: ThreadShellProps) {
|
||||
const { t } = useTranslation();
|
||||
const chatId = session?.chatId ?? null;
|
||||
@@ -628,12 +622,6 @@ export function ThreadShell({
|
||||
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
||||
const [booting, setBooting] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const availableSlashCommands = useMemo(
|
||||
() => allowConversationReset
|
||||
? slashCommands
|
||||
: slashCommands.filter((command) => command.command !== "/new"),
|
||||
[allowConversationReset, slashCommands],
|
||||
);
|
||||
const cliApps = useInstalledSettingItems({
|
||||
getToken,
|
||||
eventName: CLI_APPS_CHANGED_EVENT,
|
||||
@@ -1386,7 +1374,7 @@ export function ThreadShell({
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={availableSlashCommands}
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
skills={skills}
|
||||
@@ -1428,7 +1416,7 @@ export function ThreadShell({
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant="hero"
|
||||
slashCommands={availableSlashCommands}
|
||||
slashCommands={slashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
skills={skills}
|
||||
@@ -1454,10 +1442,10 @@ export function ThreadShell({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
||||
<HeroGreeting text={emptyStateGreeting ?? t(heroGreetingKey)} />
|
||||
<HeroGreeting text={t(heroGreetingKey)} />
|
||||
</div>
|
||||
);
|
||||
const sessionInfoAction = historyKey && showSessionInfo ? (
|
||||
const sessionInfoAction = historyKey ? (
|
||||
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
|
||||
) : undefined;
|
||||
const promptNavigatorAction = historyKey ? (
|
||||
@@ -1500,7 +1488,7 @@ export function ThreadShell({
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={availableSlashCommands}
|
||||
slashCommands={slashCommands}
|
||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||
hasMoreBefore={hasMoreBefore}
|
||||
loadingOlder={loadingOlder}
|
||||
|
||||
@@ -542,7 +542,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const near = distance < NEAR_BOTTOM_PX;
|
||||
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
|
||||
const logicallyAtBottom = owner === "automatic" || near;
|
||||
const logicallyAtBottom = owner === "automatic" || (owner === "navigation" && near);
|
||||
setAtBottom((current) =>
|
||||
current === logicallyAtBottom ? current : logicallyAtBottom,
|
||||
);
|
||||
@@ -557,6 +557,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
if (!direction) return;
|
||||
threadMotionRef.current?.handleUserScrollIntent(
|
||||
canScrollInDirection(el, direction),
|
||||
direction === "forward",
|
||||
);
|
||||
};
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
@@ -572,20 +573,21 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (event.button === 0 && event.target === el) yieldCameraToUser();
|
||||
};
|
||||
let touchStartY: number | null = null;
|
||||
let lastTouchY: number | null = null;
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
touchStartY = event.touches[0]?.clientY ?? null;
|
||||
lastTouchY = event.touches[0]?.clientY ?? null;
|
||||
};
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
const currentY = event.touches[0]?.clientY;
|
||||
const scrollDeltaY =
|
||||
touchStartY !== null && currentY !== undefined
|
||||
? touchStartY - currentY
|
||||
lastTouchY !== null && currentY !== undefined
|
||||
? lastTouchY - currentY
|
||||
: 0;
|
||||
lastTouchY = currentY ?? null;
|
||||
handleDirectionalInput(directionFromDelta(scrollDeltaY));
|
||||
};
|
||||
const handleTouchEnd = () => {
|
||||
touchStartY = null;
|
||||
lastTouchY = null;
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
|
||||
@@ -168,6 +168,9 @@ export class ThreadMotionCoordinator {
|
||||
private measurementFrameId: number | null = null;
|
||||
private geometryDirty = false;
|
||||
private composerInputDuringTurn = false;
|
||||
// A user leaving the live tail must first move beyond the near-bottom
|
||||
// boundary, or explicitly reverse toward latest, before follow can resume.
|
||||
private resumeFollowArmed = false;
|
||||
|
||||
constructor(options: ThreadMotionCoordinatorOptions) {
|
||||
this.camera = options.camera;
|
||||
@@ -198,6 +201,7 @@ export class ThreadMotionCoordinator {
|
||||
if (isNewTurn) {
|
||||
this.camera.cancel();
|
||||
this.composerInputDuringTurn = false;
|
||||
this.resumeFollowArmed = false;
|
||||
this.promptPositioned = turn.entry === "restored";
|
||||
this.mode = this.promptPositioned && turn.hasOutput
|
||||
? "follow-output"
|
||||
@@ -249,15 +253,31 @@ export class ThreadMotionCoordinator {
|
||||
this.handleUserScrollIntent(true);
|
||||
}
|
||||
|
||||
handleUserScrollIntent(canScroll: boolean): void {
|
||||
handleUserScrollIntent(canScroll: boolean, towardLatest = false): void {
|
||||
if (this.mode === "browsing-history" && towardLatest && !canScroll) {
|
||||
this.transitionToAutoFollow(false);
|
||||
return;
|
||||
}
|
||||
const event = canScroll ? "user-scroll" : "boundary-scroll";
|
||||
if (!this.transition(event)) return;
|
||||
const transitioned = this.transition(event);
|
||||
if (this.mode === "browsing-history" && canScroll) {
|
||||
this.resumeFollowArmed = towardLatest;
|
||||
} else if (transitioned && this.mode === "browsing-history") {
|
||||
this.resumeFollowArmed = false;
|
||||
}
|
||||
if (!transitioned) return;
|
||||
this.camera.cancel();
|
||||
}
|
||||
|
||||
resumeAutoFollow(): void {
|
||||
this.transitionToAutoFollow(true);
|
||||
}
|
||||
|
||||
private transitionToAutoFollow(cancelCamera: boolean): void {
|
||||
if (!this.transition("resume-follow")) return;
|
||||
this.camera.cancel();
|
||||
this.resumeFollowArmed = false;
|
||||
if (cancelCamera) this.camera.cancel();
|
||||
this.onAutoFollow?.();
|
||||
this.invalidateGeometry();
|
||||
}
|
||||
|
||||
@@ -317,11 +337,19 @@ export class ThreadMotionCoordinator {
|
||||
case "navigating-history":
|
||||
if (!this.camera.isFollowing()) {
|
||||
this.transition("navigation-settled");
|
||||
if (nearBottom) this.resumeAutoFollow();
|
||||
if (nearBottom) {
|
||||
this.resumeAutoFollow();
|
||||
} else {
|
||||
this.resumeFollowArmed = true;
|
||||
}
|
||||
}
|
||||
return "navigation";
|
||||
case "browsing-history":
|
||||
if (!nearBottom) return "user";
|
||||
if (!nearBottom) {
|
||||
this.resumeFollowArmed = true;
|
||||
return "user";
|
||||
}
|
||||
if (!this.resumeFollowArmed) return "user";
|
||||
this.resumeAutoFollow();
|
||||
return "automatic";
|
||||
default:
|
||||
@@ -339,6 +367,7 @@ export class ThreadMotionCoordinator {
|
||||
this.camera.cancel();
|
||||
this.turn = { id: null, promptId: null, hasOutput: false };
|
||||
this.composerInputDuringTurn = false;
|
||||
this.resumeFollowArmed = false;
|
||||
this.mode = "idle";
|
||||
this.promptPositioned = false;
|
||||
}
|
||||
|
||||
+5
-41
@@ -738,54 +738,14 @@
|
||||
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent);
|
||||
}
|
||||
|
||||
.thread-composer-model-badge:not([data-switching="true"]):active
|
||||
> .composer-model-pill {
|
||||
.thread-composer-model-badge:active > .composer-model-pill {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@keyframes composer-model-pill-viewport-enter {
|
||||
from {
|
||||
transform: scale(0.9074);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.composer-model-pill-viewport {
|
||||
transform-origin: right center;
|
||||
animation: composer-model-pill-viewport-enter 210ms
|
||||
cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
|
||||
}
|
||||
|
||||
.composer-model-pill-dock {
|
||||
transform-origin: right center;
|
||||
transition-property: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.composer-model-pill-track[data-settling="true"],
|
||||
.composer-model-pill-track[data-settling="true"] .composer-model-pill-dock {
|
||||
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.thread-composer-model-badge:active > .composer-model-pill {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.composer-model-pill-track[data-settling="true"],
|
||||
.composer-model-pill-dock {
|
||||
transition: none;
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
.composer-model-pill-viewport {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container thread-composer (max-width: 21rem) {
|
||||
@@ -838,6 +798,10 @@
|
||||
.thread-composer-model-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.thread-composer-model-chevron {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container thread-composer (max-width: 16rem) {
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"sidebar": {
|
||||
"navigation": "Sidebar navigation",
|
||||
"collapse": "Collapse sidebar",
|
||||
"quickChat": "Quick Chat",
|
||||
"newChat": "New topic",
|
||||
"searchAria": "Search",
|
||||
"searchPlaceholder": "Search",
|
||||
@@ -61,9 +60,6 @@
|
||||
"title": "Skills"
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "What's on your mind?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Back to chat",
|
||||
"sidebar": {
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"sidebar": {
|
||||
"navigation": "Navegación de la barra lateral",
|
||||
"collapse": "Contraer barra lateral",
|
||||
"quickChat": "Chat rápido",
|
||||
"newChat": "Nuevo tema",
|
||||
"searchAria": "Buscar",
|
||||
"searchPlaceholder": "Buscar",
|
||||
@@ -61,9 +60,6 @@
|
||||
"title": "Habilidades"
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "¿Qué tienes en mente?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Volver al chat",
|
||||
"sidebar": {
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"sidebar": {
|
||||
"navigation": "Navigation de la barre latérale",
|
||||
"collapse": "Réduire la barre latérale",
|
||||
"quickChat": "Discussion rapide",
|
||||
"newChat": "Nouveau sujet",
|
||||
"searchAria": "Rechercher",
|
||||
"searchPlaceholder": "Rechercher",
|
||||
@@ -61,9 +60,6 @@
|
||||
"title": "Compétences"
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "De quoi avez-vous envie de parler ?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Retour au chat",
|
||||
"sidebar": {
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"sidebar": {
|
||||
"navigation": "Navigasi bilah samping",
|
||||
"collapse": "Ciutkan sidebar",
|
||||
"quickChat": "Obrolan cepat",
|
||||
"newChat": "Topik baru",
|
||||
"searchAria": "Cari",
|
||||
"searchPlaceholder": "Cari",
|
||||
@@ -61,9 +60,6 @@
|
||||
"title": "Skill"
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "Apa yang sedang kamu pikirkan?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Kembali ke chat",
|
||||
"sidebar": {
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"sidebar": {
|
||||
"navigation": "サイドバーのナビゲーション",
|
||||
"collapse": "サイドバーを閉じる",
|
||||
"quickChat": "クイックチャット",
|
||||
"newChat": "新しいトピック",
|
||||
"searchAria": "検索",
|
||||
"searchPlaceholder": "検索",
|
||||
@@ -61,9 +60,6 @@
|
||||
"title": "スキル"
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "何について話しますか?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "チャットに戻る",
|
||||
"sidebar": {
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"sidebar": {
|
||||
"navigation": "사이드바 탐색",
|
||||
"collapse": "사이드바 접기",
|
||||
"quickChat": "빠른 채팅",
|
||||
"newChat": "새 주제",
|
||||
"searchAria": "검색",
|
||||
"searchPlaceholder": "검색",
|
||||
@@ -61,9 +60,6 @@
|
||||
"title": "스킬"
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "무슨 이야기를 나눠볼까요?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "채팅으로 돌아가기",
|
||||
"sidebar": {
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"sidebar": {
|
||||
"navigation": "Navegação da barra lateral",
|
||||
"collapse": "Recolher barra lateral",
|
||||
"quickChat": "Chat rápido",
|
||||
"newChat": "Novo tópico",
|
||||
"searchAria": "Buscar",
|
||||
"searchPlaceholder": "Buscar",
|
||||
@@ -61,9 +60,6 @@
|
||||
"title": "Skills"
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "O que você está pensando?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Voltar para a conversa",
|
||||
"sidebar": {
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"sidebar": {
|
||||
"navigation": "Điều hướng thanh bên",
|
||||
"collapse": "Thu gọn thanh bên",
|
||||
"quickChat": "Trò chuyện nhanh",
|
||||
"newChat": "Chủ đề mới",
|
||||
"searchAria": "Tìm kiếm",
|
||||
"searchPlaceholder": "Tìm kiếm",
|
||||
@@ -61,9 +60,6 @@
|
||||
"title": "Kỹ năng"
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "Bạn đang nghĩ gì?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Quay lại chat",
|
||||
"sidebar": {
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"sidebar": {
|
||||
"navigation": "侧边栏导航",
|
||||
"collapse": "收起侧边栏",
|
||||
"quickChat": "随便聊聊",
|
||||
"newChat": "新建话题",
|
||||
"searchAria": "搜索",
|
||||
"searchPlaceholder": "搜索",
|
||||
@@ -61,9 +60,6 @@
|
||||
"title": "技能"
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "想聊点什么?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "返回聊天",
|
||||
"sidebar": {
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"sidebar": {
|
||||
"navigation": "側邊欄導覽",
|
||||
"collapse": "收合側邊欄",
|
||||
"quickChat": "輕鬆聊聊",
|
||||
"newChat": "新增話題",
|
||||
"searchAria": "搜尋",
|
||||
"searchPlaceholder": "搜尋",
|
||||
@@ -61,9 +60,6 @@
|
||||
"title": "技能"
|
||||
}
|
||||
},
|
||||
"quickChat": {
|
||||
"greeting": "想聊點什麼?"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "返回聊天",
|
||||
"sidebar": {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
export const QUICK_CHAT_ID = "quick-chat";
|
||||
export const QUICK_CHAT_KEY = `websocket:${QUICK_CHAT_ID}`;
|
||||
|
||||
export function isQuickChatKey(key: string | null): boolean {
|
||||
return key === QUICK_CHAT_KEY;
|
||||
}
|
||||
|
||||
export function quickChatSession(persisted?: ChatSummary): ChatSummary {
|
||||
return {
|
||||
key: QUICK_CHAT_KEY,
|
||||
channel: "websocket",
|
||||
chatId: QUICK_CHAT_ID,
|
||||
createdAt: persisted?.createdAt ?? null,
|
||||
updatedAt: persisted?.updatedAt ?? null,
|
||||
preview: persisted?.preview ?? "",
|
||||
modelPreset: persisted?.modelPreset ?? null,
|
||||
runStartedAt: persisted?.runStartedAt ?? null,
|
||||
workspaceScope: persisted?.workspaceScope ?? null,
|
||||
};
|
||||
}
|
||||
@@ -349,86 +349,6 @@ describe("App layout", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a single fixed Quick Chat without provisioning a new session", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const quickChatButton = within(sidebar).getByRole("button", {
|
||||
name: "Quick Chat",
|
||||
});
|
||||
|
||||
fireEvent.click(quickChatButton);
|
||||
|
||||
expect(window.location.hash).toBe("#/quick-chat");
|
||||
expect(quickChatButton).toHaveAttribute("aria-current", "page");
|
||||
await waitFor(() =>
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"/api/sessions/websocket%3Aquick-chat/webui-thread",
|
||||
),
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
expect(createChatSpy).not.toHaveBeenCalled();
|
||||
expect(document.title).toBe("Quick Chat · nanobot");
|
||||
expect(screen.getByText("What's on your mind?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores Quick Chat before it has a persisted session", async () => {
|
||||
window.history.replaceState(null, "", "/#/quick-chat");
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
expect(window.location.hash).toBe("#/quick-chat");
|
||||
await waitFor(() =>
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"/api/sessions/websocket%3Aquick-chat/webui-thread",
|
||||
),
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole("navigation", { name: "Sidebar navigation" }))
|
||||
.getByRole("button", { name: "Quick Chat" }),
|
||||
).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
it("keeps persisted Quick Chat out of the topic list and topic search", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:quick-chat",
|
||||
channel: "websocket",
|
||||
chatId: "quick-chat",
|
||||
createdAt: "2026-07-30T08:00:00Z",
|
||||
updatedAt: "2026-07-30T08:05:00Z",
|
||||
preview: "A private casual message",
|
||||
},
|
||||
{
|
||||
key: "websocket:project-chat",
|
||||
channel: "websocket",
|
||||
chatId: "project-chat",
|
||||
createdAt: "2026-07-30T08:00:00Z",
|
||||
updatedAt: "2026-07-30T08:05:00Z",
|
||||
preview: "Project roadmap",
|
||||
},
|
||||
];
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
expect(within(sidebar).getByText("Project roadmap")).toBeInTheDocument();
|
||||
expect(within(sidebar).queryByText("A private casual message")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Search" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Search" });
|
||||
expect(within(dialog).getByText("Project roadmap")).toBeInTheDocument();
|
||||
expect(within(dialog).queryByText("A private casual message")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores the Settings route after a restart fallback hash", async () => {
|
||||
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
||||
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isQuickChatKey,
|
||||
QUICK_CHAT_ID,
|
||||
QUICK_CHAT_KEY,
|
||||
quickChatSession,
|
||||
} from "@/lib/quick-chat";
|
||||
|
||||
describe("Quick Chat identity", () => {
|
||||
it("uses one stable websocket session", () => {
|
||||
expect(QUICK_CHAT_ID).toBe("quick-chat");
|
||||
expect(QUICK_CHAT_KEY).toBe("websocket:quick-chat");
|
||||
expect(isQuickChatKey(QUICK_CHAT_KEY)).toBe(true);
|
||||
expect(isQuickChatKey("websocket:another-chat")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps persisted metadata behind the fixed identity", () => {
|
||||
expect(quickChatSession({
|
||||
key: "websocket:quick-chat",
|
||||
channel: "websocket",
|
||||
chatId: "quick-chat",
|
||||
createdAt: "2026-07-30T08:00:00Z",
|
||||
updatedAt: "2026-07-30T08:05:00Z",
|
||||
preview: "hello",
|
||||
modelPreset: "fast",
|
||||
})).toMatchObject({
|
||||
key: QUICK_CHAT_KEY,
|
||||
channel: "websocket",
|
||||
chatId: QUICK_CHAT_ID,
|
||||
createdAt: "2026-07-30T08:00:00Z",
|
||||
updatedAt: "2026-07-30T08:05:00Z",
|
||||
preview: "hello",
|
||||
modelPreset: "fast",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
@@ -313,28 +314,11 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") {
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
badge: screen.getByRole("spinbutton", { name: "Kimi" }),
|
||||
badge: screen.getByRole("button", { name: "Kimi" }),
|
||||
onPresetChange,
|
||||
};
|
||||
}
|
||||
|
||||
function pointerDown(badge: HTMLElement, pointerId = 7, clientY = 100, button = 0) {
|
||||
fireEvent.pointerDown(badge, {
|
||||
button,
|
||||
clientY,
|
||||
isPrimary: true,
|
||||
pointerId,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
}
|
||||
|
||||
function longPress(badge: HTMLElement, pointerId = 7) {
|
||||
pointerDown(badge, pointerId);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
}
|
||||
|
||||
describe("ThreadComposer", () => {
|
||||
it("focuses and sends a removable quoted answer excerpt", async () => {
|
||||
const onSend = vi.fn();
|
||||
@@ -428,7 +412,7 @@ describe("ThreadComposer", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = screen.getByRole("spinbutton", { name: "gpt-5.6-sol" });
|
||||
const badge = screen.getByRole("button", { name: "gpt-5.6-sol" });
|
||||
expect(badge).toHaveClass("w-fit", "max-w-[min(18rem,44vw)]");
|
||||
expect(badge).not.toHaveClass("w-[5.75rem]");
|
||||
expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument();
|
||||
@@ -461,93 +445,32 @@ describe("ThreadComposer", () => {
|
||||
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("scrolls complete preset pills after a left-button long press and wraps", () => {
|
||||
vi.useFakeTimers();
|
||||
it("opens a preset menu on click and switches the selected preset", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { badge, onPresetChange } = renderPresetComposer();
|
||||
expect(badge).toHaveClass("h-9");
|
||||
expect(badge).toHaveStyle({ touchAction: "manipulation" });
|
||||
const idleTouchMove = new Event("touchmove", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
badge.dispatchEvent(idleTouchMove);
|
||||
expect(idleTouchMove.defaultPrevented).toBe(false);
|
||||
fireEvent.click(badge);
|
||||
pointerDown(badge);
|
||||
fireEvent.pointerMove(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" });
|
||||
act(() => vi.advanceTimersByTime(500));
|
||||
fireEvent.pointerUp(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" });
|
||||
expect(onPresetChange).not.toHaveBeenCalled();
|
||||
expect(badge).toHaveAttribute("aria-haspopup", "menu");
|
||||
expect(badge).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
longPress(badge);
|
||||
expect(badge).toHaveAttribute("data-switching", "true");
|
||||
const viewport = screen.getByTestId("composer-model-pill-viewport");
|
||||
expect(viewport).toHaveClass(
|
||||
"right-0",
|
||||
"w-max",
|
||||
"max-w-[calc(44vw+0.5rem)]",
|
||||
"overflow-hidden",
|
||||
"-top-3",
|
||||
"-bottom-3",
|
||||
);
|
||||
const track = screen.getByTestId("composer-model-pill-track");
|
||||
expect(track).toHaveClass("w-max", "max-w-full", "items-end", "gap-1");
|
||||
const activeTouchMove = new Event("touchmove", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
badge.dispatchEvent(activeTouchMove);
|
||||
expect(activeTouchMove.defaultPrevented).toBe(true);
|
||||
const pills = track.querySelectorAll<HTMLElement>(".composer-model-pill");
|
||||
expect(pills).toHaveLength(5);
|
||||
expect(Array.from(pills).every((pill) => pill.classList.contains("w-fit"))).toBe(true);
|
||||
expect(Array.from(pills).every((pill) => pill.querySelector("img"))).toBe(true);
|
||||
expect(Array.from(badge.querySelectorAll("img")).every((image) => !image.draggable)).toBe(true);
|
||||
const centeredPill = track.querySelector<HTMLElement>("[data-preset-offset='0']");
|
||||
expect(centeredPill).toHaveTextContent("Kimi");
|
||||
expect(centeredPill).toHaveStyle({ transform: "scale(1.0800)" });
|
||||
expect(
|
||||
track.querySelector<HTMLElement>("[data-preset-offset='1']"),
|
||||
).toHaveStyle({ transform: "scale(1.0200)" });
|
||||
|
||||
fireEvent.pointerMove(badge, {
|
||||
clientY: 122,
|
||||
pointerId: 7,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("Kimi");
|
||||
fireEvent.pointerMove(badge, {
|
||||
clientY: 123,
|
||||
pointerId: 7,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("DS Pro");
|
||||
fireEvent.pointerUp(badge, {
|
||||
clientY: 123,
|
||||
pointerId: 7,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
await user.click(badge);
|
||||
|
||||
expect(badge).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("menuitemradio", { name: /Kimi.*moonshot/i }))
|
||||
.toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByRole("menuitemradio", { name: /DFlash.*deepseek/i }))
|
||||
.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("menuitemradio", { name: /DS Pro.*deepseek/i }));
|
||||
expect(onPresetChange).toHaveBeenCalledWith("dspro");
|
||||
expect(badge).toHaveAttribute("data-settling", "true");
|
||||
expect(track).toHaveAttribute("data-settling", "true");
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(260);
|
||||
});
|
||||
expect(badge).not.toHaveAttribute("data-switching");
|
||||
expect(badge).not.toHaveAttribute("data-settling");
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports the same long-press switcher in hero mode and cancels pointercancel", () => {
|
||||
vi.useFakeTimers();
|
||||
it("supports the same preset menu in hero mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { badge, onPresetChange } = renderPresetComposer("hero");
|
||||
expect(badge).toHaveClass("h-8");
|
||||
longPress(badge, 9);
|
||||
expect(badge).toHaveAttribute("data-switching", "true");
|
||||
fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
|
||||
fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
|
||||
expect(badge).not.toHaveAttribute("data-switching");
|
||||
expect(onPresetChange).not.toHaveBeenCalled();
|
||||
await user.click(badge);
|
||||
await user.click(screen.getByRole("menuitemradio", { name: /DFlash.*deepseek/i }));
|
||||
expect(onPresetChange).toHaveBeenCalledWith("dflash");
|
||||
});
|
||||
|
||||
it("transcribes voice input into the composer without sending", async () => {
|
||||
|
||||
@@ -410,6 +410,9 @@ describe("ThreadMotionCoordinator", () => {
|
||||
expect(camera.jumpTo).toHaveBeenCalledWith(780);
|
||||
|
||||
coordinator.takeUserControl();
|
||||
expect(coordinator.observeScroll(true)).toBe("user");
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
|
||||
expect(coordinator.observeScroll(false)).toBe("user");
|
||||
expect(coordinator.snapshot().mode).toBe("browsing-history");
|
||||
|
||||
@@ -417,6 +420,57 @@ describe("ThreadMotionCoordinator", () => {
|
||||
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
|
||||
});
|
||||
|
||||
it("resumes shallow history browsing when user intent turns toward latest", () => {
|
||||
const {
|
||||
camera,
|
||||
coordinator,
|
||||
advanceFrame,
|
||||
} = motionHarness({
|
||||
scrollTop: 1_400,
|
||||
});
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
camera.followTo.mockClear();
|
||||
|
||||
coordinator.handleUserScrollIntent(true);
|
||||
expect(coordinator.observeScroll(true)).toBe("user");
|
||||
advanceFrame();
|
||||
expect(camera.followTo).not.toHaveBeenCalled();
|
||||
|
||||
coordinator.handleUserScrollIntent(true, true);
|
||||
expect(coordinator.observeScroll(true)).toBe("automatic");
|
||||
expect(coordinator.snapshot().mode).toBe("follow-output");
|
||||
advanceFrame();
|
||||
expect(camera.followTo).toHaveBeenCalledWith(1_400);
|
||||
});
|
||||
|
||||
it("resumes shallow history browsing from forward intent at the boundary", () => {
|
||||
const {
|
||||
advanceFrame,
|
||||
coordinator,
|
||||
onAutoFollow,
|
||||
} = motionHarness({
|
||||
scrollTop: 1_400,
|
||||
});
|
||||
coordinator.updateTurn({
|
||||
id: "turn-1",
|
||||
promptId: "prompt-1",
|
||||
hasOutput: true,
|
||||
});
|
||||
advanceFrame();
|
||||
|
||||
coordinator.handleUserScrollIntent(true);
|
||||
expect(coordinator.observeScroll(true)).toBe("user");
|
||||
|
||||
coordinator.handleUserScrollIntent(false, true);
|
||||
expect(coordinator.snapshot().mode).toBe("follow-output");
|
||||
expect(onAutoFollow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves history browsing when an active turn is cleared", () => {
|
||||
const {
|
||||
camera,
|
||||
|
||||
@@ -586,19 +586,18 @@ describe("ThreadShell", () => {
|
||||
));
|
||||
const { rerender } = render(view("default"));
|
||||
|
||||
const badge = await screen.findByRole("spinbutton", { name: "Default" });
|
||||
const badge = await screen.findByRole("button", { name: "Default" });
|
||||
expect(badge).toHaveTextContent("Default");
|
||||
fireEvent.keyDown(badge, { key: "ArrowDown" });
|
||||
fireEvent.pointerDown(badge);
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: /^Fast/ }));
|
||||
|
||||
expect(client.sendSystemCommand).toHaveBeenCalledWith(
|
||||
"preset-order",
|
||||
"/model fast",
|
||||
);
|
||||
expect(await screen.findByText("Fast")).toBeInTheDocument();
|
||||
fireEvent.keyDown(
|
||||
screen.getByRole("spinbutton", { name: "Fast" }),
|
||||
{ key: "End" },
|
||||
);
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Fast" }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: /^Extra/ }));
|
||||
expect(client.sendSystemCommand).toHaveBeenLastCalledWith(
|
||||
"preset-order",
|
||||
"/model extra",
|
||||
@@ -972,10 +971,8 @@ describe("ThreadShell", () => {
|
||||
));
|
||||
const { rerender } = render(view(null));
|
||||
|
||||
fireEvent.keyDown(
|
||||
await screen.findByRole("spinbutton", { name: "Default" }),
|
||||
{ key: "ArrowDown" },
|
||||
);
|
||||
fireEvent.pointerDown(await screen.findByRole("button", { name: "Default" }));
|
||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: /^Fast/ }));
|
||||
expect(await screen.findByText("Fast")).toBeInTheDocument();
|
||||
expect(client.sendSystemCommand).not.toHaveBeenCalled();
|
||||
|
||||
@@ -3369,74 +3366,6 @@ describe("ThreadShell", () => {
|
||||
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("removes session-management affordances from a fixed conversation", async () => {
|
||||
const client = makeClient();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/commands")) {
|
||||
return httpJson({
|
||||
commands: [
|
||||
{
|
||||
command: "/new",
|
||||
title: "New chat",
|
||||
description: "Reset this chat and start a fresh conversation.",
|
||||
icon: "square-pen",
|
||||
lifecycle: "finalize_active_turn",
|
||||
accepts_args: false,
|
||||
},
|
||||
{
|
||||
command: "/history",
|
||||
title: "Show conversation history",
|
||||
description: "Print the last N persisted messages.",
|
||||
icon: "history",
|
||||
arg_hint: "[n]",
|
||||
lifecycle: "side_channel",
|
||||
accepts_args: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("quick-chat")}
|
||||
title="Quick Chat"
|
||||
onToggleSidebar={() => {}}
|
||||
allowConversationReset={false}
|
||||
showSessionInfo={false}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/commands",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "/" },
|
||||
});
|
||||
|
||||
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: /\/new/i })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Session details" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not bring back welcome cards when image mode is enabled", async () => {
|
||||
const client = makeClient();
|
||||
const settings = modelSettings("deepseek-v4-pro", "deepseek");
|
||||
|
||||
@@ -763,6 +763,101 @@ describe("ThreadViewport", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps shallow wheel and touch scrolling user-owned until intent reverses", async () => {
|
||||
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo");
|
||||
const threaded: UIMessage[] = [
|
||||
{ id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 },
|
||||
{ id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 },
|
||||
];
|
||||
const answer: UIMessage = {
|
||||
id: "a2",
|
||||
role: "assistant",
|
||||
content: "streaming answer",
|
||||
turnId: "turn-2",
|
||||
isStreaming: true,
|
||||
createdAt: 4,
|
||||
};
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={threaded}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
/>,
|
||||
);
|
||||
const scroller = getScroller(container);
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 1_904 },
|
||||
clientHeight: { configurable: true, value: 500 },
|
||||
scrollTop: { configurable: true, writable: true, value: 1_404 },
|
||||
});
|
||||
const prompt = container.querySelector<HTMLElement>('[data-user-prompt-id="u2"]');
|
||||
expect(prompt).not.toBeNull();
|
||||
Object.defineProperty(prompt, "offsetTop", {
|
||||
configurable: true,
|
||||
value: 1_420,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={[...threaded, answer]}
|
||||
isStreaming
|
||||
composer={<div>composer</div>}
|
||||
activeTurnId="turn-2"
|
||||
activeTurnStartedHere
|
||||
/>,
|
||||
);
|
||||
await flushAnimationFrame();
|
||||
followTo.mockClear();
|
||||
|
||||
act(() => {
|
||||
fireEvent.wheel(scroller, { deltaY: -24 });
|
||||
scroller.scrollTop = 1_380;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await flushAnimationFrame();
|
||||
|
||||
expect(followTo).not.toHaveBeenCalled();
|
||||
expect(scroller.scrollTop).toBe(1_380);
|
||||
expect(screen.getByRole("button", { name: "Scroll to bottom" })).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
scroller.scrollTop = 1_404;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
fireEvent.wheel(scroller, { deltaY: 24 });
|
||||
});
|
||||
await flushAnimationFrame();
|
||||
|
||||
expect(followTo).toHaveBeenCalledWith(1_404);
|
||||
expect(scroller.scrollTop).toBe(1_404);
|
||||
expect(screen.queryByRole("button", { name: "Scroll to bottom" }))
|
||||
.not.toBeInTheDocument();
|
||||
|
||||
followTo.mockClear();
|
||||
act(() => {
|
||||
fireEvent.touchStart(scroller, { touches: [{ clientY: 300 }] });
|
||||
fireEvent.touchMove(scroller, { touches: [{ clientY: 324 }] });
|
||||
scroller.scrollTop = 1_380;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await flushAnimationFrame();
|
||||
|
||||
expect(followTo).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: "Scroll to bottom" })).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fireEvent.touchMove(scroller, { touches: [{ clientY: 300 }] });
|
||||
scroller.scrollTop = 1_404;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
fireEvent.touchEnd(scroller);
|
||||
});
|
||||
await flushAnimationFrame();
|
||||
|
||||
expect(followTo).toHaveBeenCalledWith(1_404);
|
||||
expect(screen.queryByRole("button", { name: "Scroll to bottom" }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the scroll-to-bottom button above a growing composer", async () => {
|
||||
const resizeObserver = stubResizeObserver();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user