mirror of
https://github.com/techforces-ai/Cial.git
synced 2026-05-15 19:14:11 +00:00
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:
parent
022b46f022
commit
d7e74a6986
4 changed files with 195 additions and 9 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
130
cial-core/ui/src/components/MessageList/StreamingTimeline.tsx
Normal file
130
cial-core/ui/src/components/MessageList/StreamingTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue