be5e810ac9
The streaming and settled versions of the trailing assistant reply were two components, so settling unmounted one and mounted the other and the entrance tween replayed over text the reader was already on. One LatestAssistantEntry now owns that row under a single key, and the history list excludes it. The CSS entrance keyframe that doubled the GSAP tween is gone and the tween matches the documented 160ms. The step timeline no longer remounts on settle: it is a button-controlled disclosure with a 180ms grid-rows transition, the reader's own toggle wins over the live default, and an in-flight request with no events yet shows a queued row instead of an empty shell. BUG-474 BUG-475 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
310 lines
9.2 KiB
TypeScript
310 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
import { ChatMessageRow } from "@/components/chat-message-row";
|
|
import {
|
|
ChatMessageActions,
|
|
toggleChatMessageFeedback,
|
|
type ChatMessageFeedback,
|
|
} from "@/components/chat-message-actions";
|
|
import { ConversationFollowUps } from "@/components/conversation-follow-ups";
|
|
import { isGeneralDailyFortuneQuestion } from "@/lib/consultation-entrypoint";
|
|
import { deriveConsultationFollowUps } from "@/lib/consultation-follow-ups";
|
|
import type { ConsultationDomain } from "@/lib/consultation-domain-registry";
|
|
import type { AgentActivityView, ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
|
|
import {
|
|
chatMessageViews,
|
|
latestAssistantView,
|
|
settledChatMessageViews,
|
|
} from "@/lib/chat-message-view";
|
|
import type { PublicThinkingSection } from "@/lib/consultation-thinking-plan";
|
|
import type { ConsultationTimelineRow } from "@/lib/consultation-run-timeline";
|
|
import {
|
|
noteLatestEntryMount,
|
|
noteSettledListRender,
|
|
noteSettledRowRender,
|
|
noteStreamingRowRender,
|
|
noteUnsplitListRender,
|
|
} from "@/lib/home-streaming-render-probe";
|
|
import { memo, useEffect, type MutableRefObject } from "react";
|
|
|
|
export type ChatTranscriptActions = Readonly<{
|
|
onFeedback: (feedbackKey: string, requested: ChatMessageFeedback) => void;
|
|
onCopy: (feedbackKey: string, text: string) => void;
|
|
onRegenerate: (renderKey: string) => void;
|
|
onFollowUp: (question: string) => void;
|
|
}>;
|
|
|
|
export type ChatTranscriptProps = Readonly<{
|
|
messages: readonly ChatMessage[];
|
|
loading: boolean;
|
|
streamingText: string;
|
|
streamingActivity?: AgentActivityView;
|
|
streamingThinking?: string;
|
|
streamingSections?: readonly PublicThinkingSection[];
|
|
streamingTimeline?: readonly ConsultationTimelineRow[];
|
|
sessionId: string;
|
|
sessionType: "consultation" | "birth_time_rectification";
|
|
theme?: ConsultationDomain;
|
|
messageFeedback: Readonly<Record<string, ChatMessageFeedback>>;
|
|
copiedMessageKey: string | null;
|
|
cancellationPending: boolean;
|
|
productEntrypointsDisabled: boolean;
|
|
actionsRef: MutableRefObject<ChatTranscriptActions>;
|
|
}>;
|
|
|
|
type MessageEntryProps = Readonly<{
|
|
message: ChatMessageView;
|
|
views: readonly ChatMessageView[];
|
|
index: number;
|
|
sessionId: string;
|
|
sessionType: "consultation" | "birth_time_rectification";
|
|
theme?: ConsultationDomain;
|
|
messageFeedback: Readonly<Record<string, ChatMessageFeedback>>;
|
|
copiedMessageKey: string | null;
|
|
loading: boolean;
|
|
cancellationPending: boolean;
|
|
productEntrypointsDisabled: boolean;
|
|
actionsRef: MutableRefObject<ChatTranscriptActions>;
|
|
}>;
|
|
|
|
/**
|
|
* One transcript row. The same component renders history rows, the row that is
|
|
* still streaming and the row that just settled: actions and follow-ups appear
|
|
* once the view is settled, nothing remounts when it does.
|
|
*/
|
|
function MessageEntry({
|
|
message,
|
|
views,
|
|
index,
|
|
sessionId,
|
|
sessionType,
|
|
theme,
|
|
messageFeedback,
|
|
copiedMessageKey,
|
|
loading,
|
|
cancellationPending,
|
|
productEntrypointsDisabled,
|
|
actionsRef,
|
|
}: MessageEntryProps) {
|
|
const showActions = message.role === "assistant"
|
|
&& message.state === "settled"
|
|
&& Boolean(message.text);
|
|
const feedbackKey = `${sessionId}:${message.renderKey}`;
|
|
const latestRegeneratableKey = !loading && !cancellationPending
|
|
? [...views].reverse().find((item) => (
|
|
item.role === "assistant" && item.state === "settled" && Boolean(item.text)
|
|
))?.renderKey
|
|
: undefined;
|
|
const previousQuestion = views[index - 1]?.role === "user" ? views[index - 1]?.text : "";
|
|
const followUps = showActions
|
|
&& message.renderKey === latestRegeneratableKey
|
|
&& sessionType === "consultation"
|
|
&& previousQuestion
|
|
? deriveConsultationFollowUps({
|
|
question: previousQuestion,
|
|
answer: message.text,
|
|
theme,
|
|
entrypoint: isGeneralDailyFortuneQuestion(previousQuestion) ? "daily_starlanguage" : null,
|
|
})
|
|
: [];
|
|
return (
|
|
<div className="message-entry">
|
|
<ChatMessageRow message={message} />
|
|
{showActions && (
|
|
<ChatMessageActions
|
|
feedback={messageFeedback[feedbackKey]}
|
|
copied={copiedMessageKey === feedbackKey}
|
|
canRegenerate={message.renderKey === latestRegeneratableKey}
|
|
onFeedback={(requested) => actionsRef.current.onFeedback(feedbackKey, requested)}
|
|
onCopy={() => actionsRef.current.onCopy(feedbackKey, message.text)}
|
|
onRegenerate={() => actionsRef.current.onRegenerate(message.renderKey)}
|
|
/>
|
|
)}
|
|
<ConversationFollowUps
|
|
questions={followUps}
|
|
disabled={productEntrypointsDisabled}
|
|
onSelect={(question) => actionsRef.current.onFollowUp(question)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const HistoryMessageEntry = memo(function HistoryMessageEntry(props: MessageEntryProps) {
|
|
noteSettledRowRender();
|
|
return <MessageEntry {...props} />;
|
|
});
|
|
|
|
/**
|
|
* History rows: every settled message except the trailing assistant reply,
|
|
* which `LatestAssistantEntry` owns so it keeps one identity from the first
|
|
* streamed token through settlement.
|
|
*/
|
|
export const SettledMessageList = memo(function SettledMessageList({
|
|
messages,
|
|
excludeLatestAssistant = false,
|
|
sessionId,
|
|
sessionType,
|
|
theme,
|
|
messageFeedback,
|
|
copiedMessageKey,
|
|
loading,
|
|
cancellationPending,
|
|
productEntrypointsDisabled,
|
|
actionsRef,
|
|
}: Omit<ChatTranscriptProps, "streamingText" | "streamingActivity" | "streamingThinking" | "streamingSections" | "streamingTimeline"> & {
|
|
excludeLatestAssistant?: boolean;
|
|
}) {
|
|
noteSettledListRender();
|
|
const views = settledChatMessageViews(messages);
|
|
const history = excludeLatestAssistant && views.at(-1)?.role === "assistant" ? views.slice(0, -1) : views;
|
|
return (
|
|
<>
|
|
{history.map((message, index) => (
|
|
<HistoryMessageEntry
|
|
key={message.renderKey}
|
|
message={message}
|
|
views={views}
|
|
index={index}
|
|
sessionId={sessionId}
|
|
sessionType={sessionType}
|
|
theme={theme}
|
|
messageFeedback={messageFeedback}
|
|
copiedMessageKey={copiedMessageKey}
|
|
loading={loading}
|
|
cancellationPending={cancellationPending}
|
|
productEntrypointsDisabled={productEntrypointsDisabled}
|
|
actionsRef={actionsRef}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
});
|
|
|
|
/** The trailing assistant reply, streaming or settled, under one React identity. */
|
|
export const LatestAssistantEntry = memo(function LatestAssistantEntry(props: MessageEntryProps) {
|
|
noteStreamingRowRender();
|
|
useEffect(() => {
|
|
noteLatestEntryMount();
|
|
}, []);
|
|
return <MessageEntry {...props} />;
|
|
});
|
|
|
|
export const ChatTranscript = memo(function ChatTranscript({
|
|
messages,
|
|
loading,
|
|
streamingText,
|
|
streamingActivity,
|
|
streamingThinking,
|
|
streamingSections,
|
|
streamingTimeline,
|
|
sessionId,
|
|
sessionType,
|
|
theme,
|
|
messageFeedback,
|
|
copiedMessageKey,
|
|
cancellationPending,
|
|
productEntrypointsDisabled,
|
|
actionsRef,
|
|
}: ChatTranscriptProps) {
|
|
const latest = latestAssistantView(
|
|
messages,
|
|
loading,
|
|
streamingText,
|
|
streamingActivity,
|
|
streamingThinking,
|
|
streamingSections,
|
|
streamingTimeline,
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<SettledMessageList
|
|
messages={messages}
|
|
excludeLatestAssistant
|
|
loading={loading}
|
|
sessionId={sessionId}
|
|
sessionType={sessionType}
|
|
theme={theme}
|
|
messageFeedback={messageFeedback}
|
|
copiedMessageKey={copiedMessageKey}
|
|
cancellationPending={cancellationPending}
|
|
productEntrypointsDisabled={productEntrypointsDisabled}
|
|
actionsRef={actionsRef}
|
|
/>
|
|
{latest ? (
|
|
<LatestAssistantEntry
|
|
key={latest.view.renderKey}
|
|
message={latest.view}
|
|
views={latest.views}
|
|
index={latest.views.length - 1}
|
|
sessionId={sessionId}
|
|
sessionType={sessionType}
|
|
theme={theme}
|
|
messageFeedback={messageFeedback}
|
|
copiedMessageKey={copiedMessageKey}
|
|
loading={loading}
|
|
cancellationPending={cancellationPending}
|
|
productEntrypointsDisabled={productEntrypointsDisabled}
|
|
actionsRef={actionsRef}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
});
|
|
|
|
export function UnsplitChatTranscript({
|
|
messages,
|
|
loading,
|
|
streamingText,
|
|
streamingActivity,
|
|
streamingThinking,
|
|
streamingSections,
|
|
streamingTimeline,
|
|
sessionId,
|
|
sessionType,
|
|
theme,
|
|
messageFeedback,
|
|
copiedMessageKey,
|
|
cancellationPending,
|
|
productEntrypointsDisabled,
|
|
actionsRef,
|
|
}: ChatTranscriptProps) {
|
|
noteUnsplitListRender();
|
|
const views = chatMessageViews(
|
|
messages,
|
|
loading,
|
|
streamingText,
|
|
streamingActivity,
|
|
streamingThinking,
|
|
streamingSections,
|
|
streamingTimeline,
|
|
);
|
|
return (
|
|
<>
|
|
{views.map((message, index) => {
|
|
noteSettledRowRender();
|
|
if (message.state !== "settled") noteStreamingRowRender();
|
|
return (
|
|
<MessageEntry
|
|
key={message.renderKey}
|
|
message={message}
|
|
views={views}
|
|
index={index}
|
|
sessionId={sessionId}
|
|
sessionType={sessionType}
|
|
theme={theme}
|
|
messageFeedback={messageFeedback}
|
|
copiedMessageKey={copiedMessageKey}
|
|
loading={loading}
|
|
cancellationPending={cancellationPending}
|
|
productEntrypointsDisabled={productEntrypointsDisabled}
|
|
actionsRef={actionsRef}
|
|
/>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export { toggleChatMessageFeedback };
|