feat: optimistic message rendering with send status indicators
- User messages appear instantly with 'sending' state (dimmed, clock icon) - Transitions to 'sent' (checkmark) when server acknowledges - Shows error state (alert icon, retry visible) if send fails - Applied to both primary and secondary sessions
This commit is contained in:
@@ -329,7 +329,7 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey
|
||||
onClick={handleSubmit}
|
||||
disabled={(!text.trim() && files.length === 0) || disabled}
|
||||
aria-label={t('chat.send')}
|
||||
className="shrink-0 h-11 px-5 rounded-2xl bg-gradient-to-r from-cyan-500/80 via-indigo-500/70 to-violet-500/80 text-pc-text font-semibold text-sm hover:opacity-90 shadow-[0_8px_24px_rgba(34,211,238,0.1)] disabled:opacity-30 disabled:shadow-none transition-all flex items-center gap-2"
|
||||
className="shrink-0 h-11 px-5 rounded-2xl bg-[var(--pc-accent)] text-white font-semibold text-sm hover:opacity-90 shadow-[0_8px_24px_rgba(var(--pc-accent-rgb),0.15)] disabled:opacity-30 disabled:shadow-none transition-all flex items-center gap-2"
|
||||
>
|
||||
<Send size={16} />
|
||||
<span className="hidden sm:inline">{t('chat.send')}</span>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { CodeBlock } from './CodeBlock';
|
||||
import { ToolCall } from './ToolCall';
|
||||
import { ImageBlock } from './ImageBlock';
|
||||
import { buildImageSrc } from '../lib/image';
|
||||
import { Bot, User, Wrench, Copy, Check, RefreshCw, Zap, Info, Webhook, Braces } from 'lucide-react';
|
||||
import { Bot, User, Wrench, Copy, Check, CheckCheck, RefreshCw, Zap, Info, Webhook, Braces, Clock, AlertCircle } from 'lucide-react';
|
||||
import { t, getLocale } from '../lib/i18n';
|
||||
import { useLocale } from '../hooks/useLocale';
|
||||
import { stripWebhookScaffolding, hasWebhookScaffolding } from '../lib/systemEvent';
|
||||
@@ -431,7 +431,7 @@ export function ChatMessageComponent({ message: rawMessage, onRetry, agentAvatar
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`animate-fade-in flex gap-3 px-4 py-2 ${isUser ? 'flex-row-reverse' : ''}`}>
|
||||
<div className={`animate-fade-in flex gap-3 px-4 py-2 ${isUser ? 'flex-row-reverse' : ''} ${message.sendStatus === 'sending' ? 'opacity-70' : ''} ${message.sendStatus === 'error' ? 'opacity-60' : ''}`}>
|
||||
{/* Avatar */}
|
||||
<div className="shrink-0 mt-1 flex h-9 w-9 items-center justify-center rounded-2xl border border-pc-border bg-pc-elevated/40 overflow-hidden">
|
||||
{isUser
|
||||
@@ -446,7 +446,7 @@ export function ChatMessageComponent({ message: rawMessage, onRetry, agentAvatar
|
||||
<div className={`min-w-0 max-w-[80%] ${isUser ? 'text-right' : ''}`}>
|
||||
<div className={`group relative inline-block text-left rounded-3xl px-4 py-3 text-sm leading-relaxed max-w-full overflow-hidden ${
|
||||
isUser
|
||||
? 'bg-[var(--pc-user-bubble)] text-pc-text border border-[var(--pc-user-border)]'
|
||||
? 'bg-[rgba(var(--pc-accent-rgb),0.08)] text-pc-text border border-[rgba(var(--pc-accent-rgb),0.2)]'
|
||||
: 'bg-pc-elevated/40 text-pc-text border border-pc-border shadow-[0_0_0_1px_rgba(255,255,255,0.03)]'
|
||||
}`}>
|
||||
{/* Action buttons */}
|
||||
@@ -461,11 +461,11 @@ export function ChatMessageComponent({ message: rawMessage, onRetry, agentAvatar
|
||||
{isUser && onRetry && (
|
||||
<button
|
||||
onClick={() => onRetry(getPlainText(message))}
|
||||
className="absolute top-2 right-2 h-7 w-7 rounded-lg border border-pc-border bg-pc-elevated/80 backdrop-blur-sm flex items-center justify-center text-pc-text-secondary hover:text-pc-accent-light hover:border-[var(--pc-accent-dim)] transition-all opacity-0 group-hover:opacity-100"
|
||||
className={`absolute top-2 right-2 h-7 w-7 rounded-lg border border-pc-border bg-pc-elevated/80 backdrop-blur-sm flex items-center justify-center text-pc-text-secondary hover:text-pc-accent-light hover:border-[var(--pc-accent-dim)] transition-all ${message.sendStatus === 'error' ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
|
||||
title={t('message.retry')}
|
||||
aria-label={t('message.retry')}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
<RefreshCw size={13} className={message.sendStatus === 'error' ? 'text-red-400' : ''} />
|
||||
</button>
|
||||
)}
|
||||
{/* User-visible text */}
|
||||
@@ -510,6 +510,15 @@ export function ChatMessageComponent({ message: rawMessage, onRetry, agentAvatar
|
||||
</span>
|
||||
)}
|
||||
{message.timestamp && formatTimestamp(message.timestamp)}
|
||||
{isUser && message.sendStatus === 'sending' && (
|
||||
<span title="Sending..."><Clock size={10} className="animate-pulse text-pc-text-faint" /></span>
|
||||
)}
|
||||
{isUser && message.sendStatus === 'sent' && (
|
||||
<span title="Sent"><CheckCheck size={10} className="text-pc-accent" /></span>
|
||||
)}
|
||||
{isUser && message.sendStatus === 'error' && (
|
||||
<span title="Failed to send"><AlertCircle size={10} className="text-red-400" /></span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
|
||||
if (!ctx) return null;
|
||||
const pct = Math.min(100, (total / ctx) * 100);
|
||||
const opacity = Math.max(0.35, Math.min(1, pct / 100));
|
||||
const barStyle = { width: `${pct}%`, backgroundColor: `rgba(56, 189, 248, ${opacity})` };
|
||||
const barStyle = { width: `${pct}%`, backgroundColor: `rgba(var(--pc-accent-rgb), ${opacity})` };
|
||||
return (
|
||||
<div className="px-4 py-1.5 bg-[var(--pc-bg-surface)]/60 border-b border-pc-border flex items-center gap-3">
|
||||
{activeSessionData?.model && (
|
||||
|
||||
@@ -370,7 +370,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit,
|
||||
if (!s.contextTokens) return null;
|
||||
const pct = Math.min(100, ((s.totalTokens || 0) / s.contextTokens) * 100);
|
||||
const barOpacity = Math.max(0.35, Math.min(1, pct / 100));
|
||||
const barStyle = { width: `${pct}%`, backgroundColor: `rgba(56, 189, 248, ${barOpacity})` };
|
||||
const barStyle = { width: `${pct}%`, backgroundColor: `rgba(var(--pc-accent-rgb), ${barOpacity})` };
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<div className="flex-1 h-[3px] rounded-full bg-[var(--pc-hover)] overflow-hidden">
|
||||
|
||||
@@ -7,31 +7,48 @@ import { useToolCollapse } from '../hooks/useToolCollapse';
|
||||
|
||||
type ToolColor = { border: string; bg: string; text: string; icon: string; glow: string; expandBorder: string; expandBg: string };
|
||||
|
||||
const toolColors: Record<string, ToolColor> = {
|
||||
exec: { border: 'border-amber-500/30', bg: 'bg-amber-500/10', text: 'text-amber-300', icon: 'text-amber-400', glow: 'shadow-[0_0_8px_rgba(245,158,11,0.15)]', expandBorder: 'border-amber-500/20', expandBg: 'bg-amber-950/20' },
|
||||
web_search: { border: 'border-emerald-500/30', bg: 'bg-emerald-500/10', text: 'text-emerald-300', icon: 'text-emerald-400', glow: 'shadow-[0_0_8px_rgba(16,185,129,0.15)]', expandBorder: 'border-emerald-500/20', expandBg: 'bg-emerald-950/20' },
|
||||
web_fetch: { border: 'border-emerald-500/30', bg: 'bg-emerald-500/10', text: 'text-emerald-300', icon: 'text-emerald-400', glow: 'shadow-[0_0_8px_rgba(16,185,129,0.15)]', expandBorder: 'border-emerald-500/20', expandBg: 'bg-emerald-950/20' },
|
||||
Read: { border: 'border-sky-500/30', bg: 'bg-sky-500/10', text: 'text-sky-300', icon: 'text-sky-400', glow: 'shadow-[0_0_8px_rgba(14,165,233,0.15)]', expandBorder: 'border-sky-500/20', expandBg: 'bg-sky-950/20' },
|
||||
read: { border: 'border-sky-500/30', bg: 'bg-sky-500/10', text: 'text-sky-300', icon: 'text-sky-400', glow: 'shadow-[0_0_8px_rgba(14,165,233,0.15)]', expandBorder: 'border-sky-500/20', expandBg: 'bg-sky-950/20' },
|
||||
Write: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
|
||||
write: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
|
||||
Edit: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
|
||||
edit: { border: 'border-violet-500/30', bg: 'bg-violet-500/10', text: 'text-violet-300', icon: 'text-violet-400', glow: 'shadow-[0_0_8px_rgba(139,92,246,0.15)]', expandBorder: 'border-violet-500/20', expandBg: 'bg-violet-950/20' },
|
||||
browser: { border: 'border-cyan-500/30', bg: 'bg-cyan-500/10', text: 'text-pc-accent-light', icon: 'text-pc-accent', glow: 'shadow-[0_0_8px_rgba(6,182,212,0.15)]', expandBorder: 'border-cyan-500/20', expandBg: 'bg-cyan-950/20' },
|
||||
image: { border: 'border-pink-500/30', bg: 'bg-pink-500/10', text: 'text-pink-300', icon: 'text-pink-400', glow: 'shadow-[0_0_8px_rgba(236,72,153,0.15)]', expandBorder: 'border-pink-500/20', expandBg: 'bg-pink-950/20' },
|
||||
message: { border: 'border-indigo-500/30', bg: 'bg-indigo-500/10', text: 'text-indigo-300', icon: 'text-indigo-400', glow: 'shadow-[0_0_8px_rgba(99,102,241,0.15)]', expandBorder: 'border-indigo-500/20', expandBg: 'bg-indigo-950/20' },
|
||||
memory_search: { border: 'border-rose-500/30', bg: 'bg-rose-500/10', text: 'text-rose-300', icon: 'text-rose-400', glow: 'shadow-[0_0_8px_rgba(244,63,94,0.15)]', expandBorder: 'border-rose-500/20', expandBg: 'bg-rose-950/20' },
|
||||
memory_get: { border: 'border-rose-500/30', bg: 'bg-rose-500/10', text: 'text-rose-300', icon: 'text-rose-400', glow: 'shadow-[0_0_8px_rgba(244,63,94,0.15)]', expandBorder: 'border-rose-500/20', expandBg: 'bg-rose-950/20' },
|
||||
cron: { border: 'border-orange-500/30', bg: 'bg-orange-500/10', text: 'text-orange-300', icon: 'text-orange-400', glow: 'shadow-[0_0_8px_rgba(249,115,22,0.15)]', expandBorder: 'border-orange-500/20', expandBg: 'bg-orange-950/20' },
|
||||
sessions_spawn: { border: 'border-teal-500/30', bg: 'bg-teal-500/10', text: 'text-teal-300', icon: 'text-teal-400', glow: 'shadow-[0_0_8px_rgba(20,184,166,0.15)]', expandBorder: 'border-teal-500/20', expandBg: 'bg-teal-950/20' },
|
||||
// RGB values for each tool color — used with rgba() for theme-safe rendering
|
||||
type ToolRGB = { r: number; g: number; b: number };
|
||||
const toolRGBs: Record<string, ToolRGB> = {
|
||||
exec: { r: 245, g: 158, b: 11 }, // amber
|
||||
web_search: { r: 16, g: 185, b: 129 }, // emerald
|
||||
web_fetch: { r: 16, g: 185, b: 129 },
|
||||
Read: { r: 14, g: 165, b: 233 }, // sky
|
||||
read: { r: 14, g: 165, b: 233 },
|
||||
Write: { r: 139, g: 92, b: 246 }, // violet
|
||||
write: { r: 139, g: 92, b: 246 },
|
||||
Edit: { r: 139, g: 92, b: 246 },
|
||||
edit: { r: 139, g: 92, b: 246 },
|
||||
browser: { r: 6, g: 182, b: 212 }, // cyan
|
||||
image: { r: 236, g: 72, b: 153 }, // pink
|
||||
message: { r: 99, g: 102, b: 241 }, // indigo
|
||||
memory_search: { r: 244, g: 63, b: 94 }, // rose
|
||||
memory_get: { r: 244, g: 63, b: 94 },
|
||||
cron: { r: 249, g: 115, b: 22 }, // orange
|
||||
sessions_spawn: { r: 20, g: 184, b: 166 },// teal
|
||||
};
|
||||
const defaultRGB: ToolRGB = { r: 161, g: 161, b: 170 }; // zinc
|
||||
|
||||
const defaultColor: ToolColor = { border: 'border-pc-border-strong', bg: 'bg-pc-elevated/10', text: 'text-pc-text', icon: 'text-pc-text-secondary', glow: 'shadow-[0_0_8px_rgba(161,161,170,0.1)]', expandBorder: 'border-pc-border', expandBg: 'bg-pc-elevated/25' };
|
||||
function rgbStr(c: ToolRGB): string { return `${c.r},${c.g},${c.b}`; }
|
||||
|
||||
function getColor(name: string): ToolColor {
|
||||
return toolColors[name] || defaultColor;
|
||||
function getColorStyles(name: string): { badge: React.CSSProperties; text: React.CSSProperties; expand: React.CSSProperties; glow: string } {
|
||||
const c = toolRGBs[name] || defaultRGB;
|
||||
const rgb = rgbStr(c);
|
||||
return {
|
||||
badge: { borderColor: `rgba(${rgb},0.3)`, backgroundColor: `rgba(${rgb},0.10)` },
|
||||
text: { color: `rgb(${c.r},${c.g},${c.b})` },
|
||||
expand: { borderColor: `rgba(${rgb},0.2)`, backgroundColor: `rgba(${rgb},0.05)` },
|
||||
glow: `shadow-[0_0_8px_rgba(${rgb},0.15)]`,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep ToolColor type for compatibility but now only used for classes that are theme-safe
|
||||
const toolColors: Record<string, ToolColor> = {};
|
||||
const defaultColor: ToolColor = { border: '', bg: '', text: '', icon: '', glow: '', expandBorder: '', expandBg: '' };
|
||||
|
||||
// toolColors/defaultColor kept for potential future use
|
||||
void toolColors; void defaultColor;
|
||||
|
||||
const toolEmojis: Record<string, string> = {
|
||||
exec: '⚡',
|
||||
web_search: '🔍',
|
||||
@@ -237,7 +254,7 @@ export function ToolCall({ name, input, result }: { name: string; input?: Record
|
||||
const [wrap, setWrap] = useState(true);
|
||||
const { globalState, version } = useToolCollapse();
|
||||
const lastVersion = useRef(version);
|
||||
const c = getColor(name);
|
||||
const cs = getColorStyles(name);
|
||||
|
||||
// Respond to global collapse/expand commands
|
||||
useEffect(() => {
|
||||
@@ -256,7 +273,8 @@ export function ToolCall({ name, input, result }: { name: string; input?: Record
|
||||
{/* Tool use badge */}
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-2xl border ${c.border} ${c.bg} ${c.glow} px-3 py-1.5 text-xs ${c.text} hover:brightness-125 transition-all max-w-full`}
|
||||
className={`inline-flex items-center gap-1.5 rounded-2xl border px-3 py-1.5 text-xs hover:brightness-125 transition-all max-w-full ${cs.glow}`}
|
||||
style={{ ...cs.badge, ...cs.text }}
|
||||
>
|
||||
<span className="text-[13px] leading-none">{getToolEmoji(name)}</span>
|
||||
<span className="font-mono font-semibold shrink-0">{name}</span>
|
||||
@@ -273,10 +291,10 @@ export function ToolCall({ name, input, result }: { name: string; input?: Record
|
||||
|
||||
{/* Expanded content */}
|
||||
{open && (
|
||||
<div className={`mt-2 rounded-2xl border ${c.expandBorder} ${c.expandBg} p-3 space-y-2 overflow-hidden min-w-0`}>
|
||||
<div className="mt-2 rounded-2xl border p-3 space-y-2 overflow-hidden min-w-0" style={cs.expand}>
|
||||
{inputStr && (
|
||||
<div>
|
||||
<div className={`text-[11px] ${c.text} opacity-70 mb-1 font-medium`}>{t('tool.parameters')}</div>
|
||||
<div className="text-[11px] opacity-70 mb-1 font-medium" style={cs.text}>{t('tool.parameters')}</div>
|
||||
<div className="group/tc-block relative">
|
||||
<HighlightedPre
|
||||
text={inputStr}
|
||||
@@ -292,7 +310,7 @@ export function ToolCall({ name, input, result }: { name: string; input?: Record
|
||||
const imageData = extractImageFromResult(result);
|
||||
return (
|
||||
<div>
|
||||
<div className={`text-[11px] ${c.text} opacity-70 mb-1 font-medium`}>{t('tool.result')}</div>
|
||||
<div className="text-[11px] opacity-70 mb-1 font-medium" style={cs.text}>{t('tool.result')}</div>
|
||||
{imageData ? (
|
||||
<>
|
||||
{imageData.remaining && (
|
||||
|
||||
Reference in New Issue
Block a user