From c808863c0cac25fad4ffb783e7cca54629dc0e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=E1=BA=BF=20Anh=20=C4=90=E1=BA=B7ng?= Date: Sun, 11 Oct 2026 13:07:54 +0700 Subject: [PATCH 01/10] Redesign chat panel with brand gradient and cleaner theme tokens - Replace CSS variable color usage with explicit light/dark theme tokens - Render user messages as gradient bubbles and assistant messages as cards - Add auto-growing textarea with icon send button and rounded controls - Introduce reusable gradient and ghost button styles for approvals and proposals --- web/src/components/ide/ChatPanel.tsx | 230 +++++++++++++++++---------- 1 file changed, 145 insertions(+), 85 deletions(-) diff --git a/web/src/components/ide/ChatPanel.tsx b/web/src/components/ide/ChatPanel.tsx index 75629cb..3b9a517 100644 --- a/web/src/components/ide/ChatPanel.tsx +++ b/web/src/components/ide/ChatPanel.tsx @@ -20,10 +20,33 @@ import { type AIProvider, type AIProviderModel, } from '../../lib/chat' -import { ChevronDownIcon, SettingsIcon } from './icons' +import { ArrowUpIcon, ChevronDownIcon, SettingsIcon } from './icons' -const selectClass = - 'rounded-md border border-[var(--border)] bg-[var(--bg)] px-2.5 py-1.5 text-base text-[var(--fg)] outline-none focus:border-indigo-500 md:text-xs' +const BRAND_GRADIENT = 'linear-gradient(90deg, #DC2626 0%, #F97316 100%)' +const USER_BUBBLE_GRADIENT = 'linear-gradient(90deg, #DC2626 0%, #C2410C 100%)' +const brandStyle = { backgroundImage: BRAND_GRADIENT } + +const MAX_TEXTAREA_HEIGHT = 192 + +const pageBg = 'bg-[#F9F9F9] dark:bg-[#0F0F0F]' +const cardBg = 'bg-white dark:bg-[#1F1F1F]' +const subtleBg = 'bg-[#F2F2F2] dark:bg-[#272727]' +const cardBorder = 'border-[#E5E7EB] dark:border-[#2E2E2E]' +const primaryText = 'text-[#0F0F0F] dark:text-[#F1F1F1]' +const mutedText = 'text-[#6B6B6B] dark:text-[#AAAAAA]' +const dangerText = 'text-[#DC2626] dark:text-[#F87171]' +const accentText = 'text-[#F97316]' +const accentBg = 'bg-[#F97316]' +const dotIdle = 'bg-[#B8B8B8] dark:bg-[#555555]' +const focusRing = + 'focus:border-[#F97316] focus:ring-2 focus:ring-[#F97316]/30' +const placeholderText = 'placeholder:text-[#6B6B6B] dark:placeholder:text-[#AAAAAA]' + +const selectClass = `rounded-lg border ${cardBorder} ${subtleBg} px-2.5 py-1.5 text-base ${primaryText} outline-none transition ${focusRing} disabled:opacity-50 md:text-xs` + +const gradientButton = + 'inline-flex items-center justify-center rounded-full font-medium text-white shadow-sm transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:brightness-100' +const ghostButton = `inline-flex items-center justify-center rounded-full border ${cardBorder} ${subtleBg} px-3 py-1.5 text-xs ${primaryText} transition hover:brightness-95 disabled:opacity-50` type ToolLabelKey = | 'chat.toolList' @@ -65,7 +88,7 @@ function ThinkingBlock({ text, streaming }: { text: string; streaming: boolean } type="button" onClick={() => setOpen((value) => !value)} aria-expanded={open} - className="flex items-center gap-1 text-xs font-medium text-[var(--muted-2)] transition hover:text-[var(--fg-2)]" + className={`flex items-center gap-1 text-xs font-medium ${mutedText} transition hover:opacity-80`} > {open && ( -
+
{text || t('chat.thinking')}
)} @@ -88,17 +111,20 @@ function ThinkingBlock({ text, streaming }: { text: string; streaming: boolean } function ToolSteps({ steps, streaming }: { steps: string[]; streaming: boolean }) { return ( ) } @@ -143,6 +169,7 @@ export function ChatPanel({ const [controlsOpen, setControlsOpen] = useState(false) const abortRef = useRef(null) const endRef = useRef(null) + const textareaRef = useRef(null) const optimisticIdRef = useRef(0) const onStreamingChangeRef = useRef(onStreamingChange) @@ -235,6 +262,13 @@ export function ChatPanel({ endRef.current?.scrollIntoView({ block: 'end', behavior: 'smooth' }) }, [messages, pendingCommand]) + useEffect(() => { + const el = textareaRef.current + if (!el) return + el.style.height = 'auto' + el.style.height = `${Math.min(el.scrollHeight, MAX_TEXTAREA_HEIGHT)}px` + }, [draft]) + async function refreshConversations(): Promise { const result = await fetchAIConversations(projectId) setConversations(result) @@ -466,7 +500,7 @@ export function ChatPanel({ const provider = providers.find((item) => item.id === providerId) const canSend = Boolean(provider && modelId && draft.trim() && !streaming) - const fieldLabelClass = `flex text-xs text-[var(--muted-2)] ${ + const fieldLabelClass = `flex text-xs ${mutedText} ${ isMobile ? 'flex-col gap-1' : 'items-center gap-2' }` const fieldSelectClass = `${selectClass} ${isMobile ? 'w-full' : ''}` @@ -506,17 +540,17 @@ export function ChatPanel({
-
+
{isMobile && ( )} @@ -614,63 +648,79 @@ export function ChatPanel({
- {loading &&

{t('common.loading')}

} + {loading &&

{t('common.loading')}

} {!loading && providers.length === 0 && (
-

{t('chat.noProviders')}

-

{t('chat.configureProvider')}

+

{t('chat.noProviders')}

+

{t('chat.configureProvider')}

)} {!loading && providers.length > 0 && messages.length === 0 && ( -
+
{t('chat.empty')}
)} - {loadError &&

{loadError}

} - {messages.map((message) => ( -
-

- {message.role === 'user' ? t('chat.you') : t('chat.assistant')} -

- {message.role === 'assistant' && message.thinking && ( - - )} - {message.role === 'assistant' && message.steps && message.steps.length > 0 && ( - - )} -
- {message.content || - (message.status === 'streaming' && !message.thinking && !message.steps?.length - ? t('chat.thinking') - : '')} -
- {message.status === 'failed' && ( -

- {message.error || t('chat.failedStatus')} + {loadError &&

{loadError}

} + {messages.map((message) => { + const isUser = message.role === 'user' + return ( +
+

+ {isUser ? t('chat.you') : t('chat.assistant')}

- )} - {message.role === 'assistant' && message.notice && ( -

{message.notice}

- )} - {message.status === 'interrupted' && ( -

{t('chat.interrupted')}

- )} -
- ))} + {!isUser && message.thinking && ( + + )} + {!isUser && message.steps && message.steps.length > 0 && ( + + )} +
+ {message.content || + (message.status === 'streaming' && !message.thinking && !message.steps?.length + ? t('chat.thinking') + : '')} +
+ {message.status === 'failed' && ( +

+ {message.error || t('chat.failedStatus')} +

+ )} + {!isUser && message.notice && ( +

{message.notice}

+ )} + {message.status === 'interrupted' && ( +

+ {t('chat.interrupted')} +

+ )} +
+ ) + })} {pendingCommand && ( -
-

+
+

{t('chat.commandApprovalTitle')}

-

+

{t('chat.commandApprovalHint')}

-
+            
               {pendingCommand.command}
             
@@ -678,7 +728,8 @@ export function ChatPanel({ type="button" onClick={() => void decideCommand(true)} disabled={approvalBusy} - className="rounded-md bg-emerald-700 px-3 py-2 text-xs md:py-1.5 font-medium text-white hover:bg-emerald-600 disabled:opacity-50" + style={brandStyle} + className={`${gradientButton} px-3 py-1.5 text-xs`} > {t('chat.runCommand')} @@ -686,7 +737,7 @@ export function ChatPanel({ type="button" onClick={() => void decideCommand(false)} disabled={approvalBusy} - className="rounded-md border border-[var(--border-strong)] px-3 py-2 text-xs md:py-1.5 text-[var(--fg-2)] hover:bg-[var(--hover)] disabled:opacity-50" + className={ghostButton} > {t('chat.cancelCommand')} @@ -696,26 +747,26 @@ export function ChatPanel({ {proposals.map((proposal) => (
-

+

{t('chat.proposalTitle')} · {t(`chat.proposalKind.${proposal.change_type}`)}

-

+

{proposal.path} {proposal.change_type === 'move' && proposal.target_path ? ` → ${proposal.target_path}` : ''}

- + {t(`chat.proposalStatus.${proposal.status}`)}
{proposal.diff && ( -
+              
                 {proposal.diff}
               
)} @@ -724,14 +775,15 @@ export function ChatPanel({ @@ -742,20 +794,24 @@ export function ChatPanel({
-
- {error &&

{error}

} +
+ {error &&

{error}

} {!provider && providers.length > 0 && ( -

+

{t('chat.selectProvider')}

)} {provider && models.length === 0 && ( -

+

{t('chat.modelsUnavailable')}

)} -
void send(event)} className="mx-auto flex max-w-3xl gap-2"> + void send(event)} + className="mx-auto flex max-w-3xl items-end gap-2" + >