From 916525f94ab1979574b8ca87c07acbaeeac23726 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:54:19 +0800 Subject: [PATCH] refactor(webui): shrink fork implementation --- THIRD_PARTY_NOTICES.md | 31 --- nanobot/channels/websocket.py | 11 +- nanobot/webui/forking.py | 25 +- nanobot/webui/transcript.py | 128 ++++------- tests/agent/test_session_manager_history.py | 28 --- tests/channels/test_websocket_channel.py | 134 +---------- tests/utils/test_webui_transcript.py | 45 ---- webui/src/components/MessageBubble.tsx | 198 ++++------------ .../src/components/thread/ThreadComposer.tsx | 6 - .../src/components/thread/ThreadMessages.tsx | 53 +---- webui/src/components/thread/ThreadShell.tsx | 32 +-- .../src/components/thread/ThreadViewport.tsx | 8 +- webui/src/i18n/locales/en/common.json | 7 +- webui/src/i18n/locales/es/common.json | 7 +- webui/src/i18n/locales/fr/common.json | 7 +- webui/src/i18n/locales/id/common.json | 7 +- webui/src/i18n/locales/ja/common.json | 7 +- webui/src/i18n/locales/ko/common.json | 7 +- webui/src/i18n/locales/vi/common.json | 7 +- webui/src/i18n/locales/zh-CN/common.json | 7 +- webui/src/i18n/locales/zh-TW/common.json | 7 +- webui/src/tests/message-bubble.test.tsx | 16 -- webui/src/tests/thread-shell.test.tsx | 217 ------------------ webui/src/tests/useSessions.test.tsx | 18 -- 24 files changed, 134 insertions(+), 879 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3c1e97b7b..9085bfc8e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,37 +5,6 @@ nanobot Python distribution (`pip install nanobot-ai`). --- -## Tabler Icons — WebUI fork action icon (MIT) - -- **Source**: https://github.com/tabler/tabler-icons -- **Bundled**: inline SVG path for `arrow-fork` in `nanobot/web/dist/assets/index-*.js` - -``` -The MIT License (MIT) - -Copyright (c) 2020-2026 Paweł Kuna - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - ---- - ## KaTeX — math rendering (MIT) - **Source**: https://github.com/KaTeX/KaTeX diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 9ed3a0e76..74c8077f4 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -696,22 +696,23 @@ class WebSocketChannel(BaseChannel): if forked is None: await self._send_event(connection, "error", detail="invalid fork source or index") return + fork_id, fork_key = forked except Exception as exc: self.logger.warning("fork_chat failed: {}", exc) await self._send_event(connection, "error", detail="fork_chat_failed") return - scope = self._workspaces.scope_for_session_key(forked.session_key) - self._attach(connection, forked.chat_id) - await self._send_event(connection, "attached", chat_id=forked.chat_id) + scope = self._workspaces.scope_for_session_key(fork_key) + self._attach(connection, fork_id) + await self._send_event(connection, "attached", chat_id=fork_id) await self._send_event( connection, "session_updated", - chat_id=forked.chat_id, + chat_id=fork_id, scope="metadata", workspace_scope=scope.payload(), ) - await self._hydrate_after_subscribe(forked.chat_id) + await self._hydrate_after_subscribe(fork_id) return if t == "attach": cid = envelope.get("chat_id") diff --git a/nanobot/webui/forking.py b/nanobot/webui/forking.py index 69669ab92..c867ffc66 100644 --- a/nanobot/webui/forking.py +++ b/nanobot/webui/forking.py @@ -1,14 +1,8 @@ -"""Helpers for WebUI chat forking. - -The WebSocket channel owns transport concerns only. This module owns the -WebUI-specific session/transcript work needed to make a fork look like a normal -chat in both browser WebUI and desktop. -""" +"""WebUI chat fork orchestration.""" from __future__ import annotations import uuid -from dataclasses import dataclass from nanobot.session.manager import SessionManager from nanobot.session.webui_turns import WEBUI_TITLE_METADATA_KEY, clean_generated_title @@ -20,25 +14,14 @@ from nanobot.webui.transcript import ( ) -@dataclass(frozen=True) -class WebuiForkResult: - chat_id: str - session_key: str - - def create_webui_chat_fork( session_manager: SessionManager, *, source_chat_id: str, before_user_index: int, title: str | None = None, -) -> WebuiForkResult | None: - """Create a WebUI chat fork from a completed assistant-turn boundary. - - Returns ``None`` when the source/index is invalid. Exceptions are reserved - for unexpected I/O or persistence failures and are rolled back before being - re-raised. - """ +) -> tuple[str, str] | None: + """Return ``(chat_id, session_key)`` for a new fork, or ``None`` for bad input.""" new_id = str(uuid.uuid4()) source_key = f"websocket:{source_chat_id}" target_key = f"websocket:{new_id}" @@ -68,4 +51,4 @@ def create_webui_chat_fork( delete_webui_transcript(target_key) session_manager.delete_session(target_key) raise - return WebuiForkResult(chat_id=new_id, session_key=target_key) + return new_id, target_key diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index a5f5175d7..40f865046 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -286,6 +286,25 @@ def _is_user_transcript_row(row: dict[str, Any]) -> bool: return row.get("event") == "user" or row.get("role") == "user" +def _write_transcript_lines(session_key: str, rows: list[dict[str, Any]]) -> None: + path = webui_transcript_path(session_key) + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(".jsonl.tmp") + try: + with open(tmp_path, "w", encoding="utf-8") as f: + for row in rows: + raw = json.dumps(row, ensure_ascii=False, separators=(",", ":")) + if len(raw.encode("utf-8")) > _MAX_TRANSCRIPT_FILE_BYTES: + raise ValueError("webui transcript line too large") + f.write(raw + "\n") + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, path) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + + def fork_transcript_before_user_index( source_key: str, target_key: str, @@ -324,22 +343,7 @@ def fork_transcript_before_user_index( if not found_target: return False - path = webui_transcript_path(target_key) - path.parent.mkdir(parents=True, exist_ok=True) - tmp_path = path.with_suffix(".jsonl.tmp") - try: - with open(tmp_path, "w", encoding="utf-8") as f: - for row in copied: - raw = json.dumps(row, ensure_ascii=False, separators=(",", ":")) - if len(raw.encode("utf-8")) > _MAX_TRANSCRIPT_FILE_BYTES: - raise ValueError("webui transcript line too large") - f.write(raw + "\n") - f.flush() - os.fsync(f.fileno()) - os.replace(tmp_path, path) - except BaseException: - tmp_path.unlink(missing_ok=True) - raise + _write_transcript_lines(target_key, copied) return True @@ -360,51 +364,29 @@ def write_session_messages_as_transcript( ) -> None: """Write a minimal WebUI transcript from already-truncated session messages.""" target_chat_id = _chat_id_from_session_key(target_key) - path = webui_transcript_path(target_key) - path.parent.mkdir(parents=True, exist_ok=True) - tmp_path = path.with_suffix(".jsonl.tmp") - try: - with open(tmp_path, "w", encoding="utf-8") as f: - for msg in messages: - role = msg.get("role") - content = msg.get("content") - text = content if isinstance(content, str) else "" - if role == "user": - row: dict[str, Any] = { - "event": "user", - "chat_id": target_chat_id, - "text": text, - } - media = msg.get("media") - if isinstance(media, list) and media: - row["media_paths"] = [str(p) for p in media if isinstance(p, str) and p] - for key in ("cli_apps", "mcp_presets"): - value = msg.get(key) - if isinstance(value, list) and value: - row[key] = json.loads(json.dumps(value, ensure_ascii=False)) - elif role == "assistant": - if not text.strip(): - continue - row = { - "event": "message", - "chat_id": target_chat_id, - "text": text, - } - media = msg.get("media") - if isinstance(media, list) and media: - row["media"] = [str(p) for p in media if isinstance(p, str) and p] - else: - continue - raw = json.dumps(row, ensure_ascii=False, separators=(",", ":")) - if len(raw.encode("utf-8")) > _MAX_TRANSCRIPT_FILE_BYTES: - raise ValueError("webui transcript line too large") - f.write(raw + "\n") - f.flush() - os.fsync(f.fileno()) - os.replace(tmp_path, path) - except BaseException: - tmp_path.unlink(missing_ok=True) - raise + rows: list[dict[str, Any]] = [] + for msg in messages: + role = msg.get("role") + content = msg.get("content") + text = content if isinstance(content, str) else "" + if role == "user": + row: dict[str, Any] = {"event": "user", "chat_id": target_chat_id, "text": text} + media = msg.get("media") + if isinstance(media, list) and media: + row["media_paths"] = [str(p) for p in media if isinstance(p, str) and p] + for key in ("cli_apps", "mcp_presets"): + value = msg.get(key) + if isinstance(value, list) and value: + row[key] = json.loads(json.dumps(value, ensure_ascii=False)) + elif role == "assistant" and text.strip(): + row = {"event": "message", "chat_id": target_chat_id, "text": text} + media = msg.get("media") + if isinstance(media, list) and media: + row["media"] = [str(p) for p in media if isinstance(p, str) and p] + else: + continue + rows.append(row) + _write_transcript_lines(target_key, rows) def delete_webui_transcript(session_key: str) -> bool: @@ -1411,25 +1393,12 @@ def replay_transcript_to_ui_messages( return messages -def fork_boundary_message_count( - lines: list[dict[str, Any]], - *, - augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None, - augment_assistant_media: Callable[[list[str]], list[dict[str, Any]]] | None = None, - augment_assistant_text: Callable[[str], str] | None = None, -) -> int | None: +def fork_boundary_message_count(lines: list[dict[str, Any]]) -> int | None: """Return the replayed UI message count before the first fork marker, if any.""" for idx, rec in enumerate(lines): if rec.get("event") != WEBUI_FORK_MARKER_EVENT: continue - return len( - replay_transcript_to_ui_messages( - lines[:idx], - augment_user_media=augment_user_media, - augment_assistant_media=augment_assistant_media, - augment_assistant_text=augment_assistant_text, - ), - ) + return len(replay_transcript_to_ui_messages(lines[:idx])) return None @@ -1446,12 +1415,7 @@ def build_webui_thread_response( if not lines: return None lines = inject_missing_user_events_from_session(session_key, lines, session_messages) - fork_boundary = fork_boundary_message_count( - lines, - augment_user_media=augment_user_media, - augment_assistant_media=augment_assistant_media, - augment_assistant_text=augment_assistant_text, - ) + fork_boundary = fork_boundary_message_count(lines) msgs = replay_transcript_to_ui_messages( lines, augment_user_media=augment_user_media, diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index 6f123de32..3441c4833 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -454,34 +454,6 @@ def test_fork_session_before_user_index_copies_only_prefix(tmp_path): assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"] -def test_fork_session_from_middle_assistant_reply_keeps_selected_turn(tmp_path): - manager = SessionManager(tmp_path) - source = manager.get_or_create("websocket:source") - source.add_message("user", "round1") - source.add_message("assistant", "answer1") - source.add_message("user", "round2") - source.add_message("assistant", "answer2") - source.add_message("user", "round3 must not appear") - source.add_message("assistant", "answer3 must not appear") - manager.save(source) - - forked = manager.fork_session_before_user_index( - "websocket:source", - "websocket:fork", - 2, - ) - - assert forked is not None - assert [m["content"] for m in forked.messages] == [ - "round1", - "answer1", - "round2", - "answer2", - ] - saved = manager.read_session_file("websocket:fork") - assert "round3 must not appear" not in str(saved) - - def test_fork_session_rejects_negative_missing_and_out_of_range(tmp_path): manager = SessionManager(tmp_path) source = manager.get_or_create("websocket:source") diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 901d58664..a0dd8ddf4 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -2398,17 +2398,12 @@ async def test_fork_chat_copies_only_prefix_session_and_transcript( source.metadata["webui"] = True source.add_message("user", "round1") source.add_message("assistant", "answer1") - source.add_message("user", "round2 fork me") - source.add_message("assistant", "answer2") - source.add_message("user", "round3 must not appear") + source.add_message("user", "future") sessions.save(source) for ev in ( {"event": "user", "chat_id": "source", "text": "round1"}, {"event": "message", "chat_id": "source", "text": "answer1"}, - {"event": "turn_end", "chat_id": "source"}, - {"event": "user", "chat_id": "source", "text": "round2 fork me"}, - {"event": "message", "chat_id": "source", "text": "answer2"}, - {"event": "user", "chat_id": "source", "text": "round3 must not appear"}, + {"event": "user", "chat_id": "source", "text": "future"}, ): append_transcript_object("websocket:source", ev) @@ -2437,133 +2432,12 @@ async def test_fork_chat_copies_only_prefix_session_and_transcript( assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"] assert saved["metadata"]["title"] == "Fork: Old title" fork_lines = read_transcript_lines(f"websocket:{fork_id}") - assert [line.get("text") for line in fork_lines] == ["round1", "answer1", None, None] + assert [line.get("text") for line in fork_lines] == ["round1", "answer1", None] assert fork_lines[-1]["event"] == "fork_marker" assert all(line.get("chat_id") == fork_id for line in fork_lines) - assert "round3 must not appear" not in json.dumps(saved, ensure_ascii=False) + assert "future" not in json.dumps(saved, ensure_ascii=False) bus.publish_inbound.assert_not_awaited() - -@pytest.mark.asyncio -async def test_fork_chat_falls_back_to_session_prefix_when_transcript_lacks_user_rows( - bus: MagicMock, - tmp_path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) - sessions = SessionManager(tmp_path / "sessions") - source = sessions.get_or_create("websocket:source") - source.metadata["webui"] = True - source.add_message("user", "round1") - source.add_message("assistant", "answer1") - source.add_message("user", "round2 fork me") - source.add_message("assistant", "answer2") - source.add_message("user", "round3 must not appear") - sessions.save(source) - append_transcript_object( - "websocket:source", - {"event": "message", "chat_id": "source", "text": "answer1"}, - ) - - channel = WebSocketChannel( - {"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"}, - bus, - gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path), - ) - conn = AsyncMock() - - await channel._dispatch_envelope( - conn, - "webui-client", - {"type": "fork_chat", "source_chat_id": "source", "before_user_index": 1}, - ) - - sent = [json.loads(call.args[0]) for call in conn.send.await_args_list] - attached = next(item for item in sent if item["event"] == "attached") - fork_id = attached["chat_id"] - saved = sessions.read_session_file(f"websocket:{fork_id}") - assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"] - fork_lines = read_transcript_lines(f"websocket:{fork_id}") - assert [line.get("text") for line in fork_lines] == ["round1", "answer1", None] - assert fork_lines[-1]["event"] == "fork_marker" - assert "round3 must not appear" not in json.dumps(fork_lines, ensure_ascii=False) - bus.publish_inbound.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_fork_chat_allows_index_equal_to_user_count( - bus: MagicMock, - tmp_path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) - sessions = SessionManager(tmp_path / "sessions") - source = sessions.get_or_create("websocket:source") - source.metadata["webui"] = True - source.add_message("user", "round1") - source.add_message("assistant", "answer1") - sessions.save(source) - append_transcript_object("websocket:source", {"event": "user", "chat_id": "source", "text": "round1"}) - append_transcript_object( - "websocket:source", - {"event": "message", "chat_id": "source", "text": "answer1"}, - ) - - channel = WebSocketChannel( - {"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"}, - bus, - gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path), - ) - conn = AsyncMock() - - await channel._dispatch_envelope( - conn, - "webui-client", - {"type": "fork_chat", "source_chat_id": "source", "before_user_index": 1}, - ) - - sent = [json.loads(call.args[0]) for call in conn.send.await_args_list] - attached = next(item for item in sent if item["event"] == "attached") - fork_id = attached["chat_id"] - saved = sessions.read_session_file(f"websocket:{fork_id}") - assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"] - fork_lines = read_transcript_lines(f"websocket:{fork_id}") - assert [line.get("text") for line in fork_lines] == ["round1", "answer1", None] - assert fork_lines[-1]["event"] == "fork_marker" - bus.publish_inbound.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_fork_chat_rejects_invalid_source_and_index(bus: MagicMock, tmp_path) -> None: - sessions = SessionManager(tmp_path / "sessions") - channel = WebSocketChannel( - {"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"}, - bus, - gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path), - ) - conn = AsyncMock() - - await channel._dispatch_envelope( - conn, - "webui-client", - {"type": "fork_chat", "source_chat_id": "bad/source", "before_user_index": 0}, - ) - payload = json.loads(conn.send.await_args.args[0]) - assert payload["event"] == "error" - assert payload["detail"] == "invalid source_chat_id" - - conn.reset_mock() - await channel._dispatch_envelope( - conn, - "webui-client", - {"type": "fork_chat", "source_chat_id": "missing", "before_user_index": -1}, - ) - payload = json.loads(conn.send.await_args.args[0]) - assert payload["event"] == "error" - assert payload["detail"] == "invalid before_user_index" - bus.publish_inbound.assert_not_awaited() - - @pytest.mark.asyncio async def test_webui_message_envelope_appends_user_transcript( bus: MagicMock, diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 595e75330..e44d7eb3f 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -46,33 +46,6 @@ def test_fork_transcript_before_user_index_copies_only_prefix(tmp_path, monkeypa assert "round3 must not appear" not in "\n".join(str(line.get("text")) for line in lines) -def test_fork_transcript_from_middle_assistant_reply_keeps_selected_turn( - tmp_path, - monkeypatch, -) -> None: - monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) - source = "websocket:source" - for ev in ( - {"event": "user", "chat_id": "source", "text": "round1"}, - {"event": "message", "chat_id": "source", "text": "answer1"}, - {"event": "user", "chat_id": "source", "text": "round2"}, - {"event": "message", "chat_id": "source", "text": "answer2"}, - {"event": "user", "chat_id": "source", "text": "round3 must not appear"}, - {"event": "message", "chat_id": "source", "text": "answer3 must not appear"}, - ): - append_transcript_object(source, ev) - - ok = fork_transcript_before_user_index(source, "websocket:fork", 2) - - assert ok is True - assert [line.get("text") for line in read_transcript_lines("websocket:fork")] == [ - "round1", - "answer1", - "round2", - "answer2", - ] - - def test_fork_transcript_rejects_out_of_range_user_index(tmp_path, monkeypatch) -> None: monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) source = "websocket:source" @@ -82,24 +55,6 @@ def test_fork_transcript_rejects_out_of_range_user_index(tmp_path, monkeypatch) assert read_transcript_lines("websocket:fork") == [] -def test_fork_transcript_allows_index_equal_to_user_count(tmp_path, monkeypatch) -> None: - monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) - source = "websocket:source" - for ev in ( - {"event": "user", "chat_id": "source", "text": "round1"}, - {"event": "message", "chat_id": "source", "text": "answer1"}, - ): - append_transcript_object(source, ev) - - ok = fork_transcript_before_user_index(source, "websocket:fork", 1) - - assert ok is True - assert [line.get("text") for line in read_transcript_lines("websocket:fork")] == [ - "round1", - "answer1", - ] - - def test_build_response_reports_fork_boundary_from_marker(tmp_path, monkeypatch) -> None: monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) key = "websocket:fork" diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index 9449a7199..60e94a87b 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -5,13 +5,13 @@ import { useRef, useState, type ReactNode, - type SVGProps, } from "react"; import { Check, ChevronRight, Clock3, Copy, + GitFork, ImageIcon, Sparkles, Wrench, @@ -22,12 +22,6 @@ import { AttachmentTile } from "@/components/AttachmentTile"; import { CliAppMentionText } from "@/components/CliAppMentionText"; import { ImageLightbox } from "@/components/ImageLightbox"; import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { copyTextToClipboard } from "@/lib/clipboard"; import { formatTurnLatency } from "@/lib/format"; @@ -90,7 +84,7 @@ export function MessageBubble({ }; }, []); - const onCopyMessage = useCallback(() => { + const onCopyAssistantReply = useCallback(() => { void copyTextToClipboard(message.content).then((ok) => { if (!ok) return; setCopied(true); @@ -114,11 +108,6 @@ export function MessageBubble({ const hasImages = images.length > 0; const hasMedia = media.length > 0; const hasText = message.content.trim().length > 0; - const showUserActions = hasText; - const timeLabel = formatMessageClock(message.createdAt); - const copyLabel = copied - ? t("message.copiedMessage", { defaultValue: "Copied" }) - : t("message.copyMessage", { defaultValue: "Copy" }); return (
) : null} - {showUserActions ? ( -