feat: refactor coach chat to ChatGPT-style Material You design

- Replaced Gemini-style glass/blue aesthetic with clean Material You pink theme
- ChatGPT-style message layout: user right-aligned, coach left-aligned with avatars
- User avatar shows initials, coach avatar shows brain icon
- Chat bubble design: rounded cards matching app theme (no glass/blur effects)
- Clean textarea input with Enter to send, Shift+Enter for newlines
- ChatGPT-style empty state with suggestion chips
- Typing indicator with bouncing dots animation
- Sidebar uses Material You surface/container colors
- Status bar shows model name and ready/thinking state
- Error messages styled as Material You error containers
- Bottom hint text: 'coach can make mistakes'
- All colors use CSS custom properties for accent theme consistency
This commit is contained in:
Ned
2026-05-22 22:01:26 +00:00
parent 084acfa84a
commit d312321ffa
2 changed files with 362 additions and 197 deletions
+147 -113
View File
@@ -1785,14 +1785,14 @@ function CoachView({ dashboard, entries, user }: { dashboard: Dashboard; entries
if (!chatKey) {
return (
<section className="coach-gemini-shell coach-locked-shell">
<section className="coach-shell coach-locked-shell">
<div className="coach-empty-state">
<div className="coach-brand-orb">
<Lock size={30} aria-hidden="true" />
<div className="coach-empty-icon">
<Lock size={28} aria-hidden="true" />
</div>
<h2>unlock encrypted coach chats</h2>
<h2>unlock coach</h2>
<p>
messages encrypt in this browser before appwrite stores them. the passphrase is never saved, so use the same one on every device.
messages are encrypted before appwrite stores them. your passphrase is never saved use the same one on every device.
</p>
<form className="coach-unlock-card" onSubmit={unlockChats}>
<input
@@ -1808,148 +1808,182 @@ function CoachView({ dashboard, entries, user }: { dashboard: Dashboard; entries
unlock
</button>
</form>
{error && <p className="mt-4 max-w-xl text-sm text-red-100">{error}</p>}
{error && <p className="mt-4 max-w-md text-sm" style={{ color: "var(--error)" }}>{error}</p>}
</div>
</section>
);
}
const userInitials = user.name
? user.name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2)
: (user.email?.[0] ?? "U").toUpperCase();
return (
<section className="coach-gemini-shell">
<aside className="coach-chat-sidebar">
<div className="coach-sidebar-brand">
<div className="coach-brand-orb coach-brand-orb-small">
<Brain size={18} aria-hidden="true" />
</div>
<div>
<p className="font-semibold text-white">coach</p>
<p className="text-xs text-slate-400">{chatStorageStatus}</p>
</div>
</div>
<button className="coach-new-chat" type="button" onClick={startNewChat} disabled={busy}>
<MessageSquarePlus size={18} aria-hidden="true" />
new chat
</button>
<div className="coach-chat-list">
{chats.map((chat) => (
<div key={chat.id} className={`coach-chat-row ${chat.id === activeChatId ? "coach-chat-row-active" : ""}`}>
<button type="button" onClick={() => setActiveChatId(chat.id)}>
<span>{chat.title}</span>
<small>{new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "short" }).format(new Date(chat.updatedAt))}</small>
</button>
<button type="button" aria-label={`delete ${chat.title}`} onClick={() => void removeChat(chat.id)} disabled={busy}>
<Trash2 size={14} aria-hidden="true" />
</button>
<section className="coach-shell">
<div className="coach-layout">
<aside className="coach-sidebar">
<div className="coach-sidebar-header">
<div className="coach-sidebar-icon">
<Brain size={18} aria-hidden="true" />
</div>
))}
</div>
<div className="coach-context-card">
<p className="text-xs font-semibold uppercase text-slate-500">today</p>
<div className="mt-3 grid gap-2">
<WellnessPill label="cans" value={dashboard.todayCans} />
<WellnessPill label="caffeine" value={dashboard.todayCaffeine} />
<WellnessPill label="favourite" value={dashboard.favouriteFlavour} />
</div>
</div>
</aside>
<section className="coach-stage">
<div className="coach-stage-topbar">
<span>{OLLAMA_MODEL}</span>
<span>{busy ? "thinking" : "ready"}</span>
</div>
<div className="coach-stage-messages" aria-live="polite">
{!visibleMessages.length ? (
<div className="coach-empty-state">
<div className="coach-brand-orb">
<Sparkles size={32} aria-hidden="true" />
</div>
<h2>ready when you are</h2>
<p>ask about caffeine pace, sugar, spend, or your flavour pattern. answers stay lower case.</p>
<div className="coach-prompt-grid">
{quickPrompts.map((prompt) => (
<button key={prompt} className="suggestion-chip" type="button" disabled={busy} onClick={() => void sendPrompt(prompt)}>
{prompt}
</button>
))}
</div>
<div className="coach-sidebar-label">
<p>coach</p>
<p>{chatStorageStatus}</p>
</div>
) : (
visibleMessages.map((message) => (
<CoachMessageBubble
key={message.id}
message={message}
thinkingOpen={openThinkingIds.includes(message.id)}
onToggleThinking={() => toggleThinking(message.id)}
/>
))
)}
<div ref={messagesEndRef} />
</div>
{error && (
<div className="mx-4 mb-3 rounded-lg border border-red-400/40 bg-red-500/10 px-3 py-2 text-sm text-red-100">
{error}
</div>
)}
<form className="coach-composer" onSubmit={submit}>
<button className="composer-icon-button" type="button" onClick={startNewChat} disabled={busy} aria-label="new chat">
<Plus size={22} aria-hidden="true" />
<button className="coach-new-chat" type="button" onClick={startNewChat} disabled={busy}>
<Plus size={16} aria-hidden="true" />
new chat
</button>
<input
className="coach-input"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="ask coach"
disabled={busy}
/>
{busy ? (
<button className="composer-send-button composer-stop-button" type="button" onClick={stopThinking} aria-label="stop thinking">
<Square size={18} aria-hidden="true" />
</button>
) : (
<button className="composer-send-button" type="submit" disabled={!input.trim()} aria-label="send coach message">
<Send size={20} aria-hidden="true" />
</button>
<div className="coach-chat-list">
{chats.map((chat) => (
<div key={chat.id} className={`coach-chat-row ${chat.id === activeChatId ? "coach-chat-row-active" : ""}`}>
<button type="button" onClick={() => setActiveChatId(chat.id)}>
<span>{chat.title}</span>
<small>{new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "short" }).format(new Date(chat.updatedAt))}</small>
</button>
<button type="button" aria-label={`delete ${chat.title}`} onClick={() => void removeChat(chat.id)} disabled={busy}>
<Trash2 size={14} aria-hidden="true" />
</button>
</div>
))}
</div>
<div className="coach-context-card">
<p className="text-xs font-semibold uppercase" style={{ color: "var(--muted)" }}>today</p>
<div className="mt-2 grid gap-2">
<WellnessPill label="cans" value={dashboard.todayCans} />
<WellnessPill label="caffeine" value={dashboard.todayCaffeine} />
<WellnessPill label="favourite" value={dashboard.favouriteFlavour} />
</div>
</div>
</aside>
<section className="coach-main">
<div className="coach-topbar">
<span className="coach-topbar-status">
<span className={`coach-topbar-status-dot ${busy ? "coach-topbar-status-dot-busy" : "coach-topbar-status-dot-ready"}`} />
{busy ? "thinking" : "ready"}
</span>
<span className="coach-topbar-status" style={{ color: "var(--muted)" }}>{OLLAMA_MODEL}</span>
</div>
<div className="coach-messages" aria-live="polite">
<div className="coach-messages-inner">
{!visibleMessages.length ? (
<div className="coach-empty-state">
<div className="coach-empty-icon">
<Sparkles size={28} aria-hidden="true" />
</div>
<h2>how can I help?</h2>
<p>ask about caffeine, sugar, spending, or your flavour patterns.</p>
<div className="coach-prompt-grid">
{quickPrompts.map((prompt) => (
<button key={prompt} className="chat-suggestion-chip" type="button" disabled={busy} onClick={() => void sendPrompt(prompt)}>
{prompt}
</button>
))}
</div>
</div>
) : (
visibleMessages.map((message) => (
<CoachMessageBubble
key={message.id}
message={message}
userInitials={userInitials}
thinkingOpen={openThinkingIds.includes(message.id)}
onToggleThinking={() => toggleThinking(message.id)}
/>
))
)}
<div ref={messagesEndRef} />
</div>
</div>
{error && (
<div className="coach-error">
<div className="coach-error-inner">
{error}
</div>
</div>
)}
</form>
</section>
<form className="coach-composer" onSubmit={submit}>
<div className="coach-composer-inner">
<button className="composer-icon-button" type="button" onClick={startNewChat} disabled={busy} aria-label="new chat">
<Plus size={18} aria-hidden="true" />
</button>
<textarea
className="coach-input"
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void sendPrompt(input);
}
}}
placeholder="ask coach"
disabled={busy}
rows={1}
/>
{busy ? (
<button className="composer-send-button composer-stop-button" type="button" onClick={stopThinking} aria-label="stop thinking">
<Square size={16} aria-hidden="true" />
</button>
) : (
<button className="composer-send-button" type="submit" disabled={!input.trim()} aria-label="send message">
<Send size={16} aria-hidden="true" />
</button>
)}
</div>
<p className="coach-hint">coach can make mistakes. check important info.</p>
</form>
</section>
</div>
</section>
);
}
function CoachMessageBubble({
message,
userInitials,
thinkingOpen,
onToggleThinking,
}: {
message: CoachMessage;
userInitials: string;
thinkingOpen: boolean;
onToggleThinking: () => void;
}) {
const isAssistant = message.role === "assistant";
const canShowThinking = isAssistant && (message.pending || Boolean(message.thinking));
const thinkingLabel = message.stopped ? "stopped thinking" : message.pending ? "thinking" : "thinking";
const thinkingLabel = message.stopped ? "stopped thinking" : message.pending ? "thinking" : "view reasoning";
return (
<article className={`coach-message coach-message-${message.role}`}>
<article className={`coach-message ${isAssistant ? "coach-message-assistant" : "coach-message-user"}`}>
{isAssistant ? (
<div className="coach-message-avatar coach-message-avatar-assistant">
<Brain size={16} aria-hidden="true" />
</div>
) : (
<div className="coach-message-avatar coach-message-avatar-user">
{userInitials}
</div>
)}
<div className="coach-message-bubble">
<p className="text-xs font-semibold uppercase text-slate-500">{isAssistant ? "coach" : "you"}</p>
<div className="mt-2 whitespace-pre-wrap text-sm leading-6 text-white">
{message.content || (message.pending ? "streaming response..." : "")}
<div className="coach-bubble-content">
{message.content || (message.pending ? (
<div className="coach-typing-dots"><span /><span /><span /></div>
) : "")}
</div>
{canShowThinking && (
<div className="mt-3">
<div className="mt-2">
<button className={`thinking-slider ${message.pending ? "thinking-slider-active" : ""}`} type="button" onClick={onToggleThinking}>
<span className="thinking-slider-track">
<span>{thinkingLabel} · click to reveal reasoning</span>
</span>
{thinkingLabel}
</button>
<AnimatePresence>
{thinkingOpen && (