Add inline persistent Agent mentions
TestFlight / Build and upload (push) Successful in 1m57s

This commit is contained in:
Sybil Agent
2026-10-29 18:28:52 -07:00
parent 35a6c537ad
commit 5d738f897b
23 changed files with 1772 additions and 44 deletions
+15 -1
View File
@@ -94,6 +94,7 @@ import {
createStreamingAssistantMetadata,
createStreamingAttemptState,
finalizeStreamingAssistant,
markStreamingAssistantAsAgentAside,
promoteStreamingAssistantTrace,
setStreamingAssistantSegment,
transitionStreamingToolCall,
@@ -2628,6 +2629,7 @@ export default function App() {
...baseChat.messages
.filter((message) => !isToolCallLogMessage(message))
.map((message) => ({
id: message.id,
role: message.role,
content: message.content,
...(message.name ? { name: message.name } : {}),
@@ -2640,7 +2642,7 @@ export default function App() {
},
];
if (shouldRequestChatTitle(baseChat, pendingTitleGenerationRef.current.has(chatId))) {
if (shouldRequestChatTitle(baseChat, pendingTitleGenerationRef.current.has(chatId)) && !content.toLowerCase().includes("@sybil")) {
pendingTitleGenerationRef.current.add(chatId);
const titleSeed = content || buildAttachmentSummary(attachments) || "Uploaded files";
void suggestChatTitle({ chatId, content: titleSeed })
@@ -2676,6 +2678,18 @@ export default function App() {
{
onMeta: (payload) => {
if (payload.chatId !== chatId) return;
if (payload.agentAside) {
setPendingChatStates((current) => {
const pendingState = current[chatId];
if (!pendingState) return current;
return {
...current,
[chatId]: {
messages: markStreamingAssistantAsAgentAside(pendingState.messages, "temp-assistant-"),
},
};
});
}
},
onToolCall: (payload) => {
const transition = transitionStreamingToolCall(streamingAttempt, payload);
@@ -50,18 +50,19 @@ function toolName(value: unknown) {
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
}
const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, download }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"] }) {
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 }) {
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 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> : <MarkdownContent markdown={message.content} openLinksInNewTab allowImages={false} />}
{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} />}
{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}
@@ -96,7 +97,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
const seenMessageIdsRef = useRef(new Set(room.messages.map(message => message.id)));
const enteringMessageIdsRef = useRef(new Set<string>());
const animatedEntryIdsRef = useRef(new Set<string>());
const entryAnimationsRef = useRef(new Set<Animation>());
const entryAnimationsRef = useRef(new Map<Animation, { entry: HTMLElement; observer: ResizeObserver; contentVisibility: string }>());
const typingHeightRef = useRef(0);
const historyReadyRef = useRef(!room.isLoading);
const historyBeforeRef = useRef(room.historyBeforeCursor);
@@ -188,7 +189,12 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
if (animatedEntryIdsRef.current.has(id) || !enteringMessageIdsRef.current.has(id)) continue;
animatedEntryIdsRef.current.add(id);
if (reduceMotion) continue;
const contentVisibility = entry.style.contentVisibility;
entry.style.contentVisibility = "visible";
const height = entry.getBoundingClientRect().height;
const content = entry.querySelector<HTMLElement>(".agent-room__message");
const contentHeight = content?.getBoundingClientRect().height ?? height;
const fixedHeight = height - contentHeight;
const spacing = entry.previousElementSibling ? gap : 0;
const isReply = Boolean(entry.querySelector(".agent-room__message.is-agent"));
const initialHeight = isReply ? Math.min(replacedTypingHeight, height + spacing) : 0;
@@ -196,15 +202,53 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
// Include the inter-message gap in the reveal. Clipping keeps the bubble's
// positive-Y transform from prematurely extending the scrollable area.
entry.style.overflow = "clip";
const duration = 240;
const endTime = performance.now() + duration;
let targetHeight = height;
const animation = entry.animate([
{ height: `${initialHeight}px`, marginTop: `${-spacing}px` },
{ height: `${height}px`, marginTop: "0px" },
], { duration: 240, easing: "cubic-bezier(.2,.7,.25,1)" });
entryAnimationsRef.current.add(animation);
animation.onfinish = () => {
entry.style.overflow = "";
entryAnimationsRef.current.delete(animation);
], { duration, easing: "cubic-bezier(.2,.7,.25,1)" });
const observer = new ResizeObserver(() => {
if (!content) return;
const nextHeight = fixedHeight + content.getBoundingClientRect().height;
const remaining = endTime - performance.now();
if (Math.abs(nextHeight - targetHeight) < 1 || remaining < 1) return;
const progress = Math.max(0, Math.min(1, Number(animation.currentTime ?? 0) / duration));
if (progress >= 1) return;
const effect = animation.effect as KeyframeEffect | null;
if (progress === 0) {
effect?.setKeyframes([
{ height: `${initialHeight}px`, marginTop: `${-spacing}px`, easing: "cubic-bezier(.2,.7,.25,1)" },
{ height: `${nextHeight}px`, marginTop: "0px" },
]);
} else {
const currentHeight = entry.getBoundingClientRect().height;
const currentMarginTop = getComputedStyle(entry).marginTop;
effect?.setKeyframes([
{ height: `${initialHeight}px`, marginTop: `${-spacing}px`, offset: 0, easing: "cubic-bezier(.2,.7,.25,1)" },
{ height: `${currentHeight}px`, marginTop: currentMarginTop, offset: progress, easing: "cubic-bezier(0,0,0,1)" },
{ height: `${nextHeight}px`, marginTop: "0px", offset: 1 },
]);
}
targetHeight = nextHeight;
restoreViewport();
});
if (content) observer.observe(content);
entryAnimationsRef.current.set(animation, { entry, observer, contentVisibility });
const cleanup = () => {
entry.style.overflow = "";
entry.style.contentVisibility = contentVisibility;
observer.disconnect();
entryAnimationsRef.current.delete(animation);
};
animation.onfinish = () => {
cleanup();
restoreViewport();
};
animation.oncancel = () => {
cleanup();
if (mountedRef.current) restoreViewport();
};
}
}, [room.messages, room.isTyping]);
@@ -224,7 +268,12 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
return () => {
mountedRef.current = false;
observer.disconnect();
for (const animation of entryAnimationsRef.current) animation.cancel();
for (const [animation, state] of entryAnimationsRef.current) {
state.observer.disconnect();
state.entry.style.overflow = "";
state.entry.style.contentVisibility = state.contentVisibility;
animation.cancel();
}
entryAnimationsRef.current.clear();
window.removeEventListener("focus", onVisibility);
document.removeEventListener("visibilitychange", onVisibility);
@@ -4,6 +4,7 @@ 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 { AgentBubble } from "@/components/agent/agent-conversation";
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { ChevronDown, ChevronUp, Globe2, Link2, Wrench } from "lucide-preact";
@@ -95,6 +96,10 @@ type ToolStackContainerStyle = JSX.CSSProperties & {
};
type ToolStackMotionDirection = "expand" | "collapse" | null;
const noAgentAttachmentDownload = async (): Promise<Blob> => {
throw new Error("No Agent attachment is available in this ordinary chat reply.");
};
const COLLAPSED_TOOL_STACK_LIMIT = 4;
const TOOL_STACK_CARD_HEIGHT = 62;
const TOOL_STACK_CARD_GAP = 10;
@@ -594,6 +599,15 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
}
const isUser = message.role === "user";
const metadata = message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
? message.metadata as Record<string, unknown> : {};
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 />
</div>
);
}
const streamingAssistantMetadata = asStreamingAssistantMetadata(message.metadata);
const isPendingAssistant = message.id.startsWith("temp-assistant-") && isSending && message.content.trim().length === 0;
const attachments = getMessageAttachments(message.metadata);
+2 -1
View File
@@ -149,6 +149,7 @@ export type SearchRunRequest = {
};
export type CompletionRequestMessage = {
id?: string;
role: "system" | "user" | "assistant" | "tool";
content: string;
name?: string;
@@ -186,7 +187,7 @@ type CompletionResponse = {
};
type CompletionStreamHandlers = {
onMeta?: (payload: { chatId: string | null; callId: string | null; provider: Provider; model: string }) => void;
onMeta?: (payload: { chatId: string | null; callId: string | null; provider: Provider; model: string; agentAside?: boolean }) => void;
onToolCall?: (payload: ToolCallEvent) => void;
onDelta?: (payload: { text: string }) => void;
onDone?: (payload: { text: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number } }) => void;
+13
View File
@@ -6,6 +6,8 @@ export type StreamingAssistantMetadata = {
kind: typeof STREAMING_ASSISTANT_METADATA_KIND;
ephemeralTrace: string | null;
traceRevision: number;
agentAside?: true;
status?: "sending" | "complete";
};
export type StreamingAttemptState = {
@@ -39,6 +41,8 @@ export function asStreamingAssistantMetadata(value: unknown): StreamingAssistant
typeof record.traceRevision === "number" && Number.isFinite(record.traceRevision)
? Math.max(0, Math.floor(record.traceRevision))
: 0,
...(record.agentAside === true ? { agentAside: true as const } : {}),
...(record.status === "sending" || record.status === "complete" ? { status: record.status } : {}),
};
}
@@ -98,6 +102,7 @@ export function setStreamingAssistantSegment(messages: Message[], assistantMessa
metadata: {
...metadata,
ephemeralTrace: null,
...(metadata.agentAside ? { status: "complete" as const } : {}),
} satisfies StreamingAssistantMetadata,
}));
}
@@ -128,6 +133,14 @@ export function finalizeStreamingAssistant(messages: Message[], assistantMessage
}));
}
export function markStreamingAssistantAsAgentAside(messages: Message[], assistantMessagePrefix: string) {
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
...message,
name: "Sybil Agent",
metadata: { ...metadata, agentAside: true, status: "sending" },
}));
}
export function clearStreamingAssistantTrace(messages: Message[], assistantMessagePrefix: string) {
return updateStreamingAssistant(messages, assistantMessagePrefix, (message, metadata) => ({
...message,