mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
993322dd0b |
+13
-6
@@ -425,7 +425,7 @@ class AgentRunner:
|
||||
) -> AgentRunResult:
|
||||
final_content: str | None = None
|
||||
tools_used: list[str] = []
|
||||
usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0}
|
||||
usage = {"prompt_tokens": 0, "completion_tokens": 0}
|
||||
error: str | None = None
|
||||
stop_reason = "completed"
|
||||
tool_events: list[dict[str, str]] = []
|
||||
@@ -1384,11 +1384,6 @@ class AgentRunner:
|
||||
usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _accumulate_usage(target: dict[str, int], addition: dict[str, int]) -> None:
|
||||
for key, value in addition.items():
|
||||
target[key] = target.get(key, 0) + value
|
||||
|
||||
@staticmethod
|
||||
def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]:
|
||||
merged = dict(left)
|
||||
@@ -1396,6 +1391,18 @@ class AgentRunner:
|
||||
merged[key] = merged.get(key, 0) + value
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def _accumulate_usage(total: dict[str, int], request: dict[str, int]) -> None:
|
||||
"""Fold one model request into the current turn's usage."""
|
||||
total["request_count"] = total.get("request_count", 0) + 1
|
||||
prompt_tokens = request.get("prompt_tokens")
|
||||
if prompt_tokens is not None and prompt_tokens >= 0:
|
||||
total["context_tokens"] = prompt_tokens
|
||||
for key, value in request.items():
|
||||
if key in {"context_tokens", "request_count"} or value < 0:
|
||||
continue
|
||||
total[key] = total.get(key, 0) + value
|
||||
|
||||
async def _execute_tools(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
|
||||
@@ -100,6 +100,7 @@ class TurnModelUpdatedEvent(OutboundEvent):
|
||||
model: str
|
||||
model_preset: str | None = None
|
||||
context_window_tokens: int | None = None
|
||||
fallback: bool = False
|
||||
|
||||
|
||||
def outbound_message_for_event(
|
||||
|
||||
@@ -426,6 +426,7 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
|
||||
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||
self._reasoning_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||
|
||||
# -- Subscription bookkeeping -------------------------------------------
|
||||
|
||||
@@ -482,6 +483,9 @@ class WebSocketChannel(BaseChannel):
|
||||
for key in tuple(self._stream_text_buffers):
|
||||
if key[0] == chat_id:
|
||||
self._stream_text_buffers.pop(key, None)
|
||||
for key in tuple(self._reasoning_text_buffers):
|
||||
if key[0] == chat_id:
|
||||
self._reasoning_text_buffers.pop(key, None)
|
||||
|
||||
async def _discard_connection_owned_chat(
|
||||
self,
|
||||
@@ -1641,11 +1645,22 @@ class WebSocketChannel(BaseChannel):
|
||||
include_source=include_source,
|
||||
transcript_overrides=transcript_overrides,
|
||||
)
|
||||
if (
|
||||
not persisted
|
||||
and phase in {"answer", "complete"}
|
||||
and (metadata or {}).get("webui") is True
|
||||
):
|
||||
return self._retain_turn_on_transcript_failure(
|
||||
chat_id,
|
||||
persisted=persisted,
|
||||
metadata=metadata,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _retain_turn_on_transcript_failure(
|
||||
chat_id: str,
|
||||
*,
|
||||
persisted: bool,
|
||||
metadata: dict[str, Any] | None,
|
||||
phase: str,
|
||||
) -> bool:
|
||||
if not persisted and phase in {"answer", "complete"} and (metadata or {}).get("webui") is True:
|
||||
owner = (metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||
mark_websocket_turn_transcript_persistence_failed(
|
||||
chat_id,
|
||||
@@ -1653,6 +1668,34 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
return persisted
|
||||
|
||||
def _persist_turn_stream_event(
|
||||
self,
|
||||
chat_id: str,
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
completed_text: str | None,
|
||||
metadata: dict[str, Any] | None,
|
||||
phase: str,
|
||||
include_source: bool = False,
|
||||
) -> bool:
|
||||
"""Persist the canonical end of a live stream, never its wire chunks."""
|
||||
if not self._temporary_chats.should_persist_transcript(chat_id):
|
||||
return True
|
||||
persisted = self._transcripts.prepare_and_append_stream_event(
|
||||
chat_id,
|
||||
event,
|
||||
completed_text=completed_text,
|
||||
metadata=metadata,
|
||||
phase=phase,
|
||||
include_source=include_source,
|
||||
)
|
||||
return self._retain_turn_on_transcript_failure(
|
||||
chat_id,
|
||||
persisted=persisted,
|
||||
metadata=metadata,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
event = outbound_event_from_message(msg)
|
||||
progress_event = event if isinstance(event, ProgressEvent) else None
|
||||
@@ -1685,6 +1728,7 @@ class WebSocketChannel(BaseChannel):
|
||||
model_name=event.model,
|
||||
model_preset=event.model_preset,
|
||||
context_window_tokens=event.context_window_tokens,
|
||||
fallback=event.fallback,
|
||||
)
|
||||
return
|
||||
if isinstance(event, UserInputEvent):
|
||||
@@ -1834,9 +1878,12 @@ class WebSocketChannel(BaseChannel):
|
||||
}
|
||||
if stream_id is not None:
|
||||
body["stream_id"] = stream_id
|
||||
self._persist_turn_transcript_event(
|
||||
stream_key = (chat_id, str(stream_id or ""))
|
||||
self._reasoning_text_buffers.setdefault(stream_key, []).append(delta)
|
||||
self._persist_turn_stream_event(
|
||||
chat_id,
|
||||
body,
|
||||
completed_text=None,
|
||||
metadata=meta,
|
||||
phase="reasoning",
|
||||
)
|
||||
@@ -1862,9 +1909,12 @@ class WebSocketChannel(BaseChannel):
|
||||
}
|
||||
if stream_id is not None:
|
||||
body["stream_id"] = stream_id
|
||||
self._persist_turn_transcript_event(
|
||||
stream_key = (chat_id, str(stream_id or ""))
|
||||
reasoning_text = "".join(self._reasoning_text_buffers.pop(stream_key, []))
|
||||
self._persist_turn_stream_event(
|
||||
chat_id,
|
||||
body,
|
||||
completed_text=reasoning_text or None,
|
||||
metadata=meta,
|
||||
phase="reasoning",
|
||||
)
|
||||
@@ -1912,6 +1962,7 @@ class WebSocketChannel(BaseChannel):
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
meta = metadata or {}
|
||||
stream_key = (chat_id, str(stream_id or ""))
|
||||
completed_text: str | None = None
|
||||
if stream_end:
|
||||
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
||||
buffered = (
|
||||
@@ -1923,6 +1974,7 @@ class WebSocketChannel(BaseChannel):
|
||||
buffered.append(delta)
|
||||
full_text = "".join(buffered)
|
||||
rewritten = self._media.rewrite_local_markdown_images(full_text)
|
||||
completed_text = rewritten
|
||||
if delta or rewritten != full_text:
|
||||
body["text"] = rewritten
|
||||
else:
|
||||
@@ -1938,9 +1990,10 @@ class WebSocketChannel(BaseChannel):
|
||||
body["resuming"] = True
|
||||
if stream_end and merge_next:
|
||||
body["merge_next"] = True
|
||||
self._persist_turn_transcript_event(
|
||||
self._persist_turn_stream_event(
|
||||
chat_id,
|
||||
body,
|
||||
completed_text=completed_text,
|
||||
metadata=meta,
|
||||
phase="answer",
|
||||
include_source=True,
|
||||
@@ -1997,6 +2050,7 @@ class WebSocketChannel(BaseChannel):
|
||||
# carries a durable incomplete marker. The HTTP replay path can
|
||||
# recover the latter from session history after a gateway restart.
|
||||
clear_websocket_turn_if_current(chat_id, turn_owner)
|
||||
self._clear_stream_buffers(chat_id)
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
if not conns:
|
||||
return
|
||||
@@ -2102,6 +2156,7 @@ class WebSocketChannel(BaseChannel):
|
||||
model_name: Any,
|
||||
model_preset: Any = None,
|
||||
context_window_tokens: Any = None,
|
||||
fallback: bool = False,
|
||||
) -> None:
|
||||
"""Notify one chat's subscribers which model is handling its current request."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
@@ -2120,6 +2175,8 @@ class WebSocketChannel(BaseChannel):
|
||||
body["model_preset"] = model_preset.strip()
|
||||
if isinstance(context_window_tokens, int) and context_window_tokens > 0:
|
||||
body["context_window_tokens"] = context_window_tokens
|
||||
if fallback:
|
||||
body["fallback"] = True
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" turn_model_updated ")
|
||||
|
||||
@@ -2073,6 +2073,21 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
||||
"model_preset": "Deep Research",
|
||||
"context_window_tokens": 128_000,
|
||||
}
|
||||
|
||||
await channel.send(
|
||||
OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="",
|
||||
event=TurnModelUpdatedEvent(
|
||||
model="deepseek/deepseek-chat",
|
||||
model_preset="Deep Research",
|
||||
fallback=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
fallback_payload = json.loads(chat_one.send.call_args.args[0])
|
||||
assert fallback_payload["fallback"] is True
|
||||
chat_two.send.assert_not_awaited()
|
||||
|
||||
|
||||
@@ -2348,8 +2363,9 @@ async def test_send_delta_preserves_webui_source_metadata() -> None:
|
||||
assert second["event"] == "stream_end"
|
||||
assert second["source"] == source
|
||||
lines = read_transcript_lines("websocket:chat-source-stream")
|
||||
assert lines[-2]["source"] == source
|
||||
assert lines[-1]["source"] == source
|
||||
assert lines[-1]["event"] == "stream_end"
|
||||
assert lines[-1]["text"] == "done"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2374,6 +2390,8 @@ async def test_send_delta_marks_resuming_stream_end() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
|
||||
from nanobot.webui.transcript import build_webui_thread_response, read_transcript_lines
|
||||
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||
@@ -2403,6 +2421,12 @@ async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
|
||||
"second",
|
||||
]
|
||||
assert ("chat-1", "sid") not in channel._stream_text_buffers
|
||||
lines = read_transcript_lines("websocket:chat-1")
|
||||
assert [line["event"] for line in lines] == ["stream_end", "stream_end"]
|
||||
assert [line["text"] for line in lines] == ["first ", "first second"]
|
||||
body = build_webui_thread_response("websocket:chat-1")
|
||||
assert body is not None
|
||||
assert body["messages"][-1]["content"] == "first second"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -2596,7 +2620,8 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
|
||||
|
||||
assert channel._subs == {}
|
||||
lines = read_transcript_lines("websocket:chat-1")
|
||||
assert [line["event"] for line in lines] == ["delta", "delta", "stream_end", "turn_end"]
|
||||
assert [line["event"] for line in lines] == ["stream_end", "turn_end"]
|
||||
assert lines[0]["text"] == "hello world"
|
||||
body = build_webui_thread_response("websocket:chat-1")
|
||||
assert body is not None
|
||||
assert body["messages"][-1]["role"] == "assistant"
|
||||
@@ -2604,6 +2629,77 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
|
||||
assert body["messages"][-1]["latencyMs"] == 42
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_transcript_writes_once_per_completed_segment(monkeypatch) -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
append = MagicMock()
|
||||
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", append)
|
||||
|
||||
await channel.send_delta("chat-write-rate", "one", stream_id="s1")
|
||||
await channel.send_delta("chat-write-rate", " two", stream_id="s1")
|
||||
await channel.send_delta("chat-write-rate", " three", stream_id="s1")
|
||||
|
||||
append.assert_not_called()
|
||||
|
||||
await channel.send_delta("chat-write-rate", "", stream_id="s1", stream_end=True)
|
||||
|
||||
append.assert_called_once()
|
||||
persisted = append.call_args.args[1]
|
||||
assert persisted["event"] == "stream_end"
|
||||
assert persisted["text"] == "one two three"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_transcript_persists_one_canonical_record(monkeypatch) -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
append = MagicMock()
|
||||
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", append)
|
||||
|
||||
await channel.send_reasoning_delta("chat-reasoning-write-rate", "plan ", stream_id="r1")
|
||||
await channel.send_reasoning_delta("chat-reasoning-write-rate", "then act", stream_id="r1")
|
||||
|
||||
append.assert_not_called()
|
||||
|
||||
await channel.send_reasoning_end("chat-reasoning-write-rate", stream_id="r1")
|
||||
|
||||
append.assert_called_once()
|
||||
persisted = append.call_args.args[1]
|
||||
assert persisted["event"] == "reasoning_end"
|
||||
assert persisted["text"] == "plan then act"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_end_discards_unclosed_stream_buffers() -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"], "streaming": True},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
|
||||
await channel.send_delta("chat-unclosed", "partial", stream_id="s1")
|
||||
await channel.send_reasoning_delta("chat-unclosed", "thinking", stream_id="r1")
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-unclosed",
|
||||
content="",
|
||||
event=TurnEndEvent(),
|
||||
))
|
||||
|
||||
assert channel._stream_text_buffers == {}
|
||||
assert channel._reasoning_text_buffers == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_turn_end_emits_turn_end_event() -> None:
|
||||
bus = MagicMock()
|
||||
|
||||
@@ -509,8 +509,6 @@ class FallbackProvider(LLMProvider):
|
||||
)
|
||||
continue
|
||||
|
||||
await self._notify_fallback_model(fallback_model)
|
||||
|
||||
fallback_kwargs = {
|
||||
**kwargs,
|
||||
"model": fallback_model,
|
||||
@@ -541,6 +539,11 @@ class FallbackProvider(LLMProvider):
|
||||
fallback_response = await call(fallback_provider, fallback_kwargs)
|
||||
|
||||
if fallback_response.finish_reason != "error":
|
||||
# Do not publish a model switch merely because a fallback was
|
||||
# attempted. A fallback can fail just like the primary, and
|
||||
# the WebUI would otherwise show a misleading success signal.
|
||||
# Publish only after this response is known to be usable.
|
||||
await self._notify_fallback_model(fallback_model)
|
||||
logger.info(
|
||||
"Fallback '{}' succeeded after primary '{}' failed",
|
||||
fallback_model, primary_model,
|
||||
|
||||
@@ -251,6 +251,26 @@ def _refusal_event_key(
|
||||
)
|
||||
|
||||
|
||||
def _reasoning_summary_event_key(
|
||||
item_id: object,
|
||||
summary_index: object,
|
||||
) -> tuple[str | None, int] | None:
|
||||
"""Identify one reasoning summary part across its text deltas."""
|
||||
if not isinstance(summary_index, int) or isinstance(summary_index, bool):
|
||||
return None
|
||||
return (
|
||||
item_id if isinstance(item_id, str) else None,
|
||||
summary_index,
|
||||
)
|
||||
|
||||
|
||||
def _separate_reasoning_part(content: str | None, part: str) -> str:
|
||||
"""Separate summary parts only when the provider supplied no whitespace."""
|
||||
if content and not content[-1].isspace() and not part[0].isspace():
|
||||
return "\n" + part
|
||||
return part
|
||||
|
||||
|
||||
def _remaining_refusal_text(streamed_text: str, refusal_text: str) -> str:
|
||||
"""Return only text not already surfaced by refusal deltas."""
|
||||
if not streamed_text:
|
||||
@@ -342,6 +362,7 @@ async def consume_sse_with_reasoning(
|
||||
usage: dict[str, int] = {}
|
||||
reasoning_content: str | None = None
|
||||
streamed_reasoning = False
|
||||
reasoning_summary_key: tuple[str | None, int] | None = None
|
||||
refusal_seen = False
|
||||
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
|
||||
emitted_refusal_text = ""
|
||||
@@ -406,6 +427,18 @@ async def consume_sse_with_reasoning(
|
||||
elif event_type == "response.reasoning_summary_text.delta":
|
||||
delta_text = event.get("delta") or ""
|
||||
if delta_text:
|
||||
summary_key = _reasoning_summary_event_key(
|
||||
event.get("item_id"),
|
||||
event.get("summary_index"),
|
||||
)
|
||||
if (
|
||||
summary_key is not None
|
||||
and reasoning_summary_key is not None
|
||||
and summary_key != reasoning_summary_key
|
||||
):
|
||||
delta_text = _separate_reasoning_part(reasoning_content, delta_text)
|
||||
if summary_key is not None:
|
||||
reasoning_summary_key = summary_key
|
||||
reasoning_content = (reasoning_content or "") + delta_text
|
||||
streamed_reasoning = True
|
||||
if on_reasoning_delta:
|
||||
@@ -538,7 +571,10 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
|
||||
text = summary.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "".join(parts) or None
|
||||
content = ""
|
||||
for part in parts:
|
||||
content += _separate_reasoning_part(content, part)
|
||||
return content or None
|
||||
|
||||
|
||||
def parse_response_output(
|
||||
|
||||
@@ -495,6 +495,7 @@ def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserve
|
||||
if context.runtime is not None
|
||||
else None
|
||||
),
|
||||
fallback=True,
|
||||
),
|
||||
metadata=context.metadata,
|
||||
)
|
||||
|
||||
+89
-23
@@ -770,6 +770,36 @@ class WebUITranscriptRecorder:
|
||||
record.update(transcript_overrides)
|
||||
return self.append(chat_id, record)
|
||||
|
||||
def prepare_and_append_stream_event(
|
||||
self,
|
||||
chat_id: str,
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
completed_text: str | None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
phase: str | None = None,
|
||||
include_source: bool = False,
|
||||
) -> bool:
|
||||
"""Annotate every live stream event, but persist only completed segments.
|
||||
|
||||
Delta frames are a transport concern: retaining each token-sized chunk
|
||||
would turn rendering cadence into disk-write cadence. The matching end
|
||||
event carries the canonical segment text used by history replay.
|
||||
"""
|
||||
self.prepare_event(
|
||||
chat_id,
|
||||
event,
|
||||
metadata=metadata,
|
||||
phase=phase,
|
||||
include_source=include_source,
|
||||
)
|
||||
if event.get("event") in {"delta", "reasoning_delta"}:
|
||||
return True
|
||||
record = dict(event)
|
||||
if completed_text is not None:
|
||||
record["text"] = completed_text
|
||||
return self.append(chat_id, record)
|
||||
|
||||
def append_user_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -1903,13 +1933,24 @@ def replay_transcript_to_ui_messages(
|
||||
kept.append(m)
|
||||
messages = kept
|
||||
|
||||
def stamp_latency(latency_ms: int) -> None:
|
||||
def stamp_completion(
|
||||
*,
|
||||
latency_ms: int | None = None,
|
||||
usage: dict[str, int] | None = None,
|
||||
context_window_tokens: int | None = None,
|
||||
) -> None:
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
if messages[i].get("role") == "assistant" and messages[i].get("kind") != "trace":
|
||||
completion: dict[str, Any] = {"isStreaming": False}
|
||||
if latency_ms is not None:
|
||||
completion["latencyMs"] = latency_ms
|
||||
if usage:
|
||||
completion["usage"] = usage
|
||||
if context_window_tokens is not None:
|
||||
completion["contextWindowTokens"] = context_window_tokens
|
||||
messages[i] = {
|
||||
**messages[i],
|
||||
"latencyMs": latency_ms,
|
||||
"isStreaming": False,
|
||||
**completion,
|
||||
}
|
||||
return
|
||||
|
||||
@@ -2215,30 +2256,27 @@ def replay_transcript_to_ui_messages(
|
||||
turn_fields = _turn_fields(rec, "answer")
|
||||
source_fields = _source_fields(rec)
|
||||
if isinstance(final_text, str):
|
||||
if buffer_message_id is None:
|
||||
buffer_message_id = find_active_placeholder(messages, turn_fields)
|
||||
if buffer_message_id is None:
|
||||
buffer_message_id = _new_id("buf", idx)
|
||||
messages.append(
|
||||
{
|
||||
"id": buffer_message_id,
|
||||
"role": "assistant",
|
||||
messages.append({
|
||||
"id": buffer_message_id,
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"isStreaming": True,
|
||||
"createdAt": _created_at_ms(rec, idx),
|
||||
})
|
||||
for i, m in enumerate(messages):
|
||||
if m.get("id") == buffer_message_id:
|
||||
messages[i] = {
|
||||
**m,
|
||||
"content": final_text,
|
||||
"isStreaming": True,
|
||||
**turn_fields,
|
||||
**source_fields,
|
||||
"createdAt": _created_at_ms(rec, idx),
|
||||
},
|
||||
)
|
||||
else:
|
||||
for i, m in enumerate(messages):
|
||||
if m.get("id") == buffer_message_id:
|
||||
messages[i] = {
|
||||
**m,
|
||||
"content": final_text,
|
||||
"isStreaming": True,
|
||||
**turn_fields,
|
||||
**source_fields,
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
if merge_next:
|
||||
buffer_parts = [final_text]
|
||||
elif source_fields and buffer_message_id is not None:
|
||||
@@ -2274,6 +2312,16 @@ def replay_transcript_to_ui_messages(
|
||||
if ev == "reasoning_end":
|
||||
if suppress_until_turn_end:
|
||||
continue
|
||||
text = rec.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
close_file_edit_phase_before_activity()
|
||||
attach_reasoning_chunk(
|
||||
messages,
|
||||
text,
|
||||
idx,
|
||||
_turn_fields(rec, "reasoning"),
|
||||
_created_at_ms(rec, idx),
|
||||
)
|
||||
close_reasoning(messages)
|
||||
continue
|
||||
|
||||
@@ -2401,8 +2449,26 @@ def replay_transcript_to_ui_messages(
|
||||
messages[i] = {**m, "isStreaming": False}
|
||||
prune_reasoning_only()
|
||||
lat = rec.get("latency_ms")
|
||||
if isinstance(lat, (int, float)) and lat >= 0:
|
||||
stamp_latency(int(lat))
|
||||
usage = rec.get("usage")
|
||||
sanitized_usage = (
|
||||
{
|
||||
key: value
|
||||
for key, value in cast(dict[object, object], usage).items()
|
||||
if isinstance(key, str) and type(value) is int and value >= 0
|
||||
}
|
||||
if isinstance(usage, dict)
|
||||
else None
|
||||
)
|
||||
context_window = rec.get("context_window_tokens")
|
||||
stamp_completion(
|
||||
latency_ms=int(lat) if isinstance(lat, (int, float)) and lat >= 0 else None,
|
||||
usage=sanitized_usage,
|
||||
context_window_tokens=(
|
||||
int(context_window)
|
||||
if isinstance(context_window, (int, float)) and context_window >= 0
|
||||
else None
|
||||
),
|
||||
)
|
||||
buffer_message_id = None
|
||||
buffer_parts = []
|
||||
continue
|
||||
|
||||
@@ -1070,6 +1070,8 @@ async def test_runner_accumulates_usage_and_preserves_cached_tokens():
|
||||
assert result.usage["prompt_tokens"] == 300 # 100 + 200
|
||||
assert result.usage["completion_tokens"] == 30 # 10 + 20
|
||||
assert result.usage["cached_tokens"] == 230 # 80 + 150
|
||||
assert result.usage["context_tokens"] == 200
|
||||
assert result.usage["request_count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -584,9 +584,10 @@ class TestFallbackOnPrimaryError:
|
||||
assert restored.payload == state.payload
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reports_the_fallback_model_before_its_request(self) -> None:
|
||||
async def test_reports_only_the_successful_fallback_model(self) -> None:
|
||||
primary = _FakeProvider("primary", _error_response())
|
||||
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
|
||||
failed_fallback = _FakeProvider("failed", _error_response("backup overloaded"))
|
||||
successful_fallback = _FakeProvider("fallback", _make_response("fallback ok"))
|
||||
fallback_models: list[str] = []
|
||||
|
||||
async def _observe(model: str) -> None:
|
||||
@@ -594,8 +595,11 @@ class TestFallbackOnPrimaryError:
|
||||
|
||||
fb = FallbackProvider(
|
||||
primary=primary,
|
||||
fallback_presets=[_fallback("fallback-a", provider="backup")],
|
||||
provider_factory=MagicMock(return_value=fallback),
|
||||
fallback_presets=[
|
||||
_fallback("fallback-a", provider="backup"),
|
||||
_fallback("fallback-b", provider="backup"),
|
||||
],
|
||||
provider_factory=MagicMock(side_effect=[failed_fallback, successful_fallback]),
|
||||
fallback_model_observer=_observe,
|
||||
)
|
||||
|
||||
@@ -605,7 +609,7 @@ class TestFallbackOnPrimaryError:
|
||||
)
|
||||
|
||||
assert result.content == "fallback ok"
|
||||
assert fallback_models == ["fallback-a"]
|
||||
assert fallback_models == ["fallback-b"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logs_primary_error_before_fallback(self) -> None:
|
||||
|
||||
@@ -379,12 +379,14 @@ async def test_runner_calls_run_level_hooks_on_success():
|
||||
"done",
|
||||
"completed",
|
||||
None,
|
||||
{
|
||||
"prompt_tokens": 3,
|
||||
"completion_tokens": 2,
|
||||
"total_tokens": 5,
|
||||
"provider_tokens": 5,
|
||||
},
|
||||
{
|
||||
"prompt_tokens": 3,
|
||||
"completion_tokens": 2,
|
||||
"total_tokens": 5,
|
||||
"provider_tokens": 5,
|
||||
"request_count": 1,
|
||||
"context_tokens": 3,
|
||||
},
|
||||
["user", "assistant"],
|
||||
),
|
||||
("on_finally", "completed", None),
|
||||
|
||||
@@ -1056,8 +1056,24 @@ class TestConsumeSse:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_summary_delta_extracted(self):
|
||||
response = _SseResponse([
|
||||
{"type": "response.reasoning_summary_text.delta", "delta": "thinking "},
|
||||
{"type": "response.reasoning_summary_text.delta", "delta": "briefly"},
|
||||
{
|
||||
"type": "response.reasoning_summary_text.delta",
|
||||
"item_id": "rs_1",
|
||||
"summary_index": 0,
|
||||
"delta": "thinking ",
|
||||
},
|
||||
{
|
||||
"type": "response.reasoning_summary_text.delta",
|
||||
"item_id": "rs_1",
|
||||
"summary_index": 0,
|
||||
"delta": "briefly",
|
||||
},
|
||||
{
|
||||
"type": "response.reasoning_summary_text.delta",
|
||||
"item_id": "rs_1",
|
||||
"summary_index": 1,
|
||||
"delta": "Checking result",
|
||||
},
|
||||
{"type": "response.output_text.delta", "delta": "answer"},
|
||||
{"type": "response.completed", "response": {"status": "completed"}},
|
||||
])
|
||||
@@ -1075,8 +1091,8 @@ class TestConsumeSse:
|
||||
assert tool_calls == []
|
||||
assert finish_reason == "stop"
|
||||
assert usage == {}
|
||||
assert reasoning == "thinking briefly"
|
||||
assert deltas == ["thinking ", "briefly"]
|
||||
assert reasoning == "thinking briefly\nChecking result"
|
||||
assert deltas == ["thinking ", "briefly", "\nChecking result"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reasoning_summary_from_completed_response(self):
|
||||
@@ -1087,7 +1103,7 @@ class TestConsumeSse:
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{"type": "reasoning", "summary": [
|
||||
{"type": "summary_text", "text": "cached "},
|
||||
{"type": "summary_text", "text": "cached"},
|
||||
{"type": "summary_text", "text": "summary"},
|
||||
]},
|
||||
],
|
||||
@@ -1097,7 +1113,7 @@ class TestConsumeSse:
|
||||
|
||||
_, _, _, _, reasoning = await consume_sse_with_reasoning(response)
|
||||
|
||||
assert reasoning == "cached summary"
|
||||
assert reasoning == "cached\nsummary"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_commits_exact_items_only_after_completed_event(self):
|
||||
|
||||
@@ -393,6 +393,55 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
|
||||
assert msgs[1]["latencyMs"] == 42
|
||||
|
||||
|
||||
def test_replay_canonical_completed_stream_records() -> None:
|
||||
msgs = replay_transcript_to_ui_messages([
|
||||
{"event": "user", "chat_id": "canonical", "text": "q"},
|
||||
{"event": "reasoning_end", "chat_id": "canonical", "text": "think"},
|
||||
{"event": "stream_end", "chat_id": "canonical", "text": "answer"},
|
||||
{"event": "turn_end", "chat_id": "canonical", "latency_ms": 42},
|
||||
])
|
||||
|
||||
assert len(msgs) == 2
|
||||
assert msgs[1]["content"] == "answer"
|
||||
assert msgs[1]["reasoning"] == "think"
|
||||
assert msgs[1]["latencyMs"] == 42
|
||||
|
||||
|
||||
def test_replay_turn_end_preserves_usage_semantics(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:t-usage"
|
||||
for event in (
|
||||
{"event": "user", "chat_id": "t-usage", "text": "q"},
|
||||
{"event": "message", "chat_id": "t-usage", "text": "a"},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "t-usage",
|
||||
"latency_ms": 18_200,
|
||||
"usage": {
|
||||
"prompt_tokens": 12_400,
|
||||
"completion_tokens": 823,
|
||||
"cached_tokens": 9_672,
|
||||
"context_tokens": 8_200,
|
||||
"request_count": 3,
|
||||
},
|
||||
"context_window_tokens": 128_000,
|
||||
},
|
||||
):
|
||||
append_transcript_object(key, event)
|
||||
|
||||
messages = replay_transcript_to_ui_messages(read_transcript_lines(key))
|
||||
|
||||
assert messages[-1]["usage"] == {
|
||||
"prompt_tokens": 12_400,
|
||||
"completion_tokens": 823,
|
||||
"cached_tokens": 9_672,
|
||||
"context_tokens": 8_200,
|
||||
"request_count": 3,
|
||||
}
|
||||
assert messages[-1]["contextWindowTokens"] == 128_000
|
||||
assert messages[-1]["latencyMs"] == 18_200
|
||||
|
||||
|
||||
def test_replay_uses_persisted_created_at_ms() -> None:
|
||||
msgs = replay_transcript_to_ui_messages(
|
||||
[
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model";
|
||||
import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight";
|
||||
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
|
||||
import { relativeTime, visibleSessionPreview } from "@/lib/format";
|
||||
import {
|
||||
COLLAPSED_CHATS_VISIBLE_COUNT,
|
||||
displayTitle,
|
||||
@@ -906,14 +906,6 @@ export const ChatList = memo(function ChatList({
|
||||
);
|
||||
}
|
||||
|
||||
const fallbackTitle = t("chat.fallbackTitle", {
|
||||
id: s.chatId.slice(0, 6),
|
||||
});
|
||||
const generatedTitle = s.title?.trim() || "";
|
||||
const tooltipTitle =
|
||||
titleOverrides[s.key]?.trim() ||
|
||||
generatedTitle ||
|
||||
deriveTitle(s.preview, fallbackTitle);
|
||||
const isPinned = pinned.has(s.key);
|
||||
const isArchived = archived.has(s.key);
|
||||
const preview = visibleSessionPreview(s.preview);
|
||||
@@ -959,8 +951,7 @@ export const ChatList = memo(function ChatList({
|
||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<SidebarItemTooltip label={tooltipTitle}>
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
if (deleteSelectionMode) {
|
||||
@@ -1029,8 +1020,7 @@ export const ChatList = memo(function ChatList({
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</SidebarItemTooltip>
|
||||
</button>
|
||||
<SessionActivityIndicator state={activityState} />
|
||||
{!deleteSelectionMode ? (
|
||||
<DropdownMenu
|
||||
@@ -1435,10 +1425,7 @@ function ActivePaneRows({
|
||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<SidebarItemTooltip
|
||||
label={pane.handle ? `@${pane.handle.name} · ${pane.title}` : pane.title}
|
||||
>
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
if (deleteSelectionMode) {
|
||||
@@ -1474,8 +1461,7 @@ function ActivePaneRows({
|
||||
{isPinned ? <PinnedChatIndicator /> : null}
|
||||
<SidebarSelectionTrack active={active} handle={pane.handle} />
|
||||
</span>
|
||||
</button>
|
||||
</SidebarItemTooltip>
|
||||
</button>
|
||||
<SessionActivityIndicator state={activityState} />
|
||||
{!deleteSelectionMode ? <DropdownMenu
|
||||
modal={false}
|
||||
@@ -1652,8 +1638,7 @@ function TemporaryChatSection({
|
||||
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
||||
)}
|
||||
>
|
||||
<SidebarItemTooltip label={title}>
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(session.key)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
@@ -1666,8 +1651,7 @@ function TemporaryChatSection({
|
||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
</SidebarItemTooltip>
|
||||
</button>
|
||||
<SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} />
|
||||
{onClose ? (
|
||||
<button
|
||||
|
||||
@@ -34,7 +34,11 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
|
||||
import {
|
||||
fmtDateTime,
|
||||
formatCompactTokenCount,
|
||||
formatMessageEndTime,
|
||||
} from "@/lib/format";
|
||||
import { toMediaAttachment } from "@/lib/media";
|
||||
import { matchingSlashCommand } from "@/lib/slash-command";
|
||||
import { sessionHandleColor } from "@/lib/session-handle";
|
||||
@@ -50,6 +54,7 @@ import type {
|
||||
UIMessage,
|
||||
MessageDeliveryErrorKind,
|
||||
MessageDeliveryStatus,
|
||||
TurnUsage,
|
||||
} from "@/lib/types";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
@@ -176,6 +181,71 @@ function MessageCopyButton({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function compactDuration(milliseconds: number): string {
|
||||
const seconds = milliseconds / 1_000;
|
||||
if (seconds < 10) return `${seconds.toFixed(1)}s`;
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}m ${Math.round(seconds % 60)}s`;
|
||||
}
|
||||
|
||||
function TurnUsageMeta({
|
||||
usage,
|
||||
latencyMs,
|
||||
}: {
|
||||
usage: TurnUsage;
|
||||
latencyMs?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const prompt = usage.prompt_tokens;
|
||||
const completion = usage.completion_tokens;
|
||||
const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : "";
|
||||
const parts: string[] = [];
|
||||
if (typeof prompt === "number") parts.push(`${approximate}${formatCompactTokenCount(prompt)} in`);
|
||||
if (typeof completion === "number") parts.push(`${approximate}${formatCompactTokenCount(completion)} out`);
|
||||
if (
|
||||
typeof usage.cached_tokens === "number"
|
||||
&& typeof prompt === "number"
|
||||
&& prompt > 0
|
||||
) {
|
||||
parts.push(`${Math.round(Math.min(1, usage.cached_tokens / prompt) * 100)}% cached`);
|
||||
}
|
||||
if (typeof latencyMs === "number" && latencyMs >= 0) parts.push(compactDuration(latencyMs));
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
const details: string[] = [];
|
||||
if (approximate) {
|
||||
details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" }));
|
||||
}
|
||||
const usageMeta = (
|
||||
<span
|
||||
data-turn-usage
|
||||
tabIndex={details.length ? 0 : undefined}
|
||||
className={cn(
|
||||
"text-[11px] leading-none text-muted-foreground/70 tabular-nums",
|
||||
details.length && "cursor-help",
|
||||
)}
|
||||
>
|
||||
{parts.join(" · ")}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (details.length === 0) return usageMeta;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{usageMeta}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-96 whitespace-nowrap"
|
||||
>
|
||||
{details.join(" · ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function deliveryErrorCopy(
|
||||
kind: MessageDeliveryErrorKind | undefined,
|
||||
t: (key: string) => string,
|
||||
@@ -479,7 +549,9 @@ export function MessageBubble({
|
||||
&& (!empty || hasReasoning || media.length > 0);
|
||||
const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : "";
|
||||
const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0;
|
||||
const showAssistantFooterRow = showCopyButton || showForkButton || showAssistantTimestamp;
|
||||
const showUsage = message.role === "assistant" && !!message.usage && !message.isStreaming;
|
||||
const showAssistantFooterRow =
|
||||
showCopyButton || showForkButton || showAssistantTimestamp || showUsage;
|
||||
const showAssistantFooterSlot =
|
||||
message.role === "assistant"
|
||||
&& (!empty || hasReasoning || media.length > 0);
|
||||
@@ -545,6 +617,12 @@ export function MessageBubble({
|
||||
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showUsage ? (
|
||||
<TurnUsageMeta
|
||||
usage={message.usage!}
|
||||
latencyMs={message.latencyMs}
|
||||
/>
|
||||
) : null}
|
||||
{showAssistantTimestamp ? (
|
||||
<MessageTimestamp
|
||||
{...(showCompletedAt ? { "data-assistant-completed-at": true } : {})}
|
||||
@@ -576,7 +654,6 @@ function UserQuotedContext({ text, label }: { text: string; label: string }) {
|
||||
"border border-border/60 bg-muted/35 px-3 py-2 text-left text-muted-foreground",
|
||||
)}
|
||||
aria-label={label}
|
||||
title={text}
|
||||
>
|
||||
<Quote className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0 line-clamp-3 whitespace-pre-wrap text-[13px]/[1.45] [overflow-wrap:anywhere]">
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MarkdownText } from "@/components/MarkdownText";
|
||||
import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText";
|
||||
import { ActivityStep } from "@/components/thread/activity/ActivityStep";
|
||||
import { coalesceActivityMessages } from "@/components/thread/activity/activity-message-model";
|
||||
@@ -55,6 +56,7 @@ export { isAgentActivityMember, isReasoningOnlyAssistant };
|
||||
interface ActivityCounts {
|
||||
reasoningSteps: number;
|
||||
toolCalls: number;
|
||||
modelSegments: number;
|
||||
cliCount: number;
|
||||
mcpCount: number;
|
||||
fileCount: number;
|
||||
@@ -91,9 +93,14 @@ function countActivity(
|
||||
): ActivityCounts {
|
||||
let reasoningSteps = 0;
|
||||
let toolCalls = 0;
|
||||
let modelSegments = 0;
|
||||
const cliCount = cliRuns.length;
|
||||
const mcpCount = mcpRuns.length;
|
||||
for (const m of messages) {
|
||||
if (m.activityKind === "model") {
|
||||
modelSegments += 1;
|
||||
continue;
|
||||
}
|
||||
if (isReasoningOnlyAssistant(m)) {
|
||||
reasoningSteps += 1;
|
||||
continue;
|
||||
@@ -110,6 +117,7 @@ function countActivity(
|
||||
return {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
modelSegments,
|
||||
cliCount,
|
||||
mcpCount,
|
||||
fileCount: fileEdits.length,
|
||||
@@ -131,8 +139,8 @@ interface AgentActivityClusterProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Outer fold wrapping interleaved reasoning-only assistant rows and tool-trace rows.
|
||||
* Fixed max height with inner scroll and a single flat list of activity rows.
|
||||
* One fold wrapping the complete middle of a turn: reasoning, model segments,
|
||||
* tool traces, and file edits. The final assistant answer stays outside it.
|
||||
*/
|
||||
export function AgentActivityCluster({
|
||||
messages,
|
||||
@@ -165,6 +173,7 @@ export function AgentActivityCluster({
|
||||
const {
|
||||
reasoningSteps,
|
||||
toolCalls,
|
||||
modelSegments,
|
||||
cliCount,
|
||||
mcpCount,
|
||||
fileCount,
|
||||
@@ -186,9 +195,8 @@ export function AgentActivityCluster({
|
||||
? outerOpenLocal
|
||||
: isTurnStreaming || completionHoldOpen || (wasTurnStreaming && !isTurnStreaming);
|
||||
|
||||
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
|
||||
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || modelSegments > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
|
||||
const hasOnlyFileActivity = fileCount > 0 && activityMessages.every(messageHasOnlyFileActivity);
|
||||
const hasNonReasoningActivity = toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
|
||||
const durationMs = activityDurationMs(
|
||||
activityMessages,
|
||||
isTurnStreaming,
|
||||
@@ -197,28 +205,16 @@ export function AgentActivityCluster({
|
||||
startedAtMs,
|
||||
);
|
||||
const activityDuration = formatActivityDuration(durationMs);
|
||||
const thoughtLabel = hasNonReasoningActivity
|
||||
? isTurnStreaming
|
||||
? t("message.activityWorkingFor", {
|
||||
duration: activityDuration,
|
||||
defaultValue: "Working for {{duration}}",
|
||||
})
|
||||
: durationMs <= 0
|
||||
? t("message.activityWorked", { defaultValue: "Worked" })
|
||||
const activityLabel = isTurnStreaming
|
||||
? t("message.activityWorkingFor", {
|
||||
duration: activityDuration,
|
||||
defaultValue: "Working for {{duration}}",
|
||||
})
|
||||
: durationMs <= 0
|
||||
? t("message.activityWorked", { defaultValue: "Worked" })
|
||||
: t("message.activityWorkedFor", {
|
||||
duration: activityDuration,
|
||||
defaultValue: "Worked for {{duration}}",
|
||||
})
|
||||
: isTurnStreaming
|
||||
? t("message.activityThinkingFor", {
|
||||
duration: activityDuration,
|
||||
defaultValue: "Thinking for {{duration}}",
|
||||
})
|
||||
: durationMs <= 0
|
||||
? t("message.activityThought", { defaultValue: "Thought" })
|
||||
: t("message.activityThoughtFor", {
|
||||
duration: activityDuration,
|
||||
defaultValue: "Thought for {{duration}}",
|
||||
});
|
||||
|
||||
const cancelActivityScrollFrame = useCallback(() => {
|
||||
@@ -338,7 +334,7 @@ export function AgentActivityCluster({
|
||||
<ThinkingReasoningShell
|
||||
active={isTurnStreaming}
|
||||
expanded={outerExpanded}
|
||||
label={thoughtLabel}
|
||||
label={activityLabel}
|
||||
viewportRef={activityScrollRef}
|
||||
contentRef={activityContentRef}
|
||||
fadeTop={activityScrollFade.top}
|
||||
@@ -352,6 +348,7 @@ export function AgentActivityCluster({
|
||||
active={isTurnStreaming}
|
||||
cliAppsByName={cliAppsByName}
|
||||
mcpPresetsByName={mcpPresetsByName}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
{fileEdits.length ? (
|
||||
<FileEditGroup
|
||||
@@ -417,15 +414,28 @@ function ActivityMessageTimeline({
|
||||
active,
|
||||
cliAppsByName,
|
||||
mcpPresetsByName,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
messages: UIMessage[];
|
||||
active: boolean;
|
||||
cliAppsByName: Map<string, CliAppInfo>;
|
||||
mcpPresetsByName: Map<string, McpPresetInfo>;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const items: ReactNode[] = [];
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
if (message.activityKind === "model") {
|
||||
items.push(
|
||||
<ActivityModelMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
active={active}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isReasoningOnlyAssistant(message)) {
|
||||
items.push(
|
||||
<ReasoningRow
|
||||
@@ -451,6 +461,40 @@ function ActivityMessageTimeline({
|
||||
return <>{items}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep an intermediate assistant segment as normal Markdown. The activity
|
||||
* surface owns ordering and lifecycle, not a reduced rendering mode: users
|
||||
* should see the same prose, links, and code treatment before and after the
|
||||
* surrounding turn is folded.
|
||||
*/
|
||||
function ActivityModelMessage({
|
||||
message,
|
||||
active,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
message: UIMessage;
|
||||
active: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
if (!message.content.trim()) return null;
|
||||
return (
|
||||
<div
|
||||
data-testid="activity-model-message"
|
||||
data-assistant-selectable={active && message.isStreaming ? undefined : "true"}
|
||||
className="w-full min-w-0 py-1 text-[15px]"
|
||||
style={{ lineHeight: "var(--cjk-line-height)" }}
|
||||
>
|
||||
<MarkdownText
|
||||
streaming={active && !!message.isStreaming}
|
||||
preserveStreamingLayout
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
>
|
||||
{message.content}
|
||||
</MarkdownText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityTraceList({
|
||||
lines,
|
||||
active,
|
||||
|
||||
@@ -6,37 +6,27 @@ import {
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
} from "react";
|
||||
import { CircleHelp, Sparkles } from "lucide-react";
|
||||
import { Check, CircleHelp, Sparkles } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
floatingItemClassName,
|
||||
floatingItemFocusClassName,
|
||||
} from "@/components/ui/floating-surface";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ModelPresetOption {
|
||||
name: string;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
}
|
||||
|
||||
interface ModelPresetBadgeProps {
|
||||
label: string;
|
||||
modelDetail?: string | null;
|
||||
modelPreset?: string | null;
|
||||
modelPresets?: ModelPresetOption[];
|
||||
onPresetChange?: (name: string) => void;
|
||||
provider?: string | null;
|
||||
providerLabel?: string | null;
|
||||
needsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
isHero: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
const pickerWidthClassName = "w-[min(18rem,calc(100vw-2rem))]";
|
||||
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 = 200;
|
||||
|
||||
interface PresetGesture {
|
||||
active: boolean;
|
||||
@@ -55,15 +45,6 @@ interface PresetMotion {
|
||||
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;
|
||||
}
|
||||
@@ -86,12 +67,40 @@ function preventTouchScroll(event: TouchEvent) {
|
||||
if (event.cancelable) event.preventDefault();
|
||||
}
|
||||
|
||||
function compactModelName(model?: string | null): string | null {
|
||||
const value = model?.trim();
|
||||
if (!value) return null;
|
||||
return value.split("/").at(-1) || value;
|
||||
}
|
||||
|
||||
export interface ModelPresetOption {
|
||||
name: string;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
}
|
||||
|
||||
interface ModelPresetBadgeProps {
|
||||
label: string;
|
||||
modelDetail?: string | null;
|
||||
modelPreset?: string | null;
|
||||
modelPresets?: ModelPresetOption[];
|
||||
onPresetChange?: (name: string) => void;
|
||||
onRequestComposerFocus?: () => void;
|
||||
provider?: string | null;
|
||||
providerLabel?: string | null;
|
||||
needsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
isHero: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function ModelPresetBadge({
|
||||
label,
|
||||
modelDetail,
|
||||
modelPreset,
|
||||
modelPresets = [],
|
||||
onPresetChange,
|
||||
onRequestComposerFocus,
|
||||
provider,
|
||||
providerLabel,
|
||||
needsSetup = false,
|
||||
@@ -99,6 +108,12 @@ export function ModelPresetBadge({
|
||||
isHero,
|
||||
onClick,
|
||||
}: ModelPresetBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [motion, setMotion] = useState<PresetMotion | null>(null);
|
||||
const [motionWidth, setMotionWidth] = useState<number | null>(null);
|
||||
const gestureRef = useRef<PresetGesture | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const activeName = modelPreset?.trim() || "";
|
||||
const listedIndex = modelPresets.findIndex((preset) => preset.name === activeName);
|
||||
const activePreset: ModelPresetOption = {
|
||||
@@ -107,97 +122,78 @@ export function ModelPresetBadge({
|
||||
model: modelDetail ?? modelPresets[listedIndex]?.model,
|
||||
provider: provider || modelPresets[listedIndex]?.provider,
|
||||
};
|
||||
const fallbackPreset = fallbackModelName
|
||||
? modelPresets.find((preset) => preset.model?.trim() === fallbackModelName.trim())
|
||||
: undefined;
|
||||
const fallbackDisplayLabel = fallbackPreset?.name
|
||||
|| fallbackModelName?.trim().split(/[/:]/).pop()
|
||||
|| null;
|
||||
const displayLabel = fallbackDisplayLabel || label;
|
||||
const displayModelDetail = fallbackPreset
|
||||
? fallbackPreset.model
|
||||
: fallbackModelName
|
||||
? null
|
||||
: modelDetail;
|
||||
const displayProvider = fallbackPreset?.provider
|
||||
|| (fallbackModelName ? inferProviderFromModelName(fallbackModelName) : provider);
|
||||
const presets = !activeName
|
||||
? modelPresets
|
||||
: listedIndex < 0
|
||||
? [activePreset, ...modelPresets]
|
||||
: modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset);
|
||||
const interactive = Boolean(onClick);
|
||||
const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1;
|
||||
const opensSetup = Boolean(onClick);
|
||||
const canSwitch = !opensSetup && Boolean(onPresetChange) && activeName !== "" && presets.length > 1;
|
||||
const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName));
|
||||
const pillHeight = isHero ? 32 : 36;
|
||||
const pillStride = pillHeight + PILL_GAP_PX;
|
||||
const [motion, setMotion] = useState<PresetMotion | null>(null);
|
||||
const gestureRef = useRef<PresetGesture | null>(null);
|
||||
const clickAnimationFrameRef = useRef<number | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const suppressClickTimerRef = useRef<number | null>(null);
|
||||
const switchModelLabel = t("thread.composer.switchModel", {
|
||||
defaultValue: "Switch model for this chat",
|
||||
});
|
||||
|
||||
function clearGesture() {
|
||||
const selectPreset = (name: string) => {
|
||||
setOpen(false);
|
||||
if (name !== activeName) onPresetChange?.(name);
|
||||
requestAnimationFrame(() => onRequestComposerFocus?.());
|
||||
};
|
||||
|
||||
const clearGesture = () => {
|
||||
const gesture = gestureRef.current;
|
||||
if (gesture?.timer) clearTimeout(gesture.timer);
|
||||
if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll);
|
||||
gestureRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearMotion = () => {
|
||||
setMotion(null);
|
||||
setMotionWidth(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSwitch) {
|
||||
clearGesture();
|
||||
setMotion(null);
|
||||
clearMotion();
|
||||
}
|
||||
return () => {
|
||||
clearGesture();
|
||||
if (clickAnimationFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(clickAnimationFrameRef.current);
|
||||
clickAnimationFrameRef.current = null;
|
||||
}
|
||||
if (suppressClickTimerRef.current !== null) {
|
||||
window.clearTimeout(suppressClickTimerRef.current);
|
||||
suppressClickTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
return clearGesture;
|
||||
}, [canSwitch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!motion?.settling) return;
|
||||
const timer = setTimeout(() => setMotion(null), SETTLE_MS + 80);
|
||||
const timer = setTimeout(clearMotion, SETTLE_MS + 80);
|
||||
return () => clearTimeout(timer);
|
||||
}, [motion?.settling]);
|
||||
|
||||
function updateMotion(gesture: PresetGesture, clientY: number) {
|
||||
const 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 suppressFollowingClick() {
|
||||
suppressClickRef.current = true;
|
||||
if (suppressClickTimerRef.current !== null) {
|
||||
window.clearTimeout(suppressClickTimerRef.current);
|
||||
}
|
||||
suppressClickTimerRef.current = window.setTimeout(() => {
|
||||
suppressClickRef.current = false;
|
||||
suppressClickTimerRef.current = null;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function cycleToNextPreset() {
|
||||
if (!canSwitch || motion) return;
|
||||
const nextVirtualIndex = currentIndex + 1;
|
||||
const next = presets[wrapIndex(nextVirtualIndex, presets.length)];
|
||||
if (!next || next.name === activeName) return;
|
||||
|
||||
// Mount the same five-pill track one step before its destination, then
|
||||
// settle it into place so clicks share the drag interaction's motion.
|
||||
setMotion({ index: nextVirtualIndex, remainder: -1, settling: false });
|
||||
clickAnimationFrameRef.current = window.requestAnimationFrame(() => {
|
||||
clickAnimationFrameRef.current = null;
|
||||
setMotion({ index: nextVirtualIndex, remainder: 0, settling: true });
|
||||
onPresetChange?.(next.name);
|
||||
setMotion({
|
||||
index: gesture.baseIndex + gesture.step,
|
||||
remainder: raw - gesture.step,
|
||||
settling: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function handleClick() {
|
||||
if (interactive) {
|
||||
onClick?.();
|
||||
return;
|
||||
}
|
||||
if (suppressClickRef.current) return;
|
||||
cycleToNextPreset();
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent<HTMLElement>) {
|
||||
if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) return;
|
||||
const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (!canSwitch || gestureRef.current || motion) return;
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
const gesture: PresetGesture = {
|
||||
active: false,
|
||||
@@ -212,16 +208,19 @@ export function ModelPresetBadge({
|
||||
gesture.timer = setTimeout(() => {
|
||||
if (gestureRef.current !== gesture) return;
|
||||
gesture.active = true;
|
||||
setMotionWidth(Math.round(gesture.target.getBoundingClientRect().width) || null);
|
||||
updateMotion(gesture, gesture.latestY);
|
||||
gesture.target.addEventListener("touchmove", preventTouchScroll, { passive: false });
|
||||
try {
|
||||
gesture.target.setPointerCapture(gesture.pointerId);
|
||||
} catch { /* The pointer may already have ended. */ }
|
||||
} catch {
|
||||
// The pointer may already have ended.
|
||||
}
|
||||
}, LONG_PRESS_MS);
|
||||
gestureRef.current = gesture;
|
||||
}
|
||||
};
|
||||
|
||||
function handlePointerMove(event: PointerEvent<HTMLElement>) {
|
||||
const handlePointerMove = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
const gesture = gestureRef.current;
|
||||
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
||||
gesture.latestY = event.clientY;
|
||||
@@ -231,27 +230,26 @@ export function ModelPresetBadge({
|
||||
}
|
||||
event.preventDefault();
|
||||
updateMotion(gesture, event.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
function finishGesture(event: PointerEvent<HTMLElement>, commit: boolean) {
|
||||
const finishGesture = (event: PointerEvent<HTMLButtonElement>, 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 (gesture.active) suppressFollowingClick();
|
||||
if (!commit || !gesture.active) {
|
||||
setMotion(null);
|
||||
clearMotion();
|
||||
return;
|
||||
}
|
||||
suppressClickRef.current = true;
|
||||
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);
|
||||
}
|
||||
if (selected && selected.name !== activeName) selectPreset(selected.name);
|
||||
};
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
|
||||
if (!canSwitch) return;
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const targetByKey: Record<string, number> = {
|
||||
ArrowUp: currentIndex - 1,
|
||||
ArrowDown: currentIndex + 1,
|
||||
@@ -262,141 +260,229 @@ export function ModelPresetBadge({
|
||||
if (target === undefined) return;
|
||||
event.preventDefault();
|
||||
const next = presets[wrapIndex(target, presets.length)];
|
||||
if (next?.name !== activeName) onPresetChange?.(next.name);
|
||||
}
|
||||
if (next?.name !== activeName) selectPreset(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;
|
||||
const tooltipLabel = fallbackModelName
|
||||
|| [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
|
||||
|
||||
const badge = (
|
||||
<Container
|
||||
data-switching={motion ? "true" : undefined}
|
||||
data-settling={motion?.settling ? "true" : undefined}
|
||||
aria-label={label}
|
||||
aria-orientation={canSwitch ? "vertical" : undefined}
|
||||
aria-valuemax={canSwitch ? presets.length - 1 : undefined}
|
||||
aria-valuemin={canSwitch ? 0 : undefined}
|
||||
aria-valuenow={canSwitch ? previewIndex : undefined}
|
||||
aria-valuetext={canSwitch ? previewPreset?.name || label : undefined}
|
||||
role={canSwitch ? "spinbutton" : undefined}
|
||||
tabIndex={!interactive && !canSwitch ? 0 : undefined}
|
||||
type={interactive || canSwitch ? "button" : undefined}
|
||||
onClick={interactive || canSwitch ? handleClick : 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}
|
||||
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.name}
|
||||
modelDetail={preset.model}
|
||||
provider={preset.provider}
|
||||
isHero={isHero}
|
||||
offset={offset}
|
||||
scale={scale}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</Container>
|
||||
const pill = (
|
||||
<PresetPill
|
||||
label={displayLabel}
|
||||
modelDetail={displayModelDetail}
|
||||
provider={displayProvider}
|
||||
providerLabel={fallbackModelName ? null : providerLabel}
|
||||
needsSetup={needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
fallbackFromLabel={fallbackModelName ? label : null}
|
||||
isHero={isHero}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltipLabel) return badge;
|
||||
if (!canSwitch) {
|
||||
const Container = opensSetup ? "button" : "span";
|
||||
return (
|
||||
<Container
|
||||
aria-label={fallbackModelName ? `${displayLabel} (fallback from ${label})` : label}
|
||||
type={opensSetup ? "button" : undefined}
|
||||
onClick={opensSetup ? onClick : undefined}
|
||||
className={cn(
|
||||
"thread-composer-model-badge group inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] appearance-none border-0 bg-transparent p-0 shadow-none",
|
||||
opensSetup && "cursor-pointer focus-visible:outline-none",
|
||||
isHero ? "h-8" : "h-9",
|
||||
)}
|
||||
>
|
||||
{pill}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{badge}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
className="max-w-[min(24rem,calc(100vw-2rem))] break-all"
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) requestAnimationFrame(() => onRequestComposerFocus?.());
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-switching={motion ? "true" : undefined}
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
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()}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClickCapture={(event) => {
|
||||
if (!suppressClickRef.current) return;
|
||||
suppressClickRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
touchAction: "manipulation",
|
||||
width: motionWidth ? `${motionWidth}px` : undefined,
|
||||
}}
|
||||
className={cn(
|
||||
"thread-composer-model-badge group relative inline-flex w-fit min-w-0 max-w-[min(18rem,44vw)] cursor-pointer appearance-none border-0 bg-transparent p-0 shadow-none focus-visible:outline-none",
|
||||
motion && "z-10 cursor-grabbing",
|
||||
!motion && "cursor-grab",
|
||||
isHero ? "h-8" : "h-9",
|
||||
)}
|
||||
>
|
||||
{tooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{motion ? (
|
||||
<>
|
||||
<span data-testid="composer-model-pill-layout" className="invisible inline-flex h-full shrink-0" aria-hidden>
|
||||
{pill}
|
||||
</span>
|
||||
<span
|
||||
data-testid="composer-model-pill-viewport"
|
||||
className={cn(
|
||||
"composer-model-pill-viewport pointer-events-none absolute -left-2 right-0 overflow-hidden bg-transparent",
|
||||
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-[calc(100%-0.5rem)] flex-col items-end gap-1 will-change-transform"
|
||||
onTransitionEnd={(event) => {
|
||||
if (motion.settling && event.currentTarget === event.target) clearMotion();
|
||||
}}
|
||||
style={{
|
||||
paddingTop: isHero ? "10px" : "12px",
|
||||
transform: `translate3d(0, ${-pillStride * (2 + motion.remainder)}px, 0)`,
|
||||
}}
|
||||
>
|
||||
{PILL_OFFSETS.map((offset) => {
|
||||
const preset = presets[wrapIndex(motion.index + offset, presets.length)];
|
||||
return (
|
||||
<PresetPill
|
||||
key={motion.index + offset}
|
||||
label={preset.name}
|
||||
modelDetail={preset.model}
|
||||
provider={preset.provider}
|
||||
isHero={isHero}
|
||||
offset={offset}
|
||||
scale={motion.settling ? 1 : dockScale(offset - motion.remainder)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
) : pill}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
role="dialog"
|
||||
aria-label={switchModelLabel}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
const content = event.currentTarget;
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const selected = content.querySelector<HTMLElement>(
|
||||
'[role="option"][aria-selected="true"]',
|
||||
);
|
||||
selected?.focus();
|
||||
}}
|
||||
className={cn(
|
||||
pickerWidthClassName,
|
||||
"origin-[var(--radix-popover-content-transform-origin)] p-1.5 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-bottom-1 data-[state=open]:slide-in-from-bottom-1 duration-200 ease-out motion-reduce:animate-none",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={switchModelLabel}
|
||||
className="max-h-[min(16rem,var(--radix-popover-content-available-height))] overflow-y-auto py-1 scrollbar-thin scrollbar-track-transparent"
|
||||
>
|
||||
{presets.map((preset) => (
|
||||
<PresetOption
|
||||
key={preset.name}
|
||||
preset={preset}
|
||||
selected={preset.name === activeName}
|
||||
onSelect={selectPreset}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetOption({
|
||||
preset,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
preset: ModelPresetOption;
|
||||
selected: boolean;
|
||||
onSelect: (name: string) => void;
|
||||
}) {
|
||||
const detail = compactModelName(preset.model);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-label={preset.name}
|
||||
aria-selected={selected}
|
||||
onClick={() => onSelect(preset.name)}
|
||||
className={cn(
|
||||
floatingItemClassName,
|
||||
floatingItemFocusClassName,
|
||||
"flex min-h-9 w-full cursor-pointer gap-2.5 px-2.5 py-1.5 text-left hover:bg-muted/55",
|
||||
selected && "bg-muted/55 text-foreground",
|
||||
)}
|
||||
>
|
||||
<PresetProviderIcon
|
||||
label={preset.name}
|
||||
modelDetail={detail}
|
||||
provider={preset.provider}
|
||||
isHero={false}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-1.5 overflow-hidden whitespace-nowrap">
|
||||
<span className="shrink-0 text-[13px] font-medium text-foreground">{preset.name}</span>
|
||||
{detail && detail !== preset.name ? (
|
||||
<span className="truncate text-[12px] text-muted-foreground">{detail}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{selected ? <Check className="h-4 w-4 shrink-0 text-foreground/80" aria-hidden /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetPill({
|
||||
className,
|
||||
label,
|
||||
modelDetail,
|
||||
provider,
|
||||
providerLabel,
|
||||
needsSetup = false,
|
||||
fallbackModelName,
|
||||
fallbackFromLabel,
|
||||
isHero,
|
||||
offset,
|
||||
scale,
|
||||
}: {
|
||||
className?: string | false | null;
|
||||
label: string;
|
||||
modelDetail?: string | null;
|
||||
provider?: string | null;
|
||||
providerLabel?: string | null;
|
||||
needsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
fallbackFromLabel?: string | null;
|
||||
isHero: boolean;
|
||||
offset?: number;
|
||||
scale?: number;
|
||||
@@ -406,13 +492,10 @@ function PresetPill({
|
||||
const inferredProvider = needsSetup
|
||||
? null
|
||||
: provider || inferProviderFromModelName(modelDetail || label);
|
||||
const brand = providerBrand(inferredProvider);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||
const logoTestId = offset !== undefined
|
||||
? undefined
|
||||
: needsSetup
|
||||
? "composer-model-setup-icon"
|
||||
: `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`;
|
||||
const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
|
||||
const fallbackTitle = fallbackModelName
|
||||
? `${fallbackFromLabel || label} · using ${fallbackModelName}`
|
||||
: title;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const node = labelRef.current;
|
||||
@@ -428,14 +511,14 @@ function PresetPill({
|
||||
<span
|
||||
data-fallback={fallbackModelName ? "true" : undefined}
|
||||
data-preset-offset={offset}
|
||||
title={fallbackTitle || undefined}
|
||||
className={cn(
|
||||
"composer-model-badge composer-model-pill inline-flex h-full w-fit max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70",
|
||||
offset === undefined && "shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
|
||||
"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",
|
||||
"composer-model-badge composer-model-pill inline-flex h-full max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70",
|
||||
"w-fit",
|
||||
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible:ring-2 group-focus-visible:ring-ring/45",
|
||||
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
|
||||
isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]",
|
||||
offset !== undefined && "composer-model-pill-dock",
|
||||
className,
|
||||
)}
|
||||
style={scale === undefined ? undefined : {
|
||||
height: `${isHero ? 32 : 36}px`,
|
||||
@@ -443,46 +526,14 @@ function PresetPill({
|
||||
zIndex: Math.round(scale * 100),
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-testid={logoTestId}
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center overflow-hidden",
|
||||
needsSetup ? "text-amber-800 dark:text-amber-200" : "rounded-full border bg-background",
|
||||
isHero ? "h-4 w-4" : "h-[18px] w-[18px]",
|
||||
)}
|
||||
style={{
|
||||
borderColor: !needsSetup && brand ? `${brand.color}28` : undefined,
|
||||
boxShadow: !needsSetup && brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{needsSetup ? (
|
||||
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
|
||||
) : logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : brand ? (
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-full w-full place-items-center rounded-full text-white",
|
||||
isHero ? "text-[7.5px]" : "text-[8px]",
|
||||
)}
|
||||
style={{ backgroundColor: brand.color }}
|
||||
>
|
||||
{brand.initials.slice(0, 2)}
|
||||
</span>
|
||||
) : (
|
||||
<Sparkles className="h-3 w-3 text-muted-foreground/65" />
|
||||
)}
|
||||
</span>
|
||||
<PresetProviderIcon
|
||||
label={label}
|
||||
modelDetail={modelDetail}
|
||||
provider={inferredProvider}
|
||||
needsSetup={needsSetup}
|
||||
testId={needsSetup ? "composer-model-setup-icon" : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`}
|
||||
isHero={isHero}
|
||||
/>
|
||||
<span
|
||||
ref={labelRef}
|
||||
className={cn(
|
||||
@@ -495,3 +546,63 @@ function PresetPill({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetProviderIcon({
|
||||
label,
|
||||
modelDetail,
|
||||
provider,
|
||||
needsSetup = false,
|
||||
testId,
|
||||
isHero,
|
||||
}: {
|
||||
label: string;
|
||||
modelDetail?: string | null;
|
||||
provider?: string | null;
|
||||
needsSetup?: boolean;
|
||||
testId?: string;
|
||||
isHero: boolean;
|
||||
}) {
|
||||
const inferredProvider = needsSetup
|
||||
? null
|
||||
: provider || inferProviderFromModelName(modelDetail || label);
|
||||
const brand = providerBrand(inferredProvider);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||
return (
|
||||
<span
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center",
|
||||
needsSetup && "text-amber-800 dark:text-amber-200",
|
||||
isHero ? "h-4 w-4" : "h-[18px] w-[18px]",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{needsSetup ? (
|
||||
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
|
||||
) : logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className={cn("object-contain", isHero ? "h-3.5 w-3.5" : "h-[18px] w-[18px]")}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : brand ? (
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-full w-full place-items-center rounded-full text-white",
|
||||
isHero ? "text-[7.5px]" : "text-[8px]",
|
||||
)}
|
||||
style={{ backgroundColor: brand.color }}
|
||||
>
|
||||
{brand.initials.slice(0, 2)}
|
||||
</span>
|
||||
) : (
|
||||
<Sparkles className="h-3 w-3 text-muted-foreground/65" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ import {
|
||||
readDraggedSession,
|
||||
} from "@/lib/session-drag";
|
||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||
import { formatCompactTokenCount } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const VOICE_SHORTCUT_CODE = "KeyD";
|
||||
@@ -178,6 +179,107 @@ function getVoiceShortcutLabel(): string {
|
||||
}
|
||||
}
|
||||
|
||||
export interface ComposerContextUsage {
|
||||
contextTokens: number;
|
||||
contextWindowTokens?: number;
|
||||
}
|
||||
|
||||
function ComposerContextBadge({ usage }: { usage: ComposerContextUsage | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!usage || !Number.isFinite(usage.contextTokens) || usage.contextTokens < 0) return null;
|
||||
|
||||
const context = formatCompactTokenCount(usage.contextTokens);
|
||||
const contextWindow = typeof usage.contextWindowTokens === "number"
|
||||
&& Number.isFinite(usage.contextWindowTokens)
|
||||
&& usage.contextWindowTokens > 0
|
||||
? usage.contextWindowTokens
|
||||
: null;
|
||||
const capacity = contextWindow !== null
|
||||
? ` / ${formatCompactTokenCount(contextWindow)}`
|
||||
: "";
|
||||
// The meter is meaningful only with a known capacity. Do not turn an
|
||||
// otherwise unknown total into a second, text-heavy control beside the
|
||||
// model picker.
|
||||
if (contextWindow === null) return null;
|
||||
const percentage = Math.min(100, Math.round(usage.contextTokens / contextWindow * 100));
|
||||
const status = percentage >= 90
|
||||
? "critical"
|
||||
: percentage >= 75
|
||||
? "caution"
|
||||
: "normal";
|
||||
const contextDescription = t("thread.composer.context.tooltip", {
|
||||
defaultValue: "Context · {{tokens}}{{capacity}}",
|
||||
tokens: context,
|
||||
capacity,
|
||||
});
|
||||
const meterDescription = t("thread.composer.context.meterDescription", {
|
||||
defaultValue: "{{context}}. {{percent}}% used.",
|
||||
context: contextDescription,
|
||||
percent: percentage,
|
||||
});
|
||||
const ringCircumference = 2 * Math.PI * 6;
|
||||
const ringLength = ringCircumference * percentage / 100;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300} skipDelayDuration={80}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="composer-context-usage"
|
||||
aria-label={meterDescription}
|
||||
className={cn(
|
||||
"inline-flex size-5 shrink-0 items-center justify-center rounded-full",
|
||||
"text-muted-foreground/75 transition-colors hover:text-foreground/85",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-[15px] shrink-0 -rotate-90",
|
||||
status === "critical" && "text-destructive",
|
||||
status === "caution" && "text-amber-600 dark:text-amber-400",
|
||||
status === "normal" && "text-muted-foreground/75",
|
||||
)}
|
||||
>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${ringLength} ${ringCircumference}`}
|
||||
data-testid="composer-context-meter"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
className="w-fit max-w-[calc(100vw-2rem)] rounded-full border-border/70 px-2.5 py-1 text-xs font-medium shadow-[0_8px_24px_rgba(15,23,42,0.13)]"
|
||||
>
|
||||
<span className="whitespace-nowrap tabular-nums">{contextDescription}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadComposerProps {
|
||||
onSend: (
|
||||
content: string,
|
||||
@@ -198,6 +300,7 @@ interface ThreadComposerProps {
|
||||
modelNeedsSetup?: boolean;
|
||||
fallbackModelName?: string | null;
|
||||
onModelBadgeClick?: () => void;
|
||||
contextUsage?: ComposerContextUsage | null;
|
||||
variant?: "thread" | "hero";
|
||||
slashCommands?: SlashCommand[];
|
||||
cliApps?: CliAppInfo[];
|
||||
@@ -895,6 +998,7 @@ export function ThreadComposer({
|
||||
modelNeedsSetup = false,
|
||||
fallbackModelName = null,
|
||||
onModelBadgeClick,
|
||||
contextUsage = null,
|
||||
variant = "thread",
|
||||
slashCommands = [],
|
||||
cliApps = [],
|
||||
@@ -2435,6 +2539,7 @@ export function ThreadComposer({
|
||||
modelPreset={modelPreset}
|
||||
modelPresets={modelPresets}
|
||||
onPresetChange={onModelPresetChange}
|
||||
onRequestComposerFocus={() => textareaRef.current?.focus()}
|
||||
provider={modelProvider}
|
||||
providerLabel={modelProviderLabel}
|
||||
needsSetup={modelNeedsSetup}
|
||||
@@ -2443,6 +2548,7 @@ export function ThreadComposer({
|
||||
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{!voiceRecorder.isRecording ? <ComposerContextBadge usage={contextUsage} /> : null}
|
||||
{showVoiceButton ? (
|
||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
||||
<Tooltip>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction";
|
||||
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||
import { projectActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||
|
||||
interface ThreadMessagesProps {
|
||||
@@ -29,10 +29,9 @@ export type DisplayUnit = TurnUnit;
|
||||
export function buildDisplayUnits(
|
||||
messages: UIMessage[],
|
||||
isStreaming = false,
|
||||
activeTurnId: string | null = null,
|
||||
): DisplayUnit[] {
|
||||
return normalizeActivityTimeline(messages, {
|
||||
preserveTrailingActivity: isStreaming,
|
||||
});
|
||||
return projectActivityTimeline(messages, isStreaming ? activeTurnId : undefined);
|
||||
}
|
||||
|
||||
export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
|
||||
@@ -69,7 +68,10 @@ export function ThreadMessages({
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const messageListRef = useRef<HTMLDivElement>(null);
|
||||
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
|
||||
const units = useMemo(
|
||||
() => buildDisplayUnits(messages, isStreaming, activeTurnId),
|
||||
[activeTurnId, isStreaming, messages],
|
||||
);
|
||||
const forkBoundaryAfterUnitIndex = useMemo(
|
||||
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
|
||||
[forkBoundaryMessageCount, units],
|
||||
|
||||
@@ -8,7 +8,10 @@ import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||
import { SessionHandleLabel } from "@/components/SessionHandleLabel";
|
||||
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
||||
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
import {
|
||||
ThreadComposer,
|
||||
type ComposerContextUsage,
|
||||
} from "@/components/thread/ThreadComposer";
|
||||
import type { ModelPresetOption } from "@/components/thread/ModelPresetBadge";
|
||||
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
||||
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
||||
@@ -83,6 +86,30 @@ function sameMessageShape(a: MessageShape, b: MessageShape): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function latestComposerContextUsage(messages: UIMessage[]): ComposerContextUsage | null {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
const contextTokens = message.usage?.context_tokens;
|
||||
if (
|
||||
message.role !== "assistant"
|
||||
|| message.kind === "trace"
|
||||
|| message.isStreaming
|
||||
|| typeof contextTokens !== "number"
|
||||
|| !Number.isFinite(contextTokens)
|
||||
|| contextTokens < 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
contextTokens,
|
||||
...(typeof message.contextWindowTokens === "number"
|
||||
? { contextWindowTokens: message.contextWindowTokens }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function snapshotPreservesMessage(
|
||||
current: MessageShape,
|
||||
candidate: MessageShape,
|
||||
@@ -400,7 +427,7 @@ function toModelBadgeInfo(
|
||||
);
|
||||
return {
|
||||
label,
|
||||
model: model?.trim() || null,
|
||||
model: toModelBadgeLabel(model),
|
||||
provider,
|
||||
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
|
||||
needsSetup,
|
||||
@@ -803,8 +830,12 @@ export function ThreadShell({
|
||||
}, []);
|
||||
|
||||
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
|
||||
const currentRunStartedAt = messagesReady ? runStartedAt : null;
|
||||
const composerContextUsage = useMemo(
|
||||
() => latestComposerContextUsage(displayMessages),
|
||||
[displayMessages],
|
||||
);
|
||||
const currentGoalState = messagesReady ? goalState : undefined;
|
||||
const currentRunStartedAt = messagesReady ? runStartedAt : null;
|
||||
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
|
||||
const restoredViewportTurnId = useMemo(
|
||||
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
|
||||
@@ -882,9 +913,17 @@ export function ThreadShell({
|
||||
useEffect(() => {
|
||||
setLocalModelPreset(null);
|
||||
}, [session?.key, sessionModelPreset]);
|
||||
const configuredPresetNames = useMemo(
|
||||
() => new Set(settings?.model_presets.map((preset) => preset.name) ?? []),
|
||||
[settings],
|
||||
);
|
||||
const activeModelPreset = (
|
||||
localModelPreset
|
||||
|| sessionModelPreset
|
||||
(localModelPreset && (!settings || configuredPresetNames.has(localModelPreset))
|
||||
? localModelPreset
|
||||
: null)
|
||||
|| (sessionModelPreset && (!settings || configuredPresetNames.has(sessionModelPreset))
|
||||
? sessionModelPreset
|
||||
: null)
|
||||
|| settings?.agent.model_preset
|
||||
|| "default"
|
||||
);
|
||||
@@ -958,13 +997,10 @@ export function ThreadShell({
|
||||
}
|
||||
setFallbackModelName(null);
|
||||
return client.onChat(chatId, (event) => {
|
||||
if (event.event !== "turn_model_updated") return;
|
||||
const activeModel = event.model_name.trim();
|
||||
setFallbackModelName(
|
||||
modelBadge.model && activeModel !== modelBadge.model ? activeModel : null,
|
||||
);
|
||||
if (event.event !== "turn_model_updated" || event.fallback !== true) return;
|
||||
setFallbackModelName(event.model_name);
|
||||
});
|
||||
}, [chatId, client, modelBadge.model]);
|
||||
}, [chatId, client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyKey || !chatId || loading) return;
|
||||
@@ -1454,7 +1490,7 @@ export function ThreadShell({
|
||||
: t("thread.composer.placeholderThread")
|
||||
}
|
||||
modelLabel={modelBadgeLabel}
|
||||
modelDetail={toModelBadgeLabel(modelBadge.model)}
|
||||
modelDetail={modelBadge.model}
|
||||
modelPreset={activeModelPreset}
|
||||
modelPresets={modelPresetOptions}
|
||||
onModelPresetChange={handleModelPresetChange}
|
||||
@@ -1463,6 +1499,7 @@ export function ThreadShell({
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
contextUsage={composerContextUsage}
|
||||
variant={composerVariant}
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
@@ -1501,7 +1538,7 @@ export function ThreadShell({
|
||||
: t("thread.composer.placeholderHero")
|
||||
}
|
||||
modelLabel={modelBadgeLabel}
|
||||
modelDetail={toModelBadgeLabel(modelBadge.model)}
|
||||
modelDetail={modelBadge.model}
|
||||
modelPreset={activeModelPreset}
|
||||
modelPresets={modelPresetOptions}
|
||||
onModelPresetChange={handleModelPresetChange}
|
||||
@@ -1510,6 +1547,7 @@ export function ThreadShell({
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
contextUsage={composerContextUsage}
|
||||
variant="hero"
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
|
||||
+40
-34
@@ -165,11 +165,19 @@
|
||||
pointer-events: none;
|
||||
background-color: rgb(236 141 49);
|
||||
opacity: 0;
|
||||
transition: opacity 220ms ease-out;
|
||||
transition: opacity 180ms ease-out;
|
||||
}
|
||||
|
||||
.composer-model-badge[data-fallback="true"] {
|
||||
border-color: rgb(236 141 49 / 0.38);
|
||||
}
|
||||
|
||||
.composer-model-badge[data-fallback="true"]::before {
|
||||
opacity: 1;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.dark .composer-model-badge[data-fallback="true"]::before {
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.composer-model-badge > * {
|
||||
@@ -695,11 +703,38 @@
|
||||
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent);
|
||||
}
|
||||
|
||||
.thread-composer-model-badge:not([data-switching="true"]):active
|
||||
> .composer-model-pill {
|
||||
.thread-composer-model-badge:not([data-switching="true"]):active > .composer-model-pill {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@keyframes composer-model-pill-viewport-enter {
|
||||
from {
|
||||
transform: scale(0.9074);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.composer-model-pill-viewport {
|
||||
transform-origin: right center;
|
||||
animation: composer-model-pill-viewport-enter 210ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
|
||||
}
|
||||
|
||||
.composer-model-pill-dock {
|
||||
transform-origin: right center;
|
||||
transition-property: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.composer-model-pill-track[data-settling="true"],
|
||||
.composer-model-pill-track[data-settling="true"] .composer-model-pill-dock {
|
||||
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes preset-name-shake {
|
||||
0%,
|
||||
100% {
|
||||
@@ -715,37 +750,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes composer-model-pill-viewport-enter {
|
||||
from {
|
||||
transform: scale(0.9074);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.composer-model-pill-viewport {
|
||||
transform-origin: right center;
|
||||
animation: composer-model-pill-viewport-enter 210ms
|
||||
cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
|
||||
mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
|
||||
}
|
||||
|
||||
.composer-model-pill-dock {
|
||||
transform-origin: right center;
|
||||
transition-property: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.composer-model-pill-track[data-settling="true"],
|
||||
.composer-model-pill-track[data-settling="true"] .composer-model-pill-dock {
|
||||
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.thread-composer-model-badge:active > .composer-model-pill {
|
||||
.thread-composer-model-badge:not([data-switching="true"]):active > .composer-model-pill {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ type PendingStreamEvent =
|
||||
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
|
||||
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
|
||||
|
||||
const STREAM_END_IDLE_DELAY_MS = 1000;
|
||||
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
|
||||
|
||||
/**
|
||||
@@ -63,8 +62,8 @@ const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000;
|
||||
*
|
||||
* Lookup rule: reasoning can only extend the current reasoning placeholder.
|
||||
* Once ordinary answer text has appeared, the next reasoning chunk starts a
|
||||
* fresh Thought block so streamed output stays in arrival order:
|
||||
* Thought -> answer -> Thought -> answer.
|
||||
* fresh activity surface so streamed output stays in arrival order while the
|
||||
* final answer remains the only visible answer bubble.
|
||||
*/
|
||||
function attachReasoningChunk(
|
||||
prev: UIMessage[],
|
||||
@@ -183,16 +182,6 @@ export interface SubmittedTurn {
|
||||
sideChannel: boolean;
|
||||
}
|
||||
|
||||
function eventExtendsModelActivity(ev: InboundEvent): boolean {
|
||||
if (
|
||||
ev.event === "delta"
|
||||
|| ev.event === "reasoning_delta"
|
||||
|| ev.event === "file_edit"
|
||||
) return true;
|
||||
return ev.event === "message"
|
||||
&& (ev.kind === "tool_hint" || ev.kind === "progress" || ev.kind === "reasoning");
|
||||
}
|
||||
|
||||
function eventTurnId(ev: InboundEvent): string | undefined {
|
||||
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
|
||||
}
|
||||
@@ -296,14 +285,6 @@ export function useNanobotStream(
|
||||
const streamTimerRef = useRef<number | null>(null);
|
||||
const suppressStreamUntilTurnEndRef = useRef(false);
|
||||
const sideChannelTurnIdsRef = useRef<Set<string>>(new Set());
|
||||
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
|
||||
*
|
||||
* When the model finishes a text segment and calls a tool, the server
|
||||
* sends ``stream_end`` but the agent is still "thinking" while the tool
|
||||
* executes. By deferring the flag reset by a short window (1 s) we keep
|
||||
* the loading spinner alive across tool-call boundaries without needing
|
||||
* backend changes. */
|
||||
const streamEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
||||
|
||||
@@ -319,26 +300,11 @@ export function useNanobotStream(
|
||||
pendingStreamEventsRef.current = [];
|
||||
}, []);
|
||||
|
||||
const cancelStreamEndTimer = useCallback(() => {
|
||||
if (streamEndTimerRef.current === null) return;
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const isSideChannelEvent = useCallback((ev: InboundEvent) => {
|
||||
const turnId = eventTurnId(ev);
|
||||
return turnId !== undefined && sideChannelTurnIdsRef.current.has(turnId);
|
||||
}, []);
|
||||
|
||||
const scheduleStreamEndTimer = useCallback((turn: UIMessageTurnFields = {}) => {
|
||||
cancelStreamEndTimer();
|
||||
streamEndTimerRef.current = setTimeout(() => {
|
||||
streamEndTimerRef.current = null;
|
||||
setIsStreaming(false);
|
||||
setMessages((prev) => finalizeStreamedTurn(prev, turn));
|
||||
}, STREAM_END_IDLE_DELAY_MS);
|
||||
}, [cancelStreamEndTimer]);
|
||||
|
||||
const createActivitySegmentId = useCallback((activate = true) => {
|
||||
activitySegmentCounterRef.current += 1;
|
||||
const id = `activity-${activitySegmentCounterRef.current}`;
|
||||
@@ -387,7 +353,6 @@ export function useNanobotStream(
|
||||
(event) => event.turn.turnId !== rejectedTurnId,
|
||||
);
|
||||
sideChannelTurnIdsRef.current.delete(rejectedTurnId);
|
||||
cancelStreamEndTimer();
|
||||
setMessages((prev) => {
|
||||
const rejectedRows = prev.filter((message) => message.turnId === rejectedTurnId);
|
||||
if (rejectedRows.length === 0) return prev;
|
||||
@@ -438,7 +403,7 @@ export function useNanobotStream(
|
||||
setRunStartedAt(remainingStartedAt);
|
||||
setIsStreaming(hasRemainingRun);
|
||||
if (!hasRemainingRun) suppressStreamUntilTurnEndRef.current = false;
|
||||
}, [cancelStreamEndTimer, chatId, client]);
|
||||
}, [chatId, client]);
|
||||
|
||||
useEffect(() => client.onError(applyStreamError), [applyStreamError, client]);
|
||||
|
||||
@@ -659,15 +624,6 @@ export function useNanobotStream(
|
||||
return () => document.removeEventListener("visibilitychange", flushOnReturn);
|
||||
}, [flushPendingStreamEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onStatus((status) => {
|
||||
if (status !== "reconnecting" && status !== "closed") return;
|
||||
// A transport drop does not prove the backend turn completed. Keep the
|
||||
// semantic running state intact so queued guidance is not flushed early.
|
||||
cancelStreamEndTimer();
|
||||
});
|
||||
}, [cancelStreamEndTimer, client]);
|
||||
|
||||
// Reset local state when switching chats. Do not reset on every
|
||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||
// history response after the optimistic first message has already rendered.
|
||||
@@ -690,9 +646,8 @@ export function useNanobotStream(
|
||||
clearPendingStreamWork();
|
||||
sideChannelTurnIdsRef.current.clear();
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
cancelStreamEndTimer();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatId, client, cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]);
|
||||
}, [chatId, client, clearActivitySegment, clearPendingStreamWork]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPendingToolCalls) setIsStreaming(true);
|
||||
@@ -774,12 +729,6 @@ export function useNanobotStream(
|
||||
return;
|
||||
}
|
||||
const sideChannelEvent = isSideChannelEvent(ev);
|
||||
if (
|
||||
streamEndTimerRef.current !== null
|
||||
&& !sideChannelEvent
|
||||
&& eventExtendsModelActivity(ev)
|
||||
) cancelStreamEndTimer();
|
||||
|
||||
if (ev.event === "delta") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
const chunk = typeof ev.text === "string" ? ev.text : "";
|
||||
@@ -822,14 +771,13 @@ export function useNanobotStream(
|
||||
});
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
if (ev.resuming) {
|
||||
cancelStreamEndTimer();
|
||||
setIsStreaming(true);
|
||||
if (!mergeNext) {
|
||||
setMessages((prev) => finalizeStreamedTurn(prev, turn));
|
||||
}
|
||||
return;
|
||||
}
|
||||
scheduleStreamEndTimer(turn);
|
||||
// ``stream_end`` closes the current answer segment, not the turn.
|
||||
// Tools and follow-up model segments may still arrive before the
|
||||
// definitive ``turn_end`` event.
|
||||
setIsStreaming(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -868,9 +816,8 @@ export function useNanobotStream(
|
||||
setGoalState(ev.goal_state);
|
||||
}
|
||||
setRunStartedAt(null);
|
||||
// Definitive signal that the turn is fully complete. Cancel any
|
||||
// pending debounce timer and stop the loading indicator immediately.
|
||||
cancelStreamEndTimer();
|
||||
// Definitive signal that the turn is fully complete, so stop the
|
||||
// loading indicator immediately.
|
||||
setIsStreaming(false);
|
||||
const completedAt = Date.now();
|
||||
setMessages((prev) => {
|
||||
@@ -884,6 +831,10 @@ export function useNanobotStream(
|
||||
finalized,
|
||||
{
|
||||
...(latencyMs !== undefined ? { latencyMs } : {}),
|
||||
...(ev.usage ? { usage: ev.usage } : {}),
|
||||
...(typeof ev.context_window_tokens === "number"
|
||||
? { contextWindowTokens: ev.context_window_tokens }
|
||||
: {}),
|
||||
completedAt,
|
||||
},
|
||||
ev.turn_id,
|
||||
@@ -1013,8 +964,9 @@ export function useNanobotStream(
|
||||
|
||||
// A complete (non-streamed) assistant message. If a stream was in
|
||||
// flight, drop the placeholder so we don't render the text twice.
|
||||
// Streaming state is closed by ``stream_end`` when present, or by
|
||||
// ``turn_end`` for non-streamed and tool-heavy turns.
|
||||
// ``turn_end`` is the turn boundary. ``stream_end`` only closes the
|
||||
// current text segment so a following tool/reasoning segment remains
|
||||
// part of the same live activity surface.
|
||||
clearActivitySegment();
|
||||
setMessages((prev) => {
|
||||
const activeId = buffer.current?.messageId;
|
||||
@@ -1088,7 +1040,6 @@ export function useNanobotStream(
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ``attached`` frames aren't actionable here.
|
||||
};
|
||||
|
||||
const unsub = client.onChat(chatId, handle);
|
||||
@@ -1099,12 +1050,11 @@ export function useNanobotStream(
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
clearPendingStreamWork();
|
||||
cancelStreamEndTimer();
|
||||
};
|
||||
}, [
|
||||
applyStreamError,
|
||||
cancelStreamEndTimer,
|
||||
chatId,
|
||||
closeActiveAssistantStream,
|
||||
client,
|
||||
clearActivitySegment,
|
||||
clearPendingStreamWork,
|
||||
@@ -1114,7 +1064,6 @@ export function useNanobotStream(
|
||||
isSideChannelEvent,
|
||||
onTurnEnd,
|
||||
schedulePendingStreamFlush,
|
||||
scheduleStreamEndTimer,
|
||||
]);
|
||||
|
||||
const send = useCallback(
|
||||
@@ -1133,7 +1082,6 @@ export function useNanobotStream(
|
||||
: content;
|
||||
flushPendingStreamEvents();
|
||||
if (finalizeActiveTurn) {
|
||||
cancelStreamEndTimer();
|
||||
setIsStreaming(false);
|
||||
}
|
||||
const turnId = crypto.randomUUID();
|
||||
@@ -1188,7 +1136,7 @@ export function useNanobotStream(
|
||||
client.sendMessage(chatId, outboundContent, wireMedia, clientOptions);
|
||||
return { turnId, userMessageId, sideChannel };
|
||||
},
|
||||
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||
[chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
@@ -1209,7 +1157,6 @@ export function useNanobotStream(
|
||||
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
||||
|
||||
const reconcileTurnComplete = useCallback(() => {
|
||||
cancelStreamEndTimer();
|
||||
clearPendingStreamWork();
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
@@ -1218,7 +1165,7 @@ export function useNanobotStream(
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
setRunStartedAt(null);
|
||||
setIsStreaming(false);
|
||||
}, [cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]);
|
||||
}, [clearActivitySegment, clearPendingStreamWork]);
|
||||
|
||||
const transcribeAudio = useCallback(
|
||||
(dataUrl: string, options?: { durationMs?: number }) =>
|
||||
|
||||
@@ -1192,6 +1192,11 @@
|
||||
"removeQuotedContext": "Remove quoted context",
|
||||
"modelNotConfigured": "Model not configured",
|
||||
"configureModel": "Configure model",
|
||||
"switchModel": "Switch model for this chat",
|
||||
"context": {
|
||||
"tooltip": "Context · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}}% used."
|
||||
},
|
||||
"queued": {
|
||||
"label": "Queued guidance",
|
||||
"guide": "Guide",
|
||||
@@ -1424,7 +1429,12 @@
|
||||
"fileEditShowMoreLines": "Show {{count}} more lines",
|
||||
"fileEditShowFewerLines": "Show fewer lines",
|
||||
"fileEditOpenFile": "Open file",
|
||||
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change."
|
||||
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change.",
|
||||
"usage": {
|
||||
"context": "Context now: {{tokens}}{{capacity}}",
|
||||
"requests": "{{count}} calls this turn",
|
||||
"estimated": "Includes estimated usage"
|
||||
}
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Image preview",
|
||||
|
||||
@@ -1179,6 +1179,11 @@
|
||||
"removeQuotedContext": "Quitar contexto citado",
|
||||
"modelNotConfigured": "Modelo no configurado",
|
||||
"configureModel": "Configurar modelo",
|
||||
"switchModel": "Cambiar el modelo de este chat",
|
||||
"context": {
|
||||
"tooltip": "Contexto · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}} % usado."
|
||||
},
|
||||
"queued": {
|
||||
"label": "Guía en cola",
|
||||
"guide": "Guiar",
|
||||
@@ -1395,6 +1400,11 @@
|
||||
"fileEditShowFewerLines": "Mostrar menos líneas",
|
||||
"fileEditOpenFile": "Abrir archivo",
|
||||
"fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.",
|
||||
"usage": {
|
||||
"context": "Contexto más reciente: {{tokens}}{{capacity}} tokens",
|
||||
"requests": "{{count}} solicitudes al modelo",
|
||||
"estimated": "Incluye uso estimado"
|
||||
},
|
||||
"activityThinkingFor": "Pensando durante {{duration}}",
|
||||
"activityThought": "Pensamiento completado",
|
||||
"activityThoughtFor": "Pensó durante {{duration}}",
|
||||
|
||||
@@ -1178,6 +1178,11 @@
|
||||
"removeQuotedContext": "Supprimer le contexte cité",
|
||||
"modelNotConfigured": "Modèle non configuré",
|
||||
"configureModel": "Configurer le modèle",
|
||||
"switchModel": "Changer le modèle de cette conversation",
|
||||
"context": {
|
||||
"tooltip": "Contexte · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}} % utilisé."
|
||||
},
|
||||
"queued": {
|
||||
"label": "Guidage en attente",
|
||||
"guide": "Guider",
|
||||
@@ -1394,6 +1399,11 @@
|
||||
"fileEditShowFewerLines": "Afficher moins de lignes",
|
||||
"fileEditOpenFile": "Ouvrir le fichier",
|
||||
"fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.",
|
||||
"usage": {
|
||||
"context": "Dernier contexte : {{tokens}}{{capacity}} tokens",
|
||||
"requests": "{{count}} requêtes au modèle",
|
||||
"estimated": "Inclut une estimation de l’utilisation"
|
||||
},
|
||||
"activityThinkingFor": "Réflexion pendant {{duration}}",
|
||||
"activityThought": "Réflexion terminée",
|
||||
"activityThoughtFor": "Réflexion terminée en {{duration}}",
|
||||
|
||||
@@ -1178,6 +1178,11 @@
|
||||
"removeQuotedContext": "Hapus konteks kutipan",
|
||||
"modelNotConfigured": "Model belum dikonfigurasi",
|
||||
"configureModel": "Konfigurasi model",
|
||||
"switchModel": "Ganti model untuk percakapan ini",
|
||||
"context": {
|
||||
"tooltip": "Konteks · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}}% digunakan."
|
||||
},
|
||||
"queued": {
|
||||
"label": "Panduan antrean",
|
||||
"guide": "Pandu",
|
||||
@@ -1394,6 +1399,11 @@
|
||||
"fileEditShowFewerLines": "Tampilkan lebih sedikit baris",
|
||||
"fileEditOpenFile": "Buka file",
|
||||
"fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.",
|
||||
"usage": {
|
||||
"context": "Konteks terbaru: {{tokens}}{{capacity}} token",
|
||||
"requests": "{{count}} permintaan model",
|
||||
"estimated": "Termasuk penggunaan perkiraan"
|
||||
},
|
||||
"activityThinkingFor": "Berpikir selama {{duration}}",
|
||||
"activityThought": "Selesai berpikir",
|
||||
"activityThoughtFor": "Selesai berpikir dalam {{duration}}",
|
||||
|
||||
@@ -1178,6 +1178,11 @@
|
||||
"removeQuotedContext": "引用したコンテキストを削除",
|
||||
"modelNotConfigured": "モデルが未設定です",
|
||||
"configureModel": "モデルを設定",
|
||||
"switchModel": "この会話で使うモデルを切り替える",
|
||||
"context": {
|
||||
"tooltip": "コンテキスト · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}。{{percent}}% 使用中"
|
||||
},
|
||||
"queued": {
|
||||
"label": "保留中のガイド",
|
||||
"guide": "ガイド",
|
||||
@@ -1394,6 +1399,11 @@
|
||||
"fileEditShowFewerLines": "表示行数を減らす",
|
||||
"fileEditOpenFile": "ファイルを開く",
|
||||
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
|
||||
"usage": {
|
||||
"context": "最新のコンテキスト: {{tokens}}{{capacity}} トークン",
|
||||
"requests": "モデルリクエスト {{count}} 回",
|
||||
"estimated": "推定使用量を含みます"
|
||||
},
|
||||
"activityThinkingFor": "{{duration}}考えています",
|
||||
"activityThought": "思考しました",
|
||||
"activityThoughtFor": "{{duration}}考えました",
|
||||
|
||||
@@ -1178,6 +1178,11 @@
|
||||
"removeQuotedContext": "인용한 문맥 제거",
|
||||
"modelNotConfigured": "모델이 설정되지 않음",
|
||||
"configureModel": "모델 설정",
|
||||
"switchModel": "이 대화에서 사용할 모델 전환",
|
||||
"context": {
|
||||
"tooltip": "컨텍스트 · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}}% 사용 중."
|
||||
},
|
||||
"queued": {
|
||||
"label": "대기 중인 안내",
|
||||
"guide": "안내",
|
||||
@@ -1394,6 +1399,11 @@
|
||||
"fileEditShowFewerLines": "줄 줄이기",
|
||||
"fileEditOpenFile": "파일 열기",
|
||||
"fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
|
||||
"usage": {
|
||||
"context": "최근 컨텍스트: {{tokens}}{{capacity}} 토큰",
|
||||
"requests": "모델 요청 {{count}}회",
|
||||
"estimated": "예상 사용량 포함"
|
||||
},
|
||||
"activityThinkingFor": "{{duration}} 동안 생각 중",
|
||||
"activityThought": "생각함",
|
||||
"activityThoughtFor": "{{duration}} 동안 생각함",
|
||||
|
||||
@@ -1192,6 +1192,11 @@
|
||||
"removeQuotedContext": "Remover contexto citado",
|
||||
"modelNotConfigured": "Modelo não configurado",
|
||||
"configureModel": "Configurar modelo",
|
||||
"switchModel": "Alternar o modelo desta conversa",
|
||||
"context": {
|
||||
"tooltip": "Contexto · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. {{percent}}% usado."
|
||||
},
|
||||
"queued": {
|
||||
"label": "Guia em fila",
|
||||
"guide": "Guiar",
|
||||
@@ -1424,7 +1429,12 @@
|
||||
"fileEditShowMoreLines": "Mostrar mais {{count}} linhas",
|
||||
"fileEditShowFewerLines": "Mostrar menos linhas",
|
||||
"fileEditOpenFile": "Abrir arquivo",
|
||||
"fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa."
|
||||
"fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa.",
|
||||
"usage": {
|
||||
"context": "Contexto atual: {{tokens}}{{capacity}}",
|
||||
"requests": "{{count}} chamadas nesta rodada",
|
||||
"estimated": "Inclui uso estimado"
|
||||
}
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Pré-visualização de imagem",
|
||||
|
||||
@@ -1178,6 +1178,11 @@
|
||||
"removeQuotedContext": "Xóa ngữ cảnh được trích dẫn",
|
||||
"modelNotConfigured": "Chưa cấu hình mô hình",
|
||||
"configureModel": "Cấu hình mô hình",
|
||||
"switchModel": "Chuyển mô hình cho cuộc trò chuyện này",
|
||||
"context": {
|
||||
"tooltip": "Ngữ cảnh · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}. Đã dùng {{percent}}%."
|
||||
},
|
||||
"queued": {
|
||||
"label": "Hướng dẫn đang chờ",
|
||||
"guide": "Hướng dẫn",
|
||||
@@ -1394,6 +1399,11 @@
|
||||
"fileEditShowFewerLines": "Hiển thị ít dòng hơn",
|
||||
"fileEditOpenFile": "Mở tệp",
|
||||
"fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.",
|
||||
"usage": {
|
||||
"context": "Ngữ cảnh gần nhất: {{tokens}}{{capacity}} token",
|
||||
"requests": "{{count}} yêu cầu mô hình",
|
||||
"estimated": "Bao gồm mức sử dụng ước tính"
|
||||
},
|
||||
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
|
||||
"activityThought": "Đã suy nghĩ",
|
||||
"activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
|
||||
|
||||
@@ -1191,6 +1191,11 @@
|
||||
"removeQuotedContext": "移除引用内容",
|
||||
"modelNotConfigured": "模型未配置",
|
||||
"configureModel": "配置模型",
|
||||
"switchModel": "切换本次对话所用模型",
|
||||
"context": {
|
||||
"tooltip": "上下文 · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}。已使用 {{percent}}%。"
|
||||
},
|
||||
"queued": {
|
||||
"label": "排队中的引导消息",
|
||||
"guide": "引导",
|
||||
@@ -1424,7 +1429,12 @@
|
||||
"fileEditShowMoreLines": "显示剩余 {{count}} 行",
|
||||
"fileEditShowFewerLines": "收起部分行",
|
||||
"fileEditOpenFile": "打开文件",
|
||||
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。"
|
||||
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。",
|
||||
"usage": {
|
||||
"context": "当前上下文:{{tokens}}{{capacity}}",
|
||||
"requests": "本轮 {{count}} 次模型调用",
|
||||
"estimated": "包含估算用量"
|
||||
}
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "图片预览",
|
||||
|
||||
@@ -1178,6 +1178,11 @@
|
||||
"removeQuotedContext": "移除引用內容",
|
||||
"modelNotConfigured": "尚未設定模型",
|
||||
"configureModel": "設定模型",
|
||||
"switchModel": "切換此對話使用的模型",
|
||||
"context": {
|
||||
"tooltip": "上下文 · {{tokens}}{{capacity}}",
|
||||
"meterDescription": "{{context}}。已使用 {{percent}}%。"
|
||||
},
|
||||
"queued": {
|
||||
"label": "佇列中的引導訊息",
|
||||
"guide": "引導",
|
||||
@@ -1393,6 +1398,11 @@
|
||||
"fileEditShowFewerLines": "收起部分行",
|
||||
"fileEditOpenFile": "開啟檔案",
|
||||
"fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。",
|
||||
"usage": {
|
||||
"context": "最近一次上下文:{{tokens}}{{capacity}} tokens",
|
||||
"requests": "{{count}} 次模型請求",
|
||||
"estimated": "包含估算用量"
|
||||
},
|
||||
"activityThinkingFor": "思考中,已 {{duration}}",
|
||||
"activityThought": "已思考",
|
||||
"activityThoughtFor": "已思考 {{duration}}",
|
||||
|
||||
+199
-184
@@ -1,5 +1,8 @@
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
/** A completed turn has two surfaces: one activity container and one final
|
||||
* answer. An active turn temporarily preserves arrival order so visible
|
||||
* Markdown never moves when a later tool starts. */
|
||||
export type TurnUnit =
|
||||
| {
|
||||
type: "activity";
|
||||
@@ -9,106 +12,103 @@ export type TurnUnit =
|
||||
}
|
||||
| { type: "message"; message: UIMessage };
|
||||
|
||||
interface NormalizeActivityTimelineOptions {
|
||||
preserveTrailingActivity?: boolean;
|
||||
}
|
||||
|
||||
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
|
||||
if (message.role !== "assistant" || message.kind === "trace") return false;
|
||||
if (message.content.trim().length > 0) return false;
|
||||
if (message.activityKind === "model" || message.content.trim().length > 0) return false;
|
||||
return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming);
|
||||
}
|
||||
|
||||
export function isAgentActivityMember(message: UIMessage): boolean {
|
||||
return isReasoningOnlyAssistant(message) || message.kind === "trace";
|
||||
return isReasoningOnlyAssistant(message) || message.kind === "trace" || message.activityKind === "model";
|
||||
}
|
||||
|
||||
export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
|
||||
if (messages.length === 0) return false;
|
||||
const last = messages[messages.length - 1];
|
||||
if (!isAgentActivityMember(last)) return false;
|
||||
const last = messages.at(-1);
|
||||
if (!last || !isAgentActivityMember(last)) return false;
|
||||
if (last.isStreaming || last.reasoningStreaming) return true;
|
||||
|
||||
let trailingStart = messages.length - 1;
|
||||
while (
|
||||
trailingStart > 0
|
||||
&& isAgentActivityMember(messages[trailingStart - 1])
|
||||
) {
|
||||
trailingStart -= 1;
|
||||
}
|
||||
|
||||
const trailing = messages.slice(trailingStart);
|
||||
if (trailing.some((message) => message.isStreaming || message.reasoningStreaming)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previous = messages[trailingStart - 1];
|
||||
if (!previous || previous.role !== "assistant" || isAgentActivityMember(previous)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const trailingTurnIds = new Set(
|
||||
trailing
|
||||
.map((message) => message.turnId)
|
||||
.filter((turnId): turnId is string => typeof turnId === "string" && turnId.length > 0),
|
||||
);
|
||||
if (!previous.turnId) return trailingTurnIds.size > 0;
|
||||
return trailingTurnIds.size > 0 && !trailingTurnIds.has(previous.turnId);
|
||||
const lastTurnId = last.turnId;
|
||||
const previous = messages.at(-2);
|
||||
// A trace without a visible answer is an unfinished turn on replay. Once a
|
||||
// final assistant answer exists after it, the activity is simply history.
|
||||
return !previous
|
||||
|| previous.role !== "assistant"
|
||||
|| isAgentActivityMember(previous)
|
||||
|| previous.turnId !== lastTurnId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project completed or replayed gateway rows into the stable shape:
|
||||
*
|
||||
* user → [one live/completed activity surface] → [one final answer]
|
||||
*
|
||||
* Assistant text that is followed by another activity is an intermediate model
|
||||
* segment. It remains in the activity timeline, where the renderer keeps its
|
||||
* normal Markdown surface, but it never creates a second answer bubble. This
|
||||
* is the same causal model used by Codex-style transcripts.
|
||||
*/
|
||||
export function normalizeActivityTimeline(
|
||||
messages: UIMessage[],
|
||||
options: NormalizeActivityTimelineOptions = {},
|
||||
): TurnUnit[] {
|
||||
const units: TurnUnit[] = [];
|
||||
let turnMessages: UIMessage[] = [];
|
||||
let activeTurnId: string | undefined;
|
||||
let activeTurnStartedAtMs: number | undefined;
|
||||
|
||||
const flushTurn = (flushOptions: NormalizeActivityTimelineOptions = {}) => {
|
||||
if (turnMessages.length === 0) {
|
||||
const flushTurn = () => {
|
||||
if (!turnMessages.length) {
|
||||
activeTurnId = undefined;
|
||||
activeTurnStartedAtMs = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const turnUnits: TurnUnit[] = [];
|
||||
const turnStartedAtMs = activeTurnStartedAtMs;
|
||||
const orderedTurnMessages = orderMessagesByTurnSeq(turnMessages);
|
||||
const visibleMessages = visibleMessagesForTurn(orderedTurnMessages);
|
||||
let visibleIndex = 0;
|
||||
let activityMessages: UIMessage[] = [];
|
||||
const ordered = orderMessagesByTurnSeq(turnMessages);
|
||||
const lastActivityIndex = ordered.reduce(
|
||||
(index, message, current) => isRawActivity(message) ? current : index,
|
||||
-1,
|
||||
);
|
||||
const answerIndices = ordered
|
||||
.map((message, index) => ({ message, index }))
|
||||
.filter(({ message }) => isAssistantAnswer(message));
|
||||
const finalAnswerIndex = answerIndices.at(-1)?.index;
|
||||
// A replay can deliver a completed answer before a late trace row. Keep
|
||||
// that answer visible, but place the late activity in the single activity
|
||||
// surface before it. An answer followed by more activity is an
|
||||
// intermediate model segment and stays in that surface in turn order.
|
||||
const hasFinalAnswer = finalAnswerIndex !== undefined
|
||||
&& (finalAnswerIndex > lastActivityIndex || ordered[finalAnswerIndex].isStreaming !== true);
|
||||
|
||||
const flushActivityMessages = () => {
|
||||
if (!activityMessages.length) return;
|
||||
pushActivityUnits(
|
||||
turnUnits,
|
||||
activityMessages,
|
||||
visibleMessages.slice(visibleIndex),
|
||||
turnStartedAtMs,
|
||||
);
|
||||
activityMessages = [];
|
||||
};
|
||||
|
||||
for (const message of orderedTurnMessages) {
|
||||
if (isAgentActivityMember(message)) {
|
||||
activityMessages.push(message);
|
||||
continue;
|
||||
const activity: UIMessage[] = [];
|
||||
const answers: UIMessage[] = [];
|
||||
ordered.forEach((message, index) => {
|
||||
if (isRawActivity(message)) {
|
||||
activity.push(message);
|
||||
} else if (isAssistantAnswer(message)) {
|
||||
if (message.reasoning?.trim() || message.reasoningStreaming) {
|
||||
activity.push(reasoningOnlyMessageFromAnswer(message));
|
||||
}
|
||||
if (hasFinalAnswer && index === finalAnswerIndex) {
|
||||
answers.push(stripInlineReasoning(message));
|
||||
} else {
|
||||
activity.push(modelActivitySnippet(message));
|
||||
}
|
||||
} else {
|
||||
activity.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
if (assistantHasInlineReasoning(message)) {
|
||||
activityMessages.push(reasoningOnlyMessageFromAnswer(message));
|
||||
flushActivityMessages();
|
||||
turnUnits.push({ type: "message", message: stripInlineReasoning(message) });
|
||||
visibleIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushActivityMessages();
|
||||
turnUnits.push({ type: "message", message });
|
||||
visibleIndex += 1;
|
||||
if (activity.length) {
|
||||
units.push({
|
||||
type: "activity",
|
||||
messages: activity,
|
||||
turnLatencyMs: activityTurnLatencyMs(activity, ordered),
|
||||
startedAtMs: activeTurnStartedAtMs,
|
||||
});
|
||||
}
|
||||
if (answers.length) {
|
||||
units.push({ type: "message", message: mergeAssistantAnswers(answers) });
|
||||
}
|
||||
|
||||
flushActivityMessages();
|
||||
units.push(...normalizeCompletedTurnUnits(turnUnits, flushOptions));
|
||||
turnMessages = [];
|
||||
activeTurnId = undefined;
|
||||
activeTurnStartedAtMs = undefined;
|
||||
@@ -122,131 +122,142 @@ export function normalizeActivityTimeline(
|
||||
activeTurnStartedAtMs = validCreatedAtMs(message.createdAt);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) {
|
||||
flushTurn();
|
||||
}
|
||||
if (message.turnId) {
|
||||
activeTurnId = message.turnId;
|
||||
}
|
||||
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) flushTurn();
|
||||
if (message.turnId) activeTurnId = message.turnId;
|
||||
turnMessages.push(message);
|
||||
}
|
||||
|
||||
flushTurn(options);
|
||||
flushTurn();
|
||||
return units;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep an in-flight turn in arrival order. Until ``turn_end`` there is no
|
||||
* reliable way to know whether an assistant text segment is the final answer
|
||||
* or commentary before another tool call. Reclassifying it when that tool
|
||||
* arrives makes an already-visible Markdown tree jump between containers.
|
||||
*
|
||||
* Completed turns still use ``normalizeActivityTimeline`` and collapse into
|
||||
* one audit surface plus the final answer. While the turn is active, text and
|
||||
* contiguous activity phases stay in arrival order. A later tool therefore
|
||||
* appends a Working surface after existing Markdown instead of reparenting it.
|
||||
*/
|
||||
export function projectActivityTimeline(
|
||||
messages: UIMessage[],
|
||||
liveTurnId?: string | null,
|
||||
): TurnUnit[] {
|
||||
if (liveTurnId === undefined) return normalizeActivityTimeline(messages);
|
||||
|
||||
const liveStart = findLiveTurnStart(messages, liveTurnId);
|
||||
if (liveStart < 0) return normalizeActivityTimeline(messages);
|
||||
const nextPrompt = messages.findIndex(
|
||||
(message, index) => index > liveStart && message.role === "user",
|
||||
);
|
||||
const liveEnd = nextPrompt < 0 ? messages.length : nextPrompt;
|
||||
|
||||
return [
|
||||
...normalizeActivityTimeline(messages.slice(0, liveStart)),
|
||||
...projectLiveTurn(messages.slice(liveStart, liveEnd)),
|
||||
...normalizeActivityTimeline(messages.slice(liveEnd)),
|
||||
];
|
||||
}
|
||||
|
||||
function findLiveTurnStart(messages: UIMessage[], liveTurnId: string | null): number {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.role !== "user") continue;
|
||||
if (liveTurnId === null || message.turnId === liveTurnId) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function projectLiveTurn(messages: UIMessage[]): TurnUnit[] {
|
||||
const units: TurnUnit[] = [];
|
||||
const prompt = messages[0];
|
||||
const startedAtMs = prompt?.role === "user" ? validCreatedAtMs(prompt.createdAt) : undefined;
|
||||
let activity: UIMessage[] = [];
|
||||
|
||||
if (prompt?.role === "user") units.push({ type: "message", message: prompt });
|
||||
|
||||
const flushActivity = () => {
|
||||
if (!activity.length) return;
|
||||
units.push({
|
||||
type: "activity",
|
||||
messages: activity,
|
||||
turnLatencyMs: activityTurnLatencyMs(activity, activity),
|
||||
startedAtMs,
|
||||
});
|
||||
activity = [];
|
||||
};
|
||||
|
||||
for (const message of messages.slice(prompt?.role === "user" ? 1 : 0)) {
|
||||
if (isRawActivity(message)) {
|
||||
activity.push(message);
|
||||
continue;
|
||||
}
|
||||
if (isAssistantAnswer(message)) {
|
||||
if (message.reasoning?.trim() || message.reasoningStreaming) {
|
||||
activity.push(reasoningOnlyMessageFromAnswer(message));
|
||||
}
|
||||
flushActivity();
|
||||
units.push({ type: "message", message: stripInlineReasoning(message) });
|
||||
continue;
|
||||
}
|
||||
activity.push(message);
|
||||
}
|
||||
|
||||
flushActivity();
|
||||
return units;
|
||||
}
|
||||
|
||||
function isRawActivity(message: UIMessage): boolean {
|
||||
return isAgentActivityMember(message);
|
||||
}
|
||||
|
||||
function isAssistantAnswer(message: UIMessage): boolean {
|
||||
return message.role === "assistant" && message.kind !== "trace" && message.content.trim().length > 0;
|
||||
}
|
||||
|
||||
function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
|
||||
if (
|
||||
messages.length < 2
|
||||
|| !messages.every((message) => Number.isFinite(message.turnSeq))
|
||||
) {
|
||||
if (messages.length < 2 || !messages.every((message) => Number.isFinite(message.turnSeq))) {
|
||||
return messages;
|
||||
}
|
||||
return messages
|
||||
.map((message, index) => ({ message, index }))
|
||||
.sort((left, right) => {
|
||||
const bySeq = (left.message.turnSeq ?? 0) - (right.message.turnSeq ?? 0);
|
||||
return bySeq || left.index - right.index;
|
||||
})
|
||||
.sort((left, right) => (left.message.turnSeq! - right.message.turnSeq!) || (left.index - right.index))
|
||||
.map(({ message }) => message);
|
||||
}
|
||||
|
||||
function normalizeCompletedTurnUnits(
|
||||
turnUnits: TurnUnit[],
|
||||
options: NormalizeActivityTimelineOptions,
|
||||
): TurnUnit[] {
|
||||
if (options.preserveTrailingActivity || turnUnits.length < 2) return turnUnits;
|
||||
if (turnUnits[turnUnits.length - 1]?.type !== "activity") return turnUnits;
|
||||
|
||||
let trailingStart = turnUnits.length - 1;
|
||||
while (trailingStart > 0 && turnUnits[trailingStart - 1]?.type === "activity") {
|
||||
trailingStart -= 1;
|
||||
}
|
||||
|
||||
const previous = turnUnits[trailingStart - 1];
|
||||
if (
|
||||
!previous
|
||||
|| previous.type !== "message"
|
||||
|| previous.message.role !== "assistant"
|
||||
) {
|
||||
return turnUnits;
|
||||
}
|
||||
|
||||
return [
|
||||
...turnUnits.slice(0, trailingStart - 1),
|
||||
...turnUnits.slice(trailingStart),
|
||||
previous,
|
||||
];
|
||||
}
|
||||
|
||||
function visibleMessagesForTurn(messages: UIMessage[]): UIMessage[] {
|
||||
const visibleMessages: UIMessage[] = [];
|
||||
for (const message of messages) {
|
||||
if (isAgentActivityMember(message)) continue;
|
||||
visibleMessages.push(assistantHasInlineReasoning(message) ? stripInlineReasoning(message) : message);
|
||||
}
|
||||
return visibleMessages;
|
||||
}
|
||||
|
||||
function validCreatedAtMs(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function pushActivityUnits(
|
||||
units: TurnUnit[],
|
||||
activityMessages: UIMessage[],
|
||||
visibleMessages: UIMessage[],
|
||||
startedAtMs?: number,
|
||||
) {
|
||||
let runMessages: UIMessage[] = [];
|
||||
let runBucket: "file" | "other" | undefined;
|
||||
let runSegmentId: string | undefined;
|
||||
|
||||
const flushRun = () => {
|
||||
if (!runMessages.length) return;
|
||||
units.push({
|
||||
type: "activity",
|
||||
messages: runMessages,
|
||||
turnLatencyMs: activityTurnLatencyMs(runMessages, visibleMessages),
|
||||
startedAtMs,
|
||||
});
|
||||
runMessages = [];
|
||||
runBucket = undefined;
|
||||
runSegmentId = undefined;
|
||||
function mergeAssistantAnswers(answers: UIMessage[]): UIMessage {
|
||||
const first = answers[0];
|
||||
const last = answers.at(-1)!;
|
||||
const media = answers.flatMap((message) => message.media ?? []);
|
||||
const images = answers.flatMap((message) => message.images ?? []);
|
||||
const merged: UIMessage = {
|
||||
...first,
|
||||
...last,
|
||||
id: first.id,
|
||||
content: answers.map((message) => message.content.trim()).filter(Boolean).join("\n\n"),
|
||||
createdAt: first.createdAt,
|
||||
isStreaming: answers.some((message) => message.isStreaming),
|
||||
};
|
||||
|
||||
for (const message of activityMessages) {
|
||||
const bucket = isFileEditActivityMessage(message) ? "file" : "other";
|
||||
const segmentId = message.activitySegmentId;
|
||||
const segmentChanged =
|
||||
bucket === "file"
|
||||
&& runBucket === "file"
|
||||
&& !!runSegmentId
|
||||
&& !!segmentId
|
||||
&& runSegmentId !== segmentId;
|
||||
if ((runBucket && bucket !== runBucket) || segmentChanged) {
|
||||
flushRun();
|
||||
}
|
||||
runBucket = bucket;
|
||||
if (segmentId) runSegmentId = segmentId;
|
||||
runMessages.push(message);
|
||||
}
|
||||
|
||||
flushRun();
|
||||
if (media.length) merged.media = media;
|
||||
else delete merged.media;
|
||||
if (images.length) merged.images = images;
|
||||
else delete merged.images;
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isFileEditActivityMessage(message: UIMessage): boolean {
|
||||
return message.kind === "trace" && !!message.fileEdits?.length;
|
||||
}
|
||||
|
||||
function assistantHasInlineReasoning(message: UIMessage): boolean {
|
||||
return (
|
||||
message.role === "assistant"
|
||||
&& message.kind !== "trace"
|
||||
&& message.content.trim().length > 0
|
||||
&& (!!message.reasoning?.trim() || !!message.reasoningStreaming)
|
||||
);
|
||||
function modelActivitySnippet(message: UIMessage): UIMessage {
|
||||
return {
|
||||
...stripInlineReasoning(message),
|
||||
id: `${message.id}-activity`,
|
||||
activityKind: "model",
|
||||
turnPhase: "activity",
|
||||
// Keep the source stream state so the activity surface can render this
|
||||
// segment with the same Markdown streaming semantics as a normal answer.
|
||||
isStreaming: message.isStreaming,
|
||||
};
|
||||
}
|
||||
|
||||
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
|
||||
@@ -273,13 +284,17 @@ function stripInlineReasoning(message: UIMessage): UIMessage {
|
||||
return next;
|
||||
}
|
||||
|
||||
function activityTurnLatencyMs(activityMessages: UIMessage[], visibleMessages: UIMessage[]): number | undefined {
|
||||
for (let i = visibleMessages.length - 1; i >= 0; i -= 1) {
|
||||
const latency = visibleMessages[i].latencyMs;
|
||||
function validCreatedAtMs(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function activityTurnLatencyMs(activityMessages: UIMessage[], allMessages: UIMessage[]): number | undefined {
|
||||
for (let index = allMessages.length - 1; index >= 0; index -= 1) {
|
||||
const latency = allMessages[index].latencyMs;
|
||||
if (isValidLatency(latency)) return latency;
|
||||
}
|
||||
for (let i = activityMessages.length - 1; i >= 0; i -= 1) {
|
||||
const latency = activityMessages[i].latencyMs;
|
||||
for (let index = activityMessages.length - 1; index >= 0; index -= 1) {
|
||||
const latency = activityMessages[index].latencyMs;
|
||||
if (isValidLatency(latency)) return latency;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import i18n, { currentLocale } from "@/i18n";
|
||||
|
||||
/** Compact token counts for dense runtime metadata (for example, 74.9K). */
|
||||
export function formatCompactTokenCount(value: number): string {
|
||||
if (value < 1_000) return Math.round(value).toLocaleString();
|
||||
if (value < 1_000_000) {
|
||||
const digits = value < 100_000 ? 1 : 0;
|
||||
return `${Number((value / 1_000).toFixed(digits))}K`;
|
||||
}
|
||||
const digits = value < 100_000_000 ? 1 : 0;
|
||||
return `${Number((value / 1_000_000).toFixed(digits))}M`;
|
||||
}
|
||||
|
||||
const LOW_INFORMATION_TITLE_PREVIEWS = new Set([
|
||||
"hi",
|
||||
"hello",
|
||||
|
||||
@@ -136,7 +136,7 @@ export function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
|
||||
|
||||
export function stampLastAssistantCompletion(
|
||||
prev: UIMessage[],
|
||||
completion: Pick<UIMessage, "latencyMs" | "completedAt">,
|
||||
completion: Pick<UIMessage, "latencyMs" | "completedAt" | "usage" | "contextWindowTokens">,
|
||||
turnId?: string,
|
||||
): UIMessage[] {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
|
||||
+26
-1
@@ -39,6 +39,16 @@ export interface UIMediaAttachment {
|
||||
|
||||
export interface UIMessageSource { kind: "cron" | "local_trigger" | "trigger" | string; label?: string; }
|
||||
|
||||
export interface TurnUsage {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
cached_tokens?: number;
|
||||
context_tokens?: number;
|
||||
request_count?: number;
|
||||
estimated_tokens?: number;
|
||||
[key: string]: number | undefined;
|
||||
}
|
||||
|
||||
export interface UIMessage {
|
||||
id: string;
|
||||
role: Role;
|
||||
@@ -56,6 +66,9 @@ export interface UIMessage {
|
||||
fileEdits?: UIFileEdit[];
|
||||
/** Activity rows created during the same agent phase share one collapsible block. */
|
||||
activitySegmentId?: string;
|
||||
/** Internal projection marker for assistant text emitted before a later tool.
|
||||
* It is not a wire message and is rendered as a compact activity row. */
|
||||
activityKind?: "model";
|
||||
/** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */
|
||||
images?: UIImage[];
|
||||
/** Signed or local UI-renderable media attachments. */
|
||||
@@ -77,6 +90,10 @@ export interface UIMessage {
|
||||
latencyMs?: number;
|
||||
/** Client epoch milliseconds when the definitive ``turn_end`` was received. */
|
||||
completedAt?: number;
|
||||
/** Additive model usage for this turn; context_tokens is the final request only. */
|
||||
usage?: TurnUsage;
|
||||
/** Configured context-window capacity for the model used by this turn. */
|
||||
contextWindowTokens?: number;
|
||||
/** Lightweight provenance for proactive assistant messages. */
|
||||
source?: UIMessageSource;
|
||||
/** Structured provenance for a message delivered by another session. */
|
||||
@@ -1225,7 +1242,12 @@ export interface InboundTurnMetadata {
|
||||
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| { event: "attached"; chat_id: string; temporary?: boolean }
|
||||
| {
|
||||
event: "attached";
|
||||
chat_id: string;
|
||||
temporary?: boolean;
|
||||
usage?: TurnUsage;
|
||||
}
|
||||
| {
|
||||
event: "message_accepted";
|
||||
chat_id: string;
|
||||
@@ -1313,11 +1335,14 @@ export type InboundEvent =
|
||||
chat_id: string;
|
||||
model_name: string;
|
||||
model_preset?: string | null;
|
||||
fallback?: boolean;
|
||||
}
|
||||
| ({
|
||||
event: "turn_end";
|
||||
chat_id: string;
|
||||
latency_ms?: number;
|
||||
usage?: TurnUsage;
|
||||
context_window_tokens?: number;
|
||||
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
|
||||
goal_state?: GoalStateWsPayload;
|
||||
} & InboundTurnMetadata)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import { preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import { DEFAULT_LOCAL_PREFS, writeLocalPreferences } from "@/lib/local-preferences";
|
||||
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
||||
|
||||
@@ -139,6 +140,34 @@ function installReducedMotion() {
|
||||
}
|
||||
|
||||
describe("AgentActivityCluster", () => {
|
||||
it("keeps intermediate assistant output as normal Markdown inside live activity", async () => {
|
||||
await act(async () => {
|
||||
await preloadMarkdownText();
|
||||
});
|
||||
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={[
|
||||
{
|
||||
id: "model-activity",
|
||||
role: "assistant",
|
||||
content: "**partial answer**",
|
||||
activityKind: "model",
|
||||
isStreaming: true,
|
||||
createdAt: 1,
|
||||
},
|
||||
]}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const block = screen.getByTestId("activity-model-message");
|
||||
await waitFor(() => expect(block.querySelector("strong")).not.toBeNull());
|
||||
expect(block.querySelector("strong")).toHaveTextContent("partial answer");
|
||||
expect(screen.queryByTestId("activity-step")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("jumps to the latest activity when opened", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
@@ -398,7 +427,7 @@ describe("AgentActivityCluster", () => {
|
||||
vi.advanceTimersByTime(301);
|
||||
});
|
||||
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Thought" })).toHaveAttribute(
|
||||
expect(screen.getByRole("button", { name: "Worked" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
@@ -422,7 +451,7 @@ describe("AgentActivityCluster", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Thought" });
|
||||
const button = screen.getByRole("button", { name: "Worked" });
|
||||
expect(button).toHaveAttribute("data-thread-disclosure");
|
||||
const chevron = button.querySelector("svg");
|
||||
expect(chevron).toBeInTheDocument();
|
||||
@@ -449,7 +478,7 @@ describe("AgentActivityCluster", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Thought for 12s")).toBeInTheDocument();
|
||||
expect(screen.getByText("Worked for 12s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels mixed tool activity as work instead of thought", () => {
|
||||
@@ -481,8 +510,8 @@ describe("AgentActivityCluster", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Thought")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Thought for 0s")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Worked")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders file edits as one-line activity rows", async () => {
|
||||
|
||||
@@ -245,6 +245,7 @@ describe("MessageBubble", () => {
|
||||
|
||||
const quote = screen.getByLabelText("Quoted context");
|
||||
expect(quote).toHaveTextContent("selected assistant excerpt");
|
||||
expect(quote).not.toHaveAttribute("title");
|
||||
expect(screen.queryByText("Quoted context")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("What about this?")).toBeInTheDocument();
|
||||
|
||||
@@ -1011,4 +1012,50 @@ describe("MessageBubble", () => {
|
||||
expect(container.querySelector('img[src="/api/media/sig/svg"]')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps turn usage focused on the completed reply", () => {
|
||||
const message: UIMessage = {
|
||||
id: "a-usage",
|
||||
role: "assistant",
|
||||
content: "done",
|
||||
createdAt: Date.now(),
|
||||
latencyMs: 18_200,
|
||||
contextWindowTokens: 128_000,
|
||||
usage: {
|
||||
prompt_tokens: 12_400,
|
||||
completion_tokens: 823,
|
||||
cached_tokens: 9_672,
|
||||
context_tokens: 8_100,
|
||||
request_count: 3,
|
||||
},
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} />);
|
||||
|
||||
const usage = screen.getByText("12.4K in · 823 out · 78% cached · 18s");
|
||||
expect(usage).toHaveAttribute("data-turn-usage");
|
||||
expect(usage).not.toHaveAttribute("tabindex");
|
||||
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks estimated usage and omits cache when the provider did not report it", () => {
|
||||
const message: UIMessage = {
|
||||
id: "a-estimated-usage",
|
||||
role: "assistant",
|
||||
content: "done",
|
||||
createdAt: Date.now(),
|
||||
usage: {
|
||||
prompt_tokens: 1_250,
|
||||
completion_tokens: 90,
|
||||
estimated_tokens: 1_340,
|
||||
},
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} />);
|
||||
|
||||
const usage = screen.getByText("~1.3K in · ~90 out");
|
||||
expect(usage).not.toHaveTextContent("cached");
|
||||
fireEvent.focus(usage);
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent("Includes estimated usage");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1724,6 +1724,7 @@ describe("NanobotClient", () => {
|
||||
chat_id: "chat-a",
|
||||
model_name: "deepseek/deepseek-chat",
|
||||
model_preset: "Deep Research",
|
||||
fallback: true,
|
||||
});
|
||||
|
||||
expect(chatHandler).toHaveBeenCalledWith({
|
||||
@@ -1731,6 +1732,7 @@ describe("NanobotClient", () => {
|
||||
chat_id: "chat-a",
|
||||
model_name: "deepseek/deepseek-chat",
|
||||
model_preset: "Deep Research",
|
||||
fallback: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -317,9 +317,9 @@ function ascii(bytes: Uint8Array, offset: number, length: number): string {
|
||||
}
|
||||
|
||||
const MODEL_PRESETS = [
|
||||
{ name: "kimi", provider: "moonshot" },
|
||||
{ name: "dflash", provider: "deepseek" },
|
||||
{ name: "dspro", provider: "deepseek" },
|
||||
{ name: "kimi", model: "moonshot/kimi-k2.5", provider: "moonshot" },
|
||||
{ name: "dflash", model: "deepseek/deepseek-v4-flash", provider: "deepseek" },
|
||||
{ name: "dspro", model: "deepseek/deepseek-v4-pro", provider: "deepseek" },
|
||||
];
|
||||
|
||||
function renderPresetComposer(variant: "thread" | "hero" = "thread") {
|
||||
@@ -337,28 +337,11 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") {
|
||||
/>,
|
||||
);
|
||||
return {
|
||||
badge: screen.getByRole("spinbutton", { name: "kimi" }),
|
||||
badge: screen.getByRole("button", { name: "kimi" }),
|
||||
onPresetChange,
|
||||
};
|
||||
}
|
||||
|
||||
function pointerDown(badge: HTMLElement, pointerId = 7, clientY = 100, button = 0) {
|
||||
fireEvent.pointerDown(badge, {
|
||||
button,
|
||||
clientY,
|
||||
isPrimary: true,
|
||||
pointerId,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
}
|
||||
|
||||
function longPress(badge: HTMLElement, pointerId = 7) {
|
||||
pointerDown(badge, pointerId);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
}
|
||||
|
||||
describe("ThreadComposer", () => {
|
||||
it("locks an async send and keeps the draft when it is rejected", async () => {
|
||||
let resolveSend!: (accepted: boolean) => void;
|
||||
@@ -538,12 +521,42 @@ describe("ThreadComposer", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = screen.getByRole("spinbutton", { name: "gpt-5.6-sol" });
|
||||
const badge = screen.getByRole("button", { name: "gpt-5.6-sol" });
|
||||
expect(badge).toHaveClass("w-fit", "max-w-[min(18rem,44vw)]");
|
||||
expect(badge).not.toHaveClass("w-[5.75rem]");
|
||||
expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a compact context meter beside the model selector", async () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
modelLabel="gpt-5.6-sol"
|
||||
modelPreset="gpt-5-6-sol"
|
||||
modelProvider="openai_codex"
|
||||
contextUsage={{
|
||||
contextTokens: 74_900,
|
||||
contextWindowTokens: 1_000_000,
|
||||
}}
|
||||
placeholder="Ask anything..."
|
||||
/>,
|
||||
);
|
||||
|
||||
const context = screen.getByTestId("composer-context-usage");
|
||||
expect(context).toHaveClass("size-5", "rounded-full");
|
||||
expect(context).not.toHaveTextContent("Context 74.9K / 1M");
|
||||
expect(screen.getByTestId("composer-context-meter")).toBeInTheDocument();
|
||||
expect(context).toHaveAccessibleName(
|
||||
"Context · 74.9K / 1M. 7% used.",
|
||||
);
|
||||
|
||||
fireEvent.focus(context);
|
||||
const tooltip = await screen.findByRole("tooltip");
|
||||
expect(tooltip).toHaveTextContent("Context · 74.9K / 1M");
|
||||
expect(tooltip.parentElement).toHaveClass("rounded-full", "px-2.5", "py-1");
|
||||
expect(tooltip.parentElement).not.toHaveTextContent("Available");
|
||||
});
|
||||
|
||||
it("keeps the thread composer compact while matching the hero style", () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
@@ -559,7 +572,9 @@ describe("ThreadComposer", () => {
|
||||
const modelPill = screen.getByText("gpt-4o").closest(".composer-model-pill");
|
||||
expect(modelPill).toHaveClass("font-medium", "text-foreground/70");
|
||||
expect(modelPill).not.toHaveClass("font-semibold");
|
||||
expect(screen.getByTestId("composer-model-logo-openai")).toBeInTheDocument();
|
||||
const providerLogo = screen.getByTestId("composer-model-logo-openai");
|
||||
expect(providerLogo).toBeInTheDocument();
|
||||
expect(providerLogo).not.toHaveClass("border", "bg-background");
|
||||
const input = screen.getByPlaceholderText("Type your message...");
|
||||
expect(input.className).toContain("min-h-[50px]");
|
||||
expect(input.className).toContain("text-[16px]");
|
||||
@@ -571,141 +586,53 @@ describe("ThreadComposer", () => {
|
||||
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows model details in the shared tooltip without a native title", async () => {
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
modelLabel="gpt-4o"
|
||||
modelDetail="gpt-4o"
|
||||
modelProvider="openai"
|
||||
modelProviderLabel="OpenAI"
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = screen.getByLabelText("gpt-4o");
|
||||
expect(badge).not.toHaveAttribute("title");
|
||||
fireEvent.focus(badge);
|
||||
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent("gpt-4o · OpenAI");
|
||||
});
|
||||
|
||||
it("smoothly cycles to the next preset on click", () => {
|
||||
vi.useFakeTimers();
|
||||
let runFrame: FrameRequestCallback | null = null;
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
runFrame = callback;
|
||||
return 1;
|
||||
});
|
||||
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
|
||||
const { badge, onPresetChange } = renderPresetComposer();
|
||||
|
||||
fireEvent.click(badge);
|
||||
|
||||
expect(badge).toHaveAttribute("data-switching", "true");
|
||||
const track = screen.getByTestId("composer-model-pill-track");
|
||||
expect(track).not.toHaveAttribute("data-settling");
|
||||
expect(track).toHaveStyle({ transform: "translate3d(0, -40px, 0)" });
|
||||
|
||||
act(() => runFrame?.(16));
|
||||
|
||||
expect(onPresetChange).toHaveBeenCalledWith("dflash");
|
||||
expect(badge).toHaveAttribute("data-settling", "true");
|
||||
expect(track).toHaveAttribute("data-settling", "true");
|
||||
expect(track).toHaveStyle({ transform: "translate3d(0, -80px, 0)" });
|
||||
|
||||
act(() => vi.advanceTimersByTime(260));
|
||||
expect(badge).not.toHaveAttribute("data-switching");
|
||||
});
|
||||
|
||||
it("scrolls complete preset pills after a left-button long press and wraps", () => {
|
||||
vi.useFakeTimers();
|
||||
it("opens a model picker and switches presets with one click", async () => {
|
||||
const { badge, onPresetChange } = renderPresetComposer();
|
||||
expect(badge).toHaveClass("h-9");
|
||||
expect(badge).toHaveStyle({ touchAction: "manipulation" });
|
||||
const idleTouchMove = new Event("touchmove", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
badge.dispatchEvent(idleTouchMove);
|
||||
expect(idleTouchMove.defaultPrevented).toBe(false);
|
||||
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();
|
||||
|
||||
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("dspro");
|
||||
fireEvent.pointerUp(badge, {
|
||||
clientY: 123,
|
||||
pointerId: 7,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
|
||||
expect(onPresetChange).toHaveBeenCalledWith("dspro");
|
||||
expect(badge).toHaveClass("w-fit");
|
||||
fireEvent.click(badge);
|
||||
expect(onPresetChange).toHaveBeenCalledTimes(1);
|
||||
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");
|
||||
const picker = screen.getByRole("dialog", { name: "Switch model for this chat" });
|
||||
expect(picker).toHaveClass("w-[min(18rem,calc(100vw-2rem))]");
|
||||
expect(badge).toHaveClass("w-fit");
|
||||
expect(badge.querySelector(".composer-model-pill")).not.toHaveClass("w-full");
|
||||
expect(within(picker).getAllByRole("option")).toHaveLength(3);
|
||||
expect(within(picker).getByRole("option", { name: "dflash" })).toHaveTextContent(
|
||||
/dflash\s*deepseek-v4-flash/,
|
||||
);
|
||||
expect(within(picker).getByRole("option", { name: "kimi" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(document.activeElement).toBe(within(picker).getByRole("option", { name: "kimi" }));
|
||||
fireEvent.click(within(picker).getByRole("option", { name: "dspro" }));
|
||||
expect(onPresetChange).toHaveBeenCalledWith("dspro");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(badge).toHaveClass("w-fit");
|
||||
});
|
||||
|
||||
it("supports the same long-press switcher in hero mode and cancels pointercancel", () => {
|
||||
it("keeps long-press drag switching alongside the click picker", () => {
|
||||
vi.useFakeTimers();
|
||||
const { badge, onPresetChange } = renderPresetComposer();
|
||||
|
||||
fireEvent.pointerDown(badge, { pointerId: 1, pointerType: "touch", clientY: 100 });
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(screen.getByTestId("composer-model-pill-viewport")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("composer-model-pill-layout")).toHaveClass("invisible");
|
||||
expect(screen.getByTestId("composer-model-pill-track")).not.toHaveClass("transition-transform");
|
||||
|
||||
fireEvent.pointerMove(badge, { pointerId: 1, pointerType: "touch", clientY: 56 });
|
||||
fireEvent.pointerUp(badge, { pointerId: 1, pointerType: "touch", clientY: 56 });
|
||||
expect(onPresetChange).toHaveBeenCalledWith("dflash");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses the same click picker in hero mode", () => {
|
||||
const { badge, onPresetChange } = renderPresetComposer("hero");
|
||||
expect(badge).toHaveClass("h-8");
|
||||
longPress(badge, 9);
|
||||
expect(badge).toHaveAttribute("data-switching", "true");
|
||||
fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
|
||||
fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
|
||||
expect(badge).not.toHaveAttribute("data-switching");
|
||||
expect(onPresetChange).not.toHaveBeenCalled();
|
||||
fireEvent.click(badge);
|
||||
fireEvent.click(screen.getByRole("option", { name: "dflash" }));
|
||||
expect(onPresetChange).toHaveBeenCalledWith("dflash");
|
||||
});
|
||||
|
||||
it("transcribes voice input into the composer without sending", async () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("ThreadMessages", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status", { name: "Thinking for 5s" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("status", { name: "Working for 5s" })).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ThreadMessages
|
||||
@@ -178,6 +178,66 @@ describe("ThreadMessages", () => {
|
||||
expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph);
|
||||
});
|
||||
|
||||
it("keeps live Markdown mounted when a later tool activity arrives", async () => {
|
||||
await act(async () => {
|
||||
await preloadMarkdownText();
|
||||
});
|
||||
const turnId = "turn-live-order";
|
||||
const prompt: UIMessage = {
|
||||
id: "u-live",
|
||||
role: "user",
|
||||
content: "research this",
|
||||
createdAt: 1,
|
||||
turnId,
|
||||
turnPhase: "prompt",
|
||||
turnSeq: 0,
|
||||
};
|
||||
const commentary: UIMessage = {
|
||||
id: "a-commentary",
|
||||
role: "assistant",
|
||||
content: "**I will check that.**",
|
||||
createdAt: 2,
|
||||
isStreaming: false,
|
||||
turnId,
|
||||
turnPhase: "answer",
|
||||
turnSeq: 1,
|
||||
};
|
||||
const { rerender } = render(
|
||||
<ThreadMessages
|
||||
messages={[prompt, commentary]}
|
||||
isStreaming
|
||||
activeTurnId={turnId}
|
||||
/>,
|
||||
);
|
||||
const paragraph = await screen.findByText("I will check that.");
|
||||
expect(paragraph.closest("[data-testid='activity-model-message']")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<ThreadMessages
|
||||
messages={[
|
||||
prompt,
|
||||
commentary,
|
||||
{
|
||||
id: "tool-live",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "web_search()",
|
||||
traces: ["web_search()"],
|
||||
createdAt: 3,
|
||||
turnId,
|
||||
turnPhase: "activity",
|
||||
turnSeq: 2,
|
||||
},
|
||||
]}
|
||||
isStreaming
|
||||
activeTurnId={turnId}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("I will check that.")).toBe(paragraph);
|
||||
expect(screen.getByText(/working/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a follow-up action for text selected within one completed answer", async () => {
|
||||
const onQuoteSelection = vi.fn();
|
||||
render(
|
||||
@@ -319,12 +379,12 @@ describe("ThreadMessages", () => {
|
||||
expect(unitKeysForDisplay(liveUnits)).toEqual(unitKeysForDisplay(replayUnits));
|
||||
expect(unitKeysForDisplay(liveUnits)).toEqual([
|
||||
"turn-turn-1-user",
|
||||
"turn-turn-1-activity-1",
|
||||
"turn-turn-1-answer-1",
|
||||
"turn-turn-1-answer-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps file edits as their own activity row inside a turn", () => {
|
||||
it("keeps file edits inside the single activity surface for a turn", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
@@ -364,14 +424,16 @@ describe("ThreadMessages", () => {
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units.map((unit) => unit.type)).toEqual(["activity", "activity", "activity"]);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
|
||||
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["r2"]);
|
||||
expect(units).toHaveLength(1);
|
||||
expect(units[0].type).toBe("activity");
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"r1",
|
||||
"t1",
|
||||
"r2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps ordinary tool activity in one Thought block across segment ids", () => {
|
||||
it("keeps ordinary tool activity in one activity block across segment ids", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
@@ -449,10 +511,12 @@ describe("ThreadMessages", () => {
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
|
||||
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
expect(units[2]).toMatchObject({
|
||||
expect(units).toHaveLength(2);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"r1",
|
||||
"t1",
|
||||
]);
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "message",
|
||||
message: {
|
||||
id: "a1",
|
||||
@@ -504,8 +568,8 @@ describe("ThreadMessages", () => {
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming />);
|
||||
|
||||
expect(screen.getByLabelText(/edited foo\.txt/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/editing foo\.txt/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/editing foo\.txt/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/edited foo\.txt/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("times live activity from the user turn start", () => {
|
||||
@@ -731,22 +795,18 @@ describe("ThreadMessages", () => {
|
||||
|
||||
const units = buildDisplayUnits(messages, true);
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]);
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "message",
|
||||
message: {
|
||||
id: "a1",
|
||||
content: "partial answer",
|
||||
},
|
||||
});
|
||||
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
expect(units).toHaveLength(1);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"t0",
|
||||
"a1-activity",
|
||||
"t1",
|
||||
]);
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming />);
|
||||
|
||||
const answer = screen.getByText("partial answer");
|
||||
const liveActivity = screen.getByRole("button", { name: /working/i });
|
||||
expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(liveActivity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("moves late activity before a completed assistant answer", () => {
|
||||
@@ -779,10 +839,9 @@ describe("ThreadMessages", () => {
|
||||
|
||||
const units = buildDisplayUnits(messages);
|
||||
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
|
||||
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
|
||||
expect(units[2]).toMatchObject({
|
||||
expect(units).toHaveLength(2);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1", "t1"]);
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "message",
|
||||
message: {
|
||||
id: "a1",
|
||||
@@ -793,7 +852,7 @@ describe("ThreadMessages", () => {
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
|
||||
const answer = screen.getByText("Hong Kong is hot today.");
|
||||
const laterActivity = screen.getAllByText(/thought/i).at(-1);
|
||||
const laterActivity = screen.getByRole("button", { name: /worked/i });
|
||||
expect(laterActivity).toBeTruthy();
|
||||
expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
@@ -834,7 +893,7 @@ describe("ThreadMessages", () => {
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
|
||||
const thought = screen.getAllByText(/thought/i).at(-1);
|
||||
const thought = screen.getByRole("button", { name: /worked/i });
|
||||
const answer = screen.getByText("知道,IEM Cologne Major 2026 今天开打了。");
|
||||
expect(thought).toBeTruthy();
|
||||
expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
@@ -876,18 +935,16 @@ describe("ThreadMessages", () => {
|
||||
|
||||
const units = buildDisplayUnits(messages, true);
|
||||
|
||||
expect(units).toHaveLength(4);
|
||||
expect(units).toHaveLength(3);
|
||||
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
|
||||
"thought",
|
||||
]);
|
||||
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([
|
||||
"web",
|
||||
]);
|
||||
expect(units[2]).toMatchObject({
|
||||
expect(units[1]).toMatchObject({
|
||||
type: "message",
|
||||
message: { id: "answer" },
|
||||
});
|
||||
expect(units[3]).toMatchObject({
|
||||
expect(units[2]).toMatchObject({
|
||||
type: "message",
|
||||
message: { id: "next-user" },
|
||||
});
|
||||
@@ -1018,7 +1075,7 @@ describe("ThreadMessages", () => {
|
||||
expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows copy on every assistant slice while keeping fork on the last slice", () => {
|
||||
it("projects assistant slices into one answer with one action set", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "early",
|
||||
@@ -1050,8 +1107,9 @@ describe("ThreadMessages", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
|
||||
expect(screen.getAllByRole("button", { name: "Fork" })).toHaveLength(1);
|
||||
expect(screen.getByText("starting…")).toBeInTheDocument();
|
||||
expect(screen.getByText("final reply")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1095,7 +1153,7 @@ describe("ThreadMessages", () => {
|
||||
|
||||
rerender(<ThreadMessages {...props} isStreaming={false} activeTurnId={null} />);
|
||||
|
||||
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(3);
|
||||
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(2);
|
||||
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -1192,13 +1250,15 @@ describe("ThreadMessages", () => {
|
||||
.toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows copy on adjacent assistant text slices", () => {
|
||||
it("projects adjacent assistant text slices into one answer", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{ id: "a1", role: "assistant", content: "part one", createdAt: 1 },
|
||||
{ id: "a2", role: "assistant", content: "part two", createdAt: 2 },
|
||||
];
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
|
||||
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
|
||||
expect(screen.getByText("part one")).toBeInTheDocument();
|
||||
expect(screen.getByText("part two")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not count failed optimistic messages in assistant fork indices", () => {
|
||||
@@ -1280,7 +1340,6 @@ describe("ThreadMessages", () => {
|
||||
.filter(Boolean);
|
||||
|
||||
expect(assistantFlags).toEqual([
|
||||
["a1", false],
|
||||
["a2", true],
|
||||
["a3", true],
|
||||
]);
|
||||
|
||||
@@ -609,12 +609,33 @@ describe("ThreadShell", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const badge = await screen.findByLabelText("fast");
|
||||
expect(badge).not.toHaveAttribute("title");
|
||||
fireEvent.focus(badge);
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(
|
||||
"fast · gpt-5.5 · OpenAI Codex",
|
||||
expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("Default · deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the current preset while a renamed session reference is stale", async () => {
|
||||
const client = makeClient();
|
||||
const settings = settingsWithFastPreset();
|
||||
settings.agent.model_preset = "fast";
|
||||
settings.model_presets = settings.model_presets.map((preset) => ({
|
||||
...preset,
|
||||
active: preset.name === "fast",
|
||||
}));
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("renamed-preset", "old-fast")}
|
||||
title="Renamed preset"
|
||||
onToggleSidebar={() => {}}
|
||||
settingsSnapshot={settings}
|
||||
/>,
|
||||
"openai-codex/gpt-5.5",
|
||||
),
|
||||
);
|
||||
|
||||
expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches through every named preset while preserving call-order priority", async () => {
|
||||
@@ -641,19 +662,18 @@ describe("ThreadShell", () => {
|
||||
));
|
||||
const { rerender } = render(view("default"));
|
||||
|
||||
const badge = await screen.findByRole("spinbutton", { name: "Default" });
|
||||
const badge = await screen.findByRole("button", { name: "Default" });
|
||||
expect(badge).toHaveTextContent("Default");
|
||||
fireEvent.keyDown(badge, { key: "ArrowDown" });
|
||||
fireEvent.click(badge);
|
||||
fireEvent.click(await screen.findByRole("option", { name: /^fast\b/i }));
|
||||
|
||||
expect(client.sendSystemCommand).toHaveBeenCalledWith(
|
||||
"preset-order",
|
||||
"/model fast",
|
||||
);
|
||||
expect(await screen.findByText("fast")).toBeInTheDocument();
|
||||
fireEvent.keyDown(
|
||||
screen.getByRole("spinbutton", { name: "fast" }),
|
||||
{ key: "End" },
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "fast" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: /^extra\b/i }));
|
||||
expect(client.sendSystemCommand).toHaveBeenLastCalledWith(
|
||||
"preset-order",
|
||||
"/model extra",
|
||||
@@ -696,15 +716,11 @@ describe("ThreadShell", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const badge = await screen.findByLabelText("fast");
|
||||
fireEvent.focus(badge);
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(
|
||||
"fast · gpt-4 · Company Proxy",
|
||||
);
|
||||
expect(await screen.findByTitle("fast · gpt-4 · Company Proxy")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("only highlights fallback model updates without replacing the preset label", async () => {
|
||||
it("shows the effective fallback model in the composer badge", async () => {
|
||||
const client = makeClient();
|
||||
render(wrap(
|
||||
client,
|
||||
@@ -718,7 +734,8 @@ describe("ThreadShell", () => {
|
||||
));
|
||||
|
||||
expect(await screen.findByText("Default")).toBeInTheDocument();
|
||||
const configuredBadge = screen.getByTestId("composer-model-logo-openai_codex").parentElement;
|
||||
const configuredLogo = await screen.findByTestId("composer-model-logo-openai_codex");
|
||||
const configuredBadge = configuredLogo.parentElement;
|
||||
expect(configuredBadge).not.toBeNull();
|
||||
expect(configuredBadge).toHaveClass("composer-model-badge");
|
||||
expect(configuredBadge).not.toHaveAttribute("data-fallback");
|
||||
@@ -728,31 +745,34 @@ describe("ThreadShell", () => {
|
||||
event: "turn_model_updated",
|
||||
chat_id: "fallback-model",
|
||||
model_name: "openai-codex/gpt-5.5",
|
||||
model_preset: "Default",
|
||||
});
|
||||
});
|
||||
|
||||
expect(configuredBadge).not.toHaveAttribute("data-fallback");
|
||||
expect(screen.getByText("Default")).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
client._emitChat("fallback-model", {
|
||||
event: "turn_model_updated",
|
||||
chat_id: "fallback-model",
|
||||
model_name: "deepseek/deepseek-chat",
|
||||
fallback: true,
|
||||
});
|
||||
});
|
||||
|
||||
const logo = screen.getByTestId("composer-model-logo-openai_codex");
|
||||
const logo = await screen.findByTestId("composer-model-logo-deepseek");
|
||||
const badge = logo.parentElement;
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge).toBe(configuredBadge);
|
||||
expect(screen.getByText("Default")).toBeInTheDocument();
|
||||
expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Default")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("deepseek-chat")).toBeInTheDocument();
|
||||
expect(badge).toHaveAttribute("data-fallback", "true");
|
||||
expect(badge).not.toHaveAttribute("title");
|
||||
expect(logo).not.toHaveAttribute("data-fallback");
|
||||
const trigger = screen.getByLabelText("Default");
|
||||
fireEvent.focus(trigger);
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent("deepseek/deepseek-chat");
|
||||
expect(badge).toHaveAttribute(
|
||||
"title",
|
||||
"Default · using deepseek/deepseek-chat",
|
||||
);
|
||||
expect(logo).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
client._emitChat("fallback-model", {
|
||||
@@ -766,12 +786,7 @@ describe("ThreadShell", () => {
|
||||
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
|
||||
).not.toHaveAttribute("data-fallback");
|
||||
});
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||
"Default · gpt-5.5 · OpenAI Codex",
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
|
||||
).toBe(badge);
|
||||
expect(screen.getByText("Default")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens model settings from the unconfigured model badge", async () => {
|
||||
@@ -1084,10 +1099,8 @@ describe("ThreadShell", () => {
|
||||
));
|
||||
const { rerender } = render(view(null));
|
||||
|
||||
fireEvent.keyDown(
|
||||
await screen.findByRole("spinbutton", { name: "Default" }),
|
||||
{ key: "ArrowDown" },
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Default" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: /^fast\b/i }));
|
||||
expect(await screen.findByText("fast")).toBeInTheDocument();
|
||||
expect(client.sendSystemCommand).not.toHaveBeenCalled();
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ describe("ThreadViewport", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const disclosure = screen.getByRole("button", { name: "Thought" });
|
||||
const disclosure = screen.getByRole("button", { name: "Worked" });
|
||||
fireEvent.pointerDown(disclosure, { button: 0 });
|
||||
expect(takeUserControl).toHaveBeenCalledTimes(1);
|
||||
|
||||
|
||||
@@ -350,6 +350,51 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("stamps provider usage and latest context on the completed answer", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-usage", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-usage", {
|
||||
event: "delta",
|
||||
chat_id: "chat-usage",
|
||||
text: "done",
|
||||
turn_id: "turn-usage",
|
||||
});
|
||||
fake.emit("chat-usage", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-usage",
|
||||
turn_id: "turn-usage",
|
||||
latency_ms: 18_200,
|
||||
context_window_tokens: 128_000,
|
||||
usage: {
|
||||
prompt_tokens: 12_400,
|
||||
completion_tokens: 823,
|
||||
cached_tokens: 9_672,
|
||||
context_tokens: 8_100,
|
||||
request_count: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
content: "done",
|
||||
isStreaming: false,
|
||||
latencyMs: 18_200,
|
||||
contextWindowTokens: 128_000,
|
||||
usage: {
|
||||
prompt_tokens: 12_400,
|
||||
completion_tokens: 823,
|
||||
cached_tokens: 9_672,
|
||||
context_tokens: 8_100,
|
||||
request_count: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves proactive automation source metadata on complete assistant messages", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-cron", EMPTY_MESSAGES), {
|
||||
@@ -2488,7 +2533,7 @@ describe("useNanobotStream", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets stream_end finish streaming while side-channel status replies arrive", () => {
|
||||
it("keeps the turn active after stream_end while side-channel replies arrive", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fake = fakeClient();
|
||||
@@ -2530,6 +2575,19 @@ describe("useNanobotStream", () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-status-loop", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-status-loop",
|
||||
turn_id: promptTurnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({
|
||||
isStreaming: false,
|
||||
@@ -2589,7 +2647,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
content: "Initial findings",
|
||||
isStreaming: false,
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
||||
Reference in New Issue
Block a user