Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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%,
Expand Down
68 changes: 66 additions & 2 deletions packages/chat-ui/src/typing-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -166,6 +167,69 @@ function TypingDotsBubble({ status }: { readonly status: string }) {
);
}

function SilkThinkingIndicator({ status }: { readonly status: string }) {
const gradientId = useId();
const maskId = useId();

return (
<div className="chat-bubble-row chat-typing-row" data-own="false">
<div
className="chat-silk-thinking-indicator"
role="status"
aria-label={status}
>
<svg
className="chat-silk-thinking-mark"
viewBox="0 0 100 70"
aria-hidden="true"
>
<defs>
<linearGradient id={gradientId}>
<stop stopColor="white" stopOpacity="0" />
<stop offset=".45" stopColor="white" stopOpacity=".65" />
<stop offset=".78" stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>
<mask
id={maskId}
maskUnits="userSpaceOnUse"
x="-10"
y="-10"
width="120"
height="90"
>
<rect
className="chat-silk-beam-x"
x="-35"
y="0"
width="48"
height="75"
fill={`url(#${gradientId})`}
/>
</mask>
</defs>
<path
className="chat-silk-thinking-mark-base"
d={CORBITS_MARK_PATH}
transform="translate(-2.3 -15) scale(.213)"
opacity=".7"
/>
<path
className="chat-silk-thinking-mark-active"
d={CORBITS_MARK_PATH}
transform="translate(-2.3 -15) scale(.213)"
mask={`url(#${maskId})`}
/>
</svg>
<span className="chat-silk-thinking-text" aria-hidden="true">
Thinking…
</span>
<span className="chat-typing-indicator-label">{status}</span>
</div>
</div>
);
}

export function TypingIndicator({ label }: { readonly label: string }) {
return <TypingDotsBubble status={CHAT_STRINGS.typingIndicator(label)} />;
}
Expand All @@ -182,5 +246,5 @@ export function AgentTypingIndicator({
readonly names: readonly string[];
}) {
if (names.length === 0) return null;
return <TypingDotsBubble status={CHAT_STRINGS.agentsTyping(names)} />;
return <SilkThinkingIndicator status={CHAT_STRINGS.agentsTyping(names)} />;
}
19 changes: 13 additions & 6 deletions packages/chat-ui/test/agent-typing-indicator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading