feat(webui): show forked history boundary

This commit is contained in:
Xubin Ren 2026-06-10 02:07:47 +08:00
parent 73d4b1cb2f
commit 26a58282d4
21 changed files with 242 additions and 121 deletions

View File

@ -29,6 +29,7 @@ import {
loadSavedSecret,
saveSecret,
} from "@/lib/bootstrap";
import { displayTitle } from "@/lib/chat-groups";
import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider, useClient } from "@/providers/ClientProvider";
@ -890,7 +891,15 @@ function Shell({
beforeUserIndex: number,
) => {
try {
const chatId = await forkChat(sourceChatId, beforeUserIndex);
const sourceSession = sessions.find((session) => session.chatId === sourceChatId);
const sourceTitle = sourceSession
? displayTitle(sourceSession, sidebarState.title_overrides, t("chat.newChat"))
: t("chat.newChat");
const chatId = await forkChat(
sourceChatId,
beforeUserIndex,
t("chat.forkTitle", { title: sourceTitle }),
);
navigate({
view: "chat",
activeKey: `websocket:${chatId}`,
@ -902,7 +911,7 @@ function Shell({
console.error("Failed to fork chat", e);
return null;
}
}, [forkChat, navigate]);
}, [forkChat, navigate, sessions, sidebarState.title_overrides, t]);
const onNewChat = useCallback(() => {
navigate(defaultShellRoute());

View File

@ -117,8 +117,8 @@ export function MessageBubble({
const showUserActions = hasText;
const timeLabel = formatMessageClock(message.createdAt);
const copyLabel = copied
? t("message.copiedMessage", { defaultValue: "Copied message" })
: t("message.copyMessage", { defaultValue: "Copy message" });
? t("message.copiedMessage", { defaultValue: "Copied" })
: t("message.copyMessage", { defaultValue: "Copy" });
return (
<div
className={cn(

View File

@ -1,4 +1,4 @@
import { useMemo } from "react";
import { Fragment, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble";
@ -15,6 +15,7 @@ interface ThreadMessagesProps {
onLoadEarlier?: () => void;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
forkBoundaryMessageCount?: number | null;
onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void;
}
@ -70,11 +71,16 @@ export function ThreadMessages({
onLoadEarlier,
cliApps = [],
mcpPresets = [],
forkBoundaryMessageCount = null,
onOpenFilePreview,
onForkFromMessage,
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
const forkBoundaryAfterUnitIndex = useMemo(
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
[forkBoundaryMessageCount, units],
);
const assistantForkIndexById = useMemo(
() => assistantForkIndexByMessageId(allMessages ?? messages),
[allMessages, messages],
@ -119,51 +125,76 @@ export function ThreadMessages({
: undefined;
return (
<div
key={unitKey(unit, index)}
className={marginTop}
data-user-prompt-id={userPromptId}
>
{unit.type === "activity" ? (
<AgentActivityCluster
messages={unit.messages}
isTurnStreaming={liveActivityClusterIndices.has(index)}
hasBodyBelow={hasBodyBelow}
turnLatencyMs={unit.turnLatencyMs}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/>
) : (
<MessageBubble
message={unit.message}
showAssistantCopyAction={
unit.message.role === "assistant"
? copyFlags[index]
: true
}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
onForkFromHere={
onForkFromMessage
? forkHandlerForAssistantMessage(
unit.message,
copyFlags[index],
assistantForkIndexById,
onForkFromMessage,
)
: undefined
}
/>
)}
</div>
<Fragment key={unitKey(unit, index)}>
<div className={marginTop} data-user-prompt-id={userPromptId}>
{unit.type === "activity" ? (
<AgentActivityCluster
messages={unit.messages}
isTurnStreaming={liveActivityClusterIndices.has(index)}
hasBodyBelow={hasBodyBelow}
turnLatencyMs={unit.turnLatencyMs}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
/>
) : (
<MessageBubble
message={unit.message}
showAssistantCopyAction={
unit.message.role === "assistant"
? copyFlags[index]
: true
}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
onForkFromHere={
onForkFromMessage
? forkHandlerForAssistantMessage(
unit.message,
copyFlags[index],
assistantForkIndexById,
onForkFromMessage,
)
: undefined
}
/>
)}
</div>
{index === forkBoundaryAfterUnitIndex ? (
<ForkBoundaryDivider label={t("thread.fork.fromHistory")} />
) : null}
</Fragment>
);
})}
</div>
);
}
function unitIndexAfterMessageCount(
units: DisplayUnit[],
messageCount: number | null | undefined,
): number | null {
if (messageCount == null || messageCount <= 0) return null;
let seen = 0;
for (let i = 0; i < units.length; i += 1) {
const unit = units[i];
seen += unit.type === "activity" ? unit.messages.length : 1;
if (seen >= messageCount) return i;
}
return null;
}
function ForkBoundaryDivider({ label }: { label: string }) {
return (
<div className="my-5 flex items-center gap-3 text-[11px] text-muted-foreground/80">
<span aria-hidden className="h-px flex-1 bg-border/70" />
<span className="shrink-0">{label}</span>
<span aria-hidden className="h-px flex-1 bg-border/70" />
</div>
);
}
function assistantForkIndexByMessageId(messages: UIMessage[]): Map<string, number> {
const out = new Map<string, number>();
let nextUserIndex = 0;

View File

@ -253,6 +253,7 @@ export function ThreadShell({
hasPendingToolCalls,
refresh: refreshHistory,
version: historyVersion,
forkBoundaryMessageCount,
} = useSessionHistory(historyKey);
const { client, modelName, token } = useClient();
const [booting, setBooting] = useState(false);
@ -776,6 +777,7 @@ export function ThreadShell({
cliApps={cliApps}
mcpPresets={mcpPresets}
allMessages={displayMessages}
forkBoundaryMessageCount={forkBoundaryMessageCount}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
/>

View File

@ -38,6 +38,7 @@ interface ThreadViewportProps {
showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
forkBoundaryMessageCount?: number | null;
onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void;
}
@ -72,6 +73,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
showScrollToBottomButton = true,
cliApps = [],
mcpPresets = [],
forkBoundaryMessageCount = null,
onOpenFilePreview,
onForkFromMessage,
}, ref) {
@ -98,6 +100,10 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
[messages, visibleMessageCount],
);
const hiddenMessageCount = messages.length - visibleMessages.length;
const visibleForkBoundaryMessageCount =
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
? forkBoundaryMessageCount - hiddenMessageCount
: null;
const scrollButtonBottom = composerDockHeight > 0
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX;
@ -299,6 +305,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
onLoadEarlier={loadEarlierMessages}
cliApps={cliApps}
mcpPresets={mcpPresets}
forkBoundaryMessageCount={visibleForkBoundaryMessageCount}
onOpenFilePreview={onOpenFilePreview}
onForkFromMessage={onForkFromMessage}
/>

View File

@ -20,7 +20,7 @@ export function useSessions(): {
error: string | null;
refresh: () => Promise<void>;
createChat: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string>;
forkChat: (sourceChatId: string, beforeUserIndex: number) => Promise<string>;
forkChat: (sourceChatId: string, beforeUserIndex: number, title?: string) => Promise<string>;
deleteChat: (key: string) => Promise<void>;
} {
const { client, token } = useClient();
@ -92,8 +92,9 @@ export function useSessions(): {
const forkChat = useCallback(async (
sourceChatId: string,
beforeUserIndex: number,
title?: string,
): Promise<string> => {
const chatId = await client.forkChat(sourceChatId, beforeUserIndex);
const chatId = await client.forkChat(sourceChatId, beforeUserIndex, title);
const key = `websocket:${chatId}`;
optimisticKeysRef.current.add(key);
setSessions((prev) => [
@ -103,7 +104,7 @@ export function useSessions(): {
chatId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
title: "",
title: title ?? "",
preview: "",
workspaceScope: null,
},
@ -131,6 +132,7 @@ export function useSessionHistory(key: string | null): {
error: string | null;
refresh: () => void;
version: number;
forkBoundaryMessageCount: number | null;
/** ``true`` when the replayed transcript ends with a trace row (turn still in flight). */
hasPendingToolCalls: boolean;
} {
@ -145,6 +147,7 @@ export function useSessionHistory(key: string | null): {
loading: boolean;
error: string | null;
hasPendingToolCalls: boolean;
forkBoundaryMessageCount: number | null;
version: number;
}>({
key: null,
@ -152,6 +155,7 @@ export function useSessionHistory(key: string | null): {
loading: false,
error: null,
hasPendingToolCalls: false,
forkBoundaryMessageCount: null,
version: 0,
});
@ -163,6 +167,7 @@ export function useSessionHistory(key: string | null): {
loading: false,
error: null,
hasPendingToolCalls: false,
forkBoundaryMessageCount: null,
version: 0,
});
return;
@ -178,6 +183,7 @@ export function useSessionHistory(key: string | null): {
loading: true,
error: null,
hasPendingToolCalls: false,
forkBoundaryMessageCount: null,
version: 0,
});
(async () => {
@ -191,6 +197,7 @@ export function useSessionHistory(key: string | null): {
loading: false,
error: null,
hasPendingToolCalls: false,
forkBoundaryMessageCount: null,
version: prev.key === key ? prev.version + 1 : 1,
}));
return;
@ -202,12 +209,16 @@ export function useSessionHistory(key: string | null): {
}));
const last = ui[ui.length - 1];
const hasPending = last?.kind === "trace";
const forkBoundary = typeof body.fork_boundary_message_count === "number"
? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length))
: null;
setState((prev) => ({
key,
messages: ui,
loading: false,
error: null,
hasPendingToolCalls: hasPending,
forkBoundaryMessageCount: forkBoundary,
version: prev.key === key ? prev.version + 1 : 1,
}));
} catch (e) {
@ -219,6 +230,7 @@ export function useSessionHistory(key: string | null): {
loading: false,
error: null,
hasPendingToolCalls: false,
forkBoundaryMessageCount: null,
version: prev.key === key ? prev.version + 1 : 1,
}));
} else {
@ -228,6 +240,7 @@ export function useSessionHistory(key: string | null): {
loading: false,
error: (e as Error).message,
hasPendingToolCalls: false,
forkBoundaryMessageCount: null,
version: prev.key === key ? prev.version : 0,
}));
}
@ -245,6 +258,7 @@ export function useSessionHistory(key: string | null): {
error: null,
refresh,
version: 0,
forkBoundaryMessageCount: null,
hasPendingToolCalls: false,
};
}
@ -258,6 +272,7 @@ export function useSessionHistory(key: string | null): {
error: null,
refresh,
version: 0,
forkBoundaryMessageCount: null,
hasPendingToolCalls: false,
};
}
@ -268,6 +283,7 @@ export function useSessionHistory(key: string | null): {
error: state.error,
refresh,
version: state.version,
forkBoundaryMessageCount: state.forkBoundaryMessageCount,
hasPendingToolCalls: state.hasPendingToolCalls,
};
}

View File

@ -509,6 +509,7 @@
},
"chat": {
"fallbackTitle": "Chat {{id}}",
"forkTitle": "Fork: {{title}}",
"loading": "Loading…",
"noSessions": "No sessions yet.",
"showMore": "Show {{count}} more",
@ -811,7 +812,8 @@
"scrollToBottom": "Scroll to bottom",
"loadEarlier": "Load earlier messages",
"fork": {
"failed": "Could not fork this chat. Try again."
"failed": "Could not fork this chat. Try again.",
"fromHistory": "Forked from history"
},
"promptNavigator": {
"open": "Open prompt navigator",
@ -852,11 +854,11 @@
"imageAttachment": "Image attachment",
"automationSourceFallback": "Automation",
"automationTriggered": "Triggered automatically",
"copyMessage": "Copy message",
"copiedMessage": "Copied message",
"forkFromHere": "Fork from here",
"copyReply": "Copy reply",
"copiedReply": "Copied reply",
"copyMessage": "Copy",
"copiedMessage": "Copied",
"forkFromHere": "Fork",
"copyReply": "Copy",
"copiedReply": "Copied",
"turnLatencyTitle": "Response time (end-to-end)"
},
"lightbox": {

View File

@ -509,6 +509,7 @@
},
"chat": {
"fallbackTitle": "Chat {{id}}",
"forkTitle": "Bifurcación: {{title}}",
"loading": "Cargando…",
"noSessions": "Todavía no hay sesiones.",
"showMore": "Mostrar {{count}} más",
@ -811,7 +812,8 @@
"scrollToBottom": "Desplazarse al final",
"loadEarlier": "Cargar mensajes anteriores",
"fork": {
"failed": "No se pudo bifurcar este chat. Inténtalo de nuevo."
"failed": "No se pudo bifurcar este chat. Inténtalo de nuevo.",
"fromHistory": "Bifurcado desde el historial"
},
"promptNavigator": {
"open": "Abrir navegador de prompts",
@ -838,11 +840,11 @@
"agentActivityLiveSummary": "En curso… · {{reasoning}} pasos · {{tools}} llamadas a herramientas",
"agentActivityLiveToolsOnly": "En curso… · {{tools}} llamadas a herramientas",
"imageAttachment": "Imagen adjunta",
"copyMessage": "Copiar mensaje",
"copiedMessage": "Mensaje copiado",
"forkFromHere": "Bifurcar desde aquí",
"copyReply": "Copiar respuesta",
"copiedReply": "Respuesta copiada",
"copyMessage": "Copiar",
"copiedMessage": "Copiado",
"forkFromHere": "Bifurcar",
"copyReply": "Copiar",
"copiedReply": "Copiado",
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)",
"activityThinkingFor": "Pensando durante {{duration}}",
"activityThought": "Pensamiento completado",

View File

@ -509,6 +509,7 @@
},
"chat": {
"fallbackTitle": "Discussion {{id}}",
"forkTitle": "Branche : {{title}}",
"loading": "Chargement…",
"noSessions": "Aucune session pour le moment.",
"showMore": "Afficher {{count}} de plus",
@ -811,7 +812,8 @@
"scrollToBottom": "Faire défiler vers le bas",
"loadEarlier": "Charger les messages précédents",
"fork": {
"failed": "Impossible de bifurquer cette conversation. Réessayez."
"failed": "Impossible de bifurquer cette conversation. Réessayez.",
"fromHistory": "Bifurqué depuis l'historique"
},
"promptNavigator": {
"open": "Ouvrir le navigateur de prompts",
@ -838,11 +840,11 @@
"agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels doutils",
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels doutils",
"imageAttachment": "Pièce jointe image",
"copyMessage": "Copier le message",
"copiedMessage": "Message copié",
"forkFromHere": "Bifurquer depuis ici",
"copyReply": "Copier la réponse",
"copiedReply": "Réponse copiée",
"copyMessage": "Copier",
"copiedMessage": "Copié",
"forkFromHere": "Bifurquer",
"copyReply": "Copier",
"copiedReply": "Copié",
"turnLatencyTitle": "Temps de réponse (de bout en bout)",
"activityThinkingFor": "Réflexion pendant {{duration}}",
"activityThought": "Réflexion terminée",

View File

@ -509,6 +509,7 @@
},
"chat": {
"fallbackTitle": "Obrolan {{id}}",
"forkTitle": "Cabang: {{title}}",
"loading": "Memuat…",
"noSessions": "Belum ada sesi.",
"showMore": "Tampilkan {{count}} lagi",
@ -811,7 +812,8 @@
"scrollToBottom": "Gulir ke bawah",
"loadEarlier": "Muat pesan sebelumnya",
"fork": {
"failed": "Tidak dapat mem-fork chat ini. Coba lagi."
"failed": "Tidak dapat mem-fork chat ini. Coba lagi.",
"fromHistory": "Fork dari riwayat"
},
"promptNavigator": {
"open": "Buka navigator prompt",
@ -838,11 +840,11 @@
"agentActivityLiveSummary": "Berjalan… · {{reasoning}} langkah · {{tools}} panggilan alat",
"agentActivityLiveToolsOnly": "Berjalan… · {{tools}} panggilan alat",
"imageAttachment": "Lampiran gambar",
"copyMessage": "Salin pesan",
"copiedMessage": "Pesan disalin",
"forkFromHere": "Fork dari sini",
"copyReply": "Salin balasan",
"copiedReply": "Balasan disalin",
"copyMessage": "Salin",
"copiedMessage": "Disalin",
"forkFromHere": "Fork",
"copyReply": "Salin",
"copiedReply": "Disalin",
"turnLatencyTitle": "Waktu respons (ujung ke ujung)",
"activityThinkingFor": "Berpikir selama {{duration}}",
"activityThought": "Selesai berpikir",

View File

@ -509,6 +509,7 @@
},
"chat": {
"fallbackTitle": "チャット {{id}}",
"forkTitle": "分岐:{{title}}",
"loading": "読み込み中…",
"noSessions": "まだセッションがありません。",
"showMore": "さらに {{count}} 件表示",
@ -811,7 +812,8 @@
"scrollToBottom": "一番下へスクロール",
"loadEarlier": "以前のメッセージを読み込む",
"fork": {
"failed": "このチャットを分岐できませんでした。もう一度お試しください。"
"failed": "このチャットを分岐できませんでした。もう一度お試しください。",
"fromHistory": "履歴から分岐"
},
"promptNavigator": {
"open": "プロンプトナビゲーターを開く",
@ -838,11 +840,11 @@
"agentActivityLiveSummary": "実行中… · {{reasoning}} ステップ · ツール呼び出し {{tools}} 回",
"agentActivityLiveToolsOnly": "実行中… · ツール呼び出し {{tools}} 回",
"imageAttachment": "画像の添付",
"copyMessage": "メッセージをコピー",
"copiedMessage": "メッセージをコピーしました",
"forkFromHere": "ここから分岐",
"copyReply": "返信をコピー",
"copiedReply": "返信をコピーしました",
"copyMessage": "コピー",
"copiedMessage": "コピー済み",
"forkFromHere": "分岐",
"copyReply": "コピー",
"copiedReply": "コピー済み",
"turnLatencyTitle": "応答時間(全行程)",
"activityThinkingFor": "{{duration}}考えています",
"activityThought": "思考しました",

View File

@ -509,6 +509,7 @@
},
"chat": {
"fallbackTitle": "채팅 {{id}}",
"forkTitle": "분기: {{title}}",
"loading": "불러오는 중…",
"noSessions": "아직 세션이 없습니다.",
"showMore": "{{count}}개 더 보기",
@ -811,7 +812,8 @@
"scrollToBottom": "맨 아래로 스크롤",
"loadEarlier": "이전 메시지 불러오기",
"fork": {
"failed": "이 채팅을 분기할 수 없습니다. 다시 시도해 주세요."
"failed": "이 채팅을 분기할 수 없습니다. 다시 시도해 주세요.",
"fromHistory": "기록에서 분기됨"
},
"promptNavigator": {
"open": "프롬프트 탐색기 열기",
@ -838,11 +840,11 @@
"agentActivityLiveSummary": "진행 중… · {{reasoning}}단계 · 도구 호출 {{tools}}회",
"agentActivityLiveToolsOnly": "진행 중… · 도구 호출 {{tools}}회",
"imageAttachment": "이미지 첨부",
"copyMessage": "메시지 복사",
"copiedMessage": "메시지가 복사됨",
"forkFromHere": "여기서 분기",
"copyReply": "답변 복사",
"copiedReply": "답변이 복사됨",
"copyMessage": "복사",
"copiedMessage": "복사됨",
"forkFromHere": "분기",
"copyReply": "복사",
"copiedReply": "복사됨",
"turnLatencyTitle": "응답 시간(엔드투엔드)",
"activityThinkingFor": "{{duration}} 동안 생각 중",
"activityThought": "생각함",

View File

@ -509,6 +509,7 @@
},
"chat": {
"fallbackTitle": "Trò chuyện {{id}}",
"forkTitle": "Nhánh: {{title}}",
"loading": "Đang tải…",
"noSessions": "Chưa có phiên nào.",
"showMore": "Hiển thị thêm {{count}}",
@ -811,7 +812,8 @@
"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."
"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": {
"open": "Mở trình điều hướng prompt",
@ -838,11 +840,11 @@
"agentActivityLiveSummary": "Đang chạy… · {{reasoning}} bước · {{tools}} lần gọi công cụ",
"agentActivityLiveToolsOnly": "Đang chạy… · {{tools}} lần gọi công cụ",
"imageAttachment": "Tệp hình ảnh đính kèm",
"copyMessage": "Sao chép tin nhắn",
"copiedMessage": "Đã sao chép tin nhắn",
"forkFromHere": "Rẽ nhánh từ đây",
"copyReply": "Sao chép trả lời",
"copiedReply": "Đã sao chép trả lời",
"copyMessage": "Sao chép",
"copiedMessage": "Đã sao chép",
"forkFromHere": "Tách nhánh",
"copyReply": "Sao chép",
"copiedReply": "Đã sao chép",
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)",
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
"activityThought": "Đã suy nghĩ",

View File

@ -509,6 +509,7 @@
},
"chat": {
"fallbackTitle": "对话 {{id}}",
"forkTitle": "分叉:{{title}}",
"loading": "加载中…",
"noSessions": "还没有会话。",
"showMore": "再显示 {{count}} 个",
@ -811,7 +812,8 @@
"scrollToBottom": "滚动到底部",
"loadEarlier": "加载更早消息",
"fork": {
"failed": "无法分叉这个对话,请重试。"
"failed": "无法分叉这个对话,请重试。",
"fromHistory": "从历史消息分叉"
},
"promptNavigator": {
"open": "打开输入导航",
@ -852,11 +854,11 @@
"imageAttachment": "图片附件",
"automationSourceFallback": "自动化",
"automationTriggered": "自动触发",
"copyMessage": "复制消息",
"copiedMessage": "已复制消息",
"forkFromHere": "从这里分叉",
"copyReply": "复制回复",
"copiedReply": "已复制回复",
"copyMessage": "复制",
"copiedMessage": "已复制",
"forkFromHere": "分叉",
"copyReply": "复制",
"copiedReply": "已复制",
"turnLatencyTitle": "本轮耗时(端到端)"
},
"lightbox": {

View File

@ -509,6 +509,7 @@
},
"chat": {
"fallbackTitle": "對話 {{id}}",
"forkTitle": "分叉:{{title}}",
"loading": "載入中…",
"noSessions": "目前還沒有會話。",
"showMore": "再顯示 {{count}} 個",
@ -811,7 +812,8 @@
"scrollToBottom": "捲動到底部",
"loadEarlier": "載入更早訊息",
"fork": {
"failed": "無法分叉這個對話,請重試。"
"failed": "無法分叉這個對話,請重試。",
"fromHistory": "從歷史訊息分叉"
},
"promptNavigator": {
"open": "開啟輸入導覽",
@ -838,11 +840,11 @@
"agentActivityLiveSummary": "進行中… · {{reasoning}} 步 · {{tools}} 次工具呼叫",
"agentActivityLiveToolsOnly": "進行中… · {{tools}} 次工具呼叫",
"imageAttachment": "圖片附件",
"copyMessage": "複製訊息",
"copiedMessage": "已複製訊息",
"forkFromHere": "從這裡分叉",
"copyReply": "複製回覆",
"copiedReply": "已複製回覆",
"copyMessage": "複製",
"copiedMessage": "已複製",
"forkFromHere": "分叉",
"copyReply": "複製",
"copiedReply": "已複製",
"turnLatencyTitle": "本輪耗時(端到端)",
"activityThinkingFor": "思考中,已 {{duration}}",
"activityThought": "已思考",

View File

@ -352,6 +352,7 @@ export class NanobotClient {
forkChat(
sourceChatId: string,
beforeUserIndex: number,
title?: string,
timeoutMs: number = 5_000,
): Promise<string> {
if (this.pendingNewChat) {
@ -367,6 +368,7 @@ export class NanobotClient {
type: "fork_chat",
source_chat_id: sourceChatId,
before_user_index: beforeUserIndex,
...(title?.trim() ? { title: title.trim() } : {}),
});
});
}

View File

@ -862,6 +862,7 @@ export interface WebuiThreadPersistedPayload {
sessionKey?: string;
savedAt?: string;
messages: UIMessage[];
fork_boundary_message_count?: number;
workspace_scope?: WorkspaceScopePayload;
}
@ -877,7 +878,7 @@ export interface FilePreviewPayload {
export type Outbound =
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "fork_chat"; source_chat_id: string; before_user_index: number }
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }

View File

@ -76,8 +76,8 @@ describe("MessageBubble", () => {
expect(row).toHaveClass("ml-auto", "flex");
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
expect(screen.getByRole("button", { name: "Copy message" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
});
it("does not render fork control for user messages", () => {
@ -91,8 +91,8 @@ describe("MessageBubble", () => {
render(<MessageBubble message={message} onForkFromHere={onForkFromHere} />);
expect(screen.getByRole("button", { name: "Copy message" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Fork from here" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
});
it("renders fork control in completed assistant action rows", () => {
@ -107,7 +107,7 @@ describe("MessageBubble", () => {
render(<MessageBubble message={message} onForkFromHere={onForkFromHere} />);
fireEvent.click(screen.getByRole("button", { name: "Fork from here" }));
fireEvent.click(screen.getByRole("button", { name: "Fork" }));
expect(onForkFromHere).toHaveBeenCalledTimes(1);
});
@ -207,11 +207,11 @@ describe("MessageBubble", () => {
render(<MessageBubble message={message} />);
fireEvent.click(screen.getByRole("button", { name: "Copy reply" }));
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
expect(writeText).toHaveBeenCalledWith("I can help with the next step.");
await waitFor(() =>
expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(),
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(),
);
});
@ -235,11 +235,11 @@ describe("MessageBubble", () => {
try {
render(<MessageBubble message={message} />);
fireEvent.click(screen.getByRole("button", { name: "Copy reply" }));
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(),
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(),
);
} finally {
Reflect.deleteProperty(navigator, "clipboard");
@ -268,12 +268,12 @@ describe("MessageBubble", () => {
try {
render(<MessageBubble message={message} />);
fireEvent.click(screen.getByRole("button", { name: "Copy reply" }));
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
expect(writeText).toHaveBeenCalledWith("Rejected clipboard copy.");
await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(),
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(),
);
} finally {
Reflect.deleteProperty(navigator, "clipboard");
@ -292,7 +292,7 @@ describe("MessageBubble", () => {
render(<MessageBubble message={message} />);
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
});
it("does not show copy when showAssistantCopyAction is false", () => {
@ -305,7 +305,7 @@ describe("MessageBubble", () => {
render(<MessageBubble message={message} showAssistantCopyAction={false} />);
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument();
});
it("renders trace messages as collapsible tool groups", () => {

View File

@ -55,6 +55,23 @@ describe("ThreadMessages", () => {
expect(rows[1]).toHaveClass("mt-4");
});
it("renders a fork boundary divider after the copied history", () => {
const messages: UIMessage[] = [
{ id: "u1", role: "user", content: "original", createdAt: 1 },
{ id: "a1", role: "assistant", content: "answer", createdAt: 2 },
{ id: "u2", role: "user", content: "branch prompt", createdAt: 3 },
];
render(
<ThreadMessages
messages={messages}
forkBoundaryMessageCount={2}
/>,
);
expect(screen.getByText("Forked from history")).toBeInTheDocument();
});
it("keeps file edits as their own activity row inside a turn", () => {
const messages: UIMessage[] = [
{
@ -639,7 +656,7 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />);
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
expect(screen.getByText("final reply")).toBeInTheDocument();
});
@ -649,7 +666,7 @@ describe("ThreadMessages", () => {
{ id: "a2", role: "assistant", content: "part two", createdAt: 2 },
];
render(<ThreadMessages messages={messages} isStreaming={false} />);
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
});
it("uses turn ids as activity grouping boundaries when available", () => {

View File

@ -758,7 +758,7 @@ describe("ThreadShell", () => {
const targetText = await screen.findByText("answer 100");
fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", {
name: "Fork from here",
name: "Fork",
}));
await waitFor(() =>
@ -804,7 +804,7 @@ describe("ThreadShell", () => {
target: { value: "keep my current draft" },
});
fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", {
name: "Fork from here",
name: "Fork",
}));
await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 1));
@ -864,7 +864,7 @@ describe("ThreadShell", () => {
const targetText = await screen.findByText("answer2");
fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", {
name: "Fork from here",
name: "Fork",
}));
await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 2));
@ -962,7 +962,7 @@ describe("ThreadShell", () => {
);
await screen.findByText("answer1");
fireEvent.click(screen.getAllByRole("button", { name: "Fork from here" }).at(-1)!);
fireEvent.click(screen.getAllByRole("button", { name: "Fork" }).at(-1)!);
await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 1));
await act(async () => {

View File

@ -230,6 +230,24 @@ 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,