286 lines
9.4 KiB
TypeScript
286 lines
9.4 KiB
TypeScript
import { Fragment, useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { MessageBubble } from "@/components/MessageBubble";
|
|
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
|
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
|
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
|
|
|
|
interface ThreadMessagesProps {
|
|
messages: UIMessage[];
|
|
/** When true, agent turn still in flight — keeps activity timeline expanded. */
|
|
isStreaming?: boolean;
|
|
hiddenUserMessageCount?: number;
|
|
cliApps?: CliAppInfo[];
|
|
mcpPresets?: McpPresetInfo[];
|
|
forkBoundaryMessageCount?: number | null;
|
|
onOpenFilePreview?: (path: string) => void;
|
|
onForkFromMessage?: (beforeUserIndex: number) => void;
|
|
}
|
|
|
|
export type DisplayUnit = TurnUnit;
|
|
|
|
/** True when this unit index is the last assistant text slice before the next user message (or end of thread). */
|
|
export function isFinalAssistantSliceBeforeNextUser(
|
|
units: DisplayUnit[],
|
|
index: number,
|
|
): boolean {
|
|
const u = units[index];
|
|
if (u.type !== "message" || u.message.role !== "assistant") return true;
|
|
for (let j = index + 1; j < units.length; j++) {
|
|
const v = units[j];
|
|
if (v.type === "message" && v.message.role === "user") break;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function buildDisplayUnits(
|
|
messages: UIMessage[],
|
|
isStreaming = false,
|
|
): DisplayUnit[] {
|
|
const units = normalizeActivityTimeline(messages, {
|
|
preserveTrailingActivity: isStreaming,
|
|
});
|
|
return isStreaming ? placeLiveActivityAtTurnBottom(units) : units;
|
|
}
|
|
|
|
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
|
const flags = new Array<boolean>(units.length).fill(true);
|
|
let hasLaterUnitBeforeUser = false;
|
|
for (let i = units.length - 1; i >= 0; i -= 1) {
|
|
const unit = units[i];
|
|
if (unit.type === "message" && unit.message.role === "user") {
|
|
hasLaterUnitBeforeUser = false;
|
|
continue;
|
|
}
|
|
if (unit.type === "message" && unit.message.role === "assistant") {
|
|
flags[i] = !hasLaterUnitBeforeUser;
|
|
}
|
|
hasLaterUnitBeforeUser = true;
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
export function ThreadMessages({
|
|
messages,
|
|
isStreaming = false,
|
|
hiddenUserMessageCount = 0,
|
|
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 copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
|
const liveActivityClusterIndices = useMemo(
|
|
() => isStreaming ? currentActivityClusterIndices(units) : new Set<number>(),
|
|
[isStreaming, units],
|
|
);
|
|
const unitKeys = useMemo(() => unitKeysForDisplay(units), [units]);
|
|
let nextUserIndex = hiddenUserMessageCount;
|
|
|
|
return (
|
|
<div className="flex w-full flex-col">
|
|
{units.map((unit, index) => {
|
|
const prev = units[index - 1];
|
|
const marginTop =
|
|
index > 0
|
|
? marginAfterPrevUnit(prev)
|
|
: "";
|
|
const next = units[index + 1];
|
|
const hasBodyBelow =
|
|
unit.type === "activity"
|
|
&& next?.type === "message"
|
|
&& next.message.role === "assistant";
|
|
|
|
const userPromptId =
|
|
unit.type === "message" && unit.message.role === "user"
|
|
? unit.message.id
|
|
: undefined;
|
|
const forkIndex =
|
|
unit.type === "message" && unit.message.role === "assistant" && copyFlags[index]
|
|
? nextUserIndex
|
|
: undefined;
|
|
if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1;
|
|
|
|
return (
|
|
<Fragment key={unitKeys[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 && forkIndex !== undefined
|
|
? () => onForkFromMessage(forkIndex)
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
{index === forkBoundaryAfterUnitIndex ? (
|
|
<ForkBoundaryDivider label={t("thread.forkedFromHistory")} />
|
|
) : 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 currentActivityClusterIndices(units: DisplayUnit[]): Set<number> {
|
|
const indices = new Set<number>();
|
|
let markedCurrentActivity = false;
|
|
for (let i = units.length - 1; i >= 0; i -= 1) {
|
|
const unit = units[i];
|
|
if (unit.type === "activity") {
|
|
if (!markedCurrentActivity) {
|
|
indices.add(i);
|
|
markedCurrentActivity = true;
|
|
}
|
|
continue;
|
|
}
|
|
if (unit.message.role === "assistant" && unit.message.isStreaming) continue;
|
|
if (unit.message.role === "user") break;
|
|
}
|
|
return indices;
|
|
}
|
|
|
|
function placeLiveActivityAtTurnBottom(units: DisplayUnit[]): DisplayUnit[] {
|
|
if (units.length < 2) return units;
|
|
|
|
let turnStart = units.length - 1;
|
|
while (turnStart > 0) {
|
|
const previous = units[turnStart - 1];
|
|
if (previous.type === "message" && previous.message.role === "user") break;
|
|
turnStart -= 1;
|
|
}
|
|
|
|
const trailingTurn = units.slice(turnStart);
|
|
let liveActivityIndex = -1;
|
|
for (let i = trailingTurn.length - 1; i >= 0; i -= 1) {
|
|
if (trailingTurn[i]?.type === "activity") {
|
|
liveActivityIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
if (liveActivityIndex === -1 || liveActivityIndex === trailingTurn.length - 1) {
|
|
return units;
|
|
}
|
|
const hasNonActivityAfterLiveActivity = trailingTurn
|
|
.slice(liveActivityIndex + 1)
|
|
.some((unit) => unit.type !== "activity");
|
|
if (hasNonActivityAfterLiveActivity) {
|
|
return units;
|
|
}
|
|
|
|
const liveActivityUnit = trailingTurn[liveActivityIndex];
|
|
if (!liveActivityUnit || liveActivityUnit.type !== "activity") return units;
|
|
|
|
const reorderedTurn = [
|
|
...trailingTurn.slice(0, liveActivityIndex),
|
|
...trailingTurn.slice(liveActivityIndex + 1),
|
|
liveActivityUnit,
|
|
];
|
|
return [...units.slice(0, turnStart), ...reorderedTurn];
|
|
}
|
|
|
|
export function unitKeysForDisplay(units: DisplayUnit[]): string[] {
|
|
const occurrences = new Map<string, number>();
|
|
return units.map((unit, index) => {
|
|
const base = unitKeyBase(unit, index);
|
|
if (!base.startsWith("turn-") || base.endsWith("-user")) return base;
|
|
const next = (occurrences.get(base) ?? 0) + 1;
|
|
occurrences.set(base, next);
|
|
return `${base}-${next}`;
|
|
});
|
|
}
|
|
|
|
function unitKeyBase(unit: DisplayUnit, index: number): string {
|
|
if (unit.type === "activity") {
|
|
const anchor = unit.messages[0];
|
|
const turnKey = stableTurnMessageKey(anchor, "activity");
|
|
if (turnKey) return turnKey;
|
|
const anchorId = anchor?.id;
|
|
return anchorId != null ? `activity-${anchorId}` : `activity-idx-${index}`;
|
|
}
|
|
const turnKey = stableTurnMessageKey(unit.message);
|
|
if (turnKey) return turnKey;
|
|
return unit.message.id;
|
|
}
|
|
|
|
function stableTurnMessageKey(message: UIMessage | undefined, fallbackPhase?: string): string | null {
|
|
if (!message?.turnId) return null;
|
|
const phase = message.turnPhase ?? fallbackPhase ?? message.kind ?? message.role;
|
|
if (message.role === "user") return `turn-${message.turnId}-user`;
|
|
if (message.kind === "trace") {
|
|
return `turn-${message.turnId}-${phase}-${message.activitySegmentId ?? "activity"}`;
|
|
}
|
|
return `turn-${message.turnId}-${phase}`;
|
|
}
|
|
|
|
function marginAfterPrevUnit(prev: DisplayUnit): string {
|
|
if (prev.type === "activity") {
|
|
return "mt-4";
|
|
}
|
|
const p = prev.message;
|
|
const denseP =
|
|
p.kind === "trace"
|
|
|| (
|
|
p.role === "assistant"
|
|
&& p.content.trim().length === 0
|
|
&& (!!p.reasoning || !!p.reasoningStreaming)
|
|
);
|
|
if (denseP) {
|
|
return "mt-2";
|
|
}
|
|
return "mt-5";
|
|
}
|