Add persistent agent messaging and shared chat bubbles

Connect Sybil to the Pi root conversation with authenticated REST and WebSocket
APIs, persistent read state, and a dedicated agent tab. Keep optimistic messages
stable through delivery and retries, and animate arrivals without losing the
reader's scroll position.

Share bubble styling with regular chats and remove the obsolete admission sender.
Reject malformed upstream receipts and WebSocket upgrade requests, with regression
coverage and updated API documentation.

Validated with 51 web tests, 44 server tests, both production builds, desktop/mobile
bubble comparisons, and 11 browser animation and scrolling checks.
This commit is contained in:
2026-10-14 12:20:11 -07:00
parent dd6cd1ea7b
commit ef3d79c428
29 changed files with 2752 additions and 25 deletions
+61 -10
View File
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/ho
import type { TargetedTouchEvent } from "preact";
import {
Check,
Bot,
ChevronDown,
GitFork,
Globe2,
@@ -25,6 +26,7 @@ import { AuthScreen } from "@/components/auth/auth-screen";
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { ChatComposer } from "@/components/chat/chat-composer";
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
import { AgentConversation } from "@/components/agent/agent-conversation";
import { SearchResultsPanel } from "@/components/search/search-results-panel";
import { SearchMatchLabel } from "@/components/search/search-match-label";
import { SybilCharacter } from "@/components/sybil-character";
@@ -67,6 +69,7 @@ import {
type WorkspaceItem,
} from "@/lib/api";
import { useSessionAuth } from "@/hooks/use-session-auth";
import { useAgentRoom } from "@/hooks/use-agent-room";
import { useWorkspaceSearch } from "@/hooks/use-workspace-search";
import {
getChatModelSelection,
@@ -81,6 +84,7 @@ import {
retainSelectedHistoryItem,
resolveSidebarSelectionAfterRefresh,
type SidebarSelection,
type HistorySelection,
} from "@/lib/sidebar-selection";
import { buildQuickQuestionRequest, pickQuickQuestionModel, QUICK_QUESTION_DEFAULT_MODEL } from "@/lib/quick-question";
import {
@@ -96,7 +100,7 @@ import {
import { cn } from "@/lib/utils";
type DraftSelectionKind = "chat" | "search";
type SidebarItem = SidebarSelection & {
type SidebarItem = HistorySelection & {
title: string;
searchSnippet: string | null;
parentChatId: string | null;
@@ -140,6 +144,7 @@ type RefreshCollectionsOptions = {
function readSidebarSelectionFromUrl(): SidebarSelection | null {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
if (params.get("agent") === "root") return { kind: "agent", id: "root" };
const chatId = params.get("chat")?.trim();
if (chatId) {
return { kind: "chat", id: chatId };
@@ -156,8 +161,9 @@ function buildWorkspaceUrl(selection: SidebarSelection | null) {
const params = new URLSearchParams(window.location.search);
params.delete("chat");
params.delete("search");
params.delete("agent");
if (selection) {
params.set(selection.kind === "chat" ? "chat" : "search", selection.id);
params.set(selection.kind, selection.id);
}
const query = params.toString();
return `${window.location.pathname}${query ? `?${query}` : ""}`;
@@ -964,6 +970,7 @@ export default function App() {
handleSignIn,
logout,
} = useSessionAuth();
const agentRoom = useAgentRoom(isAuthenticated);
const [chats, setChats] = useState<ChatSummary[]>([]);
const [searches, setSearches] = useState<SearchSummary[]>([]);
@@ -1515,6 +1522,8 @@ export default function App() {
selectedItem?.kind === "search" &&
(!!selectedSearchRunState || !!activeRuns.searches[selectedItem.id]);
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
const isAgentMode = draftKind === null && selectedItem?.kind === "agent";
const isAgentBusy = !!agentRoom.activity?.isThinking || (agentRoom.activity?.backgroundWorkers ?? 0) > 0;
const isSearchRunning = !!selectedSearchIsActive;
const isSendingActiveChat = draftKind !== "search" && !!selectedChatIsActive;
const isActiveSelectionSending = isSearchMode ? isSearchRunning : isSendingActiveChat;
@@ -1577,9 +1586,10 @@ export default function App() {
setSelectedSearch(null);
return;
}
if (!selectedItem) {
if (!selectedItem || selectedItem.kind === "agent") {
setSelectedChat(null);
setSelectedSearch(null);
setIsLoadingSelection(false);
return;
}
@@ -1739,6 +1749,7 @@ export default function App() {
if (draftKind === "chat") return draftChatTitle.trim() || "New chat";
if (draftKind === "search") return "New search";
if (!selectedItem) return "Sybil";
if (selectedItem.kind === "agent") return "Sybil Agent";
if (selectedItem.kind === "chat") {
if (selectedChat?.id === selectedItem.id) return getChatTitle(selectedChat, selectedChat.messages);
if (selectedChatSummary) return getChatTitle(selectedChatSummary);
@@ -1751,6 +1762,7 @@ export default function App() {
const pageTitle = useMemo(() => {
if (draftKind || !selectedItem) return "Sybil";
if (selectedItem.kind === "agent") return "Sybil Agent — Sybil";
if (selectedItem.kind === "chat") {
if (selectedChat?.id === selectedItem.id) return `${getChatTitle(selectedChat, selectedChat.messages)} — Sybil`;
if (selectedChatSummary) return `${getChatTitle(selectedChatSummary)} — Sybil`;
@@ -1925,7 +1937,7 @@ export default function App() {
};
const selectAdjacentSidebarItem = (direction: -1 | 1) => {
if (!orderedSidebarItems.length) return;
const navigationItems: SidebarSelection[] = [{ kind: "agent", id: "root" }, ...orderedSidebarItems];
setError(null);
setContextMenu(null);
@@ -1934,15 +1946,14 @@ export default function App() {
setIsMobileSidebarOpen(false);
const current = selectedItemRef.current;
const currentIndex = current
? orderedSidebarItems.findIndex((item) => item.kind === current.kind && item.id === current.id)
? navigationItems.findIndex((item) => item.kind === current.kind && item.id === current.id)
: -1;
const fallbackIndex = direction > 0 ? 0 : orderedSidebarItems.length - 1;
const fallbackIndex = direction > 0 ? 0 : navigationItems.length - 1;
const nextIndex =
currentIndex < 0
? fallbackIndex
: Math.min(orderedSidebarItems.length - 1, Math.max(0, currentIndex + direction));
const nextItem = orderedSidebarItems[nextIndex];
const target: SidebarSelection = { kind: nextItem.kind, id: nextItem.id };
: Math.min(navigationItems.length - 1, Math.max(0, currentIndex + direction));
const target = navigationItems[nextIndex];
rememberHistoryResult(target);
selectedItemRef.current = target;
setSelectedItem(target);
@@ -3501,7 +3512,7 @@ export default function App() {
</div>
<SybilCharacter
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
isBusy={isActiveSelectionSending || isQuickQuestionSending}
isBusy={isActiveSelectionSending || isQuickQuestionSending || isAgentBusy}
/>
</div>
@@ -3537,6 +3548,42 @@ export default function App() {
Shift {primaryShortcutModifier} J
</span>
</Button>
<button
type="button"
data-testid="agent-sidebar-item"
aria-current={isAgentMode ? "page" : undefined}
aria-label={`Sybil Agent${agentRoom.unreadCount ? `, ${agentRoom.unreadCount} unread messages` : ""}${isAgentBusy ? ", working" : ""}`}
onClick={() => {
setContextMenu(null);
setError(null);
setIsChatSettingsOpen(false);
draftKindRef.current = null;
setDraftKind(null);
const target: SidebarSelection = { kind: "agent", id: "root" };
selectedItemRef.current = target;
setSelectedItem(target);
setIsMobileSidebarOpen(false);
}}
className={cn(
"flex w-full items-center gap-3 rounded-lg border px-3 py-3 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isAgentMode
? "border-violet-300/45 bg-[linear-gradient(135deg,hsl(258_86%_52%_/_0.58),hsl(277_78%_28%_/_0.55))] text-violet-50"
: "border-transparent text-violet-100/78 hover:border-violet-300/18 hover:bg-violet-400/10"
)}
>
<span className={cn("flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border", isAgentMode ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/20 bg-violet-400/5 text-violet-200")}>
<Bot className="h-[18px] w-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">Sybil Agent</span>
</span>
{isAgentBusy ? <LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-cyan-200" aria-label="Agent working" /> : null}
{agentRoom.unreadCount > 0 ? (
<span data-testid="agent-unread-badge" className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-cyan-200 px-1.5 text-[11px] font-bold tabular-nums text-slate-950">
{agentRoom.unreadCount}
</span>
) : null}
</button>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-200/58" />
<input
@@ -3683,6 +3730,9 @@ export default function App() {
onTouchEnd={handleMobileWorkspaceTouchEnd}
onTouchCancel={handleMobileWorkspaceTouchCancel}
>
{isAgentMode ? (
<AgentConversation room={agentRoom} onOpenSidebar={() => setIsMobileSidebarOpen(true)} isVisible={!isMobileSidebarOpen && !isQuickQuestionOpen} />
) : <>
<header className="flex items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
<div className="flex min-w-0 items-center gap-2">
<Button
@@ -3820,6 +3870,7 @@ export default function App() {
/>
</div>
</footer>
</>}
</main>
</div>
{contextMenu ? (