freezmo/components/chat/MessageRow.tsx
Martin Dvorak 5eec310e16 Thinner glass everywhere, chat answers as bubbles in Aero
Two asks. "Slightly more transparent": the veils drop another notch
(recipe 0.15 -> 0.12, tokens ~0.03 less, tab bar / wizard bar / gloss
likewise). "Some buttons aren't themed": the recipe now catches the
flows' hand-rolled frost by attribute selectors - the study activities'
bg-white/xx chips (multiple choice, matching, ordering, fill blank),
the exam rows that used bg-[var(--surface)] (an opaque white in Aero),
and everything on the surface tokens (chat chips, homework chips,
inputs, dropzones). Inside sheets the same shapes stay flat; the
dropzones lost their press-scale (a transform re-anchors the fixed
backdrop) and shade inward instead.

And the chat: the assistant answers are bubble-less by design and the
user bubble's thin tint washed out over the bright wallpaper, so the
longest text in the app sat straight on the photo. In Aero both roles
get a real pane now - chat-answer wraps the markdown in a bubble, the
user turn carries chat-bubble, with a stronger veil than the shared
glass for comfortable reading. Light and dark keep the bubble-less
assistant design (styled only under .aero).

Contrast: 0 below-minimum findings, 0 axe violations. Chat 101/101,
mobile 123/123, regression 44/44, i18n 13/13, native 33/33.
Version 0.3.5, SW -> freezmo-v65.
2026-10-13 12:43:46 +00:00

158 lines
6.2 KiB
TypeScript

"use client";
import Link from "next/link";
import { CopyIconButton } from "./CopyIconButton";
import { Markdown } from "./Markdown";
import { ThinkingRow } from "./ThinkingRow";
import { Button } from "@/components/ui";
import { ModelErrorActions } from "@/components/ModelErrorActions";
import { PencilIcon, PlusIcon, RefreshIcon, SparklesIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n";
import type { ChatMessage } from "@/lib/chat";
/**
* One turn: user bubbles on the right, assistant answers as full-width text
* (no bubble) with the Thinking row, sources, actions and error recovery.
*/
export function MessageRow({
message,
streaming,
isLastAssistant,
isLastUser,
busy,
onRegenerate,
onEdit,
onRetry,
onToFlashcards,
onSaveNotes,
}: {
message: ChatMessage;
streaming?: boolean;
isLastAssistant?: boolean;
isLastUser?: boolean;
busy?: boolean;
onRegenerate?: () => void;
onEdit?: () => void;
onRetry?: () => void;
onToFlashcards?: () => void;
onSaveNotes?: () => void;
}) {
const { t } = useI18n();
if (message.role === "user") {
return (
<div className="flex flex-col items-end gap-1">
<div className="chat-bubble max-w-[88%] rounded-3xl rounded-br-lg border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5">
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">{message.content}</p>
{message.attachments && message.attachments.length > 0 ? (
<p className="mt-1.5 text-[11.5px] text-[var(--text-subtle)]">
{message.attachments.map((attachment) => attachment.name).join(" · ")}
</p>
) : null}
</div>
{isLastUser && !busy && onEdit ? (
<button
type="button"
onClick={onEdit}
className="flex items-center gap-1 rounded-lg px-1.5 py-1 text-[11.5px] text-[var(--text-subtle)] transition hover:text-[var(--text)]"
>
<PencilIcon className="size-3.5" />
{t("chat.edit")}
</button>
) : null}
</div>
);
}
return (
<div className="space-y-2">
{message.thinking ? <ThinkingRow thinking={message.thinking} streaming={streaming && !message.content} /> : null}
{message.content ? (
<div className="chat-answer">
<Markdown text={message.content} />
</div>
) : streaming ? (
<span className="flex items-center gap-1 py-1" role="status" aria-label={t("chat.thinking")}>
{[0, 1, 2].map((dot) => (
<span
key={dot}
className="size-1.5 animate-bounce rounded-full bg-[var(--text-subtle)] motion-reduce:animate-none motion-reduce:opacity-60"
style={{ animationDelay: `${dot * 120}ms` }}
/>
))}
</span>
) : null}
{message.error ? (
<div
role="alert"
className="rounded-2xl border border-rose-300/60 bg-rose-50/70 p-3 dark:border-rose-400/30 dark:bg-rose-500/10"
>
<p className="text-[13px] leading-relaxed text-rose-800 dark:text-rose-200">{message.error.message}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
{onRetry ? (
<Button size="sm" variant="secondary" onClick={onRetry}>
<RefreshIcon className="size-4" /> {t("chat.retry")}
</Button>
) : null}
<ModelErrorActions kind={message.error.kind} />
</div>
</div>
) : null}
{!streaming && message.content && !message.error ? (
<div className="flex flex-wrap items-center gap-2">
<CopyIconButton text={message.content} />
{isLastAssistant && !busy && onRegenerate ? (
<button
type="button"
onClick={onRegenerate}
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<RefreshIcon className="size-3.5" />
{t("chat.regenerate")}
</button>
) : null}
{onToFlashcards ? (
<button
type="button"
onClick={onToFlashcards}
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<SparklesIcon className="size-3.5" />
{t("chat.toFlashcards")}
</button>
) : null}
{onSaveNotes ? (
<button
type="button"
onClick={onSaveNotes}
className="flex items-center gap-1.5 rounded-xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[12px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<PlusIcon className="size-3.5" />
{t("chat.saveToNotes")}
</button>
) : null}
{message.model ? <span className="text-[11px] text-[var(--text-subtle)]">{message.model}</span> : null}
</div>
) : null}
{message.sources && message.sources.length > 0 && !streaming ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[11px] uppercase tracking-wide text-[var(--text-subtle)]">{t("chat.sources")}</span>
{message.sources.slice(0, 5).map((source, index) => (
<Link
key={`${source.deckId}-${source.section}-${index}`}
href={`/deck/${source.deckId}`}
className="max-w-[220px] truncate rounded-lg border border-[var(--hairline-soft)] bg-[var(--surface-sunken)] px-2 py-1 text-[11.5px] text-[var(--text-muted)] transition hover:text-[var(--text)]"
title={`${source.deckTitle} — ${source.section}`}
>
{source.deckTitle} · {source.section}
</Link>
))}
</div>
) : null}
</div>
);
}