mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 07:14:08 +00:00
feat(webui): show forked history boundary
This commit is contained in:
parent
73d4b1cb2f
commit
26a58282d4
@ -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());
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "思考しました",
|
||||
|
||||
@ -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": "생각함",
|
||||
|
||||
@ -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ĩ",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": "已思考",
|
||||
|
||||
@ -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() } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user