Show inline Agent pending dots
TestFlight / Build and upload (push) Successful in 1m50s

This commit is contained in:
Sybil Agent
2026-10-29 21:31:11 -07:00
parent 5d738f897b
commit a390c7287a
8 changed files with 261 additions and 8 deletions
@@ -50,19 +50,23 @@ function toolName(value: unknown) {
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
}
export const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, download, showThinkingIndicator = false }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"]; showThinkingIndicator?: boolean }) {
export function AgentThinkingDots({ label = "Sybil is thinking" }: { label?: string }) {
return <span className="agent-room__typing-dots" aria-label={label} role="status"><i /><i /><i /></span>;
}
export const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, download, showThinkingIndicator = false, isPending = false }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"]; showThinkingIndicator?: boolean; isPending?: boolean }) {
const own = message.role === "user";
const info = metadata(message);
const status = typeof info.status === "string" ? info.status : "complete";
const failed = status === "failed" || status === "unconfirmed";
const thinking = showThinkingIndicator && !own && status === "sending" && !message.content.trim();
const thinking = showThinkingIndicator && !own && (isPending ?? status === "sending") && !message.content.trim();
const date = dateOf(message.createdAt);
return (
<article className={`agent-room__message ${own ? "is-own" : "is-agent"}`} data-agent-message-id={message.id} data-message-status={status} data-animation={animate ? "enter" : undefined} aria-label={own ? "Your message" : "Sybil's message"}>
{!own ? <span className="agent-room__message-avatar" aria-hidden="true"><img src="/character-still.png" alt="" draggable={false} /></span> : null}
<div className="agent-room__message-content">
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
{own ? <p className="agent-room__plain-message">{message.content}</p> : thinking ? <span className="inline-flex items-center gap-1" aria-label="Sybil is thinking" 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> : <MarkdownContent markdown={message.content} openLinksInNewTab allowImages={false} />}
{own ? <p className="agent-room__plain-message">{message.content}</p> : thinking ? <AgentThinkingDots /> : <MarkdownContent markdown={message.content} openLinksInNewTab allowImages={false} />}
{agentAttachments(message.metadata, message.role).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} inline={message.role === "assistant"} />)}
</div>
{own && failed && typeof info.error === "string" ? <div className="agent-attachment__error" role="alert">{info.error}</div> : null}
@@ -3,7 +3,7 @@ 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 { asStreamingAssistantMetadata, isInlineAgentReplyPending } from "@/lib/chat-stream-presentation";
import { AgentBubble } from "@/components/agent/agent-conversation";
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
@@ -604,7 +604,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
if (message.role === "assistant" && metadata.agentAside === true) {
return (
<div key={message.id} className="flex justify-start" data-agent-aside="true">
<AgentBubble message={message} animate={message.id.startsWith("temp-assistant-")} onRetry={() => {}} download={noAgentAttachmentDownload} showThinkingIndicator />
<AgentBubble message={message} animate={message.id.startsWith("temp-assistant-")} onRetry={() => {}} download={noAgentAttachmentDownload} showThinkingIndicator isPending={isInlineAgentReplyPending(message, isSending)} />
</div>
);
}
+7
View File
@@ -2,6 +2,13 @@ import type { Message, ToolCallEvent } from "@/lib/api";
export const STREAMING_ASSISTANT_METADATA_KIND = "streaming_assistant";
export function isInlineAgentReplyPending(message: Message, isSending: boolean): boolean {
const metadata = message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
? message.metadata as Record<string, unknown> : {};
return isSending && message.role === "assistant" && message.id.startsWith("temp-assistant-")
&& metadata.agentAside === true && !message.content.trim();
}
export type StreamingAssistantMetadata = {
kind: typeof STREAMING_ASSISTANT_METADATA_KIND;
ephemeralTrace: string | null;
@@ -7,6 +7,7 @@ import {
createStreamingAssistantMetadata,
createStreamingAttemptState,
finalizeStreamingAssistant,
isInlineAgentReplyPending,
markStreamingAssistantAsAgentAside,
promoteStreamingAssistantTrace,
setStreamingAssistantSegment,
@@ -126,6 +127,15 @@ test("Agent aside stream keeps identity through loading, markdown deltas, and co
assert.equal(messages.at(-1).content, "**Inline** reply");
});
test("inline Agent pending dots stop on first text, completion, failure, and cancellation", () => {
let message = markStreamingAssistantAsAgentAside([assistantMessage()], "temp-assistant-").at(-1);
assert.equal(isInlineAgentReplyPending(message, true), true);
message = setStreamingAssistantSegment([message], "temp-assistant-", "First visible text").at(-1);
assert.equal(isInlineAgentReplyPending(message, true), false);
assert.equal(isInlineAgentReplyPending(assistantMessage(), false), false, "failed or cancelled pending state is hidden");
assert.equal(isInlineAgentReplyPending({ ...message, id: "saved-agent-reply", content: "" }, true), false);
});
test("message helpers preserve identity when no matching temporary assistant exists", () => {
const messages = [
{