feat(webui): add assistant reply fork-from-here

This commit is contained in:
Bayern4ever-dot
2026-06-10 04:26:06 +08:00
committed by Xubin Ren
parent 4a58b83acc
commit 03bca4c0a9
30 changed files with 1358 additions and 36 deletions
+21 -1
View File
@@ -526,7 +526,7 @@ function Shell({
const { t, i18n } = useTranslation();
const { client, token } = useClient();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
const { sessions, loading, refresh, createChat, forkChat, deleteChat } = useSessions();
const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading);
const initialRouteRef = useRef<ShellRoute | null>(null);
@@ -885,6 +885,25 @@ function Shell({
}
}, [activeWorkspaceScope, createChat, navigate, t]);
const onForkChat = useCallback(async (
sourceChatId: string,
beforeUserIndex: number,
) => {
try {
const chatId = await forkChat(sourceChatId, beforeUserIndex);
navigate({
view: "chat",
activeKey: `websocket:${chatId}`,
settingsSection: "overview",
});
setMobileSidebarOpen(false);
return chatId;
} catch (e) {
console.error("Failed to fork chat", e);
return null;
}
}, [forkChat, navigate]);
const onNewChat = useCallback(() => {
navigate(defaultShellRoute());
setDraftWorkspaceScope(null);
@@ -1486,6 +1505,7 @@ function Shell({
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onForkChat={onForkChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
+167 -32
View File
@@ -5,14 +5,29 @@ import {
useRef,
useState,
type ReactNode,
type SVGProps,
} from "react";
import { Check, ChevronRight, Clock3, Copy, ImageIcon, Sparkles, Wrench } from "lucide-react";
import {
Check,
ChevronRight,
Clock3,
Copy,
ImageIcon,
Sparkles,
Wrench,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { AttachmentTile } from "@/components/AttachmentTile";
import { CliAppMentionText } from "@/components/CliAppMentionText";
import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { formatTurnLatency } from "@/lib/format";
@@ -34,6 +49,7 @@ interface MessageBubbleProps {
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
onForkFromHere?: () => void;
}
/**
@@ -51,6 +67,7 @@ export function MessageBubble({
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
onForkFromHere,
}: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
@@ -73,7 +90,7 @@ export function MessageBubble({
};
}, []);
const onCopyAssistantReply = useCallback(() => {
const onCopyMessage = useCallback(() => {
void copyTextToClipboard(message.content).then((ok) => {
if (!ok) return;
setCopied(true);
@@ -97,6 +114,11 @@ export function MessageBubble({
const hasImages = images.length > 0;
const hasMedia = media.length > 0;
const hasText = message.content.trim().length > 0;
const showUserActions = hasText;
const timeLabel = formatMessageClock(message.createdAt);
const copyLabel = copied
? t("message.copiedMessage", { defaultValue: "Copied message" })
: t("message.copyMessage", { defaultValue: "Copy message" });
return (
<div
className={cn(
@@ -122,6 +144,43 @@ export function MessageBubble({
/>
</p>
) : null}
{showUserActions ? (
<TooltipProvider delayDuration={180} skipDelayDuration={80}>
<div
className={cn(
"mt-0.5 flex h-8 items-center justify-end gap-1 self-end",
"text-[13px] text-muted-foreground/65 opacity-0 transition-opacity duration-150",
"group-focus-within:opacity-100 group-hover:opacity-100",
)}
>
{hasText ? (
<MessageActionTooltip label={copyLabel}>
<button
type="button"
onClick={onCopyMessage}
aria-label={copyLabel}
className={cn(
"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{copied ? (
<Check className="h-3.5 w-3.5" aria-hidden />
) : (
<Copy className="h-3.5 w-3.5" aria-hidden />
)}
</button>
</MessageActionTooltip>
) : null}
{timeLabel ? (
<span className="ml-1 shrink-0 select-none tabular-nums" title={timeLabel}>
{timeLabel}
</span>
) : null}
</div>
</TooltipProvider>
) : null}
</div>
);
}
@@ -138,13 +197,16 @@ export function MessageBubble({
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
const showCopyButton = showAssistantCopyAction && showAssistantActions;
const showForkButton = showAssistantActions && !!onForkFromHere;
const copyReplyLabel = copied ? t("message.copiedReply") : t("message.copyReply");
const forkLabel = t("message.forkFromHere");
const latencyMs = message.latencyMs;
const showLatencyFooter =
message.role === "assistant"
&& latencyMs != null
&& !message.isStreaming
&& (!empty || hasReasoning || media.length > 0);
const showAssistantFooterRow = showCopyButton || showLatencyFooter;
const showAssistantFooterRow = showCopyButton || showForkButton || showLatencyFooter;
return (
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{hasReasoning ? (
@@ -173,35 +235,54 @@ export function MessageBubble({
</MarkdownText>
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantFooterRow ? (
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
{showCopyButton ? (
<button
type="button"
onClick={onCopyAssistantReply}
aria-label={copied ? t("message.copiedReply") : t("message.copyReply")}
title={copied ? t("message.copiedReply") : t("message.copyReply")}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
) : null}
{showLatencyFooter ? (
<span
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
title={t("message.turnLatencyTitle")}
>
{formatTurnLatency(latencyMs)}
</span>
) : null}
</div>
<TooltipProvider delayDuration={180} skipDelayDuration={80}>
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
{showCopyButton ? (
<MessageActionTooltip label={copyReplyLabel}>
<button
type="button"
onClick={onCopyMessage}
aria-label={copyReplyLabel}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
</MessageActionTooltip>
) : null}
{showForkButton ? (
<MessageActionTooltip label={forkLabel}>
<button
type="button"
onClick={onForkFromHere}
aria-label={forkLabel}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<ForkFromHereIcon className="h-4 w-4" aria-hidden />
</button>
</MessageActionTooltip>
) : null}
{showLatencyFooter ? (
<span
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
title={t("message.turnLatencyTitle")}
>
{formatTurnLatency(latencyMs)}
</span>
) : null}
</div>
</TooltipProvider>
) : null}
</>
)}
@@ -209,6 +290,27 @@ export function MessageBubble({
);
}
function MessageActionTooltip({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side="top"
align="center"
className="rounded-full border-border/70 bg-background px-2.5 py-1 text-[12px] font-medium text-foreground shadow-[0_8px_24px_rgba(15,23,42,0.13)] dark:border-white/10 dark:bg-neutral-900 dark:text-white"
>
{label}
</TooltipContent>
</Tooltip>
);
}
function AutomationSourceBadge({ label, triggerLabel }: { label: string; triggerLabel: string }) {
return (
<div
@@ -228,6 +330,39 @@ function AutomationSourceBadge({ label, triggerLabel }: { label: string; trigger
);
}
function formatMessageClock(createdAt: number): string {
if (!Number.isFinite(createdAt) || createdAt <= 0) return "";
try {
return new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(createdAt));
} catch {
return "";
}
}
function ForkFromHereIcon({ className, ...props }: SVGProps<SVGSVGElement>) {
// Tabler Icons "arrow-fork" (MIT, Copyright Paweł Kuna).
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M16 3h5v5" />
<path d="M8 3h-5v5" />
<path d="M21 3l-7.536 7.536a5 5 0 0 0 -1.464 3.534v6.93" />
<path d="M3 3l7.536 7.536a5 5 0 0 1 1.464 3.534v.93" />
</svg>
);
}
function mergeMcpMentionPresets(
presets: McpPresetInfo[],
attachments: UIMcpPresetAttachment[] | undefined,
@@ -172,6 +172,7 @@ interface ThreadComposerProps {
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
pendingQueueKey?: string | null;
externalError?: string | null;
}
const COMMAND_ICONS: Record<string, LucideIcon> = {
@@ -765,6 +766,7 @@ export function ThreadComposer({
workspaceError = null,
onWorkspaceScopeChange,
pendingQueueKey = null,
externalError = null,
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
@@ -782,6 +784,7 @@ export function ThreadComposer({
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
const queuedPromptCounterRef = useRef(0);
const draggedQueuedPromptIdRef = useRef<string | null>(null);
const previousPendingQueueKeyRef = useRef(pendingQueueKey);
const wasStreamingRef = useRef(isStreaming);
const skipNextQueuedFlushRef = useRef(false);
const skipQueuedPromptPersistRef = useRef(false);
@@ -1128,6 +1131,28 @@ export function ThreadComposer({
});
}, []);
// Runs before paint so switching sessions never flashes stale draft text.
useLayoutEffect(() => {
if (previousPendingQueueKeyRef.current === pendingQueueKey) return;
previousPendingQueueKeyRef.current = pendingQueueKey;
setValue("");
setInlineError(null);
setSlashMenuDismissed(false);
setCliAppMenuDismissed(false);
setCursorPosition(0);
clear();
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
});
}, [clear, pendingQueueKey]);
useEffect(() => {
if (externalError) setInlineError(externalError);
}, [externalError]);
const appendTranscription = useCallback((text: string) => {
const transcript = text.trim();
if (!transcript) return;
@@ -8,6 +8,7 @@ import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
allMessages?: UIMessage[];
/** When true, agent turn still in flight — keeps activity timeline expanded. */
isStreaming?: boolean;
hiddenMessageCount?: number;
@@ -15,6 +16,7 @@ interface ThreadMessagesProps {
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void;
}
export type DisplayUnit = TurnUnit;
@@ -62,15 +64,21 @@ export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
export function ThreadMessages({
messages,
allMessages,
isStreaming = false,
hiddenMessageCount = 0,
onLoadEarlier,
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
onForkFromMessage,
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
const assistantForkIndexById = useMemo(
() => assistantForkIndexByMessageId(allMessages ?? messages),
[allMessages, messages],
);
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
const liveActivityClusterIndices = useMemo(
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
@@ -137,6 +145,16 @@ export function ThreadMessages({
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
onForkFromHere={
onForkFromMessage
? forkHandlerForAssistantMessage(
unit.message,
copyFlags[index],
assistantForkIndexById,
onForkFromMessage,
)
: undefined
}
/>
)}
</div>
@@ -146,6 +164,34 @@ export function ThreadMessages({
);
}
function assistantForkIndexByMessageId(messages: UIMessage[]): Map<string, number> {
const out = new Map<string, number>();
let nextUserIndex = 0;
for (const message of messages) {
if (message.role === "user") {
nextUserIndex += 1;
} else if (message.role === "assistant") {
out.set(message.id, nextUserIndex);
}
}
return out;
}
function forkHandlerForAssistantMessage(
message: UIMessage,
canForkAssistant: boolean,
assistantForkIndexById: Map<string, number>,
onForkFromMessage: NonNullable<ThreadMessagesProps["onForkFromMessage"]>,
): (() => void) | undefined {
if (message.role === "assistant" && canForkAssistant) {
const beforeUserIndex = assistantForkIndexById.get(message.id);
return beforeUserIndex === undefined
? undefined
: () => onForkFromMessage(beforeUserIndex);
}
return undefined;
}
function currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
const indices = new Set<number>();
let markedCurrentActivity = false;
+42 -1
View File
@@ -77,6 +77,7 @@ interface ThreadShellProps {
onGoHome?: () => void;
onNewChat?: () => void;
onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string | null>;
onForkChat?: (sourceChatId: string, beforeUserIndex: number) => Promise<string | null>;
onTurnEnd?: () => void;
theme?: "light" | "dark";
onToggleTheme?: () => void;
@@ -226,6 +227,7 @@ export function ThreadShell({
title,
onToggleSidebar,
onCreateChat,
onForkChat,
onTurnEnd,
theme = "light",
onToggleTheme = () => {},
@@ -275,6 +277,8 @@ export function ThreadShell({
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
const [forkError, setForkError] = useState<string | null>(null);
const [forkHydratingChatId, setForkHydratingChatId] = useState<string | null>(null);
const shellRef = useRef<HTMLElement | null>(null);
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
const filePreviewCloseTimerRef = useRef<number | null>(null);
@@ -283,6 +287,7 @@ export function ThreadShell({
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null);
const prevChatIdForComposerRef = useRef<string | null>(chatId);
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
const skipLayoutCacheRef = useRef(false);
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
@@ -334,6 +339,12 @@ export function ThreadShell({
};
}, []);
useEffect(() => {
if (prevChatIdForComposerRef.current === chatId) return;
prevChatIdForComposerRef.current = chatId;
setForkError(null);
}, [chatId]);
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading;
@@ -443,6 +454,12 @@ export function ThreadShell({
setMessages(projectWebuiThreadMessages(historical));
}, [chatId, historical, setMessages]);
useEffect(() => {
if (!chatId || loading || forkHydratingChatId !== chatId) return;
setForkHydratingChatId(null);
setScrollToBottomSignal((value) => value + 1);
}, [chatId, forkHydratingChatId, loading]);
useLayoutEffect(() => {
if (chatId) {
const prev = prevChatIdForCacheRef.current;
@@ -521,6 +538,7 @@ export function ThreadShell({
const handleThreadSend = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => {
setForkError(null);
setScrollToBottomSignal((value) => value + 1);
send(content, images, withWorkspaceScope(options));
},
@@ -615,6 +633,26 @@ export function ThreadShell({
};
}, [filePreviewPath]);
const handleForkFromMessage = useCallback(
async (beforeUserIndex: number) => {
if (!chatId || !onForkChat) return;
setForkError(null);
const forkedChatId = await onForkChat(chatId, beforeUserIndex);
if (!forkedChatId) {
setForkError(t("thread.fork.failed", {
defaultValue: "Could not fork this chat. Try again.",
}));
return;
}
messageCacheRef.current.delete(forkedChatId);
appliedHistoryVersionRef.current.delete(forkedChatId);
pendingCanonicalHydrateRef.current.add(forkedChatId);
setForkHydratingChatId(forkedChatId);
setForkError(null);
},
[chatId, onForkChat, t],
);
const composer = (
<>
{streamError ? (
@@ -626,7 +664,7 @@ export function ThreadShell({
{session ? (
<ThreadComposer
onSend={handleThreadSend}
disabled={!chatId}
disabled={!chatId || forkHydratingChatId === chatId}
isStreaming={isStreaming}
placeholder={
showHeroComposer
@@ -653,6 +691,7 @@ export function ThreadShell({
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={chatId}
externalError={forkError}
/>
) : (
<ThreadComposer
@@ -736,7 +775,9 @@ export function ThreadShell({
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
allMessages={displayMessages}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
/>
</div>
{filePreviewPath && historyKey ? (
@@ -29,6 +29,7 @@ export interface ThreadViewportHandle {
interface ThreadViewportProps {
messages: UIMessage[];
allMessages?: UIMessage[];
isStreaming: boolean;
composer: ReactNode;
emptyState?: ReactNode;
@@ -38,6 +39,7 @@ interface ThreadViewportProps {
cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[];
onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void;
}
const NEAR_BOTTOM_PX = 48;
@@ -61,6 +63,7 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
messages,
allMessages,
isStreaming,
composer,
emptyState,
@@ -70,6 +73,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
onForkFromMessage,
}, ref) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
@@ -289,12 +293,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<div className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages
messages={visibleMessages}
allMessages={allMessages ?? messages}
isStreaming={isStreaming}
hiddenMessageCount={hiddenMessageCount}
onLoadEarlier={loadEarlierMessages}
cliApps={cliApps}
mcpPresets={mcpPresets}
onOpenFilePreview={onOpenFilePreview}
onForkFromMessage={onForkFromMessage}
/>
</div>
</div>
+25 -1
View File
@@ -20,6 +20,7 @@ export function useSessions(): {
error: string | null;
refresh: () => Promise<void>;
createChat: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string>;
forkChat: (sourceChatId: string, beforeUserIndex: number) => Promise<string>;
deleteChat: (key: string) => Promise<void>;
} {
const { client, token } = useClient();
@@ -88,6 +89,29 @@ export function useSessions(): {
return chatId;
}, [client]);
const forkChat = useCallback(async (
sourceChatId: string,
beforeUserIndex: number,
): Promise<string> => {
const chatId = await client.forkChat(sourceChatId, beforeUserIndex);
const key = `websocket:${chatId}`;
optimisticKeysRef.current.add(key);
setSessions((prev) => [
{
key,
channel: "websocket",
chatId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
title: "",
preview: "",
workspaceScope: null,
},
...prev.filter((s) => s.key !== key),
]);
return chatId;
}, [client]);
const deleteChat = useCallback(
async (key: string) => {
await apiDeleteSession(tokenRef.current, key);
@@ -97,7 +121,7 @@ export function useSessions(): {
[],
);
return { sessions, loading, error, refresh, createChat, deleteChat };
return { sessions, loading, error, refresh, createChat, forkChat, deleteChat };
}
/** Lazy-load a session's on-disk messages the first time the UI displays it. */
+6
View File
@@ -810,6 +810,9 @@
},
"scrollToBottom": "Scroll to bottom",
"loadEarlier": "Load earlier messages",
"fork": {
"failed": "Could not fork this chat. Try again."
},
"promptNavigator": {
"open": "Open prompt navigator",
"title": "Prompts",
@@ -849,6 +852,9 @@
"imageAttachment": "Image attachment",
"automationSourceFallback": "Automation",
"automationTriggered": "Triggered automatically",
"copyMessage": "Copy message",
"copiedMessage": "Copied message",
"forkFromHere": "Fork from here",
"copyReply": "Copy reply",
"copiedReply": "Copied reply",
"turnLatencyTitle": "Response time (end-to-end)"
+6
View File
@@ -810,6 +810,9 @@
},
"scrollToBottom": "Desplazarse al final",
"loadEarlier": "Cargar mensajes anteriores",
"fork": {
"failed": "No se pudo bifurcar este chat. Inténtalo de nuevo."
},
"promptNavigator": {
"open": "Abrir navegador de prompts",
"title": "Prompts",
@@ -835,6 +838,9 @@
"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",
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)",
+6
View File
@@ -810,6 +810,9 @@
},
"scrollToBottom": "Faire défiler vers le bas",
"loadEarlier": "Charger les messages précédents",
"fork": {
"failed": "Impossible de bifurquer cette conversation. Réessayez."
},
"promptNavigator": {
"open": "Ouvrir le navigateur de prompts",
"title": "Prompts",
@@ -835,6 +838,9 @@
"agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels doutils",
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels doutils",
"imageAttachment": "Pièce jointe image",
"copyMessage": "Copier le message",
"copiedMessage": "Message copié",
"forkFromHere": "Bifurquer depuis ici",
"copyReply": "Copier la réponse",
"copiedReply": "Réponse copiée",
"turnLatencyTitle": "Temps de réponse (de bout en bout)",
+6
View File
@@ -810,6 +810,9 @@
},
"scrollToBottom": "Gulir ke bawah",
"loadEarlier": "Muat pesan sebelumnya",
"fork": {
"failed": "Tidak dapat mem-fork chat ini. Coba lagi."
},
"promptNavigator": {
"open": "Buka navigator prompt",
"title": "Prompt",
@@ -835,6 +838,9 @@
"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",
"turnLatencyTitle": "Waktu respons (ujung ke ujung)",
+6
View File
@@ -810,6 +810,9 @@
},
"scrollToBottom": "一番下へスクロール",
"loadEarlier": "以前のメッセージを読み込む",
"fork": {
"failed": "このチャットを分岐できませんでした。もう一度お試しください。"
},
"promptNavigator": {
"open": "プロンプトナビゲーターを開く",
"title": "プロンプト",
@@ -835,6 +838,9 @@
"agentActivityLiveSummary": "実行中… · {{reasoning}} ステップ · ツール呼び出し {{tools}} 回",
"agentActivityLiveToolsOnly": "実行中… · ツール呼び出し {{tools}} 回",
"imageAttachment": "画像の添付",
"copyMessage": "メッセージをコピー",
"copiedMessage": "メッセージをコピーしました",
"forkFromHere": "ここから分岐",
"copyReply": "返信をコピー",
"copiedReply": "返信をコピーしました",
"turnLatencyTitle": "応答時間(全行程)",
+6
View File
@@ -810,6 +810,9 @@
},
"scrollToBottom": "맨 아래로 스크롤",
"loadEarlier": "이전 메시지 불러오기",
"fork": {
"failed": "이 채팅을 분기할 수 없습니다. 다시 시도해 주세요."
},
"promptNavigator": {
"open": "프롬프트 탐색기 열기",
"title": "프롬프트",
@@ -835,6 +838,9 @@
"agentActivityLiveSummary": "진행 중… · {{reasoning}}단계 · 도구 호출 {{tools}}회",
"agentActivityLiveToolsOnly": "진행 중… · 도구 호출 {{tools}}회",
"imageAttachment": "이미지 첨부",
"copyMessage": "메시지 복사",
"copiedMessage": "메시지가 복사됨",
"forkFromHere": "여기서 분기",
"copyReply": "답변 복사",
"copiedReply": "답변이 복사됨",
"turnLatencyTitle": "응답 시간(엔드투엔드)",
+6
View File
@@ -810,6 +810,9 @@
},
"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."
},
"promptNavigator": {
"open": "Mở trình điều hướng prompt",
"title": "Prompt",
@@ -835,6 +838,9 @@
"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",
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)",
+6
View File
@@ -810,6 +810,9 @@
},
"scrollToBottom": "滚动到底部",
"loadEarlier": "加载更早消息",
"fork": {
"failed": "无法分叉这个对话,请重试。"
},
"promptNavigator": {
"open": "打开输入导航",
"title": "输入列表",
@@ -849,6 +852,9 @@
"imageAttachment": "图片附件",
"automationSourceFallback": "自动化",
"automationTriggered": "自动触发",
"copyMessage": "复制消息",
"copiedMessage": "已复制消息",
"forkFromHere": "从这里分叉",
"copyReply": "复制回复",
"copiedReply": "已复制回复",
"turnLatencyTitle": "本轮耗时(端到端)"
+6
View File
@@ -810,6 +810,9 @@
},
"scrollToBottom": "捲動到底部",
"loadEarlier": "載入更早訊息",
"fork": {
"failed": "無法分叉這個對話,請重試。"
},
"promptNavigator": {
"open": "開啟輸入導覽",
"title": "輸入列表",
@@ -835,6 +838,9 @@
"agentActivityLiveSummary": "進行中… · {{reasoning}} 步 · {{tools}} 次工具呼叫",
"agentActivityLiveToolsOnly": "進行中… · {{tools}} 次工具呼叫",
"imageAttachment": "圖片附件",
"copyMessage": "複製訊息",
"copiedMessage": "已複製訊息",
"forkFromHere": "從這裡分叉",
"copyReply": "複製回覆",
"copiedReply": "已複製回覆",
"turnLatencyTitle": "本輪耗時(端到端)",
+31
View File
@@ -348,6 +348,29 @@ export class NanobotClient {
});
}
/** Ask the server to create a non-destructive fork before a user-message index. */
forkChat(
sourceChatId: string,
beforeUserIndex: number,
timeoutMs: number = 5_000,
): Promise<string> {
if (this.pendingNewChat) {
return Promise.reject(new Error("newChat already in flight"));
}
return new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingNewChat = null;
reject(new Error("forkChat timed out"));
}, timeoutMs);
this.pendingNewChat = { resolve, reject, timer };
this.queueSend({
type: "fork_chat",
source_chat_id: sourceChatId,
before_user_index: beforeUserIndex,
});
});
}
attach(chatId: string): void {
this.knownChats.add(chatId);
if (this.socket?.readyState === WS_OPEN) {
@@ -481,6 +504,14 @@ export class NanobotClient {
}
}
if (parsed.event === "error" && this.pendingNewChat) {
clearTimeout(this.pendingNewChat.timer);
const detail = typeof parsed.detail === "string" ? parsed.detail : "server error";
const reason = typeof parsed.reason === "string" && parsed.reason ? `:${parsed.reason}` : "";
this.pendingNewChat.reject(new Error(`${detail}${reason}`));
this.pendingNewChat = null;
}
const chatId = (parsed as { chat_id?: string }).chat_id;
if (chatId) {
this.recordGoalStatusForRunStrip(chatId, parsed);
+1
View File
@@ -877,6 +877,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: "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 }
+1
View File
@@ -144,6 +144,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
error: null,
refresh: refreshSpy,
createChat: createChatSpy,
forkChat: async () => "fork-chat",
deleteChat: async (key: string) => {
await deleteChatSpy(key);
setSessions((prev: ChatSummary[]) => prev.filter((s) => s.key !== key));
+32
View File
@@ -76,9 +76,41 @@ 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();
});
it("does not render fork control for user messages", () => {
const onForkFromHere = vi.fn();
const message: UIMessage = {
id: "u-fork",
role: "user",
content: "continue from here",
createdAt: new Date("2026-06-06T09:04:00Z").getTime(),
};
render(<MessageBubble message={message} onForkFromHere={onForkFromHere} />);
expect(screen.getByRole("button", { name: "Copy message" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Fork from here" })).not.toBeInTheDocument();
});
it("renders fork control in completed assistant action rows", () => {
const onForkFromHere = vi.fn();
const message: UIMessage = {
id: "a-fork",
role: "assistant",
content: "branch after this answer",
latencyMs: 1_200,
createdAt: Date.now(),
};
render(<MessageBubble message={message} onForkFromHere={onForkFromHere} />);
fireEvent.click(screen.getByRole("button", { name: "Fork from here" }));
expect(onForkFromHere).toHaveBeenCalledTimes(1);
});
it("renders installed CLI app mentions inside sent user messages", () => {
const message: UIMessage = {
id: "u-cli",
+263 -1
View File
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -59,6 +59,7 @@ function makeClient() {
},
sendMessage: vi.fn(),
newChat: vi.fn(),
forkChat: vi.fn(),
attach: vi.fn(),
connect: vi.fn(),
close: vi.fn(),
@@ -721,6 +722,267 @@ describe("ThreadShell", () => {
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
});
it("forks assistant replies using the global user message index rather than the visible window index", async () => {
const client = makeClient();
const onForkChat = vi.fn().mockResolvedValue("chat-fork");
const rows = Array.from({ length: 165 }, (_, index) => [
{ role: "user" as const, content: `question ${index}` },
{ role: "assistant" as const, content: `answer ${index}` },
]).flat();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Along-chat/webui-thread")) {
return httpJson(transcriptFromSimpleMessages(rows));
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
render(
wrap(
client,
<ThreadShell
session={session("long-chat")}
title="Long chat"
onToggleSidebar={() => {}}
onForkChat={onForkChat}
/>,
),
);
const targetText = await screen.findByText("answer 100");
fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", {
name: "Fork from here",
}));
await waitFor(() =>
expect(onForkChat).toHaveBeenCalledWith("long-chat", 101),
);
});
it("shows an error without changing the draft when assistant fork fails", async () => {
const client = makeClient();
const onForkChat = vi.fn().mockResolvedValue(null);
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/webui-thread")) {
return httpJson(transcriptFromSimpleMessages([
{ role: "user", content: "fork me" },
{ role: "assistant", content: "answer" },
]));
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onForkChat={onForkChat}
/>,
),
);
const targetText = await screen.findByText("answer");
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "keep my current draft" },
});
fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", {
name: "Fork from here",
}));
await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 1));
expect(screen.getByLabelText("Message input")).toHaveValue("keep my current draft");
expect(screen.getByRole("alert")).toHaveTextContent("Could not fork this chat");
expect(client.sendMessage).not.toHaveBeenCalled();
});
it("hydrates a successful fork from canonical history without later source messages", async () => {
const client = makeClient();
const onForkChat = vi.fn().mockResolvedValue("chat-fork");
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/webui-thread")) {
return httpJson(transcriptFromSimpleMessages([
{ role: "user", content: "round1" },
{ role: "assistant", content: "answer1" },
{ role: "user", content: "round2 fork me" },
{ role: "assistant", content: "answer2" },
{ role: "user", content: "round3 must not appear" },
]));
}
if (url.includes("websocket%3Achat-fork/webui-thread")) {
return httpJson(transcriptFromSimpleMessages([
{ role: "user", content: "round1" },
{ role: "assistant", content: "answer1" },
{ role: "user", content: "round2 fork me" },
{ role: "assistant", content: "answer2" },
]));
}
if (url.includes("websocket%3Achat-other/webui-thread")) {
return httpJson(transcriptFromSimpleMessages([
{ role: "user", content: "other chat" },
]));
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onForkChat={onForkChat}
/>,
),
);
const targetText = await screen.findByText("answer2");
fireEvent.click(within(targetText.closest(".w-full") as HTMLElement).getByRole("button", {
name: "Fork from here",
}));
await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 2));
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-fork")}
title="Chat chat-fork"
onToggleSidebar={() => {}}
onForkChat={onForkChat}
/>,
),
);
});
await waitFor(() => expect(screen.getByText("answer1")).toBeInTheDocument());
expect(screen.getByText("answer2")).toBeInTheDocument();
expect(screen.queryByText("round3 must not appear")).not.toBeInTheDocument();
expect(screen.getByLabelText("Message input")).toHaveValue("");
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-other")}
title="Chat chat-other"
onToggleSidebar={() => {}}
onForkChat={onForkChat}
/>,
),
);
});
await waitFor(() =>
expect(screen.getByLabelText("Message input")).toHaveValue(""),
);
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={null}
title="New chat"
onToggleSidebar={() => {}}
onForkChat={onForkChat}
/>,
),
);
});
expect(screen.getByLabelText("Message input")).toHaveValue("");
});
it("forks from completed assistant replies without pre-filling the assistant text", async () => {
const client = makeClient();
const onForkChat = vi.fn().mockResolvedValue("chat-fork");
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/webui-thread")) {
return httpJson(transcriptFromSimpleMessages([
{ role: "user", content: "round1" },
{ role: "assistant", content: "answer1" },
]));
}
if (url.includes("websocket%3Achat-fork/webui-thread")) {
return httpJson(transcriptFromSimpleMessages([
{ role: "user", content: "round1" },
{ role: "assistant", content: "answer1" },
]));
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
const { rerender } = render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onForkChat={onForkChat}
/>,
),
);
await screen.findByText("answer1");
fireEvent.click(screen.getAllByRole("button", { name: "Fork from here" }).at(-1)!);
await waitFor(() => expect(onForkChat).toHaveBeenCalledWith("chat-a", 1));
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-fork")}
title="Chat chat-fork"
onToggleSidebar={() => {}}
onForkChat={onForkChat}
/>,
),
);
});
await waitFor(() => expect(screen.getByText("answer1")).toBeInTheDocument());
expect(screen.getByLabelText("Message input")).toHaveValue("");
});
it("does not cache optimistic messages under the next chat during a session switch", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-b");
@@ -60,6 +60,7 @@ function fakeClient() {
},
sendMessage: vi.fn(),
newChat: vi.fn(),
forkChat: vi.fn(),
attach: vi.fn(),
connect: vi.fn(),
close: vi.fn(),
+1
View File
@@ -34,6 +34,7 @@ function fakeClient() {
},
sendMessage: vi.fn(),
newChat: vi.fn(),
forkChat: vi.fn(),
attach: vi.fn(),
connect: vi.fn(),
close: vi.fn(),