[eric] chat: the pre-reply cue is a quiet breathing mark (LDRS dot-pulse, MIT, lifted as CSS), with a word beside it only when a real step label exists

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
This commit is contained in:
ciregenz
2026-09-03 12:10:10 -07:00
co-authored by Claude Fable 5.1
parent b43faecdac
commit 31437c13e6
3 changed files with 83 additions and 24 deletions
+25 -24
View File
@@ -57,9 +57,9 @@ import StreamingBubble from './bubbles/StreamingBubble';
import WelcomeQuickReplies from './WelcomeQuickReplies';
import InlineSurfaceEmbeds from './shell/InlineSurfaceEmbeds';
import { useWelcomeGreeting } from './useWelcomeGreeting';
import { THINKING_LABELS } from './thinkingLabels';
import MessageBubble from './bubbles/MessageBubble';
import BurstRevealBubble from './bubbles/BurstRevealBubble';
import ThinkingMark from './bubbles/ThinkingMark';
import { estimateRenderedTextHeight, RECHECK_VISIBILITY_EVENT } from './bubbles/markdownMeasure';
import CompactionMarker from './bubbles/CompactionMarker';
import MessageActionBar from './shell/MessageActionBar';
@@ -180,32 +180,33 @@ const ThinkingBubble: React.FC<{ label?: string | null }> = ({ label }) => {
const c = useClaudeTokens();
const shimmerBase = c.text.tertiary;
const shimmerHighlight = c.text.primary;
// Aux-LLM label wins; otherwise the pill stays plain "Thinking". The whimsical verbs read as personality
// in the per-message thinking bubble (MessageBubble), but as a vague, confusing status on a working card.
const display = label ? `${label}` : `${THINKING_LABELS[0].live}`;
// A quiet shimmer LINE, not a bordered card: status shares one visual language with the
// per-message thinking row, so only real content gets bubbles (the ChatGPT/Claude pattern).
// The mark alone until there is something real to say (the Anthropic pattern): a word like "Thinking" is
// a promise the harness cannot keep on a slow lane, and it read as a status that never changed. The
// aux-written step label ("Reading files") is real, so it rides beside the mark when it exists.
return (
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
<style>{thinkingShimmerKeyframes}</style>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75, py: 0.5, px: 1, ml: -1 }}>
<Box
component="span"
sx={{
fontSize: '0.8125rem',
fontWeight: 500,
background: `linear-gradient(90deg, ${shimmerBase} 0%, ${shimmerBase} 40%, ${shimmerHighlight} 50%, ${shimmerBase} 60%, ${shimmerBase} 100%)`,
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
color: 'transparent',
animation: 'thinking-shimmer 2s linear infinite',
transition: 'opacity 0.25s',
}}
>
{display}
</Box>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, py: 0.5, px: 1, ml: -1, minHeight: 28 }}>
<ThinkingMark color={c.text.tertiary} />
{label && (
<Box
component="span"
sx={{
fontSize: '0.8125rem',
fontWeight: 500,
background: `linear-gradient(90deg, ${shimmerBase} 0%, ${shimmerBase} 40%, ${shimmerHighlight} 50%, ${shimmerBase} 60%, ${shimmerBase} 100%)`,
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
color: 'transparent',
animation: 'thinking-shimmer 2s linear infinite',
transition: 'opacity 0.25s',
}}
>
{label}
</Box>
)}
</Box>
</Box>
);
@@ -0,0 +1,35 @@
import React from 'react';
import Box from '@mui/material/Box';
// The quiet mark that holds the floor before a reply: three dots breathing in turn, the Claude/ChatGPT
// pattern. Lifted as plain CSS from LDRS dot-pulse (MIT, (c) 2022 Griffin Johnston, github.com/GriffinJohnston/ldrs) so no dependency rides along.
const keyframes = `
@keyframes osw-thinking-mark {
0%, 100% { transform: scale(0); opacity: 0.35; }
50% { transform: scale(1); opacity: 1; }
}
`;
interface Props {
size?: number;
color: string;
}
export default function ThinkingMark({ size = 6, color }: Props) {
const dot = {
width: size,
height: size,
borderRadius: '50%',
bgcolor: color,
transform: 'scale(0)',
};
const speed = '1.3s';
return (
<Box aria-label="Waiting for a reply" role="img" sx={{ display: 'inline-flex', alignItems: 'center', gap: `${size * 0.9}px`, height: size * 2, flexShrink: 0 }}>
<style>{keyframes}</style>
<Box sx={{ ...dot, animation: `osw-thinking-mark ${speed} ease-in-out calc(${speed} * -0.375) infinite` }} />
<Box sx={{ ...dot, animation: `osw-thinking-mark ${speed} ease-in-out calc(${speed} * -0.25) infinite both` }} />
<Box sx={{ ...dot, animation: `osw-thinking-mark ${speed} ease-in-out calc(${speed} * -0.125) infinite` }} />
</Box>
);
}
@@ -0,0 +1,23 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
// Eric (2026-09-03): "why not something like what anthropic does where they just have a loading thing of
// some sort until the actual thinking / agent response appears". The pre-reply cue is the breathing mark
// alone; a word appears beside it only when the aux-written step label is real.
test('the pre-reply cue is the mark, with no default word', () => {
const chat = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/AgentChat/AgentChat.tsx'), 'utf8');
const start = chat.indexOf('const ThinkingBubble: React.FC');
const bubble = chat.slice(start, chat.indexOf('interface QueuedMessage', start));
assert.match(bubble, /<ThinkingMark color=/);
assert.ok(!bubble.includes('THINKING_LABELS'), 'a default "Thinking" word is back');
assert.match(bubble, /\{label && \(/, 'the shimmer label must be conditional on a real label');
});
test('the mark is a no-dependency lift and says where it came from', () => {
const mark = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/AgentChat/bubbles/ThinkingMark.tsx'), 'utf8');
assert.match(mark, /MIT, \(c\) 2022 Griffin Johnston/);
assert.ok(!/from 'ldrs'/.test(mark), 'no runtime dependency on the library');
assert.match(mark, /@keyframes osw-thinking-mark/);
});