From 26a58282d4ff2440512aada1759ac91634328f3e Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Wed, 10 Jun 2026 02:07:47 +0800
Subject: [PATCH] feat(webui): show forked history boundary
---
webui/src/App.tsx | 13 +-
webui/src/components/MessageBubble.tsx | 4 +-
.../src/components/thread/ThreadMessages.tsx | 111 +++++++++++-------
webui/src/components/thread/ThreadShell.tsx | 2 +
.../src/components/thread/ThreadViewport.tsx | 7 ++
webui/src/hooks/useSessions.ts | 22 +++-
webui/src/i18n/locales/en/common.json | 14 ++-
webui/src/i18n/locales/es/common.json | 14 ++-
webui/src/i18n/locales/fr/common.json | 14 ++-
webui/src/i18n/locales/id/common.json | 14 ++-
webui/src/i18n/locales/ja/common.json | 14 ++-
webui/src/i18n/locales/ko/common.json | 14 ++-
webui/src/i18n/locales/vi/common.json | 14 ++-
webui/src/i18n/locales/zh-CN/common.json | 14 ++-
webui/src/i18n/locales/zh-TW/common.json | 14 ++-
webui/src/lib/nanobot-client.ts | 2 +
webui/src/lib/types.ts | 3 +-
webui/src/tests/message-bubble.test.tsx | 26 ++--
webui/src/tests/thread-messages.test.tsx | 21 +++-
webui/src/tests/thread-shell.test.tsx | 8 +-
webui/src/tests/useSessions.test.tsx | 18 +++
21 files changed, 242 insertions(+), 121 deletions(-)
diff --git a/webui/src/App.tsx b/webui/src/App.tsx
index 33c24ccc8..70c6ef6cf 100644
--- a/webui/src/App.tsx
+++ b/webui/src/App.tsx
@@ -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());
diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx
index 39b61911e..9449a7199 100644
--- a/webui/src/components/MessageBubble.tsx
+++ b/webui/src/components/MessageBubble.tsx
@@ -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 (
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 (
-
- {unit.type === "activity" ? (
-
- ) : (
-
- )}
-
+
+
+ {unit.type === "activity" ? (
+
+ ) : (
+
+ )}
+
+ {index === forkBoundaryAfterUnitIndex ? (
+
+ ) : null}
+
);
})}
);
}
+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 (
+
+
+ {label}
+
+
+ );
+}
+
function assistantForkIndexByMessageId(messages: UIMessage[]): Map {
const out = new Map();
let nextUserIndex = 0;
diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx
index b22cc7fd2..46c0ce58e 100644
--- a/webui/src/components/thread/ThreadShell.tsx
+++ b/webui/src/components/thread/ThreadShell.tsx
@@ -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}
/>
diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx
index 37de373b0..bdfe2dbf2 100644
--- a/webui/src/components/thread/ThreadViewport.tsx
+++ b/webui/src/components/thread/ThreadViewport.tsx
@@ -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 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
diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts
index b361565b1..a493a816f 100644
--- a/webui/src/hooks/useSessions.ts
+++ b/webui/src/hooks/useSessions.ts
@@ -20,7 +20,7 @@ export function useSessions(): {
error: string | null;
refresh: () => Promise;
createChat: (workspaceScope?: WorkspaceScopePayload | null) => Promise;
- forkChat: (sourceChatId: string, beforeUserIndex: number) => Promise;
+ forkChat: (sourceChatId: string, beforeUserIndex: number, title?: string) => Promise;
deleteChat: (key: string) => Promise;
} {
const { client, token } = useClient();
@@ -92,8 +92,9 @@ export function useSessions(): {
const forkChat = useCallback(async (
sourceChatId: string,
beforeUserIndex: number,
+ title?: string,
): Promise => {
- 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,
};
}
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json
index 2ca281576..06444e662 100644
--- a/webui/src/i18n/locales/en/common.json
+++ b/webui/src/i18n/locales/en/common.json
@@ -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": {
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
index 8070cdc60..c0461da39 100644
--- a/webui/src/i18n/locales/es/common.json
+++ b/webui/src/i18n/locales/es/common.json
@@ -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",
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
index d4d7ce769..aa809e081 100644
--- a/webui/src/i18n/locales/fr/common.json
+++ b/webui/src/i18n/locales/fr/common.json
@@ -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 d’outils",
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels d’outils",
"imageAttachment": "Pièce jointe image",
- "copyMessage": "Copier le message",
- "copiedMessage": "Message copié",
- "forkFromHere": "Bifurquer depuis ici",
- "copyReply": "Copier la réponse",
- "copiedReply": "Réponse copiée",
+ "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",
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
index 5d7101e5c..13cc84e65 100644
--- a/webui/src/i18n/locales/id/common.json
+++ b/webui/src/i18n/locales/id/common.json
@@ -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",
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
index 3686dcc92..4751f0e2d 100644
--- a/webui/src/i18n/locales/ja/common.json
+++ b/webui/src/i18n/locales/ja/common.json
@@ -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": "思考しました",
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
index 0a77265fa..46ad9d913 100644
--- a/webui/src/i18n/locales/ko/common.json
+++ b/webui/src/i18n/locales/ko/common.json
@@ -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": "생각함",
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
index 07db71e82..628925b22 100644
--- a/webui/src/i18n/locales/vi/common.json
+++ b/webui/src/i18n/locales/vi/common.json
@@ -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ĩ",
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
index 7b96ba9fb..72acd3a74 100644
--- a/webui/src/i18n/locales/zh-CN/common.json
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -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": {
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
index 4049c5913..f8a68134b 100644
--- a/webui/src/i18n/locales/zh-TW/common.json
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -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": "已思考",
diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts
index ee4e70a1e..9037a921e 100644
--- a/webui/src/lib/nanobot-client.ts
+++ b/webui/src/lib/nanobot-client.ts
@@ -352,6 +352,7 @@ export class NanobotClient {
forkChat(
sourceChatId: string,
beforeUserIndex: number,
+ title?: string,
timeoutMs: number = 5_000,
): Promise {
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() } : {}),
});
});
}
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index 7ab06c90a..438373a1f 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -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 }
diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx
index 38ab872e4..e8b907f52 100644
--- a/webui/src/tests/message-bubble.test.tsx
+++ b/webui/src/tests/message-bubble.test.tsx
@@ -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();
- 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();
- 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();
- 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();
- 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();
- 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();
- 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();
- 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", () => {
diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx
index 8fea32b47..5abcf6929 100644
--- a/webui/src/tests/thread-messages.test.tsx
+++ b/webui/src/tests/thread-messages.test.tsx
@@ -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(
+ ,
+ );
+
+ 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();
- 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();
- 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", () => {
diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx
index ded9e65fa..e5b38e1ef 100644
--- a/webui/src/tests/thread-shell.test.tsx
+++ b/webui/src/tests/thread-shell.test.tsx
@@ -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 () => {
diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx
index 1d79b4673..e59a8eb2d 100644
--- a/webui/src/tests/useSessions.test.tsx
+++ b/webui/src/tests/useSessions.test.tsx
@@ -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,