feat(webui): show forked history boundary

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { useMemo } from "react"; import { Fragment, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble"; import { MessageBubble } from "@/components/MessageBubble";
@ -15,6 +15,7 @@ interface ThreadMessagesProps {
onLoadEarlier?: () => void; onLoadEarlier?: () => void;
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
forkBoundaryMessageCount?: number | null;
onOpenFilePreview?: (path: string) => void; onOpenFilePreview?: (path: string) => void;
onForkFromMessage?: (beforeUserIndex: number) => void; onForkFromMessage?: (beforeUserIndex: number) => void;
} }
@ -70,11 +71,16 @@ export function ThreadMessages({
onLoadEarlier, onLoadEarlier,
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
forkBoundaryMessageCount = null,
onOpenFilePreview, onOpenFilePreview,
onForkFromMessage, onForkFromMessage,
}: ThreadMessagesProps) { }: ThreadMessagesProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]); const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
const forkBoundaryAfterUnitIndex = useMemo(
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
[forkBoundaryMessageCount, units],
);
const assistantForkIndexById = useMemo( const assistantForkIndexById = useMemo(
() => assistantForkIndexByMessageId(allMessages ?? messages), () => assistantForkIndexByMessageId(allMessages ?? messages),
[allMessages, messages], [allMessages, messages],
@ -119,51 +125,76 @@ export function ThreadMessages({
: undefined; : undefined;
return ( return (
<div <Fragment key={unitKey(unit, index)}>
key={unitKey(unit, index)} <div className={marginTop} data-user-prompt-id={userPromptId}>
className={marginTop} {unit.type === "activity" ? (
data-user-prompt-id={userPromptId} <AgentActivityCluster
> messages={unit.messages}
{unit.type === "activity" ? ( isTurnStreaming={liveActivityClusterIndices.has(index)}
<AgentActivityCluster hasBodyBelow={hasBodyBelow}
messages={unit.messages} turnLatencyMs={unit.turnLatencyMs}
isTurnStreaming={liveActivityClusterIndices.has(index)} cliApps={cliApps}
hasBodyBelow={hasBodyBelow} mcpPresets={mcpPresets}
turnLatencyMs={unit.turnLatencyMs} onOpenFilePreview={onOpenFilePreview}
cliApps={cliApps} />
mcpPresets={mcpPresets} ) : (
onOpenFilePreview={onOpenFilePreview} <MessageBubble
/> message={unit.message}
) : ( showAssistantCopyAction={
<MessageBubble unit.message.role === "assistant"
message={unit.message} ? copyFlags[index]
showAssistantCopyAction={ : true
unit.message.role === "assistant" }
? copyFlags[index] cliApps={cliApps}
: true mcpPresets={mcpPresets}
} onOpenFilePreview={onOpenFilePreview}
cliApps={cliApps} onForkFromHere={
mcpPresets={mcpPresets} onForkFromMessage
onOpenFilePreview={onOpenFilePreview} ? forkHandlerForAssistantMessage(
onForkFromHere={ unit.message,
onForkFromMessage copyFlags[index],
? forkHandlerForAssistantMessage( assistantForkIndexById,
unit.message, onForkFromMessage,
copyFlags[index], )
assistantForkIndexById, : undefined
onForkFromMessage, }
) />
: undefined )}
} </div>
/> {index === forkBoundaryAfterUnitIndex ? (
)} <ForkBoundaryDivider label={t("thread.fork.fromHistory")} />
</div> ) : null}
</Fragment>
); );
})} })}
</div> </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> { function assistantForkIndexByMessageId(messages: UIMessage[]): Map<string, number> {
const out = new Map<string, number>(); const out = new Map<string, number>();
let nextUserIndex = 0; let nextUserIndex = 0;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -55,6 +55,23 @@ describe("ThreadMessages", () => {
expect(rows[1]).toHaveClass("mt-4"); 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", () => { it("keeps file edits as their own activity row inside a turn", () => {
const messages: UIMessage[] = [ const messages: UIMessage[] = [
{ {
@ -639,7 +656,7 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />); 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(); expect(screen.getByText("final reply")).toBeInTheDocument();
}); });
@ -649,7 +666,7 @@ describe("ThreadMessages", () => {
{ id: "a2", role: "assistant", content: "part two", createdAt: 2 }, { id: "a2", role: "assistant", content: "part two", createdAt: 2 },
]; ];
render(<ThreadMessages messages={messages} isStreaming={false} />); 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", () => { it("uses turn ids as activity grouping boundaries when available", () => {

View File

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

View File

@ -230,6 +230,24 @@ describe("useSessions", () => {
expect(result.current.sessions[0]?.workspaceScope).toEqual(workspaceScope); 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 () => { it("passes through WebUI transcript user media as images and media", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({ vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3, schemaVersion: 3,