mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-01 18:13:03 +08:00
style: improve thinking and stop button placements
This commit is contained in:
@@ -184,7 +184,7 @@ function ChatInterface({
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused: _isInputFocused,
|
||||
isInputFocused,
|
||||
commandModalPayload,
|
||||
closeCommandModal,
|
||||
showCostModal,
|
||||
@@ -424,6 +424,7 @@ function ChatInterface({
|
||||
onTextareaPaste={handlePaste}
|
||||
onTextareaScrollSync={syncInputOverlayScroll}
|
||||
onTextareaInput={handleTextareaInput}
|
||||
isInputFocused={isInputFocused}
|
||||
onInputFocusChange={handleInputFocusChange}
|
||||
placeholder={t('input.placeholder', {
|
||||
provider:
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { SessionActivity } from '../../../../hooks/useSessionProtection';
|
||||
type ActivityIndicatorProps = {
|
||||
activity: SessionActivity | null;
|
||||
onAbort?: () => void;
|
||||
isInputFocused?: boolean;
|
||||
};
|
||||
|
||||
const ACTION_KEYS = [
|
||||
@@ -18,6 +19,7 @@ const ACTION_KEYS = [
|
||||
'claudeStatus.actions.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
|
||||
@@ -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
|
||||
* entry is removed.
|
||||
*/
|
||||
export default function ActivityIndicator({ activity, onAbort }: ActivityIndicatorProps) {
|
||||
export default function ActivityIndicator({ activity, onAbort, isInputFocused = false }: ActivityIndicatorProps) {
|
||||
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);
|
||||
|
||||
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(() => {
|
||||
if (startedAt === null) return;
|
||||
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);
|
||||
}, [startedAt]);
|
||||
|
||||
if (!activity) return null;
|
||||
if (!renderedActivity) return null;
|
||||
|
||||
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(/\.+$/, '');
|
||||
|
||||
const minutes = Math.floor(elapsedSeconds / 60);
|
||||
@@ -50,19 +72,31 @@ export default function ActivityIndicator({ activity, onAbort }: ActivityIndicat
|
||||
const elapsedLabel = minutes < 1
|
||||
? t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}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 (
|
||||
<div className="animate-in fade-in mb-2 duration-300">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<span className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-primary" aria-hidden />
|
||||
<Shimmer className="text-xs font-medium">{`${label}…`}</Shimmer>
|
||||
<span className="text-xs tabular-nums text-muted-foreground/60">{elapsedLabel}</span>
|
||||
<div
|
||||
className={`pointer-events-none bg-transparent ${
|
||||
isExiting ? 'chat-activity-exit' : 'chat-activity-enter'
|
||||
}`}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
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' })}
|
||||
>
|
||||
<svg className="h-2.5 w-2.5 fill-current" viewBox="0 0 24 24" aria-hidden>
|
||||
|
||||
@@ -98,6 +98,7 @@ interface ChatComposerProps {
|
||||
onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
|
||||
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
|
||||
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
isInputFocused?: boolean;
|
||||
onInputFocusChange?: (focused: boolean) => void;
|
||||
placeholder: string;
|
||||
isTextareaExpanded: boolean;
|
||||
@@ -149,6 +150,7 @@ export default function ChatComposer({
|
||||
onTextareaPaste,
|
||||
onTextareaScrollSync,
|
||||
onTextareaInput,
|
||||
isInputFocused = false,
|
||||
onInputFocusChange,
|
||||
placeholder,
|
||||
isTextareaExpanded,
|
||||
@@ -195,12 +197,13 @@ export default function ChatComposer({
|
||||
|
||||
// Hide the thinking/status bar while any permission request is pending
|
||||
const hasPendingPermissions = pendingPermissionRequests.length > 0;
|
||||
const hasActivityIndicator = Boolean(activity && !hasPendingPermissions);
|
||||
|
||||
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 && (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<ActivityIndicator activity={activity} onAbort={onAbortSession} />
|
||||
<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} isInputFocused={isInputFocused} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -255,7 +258,10 @@ export default function ChatComposer({
|
||||
<PromptInput
|
||||
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
|
||||
status={isLoading ? 'streaming' : 'ready'}
|
||||
className={isTextareaExpanded ? 'chat-input-expanded' : ''}
|
||||
className={[
|
||||
isTextareaExpanded ? 'chat-input-expanded' : '',
|
||||
hasActivityIndicator ? 'rounded-t-none' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
{...getRootProps()}
|
||||
>
|
||||
{isDragActive && (
|
||||
|
||||
@@ -568,7 +568,23 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -947,6 +963,37 @@
|
||||
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-highlight-flash {
|
||||
animation: search-flash 4s ease-out;
|
||||
|
||||
Reference in New Issue
Block a user