style: improve thinking and stop button placements

This commit is contained in:
Haileyesus
2026-06-30 11:20:32 +03:00
parent eed37b51d4
commit 7d5bd753d4
4 changed files with 105 additions and 17 deletions

View File

@@ -184,7 +184,7 @@ function ChatInterface({
handlePermissionDecision, handlePermissionDecision,
handleGrantToolPermission, handleGrantToolPermission,
handleInputFocusChange, handleInputFocusChange,
isInputFocused: _isInputFocused, isInputFocused,
commandModalPayload, commandModalPayload,
closeCommandModal, closeCommandModal,
showCostModal, showCostModal,
@@ -424,6 +424,7 @@ function ChatInterface({
onTextareaPaste={handlePaste} onTextareaPaste={handlePaste}
onTextareaScrollSync={syncInputOverlayScroll} onTextareaScrollSync={syncInputOverlayScroll}
onTextareaInput={handleTextareaInput} onTextareaInput={handleTextareaInput}
isInputFocused={isInputFocused}
onInputFocusChange={handleInputFocusChange} onInputFocusChange={handleInputFocusChange}
placeholder={t('input.placeholder', { placeholder={t('input.placeholder', {
provider: provider:

View File

@@ -7,6 +7,7 @@ import type { SessionActivity } from '../../../../hooks/useSessionProtection';
type ActivityIndicatorProps = { type ActivityIndicatorProps = {
activity: SessionActivity | null; activity: SessionActivity | null;
onAbort?: () => void; onAbort?: () => void;
isInputFocused?: boolean;
}; };
const ACTION_KEYS = [ const ACTION_KEYS = [
@@ -18,6 +19,7 @@ const ACTION_KEYS = [
'claudeStatus.actions.reasoning', 'claudeStatus.actions.reasoning',
]; ];
const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning']; const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
const EXIT_ANIMATION_MS = 220;
/** /**
* Minimal response-in-progress indicator, in the spirit of the inline status * Minimal response-in-progress indicator, in the spirit of the inline status
@@ -26,11 +28,31 @@ const DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working',
* session has an entry in the processing map; it disappears the instant that * session has an entry in the processing map; it disappears the instant that
* entry is removed. * entry is removed.
*/ */
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) { export default function ActivityIndicator({ activity, onAbort, isInputFocused = false }: ActivityIndicatorProps) {
const { t } = useTranslation('chat'); const { t } = useTranslation('chat');
const startedAt = activity?.startedAt ?? null; const [renderedActivity, setRenderedActivity] = useState<SessionActivity | null>(activity);
const [isExiting, setIsExiting] = useState(false);
const startedAt = renderedActivity?.startedAt ?? null;
const [elapsedSeconds, setElapsedSeconds] = useState(0); const [elapsedSeconds, setElapsedSeconds] = useState(0);
useEffect(() => {
if (activity) {
setRenderedActivity(activity);
setIsExiting(false);
return;
}
if (!renderedActivity) return;
setIsExiting(true);
const timer = setTimeout(() => {
setRenderedActivity(null);
setIsExiting(false);
}, EXIT_ANIMATION_MS);
return () => clearTimeout(timer);
}, [activity, renderedActivity]);
useEffect(() => { useEffect(() => {
if (startedAt === null) return; if (startedAt === null) return;
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000))); const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
@@ -39,10 +61,10 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
return () => clearInterval(timer); return () => clearInterval(timer);
}, [startedAt]); }, [startedAt]);
if (!activity) return null; if (!renderedActivity) return null;
const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] })); const actionWords = ACTION_KEYS.map((key, i) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[i] }));
const label = (activity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length]) const label = (renderedActivity.statusText || actionWords[Math.floor(elapsedSeconds / 4) % actionWords.length])
.replace(/\.+$/, ''); .replace(/\.+$/, '');
const minutes = Math.floor(elapsedSeconds / 60); const minutes = Math.floor(elapsedSeconds / 60);
@@ -50,19 +72,31 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
const elapsedLabel = minutes < 1 const elapsedLabel = minutes < 1
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' }) ? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' })
: t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' }); : t('claudeStatus.elapsed.minutesSeconds', { minutes, seconds, defaultValue: '{{minutes}}m {{seconds}}s' });
const tabSurfaceClassName = [
'chat-activity-tab inline-flex h-8 items-center rounded-b-none rounded-t-lg border border-b-0 bg-card px-3 text-xs transition-all duration-200',
isInputFocused
? 'border-primary/30 shadow-[0_-1px_2px_hsl(var(--foreground)/0.08),1px_0_2px_hsl(var(--foreground)/0.06),-1px_0_2px_hsl(var(--foreground)/0.06)]'
: 'border-border/50 shadow-[0_-1px_1px_hsl(var(--foreground)/0.04),1px_0_1px_hsl(var(--foreground)/0.03),-1px_0_1px_hsl(var(--foreground)/0.03)]',
].join(' ');
return ( return (
<div className="animate-in fade-in mb-2 duration-300"> <div
<div className="flex items-center gap-2 px-1"> className={`pointer-events-none bg-transparent ${
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden /> isExiting ? 'chat-activity-exit' : 'chat-activity-enter'
<Shimmer className="text-xs font-medium">{`${label}`}</Shimmer> }`}
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span> >
<div className="flex items-end justify-between gap-2">
<div className={`${tabSurfaceClassName} gap-2`}>
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
<Shimmer className="font-medium">{`${label}`}</Shimmer>
<span className="tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
</div>
{activity.canInterrupt && onAbort && ( {renderedActivity.canInterrupt && onAbort && (
<button <button
type="button" type="button"
onClick={onAbort} onClick={onAbort}
className="ml-auto flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive" className={`${tabSurfaceClassName} pointer-events-auto gap-1.5 text-muted-foreground hover:bg-card hover:text-destructive`}
aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })} aria-label={t('claudeStatus.stop', { defaultValue: 'Stop' })}
> >
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden> <svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>

View File

@@ -98,6 +98,7 @@ interface ChatComposerProps {
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void; onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
onTextareaScrollSync: (target: HTMLTextAreaElement) => void; onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void; onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
isInputFocused?: boolean;
onInputFocusChange?: (focused: boolean) => void; onInputFocusChange?: (focused: boolean) => void;
placeholder: string; placeholder: string;
isTextareaExpanded: boolean; isTextareaExpanded: boolean;
@@ -149,6 +150,7 @@ export default function ChatComposer({
onTextareaPaste, onTextareaPaste,
onTextareaScrollSync, onTextareaScrollSync,
onTextareaInput, onTextareaInput,
isInputFocused = false,
onInputFocusChange, onInputFocusChange,
placeholder, placeholder,
isTextareaExpanded, isTextareaExpanded,
@@ -195,12 +197,13 @@ export default function ChatComposer({
// Hide the thinking/status bar while any permission request is pending // Hide the thinking/status bar while any permission request is pending
const hasPendingPermissions = pendingPermissionRequests.length > 0; const hasPendingPermissions = pendingPermissionRequests.length > 0;
const hasActivityIndicator = Boolean(activity && !hasPendingPermissions);
return ( return (
<div className="chat-composer-shell flex-shrink-0 px-2 pb-2 pt-0 sm:px-4 sm:pb-4 md:px-4 md:pb-6"> <div className="chat-composer-shell relative flex-shrink-0 px-2 pb-2 pt-0 sm:px-4 sm:pb-4 md:px-4 md:pb-6">
{!hasPendingPermissions && ( {!hasPendingPermissions && (
<div className="mx-auto max-w-3xl"> <div className="pointer-events-none absolute bottom-full left-1/2 z-10 w-[calc(100%-1rem)] max-w-3xl -translate-x-1/2 translate-y-px bg-transparent sm:w-[calc(100%-2rem)]">
<ActivityIndicator activity={activity} onAbort={onAbortSession} /> <ActivityIndicator activity={activity} onAbort={onAbortSession} isInputFocused={isInputFocused} />
</div> </div>
)} )}
@@ -255,7 +258,10 @@ export default function ChatComposer({
<PromptInput <PromptInput
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
status={isLoading ? 'streaming' : 'ready'} status={isLoading ? 'streaming' : 'ready'}
className={isTextareaExpanded ? 'chat-input-expanded' : ''} className={[
isTextareaExpanded ? 'chat-input-expanded' : '',
hasActivityIndicator ? 'rounded-t-none' : '',
].filter(Boolean).join(' ')}
{...getRootProps()} {...getRootProps()}
> >
{isDragActive && ( {isDragActive && (

View File

@@ -568,7 +568,23 @@
} }
.chat-composer-shell { .chat-composer-shell {
contain: layout style paint; contain: layout style;
}
.chat-activity-enter {
animation: chat-activity-enter 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
transform-origin: bottom center;
will-change: transform, opacity, filter;
}
.chat-activity-exit {
animation: chat-activity-exit 220ms cubic-bezier(0.4, 0, 1, 1) both;
transform-origin: bottom center;
will-change: transform, opacity, filter;
}
.chat-activity-tab {
clip-path: inset(-8px -8px 0 -8px);
} }
.chat-message { .chat-message {
@@ -947,6 +963,37 @@
animation: settings-fade-in 150ms ease-out; animation: settings-fade-in 150ms ease-out;
} }
@keyframes chat-activity-enter {
from {
opacity: 0;
filter: blur(3px);
transform: translateY(18px) scaleY(0.92);
}
65% {
opacity: 1;
filter: blur(0);
transform: translateY(-2px) scaleY(1.01);
}
to {
opacity: 1;
filter: blur(0);
transform: translateY(0) scaleY(1);
}
}
@keyframes chat-activity-exit {
from {
opacity: 1;
filter: blur(0);
transform: translateY(0) scaleY(1);
}
to {
opacity: 0;
filter: blur(2px);
transform: translateY(14px) scaleY(0.96);
}
}
/* Search result highlight flash */ /* Search result highlight flash */
.search-highlight-flash { .search-highlight-flash {
animation: search-flash 4s ease-out; animation: search-flash 4s ease-out;