From 03bca4c0a9f70d09f16b8c6a3499bf1f46d21ce5 Mon Sep 17 00:00:00 2001 From: Bayern4ever-dot Date: Fri, 5 Jun 2026 19:49:34 +0800 Subject: [PATCH] feat(webui): add assistant reply fork-from-here --- THIRD_PARTY_NOTICES.md | 31 ++ nanobot/channels/websocket.py | 60 ++++ nanobot/session/manager.py | 65 +++++ nanobot/webui/transcript.py | 119 ++++++++ tests/agent/test_session_manager_history.py | 81 ++++++ tests/channels/test_websocket_channel.py | 211 ++++++++++++++ tests/utils/test_webui_transcript.py | 75 +++++ webui/src/App.tsx | 22 +- webui/src/components/MessageBubble.tsx | 199 ++++++++++--- .../src/components/thread/ThreadComposer.tsx | 25 ++ .../src/components/thread/ThreadMessages.tsx | 46 +++ webui/src/components/thread/ThreadShell.tsx | 43 ++- .../src/components/thread/ThreadViewport.tsx | 6 + webui/src/hooks/useSessions.ts | 26 +- webui/src/i18n/locales/en/common.json | 6 + webui/src/i18n/locales/es/common.json | 6 + webui/src/i18n/locales/fr/common.json | 6 + webui/src/i18n/locales/id/common.json | 6 + webui/src/i18n/locales/ja/common.json | 6 + webui/src/i18n/locales/ko/common.json | 6 + webui/src/i18n/locales/vi/common.json | 6 + webui/src/i18n/locales/zh-CN/common.json | 6 + webui/src/i18n/locales/zh-TW/common.json | 6 + webui/src/lib/nanobot-client.ts | 31 ++ webui/src/lib/types.ts | 1 + webui/src/tests/app-layout.test.tsx | 1 + webui/src/tests/message-bubble.test.tsx | 32 +++ webui/src/tests/thread-shell.test.tsx | 264 +++++++++++++++++- webui/src/tests/useNanobotStream.test.tsx | 1 + webui/src/tests/useSessions.test.tsx | 1 + 30 files changed, 1358 insertions(+), 36 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9085bfc8e..3c1e97b7b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,6 +5,37 @@ 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 b3f58d982..20aaac097 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -45,6 +45,11 @@ from nanobot.webui.http_utils import ( query_first as _query_first, ) from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions +from nanobot.webui.transcript import ( + delete_webui_transcript, + fork_transcript_before_user_index, + write_session_messages_as_transcript, +) from nanobot.webui.transcription_ws import webui_transcription_event from nanobot.webui.websocket_logging import websockets_server_logger @@ -668,6 +673,61 @@ class WebSocketChannel(BaseChannel): ) await self._hydrate_after_subscribe(new_id) return + if t == "fork_chat": + source_chat_id = envelope.get("source_chat_id") + raw_index = envelope.get("before_user_index") + if not _is_valid_chat_id(source_chat_id): + await self._send_event(connection, "error", detail="invalid source_chat_id") + return + if ( + isinstance(raw_index, bool) + or not isinstance(raw_index, int) + or raw_index < 0 + ): + await self._send_event(connection, "error", detail="invalid before_user_index") + return + if self.gateway.session_manager is None: + await self._send_event(connection, "error", detail="session_manager_unavailable") + return + + new_id = str(uuid.uuid4()) + source_key = f"websocket:{source_chat_id}" + target_key = f"websocket:{new_id}" + try: + forked = self.gateway.session_manager.fork_session_before_user_index( + source_key, + target_key, + raw_index, + ) + if forked is None: + await self._send_event(connection, "error", detail="invalid fork source or index") + return + transcript_ok = fork_transcript_before_user_index( + source_key, + target_key, + raw_index, + ) + if not transcript_ok: + write_session_messages_as_transcript(target_key, forked.messages) + except Exception as exc: + delete_webui_transcript(target_key) + self.gateway.session_manager.delete_session(target_key) + 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(target_key) + self._attach(connection, new_id) + await self._send_event(connection, "attached", chat_id=new_id) + await self._send_event( + connection, + "session_updated", + chat_id=new_id, + scope="metadata", + workspace_scope=scope.payload(), + ) + await self._hydrate_after_subscribe(new_id) + return if t == "attach": cid = envelope.get("chat_id") if not _is_valid_chat_id(cid): diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index e6d8e21c3..6c92fe753 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -5,6 +5,7 @@ import os import re import shutil from contextlib import suppress +from copy import deepcopy from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -30,6 +31,14 @@ _TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$') _SESSION_PREVIEW_MAX_CHARS = 120 _SESSION_LIST_PREVIEW_MAX_RECORDS = 200 _SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000 +_FORK_VOLATILE_METADATA_KEYS = { + "goal_state", + "pending_user_turn", + "runtime_checkpoint", + "thread_goal", + "title", + "title_user_edited", +} def _sanitize_assistant_replay_text(content: str) -> str: @@ -628,6 +637,62 @@ class SessionManager: logger.warning("Failed to delete session file {}: {}", path, e) return False + def fork_session_before_user_index( + self, + source_key: str, + target_key: str, + before_user_index: int, + ) -> Session | None: + """Create *target_key* from *source_key* before a global user-message index. + + ``before_user_index`` is zero-based over user messages in the full session: + ``0`` means "before the first user message", ``1`` means "before the + second user message", and so on. A value equal to the total user-message + count copies the full session prefix. The target user message itself is + not copied; the WebUI pre-fills it in the composer for editing and resend. + """ + if before_user_index < 0: + return None + source = self._cache.get(source_key) or self._load(source_key) + if source is None: + return None + + copied: list[dict[str, Any]] = [] + user_index = 0 + found_target = False + for message in source.messages: + if message.get("role") == "user": + if user_index == before_user_index: + found_target = True + break + user_index += 1 + copied.append(deepcopy(message)) + if user_index == before_user_index: + found_target = True + if not found_target: + return None + + metadata = deepcopy(source.metadata) + for key in _FORK_VOLATILE_METADATA_KEYS: + metadata.pop(key, None) + + last_consolidated = min(source.last_consolidated, len(copied)) + if source.last_consolidated > len(copied): + metadata.pop("_last_summary", None) + last_consolidated = 0 + + now = datetime.now() + target = Session( + key=target_key, + messages=copied, + created_at=now, + updated_at=now, + metadata=metadata, + last_consolidated=last_consolidated, + ) + self.save(target, fsync=True) + return target + def read_session_file(self, key: str) -> dict[str, Any] | None: """Load a session from disk without caching; intended for read-only HTTP endpoints. diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index 2d9b6da2f..59b7a2fd9 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -274,6 +274,125 @@ class WebUITranscriptRecorder: self._turn_sequences.pop((chat_id, turn_id), None) +def _chat_id_from_session_key(session_key: str) -> str | None: + if not session_key.startswith("websocket:"): + return None + chat_id = session_key.split(":", 1)[1].strip() + return chat_id or None + + +def _is_user_transcript_row(row: dict[str, Any]) -> bool: + return row.get("event") == "user" or row.get("role") == "user" + + +def fork_transcript_before_user_index( + source_key: str, + target_key: str, + before_user_index: int, +) -> bool: + """Copy transcript rows before a zero-based global user-message index. + + ``before_user_index == user_count`` copies the full transcript prefix. WebUI + uses that when forking from an assistant reply at the end of a chat. + """ + if before_user_index < 0: + return False + lines = read_transcript_lines(source_key) + if not lines: + return False + + target_chat_id = _chat_id_from_session_key(target_key) + copied: list[dict[str, Any]] = [] + user_index = 0 + found_target = False + for row in lines: + if _is_user_transcript_row(row): + if user_index == before_user_index: + found_target = True + break + user_index += 1 + dup = json.loads(json.dumps(row, ensure_ascii=False)) + if target_chat_id is not None: + dup["chat_id"] = target_chat_id + copied.append(dup) + if user_index == before_user_index: + found_target = True + + 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 + return True + + +def write_session_messages_as_transcript( + target_key: str, + messages: list[dict[str, Any]], +) -> 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 + + def delete_webui_transcript(session_key: str) -> bool: path = webui_transcript_path(session_key) if not path.is_file(): diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index e3bf4d701..3441c4833 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -426,6 +426,87 @@ def test_get_history_synthesizes_cli_app_attachment_breadcrumb(): }] +def test_fork_session_before_user_index_copies_only_prefix(tmp_path): + manager = SessionManager(tmp_path) + source = manager.get_or_create("websocket:source") + source.metadata["webui"] = True + source.metadata["title"] = "Old title" + source.metadata["goal_state"] = {"status": "active", "objective": "do not inherit"} + 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") + manager.save(source) + + forked = manager.fork_session_before_user_index( + "websocket:source", + "websocket:fork", + 1, + ) + + assert forked is not None + assert [m["content"] for m in forked.messages] == ["round1", "answer1"] + assert forked.metadata["webui"] is True + assert "title" not in forked.metadata + assert "goal_state" not in forked.metadata + saved = manager.read_session_file("websocket:fork") + assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"] + + +def test_fork_session_rejects_negative_missing_and_out_of_range(tmp_path): + manager = SessionManager(tmp_path) + source = manager.get_or_create("websocket:source") + source.add_message("user", "round1") + manager.save(source) + + assert manager.fork_session_before_user_index("websocket:source", "websocket:x", -1) is None + assert manager.fork_session_before_user_index("websocket:missing", "websocket:x", 0) is None + assert manager.fork_session_before_user_index("websocket:source", "websocket:x", 2) is None + + +def test_fork_session_allows_index_equal_to_user_count(tmp_path): + manager = SessionManager(tmp_path) + source = manager.get_or_create("websocket:source") + source.add_message("user", "round1") + source.add_message("assistant", "answer1") + manager.save(source) + + forked = manager.fork_session_before_user_index( + "websocket:source", + "websocket:fork", + 1, + ) + + assert forked is not None + assert [m["content"] for m in forked.messages] == ["round1", "answer1"] + + +def test_fork_session_drops_summary_when_fork_point_is_inside_consolidated_prefix(tmp_path): + manager = SessionManager(tmp_path) + source = manager.get_or_create("websocket:source") + source.messages = [ + {"role": "user", "content": "round1"}, + {"role": "assistant", "content": "answer1"}, + {"role": "user", "content": "round2 fork me"}, + {"role": "assistant", "content": "answer2"}, + ] + source.last_consolidated = 4 + source.metadata["_last_summary"] = {"text": "round2 fork me and answer2"} + manager.save(source) + + forked = manager.fork_session_before_user_index( + "websocket:source", + "websocket:fork", + 1, + ) + + assert forked is not None + assert [m["content"] for m in forked.messages] == ["round1", "answer1"] + assert forked.last_consolidated == 0 + assert "_last_summary" not in forked.metadata + + def test_get_history_ignores_media_kwarg_on_non_user_rows(): """``media`` only ever appears on user entries in practice, but the synthesizer must be defensive: assistants / tools with list content diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index 3e358b076..f8e8ea2e9 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -45,6 +45,7 @@ from nanobot.webui.http_utils import ( parse_request_path as _parse_request_path, ) from nanobot.webui.settings_api import settings_payload, update_provider_settings +from nanobot.webui.transcript import append_transcript_object, read_transcript_lines # -- Shared helpers (aligned with test_websocket_integration.py) --------------- @@ -2385,6 +2386,216 @@ async def test_multiplex_new_chat_roundtrip(bus: MagicMock) -> None: await server_task +@pytest.mark.asyncio +async def test_fork_chat_copies_only_prefix_session_and_transcript( + 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) + 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"}, + ): + append_transcript_object("websocket:source", ev) + + 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 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) + 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"] + 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"] + 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, + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + 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() + conn.remote_address = ("127.0.0.1", 50123) + + await channel._dispatch_envelope( + conn, + "webui-client", + { + "type": "message", + "chat_id": "source", + "content": "round1", + "webui": True, + }, + ) + + [line] = read_transcript_lines("websocket:source") + assert { + "event": line.get("event"), + "chat_id": line.get("chat_id"), + "text": line.get("text"), + } == {"event": "user", "chat_id": "source", "text": "round1"} + assert isinstance(line.get("turn_id"), str) + assert line.get("turn_phase") == "user" + assert line.get("turn_seq") == 1 + inbound = bus.publish_inbound.await_args.args[0] + assert inbound.chat_id == "source" + assert inbound.content == "round1" + + @pytest.mark.asyncio async def test_multiplex_two_chats_isolated(bus: MagicMock) -> None: port = 29932 diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 5b0e35b17..37876e30a 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -6,8 +6,10 @@ from nanobot.webui.transcript import ( WEBUI_TRANSCRIPT_SCHEMA_VERSION, append_transcript_object, build_webui_thread_response, + fork_transcript_before_user_index, read_transcript_lines, replay_transcript_to_ui_messages, + write_session_messages_as_transcript, ) @@ -20,6 +22,79 @@ def test_append_and_read_roundtrip(tmp_path, monkeypatch) -> None: assert lines[0]["text"] == "hello" +def test_fork_transcript_before_user_index_copies_only_prefix(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": "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"}, + ): + append_transcript_object(source, ev) + + ok = fork_transcript_before_user_index(source, "websocket:fork", 1) + + assert ok is True + lines = read_transcript_lines("websocket:fork") + assert [line.get("text") for line in lines] == ["round1", "answer1", None] + assert all(line.get("chat_id") == "fork" for line in lines) + assert "round2 fork me" not in "\n".join(str(line.get("text")) for line in lines) + assert "round3 must not appear" not in "\n".join(str(line.get("text")) for line in lines) + + +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" + append_transcript_object(source, {"event": "user", "chat_id": "source", "text": "round1"}) + + assert fork_transcript_before_user_index(source, "websocket:fork", 2) is False + 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_write_session_messages_as_transcript_builds_canonical_prefix( + tmp_path, + monkeypatch, +) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + + write_session_messages_as_transcript( + "websocket:fork", + [ + {"role": "user", "content": "round1"}, + {"role": "assistant", "content": "answer1"}, + ], + ) + + lines = read_transcript_lines("websocket:fork") + assert lines == [ + {"event": "user", "chat_id": "fork", "text": "round1"}, + {"event": "message", "chat_id": "fork", "text": "answer1"}, + ] + msgs = replay_transcript_to_ui_messages(lines) + assert [m["content"] for m in msgs] == ["round1", "answer1"] + + def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None: monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) key = "websocket:t2" diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 4fe6d20e7..33c24ccc8 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -526,7 +526,7 @@ function Shell({ const { t, i18n } = useTranslation(); const { client, token } = useClient(); const { theme, toggle } = useTheme(); - const { sessions, loading, refresh, createChat, deleteChat } = useSessions(); + const { sessions, loading, refresh, createChat, forkChat, deleteChat } = useSessions(); const { state: sidebarState, update: updateSidebarState } = useSidebarState(sessions, !loading); const initialRouteRef = useRef(null); @@ -885,6 +885,25 @@ function Shell({ } }, [activeWorkspaceScope, createChat, navigate, t]); + const onForkChat = useCallback(async ( + sourceChatId: string, + beforeUserIndex: number, + ) => { + try { + const chatId = await forkChat(sourceChatId, beforeUserIndex); + navigate({ + view: "chat", + activeKey: `websocket:${chatId}`, + settingsSection: "overview", + }); + setMobileSidebarOpen(false); + return chatId; + } catch (e) { + console.error("Failed to fork chat", e); + return null; + } + }, [forkChat, navigate]); + const onNewChat = useCallback(() => { navigate(defaultShellRoute()); setDraftWorkspaceScope(null); @@ -1486,6 +1505,7 @@ function Shell({ onToggleSidebar={toggleSidebar} onNewChat={onNewChat} onCreateChat={onCreateChat} + onForkChat={onForkChat} onTurnEnd={onTurnEnd} theme={theme} onToggleTheme={toggle} diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index acd470e14..39b61911e 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -5,14 +5,29 @@ import { useRef, useState, type ReactNode, + type SVGProps, } from "react"; -import { Check, ChevronRight, Clock3, Copy, ImageIcon, Sparkles, Wrench } from "lucide-react"; +import { + Check, + ChevronRight, + Clock3, + Copy, + ImageIcon, + Sparkles, + Wrench, +} from "lucide-react"; import { useTranslation } from "react-i18next"; 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"; @@ -34,6 +49,7 @@ interface MessageBubbleProps { cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; onOpenFilePreview?: (path: string) => void; + onForkFromHere?: () => void; } /** @@ -51,6 +67,7 @@ export function MessageBubble({ cliApps = [], mcpPresets = [], onOpenFilePreview, + onForkFromHere, }: MessageBubbleProps) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); @@ -73,7 +90,7 @@ export function MessageBubble({ }; }, []); - const onCopyAssistantReply = useCallback(() => { + const onCopyMessage = useCallback(() => { void copyTextToClipboard(message.content).then((ok) => { if (!ok) return; setCopied(true); @@ -97,6 +114,11 @@ 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 message" }) + : t("message.copyMessage", { defaultValue: "Copy message" }); return (

) : null} + {showUserActions ? ( + +
+ {hasText ? ( + + + + ) : null} + {timeLabel ? ( + + {timeLabel} + + ) : null} +
+
+ ) : null}
); } @@ -138,13 +197,16 @@ export function MessageBubble({ const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty; const showCopyButton = showAssistantCopyAction && showAssistantActions; + const showForkButton = showAssistantActions && !!onForkFromHere; + const copyReplyLabel = copied ? t("message.copiedReply") : t("message.copyReply"); + const forkLabel = t("message.forkFromHere"); const latencyMs = message.latencyMs; const showLatencyFooter = message.role === "assistant" && latencyMs != null && !message.isStreaming && (!empty || hasReasoning || media.length > 0); - const showAssistantFooterRow = showCopyButton || showLatencyFooter; + const showAssistantFooterRow = showCopyButton || showForkButton || showLatencyFooter; return (
{hasReasoning ? ( @@ -173,35 +235,54 @@ export function MessageBubble({ {media.length > 0 ? : null} {showAssistantFooterRow ? ( -
- {showCopyButton ? ( - - ) : null} - {showLatencyFooter ? ( - - {formatTurnLatency(latencyMs)} - - ) : null} -
+ +
+ {showCopyButton ? ( + + + + ) : null} + {showForkButton ? ( + + + + ) : null} + {showLatencyFooter ? ( + + {formatTurnLatency(latencyMs)} + + ) : null} +
+
) : null} )} @@ -209,6 +290,27 @@ export function MessageBubble({ ); } +function MessageActionTooltip({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( + + {children} + + {label} + + + ); +} + function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) { return (
) { + // Tabler Icons "arrow-fork" (MIT, Copyright Paweł Kuna). + return ( + + + + + + + ); +} + function mergeMcpMentionPresets( presets: McpPresetInfo[], attachments: UIMcpPresetAttachment[] | undefined, diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index fba1a46fd..49b2b37c8 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -172,6 +172,7 @@ interface ThreadComposerProps { workspaceError?: string | null; onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void; pendingQueueKey?: string | null; + externalError?: string | null; } const COMMAND_ICONS: Record = { @@ -765,6 +766,7 @@ export function ThreadComposer({ workspaceError = null, onWorkspaceScopeChange, pendingQueueKey = null, + externalError = null, }: ThreadComposerProps) { const { t } = useTranslation(); const [value, setValue] = useState(""); @@ -782,6 +784,7 @@ export function ThreadComposer({ const chipRefs = useRef(new Map()); const queuedPromptCounterRef = useRef(0); const draggedQueuedPromptIdRef = useRef(null); + const previousPendingQueueKeyRef = useRef(pendingQueueKey); const wasStreamingRef = useRef(isStreaming); const skipNextQueuedFlushRef = useRef(false); const skipQueuedPromptPersistRef = useRef(false); @@ -1128,6 +1131,28 @@ export function ThreadComposer({ }); }, []); + // Runs before paint so switching sessions never flashes stale draft text. + useLayoutEffect(() => { + if (previousPendingQueueKeyRef.current === pendingQueueKey) return; + previousPendingQueueKeyRef.current = pendingQueueKey; + setValue(""); + setInlineError(null); + setSlashMenuDismissed(false); + setCliAppMenuDismissed(false); + setCursorPosition(0); + clear(); + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 260)}px`; + }); + }, [clear, pendingQueueKey]); + + useEffect(() => { + if (externalError) setInlineError(externalError); + }, [externalError]); + const appendTranscription = useCallback((text: string) => { const transcript = text.trim(); if (!transcript) return; diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index 32e405e78..f7f481ede 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -8,6 +8,7 @@ import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types"; interface ThreadMessagesProps { messages: UIMessage[]; + allMessages?: UIMessage[]; /** When true, agent turn still in flight — keeps activity timeline expanded. */ isStreaming?: boolean; hiddenMessageCount?: number; @@ -15,6 +16,7 @@ interface ThreadMessagesProps { cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; onOpenFilePreview?: (path: string) => void; + onForkFromMessage?: (beforeUserIndex: number) => void; } export type DisplayUnit = TurnUnit; @@ -62,15 +64,21 @@ export function assistantCopyFlags(units: DisplayUnit[]): boolean[] { export function ThreadMessages({ messages, + allMessages, isStreaming = false, hiddenMessageCount = 0, onLoadEarlier, cliApps = [], mcpPresets = [], onOpenFilePreview, + onForkFromMessage, }: ThreadMessagesProps) { const { t } = useTranslation(); const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]); + const assistantForkIndexById = useMemo( + () => assistantForkIndexByMessageId(allMessages ?? messages), + [allMessages, messages], + ); const copyFlags = useMemo(() => assistantCopyFlags(units), [units]); const liveActivityClusterIndices = useMemo( () => isStreaming ? currentActivityClusterIndices(units) : new Set(), @@ -137,6 +145,16 @@ export function ThreadMessages({ cliApps={cliApps} mcpPresets={mcpPresets} onOpenFilePreview={onOpenFilePreview} + onForkFromHere={ + onForkFromMessage + ? forkHandlerForAssistantMessage( + unit.message, + copyFlags[index], + assistantForkIndexById, + onForkFromMessage, + ) + : undefined + } /> )}
@@ -146,6 +164,34 @@ export function ThreadMessages({ ); } +function assistantForkIndexByMessageId(messages: UIMessage[]): Map { + const out = new Map(); + let nextUserIndex = 0; + for (const message of messages) { + if (message.role === "user") { + nextUserIndex += 1; + } else if (message.role === "assistant") { + out.set(message.id, nextUserIndex); + } + } + return out; +} + +function forkHandlerForAssistantMessage( + message: UIMessage, + canForkAssistant: boolean, + assistantForkIndexById: Map, + onForkFromMessage: NonNullable, +): (() => void) | undefined { + if (message.role === "assistant" && canForkAssistant) { + const beforeUserIndex = assistantForkIndexById.get(message.id); + return beforeUserIndex === undefined + ? undefined + : () => onForkFromMessage(beforeUserIndex); + } + return undefined; +} + function currentActivityClusterIndices(units: DisplayUnit[]): Set { const indices = new Set(); let markedCurrentActivity = false; diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index c139f82ec..b22cc7fd2 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -77,6 +77,7 @@ interface ThreadShellProps { onGoHome?: () => void; onNewChat?: () => void; onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise; + onForkChat?: (sourceChatId: string, beforeUserIndex: number) => Promise; onTurnEnd?: () => void; theme?: "light" | "dark"; onToggleTheme?: () => void; @@ -226,6 +227,7 @@ export function ThreadShell({ title, onToggleSidebar, onCreateChat, + onForkChat, onTurnEnd, theme = "light", onToggleTheme = () => {}, @@ -275,6 +277,8 @@ export function ThreadShell({ const [filePreviewPath, setFilePreviewPath] = useState(null); const [filePreviewClosing, setFilePreviewClosing] = useState(false); const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH); + const [forkError, setForkError] = useState(null); + const [forkHydratingChatId, setForkHydratingChatId] = useState(null); const shellRef = useRef(null); const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH); const filePreviewCloseTimerRef = useRef(null); @@ -283,6 +287,7 @@ export function ThreadShell({ const messageCacheRef = useRef>(new Map()); /** Last chatId we associated with the in-memory thread (for cache-on-switch). */ const prevChatIdForCacheRef = useRef(null); + const prevChatIdForComposerRef = useRef(chatId); /** Skip one message-cache write right after chatId changes (messages may not match yet). */ const skipLayoutCacheRef = useRef(false); const appliedHistoryVersionRef = useRef>(new Map()); @@ -334,6 +339,12 @@ export function ThreadShell({ }; }, []); + useEffect(() => { + if (prevChatIdForComposerRef.current === chatId) return; + prevChatIdForComposerRef.current = chatId; + setForkError(null); + }, [chatId]); + const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]); const showHeroComposer = messages.length === 0 && !loading; @@ -443,6 +454,12 @@ export function ThreadShell({ setMessages(projectWebuiThreadMessages(historical)); }, [chatId, historical, setMessages]); + useEffect(() => { + if (!chatId || loading || forkHydratingChatId !== chatId) return; + setForkHydratingChatId(null); + setScrollToBottomSignal((value) => value + 1); + }, [chatId, forkHydratingChatId, loading]); + useLayoutEffect(() => { if (chatId) { const prev = prevChatIdForCacheRef.current; @@ -521,6 +538,7 @@ export function ThreadShell({ const handleThreadSend = useCallback( (content: string, images?: SendImage[], options?: SendOptions) => { + setForkError(null); setScrollToBottomSignal((value) => value + 1); send(content, images, withWorkspaceScope(options)); }, @@ -615,6 +633,26 @@ export function ThreadShell({ }; }, [filePreviewPath]); + const handleForkFromMessage = useCallback( + async (beforeUserIndex: number) => { + if (!chatId || !onForkChat) return; + setForkError(null); + const forkedChatId = await onForkChat(chatId, beforeUserIndex); + if (!forkedChatId) { + setForkError(t("thread.fork.failed", { + defaultValue: "Could not fork this chat. Try again.", + })); + return; + } + messageCacheRef.current.delete(forkedChatId); + appliedHistoryVersionRef.current.delete(forkedChatId); + pendingCanonicalHydrateRef.current.add(forkedChatId); + setForkHydratingChatId(forkedChatId); + setForkError(null); + }, + [chatId, onForkChat, t], + ); + const composer = ( <> {streamError ? ( @@ -626,7 +664,7 @@ export function ThreadShell({ {session ? ( ) : (
{filePreviewPath && historyKey ? ( diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 1bd0012e8..37de373b0 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -29,6 +29,7 @@ export interface ThreadViewportHandle { interface ThreadViewportProps { messages: UIMessage[]; + allMessages?: UIMessage[]; isStreaming: boolean; composer: ReactNode; emptyState?: ReactNode; @@ -38,6 +39,7 @@ interface ThreadViewportProps { cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; onOpenFilePreview?: (path: string) => void; + onForkFromMessage?: (beforeUserIndex: number) => void; } const NEAR_BOTTOM_PX = 48; @@ -61,6 +63,7 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM export const ThreadViewport = forwardRef(function ThreadViewport({ messages, + allMessages, isStreaming, composer, emptyState, @@ -70,6 +73,7 @@ export const ThreadViewport = forwardRef(null); @@ -289,12 +293,14 @@ export const ThreadViewport = forwardRef diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index 1b6797c8a..b361565b1 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -20,6 +20,7 @@ export function useSessions(): { error: string | null; refresh: () => Promise; createChat: (workspaceScope?: WorkspaceScopePayload | null) => Promise; + forkChat: (sourceChatId: string, beforeUserIndex: number) => Promise; deleteChat: (key: string) => Promise; } { const { client, token } = useClient(); @@ -88,6 +89,29 @@ export function useSessions(): { return chatId; }, [client]); + const forkChat = useCallback(async ( + sourceChatId: string, + beforeUserIndex: number, + ): Promise => { + const chatId = await client.forkChat(sourceChatId, beforeUserIndex); + const key = `websocket:${chatId}`; + optimisticKeysRef.current.add(key); + setSessions((prev) => [ + { + key, + channel: "websocket", + chatId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + title: "", + preview: "", + workspaceScope: null, + }, + ...prev.filter((s) => s.key !== key), + ]); + return chatId; + }, [client]); + const deleteChat = useCallback( async (key: string) => { await apiDeleteSession(tokenRef.current, key); @@ -97,7 +121,7 @@ export function useSessions(): { [], ); - return { sessions, loading, error, refresh, createChat, deleteChat }; + return { sessions, loading, error, refresh, createChat, forkChat, deleteChat }; } /** Lazy-load a session's on-disk messages the first time the UI displays it. */ diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 876f81df3..2ca281576 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -810,6 +810,9 @@ }, "scrollToBottom": "Scroll to bottom", "loadEarlier": "Load earlier messages", + "fork": { + "failed": "Could not fork this chat. Try again." + }, "promptNavigator": { "open": "Open prompt navigator", "title": "Prompts", @@ -849,6 +852,9 @@ "imageAttachment": "Image attachment", "automationSourceFallback": "Automation", "automationTriggered": "Triggered automatically", + "copyMessage": "Copy message", + "copiedMessage": "Copied message", + "forkFromHere": "Fork from here", "copyReply": "Copy reply", "copiedReply": "Copied reply", "turnLatencyTitle": "Response time (end-to-end)" diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 09d02f291..8070cdc60 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -810,6 +810,9 @@ }, "scrollToBottom": "Desplazarse al final", "loadEarlier": "Cargar mensajes anteriores", + "fork": { + "failed": "No se pudo bifurcar este chat. Inténtalo de nuevo." + }, "promptNavigator": { "open": "Abrir navegador de prompts", "title": "Prompts", @@ -835,6 +838,9 @@ "agentActivityLiveSummary": "En curso… · {{reasoning}} pasos · {{tools}} llamadas a herramientas", "agentActivityLiveToolsOnly": "En curso… · {{tools}} llamadas a herramientas", "imageAttachment": "Imagen adjunta", + "copyMessage": "Copiar mensaje", + "copiedMessage": "Mensaje copiado", + "forkFromHere": "Bifurcar desde aquí", "copyReply": "Copiar respuesta", "copiedReply": "Respuesta copiada", "turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index fc7cdbd77..d4d7ce769 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -810,6 +810,9 @@ }, "scrollToBottom": "Faire défiler vers le bas", "loadEarlier": "Charger les messages précédents", + "fork": { + "failed": "Impossible de bifurquer cette conversation. Réessayez." + }, "promptNavigator": { "open": "Ouvrir le navigateur de prompts", "title": "Prompts", @@ -835,6 +838,9 @@ "agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels d’outils", "agentActivityLiveToolsOnly": "En cours… · {{tools}} appels d’outils", "imageAttachment": "Pièce jointe image", + "copyMessage": "Copier le message", + "copiedMessage": "Message copié", + "forkFromHere": "Bifurquer depuis ici", "copyReply": "Copier la réponse", "copiedReply": "Réponse copiée", "turnLatencyTitle": "Temps de réponse (de bout en bout)", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index c95851fc6..5d7101e5c 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -810,6 +810,9 @@ }, "scrollToBottom": "Gulir ke bawah", "loadEarlier": "Muat pesan sebelumnya", + "fork": { + "failed": "Tidak dapat mem-fork chat ini. Coba lagi." + }, "promptNavigator": { "open": "Buka navigator prompt", "title": "Prompt", @@ -835,6 +838,9 @@ "agentActivityLiveSummary": "Berjalan… · {{reasoning}} langkah · {{tools}} panggilan alat", "agentActivityLiveToolsOnly": "Berjalan… · {{tools}} panggilan alat", "imageAttachment": "Lampiran gambar", + "copyMessage": "Salin pesan", + "copiedMessage": "Pesan disalin", + "forkFromHere": "Fork dari sini", "copyReply": "Salin balasan", "copiedReply": "Balasan disalin", "turnLatencyTitle": "Waktu respons (ujung ke ujung)", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 1f68c96cb..3686dcc92 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -810,6 +810,9 @@ }, "scrollToBottom": "一番下へスクロール", "loadEarlier": "以前のメッセージを読み込む", + "fork": { + "failed": "このチャットを分岐できませんでした。もう一度お試しください。" + }, "promptNavigator": { "open": "プロンプトナビゲーターを開く", "title": "プロンプト", @@ -835,6 +838,9 @@ "agentActivityLiveSummary": "実行中… · {{reasoning}} ステップ · ツール呼び出し {{tools}} 回", "agentActivityLiveToolsOnly": "実行中… · ツール呼び出し {{tools}} 回", "imageAttachment": "画像の添付", + "copyMessage": "メッセージをコピー", + "copiedMessage": "メッセージをコピーしました", + "forkFromHere": "ここから分岐", "copyReply": "返信をコピー", "copiedReply": "返信をコピーしました", "turnLatencyTitle": "応答時間(全行程)", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 9538892d1..0a77265fa 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -810,6 +810,9 @@ }, "scrollToBottom": "맨 아래로 스크롤", "loadEarlier": "이전 메시지 불러오기", + "fork": { + "failed": "이 채팅을 분기할 수 없습니다. 다시 시도해 주세요." + }, "promptNavigator": { "open": "프롬프트 탐색기 열기", "title": "프롬프트", @@ -835,6 +838,9 @@ "agentActivityLiveSummary": "진행 중… · {{reasoning}}단계 · 도구 호출 {{tools}}회", "agentActivityLiveToolsOnly": "진행 중… · 도구 호출 {{tools}}회", "imageAttachment": "이미지 첨부", + "copyMessage": "메시지 복사", + "copiedMessage": "메시지가 복사됨", + "forkFromHere": "여기서 분기", "copyReply": "답변 복사", "copiedReply": "답변이 복사됨", "turnLatencyTitle": "응답 시간(엔드투엔드)", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 8d6f12631..07db71e82 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -810,6 +810,9 @@ }, "scrollToBottom": "Cuộn xuống cuối", "loadEarlier": "Tải tin nhắn trước đó", + "fork": { + "failed": "Không thể rẽ nhánh cuộc trò chuyện này. Hãy thử lại." + }, "promptNavigator": { "open": "Mở trình điều hướng prompt", "title": "Prompt", @@ -835,6 +838,9 @@ "agentActivityLiveSummary": "Đang chạy… · {{reasoning}} bước · {{tools}} lần gọi công cụ", "agentActivityLiveToolsOnly": "Đang chạy… · {{tools}} lần gọi công cụ", "imageAttachment": "Tệp hình ảnh đính kèm", + "copyMessage": "Sao chép tin nhắn", + "copiedMessage": "Đã sao chép tin nhắn", + "forkFromHere": "Rẽ nhánh từ đây", "copyReply": "Sao chép trả lời", "copiedReply": "Đã sao chép trả lời", "turnLatencyTitle": "Thời gian phản hồi (end-to-end)", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 3407497c2..7b96ba9fb 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -810,6 +810,9 @@ }, "scrollToBottom": "滚动到底部", "loadEarlier": "加载更早消息", + "fork": { + "failed": "无法分叉这个对话,请重试。" + }, "promptNavigator": { "open": "打开输入导航", "title": "输入列表", @@ -849,6 +852,9 @@ "imageAttachment": "图片附件", "automationSourceFallback": "自动化", "automationTriggered": "自动触发", + "copyMessage": "复制消息", + "copiedMessage": "已复制消息", + "forkFromHere": "从这里分叉", "copyReply": "复制回复", "copiedReply": "已复制回复", "turnLatencyTitle": "本轮耗时(端到端)" diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 46dbc33cb..4049c5913 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -810,6 +810,9 @@ }, "scrollToBottom": "捲動到底部", "loadEarlier": "載入更早訊息", + "fork": { + "failed": "無法分叉這個對話,請重試。" + }, "promptNavigator": { "open": "開啟輸入導覽", "title": "輸入列表", @@ -835,6 +838,9 @@ "agentActivityLiveSummary": "進行中… · {{reasoning}} 步 · {{tools}} 次工具呼叫", "agentActivityLiveToolsOnly": "進行中… · {{tools}} 次工具呼叫", "imageAttachment": "圖片附件", + "copyMessage": "複製訊息", + "copiedMessage": "已複製訊息", + "forkFromHere": "從這裡分叉", "copyReply": "複製回覆", "copiedReply": "已複製回覆", "turnLatencyTitle": "本輪耗時(端到端)", diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index 67d0758cb..ee4e70a1e 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -348,6 +348,29 @@ export class NanobotClient { }); } + /** Ask the server to create a non-destructive fork before a user-message index. */ + forkChat( + sourceChatId: string, + beforeUserIndex: number, + timeoutMs: number = 5_000, + ): Promise { + if (this.pendingNewChat) { + return Promise.reject(new Error("newChat already in flight")); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingNewChat = null; + reject(new Error("forkChat timed out")); + }, timeoutMs); + this.pendingNewChat = { resolve, reject, timer }; + this.queueSend({ + type: "fork_chat", + source_chat_id: sourceChatId, + before_user_index: beforeUserIndex, + }); + }); + } + attach(chatId: string): void { this.knownChats.add(chatId); if (this.socket?.readyState === WS_OPEN) { @@ -481,6 +504,14 @@ export class NanobotClient { } } + if (parsed.event === "error" && this.pendingNewChat) { + clearTimeout(this.pendingNewChat.timer); + const detail = typeof parsed.detail === "string" ? parsed.detail : "server error"; + const reason = typeof parsed.reason === "string" && parsed.reason ? `:${parsed.reason}` : ""; + this.pendingNewChat.reject(new Error(`${detail}${reason}`)); + this.pendingNewChat = null; + } + const chatId = (parsed as { chat_id?: string }).chat_id; if (chatId) { this.recordGoalStatusForRunStrip(chatId, parsed); diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 2731c9ddd..7ab06c90a 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -877,6 +877,7 @@ export interface FilePreviewPayload { export type Outbound = | { type: "new_chat"; workspace_scope?: WorkspaceScopePayload } + | { type: "fork_chat"; source_chat_id: string; before_user_index: number } | { type: "attach"; chat_id: string } | { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload } | { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number } diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 4a1b698b8..845efa8ab 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -144,6 +144,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => { error: null, refresh: refreshSpy, createChat: createChatSpy, + forkChat: async () => "fork-chat", deleteChat: async (key: string) => { await deleteChatSpy(key); setSessions((prev: ChatSummary[]) => prev.filter((s) => s.key !== key)); diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index b306cdbbe..38ab872e4 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -76,9 +76,41 @@ describe("MessageBubble", () => { expect(row).toHaveClass("ml-auto", "flex"); expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]"); + expect(screen.getByRole("button", { name: "Copy message" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument(); }); + it("does not render fork control for user messages", () => { + const onForkFromHere = vi.fn(); + const message: UIMessage = { + id: "u-fork", + role: "user", + content: "continue from here", + createdAt: new Date("2026-06-06T09:04:00Z").getTime(), + }; + + render(); + + expect(screen.getByRole("button", { name: "Copy message" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Fork from here" })).not.toBeInTheDocument(); + }); + + it("renders fork control in completed assistant action rows", () => { + const onForkFromHere = vi.fn(); + const message: UIMessage = { + id: "a-fork", + role: "assistant", + content: "branch after this answer", + latencyMs: 1_200, + createdAt: Date.now(), + }; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Fork from here" })); + expect(onForkFromHere).toHaveBeenCalledTimes(1); + }); + it("renders installed CLI app mentions inside sent user messages", () => { const message: UIMessage = { id: "u-cli", diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 6817b593e..ded9e65fa 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -59,6 +59,7 @@ function makeClient() { }, sendMessage: vi.fn(), newChat: vi.fn(), + forkChat: vi.fn(), attach: vi.fn(), connect: vi.fn(), close: vi.fn(), @@ -721,6 +722,267 @@ describe("ThreadShell", () => { expect(screen.queryByText("old answer")).not.toBeInTheDocument(); }); + it("forks assistant replies using the global user message index rather than the visible window index", async () => { + const client = makeClient(); + const onForkChat = vi.fn().mockResolvedValue("chat-fork"); + const rows = Array.from({ length: 165 }, (_, index) => [ + { role: "user" as const, content: `question ${index}` }, + { role: "assistant" as const, content: `answer ${index}` }, + ]).flat(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Along-chat/webui-thread")) { + return httpJson(transcriptFromSimpleMessages(rows)); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + onForkChat={onForkChat} + />, + ), + ); + + const targetText = await screen.findByText("answer 100"); + fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", { + name: "Fork from here", + })); + + await waitFor(() => + expect(onForkChat).toHaveBeenCalledWith("long-chat", 101), + ); + }); + + it("shows an error without changing the draft when assistant fork fails", async () => { + const client = makeClient(); + const onForkChat = vi.fn().mockResolvedValue(null); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Achat-a/webui-thread")) { + return httpJson(transcriptFromSimpleMessages([ + { role: "user", content: "fork me" }, + { role: "assistant", content: "answer" }, + ])); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + onForkChat={onForkChat} + />, + ), + ); + + const targetText = await screen.findByText("answer"); + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "keep my current draft" }, + }); + fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", { + name: "Fork from here", + })); + + await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 1)); + expect(screen.getByLabelText("Message input")).toHaveValue("keep my current draft"); + expect(screen.getByRole("alert")).toHaveTextContent("Could not fork this chat"); + expect(client.sendMessage).not.toHaveBeenCalled(); + }); + + it("hydrates a successful fork from canonical history without later source messages", async () => { + const client = makeClient(); + const onForkChat = vi.fn().mockResolvedValue("chat-fork"); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Achat-a/webui-thread")) { + return httpJson(transcriptFromSimpleMessages([ + { role: "user", content: "round1" }, + { role: "assistant", content: "answer1" }, + { role: "user", content: "round2 fork me" }, + { role: "assistant", content: "answer2" }, + { role: "user", content: "round3 must not appear" }, + ])); + } + if (url.includes("websocket%3Achat-fork/webui-thread")) { + return httpJson(transcriptFromSimpleMessages([ + { role: "user", content: "round1" }, + { role: "assistant", content: "answer1" }, + { role: "user", content: "round2 fork me" }, + { role: "assistant", content: "answer2" }, + ])); + } + if (url.includes("websocket%3Achat-other/webui-thread")) { + return httpJson(transcriptFromSimpleMessages([ + { role: "user", content: "other chat" }, + ])); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + const { rerender } = render( + wrap( + client, + {}} + onForkChat={onForkChat} + />, + ), + ); + + const targetText = await screen.findByText("answer2"); + fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", { + name: "Fork from here", + })); + + await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 2)); + await act(async () => { + rerender( + wrap( + client, + {}} + onForkChat={onForkChat} + />, + ), + ); + }); + + await waitFor(() => expect(screen.getByText("answer1")).toBeInTheDocument()); + expect(screen.getByText("answer2")).toBeInTheDocument(); + expect(screen.queryByText("round3 must not appear")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Message input")).toHaveValue(""); + + await act(async () => { + rerender( + wrap( + client, + {}} + onForkChat={onForkChat} + />, + ), + ); + }); + + await waitFor(() => + expect(screen.getByLabelText("Message input")).toHaveValue(""), + ); + + await act(async () => { + rerender( + wrap( + client, + {}} + onForkChat={onForkChat} + />, + ), + ); + }); + + expect(screen.getByLabelText("Message input")).toHaveValue(""); + }); + + it("forks from completed assistant replies without pre-filling the assistant text", async () => { + const client = makeClient(); + const onForkChat = vi.fn().mockResolvedValue("chat-fork"); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Achat-a/webui-thread")) { + return httpJson(transcriptFromSimpleMessages([ + { role: "user", content: "round1" }, + { role: "assistant", content: "answer1" }, + ])); + } + if (url.includes("websocket%3Achat-fork/webui-thread")) { + return httpJson(transcriptFromSimpleMessages([ + { role: "user", content: "round1" }, + { role: "assistant", content: "answer1" }, + ])); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + const { rerender } = render( + wrap( + client, + {}} + onForkChat={onForkChat} + />, + ), + ); + + await screen.findByText("answer1"); + fireEvent.click(screen.getAllByRole("button", { name: "Fork from here" }).at(-1)!); + + await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 1)); + await act(async () => { + rerender( + wrap( + client, + {}} + onForkChat={onForkChat} + />, + ), + ); + }); + + await waitFor(() => expect(screen.getByText("answer1")).toBeInTheDocument()); + expect(screen.getByLabelText("Message input")).toHaveValue(""); + }); + it("does not cache optimistic messages under the next chat during a session switch", async () => { const client = makeClient(); const onNewChat = vi.fn().mockResolvedValue("chat-b"); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 88c5b3ba2..dcec94df5 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -60,6 +60,7 @@ function fakeClient() { }, sendMessage: vi.fn(), newChat: vi.fn(), + forkChat: vi.fn(), attach: vi.fn(), connect: vi.fn(), close: vi.fn(), diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index 1ce200ce9..1d79b4673 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -34,6 +34,7 @@ function fakeClient() { }, sendMessage: vi.fn(), newChat: vi.fn(), + forkChat: vi.fn(), attach: vi.fn(), connect: vi.fn(), close: vi.fn(),