feat(stream): jittered char reveal + subtle tool timeline

- StreamingBubble: paced reveal now varies 0.6×–1.4× per frame so the
  typewriter feels organic instead of metronomic.
- StreamingTimeline: subtle vertical dots+line of pending/done tool
  calls (label · check · duration), shown above the streaming
  response. Adds a "Writing response" step once tools settle. Last 3
  visible by default; older steps collapse behind "+ N earlier
  steps". Replaces the heavy ToolList during streaming — full
  ToolPills stay for completed messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eliot M 2026-04-26 23:39:32 +00:00
parent 022b46f022
commit d7e74a6986
4 changed files with 195 additions and 9 deletions

View file

@ -142,3 +142,52 @@
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
// Streaming tool timeline
@keyframes timeline-step-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-timeline-step-in {
animation: timeline-step-in 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes timeline-dot-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.5);
}
50% {
box-shadow: 0 0 0 5px rgba(99, 102, 241, 0);
}
}
.timeline-dot {
width: 7px;
height: 7px;
border-radius: 50%;
margin-top: 5px;
flex-shrink: 0;
transition: all 0.3s ease;
}
.timeline-dot--done {
background: #34d399;
box-shadow: 0 0 6px rgba(52, 211, 153, 0.45);
}
.timeline-dot--active {
background: #6366f1;
animation: timeline-dot-pulse 1.5s ease-in-out infinite;
}
.timeline-line {
width: 1px;
min-height: 6px;
background: linear-gradient(to bottom, var(--t-border-medium), transparent);
margin: 3px 0;
}

View file

@ -13,9 +13,9 @@ import { AnimatePresence } from 'motion/react';
import type { ChatSession } from '../../lib/types';
import { MessageRow } from './MessageRow';
import { StreamingBubble } from './StreamingBubble';
import { StreamingTimeline } from './StreamingTimeline';
import { ThinkingBubble } from './ThinkingBubble';
import { ActiveNotch } from './ActiveNotch';
import { ToolList } from '../Tools/ToolList';
interface MessageListProps {
readonly session: ChatSession;
@ -80,17 +80,20 @@ export function MessageList({ session }: MessageListProps) {
session.messages.map((m) => <MessageRow key={m.id} message={m} />)
)}
{streaming && session.pendingTools.length > 0 ? (
<div className="pl-6 pr-3">
<StreamingTimeline
tools={session.pendingTools}
hasText={!!session.streamingText}
/>
</div>
) : null}
{session.streamingText ? (
<StreamingBubble text={session.streamingText} />
) : streaming ? (
<ThinkingBubble thinking={session.streamingThinking} />
) : null}
{streaming && session.pendingTools.length > 0 ? (
<div className="pl-6 pr-3">
<ToolList tools={session.pendingTools} animated />
</div>
) : null}
</div>
</div>
);

View file

@ -76,11 +76,15 @@ function usePacedReveal(target: string): string {
}
const backlog = tgt - cur;
// Catch up faster when backed up; otherwise pace at CHARS_PER_SEC.
const rate =
// Catch up faster when backed up; otherwise pace at CHARS_PER_SEC
// with a small per-frame jitter so the reveal feels organic
// instead of metronomic.
const baseRate =
backlog > CATCHUP_THRESHOLD
? Math.max(CHARS_PER_SEC, backlog * 4) // drain in ~250ms
: CHARS_PER_SEC;
const jitter = backlog > CATCHUP_THRESHOLD ? 1 : 0.6 + Math.random() * 0.8; // 0.6×1.4×
const rate = baseRate * jitter;
const advance = Math.max(1, Math.floor(rate * dt));
const next = Math.min(tgt, cur + advance);
shownLenRef.current = next;

View file

@ -0,0 +1,130 @@
/**
* StreamingTimeline subtle vertical timeline of tool calls + writing
* step shown above the streaming response. Mirrors the legacy Cial
* timeline: dot · label · check/spinner · duration.
*
* Last 3 steps visible by default; older ones collapse behind a
* "+ N earlier steps" pill. No expandable bodies this is just a
* progress indicator. The full ToolPill view is still used for
* completed messages.
*/
'use client';
import { useState } from 'react';
import { Check, ChevronDown, Layers, Loader2, MessageSquare } from 'lucide-react';
import type { Tool } from '@cial/sdk';
import { fmtDuration, isPendingTool, toolIcon, toolLabel } from '../../lib/tools';
interface StreamingTimelineProps {
readonly tools: ReadonlyArray<Tool>;
readonly hasText: boolean;
}
interface Step {
id: string;
type: 'tool' | 'writing';
label: string;
status: 'active' | 'done';
icon: React.ReactNode;
durationMs?: number;
}
const VISIBLE = 3;
export function StreamingTimeline({ tools, hasText }: StreamingTimelineProps) {
const [showAll, setShowAll] = useState(false);
const steps: Step[] = tools.map((t) => ({
id: t.toolUseId,
type: 'tool',
label: toolLabel(t),
status: isPendingTool(t) ? 'active' : 'done',
icon: toolIcon(t.name),
durationMs: t.durationMs ?? undefined,
}));
if (hasText && tools.every((t) => !isPendingTool(t))) {
steps.push({
id: 'writing',
type: 'writing',
label: 'Writing response',
status: 'active',
icon: <MessageSquare size={12} className="shrink-0 text-sky-400" />,
});
}
if (steps.length === 0) return null;
const hiddenCount = steps.length - VISIBLE;
const collapsed = hiddenCount > 0 && !showAll;
const visible = collapsed ? steps.slice(-VISIBLE) : steps;
return (
<div className="streaming-timeline ml-0.5 mb-2">
{collapsed ? (
<button
type="button"
onClick={() => setShowAll(true)}
className="mb-1.5 ml-1 flex cursor-pointer select-none items-center gap-2 text-[11px] text-[var(--t-text-dim)] transition-colors hover:text-[var(--t-text-muted)]"
>
<Layers size={11} className="shrink-0" />
<span>
{hiddenCount} earlier step{hiddenCount === 1 ? '' : 's'}
</span>
<ChevronDown size={10} className="shrink-0" />
</button>
) : null}
{visible.map((step, i) => {
const isLast = i === visible.length - 1;
return (
<div
key={step.id}
className="animate-timeline-step-in flex gap-3"
style={{ animationDelay: `${i * 60}ms` }}
>
{/* dot + connecting line */}
<div className="flex flex-col items-center">
<div className={`timeline-dot timeline-dot--${step.status}`} />
{!isLast ? <div className="timeline-line flex-1" /> : null}
</div>
{/* content */}
<div
className={`min-w-0 flex-1 pb-2.5 transition-opacity duration-300 ${
step.status === 'done' ? 'opacity-50' : ''
}`}
>
<div className="flex items-center gap-2 text-xs">
{step.icon}
<span
className={`flex-1 truncate ${
step.status === 'active'
? 'font-medium text-[var(--t-text-primary)]'
: 'text-[var(--t-text-muted)]'
}`}
>
{step.label}
</span>
{step.status === 'done' ? (
<Check size={11} className="shrink-0 text-emerald-400" />
) : null}
{step.status === 'done' && step.durationMs != null ? (
<span className="shrink-0 tabular-nums text-[10px] text-[var(--t-text-dim)]">
{fmtDuration(step.durationMs)}
</span>
) : null}
{step.status === 'active' ? (
<Loader2
size={11}
className="shrink-0 animate-spin text-indigo-400"
/>
) : null}
</div>
</div>
</div>
);
})}
</div>
);
}