Compare commits

...
Author SHA1 Message Date
Xubin Ren 993322dd0b feat(webui): unify turn observability 2026-08-22 19:41:37 +08:00
44 changed files with 1871 additions and 930 deletions
+13 -6
View File
@@ -425,7 +425,7 @@ class AgentRunner:
) -> AgentRunResult: ) -> AgentRunResult:
final_content: str | None = None final_content: str | None = None
tools_used: list[str] = [] 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 error: str | None = None
stop_reason = "completed" stop_reason = "completed"
tool_events: list[dict[str, str]] = [] tool_events: list[dict[str, str]] = []
@@ -1384,11 +1384,6 @@ class AgentRunner:
usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0) 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 @staticmethod
def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]: def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]:
merged = dict(left) merged = dict(left)
@@ -1396,6 +1391,18 @@ class AgentRunner:
merged[key] = merged.get(key, 0) + value merged[key] = merged.get(key, 0) + value
return merged 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( async def _execute_tools(
self, self,
spec: AgentRunSpec, spec: AgentRunSpec,
+1
View File
@@ -100,6 +100,7 @@ class TurnModelUpdatedEvent(OutboundEvent):
model: str model: str
model_preset: str | None = None model_preset: str | None = None
context_window_tokens: int | None = None context_window_tokens: int | None = None
fallback: bool = False
def outbound_message_for_event( def outbound_message_for_event(
+65 -8
View File
@@ -426,6 +426,7 @@ class WebSocketChannel(BaseChannel):
) )
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {} self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
self._reasoning_text_buffers: dict[tuple[str, str], list[str]] = {}
# -- Subscription bookkeeping ------------------------------------------- # -- Subscription bookkeeping -------------------------------------------
@@ -482,6 +483,9 @@ class WebSocketChannel(BaseChannel):
for key in tuple(self._stream_text_buffers): for key in tuple(self._stream_text_buffers):
if key[0] == chat_id: if key[0] == chat_id:
self._stream_text_buffers.pop(key, None) 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( async def _discard_connection_owned_chat(
self, self,
@@ -1641,11 +1645,22 @@ class WebSocketChannel(BaseChannel):
include_source=include_source, include_source=include_source,
transcript_overrides=transcript_overrides, transcript_overrides=transcript_overrides,
) )
if ( return self._retain_turn_on_transcript_failure(
not persisted chat_id,
and phase in {"answer", "complete"} persisted=persisted,
and (metadata or {}).get("webui") is True 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) owner = (metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
mark_websocket_turn_transcript_persistence_failed( mark_websocket_turn_transcript_persistence_failed(
chat_id, chat_id,
@@ -1653,6 +1668,34 @@ class WebSocketChannel(BaseChannel):
) )
return persisted 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: async def send(self, msg: OutboundMessage) -> None:
event = outbound_event_from_message(msg) event = outbound_event_from_message(msg)
progress_event = event if isinstance(event, ProgressEvent) else None progress_event = event if isinstance(event, ProgressEvent) else None
@@ -1685,6 +1728,7 @@ class WebSocketChannel(BaseChannel):
model_name=event.model, model_name=event.model,
model_preset=event.model_preset, model_preset=event.model_preset,
context_window_tokens=event.context_window_tokens, context_window_tokens=event.context_window_tokens,
fallback=event.fallback,
) )
return return
if isinstance(event, UserInputEvent): if isinstance(event, UserInputEvent):
@@ -1834,9 +1878,12 @@ class WebSocketChannel(BaseChannel):
} }
if stream_id is not None: if stream_id is not None:
body["stream_id"] = stream_id 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, chat_id,
body, body,
completed_text=None,
metadata=meta, metadata=meta,
phase="reasoning", phase="reasoning",
) )
@@ -1862,9 +1909,12 @@ class WebSocketChannel(BaseChannel):
} }
if stream_id is not None: if stream_id is not None:
body["stream_id"] = stream_id 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, chat_id,
body, body,
completed_text=reasoning_text or None,
metadata=meta, metadata=meta,
phase="reasoning", phase="reasoning",
) )
@@ -1912,6 +1962,7 @@ class WebSocketChannel(BaseChannel):
conns = list(self._subs.get(chat_id, ())) conns = list(self._subs.get(chat_id, ()))
meta = metadata or {} meta = metadata or {}
stream_key = (chat_id, str(stream_id or "")) stream_key = (chat_id, str(stream_id or ""))
completed_text: str | None = None
if stream_end: if stream_end:
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id} body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
buffered = ( buffered = (
@@ -1923,6 +1974,7 @@ class WebSocketChannel(BaseChannel):
buffered.append(delta) buffered.append(delta)
full_text = "".join(buffered) full_text = "".join(buffered)
rewritten = self._media.rewrite_local_markdown_images(full_text) rewritten = self._media.rewrite_local_markdown_images(full_text)
completed_text = rewritten
if delta or rewritten != full_text: if delta or rewritten != full_text:
body["text"] = rewritten body["text"] = rewritten
else: else:
@@ -1938,9 +1990,10 @@ class WebSocketChannel(BaseChannel):
body["resuming"] = True body["resuming"] = True
if stream_end and merge_next: if stream_end and merge_next:
body["merge_next"] = True body["merge_next"] = True
self._persist_turn_transcript_event( self._persist_turn_stream_event(
chat_id, chat_id,
body, body,
completed_text=completed_text,
metadata=meta, metadata=meta,
phase="answer", phase="answer",
include_source=True, include_source=True,
@@ -1997,6 +2050,7 @@ class WebSocketChannel(BaseChannel):
# carries a durable incomplete marker. The HTTP replay path can # carries a durable incomplete marker. The HTTP replay path can
# recover the latter from session history after a gateway restart. # recover the latter from session history after a gateway restart.
clear_websocket_turn_if_current(chat_id, turn_owner) clear_websocket_turn_if_current(chat_id, turn_owner)
self._clear_stream_buffers(chat_id)
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
if not conns: if not conns:
return return
@@ -2102,6 +2156,7 @@ class WebSocketChannel(BaseChannel):
model_name: Any, model_name: Any,
model_preset: Any = None, model_preset: Any = None,
context_window_tokens: Any = None, context_window_tokens: Any = None,
fallback: bool = False,
) -> None: ) -> None:
"""Notify one chat's subscribers which model is handling its current request.""" """Notify one chat's subscribers which model is handling its current request."""
conns = list(self._subs.get(chat_id, ())) conns = list(self._subs.get(chat_id, ()))
@@ -2120,6 +2175,8 @@ class WebSocketChannel(BaseChannel):
body["model_preset"] = model_preset.strip() body["model_preset"] = model_preset.strip()
if isinstance(context_window_tokens, int) and context_window_tokens > 0: if isinstance(context_window_tokens, int) and context_window_tokens > 0:
body["context_window_tokens"] = context_window_tokens body["context_window_tokens"] = context_window_tokens
if fallback:
body["fallback"] = True
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_model_updated ") 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", "model_preset": "Deep Research",
"context_window_tokens": 128_000, "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() 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["event"] == "stream_end"
assert second["source"] == source assert second["source"] == source
lines = read_transcript_lines("websocket:chat-source-stream") lines = read_transcript_lines("websocket:chat-source-stream")
assert lines[-2]["source"] == source
assert lines[-1]["source"] == source assert lines[-1]["source"] == source
assert lines[-1]["event"] == "stream_end"
assert lines[-1]["text"] == "done"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -2374,6 +2390,8 @@ async def test_send_delta_marks_resuming_stream_end() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None: 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() bus = MagicMock()
channel = WebSocketChannel( channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True}, {"enabled": True, "allowFrom": ["*"], "streaming": True},
@@ -2403,6 +2421,12 @@ async def test_send_delta_keeps_buffer_across_merged_stream_boundary() -> None:
"second", "second",
] ]
assert ("chat-1", "sid") not in channel._stream_text_buffers 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 @pytest.mark.asyncio
@@ -2596,7 +2620,8 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
assert channel._subs == {} assert channel._subs == {}
lines = read_transcript_lines("websocket:chat-1") 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") body = build_webui_thread_response("websocket:chat-1")
assert body is not None assert body is not None
assert body["messages"][-1]["role"] == "assistant" 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 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 @pytest.mark.asyncio
async def test_send_turn_end_emits_turn_end_event() -> None: async def test_send_turn_end_emits_turn_end_event() -> None:
bus = MagicMock() bus = MagicMock()
+5 -2
View File
@@ -509,8 +509,6 @@ class FallbackProvider(LLMProvider):
) )
continue continue
await self._notify_fallback_model(fallback_model)
fallback_kwargs = { fallback_kwargs = {
**kwargs, **kwargs,
"model": fallback_model, "model": fallback_model,
@@ -541,6 +539,11 @@ class FallbackProvider(LLMProvider):
fallback_response = await call(fallback_provider, fallback_kwargs) fallback_response = await call(fallback_provider, fallback_kwargs)
if fallback_response.finish_reason != "error": 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( logger.info(
"Fallback '{}' succeeded after primary '{}' failed", "Fallback '{}' succeeded after primary '{}' failed",
fallback_model, primary_model, fallback_model, primary_model,
+37 -1
View File
@@ -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: def _remaining_refusal_text(streamed_text: str, refusal_text: str) -> str:
"""Return only text not already surfaced by refusal deltas.""" """Return only text not already surfaced by refusal deltas."""
if not streamed_text: if not streamed_text:
@@ -342,6 +362,7 @@ async def consume_sse_with_reasoning(
usage: dict[str, int] = {} usage: dict[str, int] = {}
reasoning_content: str | None = None reasoning_content: str | None = None
streamed_reasoning = False streamed_reasoning = False
reasoning_summary_key: tuple[str | None, int] | None = None
refusal_seen = False refusal_seen = False
refusal_deltas: dict[tuple[str | None, int | None], str] = {} refusal_deltas: dict[tuple[str | None, int | None], str] = {}
emitted_refusal_text = "" emitted_refusal_text = ""
@@ -406,6 +427,18 @@ async def consume_sse_with_reasoning(
elif event_type == "response.reasoning_summary_text.delta": elif event_type == "response.reasoning_summary_text.delta":
delta_text = event.get("delta") or "" delta_text = event.get("delta") or ""
if delta_text: 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 reasoning_content = (reasoning_content or "") + delta_text
streamed_reasoning = True streamed_reasoning = True
if on_reasoning_delta: if on_reasoning_delta:
@@ -538,7 +571,10 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
text = summary.get("text") text = summary.get("text")
if isinstance(text, str): if isinstance(text, str):
parts.append(text) 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( def parse_response_output(
+1
View File
@@ -495,6 +495,7 @@ def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserve
if context.runtime is not None if context.runtime is not None
else None else None
), ),
fallback=True,
), ),
metadata=context.metadata, metadata=context.metadata,
) )
+89 -23
View File
@@ -770,6 +770,36 @@ class WebUITranscriptRecorder:
record.update(transcript_overrides) record.update(transcript_overrides)
return self.append(chat_id, record) 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( def append_user_message(
self, self,
chat_id: str, chat_id: str,
@@ -1903,13 +1933,24 @@ def replay_transcript_to_ui_messages(
kept.append(m) kept.append(m)
messages = kept 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): for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "assistant" and messages[i].get("kind") != "trace": 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] = {
**messages[i], **messages[i],
"latencyMs": latency_ms, **completion,
"isStreaming": False,
} }
return return
@@ -2215,30 +2256,27 @@ def replay_transcript_to_ui_messages(
turn_fields = _turn_fields(rec, "answer") turn_fields = _turn_fields(rec, "answer")
source_fields = _source_fields(rec) source_fields = _source_fields(rec)
if isinstance(final_text, str): 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: if buffer_message_id is None:
buffer_message_id = _new_id("buf", idx) buffer_message_id = _new_id("buf", idx)
messages.append( messages.append({
{ "id": buffer_message_id,
"id": buffer_message_id, "role": "assistant",
"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, "content": final_text,
"isStreaming": True, "isStreaming": True,
**turn_fields, **turn_fields,
**source_fields, **source_fields,
"createdAt": _created_at_ms(rec, idx), }
}, break
)
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
if merge_next: if merge_next:
buffer_parts = [final_text] buffer_parts = [final_text]
elif source_fields and buffer_message_id is not None: 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 ev == "reasoning_end":
if suppress_until_turn_end: if suppress_until_turn_end:
continue 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) close_reasoning(messages)
continue continue
@@ -2401,8 +2449,26 @@ def replay_transcript_to_ui_messages(
messages[i] = {**m, "isStreaming": False} messages[i] = {**m, "isStreaming": False}
prune_reasoning_only() prune_reasoning_only()
lat = rec.get("latency_ms") lat = rec.get("latency_ms")
if isinstance(lat, (int, float)) and lat >= 0: usage = rec.get("usage")
stamp_latency(int(lat)) 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_message_id = None
buffer_parts = [] buffer_parts = []
continue continue
+2
View File
@@ -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["prompt_tokens"] == 300 # 100 + 200
assert result.usage["completion_tokens"] == 30 # 10 + 20 assert result.usage["completion_tokens"] == 30 # 10 + 20
assert result.usage["cached_tokens"] == 230 # 80 + 150 assert result.usage["cached_tokens"] == 230 # 80 + 150
assert result.usage["context_tokens"] == 200
assert result.usage["request_count"] == 2
@pytest.mark.asyncio @pytest.mark.asyncio
+9 -5
View File
@@ -584,9 +584,10 @@ class TestFallbackOnPrimaryError:
assert restored.payload == state.payload assert restored.payload == state.payload
@pytest.mark.asyncio @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()) 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] = [] fallback_models: list[str] = []
async def _observe(model: str) -> None: async def _observe(model: str) -> None:
@@ -594,8 +595,11 @@ class TestFallbackOnPrimaryError:
fb = FallbackProvider( fb = FallbackProvider(
primary=primary, primary=primary,
fallback_presets=[_fallback("fallback-a", provider="backup")], fallback_presets=[
provider_factory=MagicMock(return_value=fallback), _fallback("fallback-a", provider="backup"),
_fallback("fallback-b", provider="backup"),
],
provider_factory=MagicMock(side_effect=[failed_fallback, successful_fallback]),
fallback_model_observer=_observe, fallback_model_observer=_observe,
) )
@@ -605,7 +609,7 @@ class TestFallbackOnPrimaryError:
) )
assert result.content == "fallback ok" assert result.content == "fallback ok"
assert fallback_models == ["fallback-a"] assert fallback_models == ["fallback-b"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_logs_primary_error_before_fallback(self) -> None: async def test_logs_primary_error_before_fallback(self) -> None:
+8 -6
View File
@@ -379,12 +379,14 @@ async def test_runner_calls_run_level_hooks_on_success():
"done", "done",
"completed", "completed",
None, None,
{ {
"prompt_tokens": 3, "prompt_tokens": 3,
"completion_tokens": 2, "completion_tokens": 2,
"total_tokens": 5, "total_tokens": 5,
"provider_tokens": 5, "provider_tokens": 5,
}, "request_count": 1,
"context_tokens": 3,
},
["user", "assistant"], ["user", "assistant"],
), ),
("on_finally", "completed", None), ("on_finally", "completed", None),
+22 -6
View File
@@ -1056,8 +1056,24 @@ class TestConsumeSse:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reasoning_summary_delta_extracted(self): async def test_reasoning_summary_delta_extracted(self):
response = _SseResponse([ 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.output_text.delta", "delta": "answer"},
{"type": "response.completed", "response": {"status": "completed"}}, {"type": "response.completed", "response": {"status": "completed"}},
]) ])
@@ -1075,8 +1091,8 @@ class TestConsumeSse:
assert tool_calls == [] assert tool_calls == []
assert finish_reason == "stop" assert finish_reason == "stop"
assert usage == {} assert usage == {}
assert reasoning == "thinking briefly" assert reasoning == "thinking briefly\nChecking result"
assert deltas == ["thinking ", "briefly"] assert deltas == ["thinking ", "briefly", "\nChecking result"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_reasoning_summary_from_completed_response(self): async def test_reasoning_summary_from_completed_response(self):
@@ -1087,7 +1103,7 @@ class TestConsumeSse:
"status": "completed", "status": "completed",
"output": [ "output": [
{"type": "reasoning", "summary": [ {"type": "reasoning", "summary": [
{"type": "summary_text", "text": "cached "}, {"type": "summary_text", "text": "cached"},
{"type": "summary_text", "text": "summary"}, {"type": "summary_text", "text": "summary"},
]}, ]},
], ],
@@ -1097,7 +1113,7 @@ class TestConsumeSse:
_, _, _, _, reasoning = await consume_sse_with_reasoning(response) _, _, _, _, reasoning = await consume_sse_with_reasoning(response)
assert reasoning == "cached summary" assert reasoning == "cached\nsummary"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_capture_commits_exact_items_only_after_completed_event(self): async def test_capture_commits_exact_items_only_after_completed_event(self):
+49
View File
@@ -393,6 +393,55 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
assert msgs[1]["latencyMs"] == 42 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: def test_replay_uses_persisted_created_at_ms() -> None:
msgs = replay_transcript_to_ui_messages( msgs = replay_transcript_to_ui_messages(
[ [
+7 -23
View File
@@ -50,7 +50,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model"; import { MAX_WORKBENCH_PANES } from "@/components/workbench/workbench-model";
import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight"; import { SIDEBAR_SELECTION_ITEM_CLASS } from "@/components/SidebarSelectionHighlight";
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format"; import { relativeTime, visibleSessionPreview } from "@/lib/format";
import { import {
COLLAPSED_CHATS_VISIBLE_COUNT, COLLAPSED_CHATS_VISIBLE_COUNT,
displayTitle, 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 isPinned = pinned.has(s.key);
const isArchived = archived.has(s.key); const isArchived = archived.has(s.key);
const preview = visibleSessionPreview(s.preview); const preview = visibleSessionPreview(s.preview);
@@ -959,8 +951,7 @@ export const ChatList = memo(function ChatList({
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground", && "bg-sidebar-accent/55 text-sidebar-accent-foreground",
)} )}
> >
<SidebarItemTooltip label={tooltipTitle}> <button
<button
type="button" type="button"
onClick={(event) => { onClick={(event) => {
if (deleteSelectionMode) { if (deleteSelectionMode) {
@@ -1029,8 +1020,7 @@ export const ChatList = memo(function ChatList({
</span> </span>
) : null} ) : null}
</span> </span>
</button> </button>
</SidebarItemTooltip>
<SessionActivityIndicator state={activityState} /> <SessionActivityIndicator state={activityState} />
{!deleteSelectionMode ? ( {!deleteSelectionMode ? (
<DropdownMenu <DropdownMenu
@@ -1435,10 +1425,7 @@ function ActivePaneRows({
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground", && "bg-sidebar-accent/55 text-sidebar-accent-foreground",
)} )}
> >
<SidebarItemTooltip <button
label={pane.handle ? `@${pane.handle.name} · ${pane.title}` : pane.title}
>
<button
type="button" type="button"
onClick={(event) => { onClick={(event) => {
if (deleteSelectionMode) { if (deleteSelectionMode) {
@@ -1474,8 +1461,7 @@ function ActivePaneRows({
{isPinned ? <PinnedChatIndicator /> : null} {isPinned ? <PinnedChatIndicator /> : null}
<SidebarSelectionTrack active={active} handle={pane.handle} /> <SidebarSelectionTrack active={active} handle={pane.handle} />
</span> </span>
</button> </button>
</SidebarItemTooltip>
<SessionActivityIndicator state={activityState} /> <SessionActivityIndicator state={activityState} />
{!deleteSelectionMode ? <DropdownMenu {!deleteSelectionMode ? <DropdownMenu
modal={false} 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]", : "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" type="button"
onClick={() => onSelect(session.key)} onClick={() => onSelect(session.key)}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
@@ -1666,8 +1651,7 @@ function TemporaryChatSection({
<span className="min-w-0 flex-1 truncate font-medium leading-5"> <span className="min-w-0 flex-1 truncate font-medium leading-5">
{title} {title}
</span> </span>
</button> </button>
</SidebarItemTooltip>
<SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} /> <SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} />
{onClose ? ( {onClose ? (
<button <button
+80 -3
View File
@@ -34,7 +34,11 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard"; import { copyTextToClipboard } from "@/lib/clipboard";
import { fmtDateTime, formatMessageEndTime } from "@/lib/format"; import {
fmtDateTime,
formatCompactTokenCount,
formatMessageEndTime,
} from "@/lib/format";
import { toMediaAttachment } from "@/lib/media"; import { toMediaAttachment } from "@/lib/media";
import { matchingSlashCommand } from "@/lib/slash-command"; import { matchingSlashCommand } from "@/lib/slash-command";
import { sessionHandleColor } from "@/lib/session-handle"; import { sessionHandleColor } from "@/lib/session-handle";
@@ -50,6 +54,7 @@ import type {
UIMessage, UIMessage,
MessageDeliveryErrorKind, MessageDeliveryErrorKind,
MessageDeliveryStatus, MessageDeliveryStatus,
TurnUsage,
} from "@/lib/types"; } from "@/lib/types";
interface MessageBubbleProps { 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( function deliveryErrorCopy(
kind: MessageDeliveryErrorKind | undefined, kind: MessageDeliveryErrorKind | undefined,
t: (key: string) => string, t: (key: string) => string,
@@ -479,7 +549,9 @@ export function MessageBubble({
&& (!empty || hasReasoning || media.length > 0); && (!empty || hasReasoning || media.length > 0);
const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : ""; const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : "";
const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0; 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 = const showAssistantFooterSlot =
message.role === "assistant" message.role === "assistant"
&& (!empty || hasReasoning || media.length > 0); && (!empty || hasReasoning || media.length > 0);
@@ -545,6 +617,12 @@ export function MessageBubble({
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent> <TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
{showUsage ? (
<TurnUsageMeta
usage={message.usage!}
latencyMs={message.latencyMs}
/>
) : null}
{showAssistantTimestamp ? ( {showAssistantTimestamp ? (
<MessageTimestamp <MessageTimestamp
{...(showCompletedAt ? { "data-assistant-completed-at": true } : {})} {...(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", "border border-border/60 bg-muted/35 px-3 py-2 text-left text-muted-foreground",
)} )}
aria-label={label} aria-label={label}
title={text}
> >
<Quote className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden /> <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]"> <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"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MarkdownText } from "@/components/MarkdownText";
import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText"; import { cliAppInitials, mcpPresetInitials } from "@/components/CliAppMentionText";
import { ActivityStep } from "@/components/thread/activity/ActivityStep"; import { ActivityStep } from "@/components/thread/activity/ActivityStep";
import { coalesceActivityMessages } from "@/components/thread/activity/activity-message-model"; import { coalesceActivityMessages } from "@/components/thread/activity/activity-message-model";
@@ -55,6 +56,7 @@ export { isAgentActivityMember, isReasoningOnlyAssistant };
interface ActivityCounts { interface ActivityCounts {
reasoningSteps: number; reasoningSteps: number;
toolCalls: number; toolCalls: number;
modelSegments: number;
cliCount: number; cliCount: number;
mcpCount: number; mcpCount: number;
fileCount: number; fileCount: number;
@@ -91,9 +93,14 @@ function countActivity(
): ActivityCounts { ): ActivityCounts {
let reasoningSteps = 0; let reasoningSteps = 0;
let toolCalls = 0; let toolCalls = 0;
let modelSegments = 0;
const cliCount = cliRuns.length; const cliCount = cliRuns.length;
const mcpCount = mcpRuns.length; const mcpCount = mcpRuns.length;
for (const m of messages) { for (const m of messages) {
if (m.activityKind === "model") {
modelSegments += 1;
continue;
}
if (isReasoningOnlyAssistant(m)) { if (isReasoningOnlyAssistant(m)) {
reasoningSteps += 1; reasoningSteps += 1;
continue; continue;
@@ -110,6 +117,7 @@ function countActivity(
return { return {
reasoningSteps, reasoningSteps,
toolCalls, toolCalls,
modelSegments,
cliCount, cliCount,
mcpCount, mcpCount,
fileCount: fileEdits.length, fileCount: fileEdits.length,
@@ -131,8 +139,8 @@ interface AgentActivityClusterProps {
} }
/** /**
* Outer fold wrapping interleaved reasoning-only assistant rows and tool-trace rows. * One fold wrapping the complete middle of a turn: reasoning, model segments,
* Fixed max height with inner scroll and a single flat list of activity rows. * tool traces, and file edits. The final assistant answer stays outside it.
*/ */
export function AgentActivityCluster({ export function AgentActivityCluster({
messages, messages,
@@ -165,6 +173,7 @@ export function AgentActivityCluster({
const { const {
reasoningSteps, reasoningSteps,
toolCalls, toolCalls,
modelSegments,
cliCount, cliCount,
mcpCount, mcpCount,
fileCount, fileCount,
@@ -186,9 +195,8 @@ export function AgentActivityCluster({
? outerOpenLocal ? outerOpenLocal
: isTurnStreaming || completionHoldOpen || (wasTurnStreaming && !isTurnStreaming); : 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 hasOnlyFileActivity = fileCount > 0 && activityMessages.every(messageHasOnlyFileActivity);
const hasNonReasoningActivity = toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
const durationMs = activityDurationMs( const durationMs = activityDurationMs(
activityMessages, activityMessages,
isTurnStreaming, isTurnStreaming,
@@ -197,28 +205,16 @@ export function AgentActivityCluster({
startedAtMs, startedAtMs,
); );
const activityDuration = formatActivityDuration(durationMs); const activityDuration = formatActivityDuration(durationMs);
const thoughtLabel = hasNonReasoningActivity const activityLabel = isTurnStreaming
? isTurnStreaming ? t("message.activityWorkingFor", {
? t("message.activityWorkingFor", { duration: activityDuration,
duration: activityDuration, defaultValue: "Working for {{duration}}",
defaultValue: "Working for {{duration}}", })
}) : durationMs <= 0
: durationMs <= 0 ? t("message.activityWorked", { defaultValue: "Worked" })
? t("message.activityWorked", { defaultValue: "Worked" })
: t("message.activityWorkedFor", { : t("message.activityWorkedFor", {
duration: activityDuration, duration: activityDuration,
defaultValue: "Worked for {{duration}}", 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(() => { const cancelActivityScrollFrame = useCallback(() => {
@@ -338,7 +334,7 @@ export function AgentActivityCluster({
<ThinkingReasoningShell <ThinkingReasoningShell
active={isTurnStreaming} active={isTurnStreaming}
expanded={outerExpanded} expanded={outerExpanded}
label={thoughtLabel} label={activityLabel}
viewportRef={activityScrollRef} viewportRef={activityScrollRef}
contentRef={activityContentRef} contentRef={activityContentRef}
fadeTop={activityScrollFade.top} fadeTop={activityScrollFade.top}
@@ -352,6 +348,7 @@ export function AgentActivityCluster({
active={isTurnStreaming} active={isTurnStreaming}
cliAppsByName={cliAppsByName} cliAppsByName={cliAppsByName}
mcpPresetsByName={mcpPresetsByName} mcpPresetsByName={mcpPresetsByName}
onOpenFilePreview={onOpenFilePreview}
/> />
{fileEdits.length ? ( {fileEdits.length ? (
<FileEditGroup <FileEditGroup
@@ -417,15 +414,28 @@ function ActivityMessageTimeline({
active, active,
cliAppsByName, cliAppsByName,
mcpPresetsByName, mcpPresetsByName,
onOpenFilePreview,
}: { }: {
messages: UIMessage[]; messages: UIMessage[];
active: boolean; active: boolean;
cliAppsByName: Map<string, CliAppInfo>; cliAppsByName: Map<string, CliAppInfo>;
mcpPresetsByName: Map<string, McpPresetInfo>; mcpPresetsByName: Map<string, McpPresetInfo>;
onOpenFilePreview?: (path: string) => void;
}) { }) {
const items: ReactNode[] = []; const items: ReactNode[] = [];
messages.forEach((message, index) => { messages.forEach((message, index) => {
if (message.activityKind === "model") {
items.push(
<ActivityModelMessage
key={message.id}
message={message}
active={active}
onOpenFilePreview={onOpenFilePreview}
/>,
);
return;
}
if (isReasoningOnlyAssistant(message)) { if (isReasoningOnlyAssistant(message)) {
items.push( items.push(
<ReasoningRow <ReasoningRow
@@ -451,6 +461,40 @@ function ActivityMessageTimeline({
return <>{items}</>; 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({ function ActivityTraceList({
lines, lines,
active, active,
+381 -270
View File
@@ -6,37 +6,27 @@ import {
type KeyboardEvent, type KeyboardEvent,
type PointerEvent, type PointerEvent,
} from "react"; } from "react";
import { CircleHelp, Sparkles } from "lucide-react"; import { Check, CircleHelp, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import { import {
Tooltip, floatingItemClassName,
TooltipContent, floatingItemFocusClassName,
TooltipProvider, } from "@/components/ui/floating-surface";
TooltipTrigger, import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
} from "@/components/ui/tooltip";
import { useLogoFallback } from "@/hooks/useLogoFallback"; import { useLogoFallback } from "@/hooks/useLogoFallback";
import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand"; import { inferProviderFromModelName, providerBrand } from "@/lib/provider-brand";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface ModelPresetOption { const pickerWidthClassName = "w-[min(18rem,calc(100vw-2rem))]";
name: string; const LONG_PRESS_MS = 400;
model?: string | null; const PRESS_SLOP_PX = 8;
provider?: string | null; const PILL_GAP_PX = 4;
} const PILL_OFFSETS = [-2, -1, 0, 1, 2] as const;
const HANDOFF_THRESHOLD = 0.56;
interface ModelPresetBadgeProps { const DOCK_MAX_SCALE = 1.08;
label: string; const DOCK_RADIUS = 1.5;
modelDetail?: string | null; const SETTLE_MS = 200;
modelPreset?: string | null;
modelPresets?: ModelPresetOption[];
onPresetChange?: (name: string) => void;
provider?: string | null;
providerLabel?: string | null;
needsSetup?: boolean;
fallbackModelName?: string | null;
isHero: boolean;
onClick?: () => void;
}
interface PresetGesture { interface PresetGesture {
active: boolean; active: boolean;
@@ -55,15 +45,6 @@ interface PresetMotion {
settling: boolean; 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 { function wrapIndex(index: number, length: number): number {
return ((index % length) + length) % length; return ((index % length) + length) % length;
} }
@@ -86,12 +67,40 @@ function preventTouchScroll(event: TouchEvent) {
if (event.cancelable) event.preventDefault(); 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({ export function ModelPresetBadge({
label, label,
modelDetail, modelDetail,
modelPreset, modelPreset,
modelPresets = [], modelPresets = [],
onPresetChange, onPresetChange,
onRequestComposerFocus,
provider, provider,
providerLabel, providerLabel,
needsSetup = false, needsSetup = false,
@@ -99,6 +108,12 @@ export function ModelPresetBadge({
isHero, isHero,
onClick, onClick,
}: ModelPresetBadgeProps) { }: 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 activeName = modelPreset?.trim() || "";
const listedIndex = modelPresets.findIndex((preset) => preset.name === activeName); const listedIndex = modelPresets.findIndex((preset) => preset.name === activeName);
const activePreset: ModelPresetOption = { const activePreset: ModelPresetOption = {
@@ -107,97 +122,78 @@ export function ModelPresetBadge({
model: modelDetail ?? modelPresets[listedIndex]?.model, model: modelDetail ?? modelPresets[listedIndex]?.model,
provider: provider || modelPresets[listedIndex]?.provider, 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 const presets = !activeName
? modelPresets ? modelPresets
: listedIndex < 0 : listedIndex < 0
? [activePreset, ...modelPresets] ? [activePreset, ...modelPresets]
: modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset); : modelPresets.map((preset, index) => index === listedIndex ? activePreset : preset);
const interactive = Boolean(onClick); const opensSetup = Boolean(onClick);
const canSwitch = !interactive && Boolean(onPresetChange) && activeName !== "" && presets.length > 1; const canSwitch = !opensSetup && Boolean(onPresetChange) && activeName !== "" && presets.length > 1;
const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName)); const currentIndex = Math.max(0, presets.findIndex((preset) => preset.name === activeName));
const pillHeight = isHero ? 32 : 36; const pillHeight = isHero ? 32 : 36;
const pillStride = pillHeight + PILL_GAP_PX; const pillStride = pillHeight + PILL_GAP_PX;
const [motion, setMotion] = useState<PresetMotion | null>(null); const switchModelLabel = t("thread.composer.switchModel", {
const gestureRef = useRef<PresetGesture | null>(null); defaultValue: "Switch model for this chat",
const clickAnimationFrameRef = useRef<number | null>(null); });
const suppressClickRef = useRef(false);
const suppressClickTimerRef = useRef<number | null>(null);
function clearGesture() { const selectPreset = (name: string) => {
setOpen(false);
if (name !== activeName) onPresetChange?.(name);
requestAnimationFrame(() => onRequestComposerFocus?.());
};
const clearGesture = () => {
const gesture = gestureRef.current; const gesture = gestureRef.current;
if (gesture?.timer) clearTimeout(gesture.timer); if (gesture?.timer) clearTimeout(gesture.timer);
if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll); if (gesture?.active) gesture.target.removeEventListener("touchmove", preventTouchScroll);
gestureRef.current = null; gestureRef.current = null;
} };
const clearMotion = () => {
setMotion(null);
setMotionWidth(null);
};
useEffect(() => { useEffect(() => {
if (!canSwitch) { if (!canSwitch) {
clearGesture(); clearGesture();
setMotion(null); clearMotion();
} }
return () => { return clearGesture;
clearGesture();
if (clickAnimationFrameRef.current !== null) {
window.cancelAnimationFrame(clickAnimationFrameRef.current);
clickAnimationFrameRef.current = null;
}
if (suppressClickTimerRef.current !== null) {
window.clearTimeout(suppressClickTimerRef.current);
suppressClickTimerRef.current = null;
}
};
}, [canSwitch]); }, [canSwitch]);
useEffect(() => { useEffect(() => {
if (!motion?.settling) return; if (!motion?.settling) return;
const timer = setTimeout(() => setMotion(null), SETTLE_MS + 80); const timer = setTimeout(clearMotion, SETTLE_MS + 80);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [motion?.settling]); }, [motion?.settling]);
function updateMotion(gesture: PresetGesture, clientY: number) { const updateMotion = (gesture: PresetGesture, clientY: number) => {
const raw = -(clientY - gesture.startY) / pillStride; const raw = -(clientY - gesture.startY) / pillStride;
gesture.step = stepWithHysteresis(raw, gesture.step); gesture.step = stepWithHysteresis(raw, gesture.step);
setMotion({ index: gesture.baseIndex + gesture.step, remainder: raw - gesture.step, settling: false }); setMotion({
} index: gesture.baseIndex + gesture.step,
remainder: raw - gesture.step,
function suppressFollowingClick() { settling: false,
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);
}); });
} };
function handleClick() { const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {
if (interactive) { if (!canSwitch || gestureRef.current || motion) return;
onClick?.();
return;
}
if (suppressClickRef.current) return;
cycleToNextPreset();
}
function handlePointerDown(event: PointerEvent<HTMLElement>) {
if (!canSwitch || gestureRef.current || motion || event.isPrimary === false) return;
if (event.pointerType === "mouse" && event.button !== 0) return; if (event.pointerType === "mouse" && event.button !== 0) return;
const gesture: PresetGesture = { const gesture: PresetGesture = {
active: false, active: false,
@@ -212,16 +208,19 @@ export function ModelPresetBadge({
gesture.timer = setTimeout(() => { gesture.timer = setTimeout(() => {
if (gestureRef.current !== gesture) return; if (gestureRef.current !== gesture) return;
gesture.active = true; gesture.active = true;
setMotionWidth(Math.round(gesture.target.getBoundingClientRect().width) || null);
updateMotion(gesture, gesture.latestY); updateMotion(gesture, gesture.latestY);
gesture.target.addEventListener("touchmove", preventTouchScroll, { passive: false }); gesture.target.addEventListener("touchmove", preventTouchScroll, { passive: false });
try { try {
gesture.target.setPointerCapture(gesture.pointerId); gesture.target.setPointerCapture(gesture.pointerId);
} catch { /* The pointer may already have ended. */ } } catch {
// The pointer may already have ended.
}
}, LONG_PRESS_MS); }, LONG_PRESS_MS);
gestureRef.current = gesture; gestureRef.current = gesture;
} };
function handlePointerMove(event: PointerEvent<HTMLElement>) { const handlePointerMove = (event: PointerEvent<HTMLButtonElement>) => {
const gesture = gestureRef.current; const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return; if (!gesture || gesture.pointerId !== event.pointerId) return;
gesture.latestY = event.clientY; gesture.latestY = event.clientY;
@@ -231,27 +230,26 @@ export function ModelPresetBadge({
} }
event.preventDefault(); event.preventDefault();
updateMotion(gesture, event.clientY); updateMotion(gesture, event.clientY);
} };
function finishGesture(event: PointerEvent<HTMLElement>, commit: boolean) { const finishGesture = (event: PointerEvent<HTMLButtonElement>, commit: boolean) => {
const gesture = gestureRef.current; const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return; if (!gesture || gesture.pointerId !== event.pointerId) return;
clearGesture(); clearGesture();
if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) { if (event.currentTarget.hasPointerCapture?.(gesture.pointerId)) {
event.currentTarget.releasePointerCapture?.(gesture.pointerId); event.currentTarget.releasePointerCapture?.(gesture.pointerId);
} }
if (gesture.active) suppressFollowingClick();
if (!commit || !gesture.active) { if (!commit || !gesture.active) {
setMotion(null); clearMotion();
return; return;
} }
suppressClickRef.current = true;
const selected = presets[wrapIndex(gesture.baseIndex + gesture.step, presets.length)]; const selected = presets[wrapIndex(gesture.baseIndex + gesture.step, presets.length)];
setMotion((current) => current && { ...current, remainder: 0, settling: true }); 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>) { const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (!canSwitch) return;
const targetByKey: Record<string, number> = { const targetByKey: Record<string, number> = {
ArrowUp: currentIndex - 1, ArrowUp: currentIndex - 1,
ArrowDown: currentIndex + 1, ArrowDown: currentIndex + 1,
@@ -262,141 +260,229 @@ export function ModelPresetBadge({
if (target === undefined) return; if (target === undefined) return;
event.preventDefault(); event.preventDefault();
const next = presets[wrapIndex(target, presets.length)]; 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 pill = (
const previewPreset = presets[previewIndex]; <PresetPill
const Container = interactive || canSwitch ? "button" : "span"; label={displayLabel}
const trackOffset = motion ? -pillStride * (2 + motion.remainder) : 0; modelDetail={displayModelDetail}
const tooltipLabel = fallbackModelName provider={displayProvider}
|| [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · "); providerLabel={fallbackModelName ? null : providerLabel}
needsSetup={needsSetup}
const badge = ( fallbackModelName={fallbackModelName}
<Container fallbackFromLabel={fallbackModelName ? label : null}
data-switching={motion ? "true" : undefined} isHero={isHero}
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>
); );
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 ( return (
<TooltipProvider delayDuration={500} skipDelayDuration={100}> <Popover
<Tooltip> open={open}
<TooltipTrigger asChild>{badge}</TooltipTrigger> onOpenChange={(nextOpen) => {
<TooltipContent setOpen(nextOpen);
side="top" if (!nextOpen) requestAnimationFrame(() => onRequestComposerFocus?.());
align="center" }}
sideOffset={8} >
collisionPadding={12} <PopoverTrigger asChild>
className="max-w-[min(24rem,calc(100vw-2rem))] break-all" <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} {motion ? (
</TooltipContent> <>
</Tooltip> <span data-testid="composer-model-pill-layout" className="invisible inline-flex h-full shrink-0" aria-hidden>
</TooltipProvider> {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({ function PresetPill({
className,
label, label,
modelDetail, modelDetail,
provider, provider,
providerLabel,
needsSetup = false, needsSetup = false,
fallbackModelName, fallbackModelName,
fallbackFromLabel,
isHero, isHero,
offset, offset,
scale, scale,
}: { }: {
className?: string | false | null;
label: string; label: string;
modelDetail?: string | null; modelDetail?: string | null;
provider?: string | null; provider?: string | null;
providerLabel?: string | null;
needsSetup?: boolean; needsSetup?: boolean;
fallbackModelName?: string | null; fallbackModelName?: string | null;
fallbackFromLabel?: string | null;
isHero: boolean; isHero: boolean;
offset?: number; offset?: number;
scale?: number; scale?: number;
@@ -406,13 +492,10 @@ function PresetPill({
const inferredProvider = needsSetup const inferredProvider = needsSetup
? null ? null
: provider || inferProviderFromModelName(modelDetail || label); : provider || inferProviderFromModelName(modelDetail || label);
const brand = providerBrand(inferredProvider); const title = [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls); const fallbackTitle = fallbackModelName
const logoTestId = offset !== undefined ? `${fallbackFromLabel || label} · using ${fallbackModelName}`
? undefined : title;
: needsSetup
? "composer-model-setup-icon"
: `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`;
useLayoutEffect(() => { useLayoutEffect(() => {
const node = labelRef.current; const node = labelRef.current;
@@ -428,14 +511,14 @@ function PresetPill({
<span <span
data-fallback={fallbackModelName ? "true" : undefined} data-fallback={fallbackModelName ? "true" : undefined}
data-preset-offset={offset} data-preset-offset={offset}
title={fallbackTitle || undefined}
className={cn( className={cn(
"composer-model-badge composer-model-pill inline-flex h-full w-fit max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70", "composer-model-badge composer-model-pill inline-flex h-full 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)]", "w-fit",
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible/model-badge:ring-2 group-focus-visible/model-badge:ring-ring/45", "transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible: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", needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]", isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]",
offset !== undefined && "composer-model-pill-dock", offset !== undefined && "composer-model-pill-dock",
className,
)} )}
style={scale === undefined ? undefined : { style={scale === undefined ? undefined : {
height: `${isHero ? 32 : 36}px`, height: `${isHero ? 32 : 36}px`,
@@ -443,46 +526,14 @@ function PresetPill({
zIndex: Math.round(scale * 100), zIndex: Math.round(scale * 100),
}} }}
> >
<span <PresetProviderIcon
data-testid={logoTestId} label={label}
className={cn( modelDetail={modelDetail}
"grid shrink-0 place-items-center overflow-hidden", provider={inferredProvider}
needsSetup ? "text-amber-800 dark:text-amber-200" : "rounded-full border bg-background", needsSetup={needsSetup}
isHero ? "h-4 w-4" : "h-[18px] w-[18px]", testId={needsSetup ? "composer-model-setup-icon" : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`}
)} isHero={isHero}
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>
<span <span
ref={labelRef} ref={labelRef}
className={cn( className={cn(
@@ -495,3 +546,63 @@ function PresetPill({
</span> </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, readDraggedSession,
} from "@/lib/session-drag"; } from "@/lib/session-drag";
import { formatQuotedUserMessage } from "@/lib/user-message-quote"; import { formatQuotedUserMessage } from "@/lib/user-message-quote";
import { formatCompactTokenCount } from "@/lib/format";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const VOICE_SHORTCUT_CODE = "KeyD"; 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 { interface ThreadComposerProps {
onSend: ( onSend: (
content: string, content: string,
@@ -198,6 +300,7 @@ interface ThreadComposerProps {
modelNeedsSetup?: boolean; modelNeedsSetup?: boolean;
fallbackModelName?: string | null; fallbackModelName?: string | null;
onModelBadgeClick?: () => void; onModelBadgeClick?: () => void;
contextUsage?: ComposerContextUsage | null;
variant?: "thread" | "hero"; variant?: "thread" | "hero";
slashCommands?: SlashCommand[]; slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
@@ -895,6 +998,7 @@ export function ThreadComposer({
modelNeedsSetup = false, modelNeedsSetup = false,
fallbackModelName = null, fallbackModelName = null,
onModelBadgeClick, onModelBadgeClick,
contextUsage = null,
variant = "thread", variant = "thread",
slashCommands = [], slashCommands = [],
cliApps = [], cliApps = [],
@@ -2435,6 +2539,7 @@ export function ThreadComposer({
modelPreset={modelPreset} modelPreset={modelPreset}
modelPresets={modelPresets} modelPresets={modelPresets}
onPresetChange={onModelPresetChange} onPresetChange={onModelPresetChange}
onRequestComposerFocus={() => textareaRef.current?.focus()}
provider={modelProvider} provider={modelProvider}
providerLabel={modelProviderLabel} providerLabel={modelProviderLabel}
needsSetup={modelNeedsSetup} needsSetup={modelNeedsSetup}
@@ -2443,6 +2548,7 @@ export function ThreadComposer({
onClick={modelNeedsSetup ? onModelBadgeClick : undefined} onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
/> />
) : null} ) : null}
{!voiceRecorder.isRecording ? <ComposerContextBadge usage={contextUsage} /> : null}
{showVoiceButton ? ( {showVoiceButton ? (
<TooltipProvider delayDuration={220} skipDelayDuration={80}> <TooltipProvider delayDuration={220} skipDelayDuration={80}>
<Tooltip> <Tooltip>
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble"; import { MessageBubble } from "@/components/MessageBubble";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction"; 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"; import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
interface ThreadMessagesProps { interface ThreadMessagesProps {
@@ -29,10 +29,9 @@ export type DisplayUnit = TurnUnit;
export function buildDisplayUnits( export function buildDisplayUnits(
messages: UIMessage[], messages: UIMessage[],
isStreaming = false, isStreaming = false,
activeTurnId: string | null = null,
): DisplayUnit[] { ): DisplayUnit[] {
return normalizeActivityTimeline(messages, { return projectActivityTimeline(messages, isStreaming ? activeTurnId : undefined);
preserveTrailingActivity: isStreaming,
});
} }
export function assistantForkFlags(units: DisplayUnit[]): boolean[] { export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
@@ -69,7 +68,10 @@ export function ThreadMessages({
}: ThreadMessagesProps) { }: ThreadMessagesProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const messageListRef = useRef<HTMLDivElement>(null); 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( const forkBoundaryAfterUnitIndex = useMemo(
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount), () => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
[forkBoundaryMessageCount, units], [forkBoundaryMessageCount, units],
+51 -13
View File
@@ -8,7 +8,10 @@ import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { SessionHandleLabel } from "@/components/SessionHandleLabel"; import { SessionHandleLabel } from "@/components/SessionHandleLabel";
import { PromptNavigator } from "@/components/thread/PromptNavigator"; import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover"; 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 type { ModelPresetOption } from "@/components/thread/ModelPresetBadge";
import { ThreadHeader } from "@/components/thread/ThreadHeader"; import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; 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( function snapshotPreservesMessage(
current: MessageShape, current: MessageShape,
candidate: MessageShape, candidate: MessageShape,
@@ -400,7 +427,7 @@ function toModelBadgeInfo(
); );
return { return {
label, label,
model: model?.trim() || null, model: toModelBadgeLabel(model),
provider, provider,
providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null, providerLabel: provider ? providerDisplayLabel(settings?.providers ?? [], provider) : null,
needsSetup, needsSetup,
@@ -803,8 +830,12 @@ export function ThreadShell({
}, []); }, []);
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]); const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const currentRunStartedAt = messagesReady ? runStartedAt : null; const composerContextUsage = useMemo(
() => latestComposerContextUsage(displayMessages),
[displayMessages],
);
const currentGoalState = messagesReady ? goalState : undefined; const currentGoalState = messagesReady ? goalState : undefined;
const currentRunStartedAt = messagesReady ? runStartedAt : null;
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null); const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
const restoredViewportTurnId = useMemo( const restoredViewportTurnId = useMemo(
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null, () => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
@@ -882,9 +913,17 @@ export function ThreadShell({
useEffect(() => { useEffect(() => {
setLocalModelPreset(null); setLocalModelPreset(null);
}, [session?.key, sessionModelPreset]); }, [session?.key, sessionModelPreset]);
const configuredPresetNames = useMemo(
() => new Set(settings?.model_presets.map((preset) => preset.name) ?? []),
[settings],
);
const activeModelPreset = ( const activeModelPreset = (
localModelPreset (localModelPreset && (!settings || configuredPresetNames.has(localModelPreset))
|| sessionModelPreset ? localModelPreset
: null)
|| (sessionModelPreset && (!settings || configuredPresetNames.has(sessionModelPreset))
? sessionModelPreset
: null)
|| settings?.agent.model_preset || settings?.agent.model_preset
|| "default" || "default"
); );
@@ -958,13 +997,10 @@ export function ThreadShell({
} }
setFallbackModelName(null); setFallbackModelName(null);
return client.onChat(chatId, (event) => { return client.onChat(chatId, (event) => {
if (event.event !== "turn_model_updated") return; if (event.event !== "turn_model_updated" || event.fallback !== true) return;
const activeModel = event.model_name.trim(); setFallbackModelName(event.model_name);
setFallbackModelName(
modelBadge.model && activeModel !== modelBadge.model ? activeModel : null,
);
}); });
}, [chatId, client, modelBadge.model]); }, [chatId, client]);
useEffect(() => { useEffect(() => {
if (!historyKey || !chatId || loading) return; if (!historyKey || !chatId || loading) return;
@@ -1454,7 +1490,7 @@ export function ThreadShell({
: t("thread.composer.placeholderThread") : t("thread.composer.placeholderThread")
} }
modelLabel={modelBadgeLabel} modelLabel={modelBadgeLabel}
modelDetail={toModelBadgeLabel(modelBadge.model)} modelDetail={modelBadge.model}
modelPreset={activeModelPreset} modelPreset={activeModelPreset}
modelPresets={modelPresetOptions} modelPresets={modelPresetOptions}
onModelPresetChange={handleModelPresetChange} onModelPresetChange={handleModelPresetChange}
@@ -1463,6 +1499,7 @@ export function ThreadShell({
modelNeedsSetup={modelBadge.needsSetup} modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName} fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
contextUsage={composerContextUsage}
variant={composerVariant} variant={composerVariant}
slashCommands={availableSlashCommands} slashCommands={availableSlashCommands}
cliApps={cliApps} cliApps={cliApps}
@@ -1501,7 +1538,7 @@ export function ThreadShell({
: t("thread.composer.placeholderHero") : t("thread.composer.placeholderHero")
} }
modelLabel={modelBadgeLabel} modelLabel={modelBadgeLabel}
modelDetail={toModelBadgeLabel(modelBadge.model)} modelDetail={modelBadge.model}
modelPreset={activeModelPreset} modelPreset={activeModelPreset}
modelPresets={modelPresetOptions} modelPresets={modelPresetOptions}
onModelPresetChange={handleModelPresetChange} onModelPresetChange={handleModelPresetChange}
@@ -1510,6 +1547,7 @@ export function ThreadShell({
modelNeedsSetup={modelBadge.needsSetup} modelNeedsSetup={modelBadge.needsSetup}
fallbackModelName={fallbackModelName} fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
contextUsage={composerContextUsage}
variant="hero" variant="hero"
slashCommands={availableSlashCommands} slashCommands={availableSlashCommands}
cliApps={cliApps} cliApps={cliApps}
+40 -34
View File
@@ -165,11 +165,19 @@
pointer-events: none; pointer-events: none;
background-color: rgb(236 141 49); background-color: rgb(236 141 49);
opacity: 0; 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 { .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 > * { .composer-model-badge > * {
@@ -695,11 +703,38 @@
mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent); mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 0.75rem), transparent);
} }
.thread-composer-model-badge:not([data-switching="true"]):active .thread-composer-model-badge:not([data-switching="true"]):active > .composer-model-pill {
> .composer-model-pill {
transform: scale(0.98); transform: scale(0.98);
} }
@keyframes composer-model-pill-viewport-enter {
from {
transform: scale(0.9074);
}
to {
transform: scale(1);
}
}
.composer-model-pill-viewport {
transform-origin: right center;
animation: composer-model-pill-viewport-enter 210ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
mask-image: linear-gradient(to bottom, transparent, #000 4px, #000 calc(100% - 4px), transparent);
}
.composer-model-pill-dock {
transform-origin: right center;
transition-property: none;
will-change: transform;
}
.composer-model-pill-track[data-settling="true"],
.composer-model-pill-track[data-settling="true"] .composer-model-pill-dock {
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes preset-name-shake { @keyframes preset-name-shake {
0%, 0%,
100% { 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) { @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; transform: none !important;
} }
+20 -73
View File
@@ -55,7 +55,6 @@ type PendingStreamEvent =
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] } | { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields }; | { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
const STREAM_END_IDLE_DELAY_MS = 1000;
const BACKGROUND_STREAM_FLUSH_INTERVAL_MS = 1_000; 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. * Lookup rule: reasoning can only extend the current reasoning placeholder.
* Once ordinary answer text has appeared, the next reasoning chunk starts a * Once ordinary answer text has appeared, the next reasoning chunk starts a
* fresh Thought block so streamed output stays in arrival order: * fresh activity surface so streamed output stays in arrival order while the
* Thought -> answer -> Thought -> answer. * final answer remains the only visible answer bubble.
*/ */
function attachReasoningChunk( function attachReasoningChunk(
prev: UIMessage[], prev: UIMessage[],
@@ -183,16 +182,6 @@ export interface SubmittedTurn {
sideChannel: boolean; 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 { function eventTurnId(ev: InboundEvent): string | undefined {
return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : 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 streamTimerRef = useRef<number | null>(null);
const suppressStreamUntilTurnEndRef = useRef(false); const suppressStreamUntilTurnEndRef = useRef(false);
const sideChannelTurnIdsRef = useRef<Set<string>>(new Set()); 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), []); const dismissStreamError = useCallback(() => setStreamError(null), []);
@@ -319,26 +300,11 @@ export function useNanobotStream(
pendingStreamEventsRef.current = []; pendingStreamEventsRef.current = [];
}, []); }, []);
const cancelStreamEndTimer = useCallback(() => {
if (streamEndTimerRef.current === null) return;
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}, []);
const isSideChannelEvent = useCallback((ev: InboundEvent) => { const isSideChannelEvent = useCallback((ev: InboundEvent) => {
const turnId = eventTurnId(ev); const turnId = eventTurnId(ev);
return turnId !== undefined && sideChannelTurnIdsRef.current.has(turnId); 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) => { const createActivitySegmentId = useCallback((activate = true) => {
activitySegmentCounterRef.current += 1; activitySegmentCounterRef.current += 1;
const id = `activity-${activitySegmentCounterRef.current}`; const id = `activity-${activitySegmentCounterRef.current}`;
@@ -387,7 +353,6 @@ export function useNanobotStream(
(event) => event.turn.turnId !== rejectedTurnId, (event) => event.turn.turnId !== rejectedTurnId,
); );
sideChannelTurnIdsRef.current.delete(rejectedTurnId); sideChannelTurnIdsRef.current.delete(rejectedTurnId);
cancelStreamEndTimer();
setMessages((prev) => { setMessages((prev) => {
const rejectedRows = prev.filter((message) => message.turnId === rejectedTurnId); const rejectedRows = prev.filter((message) => message.turnId === rejectedTurnId);
if (rejectedRows.length === 0) return prev; if (rejectedRows.length === 0) return prev;
@@ -438,7 +403,7 @@ export function useNanobotStream(
setRunStartedAt(remainingStartedAt); setRunStartedAt(remainingStartedAt);
setIsStreaming(hasRemainingRun); setIsStreaming(hasRemainingRun);
if (!hasRemainingRun) suppressStreamUntilTurnEndRef.current = false; if (!hasRemainingRun) suppressStreamUntilTurnEndRef.current = false;
}, [cancelStreamEndTimer, chatId, client]); }, [chatId, client]);
useEffect(() => client.onError(applyStreamError), [applyStreamError, client]); useEffect(() => client.onError(applyStreamError), [applyStreamError, client]);
@@ -659,15 +624,6 @@ export function useNanobotStream(
return () => document.removeEventListener("visibilitychange", flushOnReturn); return () => document.removeEventListener("visibilitychange", flushOnReturn);
}, [flushPendingStreamEvents]); }, [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 // Reset local state when switching chats. Do not reset on every
// ``initialMessages`` update: a brand-new chat can receive an empty/404 // ``initialMessages`` update: a brand-new chat can receive an empty/404
// history response after the optimistic first message has already rendered. // history response after the optimistic first message has already rendered.
@@ -690,9 +646,8 @@ export function useNanobotStream(
clearPendingStreamWork(); clearPendingStreamWork();
sideChannelTurnIdsRef.current.clear(); sideChannelTurnIdsRef.current.clear();
suppressStreamUntilTurnEndRef.current = false; suppressStreamUntilTurnEndRef.current = false;
cancelStreamEndTimer();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId, client, cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]); }, [chatId, client, clearActivitySegment, clearPendingStreamWork]);
useEffect(() => { useEffect(() => {
if (hasPendingToolCalls) setIsStreaming(true); if (hasPendingToolCalls) setIsStreaming(true);
@@ -774,12 +729,6 @@ export function useNanobotStream(
return; return;
} }
const sideChannelEvent = isSideChannelEvent(ev); const sideChannelEvent = isSideChannelEvent(ev);
if (
streamEndTimerRef.current !== null
&& !sideChannelEvent
&& eventExtendsModelActivity(ev)
) cancelStreamEndTimer();
if (ev.event === "delta") { if (ev.event === "delta") {
if (suppressStreamUntilTurnEndRef.current) return; if (suppressStreamUntilTurnEndRef.current) return;
const chunk = typeof ev.text === "string" ? ev.text : ""; const chunk = typeof ev.text === "string" ? ev.text : "";
@@ -822,14 +771,13 @@ export function useNanobotStream(
}); });
if (suppressStreamUntilTurnEndRef.current) return; if (suppressStreamUntilTurnEndRef.current) return;
if (ev.resuming) { if (ev.resuming) {
cancelStreamEndTimer();
setIsStreaming(true); setIsStreaming(true);
if (!mergeNext) {
setMessages((prev) => finalizeStreamedTurn(prev, turn));
}
return; 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; return;
} }
@@ -868,9 +816,8 @@ export function useNanobotStream(
setGoalState(ev.goal_state); setGoalState(ev.goal_state);
} }
setRunStartedAt(null); setRunStartedAt(null);
// Definitive signal that the turn is fully complete. Cancel any // Definitive signal that the turn is fully complete, so stop the
// pending debounce timer and stop the loading indicator immediately. // loading indicator immediately.
cancelStreamEndTimer();
setIsStreaming(false); setIsStreaming(false);
const completedAt = Date.now(); const completedAt = Date.now();
setMessages((prev) => { setMessages((prev) => {
@@ -884,6 +831,10 @@ export function useNanobotStream(
finalized, finalized,
{ {
...(latencyMs !== undefined ? { latencyMs } : {}), ...(latencyMs !== undefined ? { latencyMs } : {}),
...(ev.usage ? { usage: ev.usage } : {}),
...(typeof ev.context_window_tokens === "number"
? { contextWindowTokens: ev.context_window_tokens }
: {}),
completedAt, completedAt,
}, },
ev.turn_id, ev.turn_id,
@@ -1013,8 +964,9 @@ export function useNanobotStream(
// A complete (non-streamed) assistant message. If a stream was in // A complete (non-streamed) assistant message. If a stream was in
// flight, drop the placeholder so we don't render the text twice. // 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`` is the turn boundary. ``stream_end`` only closes the
// ``turn_end`` for non-streamed and tool-heavy turns. // current text segment so a following tool/reasoning segment remains
// part of the same live activity surface.
clearActivitySegment(); clearActivitySegment();
setMessages((prev) => { setMessages((prev) => {
const activeId = buffer.current?.messageId; const activeId = buffer.current?.messageId;
@@ -1088,7 +1040,6 @@ export function useNanobotStream(
}); });
return; return;
} }
// ``attached`` frames aren't actionable here.
}; };
const unsub = client.onChat(chatId, handle); const unsub = client.onChat(chatId, handle);
@@ -1099,12 +1050,11 @@ export function useNanobotStream(
closedAssistantStreamIdsRef.current.clear(); closedAssistantStreamIdsRef.current.clear();
clearActivitySegment(); clearActivitySegment();
clearPendingStreamWork(); clearPendingStreamWork();
cancelStreamEndTimer();
}; };
}, [ }, [
applyStreamError, applyStreamError,
cancelStreamEndTimer,
chatId, chatId,
closeActiveAssistantStream,
client, client,
clearActivitySegment, clearActivitySegment,
clearPendingStreamWork, clearPendingStreamWork,
@@ -1114,7 +1064,6 @@ export function useNanobotStream(
isSideChannelEvent, isSideChannelEvent,
onTurnEnd, onTurnEnd,
schedulePendingStreamFlush, schedulePendingStreamFlush,
scheduleStreamEndTimer,
]); ]);
const send = useCallback( const send = useCallback(
@@ -1133,7 +1082,6 @@ export function useNanobotStream(
: content; : content;
flushPendingStreamEvents(); flushPendingStreamEvents();
if (finalizeActiveTurn) { if (finalizeActiveTurn) {
cancelStreamEndTimer();
setIsStreaming(false); setIsStreaming(false);
} }
const turnId = crypto.randomUUID(); const turnId = crypto.randomUUID();
@@ -1188,7 +1136,7 @@ export function useNanobotStream(
client.sendMessage(chatId, outboundContent, wireMedia, clientOptions); client.sendMessage(chatId, outboundContent, wireMedia, clientOptions);
return { turnId, userMessageId, sideChannel }; return { turnId, userMessageId, sideChannel };
}, },
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents], [chatId, clearActivitySegment, client, flushPendingStreamEvents],
); );
const stop = useCallback(() => { const stop = useCallback(() => {
@@ -1209,7 +1157,6 @@ export function useNanobotStream(
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]); }, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
const reconcileTurnComplete = useCallback(() => { const reconcileTurnComplete = useCallback(() => {
cancelStreamEndTimer();
clearPendingStreamWork(); clearPendingStreamWork();
buffer.current = null; buffer.current = null;
activeAssistantRef.current = null; activeAssistantRef.current = null;
@@ -1218,7 +1165,7 @@ export function useNanobotStream(
suppressStreamUntilTurnEndRef.current = false; suppressStreamUntilTurnEndRef.current = false;
setRunStartedAt(null); setRunStartedAt(null);
setIsStreaming(false); setIsStreaming(false);
}, [cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]); }, [clearActivitySegment, clearPendingStreamWork]);
const transcribeAudio = useCallback( const transcribeAudio = useCallback(
(dataUrl: string, options?: { durationMs?: number }) => (dataUrl: string, options?: { durationMs?: number }) =>
+11 -1
View File
@@ -1192,6 +1192,11 @@
"removeQuotedContext": "Remove quoted context", "removeQuotedContext": "Remove quoted context",
"modelNotConfigured": "Model not configured", "modelNotConfigured": "Model not configured",
"configureModel": "Configure model", "configureModel": "Configure model",
"switchModel": "Switch model for this chat",
"context": {
"tooltip": "Context · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% used."
},
"queued": { "queued": {
"label": "Queued guidance", "label": "Queued guidance",
"guide": "Guide", "guide": "Guide",
@@ -1424,7 +1429,12 @@
"fileEditShowMoreLines": "Show {{count}} more lines", "fileEditShowMoreLines": "Show {{count}} more lines",
"fileEditShowFewerLines": "Show fewer lines", "fileEditShowFewerLines": "Show fewer lines",
"fileEditOpenFile": "Open file", "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": { "lightbox": {
"title": "Image preview", "title": "Image preview",
+10
View File
@@ -1179,6 +1179,11 @@
"removeQuotedContext": "Quitar contexto citado", "removeQuotedContext": "Quitar contexto citado",
"modelNotConfigured": "Modelo no configurado", "modelNotConfigured": "Modelo no configurado",
"configureModel": "Configurar modelo", "configureModel": "Configurar modelo",
"switchModel": "Cambiar el modelo de este chat",
"context": {
"tooltip": "Contexto · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}} % usado."
},
"queued": { "queued": {
"label": "Guía en cola", "label": "Guía en cola",
"guide": "Guiar", "guide": "Guiar",
@@ -1395,6 +1400,11 @@
"fileEditShowFewerLines": "Mostrar menos líneas", "fileEditShowFewerLines": "Mostrar menos líneas",
"fileEditOpenFile": "Abrir archivo", "fileEditOpenFile": "Abrir archivo",
"fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.", "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}}", "activityThinkingFor": "Pensando durante {{duration}}",
"activityThought": "Pensamiento completado", "activityThought": "Pensamiento completado",
"activityThoughtFor": "Pensó durante {{duration}}", "activityThoughtFor": "Pensó durante {{duration}}",
+10
View File
@@ -1178,6 +1178,11 @@
"removeQuotedContext": "Supprimer le contexte cité", "removeQuotedContext": "Supprimer le contexte cité",
"modelNotConfigured": "Modèle non configuré", "modelNotConfigured": "Modèle non configuré",
"configureModel": "Configurer le modèle", "configureModel": "Configurer le modèle",
"switchModel": "Changer le modèle de cette conversation",
"context": {
"tooltip": "Contexte · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}} % utilisé."
},
"queued": { "queued": {
"label": "Guidage en attente", "label": "Guidage en attente",
"guide": "Guider", "guide": "Guider",
@@ -1394,6 +1399,11 @@
"fileEditShowFewerLines": "Afficher moins de lignes", "fileEditShowFewerLines": "Afficher moins de lignes",
"fileEditOpenFile": "Ouvrir le fichier", "fileEditOpenFile": "Ouvrir le fichier",
"fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.", "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 lutilisation"
},
"activityThinkingFor": "Réflexion pendant {{duration}}", "activityThinkingFor": "Réflexion pendant {{duration}}",
"activityThought": "Réflexion terminée", "activityThought": "Réflexion terminée",
"activityThoughtFor": "Réflexion terminée en {{duration}}", "activityThoughtFor": "Réflexion terminée en {{duration}}",
+10
View File
@@ -1178,6 +1178,11 @@
"removeQuotedContext": "Hapus konteks kutipan", "removeQuotedContext": "Hapus konteks kutipan",
"modelNotConfigured": "Model belum dikonfigurasi", "modelNotConfigured": "Model belum dikonfigurasi",
"configureModel": "Konfigurasi model", "configureModel": "Konfigurasi model",
"switchModel": "Ganti model untuk percakapan ini",
"context": {
"tooltip": "Konteks · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% digunakan."
},
"queued": { "queued": {
"label": "Panduan antrean", "label": "Panduan antrean",
"guide": "Pandu", "guide": "Pandu",
@@ -1394,6 +1399,11 @@
"fileEditShowFewerLines": "Tampilkan lebih sedikit baris", "fileEditShowFewerLines": "Tampilkan lebih sedikit baris",
"fileEditOpenFile": "Buka file", "fileEditOpenFile": "Buka file",
"fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.", "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}}", "activityThinkingFor": "Berpikir selama {{duration}}",
"activityThought": "Selesai berpikir", "activityThought": "Selesai berpikir",
"activityThoughtFor": "Selesai berpikir dalam {{duration}}", "activityThoughtFor": "Selesai berpikir dalam {{duration}}",
+10
View File
@@ -1178,6 +1178,11 @@
"removeQuotedContext": "引用したコンテキストを削除", "removeQuotedContext": "引用したコンテキストを削除",
"modelNotConfigured": "モデルが未設定です", "modelNotConfigured": "モデルが未設定です",
"configureModel": "モデルを設定", "configureModel": "モデルを設定",
"switchModel": "この会話で使うモデルを切り替える",
"context": {
"tooltip": "コンテキスト · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。{{percent}}% 使用中"
},
"queued": { "queued": {
"label": "保留中のガイド", "label": "保留中のガイド",
"guide": "ガイド", "guide": "ガイド",
@@ -1394,6 +1399,11 @@
"fileEditShowFewerLines": "表示行数を減らす", "fileEditShowFewerLines": "表示行数を減らす",
"fileEditOpenFile": "ファイルを開く", "fileEditOpenFile": "ファイルを開く",
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。", "fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
"usage": {
"context": "最新のコンテキスト: {{tokens}}{{capacity}} トークン",
"requests": "モデルリクエスト {{count}} 回",
"estimated": "推定使用量を含みます"
},
"activityThinkingFor": "{{duration}}考えています", "activityThinkingFor": "{{duration}}考えています",
"activityThought": "思考しました", "activityThought": "思考しました",
"activityThoughtFor": "{{duration}}考えました", "activityThoughtFor": "{{duration}}考えました",
+10
View File
@@ -1178,6 +1178,11 @@
"removeQuotedContext": "인용한 문맥 제거", "removeQuotedContext": "인용한 문맥 제거",
"modelNotConfigured": "모델이 설정되지 않음", "modelNotConfigured": "모델이 설정되지 않음",
"configureModel": "모델 설정", "configureModel": "모델 설정",
"switchModel": "이 대화에서 사용할 모델 전환",
"context": {
"tooltip": "컨텍스트 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% 사용 중."
},
"queued": { "queued": {
"label": "대기 중인 안내", "label": "대기 중인 안내",
"guide": "안내", "guide": "안내",
@@ -1394,6 +1399,11 @@
"fileEditShowFewerLines": "줄 줄이기", "fileEditShowFewerLines": "줄 줄이기",
"fileEditOpenFile": "파일 열기", "fileEditOpenFile": "파일 열기",
"fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.", "fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
"usage": {
"context": "최근 컨텍스트: {{tokens}}{{capacity}} 토큰",
"requests": "모델 요청 {{count}}회",
"estimated": "예상 사용량 포함"
},
"activityThinkingFor": "{{duration}} 동안 생각 중", "activityThinkingFor": "{{duration}} 동안 생각 중",
"activityThought": "생각함", "activityThought": "생각함",
"activityThoughtFor": "{{duration}} 동안 생각함", "activityThoughtFor": "{{duration}} 동안 생각함",
+11 -1
View File
@@ -1192,6 +1192,11 @@
"removeQuotedContext": "Remover contexto citado", "removeQuotedContext": "Remover contexto citado",
"modelNotConfigured": "Modelo não configurado", "modelNotConfigured": "Modelo não configurado",
"configureModel": "Configurar modelo", "configureModel": "Configurar modelo",
"switchModel": "Alternar o modelo desta conversa",
"context": {
"tooltip": "Contexto · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}. {{percent}}% usado."
},
"queued": { "queued": {
"label": "Guia em fila", "label": "Guia em fila",
"guide": "Guiar", "guide": "Guiar",
@@ -1424,7 +1429,12 @@
"fileEditShowMoreLines": "Mostrar mais {{count}} linhas", "fileEditShowMoreLines": "Mostrar mais {{count}} linhas",
"fileEditShowFewerLines": "Mostrar menos linhas", "fileEditShowFewerLines": "Mostrar menos linhas",
"fileEditOpenFile": "Abrir arquivo", "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": { "lightbox": {
"title": "Pré-visualização de imagem", "title": "Pré-visualização de imagem",
+10
View File
@@ -1178,6 +1178,11 @@
"removeQuotedContext": "Xóa ngữ cảnh được trích dẫn", "removeQuotedContext": "Xóa ngữ cảnh được trích dẫn",
"modelNotConfigured": "Chưa cấu hình mô hình", "modelNotConfigured": "Chưa cấu hình mô hình",
"configureModel": "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": { "queued": {
"label": "Hướng dẫn đang chờ", "label": "Hướng dẫn đang chờ",
"guide": "Hướng dẫn", "guide": "Hướng dẫn",
@@ -1394,6 +1399,11 @@
"fileEditShowFewerLines": "Hiển thị ít dòng hơn", "fileEditShowFewerLines": "Hiển thị ít dòng hơn",
"fileEditOpenFile": "Mở tệp", "fileEditOpenFile": "Mở tệp",
"fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.", "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}}", "activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
"activityThought": "Đã suy nghĩ", "activityThought": "Đã suy nghĩ",
"activityThoughtFor": "Đã suy nghĩ trong {{duration}}", "activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
+11 -1
View File
@@ -1191,6 +1191,11 @@
"removeQuotedContext": "移除引用内容", "removeQuotedContext": "移除引用内容",
"modelNotConfigured": "模型未配置", "modelNotConfigured": "模型未配置",
"configureModel": "配置模型", "configureModel": "配置模型",
"switchModel": "切换本次对话所用模型",
"context": {
"tooltip": "上下文 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。已使用 {{percent}}%。"
},
"queued": { "queued": {
"label": "排队中的引导消息", "label": "排队中的引导消息",
"guide": "引导", "guide": "引导",
@@ -1424,7 +1429,12 @@
"fileEditShowMoreLines": "显示剩余 {{count}} 行", "fileEditShowMoreLines": "显示剩余 {{count}} 行",
"fileEditShowFewerLines": "收起部分行", "fileEditShowFewerLines": "收起部分行",
"fileEditOpenFile": "打开文件", "fileEditOpenFile": "打开文件",
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。" "fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。",
"usage": {
"context": "当前上下文:{{tokens}}{{capacity}}",
"requests": "本轮 {{count}} 次模型调用",
"estimated": "包含估算用量"
}
}, },
"lightbox": { "lightbox": {
"title": "图片预览", "title": "图片预览",
+10
View File
@@ -1178,6 +1178,11 @@
"removeQuotedContext": "移除引用內容", "removeQuotedContext": "移除引用內容",
"modelNotConfigured": "尚未設定模型", "modelNotConfigured": "尚未設定模型",
"configureModel": "設定模型", "configureModel": "設定模型",
"switchModel": "切換此對話使用的模型",
"context": {
"tooltip": "上下文 · {{tokens}}{{capacity}}",
"meterDescription": "{{context}}。已使用 {{percent}}%。"
},
"queued": { "queued": {
"label": "佇列中的引導訊息", "label": "佇列中的引導訊息",
"guide": "引導", "guide": "引導",
@@ -1393,6 +1398,11 @@
"fileEditShowFewerLines": "收起部分行", "fileEditShowFewerLines": "收起部分行",
"fileEditOpenFile": "開啟檔案", "fileEditOpenFile": "開啟檔案",
"fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。", "fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。",
"usage": {
"context": "最近一次上下文:{{tokens}}{{capacity}} tokens",
"requests": "{{count}} 次模型請求",
"estimated": "包含估算用量"
},
"activityThinkingFor": "思考中,已 {{duration}}", "activityThinkingFor": "思考中,已 {{duration}}",
"activityThought": "已思考", "activityThought": "已思考",
"activityThoughtFor": "已思考 {{duration}}", "activityThoughtFor": "已思考 {{duration}}",
+199 -184
View File
@@ -1,5 +1,8 @@
import type { UIMessage } from "@/lib/types"; 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 = export type TurnUnit =
| { | {
type: "activity"; type: "activity";
@@ -9,106 +12,103 @@ export type TurnUnit =
} }
| { type: "message"; message: UIMessage }; | { type: "message"; message: UIMessage };
interface NormalizeActivityTimelineOptions {
preserveTrailingActivity?: boolean;
}
export function isReasoningOnlyAssistant(message: UIMessage): boolean { export function isReasoningOnlyAssistant(message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false; 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); return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming);
} }
export function isAgentActivityMember(message: UIMessage): boolean { 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 { export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
if (messages.length === 0) return false; const last = messages.at(-1);
const last = messages[messages.length - 1]; if (!last || !isAgentActivityMember(last)) return false;
if (!isAgentActivityMember(last)) return false; if (last.isStreaming || last.reasoningStreaming) return true;
let trailingStart = messages.length - 1; const lastTurnId = last.turnId;
while ( const previous = messages.at(-2);
trailingStart > 0 // A trace without a visible answer is an unfinished turn on replay. Once a
&& isAgentActivityMember(messages[trailingStart - 1]) // final assistant answer exists after it, the activity is simply history.
) { return !previous
trailingStart -= 1; || previous.role !== "assistant"
} || isAgentActivityMember(previous)
|| previous.turnId !== lastTurnId;
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);
} }
/**
* 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( export function normalizeActivityTimeline(
messages: UIMessage[], messages: UIMessage[],
options: NormalizeActivityTimelineOptions = {},
): TurnUnit[] { ): TurnUnit[] {
const units: TurnUnit[] = []; const units: TurnUnit[] = [];
let turnMessages: UIMessage[] = []; let turnMessages: UIMessage[] = [];
let activeTurnId: string | undefined; let activeTurnId: string | undefined;
let activeTurnStartedAtMs: number | undefined; let activeTurnStartedAtMs: number | undefined;
const flushTurn = (flushOptions: NormalizeActivityTimelineOptions = {}) => { const flushTurn = () => {
if (turnMessages.length === 0) { if (!turnMessages.length) {
activeTurnId = undefined; activeTurnId = undefined;
activeTurnStartedAtMs = undefined;
return; return;
} }
const turnUnits: TurnUnit[] = []; const ordered = orderMessagesByTurnSeq(turnMessages);
const turnStartedAtMs = activeTurnStartedAtMs; const lastActivityIndex = ordered.reduce(
const orderedTurnMessages = orderMessagesByTurnSeq(turnMessages); (index, message, current) => isRawActivity(message) ? current : index,
const visibleMessages = visibleMessagesForTurn(orderedTurnMessages); -1,
let visibleIndex = 0; );
let activityMessages: UIMessage[] = []; 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 = () => { const activity: UIMessage[] = [];
if (!activityMessages.length) return; const answers: UIMessage[] = [];
pushActivityUnits( ordered.forEach((message, index) => {
turnUnits, if (isRawActivity(message)) {
activityMessages, activity.push(message);
visibleMessages.slice(visibleIndex), } else if (isAssistantAnswer(message)) {
turnStartedAtMs, if (message.reasoning?.trim() || message.reasoningStreaming) {
); activity.push(reasoningOnlyMessageFromAnswer(message));
activityMessages = []; }
}; if (hasFinalAnswer && index === finalAnswerIndex) {
answers.push(stripInlineReasoning(message));
for (const message of orderedTurnMessages) { } else {
if (isAgentActivityMember(message)) { activity.push(modelActivitySnippet(message));
activityMessages.push(message); }
continue; } else {
activity.push(message);
} }
});
if (assistantHasInlineReasoning(message)) { if (activity.length) {
activityMessages.push(reasoningOnlyMessageFromAnswer(message)); units.push({
flushActivityMessages(); type: "activity",
turnUnits.push({ type: "message", message: stripInlineReasoning(message) }); messages: activity,
visibleIndex += 1; turnLatencyMs: activityTurnLatencyMs(activity, ordered),
continue; startedAtMs: activeTurnStartedAtMs,
} });
}
flushActivityMessages(); if (answers.length) {
turnUnits.push({ type: "message", message }); units.push({ type: "message", message: mergeAssistantAnswers(answers) });
visibleIndex += 1;
} }
flushActivityMessages();
units.push(...normalizeCompletedTurnUnits(turnUnits, flushOptions));
turnMessages = []; turnMessages = [];
activeTurnId = undefined; activeTurnId = undefined;
activeTurnStartedAtMs = undefined; activeTurnStartedAtMs = undefined;
@@ -122,131 +122,142 @@ export function normalizeActivityTimeline(
activeTurnStartedAtMs = validCreatedAtMs(message.createdAt); activeTurnStartedAtMs = validCreatedAtMs(message.createdAt);
continue; continue;
} }
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) flushTurn();
if (message.turnId && activeTurnId && message.turnId !== activeTurnId) { if (message.turnId) activeTurnId = message.turnId;
flushTurn();
}
if (message.turnId) {
activeTurnId = message.turnId;
}
turnMessages.push(message); turnMessages.push(message);
} }
flushTurn(options); flushTurn();
return units; 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[] { function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
if ( if (messages.length < 2 || !messages.every((message) => Number.isFinite(message.turnSeq))) {
messages.length < 2
|| !messages.every((message) => Number.isFinite(message.turnSeq))
) {
return messages; return messages;
} }
return messages return messages
.map((message, index) => ({ message, index })) .map((message, index) => ({ message, index }))
.sort((left, right) => { .sort((left, right) => (left.message.turnSeq! - right.message.turnSeq!) || (left.index - right.index))
const bySeq = (left.message.turnSeq ?? 0) - (right.message.turnSeq ?? 0);
return bySeq || left.index - right.index;
})
.map(({ message }) => message); .map(({ message }) => message);
} }
function normalizeCompletedTurnUnits( function mergeAssistantAnswers(answers: UIMessage[]): UIMessage {
turnUnits: TurnUnit[], const first = answers[0];
options: NormalizeActivityTimelineOptions, const last = answers.at(-1)!;
): TurnUnit[] { const media = answers.flatMap((message) => message.media ?? []);
if (options.preserveTrailingActivity || turnUnits.length < 2) return turnUnits; const images = answers.flatMap((message) => message.images ?? []);
if (turnUnits[turnUnits.length - 1]?.type !== "activity") return turnUnits; const merged: UIMessage = {
...first,
let trailingStart = turnUnits.length - 1; ...last,
while (trailingStart > 0 && turnUnits[trailingStart - 1]?.type === "activity") { id: first.id,
trailingStart -= 1; content: answers.map((message) => message.content.trim()).filter(Boolean).join("\n\n"),
} createdAt: first.createdAt,
isStreaming: answers.some((message) => message.isStreaming),
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;
}; };
if (media.length) merged.media = media;
for (const message of activityMessages) { else delete merged.media;
const bucket = isFileEditActivityMessage(message) ? "file" : "other"; if (images.length) merged.images = images;
const segmentId = message.activitySegmentId; else delete merged.images;
const segmentChanged = return merged;
bucket === "file"
&& runBucket === "file"
&& !!runSegmentId
&& !!segmentId
&& runSegmentId !== segmentId;
if ((runBucket && bucket !== runBucket) || segmentChanged) {
flushRun();
}
runBucket = bucket;
if (segmentId) runSegmentId = segmentId;
runMessages.push(message);
}
flushRun();
} }
function isFileEditActivityMessage(message: UIMessage): boolean { function modelActivitySnippet(message: UIMessage): UIMessage {
return message.kind === "trace" && !!message.fileEdits?.length; return {
} ...stripInlineReasoning(message),
id: `${message.id}-activity`,
function assistantHasInlineReasoning(message: UIMessage): boolean { activityKind: "model",
return ( turnPhase: "activity",
message.role === "assistant" // Keep the source stream state so the activity surface can render this
&& message.kind !== "trace" // segment with the same Markdown streaming semantics as a normal answer.
&& message.content.trim().length > 0 isStreaming: message.isStreaming,
&& (!!message.reasoning?.trim() || !!message.reasoningStreaming) };
);
} }
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage { function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
@@ -273,13 +284,17 @@ function stripInlineReasoning(message: UIMessage): UIMessage {
return next; return next;
} }
function activityTurnLatencyMs(activityMessages: UIMessage[], visibleMessages: UIMessage[]): number | undefined { function validCreatedAtMs(value: unknown): number | undefined {
for (let i = visibleMessages.length - 1; i >= 0; i -= 1) { return typeof value === "number" && Number.isFinite(value) ? value : undefined;
const latency = visibleMessages[i].latencyMs; }
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; if (isValidLatency(latency)) return latency;
} }
for (let i = activityMessages.length - 1; i >= 0; i -= 1) { for (let index = activityMessages.length - 1; index >= 0; index -= 1) {
const latency = activityMessages[i].latencyMs; const latency = activityMessages[index].latencyMs;
if (isValidLatency(latency)) return latency; if (isValidLatency(latency)) return latency;
} }
return undefined; return undefined;
+11
View File
@@ -1,5 +1,16 @@
import i18n, { currentLocale } from "@/i18n"; 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([ const LOW_INFORMATION_TITLE_PREVIEWS = new Set([
"hi", "hi",
"hello", "hello",
+1 -1
View File
@@ -136,7 +136,7 @@ export function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
export function stampLastAssistantCompletion( export function stampLastAssistantCompletion(
prev: UIMessage[], prev: UIMessage[],
completion: Pick<UIMessage, "latencyMs" | "completedAt">, completion: Pick<UIMessage, "latencyMs" | "completedAt" | "usage" | "contextWindowTokens">,
turnId?: string, turnId?: string,
): UIMessage[] { ): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) { for (let i = prev.length - 1; i >= 0; i -= 1) {
+26 -1
View File
@@ -39,6 +39,16 @@ export interface UIMediaAttachment {
export interface UIMessageSource { kind: "cron" | "local_trigger" | "trigger" | string; label?: string; } 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 { export interface UIMessage {
id: string; id: string;
role: Role; role: Role;
@@ -56,6 +66,9 @@ export interface UIMessage {
fileEdits?: UIFileEdit[]; fileEdits?: UIFileEdit[];
/** Activity rows created during the same agent phase share one collapsible block. */ /** Activity rows created during the same agent phase share one collapsible block. */
activitySegmentId?: string; 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. */ /** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */
images?: UIImage[]; images?: UIImage[];
/** Signed or local UI-renderable media attachments. */ /** Signed or local UI-renderable media attachments. */
@@ -77,6 +90,10 @@ export interface UIMessage {
latencyMs?: number; latencyMs?: number;
/** Client epoch milliseconds when the definitive ``turn_end`` was received. */ /** Client epoch milliseconds when the definitive ``turn_end`` was received. */
completedAt?: number; 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. */ /** Lightweight provenance for proactive assistant messages. */
source?: UIMessageSource; source?: UIMessageSource;
/** Structured provenance for a message delivered by another session. */ /** Structured provenance for a message delivered by another session. */
@@ -1225,7 +1242,12 @@ export interface InboundTurnMetadata {
export type InboundEvent = export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string } | { 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"; event: "message_accepted";
chat_id: string; chat_id: string;
@@ -1313,11 +1335,14 @@ export type InboundEvent =
chat_id: string; chat_id: string;
model_name: string; model_name: string;
model_preset?: string | null; model_preset?: string | null;
fallback?: boolean;
} }
| ({ | ({
event: "turn_end"; event: "turn_end";
chat_id: string; chat_id: string;
latency_ms?: number; latency_ms?: number;
usage?: TurnUsage;
context_window_tokens?: number;
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */ /** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
goal_state?: GoalStateWsPayload; goal_state?: GoalStateWsPayload;
} & InboundTurnMetadata) } & InboundTurnMetadata)
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { preloadMarkdownText } from "@/components/MarkdownText";
import { DEFAULT_LOCAL_PREFS, writeLocalPreferences } from "@/lib/local-preferences"; import { DEFAULT_LOCAL_PREFS, writeLocalPreferences } from "@/lib/local-preferences";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types"; import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
@@ -139,6 +140,34 @@ function installReducedMotion() {
} }
describe("AgentActivityCluster", () => { 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", () => { it("jumps to the latest activity when opened", () => {
const raf = installAnimationFrameQueue(); const raf = installAnimationFrameQueue();
try { try {
@@ -398,7 +427,7 @@ describe("AgentActivityCluster", () => {
vi.advanceTimersByTime(301); vi.advanceTimersByTime(301);
}); });
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument(); expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Thought" })).toHaveAttribute( expect(screen.getByRole("button", { name: "Worked" })).toHaveAttribute(
"aria-expanded", "aria-expanded",
"false", "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"); expect(button).toHaveAttribute("data-thread-disclosure");
const chevron = button.querySelector("svg"); const chevron = button.querySelector("svg");
expect(chevron).toBeInTheDocument(); 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", () => { it("labels mixed tool activity as work instead of thought", () => {
@@ -481,8 +510,8 @@ describe("AgentActivityCluster", () => {
/>, />,
); );
expect(screen.getByText("Thought")).toBeInTheDocument(); expect(screen.getByText("Worked")).toBeInTheDocument();
expect(screen.queryByText("Thought for 0s")).not.toBeInTheDocument(); expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument();
}); });
it("renders file edits as one-line activity rows", async () => { it("renders file edits as one-line activity rows", async () => {
+47
View File
@@ -245,6 +245,7 @@ describe("MessageBubble", () => {
const quote = screen.getByLabelText("Quoted context"); const quote = screen.getByLabelText("Quoted context");
expect(quote).toHaveTextContent("selected assistant excerpt"); expect(quote).toHaveTextContent("selected assistant excerpt");
expect(quote).not.toHaveAttribute("title");
expect(screen.queryByText("Quoted context")).not.toBeInTheDocument(); expect(screen.queryByText("Quoted context")).not.toBeInTheDocument();
expect(screen.getByText("What about this?")).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(container.querySelector('img[src="/api/media/sig/svg"]')).toBeInTheDocument();
expect(screen.queryByLabelText("File attachment")).not.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");
});
}); });
+2
View File
@@ -1724,6 +1724,7 @@ describe("NanobotClient", () => {
chat_id: "chat-a", chat_id: "chat-a",
model_name: "deepseek/deepseek-chat", model_name: "deepseek/deepseek-chat",
model_preset: "Deep Research", model_preset: "Deep Research",
fallback: true,
}); });
expect(chatHandler).toHaveBeenCalledWith({ expect(chatHandler).toHaveBeenCalledWith({
@@ -1731,6 +1732,7 @@ describe("NanobotClient", () => {
chat_id: "chat-a", chat_id: "chat-a",
model_name: "deepseek/deepseek-chat", model_name: "deepseek/deepseek-chat",
model_preset: "Deep Research", model_preset: "Deep Research",
fallback: true,
}); });
}); });
+77 -150
View File
@@ -317,9 +317,9 @@ function ascii(bytes: Uint8Array, offset: number, length: number): string {
} }
const MODEL_PRESETS = [ const MODEL_PRESETS = [
{ name: "kimi", provider: "moonshot" }, { name: "kimi", model: "moonshot/kimi-k2.5", provider: "moonshot" },
{ name: "dflash", provider: "deepseek" }, { name: "dflash", model: "deepseek/deepseek-v4-flash", provider: "deepseek" },
{ name: "dspro", provider: "deepseek" }, { name: "dspro", model: "deepseek/deepseek-v4-pro", provider: "deepseek" },
]; ];
function renderPresetComposer(variant: "thread" | "hero" = "thread") { function renderPresetComposer(variant: "thread" | "hero" = "thread") {
@@ -337,28 +337,11 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") {
/>, />,
); );
return { return {
badge: screen.getByRole("spinbutton", { name: "kimi" }), badge: screen.getByRole("button", { name: "kimi" }),
onPresetChange, onPresetChange,
}; };
} }
function pointerDown(badge: HTMLElement, pointerId = 7, clientY = 100, button = 0) {
fireEvent.pointerDown(badge, {
button,
clientY,
isPrimary: true,
pointerId,
pointerType: "mouse",
});
}
function longPress(badge: HTMLElement, pointerId = 7) {
pointerDown(badge, pointerId);
act(() => {
vi.advanceTimersByTime(400);
});
}
describe("ThreadComposer", () => { describe("ThreadComposer", () => {
it("locks an async send and keeps the draft when it is rejected", async () => { it("locks an async send and keeps the draft when it is rejected", async () => {
let resolveSend!: (accepted: boolean) => void; 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).toHaveClass("w-fit", "max-w-[min(18rem,44vw)]");
expect(badge).not.toHaveClass("w-[5.75rem]"); expect(badge).not.toHaveClass("w-[5.75rem]");
expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument(); expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument();
}); });
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", () => { it("keeps the thread composer compact while matching the hero style", () => {
render( render(
<ThreadComposer <ThreadComposer
@@ -559,7 +572,9 @@ describe("ThreadComposer", () => {
const modelPill = screen.getByText("gpt-4o").closest(".composer-model-pill"); const modelPill = screen.getByText("gpt-4o").closest(".composer-model-pill");
expect(modelPill).toHaveClass("font-medium", "text-foreground/70"); expect(modelPill).toHaveClass("font-medium", "text-foreground/70");
expect(modelPill).not.toHaveClass("font-semibold"); 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..."); const input = screen.getByPlaceholderText("Type your message...");
expect(input.className).toContain("min-h-[50px]"); expect(input.className).toContain("min-h-[50px]");
expect(input.className).toContain("text-[16px]"); expect(input.className).toContain("text-[16px]");
@@ -571,141 +586,53 @@ describe("ThreadComposer", () => {
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument(); expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
}); });
it("shows model details in the shared tooltip without a native title", async () => { it("opens a model picker and switches presets with one click", 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();
const { badge, onPresetChange } = renderPresetComposer(); const { badge, onPresetChange } = renderPresetComposer();
expect(badge).toHaveClass("h-9"); expect(badge).toHaveClass("h-9");
expect(badge).toHaveStyle({ touchAction: "manipulation" }); expect(badge).toHaveClass("w-fit");
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");
fireEvent.click(badge); fireEvent.click(badge);
expect(onPresetChange).toHaveBeenCalledTimes(1); const picker = screen.getByRole("dialog", { name: "Switch model for this chat" });
expect(badge).toHaveAttribute("data-settling", "true"); expect(picker).toHaveClass("w-[min(18rem,calc(100vw-2rem))]");
expect(track).toHaveAttribute("data-settling", "true"); expect(badge).toHaveClass("w-fit");
act(() => { expect(badge.querySelector(".composer-model-pill")).not.toHaveClass("w-full");
vi.advanceTimersByTime(260); expect(within(picker).getAllByRole("option")).toHaveLength(3);
}); expect(within(picker).getByRole("option", { name: "dflash" })).toHaveTextContent(
expect(badge).not.toHaveAttribute("data-switching"); /dflash\s*deepseek-v4-flash/,
expect(badge).not.toHaveAttribute("data-settling"); );
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(); 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"); const { badge, onPresetChange } = renderPresetComposer("hero");
expect(badge).toHaveClass("h-8"); expect(badge).toHaveClass("h-8");
longPress(badge, 9); fireEvent.click(badge);
expect(badge).toHaveAttribute("data-switching", "true"); fireEvent.click(screen.getByRole("option", { name: "dflash" }));
fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" }); expect(onPresetChange).toHaveBeenCalledWith("dflash");
fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
expect(badge).not.toHaveAttribute("data-switching");
expect(onPresetChange).not.toHaveBeenCalled();
}); });
it("transcribes voice input into the composer without sending", async () => { it("transcribes voice input into the composer without sending", async () => {
+102 -43
View File
@@ -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( rerender(
<ThreadMessages <ThreadMessages
@@ -178,6 +178,66 @@ describe("ThreadMessages", () => {
expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph); 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 () => { it("offers a follow-up action for text selected within one completed answer", async () => {
const onQuoteSelection = vi.fn(); const onQuoteSelection = vi.fn();
render( render(
@@ -319,12 +379,12 @@ describe("ThreadMessages", () => {
expect(unitKeysForDisplay(liveUnits)).toEqual(unitKeysForDisplay(replayUnits)); expect(unitKeysForDisplay(liveUnits)).toEqual(unitKeysForDisplay(replayUnits));
expect(unitKeysForDisplay(liveUnits)).toEqual([ expect(unitKeysForDisplay(liveUnits)).toEqual([
"turn-turn-1-user", "turn-turn-1-user",
"turn-turn-1-activity-1",
"turn-turn-1-answer-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[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@@ -364,14 +424,16 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3); expect(units).toHaveLength(1);
expect(units.map((unit) => unit.type)).toEqual(["activity", "activity", "activity"]); expect(units[0].type).toBe("activity");
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]); "r1",
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["r2"]); "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[] = [ const messages: UIMessage[] = [
{ {
id: "r1", id: "r1",
@@ -449,10 +511,12 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3); expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]); "r1",
expect(units[2]).toMatchObject({ "t1",
]);
expect(units[1]).toMatchObject({
type: "message", type: "message",
message: { message: {
id: "a1", id: "a1",
@@ -504,8 +568,8 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming />); render(<ThreadMessages messages={messages} isStreaming />);
expect(screen.getByLabelText(/edited foo\.txt/i)).toBeInTheDocument(); expect(screen.getByLabelText(/editing foo\.txt/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/editing foo\.txt/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/edited foo\.txt/i)).not.toBeInTheDocument();
}); });
it("times live activity from the user turn start", () => { it("times live activity from the user turn start", () => {
@@ -731,22 +795,18 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages, true); const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(3); expect(units).toHaveLength(1);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
expect(units[1]).toMatchObject({ "t0",
type: "message", "a1-activity",
message: { "t1",
id: "a1", ]);
content: "partial answer",
},
});
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
render(<ThreadMessages messages={messages} isStreaming />); render(<ThreadMessages messages={messages} isStreaming />);
const answer = screen.getByText("partial answer"); const answer = screen.getByText("partial answer");
const liveActivity = screen.getByRole("button", { name: /working/i }); 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", () => { it("moves late activity before a completed assistant answer", () => {
@@ -779,10 +839,9 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages); const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3); expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]); expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1", "t1"]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]); expect(units[1]).toMatchObject({
expect(units[2]).toMatchObject({
type: "message", type: "message",
message: { message: {
id: "a1", id: "a1",
@@ -793,7 +852,7 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />); render(<ThreadMessages messages={messages} isStreaming={false} />);
const answer = screen.getByText("Hong Kong is hot today."); 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).toBeTruthy();
expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}); });
@@ -834,7 +893,7 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />); 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 今天开打了。"); const answer = screen.getByText("知道,IEM Cologne Major 2026 今天开打了。");
expect(thought).toBeTruthy(); expect(thought).toBeTruthy();
expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
@@ -876,18 +935,16 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages, true); 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([ expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"thought", "thought",
]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([
"web", "web",
]); ]);
expect(units[2]).toMatchObject({ expect(units[1]).toMatchObject({
type: "message", type: "message",
message: { id: "answer" }, message: { id: "answer" },
}); });
expect(units[3]).toMatchObject({ expect(units[2]).toMatchObject({
type: "message", type: "message",
message: { id: "next-user" }, message: { id: "next-user" },
}); });
@@ -1018,7 +1075,7 @@ describe("ThreadMessages", () => {
expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument(); 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[] = [ const messages: UIMessage[] = [
{ {
id: "early", 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.getAllByRole("button", { name: "Fork" })).toHaveLength(1);
expect(screen.getByText("starting…")).toBeInTheDocument();
expect(screen.getByText("final reply")).toBeInTheDocument(); expect(screen.getByText("final reply")).toBeInTheDocument();
}); });
@@ -1095,7 +1153,7 @@ describe("ThreadMessages", () => {
rerender(<ThreadMessages {...props} isStreaming={false} activeTurnId={null} />); 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); expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(2);
}); });
@@ -1192,13 +1250,15 @@ describe("ThreadMessages", () => {
.toHaveLength(1); .toHaveLength(1);
}); });
it("shows copy on adjacent assistant text slices", () => { it("projects adjacent assistant text slices into one answer", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ id: "a1", role: "assistant", content: "part one", createdAt: 1 }, { id: "a1", role: "assistant", content: "part one", createdAt: 1 },
{ id: "a2", role: "assistant", content: "part two", createdAt: 2 }, { id: "a2", role: "assistant", content: "part two", createdAt: 2 },
]; ];
render(<ThreadMessages messages={messages} isStreaming={false} />); 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", () => { it("does not count failed optimistic messages in assistant fork indices", () => {
@@ -1280,7 +1340,6 @@ describe("ThreadMessages", () => {
.filter(Boolean); .filter(Boolean);
expect(assistantFlags).toEqual([ expect(assistantFlags).toEqual([
["a1", false],
["a2", true], ["a2", true],
["a3", true], ["a3", true],
]); ]);
+49 -36
View File
@@ -609,12 +609,33 @@ describe("ThreadShell", () => {
), ),
); );
const badge = await screen.findByLabelText("fast"); expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
expect(badge).not.toHaveAttribute("title"); expect(screen.queryByTitle("Default · deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument();
fireEvent.focus(badge); });
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"fast · gpt-5.5 · OpenAI Codex", 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 () => { it("switches through every named preset while preserving call-order priority", async () => {
@@ -641,19 +662,18 @@ describe("ThreadShell", () => {
)); ));
const { rerender } = render(view("default")); 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"); 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( expect(client.sendSystemCommand).toHaveBeenCalledWith(
"preset-order", "preset-order",
"/model fast", "/model fast",
); );
expect(await screen.findByText("fast")).toBeInTheDocument(); expect(await screen.findByText("fast")).toBeInTheDocument();
fireEvent.keyDown( fireEvent.click(screen.getByRole("button", { name: "fast" }));
screen.getByRole("spinbutton", { name: "fast" }), fireEvent.click(await screen.findByRole("option", { name: /^extra\b/i }));
{ key: "End" },
);
expect(client.sendSystemCommand).toHaveBeenLastCalledWith( expect(client.sendSystemCommand).toHaveBeenLastCalledWith(
"preset-order", "preset-order",
"/model extra", "/model extra",
@@ -696,15 +716,11 @@ describe("ThreadShell", () => {
), ),
); );
const badge = await screen.findByLabelText("fast"); expect(await screen.findByTitle("fast · gpt-4 · Company Proxy")).toBeInTheDocument();
fireEvent.focus(badge);
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"fast · gpt-4 · Company Proxy",
);
expect(screen.queryByRole("button", { name: "Model not configured" })).not.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(); const client = makeClient();
render(wrap( render(wrap(
client, client,
@@ -718,7 +734,8 @@ describe("ThreadShell", () => {
)); ));
expect(await screen.findByText("Default")).toBeInTheDocument(); 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).not.toBeNull();
expect(configuredBadge).toHaveClass("composer-model-badge"); expect(configuredBadge).toHaveClass("composer-model-badge");
expect(configuredBadge).not.toHaveAttribute("data-fallback"); expect(configuredBadge).not.toHaveAttribute("data-fallback");
@@ -728,31 +745,34 @@ describe("ThreadShell", () => {
event: "turn_model_updated", event: "turn_model_updated",
chat_id: "fallback-model", chat_id: "fallback-model",
model_name: "openai-codex/gpt-5.5", model_name: "openai-codex/gpt-5.5",
model_preset: "Default",
}); });
}); });
expect(configuredBadge).not.toHaveAttribute("data-fallback"); expect(configuredBadge).not.toHaveAttribute("data-fallback");
expect(screen.getByText("Default")).toBeInTheDocument();
act(() => { act(() => {
client._emitChat("fallback-model", { client._emitChat("fallback-model", {
event: "turn_model_updated", event: "turn_model_updated",
chat_id: "fallback-model", chat_id: "fallback-model",
model_name: "deepseek/deepseek-chat", 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; const badge = logo.parentElement;
expect(badge).not.toBeNull(); expect(badge).not.toBeNull();
expect(badge).toBe(configuredBadge); expect(badge).toBe(configuredBadge);
expect(screen.getByText("Default")).toBeInTheDocument(); expect(screen.queryByText("Default")).not.toBeInTheDocument();
expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument(); expect(screen.getByText("deepseek-chat")).toBeInTheDocument();
expect(badge).toHaveAttribute("data-fallback", "true"); expect(badge).toHaveAttribute("data-fallback", "true");
expect(badge).not.toHaveAttribute("title"); expect(badge).toHaveAttribute(
expect(logo).not.toHaveAttribute("data-fallback"); "title",
const trigger = screen.getByLabelText("Default"); "Default · using deepseek/deepseek-chat",
fireEvent.focus(trigger); );
expect(await screen.findByRole("tooltip")).toHaveTextContent("deepseek/deepseek-chat"); expect(logo).toBeInTheDocument();
act(() => { act(() => {
client._emitChat("fallback-model", { client._emitChat("fallback-model", {
@@ -766,12 +786,7 @@ describe("ThreadShell", () => {
screen.getByTestId("composer-model-logo-openai_codex").parentElement, screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).not.toHaveAttribute("data-fallback"); ).not.toHaveAttribute("data-fallback");
}); });
expect(screen.getByRole("tooltip")).toHaveTextContent( expect(screen.getByText("Default")).toBeInTheDocument();
"Default · gpt-5.5 · OpenAI Codex",
);
expect(
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).toBe(badge);
}); });
it("opens model settings from the unconfigured model badge", async () => { it("opens model settings from the unconfigured model badge", async () => {
@@ -1084,10 +1099,8 @@ describe("ThreadShell", () => {
)); ));
const { rerender } = render(view(null)); const { rerender } = render(view(null));
fireEvent.keyDown( fireEvent.click(await screen.findByRole("button", { name: "Default" }));
await screen.findByRole("spinbutton", { name: "Default" }), fireEvent.click(await screen.findByRole("option", { name: /^fast\b/i }));
{ key: "ArrowDown" },
);
expect(await screen.findByText("fast")).toBeInTheDocument(); expect(await screen.findByText("fast")).toBeInTheDocument();
expect(client.sendSystemCommand).not.toHaveBeenCalled(); expect(client.sendSystemCommand).not.toHaveBeenCalled();
+1 -1
View File
@@ -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 }); fireEvent.pointerDown(disclosure, { button: 0 });
expect(takeUserControl).toHaveBeenCalledTimes(1); expect(takeUserControl).toHaveBeenCalledTimes(1);
+60 -2
View File
@@ -350,6 +350,51 @@ describe("useNanobotStream", () => {
expect(result.current.isStreaming).toBe(false); 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", () => { it("preserves proactive automation source metadata on complete assistant messages", () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-cron", EMPTY_MESSAGES), { 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(); vi.useFakeTimers();
try { try {
const fake = fakeClient(); const fake = fakeClient();
@@ -2530,6 +2575,19 @@ describe("useNanobotStream", () => {
vi.advanceTimersByTime(1000); 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.isStreaming).toBe(false);
expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({ expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({
isStreaming: false, isStreaming: false,
@@ -2589,7 +2647,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(3); expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[1]).toMatchObject({ expect(result.current.messages[1]).toMatchObject({
content: "Initial findings", content: "Initial findings",
isStreaming: false, isStreaming: true,
}); });
act(() => { act(() => {