Compare commits

..
Author SHA1 Message Date
Xubin Ren 3d14bcaf72 refactor(webui): reuse sidebar selection highlight 2026-07-31 00:59:35 +08:00
Xubin Ren 47d83af0b6 feat(webui): add persistent Quick Chat 2026-07-31 00:53:10 +08:00
46 changed files with 1027 additions and 928 deletions
-7
View File
@@ -49,13 +49,6 @@ 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. 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: To switch presets for future turns:
```text ```text
+2 -1
View File
@@ -356,7 +356,8 @@ Providers that use the Responses API can keep reasoning context across a
conversation, which helps with multi-step tasks. Supported providers can also conversation, which helps with multi-step tasks. Supported providers can also
compact long conversations automatically. compact long conversations automatically.
nanobot preserves Responses conversation state automatically for OpenAI Responses, OpenAI Codex, Azure OpenAI, DeepSeek V4 Flash, and compatible GitHub Copilot models. nanobot preserves Responses conversation state automatically for OpenAI
Responses, OpenAI Codex, Azure OpenAI, and compatible GitHub Copilot models.
Native compaction is also automatic when the provider supports it. The Native compaction is also automatic when the provider supports it. The
threshold is derived from the active model's context window and reserved output threshold is derived from the active model's context window and reserved output
headroom; no provider configuration is required. headroom; no provider configuration is required.
-2
View File
@@ -231,8 +231,6 @@ 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. `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 ### Custom OpenAI-Compatible Endpoint
The `custom` provider fits one OpenAI-compatible endpoint that is not represented by a named provider. The `custom` provider fits one OpenAI-compatible endpoint that is not represented by a named provider.
-13
View File
@@ -147,19 +147,6 @@ 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 or MCP presets. The model badge shows the current model or preset and links back
to model settings when setup is incomplete. 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 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) image mode from the composer. See [`image-generation.md`](./image-generation.md)
for provider setup and output behavior. for provider setup and output behavior.
+6 -5
View File
@@ -493,11 +493,12 @@ class SlackChannel(BaseChannel):
except Exception as e: except Exception as e:
self.logger.debug("reactions_add failed: {}", e) self.logger.debug("reactions_add failed: {}", e)
# Thread-scoped session key whenever the turn lives in a thread: either the # Thread-scoped session key whenever the user is in a real thread
# message arrived inside one (raw_thread_ts) or reply_in_thread opens a new # (raw_thread_ts is set). DM threads get their own session, separate
# thread for this channel message. DM roots have no thread_ts and keep the # from the DM root, so context doesn't bleed across thread boundaries.
# default per-chat session, so context doesn't bleed across thread boundaries. session_key = (
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts else None f"slack:{chat_id}:{thread_ts}" if thread_ts and raw_thread_ts else None
)
media_paths: list[str] = [] media_paths: list[str] = []
file_markers: list[str] = [] file_markers: list[str] = []
for file_info in _as_json_list(event.get("files")) or []: for file_info in _as_json_list(event.get("files")) or []:
@@ -555,113 +555,6 @@ async def test_dm_thread_message_keeps_thread_ts_and_threaded_session() -> None:
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100" 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 @pytest.mark.asyncio
async def test_slack_slash_command_skips_thread_context() -> None: async def test_slack_slash_command_skips_thread_context() -> None:
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus()) channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
+2 -25
View File
@@ -230,30 +230,9 @@ class WeixinChannel(BaseChannel):
self.logger.error("Failed to load Weixin account state", exc_info=True) self.logger.error("Failed to load Weixin account state", exc_info=True)
return False return False
def _save_state(self, *, force: bool = False) -> None: def _save_state(self) -> None:
state_file = self._get_state_dir() / "account.json" state_file = self._get_state_dir() / "account.json"
with suppress(Exception): 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 = { data = {
"token": self._token, "token": self._token,
"get_updates_buf": self._get_updates_buf, "get_updates_buf": self._get_updates_buf,
@@ -510,7 +489,7 @@ class WeixinChannel(BaseChannel):
self._token = token self._token = token
if base_url: if base_url:
self.config.base_url = base_url self.config.base_url = base_url
self._save_state(force=True) self._save_state()
async def connect_close_client(self) -> None: async def connect_close_client(self) -> None:
self._running = False self._running = False
@@ -634,8 +613,6 @@ class WeixinChannel(BaseChannel):
remaining = self._session_pause_remaining_s() remaining = self._session_pause_remaining_s()
if remaining > 0: if remaining > 0:
await asyncio.sleep(remaining) await asyncio.sleep(remaining)
if not self.config.token:
self._load_state()
return return
body: dict[str, Any] = { body: dict[str, Any] = {
@@ -98,80 +98,6 @@ def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
assert restored._context_tokens == {"wx-user": "ctx-1"} 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 @pytest.mark.asyncio
async def test_process_message_deduplicates_inbound_ids() -> None: async def test_process_message_deduplicates_inbound_ids() -> None:
channel, bus = _make_channel() channel, bus = _make_channel()
@@ -536,56 +462,6 @@ async def test_poll_once_pauses_session_on_expired_errcode() -> None:
assert channel._session_pause_remaining_s() > 0 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 @pytest.mark.asyncio
async def test_qr_login_refreshes_expired_qr_and_then_succeeds( async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
no_qr_poll_delay, no_qr_poll_delay,
+6 -21
View File
@@ -958,34 +958,22 @@ class OpenAICompatProvider(LLMProvider):
model: str | None, model: str | None,
reasoning_effort: str | None, reasoning_effort: str | None,
) -> bool: ) -> bool:
"""Choose Responses for providers/models that explicitly support it.""" """Use Responses API only for direct OpenAI requests that benefit from it."""
if self._api_type == "chat_completions": if self._api_type == "chat_completions":
return False return False
spec_name = self._spec.name if self._spec is not None else None if self._spec and self._spec.name not in ("openai", "github_copilot"):
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 return False
if self._api_type == "responses": if self._api_type == "responses":
# Explicit configuration means Responses is mandatory; do not # Explicit configuration means Responses is mandatory; do not
# consult the circuit breaker or fall back to Chat Completions. # consult the circuit breaker or fall back to Chat Completions.
return True return True
if provider_responses and (self._spec is None or self._spec.name != "github_copilot"): if self._spec is None or self._spec.name != "github_copilot":
if not _is_direct_openai_base(self._effective_base): if not _is_direct_openai_base(self._effective_base):
return False return False
model_name = (model or self.default_model).lower()
wants = False wants = False
if model_responses: if reasoning_effort and reasoning_effort.lower() != "none":
wants = True
elif reasoning_effort and reasoning_effort.lower() != "none":
wants = True wants = True
elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")): elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
wants = True wants = True
@@ -1111,13 +1099,11 @@ class OpenAICompatProvider(LLMProvider):
self._sanitize_empty_content(sanitized_state.pending_messages) 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( instructions, input_items, replayed = prepare_responses_input(
sanitized_messages, sanitized_messages,
state=sanitized_state, state=sanitized_state,
provider=self._responses_state_provider(), provider=self._responses_state_provider(),
model=model_name, model=model_name,
preserve_reasoning=preserve_reasoning,
) )
body: dict[str, Any] = { body: dict[str, Any] = {
@@ -1145,7 +1131,7 @@ class OpenAICompatProvider(LLMProvider):
if self._supports_temperature(model_name, reasoning_effort): if self._supports_temperature(model_name, reasoning_effort):
body["temperature"] = temperature body["temperature"] = temperature
if not self._supports_temperature(model_name, reasoning_effort) and not preserve_reasoning: if not self._supports_temperature(model_name, reasoning_effort):
body["include"] = ["reasoning.encrypted_content"] body["include"] = ["reasoning.encrypted_content"]
if reasoning_effort and reasoning_effort.lower() != "none": if reasoning_effort and reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": reasoning_effort} body["reasoning"] = {"effort": reasoning_effort}
@@ -1841,7 +1827,6 @@ class OpenAICompatProvider(LLMProvider):
_timed_stream(), _timed_stream(),
on_content_delta, on_content_delta,
on_tool_call_delta=on_tool_call_delta, on_tool_call_delta=on_tool_call_delta,
on_reasoning_delta=on_thinking_delta,
capture=capture, capture=capture,
) )
self._record_responses_success(model, reasoning_effort) self._record_responses_success(model, reasoning_effort)
@@ -12,11 +12,7 @@ def _as_json_object(value: object) -> dict[str, Any] | None:
return cast(dict[str, Any], value) if isinstance(value, dict) else None return cast(dict[str, Any], value) if isinstance(value, dict) else None
def convert_messages( def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
messages: list[dict[str, Any]],
*,
preserve_reasoning: bool = False,
) -> tuple[str, list[dict[str, Any]]]:
"""Convert Chat Completions messages to Responses API input items. """Convert Chat Completions messages to Responses API input items.
Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted
@@ -40,13 +36,6 @@ def convert_messages(
continue continue
if role == "assistant": 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: if isinstance(content, str) and content:
message_id = _unique_item_id(f"msg_{idx}", used_item_ids) message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
input_items.append({ input_items.append({
+13 -35
View File
@@ -69,9 +69,7 @@ def _response_object(value: object) -> dict[str, Any] | None:
return object_value return object_value
dump = getattr(value, "model_dump", None) dump = getattr(value, "model_dump", None)
if callable(dump): if callable(dump):
dumped = _as_json_object(dump()) return _as_json_object(dump())
if dumped is not None:
return dumped
try: try:
return _as_json_object(vars(value)) return _as_json_object(vars(value))
except TypeError: except TypeError:
@@ -446,14 +444,6 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
for item in _response_object_list(output): for item in _response_object_list(output):
if item.get("type") != "reasoning": if item.get("type") != "reasoning":
continue 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")): for summary in _response_object_list(item.get("summary")):
if summary.get("type") == "summary_text" and summary.get("text"): if summary.get("type") == "summary_text" and summary.get("text"):
text = summary.get("text") text = summary.get("text")
@@ -493,9 +483,11 @@ def parse_response_output(
if isinstance(refusal, str): if isinstance(refusal, str):
content_parts.append(refusal) content_parts.append(refusal)
elif item_type == "reasoning": elif item_type == "reasoning":
text = _extract_reasoning_summary_from_output([item]) for s in _response_object_list(item.get("summary")):
if text: if s.get("type") == "summary_text" and s.get("text"):
reasoning_content = (reasoning_content or "") + text text = s.get("text")
if isinstance(text, str):
reasoning_content = (reasoning_content or "") + text
elif item_type == "function_call": elif item_type == "function_call":
call_id = item.get("call_id") or "" call_id = item.get("call_id") or ""
item_id = item.get("id") or "fc_0" item_id = item.get("id") or "fc_0"
@@ -540,7 +532,6 @@ async def consume_sdk_stream(
stream: Any, stream: Any,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], 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, capture: ResponsesStreamCapture | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]: ) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
"""Consume an SDK async stream from ``client.responses.create(stream=True)``.""" """Consume an SDK async stream from ``client.responses.create(stream=True)``."""
@@ -551,7 +542,6 @@ async def consume_sdk_stream(
finish_reason = "stop" finish_reason = "stop"
usage: dict[str, int] = {} usage: dict[str, int] = {}
reasoning_content: str | None = None reasoning_content: str | None = None
streamed_reasoning = False
refusal_seen = False refusal_seen = False
refusal_deltas: dict[tuple[str | None, int | None], str] = {} refusal_deltas: dict[tuple[str | None, int | None], str] = {}
emitted_refusal_text = "" emitted_refusal_text = ""
@@ -582,19 +572,6 @@ async def consume_sdk_stream(
content += delta_text content += delta_text
if on_content_delta and delta_text: if on_content_delta and delta_text:
await on_content_delta(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": elif event_type == "response.refusal.delta":
refusal_seen = True refusal_seen = True
delta_text = getattr(event, "delta", None) delta_text = getattr(event, "delta", None)
@@ -712,12 +689,13 @@ async def consume_sdk_stream(
"completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0), "completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0),
"total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0), "total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0),
} }
if not reasoning_content: for out_item in cast(list[Any], getattr(resp, "output", None) or []):
reasoning_content = _extract_reasoning_summary_from_output( if getattr(out_item, "type", None) == "reasoning":
getattr(resp, "output", None) for s in cast(list[Any], getattr(out_item, "summary", None) or []):
) if getattr(s, "type", None) == "summary_text":
if reasoning_content and on_reasoning_delta: text = getattr(s, "text", None)
await on_reasoning_delta(reasoning_content) if text:
reasoning_content = (reasoning_content or "") + text
elif event_type in {"error", "response.failed"}: elif event_type in {"error", "response.failed"}:
detail = getattr(event, "error", None) or getattr(event, "message", None) or event detail = getattr(event, "error", None) or getattr(event, "message", None) or event
raise RuntimeError(f"Response failed: {str(detail)[:500]}") raise RuntimeError(f"Response failed: {str(detail)[:500]}")
+2 -9
View File
@@ -43,7 +43,6 @@ def prepare_responses_input(
state: ProviderConversationState | None, state: ProviderConversationState | None,
provider: str, provider: str,
model: str, model: str,
preserve_reasoning: bool = False,
) -> tuple[str, list[dict[str, Any]], bool]: ) -> tuple[str, list[dict[str, Any]], bool]:
"""Build a request from exact prior items plus only newly appended messages. """Build a request from exact prior items plus only newly appended messages.
@@ -51,10 +50,7 @@ def prepare_responses_input(
When no compatible state exists, it is converted normally as a safe When no compatible state exists, it is converted normally as a safe
fallback. fallback.
""" """
instructions, fallback_items = convert_messages( instructions, fallback_items = convert_messages(messages)
messages,
preserve_reasoning=preserve_reasoning,
)
if state is None or not responses_state_matches( if state is None or not responses_state_matches(
state, state,
provider=provider, provider=provider,
@@ -66,10 +62,7 @@ def prepare_responses_input(
if prior_items is None: if prior_items is None:
return instructions, fallback_items, False return instructions, fallback_items, False
_, delta_items = convert_messages( _, delta_items = convert_messages(state.pending_messages)
state.pending_messages,
preserve_reasoning=preserve_reasoning,
)
logger.debug( logger.debug(
"Replaying Responses state: prior_items={} pending_messages={}", "Replaying Responses state: prior_items={} pending_messages={}",
len(prior_items), len(prior_items),
-6
View File
@@ -111,11 +111,6 @@ class ProviderSpec:
# Substring match against the wire model name (lowercased). # Substring match against the wire model name (lowercased).
implicit_reasoning_models: tuple[str, ...] = () 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",...} + # When the model returns content as a list of {"type":"thinking",...} +
# {"type":"text",...} blocks, extract the thinking text into # {"type":"text",...} blocks, extract the thinking text into
# reasoning_content. Mistral's Magistral / reasoning-enabled responses use # reasoning_content. Mistral's Magistral / reasoning-enabled responses use
@@ -466,7 +461,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat", backend="openai_compat",
default_api_base="https://api.deepseek.com", default_api_base="https://api.deepseek.com",
thinking_style="thinking_type", thinking_style="thinking_type",
responses_models=("deepseek-v4-flash",),
), ),
# Gemini: Google's OpenAI-compatible endpoint # Gemini: Google's OpenAI-compatible endpoint
ProviderSpec( ProviderSpec(
+1 -1
View File
@@ -51,7 +51,7 @@ dependencies = [
"filelock>=3.25.2", "filelock>=3.25.2",
"watchfiles>=1.1.1,<2.0.0", "watchfiles>=1.1.1,<2.0.0",
"packaging>=24.0", "packaging>=24.0",
"tzdata>=2025.2", "tzdata>=2025.2; sys_platform == 'win32'",
"defusedxml>=0.7.1,<1.0.0", "defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0", "pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0", "python-docx>=1.1.0,<2.0.0",
+1 -1
View File
@@ -2558,7 +2558,7 @@ def test_optional_dependency_metadata_for_enable():
): ):
assert not any(dep.startswith(dep_name) for dep in required) assert not any(dep.startswith(dep_name) for dep in required)
for dependency in ( for dependency in (
"tzdata>=2025.2", "tzdata>=2025.2; sys_platform == 'win32'",
"defusedxml>=0.7.1,<1.0.0", "defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0", "pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0", "python-docx>=1.1.0,<2.0.0",
-30
View File
@@ -1,8 +1,4 @@
import json import json
import os
import subprocess
import sys
import textwrap
import warnings import warnings
import pytest import pytest
@@ -46,32 +42,6 @@ def test_agent_timezone_rejects_unknown_iana_name() -> None:
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}}) 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: def test_provider_api_type_accepts_exact_values_only() -> None:
config = Config.model_validate({ config = Config.model_validate({
"providers": { "providers": {
-56
View File
@@ -150,22 +150,6 @@ class TestConvertMessages:
assert items[0]["content"][0]["type"] == "output_text" assert items[0]["content"][0]["type"] == "output_text"
assert items[0]["content"][0]["text"] == "I'll help" 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): def test_assistant_empty_content_skipped(self):
_, items = convert_messages([{"role": "assistant", "content": ""}]) _, items = convert_messages([{"role": "assistant", "content": ""}])
assert len(items) == 0 assert len(items) == 0
@@ -555,22 +539,6 @@ class TestParseResponseOutput:
assert result.content == "42" assert result.content == "42"
assert result.reasoning_content == "I think therefore I am." 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): def test_empty_output(self):
resp = {"output": [], "status": "completed", "usage": {}} resp = {"output": [], "status": "completed", "usage": {}}
result = parse_response_output(resp) result = parse_response_output(resp)
@@ -1665,30 +1633,6 @@ class TestConsumeSdkStream:
_, _, _, _, reasoning = await consume_sdk_stream(stream()) _, _, _, _, reasoning = await consume_sdk_stream(stream())
assert reasoning == "thinking..." 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 @pytest.mark.asyncio
async def test_error_event_raises(self): async def test_error_event_raises(self):
ev = MagicMock(type="error", error="rate_limit_exceeded") ev = MagicMock(type="error", error="rate_limit_exceeded")
@@ -29,32 +29,6 @@ def test_responses_api_available_by_default(provider):
assert provider._should_use_responses_api("gpt-5", None) is True 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): def test_direct_openai_enables_server_compaction(provider):
provider._extra_body = {} provider._extra_body = {}
+66 -10
View File
@@ -37,6 +37,12 @@ import {
import { displayTitle } from "@/lib/chat-groups"; import { displayTitle } from "@/lib/chat-groups";
import { deriveTitle } from "@/lib/format"; import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client"; 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 { ClientProvider, useClient } from "@/providers/ClientProvider";
import type { import type {
BootstrapResponse, BootstrapResponse,
@@ -225,6 +231,9 @@ function readShellRoute(): ShellRoute {
if (path === "/skills") { if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" }; return { view: "skills", activeKey, settingsSection: "skills" };
} }
if (path === "/quick-chat") {
return { view: "chat", activeKey: QUICK_CHAT_KEY, settingsSection: "overview" };
}
if (path.startsWith("/chat/")) { if (path.startsWith("/chat/")) {
const encoded = path.slice("/chat/".length); const encoded = path.slice("/chat/".length);
try { try {
@@ -241,6 +250,7 @@ function readShellRoute(): ShellRoute {
function shellRouteHash(route: ShellRoute): string { function shellRouteHash(route: ShellRoute): string {
if (route.view === "chat") { if (route.view === "chat") {
if (isQuickChatKey(route.activeKey)) return "#/quick-chat";
return route.activeKey return route.activeKey
? `#/chat/${encodeURIComponent(route.activeKey)}` ? `#/chat/${encodeURIComponent(route.activeKey)}`
: "#/new"; : "#/new";
@@ -947,8 +957,16 @@ function Shell({
deleteChat, deleteChat,
getSessionAutomations, getSessionAutomations,
} = useSessions(); } = 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 } = const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading); useSidebarState(regularSessions, !loading);
const initialRouteRef = useRef<ShellRoute | null>(null); const initialRouteRef = useRef<ShellRoute | null>(null);
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute(); if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
const [activeKey, setActiveKey] = useState<string | null>( const [activeKey, setActiveKey] = useState<string | null>(
@@ -1114,8 +1132,10 @@ function Shell({
const activeSession = useMemo<ChatSummary | null>(() => { const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null; if (!activeKey) return null;
if (isQuickChatKey(activeKey)) return quickSession;
return sessions.find((s) => s.key === activeKey) ?? null; return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]); }, [sessions, activeKey, quickSession]);
const quickChatActive = isQuickChatKey(activeKey);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]); const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null; const activeChatId = activeSession?.chatId ?? null;
@@ -1130,6 +1150,9 @@ function Shell({
}); });
}, [activeChatId]); }, [activeChatId]);
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => { const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
if (quickChatActive) {
return workspaces?.default_scope ?? null;
}
if (activeChatId && workspaceOverrides[activeChatId]) { if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId]; return workspaceOverrides[activeChatId];
} }
@@ -1141,6 +1164,7 @@ function Shell({
activeChatId, activeChatId,
activeSession?.workspaceScope, activeSession?.workspaceScope,
draftWorkspaceScope, draftWorkspaceScope,
quickChatActive,
workspaceOverrides, workspaceOverrides,
workspaces?.default_scope, workspaces?.default_scope,
]); ]);
@@ -1161,7 +1185,10 @@ function Shell({
useEffect(() => { useEffect(() => {
if (loading) return; if (loading) return;
const knownChatIds = new Set(sessions.map((session) => session.chatId)); const knownChatIds = new Set([
QUICK_CHAT_ID,
...sessions.map((session) => session.chatId),
]);
setUpdatedChatIds((current) => { setUpdatedChatIds((current) => {
const next = new Set( const next = new Set(
Array.from(current).filter((chatId) => knownChatIds.has(chatId)), Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
@@ -1176,6 +1203,7 @@ function Shell({
useEffect(() => { useEffect(() => {
if (loading || !activeKey) return; if (loading || !activeKey) return;
if (isQuickChatKey(activeKey)) return;
if (sessions.some((session) => session.key === activeKey)) return; if (sessions.some((session) => session.key === activeKey)) return;
const currentRoute = readShellRoute(); const currentRoute = readShellRoute();
navigate( navigate(
@@ -1417,6 +1445,18 @@ function Shell({
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, [navigate]); }, [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( const onNewChatInProject = useCallback(
(projectPath: string, projectName: string) => { (projectPath: string, projectName: string) => {
const base = workspaces?.default_scope ?? activeWorkspaceScope; const base = workspaces?.default_scope ?? activeWorkspaceScope;
@@ -1682,6 +1722,7 @@ function Shell({
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
const nextKey = (() => { const nextKey = (() => {
if (!activeKey) return null; if (!activeKey) return null;
if (isQuickChatKey(activeKey)) return activeKey;
if (sessions.some((session) => session.key === activeKey)) return activeKey; if (sessions.some((session) => session.key === activeKey)) return activeKey;
return sessions[0]?.key ?? null; return sessions[0]?.key ?? null;
})(); })();
@@ -1773,7 +1814,10 @@ function Shell({
}); });
}, [client, t]); }, [client, t]);
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh); const onTurnEnd = useDeferredTitleRefresh(
quickChatActive ? null : activeSession,
refresh,
);
const onConfirmDelete = useCallback(async () => { const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return; if (!pendingDelete) return;
@@ -1863,7 +1907,9 @@ function Shell({
}); });
}, []); }, []);
const headerTitle = activeSession const headerTitle = quickChatActive
? t("sidebar.quickChat")
: activeSession
? sidebarState.title_overrides[activeSession.key] || ? sidebarState.title_overrides[activeSession.key] ||
activeSession.title || activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat")) deriveTitle(activeSession.preview, t("chat.newChat"))
@@ -1900,9 +1946,12 @@ function Shell({
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]); }, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
const sidebarProps = { const sidebarProps = {
sessions, sessions: regularSessions,
activeKey, activeKey: view === "chat" ? activeKey : null,
loading, loading,
quickChatActive: view === "chat" && quickChatActive,
newChatActive: view === "chat" && activeKey === null,
onOpenQuickChat,
onNewChat, onNewChat,
onSelect: onSelectChat, onSelect: onSelectChat,
onRequestDelete, onRequestDelete,
@@ -2065,7 +2114,7 @@ function Shell({
<SessionSearchDialog <SessionSearchDialog
open open
onOpenChange={setSessionSearchOpen} onOpenChange={setSessionSearchOpen}
sessions={sessions} sessions={regularSessions}
activeKey={activeKey} activeKey={activeKey}
loading={loading} loading={loading}
titleOverrides={sidebarState.title_overrides} titleOverrides={sidebarState.title_overrides}
@@ -2090,7 +2139,7 @@ function Shell({
onToggleSidebar={toggleSidebar} onToggleSidebar={toggleSidebar}
onNewChat={onNewChat} onNewChat={onNewChat}
onCreateChat={onCreateChat} onCreateChat={onCreateChat}
onForkChat={onForkChat} onForkChat={quickChatActive ? undefined : onForkChat}
onTurnEnd={onTurnEnd} onTurnEnd={onTurnEnd}
theme={theme} theme={theme}
onToggleTheme={toggle} onToggleTheme={toggle}
@@ -2099,13 +2148,20 @@ function Shell({
hideHeader={false} hideHeader={false}
workspaceScope={activeWorkspaceScope} workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null} workspaceDefaultScope={workspaces?.default_scope ?? null}
workspaceControls={workspaces?.controls ?? null} workspaceControls={
quickChatActive ? null : (workspaces?.controls ?? null)
}
workspaceScopeDisabled={activeChatRunning} workspaceScopeDisabled={activeChatRunning}
workspaceError={workspaceError} workspaceError={workspaceError}
onWorkspaceScopeChange={applyWorkspaceScope} onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot} settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings} onOpenModelSettings={onOpenModelSettings}
skills={skills} skills={skills}
allowConversationReset={!quickChatActive}
showSessionInfo={!quickChatActive}
emptyStateGreeting={
quickChatActive ? t("quickChat.greeting") : undefined
}
/> />
</div> </div>
{view !== "chat" && ( {view !== "chat" && (
+12 -85
View File
@@ -1,7 +1,6 @@
import { import {
memo, memo,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@@ -25,6 +24,10 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import {
SIDEBAR_SELECTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format"; import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
import { import {
COLLAPSED_CHATS_VISIBLE_COUNT, COLLAPSED_CHATS_VISIBLE_COUNT,
@@ -106,9 +109,6 @@ export const ChatList = memo(function ChatList({
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS); const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const listContentRef = useRef<HTMLDivElement>(null); const listContentRef = useRef<HTMLDivElement>(null);
const activeRowRef = useRef<HTMLDivElement>(null); const activeRowRef = useRef<HTMLDivElement>(null);
const activeHighlightRef = useRef<HTMLDivElement>(null);
const activeHighlightSurfaceRef = useRef<HTMLDivElement>(null);
const highlightVisibleRef = useRef(false);
const labels = useMemo<ChatGroupLabels>(() => ({ const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"), pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"), all: t("chat.groups.all"),
@@ -163,74 +163,6 @@ export const ChatList = memo(function ChatList({
setVisibleLimit(INITIAL_VISIBLE_SESSIONS); setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
}, [showArchived, sort]); }, [showArchived, sort]);
useLayoutEffect(() => {
let resetTransitionFrame: number | null = null;
const updateHighlight = () => {
const content = listContentRef.current;
const row = activeRowRef.current;
const highlight = activeHighlightRef.current;
const surface = activeHighlightSurfaceRef.current;
if (!highlight || !surface) return;
if (!content || !row) {
surface.style.opacity = "0";
surface.style.transform = "scale(0.97)";
highlightVisibleRef.current = false;
return;
}
const shouldFloatIn = !highlightVisibleRef.current;
if (shouldFloatIn) {
highlight.style.transitionProperty = "none";
}
const contentRect = content.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
highlight.style.width = `${rowRect.width}px`;
highlight.style.height = `${rowRect.height}px`;
highlight.style.transform = `translate3d(${rowRect.left - contentRect.left}px, ${
rowRect.top - contentRect.top
}px, 0)`;
if (shouldFloatIn) {
void highlight.offsetWidth;
}
surface.style.opacity = "1";
surface.style.transform = "scale(1)";
highlightVisibleRef.current = true;
if (shouldFloatIn) {
resetTransitionFrame = window.requestAnimationFrame(() => {
highlight.style.removeProperty("transition-property");
resetTransitionFrame = null;
});
}
};
updateHighlight();
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(updateHighlight);
if (resizeObserver) {
if (listContentRef.current) resizeObserver.observe(listContentRef.current);
if (activeRowRef.current) resizeObserver.observe(activeRowRef.current);
}
window.addEventListener("resize", updateHighlight);
return () => {
if (resetTransitionFrame !== null) {
window.cancelAnimationFrame(resetTransitionFrame);
}
activeHighlightRef.current?.style.removeProperty("transition-property");
resizeObserver?.disconnect();
window.removeEventListener("resize", updateHighlight);
};
}, [activeKey, density, limitedGroups, showPreviews, showTimestamps]);
if (loading && sessions.length === 0) { if (loading && sessions.length === 0) {
return ( return (
<div className="px-3 py-6 text-[12px] text-muted-foreground"> <div className="px-3 py-6 text-[12px] text-muted-foreground">
@@ -333,7 +265,8 @@ export const ChatList = memo(function ChatList({
ref={active ? activeRowRef : undefined} ref={active ? activeRowRef : undefined}
data-chat-row={s.key} data-chat-row={s.key}
className={cn( className={cn(
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors", "group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
SIDEBAR_SELECTION_ITEM_CLASS,
compact ? "min-h-7" : "min-h-8", compact ? "min-h-7" : "min-h-8",
active active
? "text-sidebar-accent-foreground" ? "text-sidebar-accent-foreground"
@@ -475,18 +408,12 @@ export const ChatList = memo(function ChatList({
</button> </button>
</div> </div>
) : null} ) : null}
<div <SidebarSelectionHighlight
ref={activeHighlightRef} containerRef={listContentRef}
data-testid="active-chat-highlight" targetRef={activeRowRef}
aria-hidden="true" activeId={activeKey}
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none" scope="sessions"
> />
<div
ref={activeHighlightSurfaceRef}
data-testid="active-chat-highlight-surface"
className="h-full w-full scale-[0.97] rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none dark:bg-white/[0.07]"
/>
</div>
</div> </div>
</div> </div>
); );
+54 -7
View File
@@ -1,8 +1,14 @@
import { useState, type ReactNode } from "react"; import {
type ReactNode,
type RefObject,
useRef,
useState,
} from "react";
import { import {
Archive, Archive,
Brain, Brain,
CalendarClock, CalendarClock,
MessageCircle,
Menu, Menu,
Search, Search,
Settings, Settings,
@@ -13,6 +19,10 @@ import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList"; import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge"; import { ConnectionBadge } from "@/components/ConnectionBadge";
import {
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { import type {
ChatSummary, ChatSummary,
@@ -24,6 +34,9 @@ interface SidebarProps {
sessions: ChatSummary[]; sessions: ChatSummary[];
activeKey: string | null; activeKey: string | null;
loading: boolean; loading: boolean;
quickChatActive: boolean;
newChatActive: boolean;
onOpenQuickChat: () => void;
onNewChat: () => void; onNewChat: () => void;
onSelect: (key: string) => void; onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void; onRequestDelete: (key: string, label: string) => void;
@@ -82,6 +95,15 @@ export function Sidebar(props: SidebarProps) {
const collapsed = Boolean(props.collapsed); const collapsed = Boolean(props.collapsed);
const toggleLabel = t("thread.header.toggleSidebar"); const toggleLabel = t("thread.header.toggleSidebar");
const newChatShortcut = newChatShortcutLabel(); const newChatShortcut = newChatShortcutLabel();
const actionListRef = useRef<HTMLDivElement>(null);
const activeActionRef = useRef<HTMLButtonElement>(null);
const activeActionId = props.quickChatActive
? "quick-chat"
: props.newChatActive
? "new-chat"
: props.activeUtility
? `utility:${props.activeUtility}`
: null;
return ( return (
<nav <nav
@@ -134,15 +156,26 @@ export function Sidebar(props: SidebarProps) {
</div> </div>
<div <div
ref={actionListRef}
className={cn( className={cn(
"space-y-1.5 px-2 pb-2", "relative space-y-1.5 px-2 pb-2",
collapsed && "flex w-14 flex-col items-center px-0", collapsed && "flex w-14 flex-col items-center px-0",
)} )}
> >
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.quickChat")}
onClick={props.onOpenQuickChat}
active={props.quickChatActive}
selectionRef={activeActionRef}
icon={<MessageCircle className="h-4 w-4" />}
/>
<SidebarActionButton <SidebarActionButton
collapsed={collapsed} collapsed={collapsed}
label={t("sidebar.newChat")} label={t("sidebar.newChat")}
onClick={props.onNewChat} onClick={props.onNewChat}
active={props.newChatActive}
selectionRef={activeActionRef}
icon={<SquarePen className="h-4 w-4" />} icon={<SquarePen className="h-4 w-4" />}
shortcut={newChatShortcut} shortcut={newChatShortcut}
ariaKeyShortcuts="Meta+Shift+O Control+Shift+O" ariaKeyShortcuts="Meta+Shift+O Control+Shift+O"
@@ -159,6 +192,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenApps} onClick={props.onOpenApps}
onIntent={props.onSettingsIntent} onIntent={props.onSettingsIntent}
active={props.activeUtility === "apps"} active={props.activeUtility === "apps"}
selectionRef={activeActionRef}
icon={<Blocks className="h-4 w-4" />} icon={<Blocks className="h-4 w-4" />}
/> />
<SidebarActionButton <SidebarActionButton
@@ -167,6 +201,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenSkills} onClick={props.onOpenSkills}
onIntent={props.onSettingsIntent} onIntent={props.onSettingsIntent}
active={props.activeUtility === "skills"} active={props.activeUtility === "skills"}
selectionRef={activeActionRef}
icon={<Brain className="h-4 w-4" />} icon={<Brain className="h-4 w-4" />}
/> />
<SidebarActionButton <SidebarActionButton
@@ -175,6 +210,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenAutomations} onClick={props.onOpenAutomations}
onIntent={props.onSettingsIntent} onIntent={props.onSettingsIntent}
active={props.activeUtility === "automations"} active={props.activeUtility === "automations"}
selectionRef={activeActionRef}
icon={<CalendarClock className="h-4 w-4" />} icon={<CalendarClock className="h-4 w-4" />}
/> />
{props.archivedCount ? ( {props.archivedCount ? (
@@ -185,6 +221,12 @@ export function Sidebar(props: SidebarProps) {
icon={<Archive className="h-4 w-4" />} icon={<Archive className="h-4 w-4" />}
/> />
) : null} ) : null}
<SidebarSelectionHighlight
containerRef={actionListRef}
targetRef={activeActionRef}
activeId={activeActionId}
scope="actions"
/>
</div> </div>
<div <div
className={cn( className={cn(
@@ -255,6 +297,7 @@ function SidebarActionButton({
shortcut, shortcut,
ariaKeyShortcuts, ariaKeyShortcuts,
onIntent, onIntent,
selectionRef,
}: { }: {
collapsed: boolean; collapsed: boolean;
label: string; label: string;
@@ -265,13 +308,15 @@ function SidebarActionButton({
shortcut?: string; shortcut?: string;
ariaKeyShortcuts?: string; ariaKeyShortcuts?: string;
onIntent?: () => void; onIntent?: () => void;
selectionRef?: RefObject<HTMLButtonElement>;
}) { }) {
const title = shortcut ? `${label} (${shortcut})` : collapsed ? label : undefined; const title = shortcut ? `${label} (${shortcut})` : collapsed ? label : undefined;
return ( return (
<Button <Button
ref={active ? selectionRef : undefined}
type="button" type="button"
variant="ghost" variant={null}
aria-label={label} aria-label={label}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
aria-keyshortcuts={ariaKeyShortcuts} aria-keyshortcuts={ariaKeyShortcuts}
@@ -280,12 +325,14 @@ function SidebarActionButton({
onFocus={onIntent} onFocus={onIntent}
onPointerEnter={onIntent} onPointerEnter={onIntent}
className={cn( className={cn(
"touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground", "touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-xl font-medium",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out", SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
collapsed collapsed
? "w-9 justify-center gap-0 rounded-xl px-0" ? "w-9 justify-center gap-0 px-0"
: "w-full justify-start gap-2 px-3 text-[12.5px]", : "w-full justify-start gap-2 px-3 text-[12.5px]",
active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]", active
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/85 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
className, className,
)} )}
> >
@@ -0,0 +1,90 @@
import {
type RefObject,
useLayoutEffect,
useRef,
} from "react";
interface SidebarSelectionHighlightProps {
containerRef: RefObject<HTMLElement>;
targetRef: RefObject<HTMLElement>;
activeId: string | null;
scope: string;
}
export const SIDEBAR_SELECTION_ITEM_CLASS =
"relative z-[1] transition-[color] duration-150 ease-out motion-reduce:transition-none";
export const SIDEBAR_SELECTION_ACTION_ITEM_CLASS =
"relative z-[1] transition-[width,padding,color] [transition-duration:300ms,300ms,150ms] ease-out motion-reduce:transition-none";
export function SidebarSelectionHighlight({
containerRef,
targetRef,
activeId,
scope,
}: SidebarSelectionHighlightProps) {
const highlightRef = useRef<HTMLDivElement>(null);
const positionedRef = useRef(false);
useLayoutEffect(() => {
const highlight = highlightRef.current;
const container = containerRef.current;
const target = targetRef.current;
let restoreTransitionFrame: number | null = null;
const position = () => {
if (!highlight) return;
if (!activeId || !container || !target) {
highlight.style.opacity = "0";
positionedRef.current = false;
return;
}
const firstPosition = !positionedRef.current;
if (firstPosition) highlight.style.transitionProperty = "none";
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
highlight.style.width = `${targetRect.width}px`;
highlight.style.height = `${targetRect.height}px`;
highlight.style.transform = `translate3d(${targetRect.left - containerRect.left}px, ${
targetRect.top - containerRect.top
}px, 0)`;
highlight.style.opacity = "1";
positionedRef.current = true;
if (firstPosition) {
restoreTransitionFrame = window.requestAnimationFrame(() => {
highlight.style.removeProperty("transition-property");
restoreTransitionFrame = null;
});
}
};
position();
const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(position);
if (container) resizeObserver?.observe(container);
if (target) resizeObserver?.observe(target);
window.addEventListener("resize", position);
return () => {
if (restoreTransitionFrame !== null) {
window.cancelAnimationFrame(restoreTransitionFrame);
}
highlight?.style.removeProperty("transition-property");
resizeObserver?.disconnect();
window.removeEventListener("resize", position);
};
});
return (
<div
ref={highlightRef}
data-testid={`${scope}-selection-highlight`}
data-active-id={activeId ?? undefined}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none dark:bg-white/[0.07]"
/>
);
}
+17 -3
View File
@@ -65,6 +65,10 @@ import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry"; import { channelUiPresentation } from "@/channel-plugins/registry";
import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import {
SIDEBAR_SELECTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings"; import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap"; import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
import { ToggleButton } from "@/components/settings/ToggleButton"; import { ToggleButton } from "@/components/settings/ToggleButton";
@@ -2497,6 +2501,8 @@ function SettingsSidebar({
hostChromeInset?: boolean; hostChromeInset?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const desktopNavRef = useRef<HTMLDivElement>(null);
const activeNavItemRef = useRef<HTMLButtonElement>(null);
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection) const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
?? SETTINGS_NAV_ITEMS[0]; ?? SETTINGS_NAV_ITEMS[0];
const ActiveIcon = activeItem.icon; const ActiveIcon = activeItem.icon;
@@ -2569,19 +2575,21 @@ function SettingsSidebar({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<div className="hidden space-y-1 lg:block"> <div ref={desktopNavRef} className="relative hidden space-y-1 lg:block">
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => { {SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection; const active = key === activeSection;
return ( return (
<button <button
ref={active ? activeNavItemRef : undefined}
key={key} key={key}
type="button" type="button"
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
onClick={() => onSelectSection(key)} onClick={() => onSelectSection(key)}
className={cn( className={cn(
"touch-target flex h-9 w-full items-center gap-2 rounded-[10px] px-2.5 text-left text-[13px] font-medium transition-colors", "touch-target flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-[13px] font-medium",
SIDEBAR_SELECTION_ITEM_CLASS,
active active
? "bg-sidebar-accent text-foreground" ? "text-sidebar-accent-foreground"
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground", : "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
)} )}
> >
@@ -2592,6 +2600,12 @@ function SettingsSidebar({
</button> </button>
); );
})} })}
<SidebarSelectionHighlight
containerRef={desktopNavRef}
targetRef={activeNavItemRef}
activeId={activeSection}
scope="settings"
/>
</div> </div>
</nav> </nav>
+264 -100
View File
@@ -1,13 +1,13 @@
import { useLayoutEffect, useRef, useState } from "react";
import { ChevronDown, CircleHelp, Sparkles } from "lucide-react";
import { import {
DropdownMenu, useEffect,
DropdownMenuContent, useLayoutEffect,
DropdownMenuRadioGroup, useRef,
DropdownMenuRadioItem, useState,
DropdownMenuTrigger, type KeyboardEvent,
} from "@/components/ui/dropdown-menu"; type PointerEvent,
} from "react";
import { CircleHelp, Sparkles } from "lucide-react";
import { useLogoFallback } from "@/hooks/useLogoFallback"; import { useLogoFallback } from "@/hooks/useLogoFallback";
import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand"; import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -33,6 +33,54 @@ interface ModelPresetBadgeProps {
onClick?: () => void; 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({ export function ModelPresetBadge({
label, label,
modelDetail, modelDetail,
@@ -62,94 +110,204 @@ export function ModelPresetBadge({
: modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset); : modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset);
const interactive = Boolean(onClick); const interactive = Boolean(onClick);
const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1; const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1;
const badgeClassName = cn( const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName));
"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", const pillHeight = isHero ? 32 : 36;
(interactive || canSwitch) && "cursor-pointer focus-visible:outline-none", const pillStride = pillHeight + PILL_GAP_PX;
isHero ? "h-8" : "h-9", const [motion, setMotion] = useState<PresetMotion | null>(null);
); const gestureRef = useRef<PresetGesture | null>(null);
const badgeContent = (
<PresetPill
label={label}
modelDetail={modelDetail}
provider={provider}
providerLabel={providerLabel}
needsSetup={needsSetup}
fallbackModelName={fallbackModelName}
isHero={isHero}
showPicker={canSwitch}
/>
);
if (canSwitch) { function clearGesture() {
return ( const gesture = gestureRef.current;
<DropdownMenu modal={false}> if (gesture?.timer) clearTimeout(gesture.timer);
<DropdownMenuTrigger asChild> if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll);
<button type="button" aria-label={label} className={badgeClassName}> gestureRef.current = null;
{badgeContent}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="top"
sideOffset={8}
collisionPadding={12}
className="w-[min(20rem,calc(100vw-2rem))] rounded-[18px]"
>
<DropdownMenuRadioGroup
value={activeName}
onValueChange={(name) => {
if (name !== activeName) onPresetChange?.(name);
}}
>
{presets.map((preset) => {
const detail = [...new Set([preset.model, preset.provider].filter(Boolean))]
.join(" · ");
return (
<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>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
} }
if (interactive) { useEffect(() => {
return ( if (!canSwitch) {
<button clearGesture();
type="button" setMotion(null);
aria-label={label} }
onClick={onClick} return clearGesture;
className={badgeClassName} }, [canSwitch]);
>
{badgeContent} useEffect(() => {
</button> 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 ( return (
<span aria-label={label} className={badgeClassName}> <Container
{badgeContent} data-switching={motion ? "true" : undefined}
</span> 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
>
<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)`,
}}
>
{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);
return (
<PresetPill
key={virtualIndex}
label={preset.label || preset.name}
modelDetail={preset.model}
provider={preset.provider}
isHero={isHero}
offset={offset}
scale={scale}
/>
);
})}
</span>
</span>
) : null}
</Container>
); );
} }
function PresetPill({ function PresetPill({
className,
label, label,
modelDetail, modelDetail,
provider, provider,
@@ -157,8 +315,10 @@ function PresetPill({
needsSetup = false, needsSetup = false,
fallbackModelName, fallbackModelName,
isHero, isHero,
showPicker = false, offset,
scale,
}: { }: {
className?: string | false | null;
label: string; label: string;
modelDetail?: string | null; modelDetail?: string | null;
provider?: string | null; provider?: string | null;
@@ -166,7 +326,8 @@ function PresetPill({
needsSetup?: boolean; needsSetup?: boolean;
fallbackModelName?: string | null; fallbackModelName?: string | null;
isHero: boolean; isHero: boolean;
showPicker?: boolean; offset?: number;
scale?: number;
}) { }) {
const labelRef = useRef<HTMLSpanElement | null>(null); const labelRef = useRef<HTMLSpanElement | null>(null);
const [labelOverflows, setLabelOverflows] = useState(false); const [labelOverflows, setLabelOverflows] = useState(false);
@@ -176,9 +337,11 @@ function PresetPill({
const brand = providerBrand(inferredProvider); const brand = providerBrand(inferredProvider);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls); const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · "); const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
const logoTestId = needsSetup const logoTestId = offset !== undefined
? "composer-model-setup-icon" ? undefined
: `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`; : needsSetup
? "composer-model-setup-icon"
: `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`;
useLayoutEffect(() => { useLayoutEffect(() => {
const node = labelRef.current; const node = labelRef.current;
@@ -193,15 +356,22 @@ function PresetPill({
return ( return (
<span <span
data-fallback={fallbackModelName ? "true" : undefined} data-fallback={fallbackModelName ? "true" : undefined}
data-preset-offset={offset}
title={fallbackModelName || title || undefined} title={fallbackModelName || title || undefined}
className={cn( 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", "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",
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]", offset === undefined && "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", "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", 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]", 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 <span
data-testid={logoTestId} data-testid={logoTestId}
@@ -252,12 +422,6 @@ function PresetPill({
> >
{label} {label}
</span> </span>
{showPicker ? (
<ChevronDown
className="thread-composer-model-chevron h-3.5 w-3.5 shrink-0 text-muted-foreground/75"
aria-hidden
/>
) : null}
</span> </span>
); );
} }
+17 -5
View File
@@ -315,6 +315,9 @@ interface ThreadShellProps {
settingsSnapshot?: SettingsPayload | null; settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void; onOpenModelSettings?: () => void;
skills?: SkillSummary[]; skills?: SkillSummary[];
allowConversationReset?: boolean;
showSessionInfo?: boolean;
emptyStateGreeting?: string;
} }
function toModelBadgeLabel(modelName: string | null): string | null { function toModelBadgeLabel(modelName: string | null): string | null {
@@ -597,6 +600,9 @@ export function ThreadShell({
settingsSnapshot = null, settingsSnapshot = null,
onOpenModelSettings, onOpenModelSettings,
skills = [], skills = [],
allowConversationReset = true,
showSessionInfo = true,
emptyStateGreeting,
}: ThreadShellProps) { }: ThreadShellProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const chatId = session?.chatId ?? null; const chatId = session?.chatId ?? null;
@@ -622,6 +628,12 @@ export function ThreadShell({
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null); const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
const [booting, setBooting] = useState(false); const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]); const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const availableSlashCommands = useMemo(
() => allowConversationReset
? slashCommands
: slashCommands.filter((command) => command.command !== "/new"),
[allowConversationReset, slashCommands],
);
const cliApps = useInstalledSettingItems({ const cliApps = useInstalledSettingItems({
getToken, getToken,
eventName: CLI_APPS_CHANGED_EVENT, eventName: CLI_APPS_CHANGED_EVENT,
@@ -1374,7 +1386,7 @@ export function ThreadShell({
fallbackModelName={fallbackModelName} fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"} variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands} slashCommands={availableSlashCommands}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
skills={skills} skills={skills}
@@ -1416,7 +1428,7 @@ export function ThreadShell({
fallbackModelName={fallbackModelName} fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant="hero" variant="hero"
slashCommands={slashCommands} slashCommands={availableSlashCommands}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
skills={skills} skills={skills}
@@ -1442,10 +1454,10 @@ export function ThreadShell({
</div> </div>
) : ( ) : (
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500"> <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={t(heroGreetingKey)} /> <HeroGreeting text={emptyStateGreeting ?? t(heroGreetingKey)} />
</div> </div>
); );
const sessionInfoAction = historyKey ? ( const sessionInfoAction = historyKey && showSessionInfo ? (
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} /> <SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
) : undefined; ) : undefined;
const promptNavigatorAction = historyKey ? ( const promptNavigatorAction = historyKey ? (
@@ -1488,7 +1500,7 @@ export function ThreadShell({
showScrollToBottomButton={!!session} showScrollToBottomButton={!!session}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
slashCommands={slashCommands} slashCommands={availableSlashCommands}
forkBoundaryMessageCount={forkBoundaryMessageCount} forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore} hasMoreBefore={hasMoreBefore}
loadingOlder={loadingOlder} loadingOlder={loadingOlder}
@@ -542,7 +542,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const distance = el.scrollHeight - el.scrollTop - el.clientHeight; const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
const near = distance < NEAR_BOTTOM_PX; const near = distance < NEAR_BOTTOM_PX;
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic"; const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
const logicallyAtBottom = owner === "automatic" || (owner === "navigation" && near); const logicallyAtBottom = owner === "automatic" || near;
setAtBottom((current) => setAtBottom((current) =>
current === logicallyAtBottom ? current : logicallyAtBottom, current === logicallyAtBottom ? current : logicallyAtBottom,
); );
@@ -557,7 +557,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
if (!direction) return; if (!direction) return;
threadMotionRef.current?.handleUserScrollIntent( threadMotionRef.current?.handleUserScrollIntent(
canScrollInDirection(el, direction), canScrollInDirection(el, direction),
direction === "forward",
); );
}; };
const handleWheel = (event: WheelEvent) => { const handleWheel = (event: WheelEvent) => {
@@ -573,21 +572,20 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const handlePointerDown = (event: PointerEvent) => { const handlePointerDown = (event: PointerEvent) => {
if (event.button === 0 && event.target === el) yieldCameraToUser(); if (event.button === 0 && event.target === el) yieldCameraToUser();
}; };
let lastTouchY: number | null = null; let touchStartY: number | null = null;
const handleTouchStart = (event: TouchEvent) => { const handleTouchStart = (event: TouchEvent) => {
lastTouchY = event.touches[0]?.clientY ?? null; touchStartY = event.touches[0]?.clientY ?? null;
}; };
const handleTouchMove = (event: TouchEvent) => { const handleTouchMove = (event: TouchEvent) => {
const currentY = event.touches[0]?.clientY; const currentY = event.touches[0]?.clientY;
const scrollDeltaY = const scrollDeltaY =
lastTouchY !== null && currentY !== undefined touchStartY !== null && currentY !== undefined
? lastTouchY - currentY ? touchStartY - currentY
: 0; : 0;
lastTouchY = currentY ?? null;
handleDirectionalInput(directionFromDelta(scrollDeltaY)); handleDirectionalInput(directionFromDelta(scrollDeltaY));
}; };
const handleTouchEnd = () => { const handleTouchEnd = () => {
lastTouchY = null; touchStartY = null;
}; };
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ( if (
+5 -34
View File
@@ -168,9 +168,6 @@ export class ThreadMotionCoordinator {
private measurementFrameId: number | null = null; private measurementFrameId: number | null = null;
private geometryDirty = false; private geometryDirty = false;
private composerInputDuringTurn = 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) { constructor(options: ThreadMotionCoordinatorOptions) {
this.camera = options.camera; this.camera = options.camera;
@@ -201,7 +198,6 @@ export class ThreadMotionCoordinator {
if (isNewTurn) { if (isNewTurn) {
this.camera.cancel(); this.camera.cancel();
this.composerInputDuringTurn = false; this.composerInputDuringTurn = false;
this.resumeFollowArmed = false;
this.promptPositioned = turn.entry === "restored"; this.promptPositioned = turn.entry === "restored";
this.mode = this.promptPositioned && turn.hasOutput this.mode = this.promptPositioned && turn.hasOutput
? "follow-output" ? "follow-output"
@@ -253,31 +249,15 @@ export class ThreadMotionCoordinator {
this.handleUserScrollIntent(true); this.handleUserScrollIntent(true);
} }
handleUserScrollIntent(canScroll: boolean, towardLatest = false): void { handleUserScrollIntent(canScroll: boolean): void {
if (this.mode === "browsing-history" && towardLatest && !canScroll) {
this.transitionToAutoFollow(false);
return;
}
const event = canScroll ? "user-scroll" : "boundary-scroll"; const event = canScroll ? "user-scroll" : "boundary-scroll";
const transitioned = this.transition(event); if (!this.transition(event)) return;
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(); this.camera.cancel();
} }
resumeAutoFollow(): void { resumeAutoFollow(): void {
this.transitionToAutoFollow(true);
}
private transitionToAutoFollow(cancelCamera: boolean): void {
if (!this.transition("resume-follow")) return; if (!this.transition("resume-follow")) return;
this.resumeFollowArmed = false; this.camera.cancel();
if (cancelCamera) this.camera.cancel();
this.onAutoFollow?.();
this.invalidateGeometry(); this.invalidateGeometry();
} }
@@ -337,19 +317,11 @@ export class ThreadMotionCoordinator {
case "navigating-history": case "navigating-history":
if (!this.camera.isFollowing()) { if (!this.camera.isFollowing()) {
this.transition("navigation-settled"); this.transition("navigation-settled");
if (nearBottom) { if (nearBottom) this.resumeAutoFollow();
this.resumeAutoFollow();
} else {
this.resumeFollowArmed = true;
}
} }
return "navigation"; return "navigation";
case "browsing-history": case "browsing-history":
if (!nearBottom) { if (!nearBottom) return "user";
this.resumeFollowArmed = true;
return "user";
}
if (!this.resumeFollowArmed) return "user";
this.resumeAutoFollow(); this.resumeAutoFollow();
return "automatic"; return "automatic";
default: default:
@@ -367,7 +339,6 @@ export class ThreadMotionCoordinator {
this.camera.cancel(); this.camera.cancel();
this.turn = { id: null, promptId: null, hasOutput: false }; this.turn = { id: null, promptId: null, hasOutput: false };
this.composerInputDuringTurn = false; this.composerInputDuringTurn = false;
this.resumeFollowArmed = false;
this.mode = "idle"; this.mode = "idle";
this.promptPositioned = false; this.promptPositioned = false;
} }
+41 -5
View File
@@ -738,14 +738,54 @@
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent); mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent);
} }
.thread-composer-model-badge:active > .composer-model-pill { .thread-composer-model-badge:not([data-switching="true"]):active
> .composer-model-pill {
transform: scale(0.98); 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) { @media (prefers-reduced-motion: reduce) {
.thread-composer-model-badge:active > .composer-model-pill { .thread-composer-model-badge:active > .composer-model-pill {
transform: none !important; 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) { @container thread-composer (max-width: 21rem) {
@@ -798,10 +838,6 @@
.thread-composer-model-label { .thread-composer-model-label {
display: none; display: none;
} }
.thread-composer-model-chevron {
display: none;
}
} }
@container thread-composer (max-width: 16rem) { @container thread-composer (max-width: 16rem) {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "Sidebar navigation", "navigation": "Sidebar navigation",
"collapse": "Collapse sidebar", "collapse": "Collapse sidebar",
"quickChat": "Quick Chat",
"newChat": "New topic", "newChat": "New topic",
"searchAria": "Search", "searchAria": "Search",
"searchPlaceholder": "Search", "searchPlaceholder": "Search",
@@ -60,6 +61,9 @@
"title": "Skills" "title": "Skills"
} }
}, },
"quickChat": {
"greeting": "What's on your mind?"
},
"settings": { "settings": {
"backToChat": "Back to chat", "backToChat": "Back to chat",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "Navegación de la barra lateral", "navigation": "Navegación de la barra lateral",
"collapse": "Contraer barra lateral", "collapse": "Contraer barra lateral",
"quickChat": "Chat rápido",
"newChat": "Nuevo tema", "newChat": "Nuevo tema",
"searchAria": "Buscar", "searchAria": "Buscar",
"searchPlaceholder": "Buscar", "searchPlaceholder": "Buscar",
@@ -60,6 +61,9 @@
"title": "Habilidades" "title": "Habilidades"
} }
}, },
"quickChat": {
"greeting": "¿Qué tienes en mente?"
},
"settings": { "settings": {
"backToChat": "Volver al chat", "backToChat": "Volver al chat",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "Navigation de la barre latérale", "navigation": "Navigation de la barre latérale",
"collapse": "Réduire la barre latérale", "collapse": "Réduire la barre latérale",
"quickChat": "Discussion rapide",
"newChat": "Nouveau sujet", "newChat": "Nouveau sujet",
"searchAria": "Rechercher", "searchAria": "Rechercher",
"searchPlaceholder": "Rechercher", "searchPlaceholder": "Rechercher",
@@ -60,6 +61,9 @@
"title": "Compétences" "title": "Compétences"
} }
}, },
"quickChat": {
"greeting": "De quoi avez-vous envie de parler ?"
},
"settings": { "settings": {
"backToChat": "Retour au chat", "backToChat": "Retour au chat",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "Navigasi bilah samping", "navigation": "Navigasi bilah samping",
"collapse": "Ciutkan sidebar", "collapse": "Ciutkan sidebar",
"quickChat": "Obrolan cepat",
"newChat": "Topik baru", "newChat": "Topik baru",
"searchAria": "Cari", "searchAria": "Cari",
"searchPlaceholder": "Cari", "searchPlaceholder": "Cari",
@@ -60,6 +61,9 @@
"title": "Skill" "title": "Skill"
} }
}, },
"quickChat": {
"greeting": "Apa yang sedang kamu pikirkan?"
},
"settings": { "settings": {
"backToChat": "Kembali ke chat", "backToChat": "Kembali ke chat",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "サイドバーのナビゲーション", "navigation": "サイドバーのナビゲーション",
"collapse": "サイドバーを閉じる", "collapse": "サイドバーを閉じる",
"quickChat": "クイックチャット",
"newChat": "新しいトピック", "newChat": "新しいトピック",
"searchAria": "検索", "searchAria": "検索",
"searchPlaceholder": "検索", "searchPlaceholder": "検索",
@@ -60,6 +61,9 @@
"title": "スキル" "title": "スキル"
} }
}, },
"quickChat": {
"greeting": "何について話しますか?"
},
"settings": { "settings": {
"backToChat": "チャットに戻る", "backToChat": "チャットに戻る",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "사이드바 탐색", "navigation": "사이드바 탐색",
"collapse": "사이드바 접기", "collapse": "사이드바 접기",
"quickChat": "빠른 채팅",
"newChat": "새 주제", "newChat": "새 주제",
"searchAria": "검색", "searchAria": "검색",
"searchPlaceholder": "검색", "searchPlaceholder": "검색",
@@ -60,6 +61,9 @@
"title": "스킬" "title": "스킬"
} }
}, },
"quickChat": {
"greeting": "무슨 이야기를 나눠볼까요?"
},
"settings": { "settings": {
"backToChat": "채팅으로 돌아가기", "backToChat": "채팅으로 돌아가기",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "Navegação da barra lateral", "navigation": "Navegação da barra lateral",
"collapse": "Recolher barra lateral", "collapse": "Recolher barra lateral",
"quickChat": "Chat rápido",
"newChat": "Novo tópico", "newChat": "Novo tópico",
"searchAria": "Buscar", "searchAria": "Buscar",
"searchPlaceholder": "Buscar", "searchPlaceholder": "Buscar",
@@ -60,6 +61,9 @@
"title": "Skills" "title": "Skills"
} }
}, },
"quickChat": {
"greeting": "O que você está pensando?"
},
"settings": { "settings": {
"backToChat": "Voltar para a conversa", "backToChat": "Voltar para a conversa",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "Điều hướng thanh bên", "navigation": "Điều hướng thanh bên",
"collapse": "Thu gọn thanh bên", "collapse": "Thu gọn thanh bên",
"quickChat": "Trò chuyện nhanh",
"newChat": "Chủ đề mới", "newChat": "Chủ đề mới",
"searchAria": "Tìm kiếm", "searchAria": "Tìm kiếm",
"searchPlaceholder": "Tìm kiếm", "searchPlaceholder": "Tìm kiếm",
@@ -60,6 +61,9 @@
"title": "Kỹ năng" "title": "Kỹ năng"
} }
}, },
"quickChat": {
"greeting": "Bạn đang nghĩ gì?"
},
"settings": { "settings": {
"backToChat": "Quay lại chat", "backToChat": "Quay lại chat",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "侧边栏导航", "navigation": "侧边栏导航",
"collapse": "收起侧边栏", "collapse": "收起侧边栏",
"quickChat": "随便聊聊",
"newChat": "新建话题", "newChat": "新建话题",
"searchAria": "搜索", "searchAria": "搜索",
"searchPlaceholder": "搜索", "searchPlaceholder": "搜索",
@@ -60,6 +61,9 @@
"title": "技能" "title": "技能"
} }
}, },
"quickChat": {
"greeting": "想聊点什么?"
},
"settings": { "settings": {
"backToChat": "返回聊天", "backToChat": "返回聊天",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "側邊欄導覽", "navigation": "側邊欄導覽",
"collapse": "收合側邊欄", "collapse": "收合側邊欄",
"quickChat": "輕鬆聊聊",
"newChat": "新增話題", "newChat": "新增話題",
"searchAria": "搜尋", "searchAria": "搜尋",
"searchPlaceholder": "搜尋", "searchPlaceholder": "搜尋",
@@ -60,6 +61,9 @@
"title": "技能" "title": "技能"
} }
}, },
"quickChat": {
"greeting": "想聊點什麼?"
},
"settings": { "settings": {
"backToChat": "返回聊天", "backToChat": "返回聊天",
"sidebar": { "sidebar": {
+22
View File
@@ -0,0 +1,22 @@
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,
};
}
+137 -2
View File
@@ -349,6 +349,107 @@ describe("App layout", () => {
).toBeTruthy(); ).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",
});
const newTopicButton = within(sidebar).getByRole("button", {
name: "New topic",
});
const actionHighlight = within(sidebar).getByTestId(
"actions-selection-highlight",
);
fireEvent.click(quickChatButton);
expect(window.location.hash).toBe("#/quick-chat");
expect(quickChatButton).toHaveAttribute("aria-current", "page");
expect(newTopicButton).not.toHaveAttribute("aria-current");
expect(quickChatButton).not.toHaveClass("bg-sidebar-accent");
expect(quickChatButton).toHaveClass("transition-[width,padding,color]");
expect(actionHighlight).toHaveAttribute("data-active-id", "quick-chat");
expect(
within(sidebar).queryByTestId("actions-selection-highlight-surface"),
).not.toBeInTheDocument();
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();
fireEvent.click(newTopicButton);
expect(window.location.hash).toBe("#/new");
expect(newTopicButton).toHaveAttribute("aria-current", "page");
expect(quickChatButton).not.toHaveAttribute("aria-current");
expect(actionHighlight).toHaveAttribute("data-active-id", "new-chat");
expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1);
});
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 () => { it("restores the Settings route after a restart fallback hash", async () => {
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now())); localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels"); localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
@@ -2128,16 +2229,41 @@ describe("App layout", () => {
expect(window.location.hash).toBe("#/settings"); expect(window.location.hash).toBe("#/settings");
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" }); const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" })); const overviewButton = within(settingsNav).getByRole("button", {
name: "Overview",
exact: true,
});
const modelsButton = within(settingsNav).getByRole("button", {
name: "Models",
exact: true,
});
const settingsHighlight = within(settingsNav).getByTestId(
"settings-selection-highlight",
);
expect(overviewButton).toHaveAttribute("aria-current", "page");
expect(overviewButton).not.toHaveClass("bg-sidebar-accent");
expect(overviewButton).toHaveClass("transition-[color]");
expect(settingsHighlight).toHaveAttribute("data-active-id", "overview");
fireEvent.click(modelsButton);
expect(await screen.findByText("Model presets")).toBeInTheDocument(); expect(await screen.findByText("Model presets")).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Models" })).not.toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Models" })).not.toBeInTheDocument();
expect(window.location.hash).toBe("#/settings?section=models"); expect(window.location.hash).toBe("#/settings?section=models");
expect(modelsButton).toHaveAttribute("aria-current", "page");
expect(settingsHighlight).toHaveAttribute("data-active-id", "models");
fireEvent.click(within(settingsNav).getByRole("button", { name: "Voice" })); const voiceButton = within(settingsNav).getByRole("button", {
name: "Voice",
exact: true,
});
fireEvent.click(voiceButton);
expect(await screen.findByRole("heading", { name: "Voice input" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "Voice input" })).toBeInTheDocument();
expect(window.location.hash).toBe("#/settings?section=voice"); expect(window.location.hash).toBe("#/settings?section=voice");
expect(voiceButton).toHaveAttribute("aria-current", "page");
expect(settingsHighlight).toHaveAttribute("data-active-id", "voice");
}); });
it("transitions between Apps and Skills without replacing the sidebar", async () => { it("transitions between Apps and Skills without replacing the sidebar", async () => {
@@ -2163,6 +2289,11 @@ describe("App layout", () => {
"aria-current", "aria-current",
"page", "page",
); );
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
"data-active-id",
"utility:apps",
);
expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1);
expect(screen.getByTestId("settings-section-transition")).toHaveAttribute( expect(screen.getByTestId("settings-section-transition")).toHaveAttribute(
"data-settings-section", "data-settings-section",
"apps", "apps",
@@ -2190,6 +2321,10 @@ describe("App layout", () => {
"aria-current", "aria-current",
"page", "page",
); );
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
"data-active-id",
"utility:skills",
);
expect(document.title).toBe("Skills · nanobot"); expect(document.title).toBe("Skills · nanobot");
}); });
+10 -12
View File
@@ -220,7 +220,7 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument(); expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
}); });
it("floats a borderless highlight in, then slides it between selected topics", () => { it("positions one background highlight, then slides it between selected topics", () => {
let revealFrame: FrameRequestCallback | null = null; let revealFrame: FrameRequestCallback | null = null;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
revealFrame = callback; revealFrame = callback;
@@ -259,14 +259,15 @@ describe("ChatList", () => {
/>, />,
); );
const highlight = screen.getByTestId("active-chat-highlight"); const highlight = screen.getByTestId("sessions-selection-highlight");
const surface = screen.getByTestId("active-chat-highlight-surface"); expect(highlight).toHaveClass(
expect(surface).toHaveClass(
"bg-sidebar-foreground/[0.055]", "bg-sidebar-foreground/[0.055]",
"transition-[opacity,transform]", "transition-[transform,width,height]",
"motion-reduce:transition-none", "motion-reduce:transition-none",
); );
expect(surface).toHaveStyle("opacity: 0; transform: scale(0.97)"); expect(highlight).toHaveStyle("opacity: 0");
expect(screen.queryByTestId("sessions-selection-highlight-surface"))
.not.toBeInTheDocument();
rerender( rerender(
<ChatList <ChatList
@@ -277,18 +278,15 @@ describe("ChatList", () => {
const activeButton = screen.getByTitle("Active topic"); const activeButton = screen.getByTitle("Active topic");
expect(activeButton).toHaveAttribute("aria-current", "page"); expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.parentElement).toHaveClass("transition-[color]");
expect(activeButton.parentElement).not.toHaveClass("transition-colors");
expect(activeButton.parentElement).not.toHaveClass( expect(activeButton.parentElement).not.toHaveClass(
"bg-sidebar-accent", "bg-sidebar-accent",
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]", "shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
); );
expect(highlight).toHaveClass(
"transition-[transform,width,height]",
"motion-reduce:transition-none",
);
expect(highlight).toHaveStyle( expect(highlight).toHaveStyle(
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); transition-property: none", "width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
); );
expect(surface).toHaveStyle("opacity: 1; transform: scale(1)");
revealFrame?.(0); revealFrame?.(0);
expect(highlight.style.transitionProperty).toBe(""); expect(highlight.style.transitionProperty).toBe("");
+37
View File
@@ -0,0 +1,37 @@
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",
});
});
});
+97 -20
View File
@@ -1,5 +1,4 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; 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 { afterEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
@@ -314,11 +313,28 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") {
/>, />,
); );
return { return {
badge: screen.getByRole("button", { name: "Kimi" }), badge: screen.getByRole("spinbutton", { name: "Kimi" }),
onPresetChange, 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", () => { describe("ThreadComposer", () => {
it("focuses and sends a removable quoted answer excerpt", async () => { it("focuses and sends a removable quoted answer excerpt", async () => {
const onSend = vi.fn(); const onSend = vi.fn();
@@ -412,7 +428,7 @@ describe("ThreadComposer", () => {
/>, />,
); );
const badge = screen.getByRole("button", { name: "gpt-5.6-sol" }); const badge = screen.getByRole("spinbutton", { name: "gpt-5.6-sol" });
expect(badge).toHaveClass("w-fit", "max-w-[min(18rem,44vw)]"); expect(badge).toHaveClass("w-fit", "max-w-[min(18rem,44vw)]");
expect(badge).not.toHaveClass("w-[5.75rem]"); expect(badge).not.toHaveClass("w-[5.75rem]");
expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument(); expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument();
@@ -445,32 +461,93 @@ describe("ThreadComposer", () => {
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument(); expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
}); });
it("opens a preset menu on click and switches the selected preset", async () => { it("scrolls complete preset pills after a left-button long press and wraps", () => {
const user = userEvent.setup(); vi.useFakeTimers();
const { badge, onPresetChange } = renderPresetComposer(); const { badge, onPresetChange } = renderPresetComposer();
expect(badge).toHaveClass("h-9"); expect(badge).toHaveClass("h-9");
expect(badge).toHaveAttribute("aria-haspopup", "menu"); expect(badge).toHaveStyle({ touchAction: "manipulation" });
expect(badge).toHaveAttribute("aria-expanded", "false"); 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();
await user.click(badge); 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",
});
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(onPresetChange).toHaveBeenCalledWith("dspro");
expect(screen.queryByRole("menu")).not.toBeInTheDocument(); 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");
}); });
it("supports the same preset menu in hero mode", async () => { it("supports the same long-press switcher in hero mode and cancels pointercancel", () => {
const user = userEvent.setup(); vi.useFakeTimers();
const { badge, onPresetChange } = renderPresetComposer("hero"); const { badge, onPresetChange } = renderPresetComposer("hero");
expect(badge).toHaveClass("h-8"); expect(badge).toHaveClass("h-8");
await user.click(badge); longPress(badge, 9);
await user.click(screen.getByRole("menuitemradio", { name: /DFlash.*deepseek/i })); expect(badge).toHaveAttribute("data-switching", "true");
expect(onPresetChange).toHaveBeenCalledWith("dflash"); 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();
}); });
it("transcribes voice input into the composer without sending", async () => { it("transcribes voice input into the composer without sending", async () => {
-54
View File
@@ -410,9 +410,6 @@ describe("ThreadMotionCoordinator", () => {
expect(camera.jumpTo).toHaveBeenCalledWith(780); expect(camera.jumpTo).toHaveBeenCalledWith(780);
coordinator.takeUserControl(); coordinator.takeUserControl();
expect(coordinator.observeScroll(true)).toBe("user");
expect(coordinator.snapshot().mode).toBe("browsing-history");
expect(coordinator.observeScroll(false)).toBe("user"); expect(coordinator.observeScroll(false)).toBe("user");
expect(coordinator.snapshot().mode).toBe("browsing-history"); expect(coordinator.snapshot().mode).toBe("browsing-history");
@@ -420,57 +417,6 @@ describe("ThreadMotionCoordinator", () => {
expect(coordinator.snapshot().mode).toBe("anchor-prompt"); 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", () => { it("preserves history browsing when an active turn is cleared", () => {
const { const {
camera, camera,
+78 -7
View File
@@ -586,18 +586,19 @@ describe("ThreadShell", () => {
)); ));
const { rerender } = render(view("default")); const { rerender } = render(view("default"));
const badge = await screen.findByRole("button", { name: "Default" }); const badge = await screen.findByRole("spinbutton", { name: "Default" });
expect(badge).toHaveTextContent("Default"); expect(badge).toHaveTextContent("Default");
fireEvent.pointerDown(badge); fireEvent.keyDown(badge, { key: "ArrowDown" });
fireEvent.click(await screen.findByRole("menuitemradio", { name: /^Fast/ }));
expect(client.sendSystemCommand).toHaveBeenCalledWith( expect(client.sendSystemCommand).toHaveBeenCalledWith(
"preset-order", "preset-order",
"/model fast", "/model fast",
); );
expect(await screen.findByText("Fast")).toBeInTheDocument(); expect(await screen.findByText("Fast")).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "Fast" })); fireEvent.keyDown(
fireEvent.click(await screen.findByRole("menuitemradio", { name: /^Extra/ })); screen.getByRole("spinbutton", { name: "Fast" }),
{ key: "End" },
);
expect(client.sendSystemCommand).toHaveBeenLastCalledWith( expect(client.sendSystemCommand).toHaveBeenLastCalledWith(
"preset-order", "preset-order",
"/model extra", "/model extra",
@@ -971,8 +972,10 @@ describe("ThreadShell", () => {
)); ));
const { rerender } = render(view(null)); const { rerender } = render(view(null));
fireEvent.pointerDown(await screen.findByRole("button", { name: "Default" })); fireEvent.keyDown(
fireEvent.click(await screen.findByRole("menuitemradio", { name: /^Fast/ })); await screen.findByRole("spinbutton", { name: "Default" }),
{ key: "ArrowDown" },
);
expect(await screen.findByText("Fast")).toBeInTheDocument(); expect(await screen.findByText("Fast")).toBeInTheDocument();
expect(client.sendSystemCommand).not.toHaveBeenCalled(); expect(client.sendSystemCommand).not.toHaveBeenCalled();
@@ -3366,6 +3369,74 @@ describe("ThreadShell", () => {
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument(); 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 () => { it("does not bring back welcome cards when image mode is enabled", async () => {
const client = makeClient(); const client = makeClient();
const settings = modelSettings("deepseek-v4-pro", "deepseek"); const settings = modelSettings("deepseek-v4-pro", "deepseek");
-95
View File
@@ -763,101 +763,6 @@ 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 () => { it("keeps the scroll-to-bottom button above a growing composer", async () => {
const resizeObserver = stubResizeObserver(); const resizeObserver = stubResizeObserver();