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.
158 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|