mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-06 13:42:58 +08:00
refactor: chat composer new design
This commit is contained in:
@@ -737,7 +737,7 @@ export function useChatComposerState({
|
|||||||
}
|
}
|
||||||
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
|
// Re-run when input changes so restored drafts get the same autosize behavior as typed text.
|
||||||
textareaRef.current.style.height = 'auto';
|
textareaRef.current.style.height = 'auto';
|
||||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
textareaRef.current.style.height = `${Math.max(22, textareaRef.current.scrollHeight)}px`;
|
||||||
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
||||||
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
|
const expanded = textareaRef.current.scrollHeight > lineHeight * 2;
|
||||||
setIsTextareaExpanded(expanded);
|
setIsTextareaExpanded(expanded);
|
||||||
@@ -824,7 +824,7 @@ export function useChatComposerState({
|
|||||||
(event: FormEvent<HTMLTextAreaElement>) => {
|
(event: FormEvent<HTMLTextAreaElement>) => {
|
||||||
const target = event.currentTarget;
|
const target = event.currentTarget;
|
||||||
target.style.height = 'auto';
|
target.style.height = 'auto';
|
||||||
target.style.height = `${target.scrollHeight}px`;
|
target.style.height = `${Math.max(22, target.scrollHeight)}px`;
|
||||||
setCursorPosition(target.selectionStart);
|
setCursorPosition(target.selectionStart);
|
||||||
syncInputOverlayScroll(target);
|
syncInputOverlayScroll(target);
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,24 @@ import type {
|
|||||||
SetStateAction,
|
SetStateAction,
|
||||||
TouchEvent,
|
TouchEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
|
||||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
||||||
import CommandMenu from './CommandMenu';
|
import CommandMenu from './CommandMenu';
|
||||||
import ClaudeStatus from './ClaudeStatus';
|
import ClaudeStatus from './ClaudeStatus';
|
||||||
import ImageAttachment from './ImageAttachment';
|
import ImageAttachment from './ImageAttachment';
|
||||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||||
import ChatInputControls from './ChatInputControls';
|
import ThinkingModeSelector from './ThinkingModeSelector';
|
||||||
|
import TokenUsagePie from './TokenUsagePie';
|
||||||
|
import {
|
||||||
|
PromptInput,
|
||||||
|
PromptInputHeader,
|
||||||
|
PromptInputBody,
|
||||||
|
PromptInputTextarea,
|
||||||
|
PromptInputFooter,
|
||||||
|
PromptInputTools,
|
||||||
|
PromptInputButton,
|
||||||
|
PromptInputSubmit,
|
||||||
|
} from '../../../../shared/view/ui';
|
||||||
|
|
||||||
interface MentionableFile {
|
interface MentionableFile {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -171,73 +183,37 @@ export default function ChatComposer({
|
|||||||
return (
|
return (
|
||||||
<div className={`flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6 ${mobileFloatingClass}`}>
|
<div className={`flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6 ${mobileFloatingClass}`}>
|
||||||
{!hasPendingPermissions && (
|
{!hasPendingPermissions && (
|
||||||
<div className="flex-1">
|
<ClaudeStatus
|
||||||
<ClaudeStatus
|
status={claudeStatus}
|
||||||
status={claudeStatus}
|
isLoading={isLoading}
|
||||||
isLoading={isLoading}
|
onAbort={onAbortSession}
|
||||||
onAbort={onAbortSession}
|
provider={provider}
|
||||||
provider={provider}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingPermissionRequests.length > 0 && (
|
||||||
|
<div className="mx-auto mb-3 max-w-4xl">
|
||||||
|
<PermissionRequestsBanner
|
||||||
|
pendingPermissionRequests={pendingPermissionRequests}
|
||||||
|
handlePermissionDecision={handlePermissionDecision}
|
||||||
|
handleGrantToolPermission={handleGrantToolPermission}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mx-auto mb-3 max-w-4xl">
|
{!hasQuestionPanel && <div className="relative mx-auto max-w-4xl">
|
||||||
<PermissionRequestsBanner
|
{isUserScrolledUp && hasMessages && (
|
||||||
pendingPermissionRequests={pendingPermissionRequests}
|
<div className="absolute -top-10 left-0 right-0 z-10 flex justify-center">
|
||||||
handlePermissionDecision={handlePermissionDecision}
|
<button
|
||||||
handleGrantToolPermission={handleGrantToolPermission}
|
type="button"
|
||||||
/>
|
onClick={onScrollToBottom}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-border/50 bg-card text-muted-foreground shadow-sm transition-all duration-200 hover:bg-accent hover:text-foreground"
|
||||||
{!hasQuestionPanel && <ChatInputControls
|
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
|
||||||
permissionMode={permissionMode}
|
>
|
||||||
onModeSwitch={onModeSwitch}
|
<ArrowDownIcon className="h-4 w-4" />
|
||||||
provider={provider}
|
</button>
|
||||||
thinkingMode={thinkingMode}
|
|
||||||
setThinkingMode={setThinkingMode}
|
|
||||||
tokenBudget={tokenBudget}
|
|
||||||
slashCommandsCount={slashCommandsCount}
|
|
||||||
onToggleCommandMenu={onToggleCommandMenu}
|
|
||||||
hasInput={hasInput}
|
|
||||||
onClearInput={onClearInput}
|
|
||||||
isUserScrolledUp={isUserScrolledUp}
|
|
||||||
hasMessages={hasMessages}
|
|
||||||
onScrollToBottom={onScrollToBottom}
|
|
||||||
/>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className="relative mx-auto max-w-4xl">
|
|
||||||
{isDragActive && (
|
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-primary/15">
|
|
||||||
<div className="rounded-xl border border-border/30 bg-card p-4 shadow-lg">
|
|
||||||
<svg className="mx-auto mb-2 h-8 w-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p className="text-sm font-medium">Drop images here</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{attachedImages.length > 0 && (
|
|
||||||
<div className="mb-2 rounded-xl bg-muted/40 p-2">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{attachedImages.map((file, index) => (
|
|
||||||
<ImageAttachment
|
|
||||||
key={index}
|
|
||||||
file={file}
|
|
||||||
onRemove={() => onRemoveImage(index)}
|
|
||||||
uploadProgress={uploadingImages.get(file.name)}
|
|
||||||
error={imageErrors.get(file.name)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showFileDropdown && filteredFiles.length > 0 && (
|
{showFileDropdown && filteredFiles.length > 0 && (
|
||||||
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
|
<div className="absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md">
|
||||||
{filteredFiles.map((file, index) => (
|
{filteredFiles.map((file, index) => (
|
||||||
@@ -275,21 +251,56 @@ export default function ChatComposer({
|
|||||||
frequentCommands={frequentCommands}
|
frequentCommands={frequentCommands}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<PromptInput
|
||||||
|
onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void}
|
||||||
|
status={isLoading ? 'streaming' : 'ready'}
|
||||||
|
className={isTextareaExpanded ? 'chat-input-expanded' : ''}
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
className={`relative overflow-hidden rounded-2xl border border-border/50 bg-card/80 shadow-sm backdrop-blur-sm transition-all duration-200 focus-within:border-primary/30 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/15 ${
|
|
||||||
isTextareaExpanded ? 'chat-input-expanded' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<input {...getInputProps()} />
|
{isDragActive && (
|
||||||
<div ref={inputHighlightRef} aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl">
|
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-primary/15">
|
||||||
<div className="chat-input-placeholder block w-full whitespace-pre-wrap break-words py-1.5 pl-12 pr-20 text-base leading-6 text-transparent sm:py-4 sm:pr-40">
|
<div className="rounded-xl border border-border/30 bg-card p-4 shadow-lg">
|
||||||
{renderInputWithMentions(input)}
|
<svg className="mx-auto mb-2 h-8 w-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm font-medium">Drop images here</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="relative z-10">
|
{attachedImages.length > 0 && (
|
||||||
<textarea
|
<PromptInputHeader>
|
||||||
|
<div className="rounded-xl bg-muted/40 p-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{attachedImages.map((file, index) => (
|
||||||
|
<ImageAttachment
|
||||||
|
key={index}
|
||||||
|
file={file}
|
||||||
|
onRemove={() => onRemoveImage(index)}
|
||||||
|
uploadProgress={uploadingImages.get(file.name)}
|
||||||
|
error={imageErrors.get(file.name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PromptInputHeader>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
|
||||||
|
<PromptInputBody>
|
||||||
|
<div ref={inputHighlightRef} aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-xl">
|
||||||
|
<div className="chat-input-placeholder block w-full whitespace-pre-wrap break-words px-4 py-2 text-sm leading-6 text-transparent">
|
||||||
|
{renderInputWithMentions(input)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PromptInputTextarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={onInputChange}
|
onChange={onInputChange}
|
||||||
@@ -301,54 +312,109 @@ export default function ChatComposer({
|
|||||||
onBlur={() => onInputFocusChange?.(false)}
|
onBlur={() => onInputFocusChange?.(false)}
|
||||||
onInput={onTextareaInput}
|
onInput={onTextareaInput}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
|
|
||||||
style={{ height: '50px' }}
|
|
||||||
/>
|
/>
|
||||||
|
</PromptInputBody>
|
||||||
|
|
||||||
|
<PromptInputFooter>
|
||||||
|
<PromptInputTools>
|
||||||
|
<PromptInputButton
|
||||||
|
tooltip={{ content: t('input.attachImages') }}
|
||||||
|
onClick={openImagePicker}
|
||||||
|
>
|
||||||
|
<ImageIcon />
|
||||||
|
</PromptInputButton>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openImagePicker}
|
onClick={onModeSwitch}
|
||||||
className="absolute left-2 top-1/2 -translate-y-1/2 transform rounded-xl p-2 transition-colors hover:bg-accent/60"
|
className={`rounded-lg border px-2.5 py-1 text-xs font-medium transition-all duration-200 ${
|
||||||
title={t('input.attachImages')}
|
permissionMode === 'default'
|
||||||
|
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
|
||||||
|
: permissionMode === 'acceptEdits'
|
||||||
|
? 'border-green-300/60 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-600/40 dark:bg-green-900/15 dark:text-green-300 dark:hover:bg-green-900/25'
|
||||||
|
: permissionMode === 'bypassPermissions'
|
||||||
|
? 'border-orange-300/60 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-600/40 dark:bg-orange-900/15 dark:text-orange-300 dark:hover:bg-orange-900/25'
|
||||||
|
: 'border-primary/20 bg-primary/5 text-primary hover:bg-primary/10'
|
||||||
|
}`}
|
||||||
|
title={t('input.clickToChangeMode')}
|
||||||
>
|
>
|
||||||
<svg className="h-5 w-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="flex items-center gap-1.5">
|
||||||
<path
|
<div
|
||||||
strokeLinecap="round"
|
className={`h-1.5 w-1.5 rounded-full ${
|
||||||
strokeLinejoin="round"
|
permissionMode === 'default'
|
||||||
strokeWidth={2}
|
? 'bg-muted-foreground'
|
||||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
: permissionMode === 'acceptEdits'
|
||||||
|
? 'bg-green-500'
|
||||||
|
: permissionMode === 'bypassPermissions'
|
||||||
|
? 'bg-orange-500'
|
||||||
|
: 'bg-primary'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
</svg>
|
<span>
|
||||||
|
{permissionMode === 'default' && t('codex.modes.default')}
|
||||||
|
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
|
||||||
|
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
|
||||||
|
{permissionMode === 'plan' && t('codex.modes.plan')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{provider === 'claude' && (
|
||||||
type="submit"
|
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
||||||
disabled={!input.trim() || isLoading}
|
)}
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.preventDefault();
|
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
|
||||||
onSubmit(event);
|
|
||||||
}}
|
<PromptInputButton
|
||||||
onTouchStart={(event) => {
|
tooltip={{ content: t('input.showAllCommands') }}
|
||||||
event.preventDefault();
|
onClick={onToggleCommandMenu}
|
||||||
onSubmit(event);
|
className="relative"
|
||||||
}}
|
|
||||||
className="absolute right-2 top-1/2 flex h-10 w-10 -translate-y-1/2 transform items-center justify-center rounded-xl bg-primary transition-all duration-200 hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-1 focus:ring-offset-background disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground sm:h-11 sm:w-11"
|
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4 rotate-90 transform text-primary-foreground sm:h-[18px] sm:w-[18px]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<MessageSquareIcon />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
{slashCommandsCount > 0 && (
|
||||||
</svg>
|
<span
|
||||||
</button>
|
className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground"
|
||||||
|
>
|
||||||
|
{slashCommandsCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</PromptInputButton>
|
||||||
|
|
||||||
|
{hasInput && (
|
||||||
|
<PromptInputButton
|
||||||
|
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
|
||||||
|
onClick={onClearInput}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</PromptInputButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</PromptInputTools>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`pointer-events-none absolute bottom-1 left-12 right-14 hidden text-xs text-muted-foreground/50 transition-opacity duration-200 sm:right-40 sm:block ${
|
className={`hidden text-xs text-muted-foreground/50 transition-opacity duration-200 lg:block ${
|
||||||
input.trim() ? 'opacity-0' : 'opacity-100'
|
input.trim() ? 'opacity-0' : 'opacity-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
|
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
|
||||||
</div>
|
</div>
|
||||||
|
<PromptInputSubmit
|
||||||
|
disabled={!input.trim() || isLoading}
|
||||||
|
className="h-10 w-10 sm:h-10 sm:w-10"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit(event as unknown as MouseEvent<HTMLButtonElement>);
|
||||||
|
}}
|
||||||
|
onTouchStart={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit(event as unknown as TouchEvent<HTMLButtonElement>);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PromptInputFooter>
|
||||||
</form>}
|
</PromptInput>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import type { PermissionMode, Provider } from '../../types/types';
|
|
||||||
import ThinkingModeSelector from './ThinkingModeSelector';
|
|
||||||
import TokenUsagePie from './TokenUsagePie';
|
|
||||||
|
|
||||||
interface ChatInputControlsProps {
|
|
||||||
permissionMode: PermissionMode | string;
|
|
||||||
onModeSwitch: () => void;
|
|
||||||
provider: Provider | string;
|
|
||||||
thinkingMode: string;
|
|
||||||
setThinkingMode: React.Dispatch<React.SetStateAction<string>>;
|
|
||||||
tokenBudget: { used?: number; total?: number } | null;
|
|
||||||
slashCommandsCount: number;
|
|
||||||
onToggleCommandMenu: () => void;
|
|
||||||
hasInput: boolean;
|
|
||||||
onClearInput: () => void;
|
|
||||||
isUserScrolledUp: boolean;
|
|
||||||
hasMessages: boolean;
|
|
||||||
onScrollToBottom: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ChatInputControls({
|
|
||||||
permissionMode,
|
|
||||||
onModeSwitch,
|
|
||||||
provider,
|
|
||||||
thinkingMode,
|
|
||||||
setThinkingMode,
|
|
||||||
tokenBudget,
|
|
||||||
slashCommandsCount,
|
|
||||||
onToggleCommandMenu,
|
|
||||||
hasInput,
|
|
||||||
onClearInput,
|
|
||||||
isUserScrolledUp,
|
|
||||||
hasMessages,
|
|
||||||
onScrollToBottom,
|
|
||||||
}: ChatInputControlsProps) {
|
|
||||||
const { t } = useTranslation('chat');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onModeSwitch}
|
|
||||||
className={`rounded-lg border px-2.5 py-1 text-sm font-medium transition-all duration-200 sm:px-3 sm:py-1.5 ${
|
|
||||||
permissionMode === 'default'
|
|
||||||
? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'
|
|
||||||
: permissionMode === 'acceptEdits'
|
|
||||||
? 'border-green-300/60 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-600/40 dark:bg-green-900/15 dark:text-green-300 dark:hover:bg-green-900/25'
|
|
||||||
: permissionMode === 'bypassPermissions'
|
|
||||||
? 'border-orange-300/60 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-600/40 dark:bg-orange-900/15 dark:text-orange-300 dark:hover:bg-orange-900/25'
|
|
||||||
: 'border-primary/20 bg-primary/5 text-primary hover:bg-primary/10'
|
|
||||||
}`}
|
|
||||||
title={t('input.clickToChangeMode')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<div
|
|
||||||
className={`h-1.5 w-1.5 rounded-full ${
|
|
||||||
permissionMode === 'default'
|
|
||||||
? 'bg-muted-foreground'
|
|
||||||
: permissionMode === 'acceptEdits'
|
|
||||||
? 'bg-green-500'
|
|
||||||
: permissionMode === 'bypassPermissions'
|
|
||||||
? 'bg-orange-500'
|
|
||||||
: 'bg-primary'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{permissionMode === 'default' && t('codex.modes.default')}
|
|
||||||
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
|
|
||||||
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
|
|
||||||
{permissionMode === 'plan' && t('codex.modes.plan')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{provider === 'claude' && (
|
|
||||||
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggleCommandMenu}
|
|
||||||
className="relative flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground sm:h-8 sm:w-8"
|
|
||||||
title={t('input.showAllCommands')}
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4 sm:h-5 sm:w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{slashCommandsCount > 0 && (
|
|
||||||
<span
|
|
||||||
className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground sm:h-5 sm:w-5"
|
|
||||||
>
|
|
||||||
{slashCommandsCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{hasInput && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClearInput}
|
|
||||||
className="group flex h-7 w-7 items-center justify-center rounded-lg border border-border/50 bg-card shadow-sm transition-all duration-200 hover:bg-accent/60 sm:h-8 sm:w-8"
|
|
||||||
title={t('input.clearInput', { defaultValue: 'Clear input' })}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-3.5 w-3.5 text-muted-foreground transition-colors group-hover:text-foreground sm:h-4 sm:w-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isUserScrolledUp && hasMessages && (
|
|
||||||
<button
|
|
||||||
onClick={onScrollToBottom}
|
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all duration-200 hover:scale-105 hover:bg-primary/90 sm:h-8 sm:w-8"
|
|
||||||
title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}
|
|
||||||
>
|
|
||||||
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
219
src/shared/view/ui/PromptInput.tsx
Normal file
219
src/shared/view/ui/PromptInput.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { SendHorizonalIcon, SquareIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '../../../lib/utils';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import Tooltip from './Tooltip';
|
||||||
|
|
||||||
|
/* ─── Context ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
type PromptInputStatus = 'ready' | 'submitted' | 'streaming' | 'error';
|
||||||
|
|
||||||
|
interface PromptInputContextValue {
|
||||||
|
status: PromptInputStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PromptInputContext = React.createContext<PromptInputContextValue | null>(null);
|
||||||
|
|
||||||
|
const usePromptInput = () => {
|
||||||
|
const context = React.useContext(PromptInputContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('PromptInput components must be used within PromptInput');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ─── PromptInput (root form) ────────────────────────────────────── */
|
||||||
|
|
||||||
|
export interface PromptInputProps extends React.FormHTMLAttributes<HTMLFormElement> {
|
||||||
|
status?: PromptInputStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PromptInput = React.forwardRef<HTMLFormElement, PromptInputProps>(
|
||||||
|
({ className, status = 'ready', children, ...props }, ref) => {
|
||||||
|
const contextValue = React.useMemo(() => ({ status }), [status]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PromptInputContext.Provider value={contextValue}>
|
||||||
|
<form
|
||||||
|
ref={ref}
|
||||||
|
data-slot="prompt-input"
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden rounded-xl border border-border/50 bg-card/80 shadow-sm backdrop-blur-sm transition-all duration-200 focus-within:border-primary/30 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/15',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
|
</PromptInputContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
PromptInput.displayName = 'PromptInput';
|
||||||
|
|
||||||
|
/* ─── PromptInputHeader ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const PromptInputHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-slot="prompt-input-header"
|
||||||
|
className={cn('px-3 pt-3', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
PromptInputHeader.displayName = 'PromptInputHeader';
|
||||||
|
|
||||||
|
/* ─── PromptInputBody ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const PromptInputBody = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-slot="prompt-input-body"
|
||||||
|
className={cn('relative', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
PromptInputBody.displayName = 'PromptInputBody';
|
||||||
|
|
||||||
|
/* ─── PromptInputTextarea ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const PromptInputTextarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
data-slot="prompt-input-textarea"
|
||||||
|
className={cn(
|
||||||
|
'chat-input-placeholder block max-h-[40vh] w-full resize-none overflow-y-auto bg-transparent px-4 py-2 text-sm leading-6 text-foreground placeholder-muted-foreground/50 focus:outline-none sm:max-h-[300px]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
PromptInputTextarea.displayName = 'PromptInputTextarea';
|
||||||
|
|
||||||
|
/* ─── PromptInputFooter ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const PromptInputFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-slot="prompt-input-footer"
|
||||||
|
className={cn('flex items-center justify-between border-t border-border/30 px-3 py-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
PromptInputFooter.displayName = 'PromptInputFooter';
|
||||||
|
|
||||||
|
/* ─── PromptInputTools ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export const PromptInputTools = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-slot="prompt-input-tools"
|
||||||
|
className={cn('flex items-center gap-1', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
PromptInputTools.displayName = 'PromptInputTools';
|
||||||
|
|
||||||
|
/* ─── PromptInputButton ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export interface PromptInputButtonTooltip {
|
||||||
|
content: React.ReactNode;
|
||||||
|
shortcut?: string;
|
||||||
|
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptInputButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
tooltip?: PromptInputButtonTooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PromptInputButton = React.forwardRef<HTMLButtonElement, PromptInputButtonProps>(
|
||||||
|
({ className, tooltip, children, ...props }, ref) => {
|
||||||
|
const button = (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn('h-8 w-8 [&_svg]:size-4', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tooltip) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
tooltip.shortcut ? (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{tooltip.content}
|
||||||
|
<kbd className="rounded bg-white/20 px-1 text-[10px]">{tooltip.shortcut}</kbd>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
tooltip.content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
position={tooltip.side ?? 'top'}
|
||||||
|
>
|
||||||
|
{button}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
PromptInputButton.displayName = 'PromptInputButton';
|
||||||
|
|
||||||
|
/* ─── PromptInputSubmit ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export interface PromptInputSubmitProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
status?: PromptInputStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PromptInputSubmit = React.forwardRef<HTMLButtonElement, PromptInputSubmitProps>(
|
||||||
|
({ className, status: statusProp, children, ...props }, ref) => {
|
||||||
|
const context = React.useContext(PromptInputContext);
|
||||||
|
const status = statusProp ?? context?.status ?? 'ready';
|
||||||
|
const isActive = status === 'submitted' || status === 'streaming';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
type={isActive ? 'button' : 'submit'}
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
className={cn('h-8 w-8 rounded-lg', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (isActive ? (
|
||||||
|
<SquareIcon className="h-3.5 w-3.5 fill-current" />
|
||||||
|
) : (
|
||||||
|
<SendHorizonalIcon className="h-4 w-4" />
|
||||||
|
))}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
PromptInputSubmit.displayName = 'PromptInputSubmit';
|
||||||
|
|
||||||
|
export { usePromptInput };
|
||||||
@@ -12,4 +12,5 @@ export { ScrollArea } from './ScrollArea';
|
|||||||
export { Reasoning, ReasoningTrigger, ReasoningContent, useReasoning } from './Reasoning';
|
export { Reasoning, ReasoningTrigger, ReasoningContent, useReasoning } from './Reasoning';
|
||||||
export { Shimmer } from './Shimmer';
|
export { Shimmer } from './Shimmer';
|
||||||
export { default as Tooltip } from './Tooltip';
|
export { default as Tooltip } from './Tooltip';
|
||||||
|
export { PromptInput, PromptInputHeader, PromptInputBody, PromptInputTextarea, PromptInputFooter, PromptInputTools, PromptInputButton, PromptInputSubmit } from './PromptInput';
|
||||||
export { PillBar, Pill } from './PillBar';
|
export { PillBar, Pill } from './PillBar';
|
||||||
|
|||||||
Reference in New Issue
Block a user