feat(webui): show forked history boundary

This commit is contained in:
Xubin Ren
2026-06-10 04:26:06 +08:00
parent 73d4b1cb2f
commit 26a58282d4
21 changed files with 242 additions and 121 deletions
+71 -40
View File
@@ -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}
/>