refactor(webui): shrink fork implementation

This commit is contained in:
Xubin Ren 2026-06-10 02:54:19 +08:00
parent 1f926e3769
commit 916525f94a
24 changed files with 134 additions and 879 deletions

View File

@ -5,37 +5,6 @@ nanobot Python distribution (`pip install nanobot-ai`).
--- ---
## Tabler Icons — WebUI fork action icon (MIT)
- **Source**: https://github.com/tabler/tabler-icons
- **Bundled**: inline SVG path for `arrow-fork` in `nanobot/web/dist/assets/index-*.js`
```
The MIT License (MIT)
Copyright (c) 2020-2026 Paweł Kuna
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
---
## KaTeX — math rendering (MIT) ## KaTeX — math rendering (MIT)
- **Source**: https://github.com/KaTeX/KaTeX - **Source**: https://github.com/KaTeX/KaTeX

View File

@ -696,22 +696,23 @@ class WebSocketChannel(BaseChannel):
if forked is None: if forked is None:
await self._send_event(connection, "error", detail="invalid fork source or index") await self._send_event(connection, "error", detail="invalid fork source or index")
return return
fork_id, fork_key = forked
except Exception as exc: except Exception as exc:
self.logger.warning("fork_chat failed: {}", exc) self.logger.warning("fork_chat failed: {}", exc)
await self._send_event(connection, "error", detail="fork_chat_failed") await self._send_event(connection, "error", detail="fork_chat_failed")
return return
scope = self._workspaces.scope_for_session_key(forked.session_key) scope = self._workspaces.scope_for_session_key(fork_key)
self._attach(connection, forked.chat_id) self._attach(connection, fork_id)
await self._send_event(connection, "attached", chat_id=forked.chat_id) await self._send_event(connection, "attached", chat_id=fork_id)
await self._send_event( await self._send_event(
connection, connection,
"session_updated", "session_updated",
chat_id=forked.chat_id, chat_id=fork_id,
scope="metadata", scope="metadata",
workspace_scope=scope.payload(), workspace_scope=scope.payload(),
) )
await self._hydrate_after_subscribe(forked.chat_id) await self._hydrate_after_subscribe(fork_id)
return return
if t == "attach": if t == "attach":
cid = envelope.get("chat_id") cid = envelope.get("chat_id")

View File

@ -1,14 +1,8 @@
"""Helpers for WebUI chat forking. """WebUI chat fork orchestration."""
The WebSocket channel owns transport concerns only. This module owns the
WebUI-specific session/transcript work needed to make a fork look like a normal
chat in both browser WebUI and desktop.
"""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
from nanobot.session.webui_turns import WEBUI_TITLE_METADATA_KEY, clean_generated_title from nanobot.session.webui_turns import WEBUI_TITLE_METADATA_KEY, clean_generated_title
@ -20,25 +14,14 @@ from nanobot.webui.transcript import (
) )
@dataclass(frozen=True)
class WebuiForkResult:
chat_id: str
session_key: str
def create_webui_chat_fork( def create_webui_chat_fork(
session_manager: SessionManager, session_manager: SessionManager,
*, *,
source_chat_id: str, source_chat_id: str,
before_user_index: int, before_user_index: int,
title: str | None = None, title: str | None = None,
) -> WebuiForkResult | None: ) -> tuple[str, str] | None:
"""Create a WebUI chat fork from a completed assistant-turn boundary. """Return ``(chat_id, session_key)`` for a new fork, or ``None`` for bad input."""
Returns ``None`` when the source/index is invalid. Exceptions are reserved
for unexpected I/O or persistence failures and are rolled back before being
re-raised.
"""
new_id = str(uuid.uuid4()) new_id = str(uuid.uuid4())
source_key = f"websocket:{source_chat_id}" source_key = f"websocket:{source_chat_id}"
target_key = f"websocket:{new_id}" target_key = f"websocket:{new_id}"
@ -68,4 +51,4 @@ def create_webui_chat_fork(
delete_webui_transcript(target_key) delete_webui_transcript(target_key)
session_manager.delete_session(target_key) session_manager.delete_session(target_key)
raise raise
return WebuiForkResult(chat_id=new_id, session_key=target_key) return new_id, target_key

View File

@ -286,6 +286,25 @@ def _is_user_transcript_row(row: dict[str, Any]) -> bool:
return row.get("event") == "user" or row.get("role") == "user" return row.get("event") == "user" or row.get("role") == "user"
def _write_transcript_lines(session_key: str, rows: list[dict[str, Any]]) -> None:
path = webui_transcript_path(session_key)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(".jsonl.tmp")
try:
with open(tmp_path, "w", encoding="utf-8") as f:
for row in rows:
raw = json.dumps(row, ensure_ascii=False, separators=(",", ":"))
if len(raw.encode("utf-8")) > _MAX_TRANSCRIPT_FILE_BYTES:
raise ValueError("webui transcript line too large")
f.write(raw + "\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
def fork_transcript_before_user_index( def fork_transcript_before_user_index(
source_key: str, source_key: str,
target_key: str, target_key: str,
@ -324,22 +343,7 @@ def fork_transcript_before_user_index(
if not found_target: if not found_target:
return False return False
path = webui_transcript_path(target_key) _write_transcript_lines(target_key, copied)
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 return True
@ -360,51 +364,29 @@ def write_session_messages_as_transcript(
) -> None: ) -> None:
"""Write a minimal WebUI transcript from already-truncated session messages.""" """Write a minimal WebUI transcript from already-truncated session messages."""
target_chat_id = _chat_id_from_session_key(target_key) target_chat_id = _chat_id_from_session_key(target_key)
path = webui_transcript_path(target_key) rows: list[dict[str, Any]] = []
path.parent.mkdir(parents=True, exist_ok=True) for msg in messages:
tmp_path = path.with_suffix(".jsonl.tmp") role = msg.get("role")
try: content = msg.get("content")
with open(tmp_path, "w", encoding="utf-8") as f: text = content if isinstance(content, str) else ""
for msg in messages: if role == "user":
role = msg.get("role") row: dict[str, Any] = {"event": "user", "chat_id": target_chat_id, "text": text}
content = msg.get("content") media = msg.get("media")
text = content if isinstance(content, str) else "" if isinstance(media, list) and media:
if role == "user": row["media_paths"] = [str(p) for p in media if isinstance(p, str) and p]
row: dict[str, Any] = { for key in ("cli_apps", "mcp_presets"):
"event": "user", value = msg.get(key)
"chat_id": target_chat_id, if isinstance(value, list) and value:
"text": text, row[key] = json.loads(json.dumps(value, ensure_ascii=False))
} elif role == "assistant" and text.strip():
media = msg.get("media") row = {"event": "message", "chat_id": target_chat_id, "text": text}
if isinstance(media, list) and media: media = msg.get("media")
row["media_paths"] = [str(p) for p in media if isinstance(p, str) and p] if isinstance(media, list) and media:
for key in ("cli_apps", "mcp_presets"): row["media"] = [str(p) for p in media if isinstance(p, str) and p]
value = msg.get(key) else:
if isinstance(value, list) and value: continue
row[key] = json.loads(json.dumps(value, ensure_ascii=False)) rows.append(row)
elif role == "assistant": _write_transcript_lines(target_key, rows)
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: def delete_webui_transcript(session_key: str) -> bool:
@ -1411,25 +1393,12 @@ def replay_transcript_to_ui_messages(
return messages return messages
def fork_boundary_message_count( def fork_boundary_message_count(lines: list[dict[str, Any]]) -> int | None:
lines: list[dict[str, Any]],
*,
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
augment_assistant_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
augment_assistant_text: Callable[[str], str] | None = None,
) -> int | None:
"""Return the replayed UI message count before the first fork marker, if any.""" """Return the replayed UI message count before the first fork marker, if any."""
for idx, rec in enumerate(lines): for idx, rec in enumerate(lines):
if rec.get("event") != WEBUI_FORK_MARKER_EVENT: if rec.get("event") != WEBUI_FORK_MARKER_EVENT:
continue continue
return len( return len(replay_transcript_to_ui_messages(lines[:idx]))
replay_transcript_to_ui_messages(
lines[:idx],
augment_user_media=augment_user_media,
augment_assistant_media=augment_assistant_media,
augment_assistant_text=augment_assistant_text,
),
)
return None return None
@ -1446,12 +1415,7 @@ def build_webui_thread_response(
if not lines: if not lines:
return None return None
lines = inject_missing_user_events_from_session(session_key, lines, session_messages) lines = inject_missing_user_events_from_session(session_key, lines, session_messages)
fork_boundary = fork_boundary_message_count( fork_boundary = fork_boundary_message_count(lines)
lines,
augment_user_media=augment_user_media,
augment_assistant_media=augment_assistant_media,
augment_assistant_text=augment_assistant_text,
)
msgs = replay_transcript_to_ui_messages( msgs = replay_transcript_to_ui_messages(
lines, lines,
augment_user_media=augment_user_media, augment_user_media=augment_user_media,

View File

@ -454,34 +454,6 @@ def test_fork_session_before_user_index_copies_only_prefix(tmp_path):
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"] assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
def test_fork_session_from_middle_assistant_reply_keeps_selected_turn(tmp_path):
manager = SessionManager(tmp_path)
source = manager.get_or_create("websocket:source")
source.add_message("user", "round1")
source.add_message("assistant", "answer1")
source.add_message("user", "round2")
source.add_message("assistant", "answer2")
source.add_message("user", "round3 must not appear")
source.add_message("assistant", "answer3 must not appear")
manager.save(source)
forked = manager.fork_session_before_user_index(
"websocket:source",
"websocket:fork",
2,
)
assert forked is not None
assert [m["content"] for m in forked.messages] == [
"round1",
"answer1",
"round2",
"answer2",
]
saved = manager.read_session_file("websocket:fork")
assert "round3 must not appear" not in str(saved)
def test_fork_session_rejects_negative_missing_and_out_of_range(tmp_path): def test_fork_session_rejects_negative_missing_and_out_of_range(tmp_path):
manager = SessionManager(tmp_path) manager = SessionManager(tmp_path)
source = manager.get_or_create("websocket:source") source = manager.get_or_create("websocket:source")

View File

@ -2398,17 +2398,12 @@ async def test_fork_chat_copies_only_prefix_session_and_transcript(
source.metadata["webui"] = True source.metadata["webui"] = True
source.add_message("user", "round1") source.add_message("user", "round1")
source.add_message("assistant", "answer1") source.add_message("assistant", "answer1")
source.add_message("user", "round2 fork me") source.add_message("user", "future")
source.add_message("assistant", "answer2")
source.add_message("user", "round3 must not appear")
sessions.save(source) sessions.save(source)
for ev in ( for ev in (
{"event": "user", "chat_id": "source", "text": "round1"}, {"event": "user", "chat_id": "source", "text": "round1"},
{"event": "message", "chat_id": "source", "text": "answer1"}, {"event": "message", "chat_id": "source", "text": "answer1"},
{"event": "turn_end", "chat_id": "source"}, {"event": "user", "chat_id": "source", "text": "future"},
{"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) append_transcript_object("websocket:source", ev)
@ -2437,133 +2432,12 @@ async def test_fork_chat_copies_only_prefix_session_and_transcript(
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"] assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
assert saved["metadata"]["title"] == "Fork: Old title" assert saved["metadata"]["title"] == "Fork: Old title"
fork_lines = read_transcript_lines(f"websocket:{fork_id}") fork_lines = read_transcript_lines(f"websocket:{fork_id}")
assert [line.get("text") for line in fork_lines] == ["round1", "answer1", None, None] assert [line.get("text") for line in fork_lines] == ["round1", "answer1", None]
assert fork_lines[-1]["event"] == "fork_marker" assert fork_lines[-1]["event"] == "fork_marker"
assert all(line.get("chat_id") == fork_id for line in fork_lines) assert all(line.get("chat_id") == fork_id for line in fork_lines)
assert "round3 must not appear" not in json.dumps(saved, ensure_ascii=False) assert "future" not in json.dumps(saved, ensure_ascii=False)
bus.publish_inbound.assert_not_awaited() bus.publish_inbound.assert_not_awaited()
@pytest.mark.asyncio
async def test_fork_chat_falls_back_to_session_prefix_when_transcript_lacks_user_rows(
bus: MagicMock,
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sessions = SessionManager(tmp_path / "sessions")
source = sessions.get_or_create("websocket:source")
source.metadata["webui"] = True
source.add_message("user", "round1")
source.add_message("assistant", "answer1")
source.add_message("user", "round2 fork me")
source.add_message("assistant", "answer2")
source.add_message("user", "round3 must not appear")
sessions.save(source)
append_transcript_object(
"websocket:source",
{"event": "message", "chat_id": "source", "text": "answer1"},
)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
conn = AsyncMock()
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "fork_chat", "source_chat_id": "source", "before_user_index": 1},
)
sent = [json.loads(call.args[0]) for call in conn.send.await_args_list]
attached = next(item for item in sent if item["event"] == "attached")
fork_id = attached["chat_id"]
saved = sessions.read_session_file(f"websocket:{fork_id}")
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
fork_lines = read_transcript_lines(f"websocket:{fork_id}")
assert [line.get("text") for line in fork_lines] == ["round1", "answer1", None]
assert fork_lines[-1]["event"] == "fork_marker"
assert "round3 must not appear" not in json.dumps(fork_lines, ensure_ascii=False)
bus.publish_inbound.assert_not_awaited()
@pytest.mark.asyncio
async def test_fork_chat_allows_index_equal_to_user_count(
bus: MagicMock,
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sessions = SessionManager(tmp_path / "sessions")
source = sessions.get_or_create("websocket:source")
source.metadata["webui"] = True
source.add_message("user", "round1")
source.add_message("assistant", "answer1")
sessions.save(source)
append_transcript_object("websocket:source", {"event": "user", "chat_id": "source", "text": "round1"})
append_transcript_object(
"websocket:source",
{"event": "message", "chat_id": "source", "text": "answer1"},
)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
conn = AsyncMock()
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "fork_chat", "source_chat_id": "source", "before_user_index": 1},
)
sent = [json.loads(call.args[0]) for call in conn.send.await_args_list]
attached = next(item for item in sent if item["event"] == "attached")
fork_id = attached["chat_id"]
saved = sessions.read_session_file(f"websocket:{fork_id}")
assert [m["content"] for m in saved["messages"]] == ["round1", "answer1"]
fork_lines = read_transcript_lines(f"websocket:{fork_id}")
assert [line.get("text") for line in fork_lines] == ["round1", "answer1", None]
assert fork_lines[-1]["event"] == "fork_marker"
bus.publish_inbound.assert_not_awaited()
@pytest.mark.asyncio
async def test_fork_chat_rejects_invalid_source_and_index(bus: MagicMock, tmp_path) -> None:
sessions = SessionManager(tmp_path / "sessions")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
conn = AsyncMock()
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "fork_chat", "source_chat_id": "bad/source", "before_user_index": 0},
)
payload = json.loads(conn.send.await_args.args[0])
assert payload["event"] == "error"
assert payload["detail"] == "invalid source_chat_id"
conn.reset_mock()
await channel._dispatch_envelope(
conn,
"webui-client",
{"type": "fork_chat", "source_chat_id": "missing", "before_user_index": -1},
)
payload = json.loads(conn.send.await_args.args[0])
assert payload["event"] == "error"
assert payload["detail"] == "invalid before_user_index"
bus.publish_inbound.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_webui_message_envelope_appends_user_transcript( async def test_webui_message_envelope_appends_user_transcript(
bus: MagicMock, bus: MagicMock,

View File

@ -46,33 +46,6 @@ def test_fork_transcript_before_user_index_copies_only_prefix(tmp_path, monkeypa
assert "round3 must not appear" not in "\n".join(str(line.get("text")) for line in lines) assert "round3 must not appear" not in "\n".join(str(line.get("text")) for line in lines)
def test_fork_transcript_from_middle_assistant_reply_keeps_selected_turn(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
source = "websocket:source"
for ev in (
{"event": "user", "chat_id": "source", "text": "round1"},
{"event": "message", "chat_id": "source", "text": "answer1"},
{"event": "user", "chat_id": "source", "text": "round2"},
{"event": "message", "chat_id": "source", "text": "answer2"},
{"event": "user", "chat_id": "source", "text": "round3 must not appear"},
{"event": "message", "chat_id": "source", "text": "answer3 must not appear"},
):
append_transcript_object(source, ev)
ok = fork_transcript_before_user_index(source, "websocket:fork", 2)
assert ok is True
assert [line.get("text") for line in read_transcript_lines("websocket:fork")] == [
"round1",
"answer1",
"round2",
"answer2",
]
def test_fork_transcript_rejects_out_of_range_user_index(tmp_path, monkeypatch) -> None: 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) monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
source = "websocket:source" source = "websocket:source"
@ -82,24 +55,6 @@ def test_fork_transcript_rejects_out_of_range_user_index(tmp_path, monkeypatch)
assert read_transcript_lines("websocket:fork") == [] assert read_transcript_lines("websocket:fork") == []
def test_fork_transcript_allows_index_equal_to_user_count(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
source = "websocket:source"
for ev in (
{"event": "user", "chat_id": "source", "text": "round1"},
{"event": "message", "chat_id": "source", "text": "answer1"},
):
append_transcript_object(source, ev)
ok = fork_transcript_before_user_index(source, "websocket:fork", 1)
assert ok is True
assert [line.get("text") for line in read_transcript_lines("websocket:fork")] == [
"round1",
"answer1",
]
def test_build_response_reports_fork_boundary_from_marker(tmp_path, monkeypatch) -> None: def test_build_response_reports_fork_boundary_from_marker(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:fork" key = "websocket:fork"

View File

@ -5,13 +5,13 @@ import {
useRef, useRef,
useState, useState,
type ReactNode, type ReactNode,
type SVGProps,
} from "react"; } from "react";
import { import {
Check, Check,
ChevronRight, ChevronRight,
Clock3, Clock3,
Copy, Copy,
GitFork,
ImageIcon, ImageIcon,
Sparkles, Sparkles,
Wrench, Wrench,
@ -22,12 +22,6 @@ import { AttachmentTile } from "@/components/AttachmentTile";
import { CliAppMentionText } from "@/components/CliAppMentionText"; import { CliAppMentionText } from "@/components/CliAppMentionText";
import { ImageLightbox } from "@/components/ImageLightbox"; import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText"; import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard"; import { copyTextToClipboard } from "@/lib/clipboard";
import { formatTurnLatency } from "@/lib/format"; import { formatTurnLatency } from "@/lib/format";
@ -90,7 +84,7 @@ export function MessageBubble({
}; };
}, []); }, []);
const onCopyMessage = useCallback(() => { const onCopyAssistantReply = useCallback(() => {
void copyTextToClipboard(message.content).then((ok) => { void copyTextToClipboard(message.content).then((ok) => {
if (!ok) return; if (!ok) return;
setCopied(true); setCopied(true);
@ -114,11 +108,6 @@ export function MessageBubble({
const hasImages = images.length > 0; const hasImages = images.length > 0;
const hasMedia = media.length > 0; const hasMedia = media.length > 0;
const hasText = message.content.trim().length > 0; const hasText = message.content.trim().length > 0;
const showUserActions = hasText;
const timeLabel = formatMessageClock(message.createdAt);
const copyLabel = copied
? t("message.copiedMessage", { defaultValue: "Copied" })
: t("message.copyMessage", { defaultValue: "Copy" });
return ( return (
<div <div
className={cn( className={cn(
@ -144,43 +133,6 @@ export function MessageBubble({
/> />
</p> </p>
) : null} ) : 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> </div>
); );
} }
@ -235,54 +187,50 @@ export function MessageBubble({
</MarkdownText> </MarkdownText>
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null} {media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantFooterRow ? ( {showAssistantFooterRow ? (
<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">
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground"> {showCopyButton ? (
{showCopyButton ? ( <button
<MessageActionTooltip label={copyReplyLabel}> type="button"
<button onClick={onCopyAssistantReply}
type="button" aria-label={copyReplyLabel}
onClick={onCopyMessage} title={copyReplyLabel}
aria-label={copyReplyLabel} className={cn(
className={cn( "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full", "transition-colors hover:bg-muted/55 hover:text-foreground",
"transition-colors hover:bg-muted/55 hover:text-foreground", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", )}
)} >
> {copied ? (
{copied ? ( <Check className="h-4 w-4" aria-hidden />
<Check className="h-4 w-4" aria-hidden /> ) : (
) : ( <Copy className="h-4 w-4" aria-hidden />
<Copy className="h-4 w-4" aria-hidden /> )}
)} </button>
</button> ) : null}
</MessageActionTooltip> {showForkButton ? (
) : null} <button
{showForkButton ? ( type="button"
<MessageActionTooltip label={forkLabel}> onClick={onForkFromHere}
<button aria-label={forkLabel}
type="button" title={forkLabel}
onClick={onForkFromHere} className={cn(
aria-label={forkLabel} "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
className={cn( "transition-colors hover:bg-muted/55 hover:text-foreground",
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"transition-colors hover:bg-muted/55 hover:text-foreground", )}
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", >
)} <GitFork className="h-4 w-4" aria-hidden />
> </button>
<ForkFromHereIcon className="h-4 w-4" aria-hidden /> ) : null}
</button> {showLatencyFooter ? (
</MessageActionTooltip> <span
) : null} className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
{showLatencyFooter ? ( title={t("message.turnLatencyTitle")}
<span >
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums" {formatTurnLatency(latencyMs)}
title={t("message.turnLatencyTitle")} </span>
> ) : null}
{formatTurnLatency(latencyMs)} </div>
</span>
) : null}
</div>
</TooltipProvider>
) : null} ) : null}
</> </>
)} )}
@ -290,27 +238,6 @@ 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 }) { function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) {
return ( return (
<div <div
@ -330,39 +257,6 @@ 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( function mergeMcpMentionPresets(
presets: McpPresetInfo[], presets: McpPresetInfo[],
attachments: UIMcpPresetAttachment[] | undefined, attachments: UIMcpPresetAttachment[] | undefined,

View File

@ -172,7 +172,6 @@ interface ThreadComposerProps {
workspaceError?: string | null; workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void; onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
pendingQueueKey?: string | null; pendingQueueKey?: string | null;
externalError?: string | null;
} }
const COMMAND_ICONS: Record<string, LucideIcon> = { const COMMAND_ICONS: Record<string, LucideIcon> = {
@ -766,7 +765,6 @@ export function ThreadComposer({
workspaceError = null, workspaceError = null,
onWorkspaceScopeChange, onWorkspaceScopeChange,
pendingQueueKey = null, pendingQueueKey = null,
externalError = null,
}: ThreadComposerProps) { }: ThreadComposerProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [value, setValue] = useState(""); const [value, setValue] = useState("");
@ -1149,10 +1147,6 @@ export function ThreadComposer({
}); });
}, [clear, pendingQueueKey]); }, [clear, pendingQueueKey]);
useEffect(() => {
if (externalError) setInlineError(externalError);
}, [externalError]);
const appendTranscription = useCallback((text: string) => { const appendTranscription = useCallback((text: string) => {
const transcript = text.trim(); const transcript = text.trim();
if (!transcript) return; if (!transcript) return;

View File

@ -8,10 +8,10 @@ import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
interface ThreadMessagesProps { interface ThreadMessagesProps {
messages: UIMessage[]; messages: UIMessage[];
allMessages?: UIMessage[];
/** When true, agent turn still in flight — keeps activity timeline expanded. */ /** When true, agent turn still in flight — keeps activity timeline expanded. */
isStreaming?: boolean; isStreaming?: boolean;
hiddenMessageCount?: number; hiddenMessageCount?: number;
hiddenUserMessageCount?: number;
onLoadEarlier?: () => void; onLoadEarlier?: () => void;
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
@ -65,9 +65,9 @@ export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
export function ThreadMessages({ export function ThreadMessages({
messages, messages,
allMessages,
isStreaming = false, isStreaming = false,
hiddenMessageCount = 0, hiddenMessageCount = 0,
hiddenUserMessageCount = 0,
onLoadEarlier, onLoadEarlier,
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
@ -81,15 +81,12 @@ export function ThreadMessages({
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount), () => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
[forkBoundaryMessageCount, units], [forkBoundaryMessageCount, units],
); );
const assistantForkIndexById = useMemo(
() => assistantForkIndexByMessageId(allMessages ?? messages),
[allMessages, messages],
);
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]); const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
const liveActivityClusterIndices = useMemo( const liveActivityClusterIndices = useMemo(
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(), () => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
[isStreaming, units], [isStreaming, units],
); );
let nextUserIndex = hiddenUserMessageCount;
return ( return (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
@ -123,6 +120,11 @@ export function ThreadMessages({
unit.type === "message" && unit.message.role === "user" unit.type === "message" && unit.message.role === "user"
? unit.message.id ? unit.message.id
: undefined; : undefined;
const forkIndex =
unit.type === "message" && unit.message.role === "assistant" && copyFlags[index]
? nextUserIndex
: undefined;
if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1;
return ( return (
<Fragment key={unitKey(unit, index)}> <Fragment key={unitKey(unit, index)}>
@ -149,20 +151,15 @@ export function ThreadMessages({
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview} onOpenFilePreview={onOpenFilePreview}
onForkFromHere={ onForkFromHere={
onForkFromMessage onForkFromMessage && forkIndex !== undefined
? forkHandlerForAssistantMessage( ? () => onForkFromMessage(forkIndex)
unit.message,
copyFlags[index],
assistantForkIndexById,
onForkFromMessage,
)
: undefined : undefined
} }
/> />
)} )}
</div> </div>
{index === forkBoundaryAfterUnitIndex ? ( {index === forkBoundaryAfterUnitIndex ? (
<ForkBoundaryDivider label={t("thread.fork.fromHistory")} /> <ForkBoundaryDivider label={t("thread.forkedFromHistory")} />
) : null} ) : null}
</Fragment> </Fragment>
); );
@ -195,34 +192,6 @@ function ForkBoundaryDivider({ label }: { label: string }) {
); );
} }
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> { function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
const indices = new Set<number>(); const indices = new Set<number>();
let markedCurrentActivity = false; let markedCurrentActivity = false;

View File

@ -278,8 +278,6 @@ export function ThreadShell({
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null); const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
const [filePreviewClosing, setFilePreviewClosing] = useState(false); const [filePreviewClosing, setFilePreviewClosing] = useState(false);
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH); 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 shellRef = useRef<HTMLElement | null>(null);
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH); const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
const filePreviewCloseTimerRef = useRef<number | null>(null); const filePreviewCloseTimerRef = useRef<number | null>(null);
@ -288,7 +286,6 @@ export function ThreadShell({
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map()); const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */ /** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null); 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). */ /** Skip one message-cache write right after chatId changes (messages may not match yet). */
const skipLayoutCacheRef = useRef(false); const skipLayoutCacheRef = useRef(false);
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map()); const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
@ -340,12 +337,6 @@ export function ThreadShell({
}; };
}, []); }, []);
useEffect(() => {
if (prevChatIdForComposerRef.current === chatId) return;
prevChatIdForComposerRef.current = chatId;
setForkError(null);
}, [chatId]);
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]); const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading; const showHeroComposer = messages.length === 0 && !loading;
@ -455,12 +446,6 @@ export function ThreadShell({
setMessages(projectWebuiThreadMessages(historical)); setMessages(projectWebuiThreadMessages(historical));
}, [chatId, historical, setMessages]); }, [chatId, historical, setMessages]);
useEffect(() => {
if (!chatId || loading || forkHydratingChatId !== chatId) return;
setForkHydratingChatId(null);
setScrollToBottomSignal((value) => value + 1);
}, [chatId, forkHydratingChatId, loading]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (chatId) { if (chatId) {
const prev = prevChatIdForCacheRef.current; const prev = prevChatIdForCacheRef.current;
@ -539,7 +524,6 @@ export function ThreadShell({
const handleThreadSend = useCallback( const handleThreadSend = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => { (content: string, images?: SendImage[], options?: SendOptions) => {
setForkError(null);
setScrollToBottomSignal((value) => value + 1); setScrollToBottomSignal((value) => value + 1);
send(content, images, withWorkspaceScope(options)); send(content, images, withWorkspaceScope(options));
}, },
@ -637,21 +621,13 @@ export function ThreadShell({
const handleForkFromMessage = useCallback( const handleForkFromMessage = useCallback(
async (beforeUserIndex: number) => { async (beforeUserIndex: number) => {
if (!chatId || !onForkChat) return; if (!chatId || !onForkChat) return;
setForkError(null);
const forkedChatId = await onForkChat(chatId, beforeUserIndex); const forkedChatId = await onForkChat(chatId, beforeUserIndex);
if (!forkedChatId) { if (!forkedChatId) return;
setForkError(t("thread.fork.failed", {
defaultValue: "Could not fork this chat. Try again.",
}));
return;
}
messageCacheRef.current.delete(forkedChatId); messageCacheRef.current.delete(forkedChatId);
appliedHistoryVersionRef.current.delete(forkedChatId); appliedHistoryVersionRef.current.delete(forkedChatId);
pendingCanonicalHydrateRef.current.add(forkedChatId); pendingCanonicalHydrateRef.current.add(forkedChatId);
setForkHydratingChatId(forkedChatId);
setForkError(null);
}, },
[chatId, onForkChat, t], [chatId, onForkChat],
); );
const composer = ( const composer = (
@ -665,7 +641,7 @@ export function ThreadShell({
{session ? ( {session ? (
<ThreadComposer <ThreadComposer
onSend={handleThreadSend} onSend={handleThreadSend}
disabled={!chatId || forkHydratingChatId === chatId} disabled={!chatId}
isStreaming={isStreaming} isStreaming={isStreaming}
placeholder={ placeholder={
showHeroComposer showHeroComposer
@ -692,7 +668,6 @@ export function ThreadShell({
workspaceError={workspaceError} workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange} onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={chatId} pendingQueueKey={chatId}
externalError={forkError}
/> />
) : ( ) : (
<ThreadComposer <ThreadComposer
@ -776,7 +751,6 @@ export function ThreadShell({
showScrollToBottomButton={!!session} showScrollToBottomButton={!!session}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
allMessages={displayMessages}
forkBoundaryMessageCount={forkBoundaryMessageCount} forkBoundaryMessageCount={forkBoundaryMessageCount}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined} onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined} onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}

View File

@ -29,7 +29,6 @@ export interface ThreadViewportHandle {
interface ThreadViewportProps { interface ThreadViewportProps {
messages: UIMessage[]; messages: UIMessage[];
allMessages?: UIMessage[];
isStreaming: boolean; isStreaming: boolean;
composer: ReactNode; composer: ReactNode;
emptyState?: ReactNode; emptyState?: ReactNode;
@ -64,7 +63,6 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
messages, messages,
allMessages,
isStreaming, isStreaming,
composer, composer,
emptyState, emptyState,
@ -100,6 +98,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
[messages, visibleMessageCount], [messages, visibleMessageCount],
); );
const hiddenMessageCount = messages.length - visibleMessages.length; const hiddenMessageCount = messages.length - visibleMessages.length;
const hiddenUserMessageCount =
hiddenMessageCount > 0
? messages.slice(0, hiddenMessageCount).filter((message) => message.role === "user").length
: 0;
const visibleForkBoundaryMessageCount = const visibleForkBoundaryMessageCount =
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
? forkBoundaryMessageCount - hiddenMessageCount ? forkBoundaryMessageCount - hiddenMessageCount
@ -299,9 +301,9 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<div className="mx-auto w-full max-w-[49.5rem]"> <div className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages <ThreadMessages
messages={visibleMessages} messages={visibleMessages}
allMessages={allMessages ?? messages}
isStreaming={isStreaming} isStreaming={isStreaming}
hiddenMessageCount={hiddenMessageCount} hiddenMessageCount={hiddenMessageCount}
hiddenUserMessageCount={hiddenUserMessageCount}
onLoadEarlier={loadEarlierMessages} onLoadEarlier={loadEarlierMessages}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}

View File

@ -811,10 +811,7 @@
}, },
"scrollToBottom": "Scroll to bottom", "scrollToBottom": "Scroll to bottom",
"loadEarlier": "Load earlier messages", "loadEarlier": "Load earlier messages",
"fork": { "forkedFromHistory": "Forked from history",
"failed": "Could not fork this chat. Try again.",
"fromHistory": "Forked from history"
},
"promptNavigator": { "promptNavigator": {
"open": "Open prompt navigator", "open": "Open prompt navigator",
"title": "Prompts", "title": "Prompts",
@ -854,8 +851,6 @@
"imageAttachment": "Image attachment", "imageAttachment": "Image attachment",
"automationSourceFallback": "Automation", "automationSourceFallback": "Automation",
"automationTriggered": "Triggered automatically", "automationTriggered": "Triggered automatically",
"copyMessage": "Copy",
"copiedMessage": "Copied",
"forkFromHere": "Fork", "forkFromHere": "Fork",
"copyReply": "Copy", "copyReply": "Copy",
"copiedReply": "Copied", "copiedReply": "Copied",

View File

@ -811,10 +811,7 @@
}, },
"scrollToBottom": "Desplazarse al final", "scrollToBottom": "Desplazarse al final",
"loadEarlier": "Cargar mensajes anteriores", "loadEarlier": "Cargar mensajes anteriores",
"fork": { "forkedFromHistory": "Bifurcado desde el historial",
"failed": "No se pudo bifurcar este chat. Inténtalo de nuevo.",
"fromHistory": "Bifurcado desde el historial"
},
"promptNavigator": { "promptNavigator": {
"open": "Abrir navegador de prompts", "open": "Abrir navegador de prompts",
"title": "Prompts", "title": "Prompts",
@ -840,8 +837,6 @@
"agentActivityLiveSummary": "En curso… · {{reasoning}} pasos · {{tools}} llamadas a herramientas", "agentActivityLiveSummary": "En curso… · {{reasoning}} pasos · {{tools}} llamadas a herramientas",
"agentActivityLiveToolsOnly": "En curso… · {{tools}} llamadas a herramientas", "agentActivityLiveToolsOnly": "En curso… · {{tools}} llamadas a herramientas",
"imageAttachment": "Imagen adjunta", "imageAttachment": "Imagen adjunta",
"copyMessage": "Copiar",
"copiedMessage": "Copiado",
"forkFromHere": "Bifurcar", "forkFromHere": "Bifurcar",
"copyReply": "Copiar", "copyReply": "Copiar",
"copiedReply": "Copiado", "copiedReply": "Copiado",

View File

@ -811,10 +811,7 @@
}, },
"scrollToBottom": "Faire défiler vers le bas", "scrollToBottom": "Faire défiler vers le bas",
"loadEarlier": "Charger les messages précédents", "loadEarlier": "Charger les messages précédents",
"fork": { "forkedFromHistory": "Bifurqué depuis l'historique",
"failed": "Impossible de bifurquer cette conversation. Réessayez.",
"fromHistory": "Bifurqué depuis l'historique"
},
"promptNavigator": { "promptNavigator": {
"open": "Ouvrir le navigateur de prompts", "open": "Ouvrir le navigateur de prompts",
"title": "Prompts", "title": "Prompts",
@ -840,8 +837,6 @@
"agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels doutils", "agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels doutils",
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels doutils", "agentActivityLiveToolsOnly": "En cours… · {{tools}} appels doutils",
"imageAttachment": "Pièce jointe image", "imageAttachment": "Pièce jointe image",
"copyMessage": "Copier",
"copiedMessage": "Copié",
"forkFromHere": "Bifurquer", "forkFromHere": "Bifurquer",
"copyReply": "Copier", "copyReply": "Copier",
"copiedReply": "Copié", "copiedReply": "Copié",

View File

@ -811,10 +811,7 @@
}, },
"scrollToBottom": "Gulir ke bawah", "scrollToBottom": "Gulir ke bawah",
"loadEarlier": "Muat pesan sebelumnya", "loadEarlier": "Muat pesan sebelumnya",
"fork": { "forkedFromHistory": "Fork dari riwayat",
"failed": "Tidak dapat mem-fork chat ini. Coba lagi.",
"fromHistory": "Fork dari riwayat"
},
"promptNavigator": { "promptNavigator": {
"open": "Buka navigator prompt", "open": "Buka navigator prompt",
"title": "Prompt", "title": "Prompt",
@ -840,8 +837,6 @@
"agentActivityLiveSummary": "Berjalan… · {{reasoning}} langkah · {{tools}} panggilan alat", "agentActivityLiveSummary": "Berjalan… · {{reasoning}} langkah · {{tools}} panggilan alat",
"agentActivityLiveToolsOnly": "Berjalan… · {{tools}} panggilan alat", "agentActivityLiveToolsOnly": "Berjalan… · {{tools}} panggilan alat",
"imageAttachment": "Lampiran gambar", "imageAttachment": "Lampiran gambar",
"copyMessage": "Salin",
"copiedMessage": "Disalin",
"forkFromHere": "Fork", "forkFromHere": "Fork",
"copyReply": "Salin", "copyReply": "Salin",
"copiedReply": "Disalin", "copiedReply": "Disalin",

View File

@ -811,10 +811,7 @@
}, },
"scrollToBottom": "一番下へスクロール", "scrollToBottom": "一番下へスクロール",
"loadEarlier": "以前のメッセージを読み込む", "loadEarlier": "以前のメッセージを読み込む",
"fork": { "forkedFromHistory": "履歴から分岐",
"failed": "このチャットを分岐できませんでした。もう一度お試しください。",
"fromHistory": "履歴から分岐"
},
"promptNavigator": { "promptNavigator": {
"open": "プロンプトナビゲーターを開く", "open": "プロンプトナビゲーターを開く",
"title": "プロンプト", "title": "プロンプト",
@ -840,8 +837,6 @@
"agentActivityLiveSummary": "実行中… · {{reasoning}} ステップ · ツール呼び出し {{tools}} 回", "agentActivityLiveSummary": "実行中… · {{reasoning}} ステップ · ツール呼び出し {{tools}} 回",
"agentActivityLiveToolsOnly": "実行中… · ツール呼び出し {{tools}} 回", "agentActivityLiveToolsOnly": "実行中… · ツール呼び出し {{tools}} 回",
"imageAttachment": "画像の添付", "imageAttachment": "画像の添付",
"copyMessage": "コピー",
"copiedMessage": "コピー済み",
"forkFromHere": "分岐", "forkFromHere": "分岐",
"copyReply": "コピー", "copyReply": "コピー",
"copiedReply": "コピー済み", "copiedReply": "コピー済み",

View File

@ -811,10 +811,7 @@
}, },
"scrollToBottom": "맨 아래로 스크롤", "scrollToBottom": "맨 아래로 스크롤",
"loadEarlier": "이전 메시지 불러오기", "loadEarlier": "이전 메시지 불러오기",
"fork": { "forkedFromHistory": "기록에서 분기됨",
"failed": "이 채팅을 분기할 수 없습니다. 다시 시도해 주세요.",
"fromHistory": "기록에서 분기됨"
},
"promptNavigator": { "promptNavigator": {
"open": "프롬프트 탐색기 열기", "open": "프롬프트 탐색기 열기",
"title": "프롬프트", "title": "프롬프트",
@ -840,8 +837,6 @@
"agentActivityLiveSummary": "진행 중… · {{reasoning}}단계 · 도구 호출 {{tools}}회", "agentActivityLiveSummary": "진행 중… · {{reasoning}}단계 · 도구 호출 {{tools}}회",
"agentActivityLiveToolsOnly": "진행 중… · 도구 호출 {{tools}}회", "agentActivityLiveToolsOnly": "진행 중… · 도구 호출 {{tools}}회",
"imageAttachment": "이미지 첨부", "imageAttachment": "이미지 첨부",
"copyMessage": "복사",
"copiedMessage": "복사됨",
"forkFromHere": "분기", "forkFromHere": "분기",
"copyReply": "복사", "copyReply": "복사",
"copiedReply": "복사됨", "copiedReply": "복사됨",

View File

@ -811,10 +811,7 @@
}, },
"scrollToBottom": "Cuộn xuống cuối", "scrollToBottom": "Cuộn xuống cuối",
"loadEarlier": "Tải tin nhắn trước đó", "loadEarlier": "Tải tin nhắn trước đó",
"fork": { "forkedFromHistory": "Tách nhánh từ lịch sử",
"failed": "Không thể rẽ nhánh cuộc trò chuyện này. Hãy thử lại.",
"fromHistory": "Tách nhánh từ lịch sử"
},
"promptNavigator": { "promptNavigator": {
"open": "Mở trình điều hướng prompt", "open": "Mở trình điều hướng prompt",
"title": "Prompt", "title": "Prompt",
@ -840,8 +837,6 @@
"agentActivityLiveSummary": "Đang chạy… · {{reasoning}} bước · {{tools}} lần gọi công cụ", "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ụ", "agentActivityLiveToolsOnly": "Đang chạy… · {{tools}} lần gọi công cụ",
"imageAttachment": "Tệp hình ảnh đính kèm", "imageAttachment": "Tệp hình ảnh đính kèm",
"copyMessage": "Sao chép",
"copiedMessage": "Đã sao chép",
"forkFromHere": "Tách nhánh", "forkFromHere": "Tách nhánh",
"copyReply": "Sao chép", "copyReply": "Sao chép",
"copiedReply": "Đã sao chép", "copiedReply": "Đã sao chép",

View File

@ -811,10 +811,7 @@
}, },
"scrollToBottom": "滚动到底部", "scrollToBottom": "滚动到底部",
"loadEarlier": "加载更早消息", "loadEarlier": "加载更早消息",
"fork": { "forkedFromHistory": "从历史消息分叉",
"failed": "无法分叉这个对话,请重试。",
"fromHistory": "从历史消息分叉"
},
"promptNavigator": { "promptNavigator": {
"open": "打开输入导航", "open": "打开输入导航",
"title": "输入列表", "title": "输入列表",
@ -854,8 +851,6 @@
"imageAttachment": "图片附件", "imageAttachment": "图片附件",
"automationSourceFallback": "自动化", "automationSourceFallback": "自动化",
"automationTriggered": "自动触发", "automationTriggered": "自动触发",
"copyMessage": "复制",
"copiedMessage": "已复制",
"forkFromHere": "分叉", "forkFromHere": "分叉",
"copyReply": "复制", "copyReply": "复制",
"copiedReply": "已复制", "copiedReply": "已复制",

View File

@ -811,10 +811,7 @@
}, },
"scrollToBottom": "捲動到底部", "scrollToBottom": "捲動到底部",
"loadEarlier": "載入更早訊息", "loadEarlier": "載入更早訊息",
"fork": { "forkedFromHistory": "從歷史訊息分叉",
"failed": "無法分叉這個對話,請重試。",
"fromHistory": "從歷史訊息分叉"
},
"promptNavigator": { "promptNavigator": {
"open": "開啟輸入導覽", "open": "開啟輸入導覽",
"title": "輸入列表", "title": "輸入列表",
@ -840,8 +837,6 @@
"agentActivityLiveSummary": "進行中… · {{reasoning}} 步 · {{tools}} 次工具呼叫", "agentActivityLiveSummary": "進行中… · {{reasoning}} 步 · {{tools}} 次工具呼叫",
"agentActivityLiveToolsOnly": "進行中… · {{tools}} 次工具呼叫", "agentActivityLiveToolsOnly": "進行中… · {{tools}} 次工具呼叫",
"imageAttachment": "圖片附件", "imageAttachment": "圖片附件",
"copyMessage": "複製",
"copiedMessage": "已複製",
"forkFromHere": "分叉", "forkFromHere": "分叉",
"copyReply": "複製", "copyReply": "複製",
"copiedReply": "已複製", "copiedReply": "已複製",

View File

@ -76,22 +76,6 @@ describe("MessageBubble", () => {
expect(row).toHaveClass("ml-auto", "flex"); expect(row).toHaveClass("ml-auto", "flex");
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]"); expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Fork" })).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" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
}); });

View File

@ -766,223 +766,6 @@ describe("ThreadShell", () => {
); );
}); });
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",
}));
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",
}));
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" }).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 () => { it("does not cache optimistic messages under the next chat during a session switch", async () => {
const client = makeClient(); const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-b"); const onNewChat = vi.fn().mockResolvedValue("chat-b");

View File

@ -230,24 +230,6 @@ describe("useSessions", () => {
expect(result.current.sessions[0]?.workspaceScope).toEqual(workspaceScope); expect(result.current.sessions[0]?.workspaceScope).toEqual(workspaceScope);
}); });
it("keeps a fork title visible while the server session list catches up", async () => {
vi.mocked(api.listSessions).mockResolvedValue([]);
const client = fakeClient();
client.forkChat.mockResolvedValue("chat-fork");
const { result } = renderHook(() => useSessions(), {
wrapper: wrap(client),
});
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.forkChat("source", 2, "Fork: Original title");
});
expect(client.forkChat).toHaveBeenCalledWith("source", 2, "Fork: Original title");
expect(result.current.sessions[0]?.title).toBe("Fork: Original title");
});
it("passes through WebUI transcript user media as images and media", async () => { it("passes through WebUI transcript user media as images and media", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({ vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3, schemaVersion: 3,