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)
- **Source**: https://github.com/KaTeX/KaTeX

View File

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

View File

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

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

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"]
def test_fork_session_from_middle_assistant_reply_keeps_selected_turn(tmp_path):
manager = SessionManager(tmp_path)
source = manager.get_or_create("websocket:source")
source.add_message("user", "round1")
source.add_message("assistant", "answer1")
source.add_message("user", "round2")
source.add_message("assistant", "answer2")
source.add_message("user", "round3 must not appear")
source.add_message("assistant", "answer3 must not appear")
manager.save(source)
forked = manager.fork_session_before_user_index(
"websocket:source",
"websocket:fork",
2,
)
assert forked is not None
assert [m["content"] for m in forked.messages] == [
"round1",
"answer1",
"round2",
"answer2",
]
saved = manager.read_session_file("websocket:fork")
assert "round3 must not appear" not in str(saved)
def test_fork_session_rejects_negative_missing_and_out_of_range(tmp_path):
manager = SessionManager(tmp_path)
source = manager.get_or_create("websocket:source")

View File

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

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

View File

@ -5,13 +5,13 @@ import {
useRef,
useState,
type ReactNode,
type SVGProps,
} from "react";
import {
Check,
ChevronRight,
Clock3,
Copy,
GitFork,
ImageIcon,
Sparkles,
Wrench,
@ -22,12 +22,6 @@ import { AttachmentTile } from "@/components/AttachmentTile";
import { CliAppMentionText } from "@/components/CliAppMentionText";
import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { formatTurnLatency } from "@/lib/format";
@ -90,7 +84,7 @@ export function MessageBubble({
};
}, []);
const onCopyMessage = useCallback(() => {
const onCopyAssistantReply = useCallback(() => {
void copyTextToClipboard(message.content).then((ok) => {
if (!ok) return;
setCopied(true);
@ -114,11 +108,6 @@ export function MessageBubble({
const hasImages = images.length > 0;
const hasMedia = media.length > 0;
const hasText = message.content.trim().length > 0;
const showUserActions = hasText;
const timeLabel = formatMessageClock(message.createdAt);
const copyLabel = copied
? t("message.copiedMessage", { defaultValue: "Copied" })
: t("message.copyMessage", { defaultValue: "Copy" });
return (
<div
className={cn(
@ -144,43 +133,6 @@ 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>
);
}
@ -235,54 +187,50 @@ export function MessageBubble({
</MarkdownText>
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{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">
{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>
<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={copyReplyLabel}
title={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>
) : null}
{showForkButton ? (
<button
type="button"
onClick={onForkFromHere}
aria-label={forkLabel}
title={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",
)}
>
<GitFork 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>
) : 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 }) {
return (
<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(
presets: McpPresetInfo[],
attachments: UIMcpPresetAttachment[] | undefined,

View File

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

View File

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

View File

@ -278,8 +278,6 @@ 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);
@ -288,7 +286,6 @@ 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());
@ -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 showHeroComposer = messages.length === 0 && !loading;
@ -455,12 +446,6 @@ 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;
@ -539,7 +524,6 @@ export function ThreadShell({
const handleThreadSend = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => {
setForkError(null);
setScrollToBottomSignal((value) => value + 1);
send(content, images, withWorkspaceScope(options));
},
@ -637,21 +621,13 @@ export function ThreadShell({
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;
}
if (!forkedChatId) return;
messageCacheRef.current.delete(forkedChatId);
appliedHistoryVersionRef.current.delete(forkedChatId);
pendingCanonicalHydrateRef.current.add(forkedChatId);
setForkHydratingChatId(forkedChatId);
setForkError(null);
},
[chatId, onForkChat, t],
[chatId, onForkChat],
);
const composer = (
@ -665,7 +641,7 @@ export function ThreadShell({
{session ? (
<ThreadComposer
onSend={handleThreadSend}
disabled={!chatId || forkHydratingChatId === chatId}
disabled={!chatId}
isStreaming={isStreaming}
placeholder={
showHeroComposer
@ -692,7 +668,6 @@ export function ThreadShell({
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={chatId}
externalError={forkError}
/>
) : (
<ThreadComposer
@ -776,7 +751,6 @@ export function ThreadShell({
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
allMessages={displayMessages}
forkBoundaryMessageCount={forkBoundaryMessageCount}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -811,10 +811,7 @@
},
"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.",
"fromHistory": "Tách nhánh từ lịch sử"
},
"forkedFromHistory": "Tách nhánh từ lịch sử",
"promptNavigator": {
"open": "Mở trình điều hướng prompt",
"title": "Prompt",
@ -840,8 +837,6 @@
"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",
"copiedMessage": "Đã sao chép",
"forkFromHere": "Tách nhánh",
"copyReply": "Sao chép",
"copiedReply": "Đã sao chép",

View File

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

View File

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

View File

@ -76,22 +76,6 @@ describe("MessageBubble", () => {
expect(row).toHaveClass("ml-auto", "flex");
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();
});

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 () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-b");

View File

@ -230,24 +230,6 @@ describe("useSessions", () => {
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 () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3,