feat(webui): add assistant reply fork-from-here

This commit is contained in:
Bayern4ever-dot 2026-06-05 19:49:34 +08:00 committed by Xubin Ren
parent 4a58b83acc
commit 03bca4c0a9
30 changed files with 1358 additions and 36 deletions

View File

@ -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

View File

@ -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):

View File

@ -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.

View File

@ -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():

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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<ShellRoute | null>(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}

View File

@ -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 (
<div
className={cn(
@ -122,6 +144,43 @@ export function MessageBubble({
/>
</p>
) : null}
{showUserActions ? (
<TooltipProvider delayDuration={180} skipDelayDuration={80}>
<div
className={cn(
"mt-0.5 flex h-8 items-center justify-end gap-1 self-end",
"text-[13px] text-muted-foreground/65 opacity-0 transition-opacity duration-150",
"group-focus-within:opacity-100 group-hover:opacity-100",
)}
>
{hasText ? (
<MessageActionTooltip label={copyLabel}>
<button
type="button"
onClick={onCopyMessage}
aria-label={copyLabel}
className={cn(
"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{copied ? (
<Check className="h-3.5 w-3.5" aria-hidden />
) : (
<Copy className="h-3.5 w-3.5" aria-hidden />
)}
</button>
</MessageActionTooltip>
) : null}
{timeLabel ? (
<span className="ml-1 shrink-0 select-none tabular-nums" title={timeLabel}>
{timeLabel}
</span>
) : null}
</div>
</TooltipProvider>
) : null}
</div>
);
}
@ -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 (
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{hasReasoning ? (
@ -173,35 +235,54 @@ export function MessageBubble({
</MarkdownText>
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantFooterRow ? (
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
{showCopyButton ? (
<button
type="button"
onClick={onCopyAssistantReply}
aria-label={copied ? t("message.copiedReply") : t("message.copyReply")}
title={copied ? t("message.copiedReply") : t("message.copyReply")}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
) : null}
{showLatencyFooter ? (
<span
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
title={t("message.turnLatencyTitle")}
>
{formatTurnLatency(latencyMs)}
</span>
) : null}
</div>
<TooltipProvider delayDuration={180} skipDelayDuration={80}>
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
{showCopyButton ? (
<MessageActionTooltip label={copyReplyLabel}>
<button
type="button"
onClick={onCopyMessage}
aria-label={copyReplyLabel}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
</MessageActionTooltip>
) : null}
{showForkButton ? (
<MessageActionTooltip label={forkLabel}>
<button
type="button"
onClick={onForkFromHere}
aria-label={forkLabel}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<ForkFromHereIcon className="h-4 w-4" aria-hidden />
</button>
</MessageActionTooltip>
) : null}
{showLatencyFooter ? (
<span
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
title={t("message.turnLatencyTitle")}
>
{formatTurnLatency(latencyMs)}
</span>
) : null}
</div>
</TooltipProvider>
) : null}
</>
)}
@ -209,6 +290,27 @@ export function MessageBubble({
);
}
function MessageActionTooltip({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side="top"
align="center"
className="rounded-full border-border/70 bg-background px-2.5 py-1 text-[12px] font-medium text-foreground shadow-[0_8px_24px_rgba(15,23,42,0.13)] dark:border-white/10 dark:bg-neutral-900 dark:text-white"
>
{label}
</TooltipContent>
</Tooltip>
);
}
function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) {
return (
<div
@ -228,6 +330,39 @@ function AutomationSourceBadge({ label, triggerLabel }: { label: string; trigger
);
}
function formatMessageClock(createdAt: number): string {
if (!Number.isFinite(createdAt) || createdAt <= 0) return "";
try {
return new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(createdAt));
} catch {
return "";
}
}
function ForkFromHereIcon({ className, ...props }: SVGProps<SVGSVGElement>) {
// Tabler Icons "arrow-fork" (MIT, Copyright Paweł Kuna).
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M16 3h5v5" />
<path d="M8 3h-5v5" />
<path d="M21 3l-7.536 7.536a5 5 0 0 0 -1.464 3.534v6.93" />
<path d="M3 3l7.536 7.536a5 5 0 0 1 1.464 3.534v.93" />
</svg>
);
}
function mergeMcpMentionPresets(
presets: McpPresetInfo[],
attachments: UIMcpPresetAttachment[] | undefined,

View File

@ -172,6 +172,7 @@ interface ThreadComposerProps {
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
pendingQueueKey?: string | null;
externalError?: string | null;
}
const COMMAND_ICONS: Record<string, LucideIcon> = {
@ -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<string, HTMLButtonElement>());
const queuedPromptCounterRef = useRef(0);
const draggedQueuedPromptIdRef = useRef<string | null>(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;

View File

@ -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<number>(),
@ -137,6 +145,16 @@ export function ThreadMessages({
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
onForkFromHere={
onForkFromMessage
? forkHandlerForAssistantMessage(
unit.message,
copyFlags[index],
assistantForkIndexById,
onForkFromMessage,
)
: undefined
}
/>
)}
</div>
@ -146,6 +164,34 @@ export function ThreadMessages({
);
}
function assistantForkIndexByMessageId(messages: UIMessage[]): Map<string, number> {
const out = new Map<string, number>();
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<string, number>,
onForkFromMessage: NonNullable<ThreadMessagesProps["onForkFromMessage"]>,
): (() => 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<number> {
const indices = new Set<number>();
let markedCurrentActivity = false;

View File

@ -77,6 +77,7 @@ interface ThreadShellProps {
onGoHome?: () => void;
onNewChat?: () => void;
onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string | null>;
onForkChat?: (sourceChatId: string, beforeUserIndex: number) => Promise<string | null>;
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<string | null>(null);
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
const [forkError, setForkError] = useState<string | null>(null);
const [forkHydratingChatId, setForkHydratingChatId] = useState<string | null>(null);
const shellRef = useRef<HTMLElement | null>(null);
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
const filePreviewCloseTimerRef = useRef<number | null>(null);
@ -283,6 +287,7 @@ export function ThreadShell({
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null);
const prevChatIdForComposerRef = useRef<string | null>(chatId);
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
const skipLayoutCacheRef = useRef(false);
const appliedHistoryVersionRef = useRef<Map<string, number>>(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 ? (
<ThreadComposer
onSend={handleThreadSend}
disabled={!chatId}
disabled={!chatId || forkHydratingChatId === chatId}
isStreaming={isStreaming}
placeholder={
showHeroComposer
@ -653,6 +691,7 @@ export function ThreadShell({
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={chatId}
externalError={forkError}
/>
) : (
<ThreadComposer
@ -736,7 +775,9 @@ export function ThreadShell({
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
allMessages={displayMessages}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
/>
</div>
{filePreviewPath && historyKey ? (

View File

@ -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<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
messages,
allMessages,
isStreaming,
composer,
emptyState,
@ -70,6 +73,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
onForkFromMessage,
}, ref) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
@ -289,12 +293,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<div className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages
messages={visibleMessages}
allMessages={allMessages ?? messages}
isStreaming={isStreaming}
hiddenMessageCount={hiddenMessageCount}
onLoadEarlier={loadEarlierMessages}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
onForkFromMessage={onForkFromMessage}
/>
</div>
</div>

View File

@ -20,6 +20,7 @@ export function useSessions(): {
error: string | null;
refresh: () => Promise<void>;
createChat: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string>;
forkChat: (sourceChatId: string, beforeUserIndex: number) => Promise<string>;
deleteChat: (key: string) => Promise<void>;
} {
const { client, token } = useClient();
@ -88,6 +89,29 @@ export function useSessions(): {
return chatId;
}, [client]);
const forkChat = useCallback(async (
sourceChatId: string,
beforeUserIndex: number,
): Promise<string> => {
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. */

View File

@ -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)"

View File

@ -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)",

View File

@ -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 doutils",
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels doutils",
"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)",

View File

@ -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)",

View File

@ -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": "応答時間(全行程)",

View File

@ -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": "응답 시간(엔드투엔드)",

View File

@ -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)",

View File

@ -810,6 +810,9 @@
},
"scrollToBottom": "滚动到底部",
"loadEarlier": "加载更早消息",
"fork": {
"failed": "无法分叉这个对话,请重试。"
},
"promptNavigator": {
"open": "打开输入导航",
"title": "输入列表",
@ -849,6 +852,9 @@
"imageAttachment": "图片附件",
"automationSourceFallback": "自动化",
"automationTriggered": "自动触发",
"copyMessage": "复制消息",
"copiedMessage": "已复制消息",
"forkFromHere": "从这里分叉",
"copyReply": "复制回复",
"copiedReply": "已复制回复",
"turnLatencyTitle": "本轮耗时(端到端)"

View File

@ -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": "本輪耗時(端到端)",

View File

@ -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<string> {
if (this.pendingNewChat) {
return Promise.reject(new Error("newChat already in flight"));
}
return new Promise<string>((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);

View File

@ -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 }

View File

@ -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));

View File

@ -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(<MessageBubble message={message} onForkFromHere={onForkFromHere} />);
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(<MessageBubble message={message} onForkFromHere={onForkFromHere} />);
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",

View File

@ -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,
<ThreadShell
session={session("long-chat")}
title="Long chat"
onToggleSidebar={() => {}}
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,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
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,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
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,
<ThreadShell
session={session("chat-fork")}
title="Chat chat-fork"
onToggleSidebar={() => {}}
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,
<ThreadShell
session={session("chat-other")}
title="Chat chat-other"
onToggleSidebar={() => {}}
onForkChat={onForkChat}
/>,
),
);
});
await waitFor(() =>
expect(screen.getByLabelText("Message input")).toHaveValue(""),
);
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={null}
title="New chat"
onToggleSidebar={() => {}}
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,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
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,
<ThreadShell
session={session("chat-fork")}
title="Chat chat-fork"
onToggleSidebar={() => {}}
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");

View File

@ -60,6 +60,7 @@ function fakeClient() {
},
sendMessage: vi.fn(),
newChat: vi.fn(),
forkChat: vi.fn(),
attach: vi.fn(),
connect: vi.fn(),
close: vi.fn(),

View File

@ -34,6 +34,7 @@ function fakeClient() {
},
sendMessage: vi.fn(),
newChat: vi.fn(),
forkChat: vi.fn(),
attach: vi.fn(),
connect: vi.fn(),
close: vi.fn(),