mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
feat(webui): unify turn observability
This commit is contained in:
+13
-6
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+79
-13
@@ -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,20 +2256,17 @@ 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": final_text,
|
"content": "",
|
||||||
"isStreaming": True,
|
"isStreaming": True,
|
||||||
**turn_fields,
|
|
||||||
**source_fields,
|
|
||||||
"createdAt": _created_at_ms(rec, idx),
|
"createdAt": _created_at_ms(rec, idx),
|
||||||
},
|
})
|
||||||
)
|
|
||||||
else:
|
|
||||||
for i, m in enumerate(messages):
|
for i, m in enumerate(messages):
|
||||||
if m.get("id") == buffer_message_id:
|
if m.get("id") == buffer_message_id:
|
||||||
messages[i] = {
|
messages[i] = {
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -384,6 +384,8 @@ async def test_runner_calls_run_level_hooks_on_success():
|
|||||||
"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"],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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,7 +951,6 @@ 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) => {
|
||||||
@@ -1030,7 +1021,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</SidebarItemTooltip>
|
|
||||||
<SessionActivityIndicator state={activityState} />
|
<SessionActivityIndicator state={activityState} />
|
||||||
{!deleteSelectionMode ? (
|
{!deleteSelectionMode ? (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
@@ -1434,9 +1424,6 @@ function ActivePaneRows({
|
|||||||
deleteSelectionMode && selected
|
deleteSelectionMode && selected
|
||||||
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
&& "bg-sidebar-accent/55 text-sidebar-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<SidebarItemTooltip
|
|
||||||
label={pane.handle ? `@${pane.handle.name} · ${pane.title}` : pane.title}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1475,7 +1462,6 @@ function ActivePaneRows({
|
|||||||
<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,7 +1638,6 @@ 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)}
|
||||||
@@ -1667,7 +1652,6 @@ function TemporaryChatSection({
|
|||||||
{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
|
||||||
|
|||||||
@@ -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,8 +205,7 @@ 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}}",
|
||||||
@@ -208,17 +215,6 @@ export function AgentActivityCluster({
|
|||||||
: 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,
|
||||||
|
|||||||
@@ -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,31 +260,54 @@ 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 pill = (
|
||||||
|
<PresetPill
|
||||||
|
label={displayLabel}
|
||||||
|
modelDetail={displayModelDetail}
|
||||||
|
provider={displayProvider}
|
||||||
|
providerLabel={fallbackModelName ? null : providerLabel}
|
||||||
|
needsSetup={needsSetup}
|
||||||
|
fallbackModelName={fallbackModelName}
|
||||||
|
fallbackFromLabel={fallbackModelName ? label : null}
|
||||||
|
isHero={isHero}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewIndex = wrapIndex(motion?.index ?? currentIndex, presets.length);
|
return (
|
||||||
const previewPreset = presets[previewIndex];
|
<Popover
|
||||||
const Container = interactive || canSwitch ? "button" : "span";
|
open={open}
|
||||||
const trackOffset = motion ? -pillStride * (2 + motion.remainder) : 0;
|
onOpenChange={(nextOpen) => {
|
||||||
const tooltipLabel = fallbackModelName
|
setOpen(nextOpen);
|
||||||
|| [...new Set([label, modelDetail, providerLabel].filter(Boolean))].join(" · ");
|
if (!nextOpen) requestAnimationFrame(() => onRequestComposerFocus?.());
|
||||||
|
}}
|
||||||
const badge = (
|
>
|
||||||
<Container
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
data-switching={motion ? "true" : undefined}
|
data-switching={motion ? "true" : undefined}
|
||||||
data-settling={motion?.settling ? "true" : undefined}
|
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-orientation={canSwitch ? "vertical" : undefined}
|
aria-expanded={open}
|
||||||
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}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerLeave={(event) => {
|
onPointerLeave={(event) => {
|
||||||
@@ -300,29 +321,33 @@ export function ModelPresetBadge({
|
|||||||
if (gestureRef.current?.active) event.preventDefault();
|
if (gestureRef.current?.active) event.preventDefault();
|
||||||
}}
|
}}
|
||||||
onDragStart={(event) => event.preventDefault()}
|
onDragStart={(event) => event.preventDefault()}
|
||||||
style={{ touchAction: canSwitch ? "manipulation" : undefined }}
|
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(
|
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",
|
"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",
|
||||||
interactive && "cursor-pointer",
|
|
||||||
canSwitch && "cursor-grab select-none focus-visible:outline-none",
|
|
||||||
motion && "z-10 cursor-grabbing",
|
motion && "z-10 cursor-grabbing",
|
||||||
|
!motion && "cursor-grab",
|
||||||
isHero ? "h-8" : "h-9",
|
isHero ? "h-8" : "h-9",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PresetPill
|
|
||||||
className={motion && "invisible"}
|
|
||||||
label={label}
|
|
||||||
modelDetail={modelDetail}
|
|
||||||
provider={provider}
|
|
||||||
needsSetup={needsSetup}
|
|
||||||
fallbackModelName={fallbackModelName}
|
|
||||||
isHero={isHero}
|
|
||||||
/>
|
|
||||||
{motion ? (
|
{motion ? (
|
||||||
|
<>
|
||||||
|
<span data-testid="composer-model-pill-layout" className="invisible inline-flex h-full shrink-0" aria-hidden>
|
||||||
|
{pill}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
data-testid="composer-model-pill-viewport"
|
data-testid="composer-model-pill-viewport"
|
||||||
className={cn(
|
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]",
|
"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",
|
isHero ? "-bottom-2.5 -top-2.5" : "-bottom-3 -top-3",
|
||||||
)}
|
)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -330,73 +355,134 @@ export function ModelPresetBadge({
|
|||||||
<span
|
<span
|
||||||
data-testid="composer-model-pill-track"
|
data-testid="composer-model-pill-track"
|
||||||
data-settling={motion.settling ? "true" : undefined}
|
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"
|
className="composer-model-pill-track ml-auto flex w-[calc(100%-0.5rem)] flex-col items-end gap-1 will-change-transform"
|
||||||
onTransitionEnd={(event) => {
|
onTransitionEnd={(event) => {
|
||||||
if (motion.settling && event.currentTarget === event.target) setMotion(null);
|
if (motion.settling && event.currentTarget === event.target) clearMotion();
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
paddingTop: isHero ? "10px" : "12px",
|
paddingTop: isHero ? "10px" : "12px",
|
||||||
transform: `translate3d(0, ${trackOffset}px, 0)`,
|
transform: `translate3d(0, ${-pillStride * (2 + motion.remainder)}px, 0)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PILL_OFFSETS.map((offset) => {
|
{PILL_OFFSETS.map((offset) => {
|
||||||
const virtualIndex = motion.index + offset;
|
const preset = presets[wrapIndex(motion.index + offset, presets.length)];
|
||||||
const preset = presets[wrapIndex(virtualIndex, presets.length)];
|
|
||||||
const scale = motion.settling ? 1 : dockScale(offset - motion.remainder);
|
|
||||||
return (
|
return (
|
||||||
<PresetPill
|
<PresetPill
|
||||||
key={virtualIndex}
|
key={motion.index + offset}
|
||||||
label={preset.name}
|
label={preset.name}
|
||||||
modelDetail={preset.model}
|
modelDetail={preset.model}
|
||||||
provider={preset.provider}
|
provider={preset.provider}
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
offset={offset}
|
offset={offset}
|
||||||
scale={scale}
|
scale={motion.settling ? 1 : dockScale(offset - motion.remainder)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
</>
|
||||||
</Container>
|
) : pill}
|
||||||
);
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
if (!tooltipLabel) return badge;
|
<PopoverContent
|
||||||
return (
|
align="end"
|
||||||
<TooltipProvider delayDuration={500} skipDelayDuration={100}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>{badge}</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="top"
|
side="top"
|
||||||
align="center"
|
sideOffset={10}
|
||||||
sideOffset={8}
|
role="dialog"
|
||||||
collisionPadding={12}
|
aria-label={switchModelLabel}
|
||||||
className="max-w-[min(24rem,calc(100vw-2rem))] break-all"
|
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",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{tooltipLabel}
|
<div
|
||||||
</TooltipContent>
|
role="listbox"
|
||||||
</Tooltip>
|
aria-label={switchModelLabel}
|
||||||
</TooltipProvider>
|
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,17 +526,55 @@ function PresetPill({
|
|||||||
zIndex: Math.round(scale * 100),
|
zIndex: Math.round(scale * 100),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<PresetProviderIcon
|
||||||
|
label={label}
|
||||||
|
modelDetail={modelDetail}
|
||||||
|
provider={inferredProvider}
|
||||||
|
needsSetup={needsSetup}
|
||||||
|
testId={needsSetup ? "composer-model-setup-icon" : `composer-model-logo${inferredProvider ? `-${inferredProvider}` : ""}`}
|
||||||
|
isHero={isHero}
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
data-testid={logoTestId}
|
ref={labelRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid shrink-0 place-items-center overflow-hidden",
|
"thread-composer-model-label min-w-0 overflow-hidden whitespace-nowrap text-center",
|
||||||
needsSetup ? "text-amber-800 dark:text-amber-200" : "rounded-full border bg-background",
|
labelOverflows && "thread-composer-model-label-fade",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</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]",
|
isHero ? "h-4 w-4" : "h-[18px] w-[18px]",
|
||||||
)}
|
)}
|
||||||
style={{
|
|
||||||
borderColor: !needsSetup && brand ? `${brand.color}28` : undefined,
|
|
||||||
boxShadow: !needsSetup && brand ? `inset 0 0 0 1px ${brand.color}18` : undefined,
|
|
||||||
}}
|
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{needsSetup ? (
|
{needsSetup ? (
|
||||||
@@ -465,7 +586,7 @@ function PresetPill({
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")}
|
className={cn("object-contain", isHero ? "h-3.5 w-3.5" : "h-[18px] w-[18px]")}
|
||||||
onLoad={onLogoLoad}
|
onLoad={onLogoLoad}
|
||||||
onError={onLogoError}
|
onError={onLogoError}
|
||||||
/>
|
/>
|
||||||
@@ -483,15 +604,5 @@ function PresetPill({
|
|||||||
<Sparkles className="h-3 w-3 text-muted-foreground/65" />
|
<Sparkles className="h-3 w-3 text-muted-foreground/65" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
|
||||||
ref={labelRef}
|
|
||||||
className={cn(
|
|
||||||
"thread-composer-model-label min-w-0 overflow-hidden whitespace-nowrap text-center",
|
|
||||||
labelOverflows && "thread-composer-model-label-fade",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</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],
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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 l’utilisation"
|
||||||
|
},
|
||||||
"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}}",
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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}}考えました",
|
||||||
|
|||||||
@@ -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}} 동안 생각함",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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": "图片预览",
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
+200
-185
@@ -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 flushActivityMessages = () => {
|
|
||||||
if (!activityMessages.length) return;
|
|
||||||
pushActivityUnits(
|
|
||||||
turnUnits,
|
|
||||||
activityMessages,
|
|
||||||
visibleMessages.slice(visibleIndex),
|
|
||||||
turnStartedAtMs,
|
|
||||||
);
|
);
|
||||||
activityMessages = [];
|
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);
|
||||||
|
|
||||||
for (const message of orderedTurnMessages) {
|
const activity: UIMessage[] = [];
|
||||||
if (isAgentActivityMember(message)) {
|
const answers: UIMessage[] = [];
|
||||||
activityMessages.push(message);
|
ordered.forEach((message, index) => {
|
||||||
continue;
|
if (isRawActivity(message)) {
|
||||||
|
activity.push(message);
|
||||||
|
} else if (isAssistantAnswer(message)) {
|
||||||
|
if (message.reasoning?.trim() || message.reasoningStreaming) {
|
||||||
|
activity.push(reasoningOnlyMessageFromAnswer(message));
|
||||||
|
}
|
||||||
|
if (hasFinalAnswer && index === finalAnswerIndex) {
|
||||||
|
answers.push(stripInlineReasoning(message));
|
||||||
|
} else {
|
||||||
|
activity.push(modelActivitySnippet(message));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activity.push(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activity.length) {
|
||||||
|
units.push({
|
||||||
|
type: "activity",
|
||||||
|
messages: activity,
|
||||||
|
turnLatencyMs: activityTurnLatencyMs(activity, ordered),
|
||||||
|
startedAtMs: activeTurnStartedAtMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (answers.length) {
|
||||||
|
units.push({ type: "message", message: mergeAssistantAnswers(answers) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (assistantHasInlineReasoning(message)) {
|
|
||||||
activityMessages.push(reasoningOnlyMessageFromAnswer(message));
|
|
||||||
flushActivityMessages();
|
|
||||||
turnUnits.push({ type: "message", message: stripInlineReasoning(message) });
|
|
||||||
visibleIndex += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
flushActivityMessages();
|
|
||||||
turnUnits.push({ type: "message", message });
|
|
||||||
visibleIndex += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user