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,