feat(webui): show forked history boundary
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user