mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-02 02:22:55 +08:00
style: improve thinking and stop button placements
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user