This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import type { ComponentChildren, JSX } from "preact";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { getMessageAttachments, type Message } from "@/lib/api";
|
||||
import { asStreamingAssistantMetadata } from "@/lib/chat-stream-presentation";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
|
||||
|
||||
@@ -396,8 +397,144 @@ function ToolCallStack({
|
||||
);
|
||||
}
|
||||
|
||||
type RenderedThinkingTrace = {
|
||||
text: string;
|
||||
revision: number;
|
||||
};
|
||||
|
||||
const THINKING_TRACE_EXIT_MS = 220;
|
||||
|
||||
export function StreamingAssistantPresentation({ message, isSending }: { message: Message; isSending: boolean }) {
|
||||
const metadata = asStreamingAssistantMetadata(message.metadata);
|
||||
const traceText = metadata?.ephemeralTrace?.trim() || null;
|
||||
const traceRevision = metadata?.traceRevision ?? 0;
|
||||
const [activeTrace, setActiveTrace] = useState<RenderedThinkingTrace | null>(() =>
|
||||
traceText ? { text: traceText, revision: traceRevision } : null
|
||||
);
|
||||
const [outgoingTrace, setOutgoingTrace] = useState<RenderedThinkingTrace | null>(null);
|
||||
const [reservedTraceHeight, setReservedTraceHeight] = useState(0);
|
||||
const activeTraceRef = useRef<HTMLDivElement | null>(null);
|
||||
const outgoingTraceRef = useRef<HTMLDivElement | null>(null);
|
||||
const exitTimerRef = useRef<number | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const isSameTrace =
|
||||
(!activeTrace && !traceText) ||
|
||||
(activeTrace?.text === traceText && activeTrace?.revision === traceRevision && Boolean(activeTrace) === Boolean(traceText));
|
||||
if (isSameTrace) return;
|
||||
|
||||
if (exitTimerRef.current !== null) {
|
||||
window.clearTimeout(exitTimerRef.current);
|
||||
exitTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (activeTrace) {
|
||||
setOutgoingTrace(activeTrace);
|
||||
exitTimerRef.current = window.setTimeout(() => {
|
||||
setOutgoingTrace(null);
|
||||
exitTimerRef.current = null;
|
||||
}, THINKING_TRACE_EXIT_MS);
|
||||
} else {
|
||||
setOutgoingTrace(null);
|
||||
}
|
||||
|
||||
setActiveTrace(traceText ? { text: traceText, revision: traceRevision } : null);
|
||||
}, [traceRevision, traceText]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (exitTimerRef.current !== null) window.clearTimeout(exitTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const traceElements = [activeTraceRef.current, outgoingTraceRef.current].filter(
|
||||
(element): element is HTMLDivElement => element !== null
|
||||
);
|
||||
if (!traceElements.length) return;
|
||||
|
||||
const reserveMeasuredHeight = () => {
|
||||
const measuredHeight = Math.max(...traceElements.map((element) => element.getBoundingClientRect().height));
|
||||
if (measuredHeight > 0) {
|
||||
setReservedTraceHeight((current) => Math.max(current, Math.ceil(measuredHeight)));
|
||||
}
|
||||
};
|
||||
|
||||
reserveMeasuredHeight();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
|
||||
const observer = new ResizeObserver(reserveMeasuredHeight);
|
||||
for (const element of traceElements) observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [activeTrace, outgoingTrace]);
|
||||
|
||||
const hasAnswer = message.content.trim().length > 0;
|
||||
const showTyping = isSending && !hasAnswer && !traceText && !activeTrace && !outgoingTrace;
|
||||
const phase = hasAnswer ? "answer" : traceText || activeTrace || outgoingTrace ? "trace" : "pending";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="streaming-assistant-slot"
|
||||
data-streaming-assistant="true"
|
||||
data-streaming-phase={phase}
|
||||
style={reservedTraceHeight ? { minHeight: `${reservedTraceHeight}px` } : undefined}
|
||||
>
|
||||
{outgoingTrace ? (
|
||||
<div
|
||||
ref={outgoingTraceRef}
|
||||
key={`outgoing-trace-${outgoingTrace.revision}`}
|
||||
className="streaming-assistant-layer ephemeral-thinking-trace ephemeral-thinking-trace-exit"
|
||||
data-thinking-trace="true"
|
||||
data-thinking-trace-state="outgoing"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{outgoingTrace.text}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{activeTrace ? (
|
||||
<div
|
||||
ref={activeTraceRef}
|
||||
key={`active-trace-${activeTrace.revision}`}
|
||||
className="streaming-assistant-layer ephemeral-thinking-trace ephemeral-thinking-trace-enter"
|
||||
data-thinking-trace="true"
|
||||
data-thinking-trace-state="active"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{activeTrace.text}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{activeTrace && !hasAnswer ? (
|
||||
<span className="sr-only" role="status" aria-live="polite">
|
||||
Assistant is working
|
||||
</span>
|
||||
) : null}
|
||||
{hasAnswer ? (
|
||||
<div className="streaming-assistant-layer streaming-assistant-answer" data-streaming-answer="true">
|
||||
<MarkdownContent
|
||||
markdown={message.content}
|
||||
openLinksInNewTab
|
||||
className="leading-[1.82] text-violet-50 [&_a]:text-inherit [&_a]:underline"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showTyping ? (
|
||||
<span
|
||||
className="streaming-assistant-layer inline-flex items-center gap-1"
|
||||
data-streaming-pending="true"
|
||||
aria-label="Assistant is typing"
|
||||
role="status"
|
||||
>
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:280ms]" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) {
|
||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
|
||||
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-"));
|
||||
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
|
||||
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
|
||||
const seenToolCallMessageIDsRef = useRef<Set<string> | null>(null);
|
||||
@@ -457,6 +594,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
||||
}
|
||||
|
||||
const isUser = message.role === "user";
|
||||
const streamingAssistantMetadata = asStreamingAssistantMetadata(message.metadata);
|
||||
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
|
||||
const attachments = getMessageAttachments(message.metadata);
|
||||
return (
|
||||
@@ -475,7 +613,9 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
||||
}
|
||||
>
|
||||
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
|
||||
{isPendingAssistant ? (
|
||||
{streamingAssistantMetadata ? (
|
||||
<StreamingAssistantPresentation message={message} isSending={isSending} />
|
||||
) : isPendingAssistant ? (
|
||||
<span className="inline-flex items-center gap-1" aria-label="Assistant is typing" role="status">
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]" />
|
||||
<span className="inline-block h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground [animation-delay:140ms]" />
|
||||
|
||||
Reference in New Issue
Block a user