diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index 45237d7393..4e82c63499 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -2561,6 +2561,50 @@ background: color-mix(in srgb, var(--foreground) 55%, transparent); } +.chat-silk-thinking-indicator { + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--muted-foreground); + font-size: 0.875rem; +} + +.chat-silk-thinking-mark { + width: 1.875rem; + height: auto; + overflow: visible; + flex: 0 0 auto; +} + +.chat-silk-thinking-mark-base { + fill: var(--muted-foreground); +} + +.chat-silk-thinking-mark-active { + fill: var(--primary); +} + +.chat-silk-beam-x { + animation: chat-silk-sweep 1.45s cubic-bezier(0.22, 0.05, 0.38, 1) infinite; +} + +@keyframes chat-silk-sweep { + 0% { + transform: translateX(8px); + opacity: 0; + } + 8% { + opacity: 1; + } + 86% { + opacity: 1; + } + 100% { + transform: translateX(130px); + opacity: 0; + } +} + @media (prefers-reduced-motion: no-preference) { .chat-typing-indicator-dots span { animation: chat-typing-dot 1.2s ease-in-out infinite; @@ -2575,6 +2619,14 @@ } } +@media (prefers-reduced-motion: reduce) { + .chat-silk-beam-x { + animation: none; + opacity: 1; + transform: translateX(44px); + } +} + @keyframes chat-typing-dot { 0%, 60%, diff --git a/packages/chat-ui/src/typing-indicator.tsx b/packages/chat-ui/src/typing-indicator.tsx index 868adfc060..2749ea2c6d 100644 --- a/packages/chat-ui/src/typing-indicator.tsx +++ b/packages/chat-ui/src/typing-indicator.tsx @@ -6,7 +6,8 @@ // forwards that event type (see use-workbench-stream.ts) — so this is wiring // an existing read, not new realtime backend. -import { useEffect, useRef, useState } from "react"; +import { CORBITS_MARK_PATH } from "@corbits/react-ui"; +import { useEffect, useId, useRef, useState } from "react"; import type { ParticipantRecord } from "./api"; import { localPartOf, type CurrentUser } from "./timeline"; @@ -166,6 +167,69 @@ function TypingDotsBubble({ status }: { readonly status: string }) { ); } +function SilkThinkingIndicator({ status }: { readonly status: string }) { + const gradientId = useId(); + const maskId = useId(); + + return ( +
+
+ + + {status} +
+
+ ); +} + export function TypingIndicator({ label }: { readonly label: string }) { return ; } @@ -182,5 +246,5 @@ export function AgentTypingIndicator({ readonly names: readonly string[]; }) { if (names.length === 0) return null; - return ; + return ; } diff --git a/packages/chat-ui/test/agent-typing-indicator.test.tsx b/packages/chat-ui/test/agent-typing-indicator.test.tsx index f87d3f3903..a318b9b4ae 100644 --- a/packages/chat-ui/test/agent-typing-indicator.test.tsx +++ b/packages/chat-ui/test/agent-typing-indicator.test.tsx @@ -19,26 +19,33 @@ function render(names: readonly string[]) { container, label: () => container.querySelector(".chat-typing-indicator-label")?.textContent, - unmount: () => root.unmount(), + unmount: () => act(() => root.unmount()), }; } describe("AgentTypingIndicator", () => { test("renders nothing when no agent is streaming", () => { const { container, unmount } = render([]); - expect(container.querySelector(".chat-typing-indicator")).toBeNull(); + expect(container.querySelector(".chat-silk-thinking-indicator")).toBeNull(); unmount(); }); - test("one agent reads as a single typist", () => { + test("one agent renders the branded Silk thinking state", () => { const { container, label, unmount } = render(["Myra"]); expect(label()).toBe("Myra is typing…"); expect( - container.querySelector(".chat-typing-indicator-dots"), + container.querySelector(".chat-silk-thinking-indicator"), ).not.toBeNull(); expect( - container.querySelectorAll(".chat-agent-typing-avatar"), - ).toHaveLength(0); + container.querySelector(".chat-silk-thinking-mark-base"), + ).not.toBeNull(); + expect( + container.querySelector(".chat-silk-thinking-mark-active"), + ).not.toBeNull(); + expect(container.querySelector(".chat-silk-beam-x")).not.toBeNull(); + expect( + container.querySelector(".chat-silk-thinking-text")?.textContent, + ).toBe("Thinking…"); expect( container.querySelector(".chat-typing-row")?.getAttribute("data-own"), ).toBe("false");