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
{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(),