The header and composer were full-bleed bands (flush to the top/right/bottom viewport edges, composer corners off-screen) next to a rail that floats with a 12px margin. The new --layout-gutter (12px) spaces the rail, header, empty-state card and composer identically on every side; --radius-panel/-control/-chip (-pill) replace the ad-hoc radii across the shared classes, so every panel is rounded on all four corners and shared controls match in every theme. The desktop-only ::after hairlines and the left-corner-only radius are gone. Aero: the rail and the chat chrome (answers included) share one even recipe - the wallpaper extra-blurred into a hue-free wash (background-glass.webp) under a uniform ~50% white veil and a whisper of gloss. Measured within ~10 RGB units across the rail top->bottom and the card/composer left->right; the blue->green smear and the teal composer are gone. The composer's field is a clean even tile with no wallpaper gradient inside it. Chat fixes: send is a filled primary tile shaped exactly like attach (dimmed while the input is empty), suggestion/context chips use the shared .chip token (40/44px target, visible rim, full-contrast text, raw model id in the tooltip), the AI note reads at 12px --text-muted (.ai-disclaimer), the selected sidebar row carries the accent border + accent icon, the theme toggle is back in Aero (the moon flips to Dark; Settings brings Aero back), and the empty state is centered exactly between header and composer. Outside CSS: the wallpaper's top-left HUD-circle decoration (it peeked through the 12px gutters on wide windows) was healed out of background.webp and background-blur.webp; the orbs/bubbles ride one masked .ice-floats layer that fades them under the chrome bands and the rail strip, so none is sliced by a panel edge (still parked under reduced motion). Version 0.6.0, SW -> freezmo-v82. Tests: chat suite pins the new geometry (12px gutter, 24px corners, even wash on all four panels, send-vs-attach shape, chips/input radii); mobile pins the phone chrome and the Aero toggle; bubbles pins the floats layer + its reduced-motion opt-out.
208 lines
7.4 KiB
TypeScript
208 lines
7.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
import { AiDisclaimer } from "@/components/AiDisclaimer";
|
|
import { ChatIcon, LayersIcon, PaperclipIcon, SendIcon, StopIcon, XIcon } from "@/components/icons";
|
|
import { Spinner } from "@/components/ui";
|
|
import { useI18n } from "@/lib/i18n";
|
|
import { useIsPhone } from "@/lib/use-phone";
|
|
import type { ChatAttachment } from "@/lib/chat";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type PendingAttachment = { attachment: ChatAttachment; text: string };
|
|
|
|
/**
|
|
* The pinned composer: auto-growing 16px textarea, send/stop, attach, and the
|
|
* context + model chips. Enter sends on desktop, newline on phones; the
|
|
* keyboard inset is handled by the screen around it.
|
|
*/
|
|
export function Composer({
|
|
value,
|
|
onChange,
|
|
onSend,
|
|
onStop,
|
|
busy,
|
|
online,
|
|
scopeLabel,
|
|
modelLabel,
|
|
modelTitle,
|
|
scopeOpen,
|
|
modelOpen,
|
|
onOpenScope,
|
|
onOpenModel,
|
|
onAttachFile,
|
|
attachBusy,
|
|
attachments,
|
|
onRemoveAttachment,
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
onSend: () => void;
|
|
onStop: () => void;
|
|
busy: boolean;
|
|
online: boolean;
|
|
scopeLabel: string;
|
|
modelLabel: string;
|
|
/** The raw model name, shown as the chip's tooltip. */
|
|
modelTitle?: string;
|
|
scopeOpen: boolean;
|
|
modelOpen: boolean;
|
|
onOpenScope: () => void;
|
|
onOpenModel: () => void;
|
|
onAttachFile: (file: File) => void;
|
|
attachBusy: boolean;
|
|
attachments: PendingAttachment[];
|
|
onRemoveAttachment: (index: number) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const isPhone = useIsPhone();
|
|
const textarea = useRef<HTMLTextAreaElement>(null);
|
|
const fileInput = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
const element = textarea.current;
|
|
if (!element) return;
|
|
element.style.height = "auto";
|
|
element.style.height = `${Math.min(element.scrollHeight, 180)}px`;
|
|
}, [value]);
|
|
|
|
const canSend = online && !busy && value.trim().length > 0;
|
|
|
|
return (
|
|
<div className="chat-composer-band glass-panel px-4 pb-safe pt-2 lg:px-10">
|
|
{attachments.length > 0 ? (
|
|
<ul className="mb-2 flex flex-wrap gap-1.5">
|
|
{attachments.map((entry, index) => (
|
|
<li
|
|
key={`${entry.attachment.name}-${index}`}
|
|
className="flex items-center gap-1.5 rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] py-1 pl-2.5 pr-1 text-[12px] text-[var(--text-muted)]"
|
|
>
|
|
<span className="max-w-[160px] truncate">{entry.attachment.name}</span>
|
|
<span className="text-[var(--text-subtle)]">{entry.attachment.kind === "photo" ? t("chat.attachPhoto") : t("chat.attachDoc")}</span>
|
|
<button
|
|
type="button"
|
|
aria-label={t("chat.attachRemove")}
|
|
title={t("chat.attachRemove")}
|
|
onClick={() => onRemoveAttachment(index)}
|
|
className="grid size-6 place-items-center rounded-[var(--radius-chip)] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
|
>
|
|
<XIcon className="size-3.5" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
|
|
<div className="flex items-end gap-2">
|
|
<button
|
|
type="button"
|
|
aria-label={t("chat.attach")}
|
|
title={t("chat.attach")}
|
|
disabled={attachBusy}
|
|
onClick={() => fileInput.current?.click()}
|
|
className="touch-target grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] disabled:opacity-60"
|
|
>
|
|
{attachBusy ? <Spinner className="size-4" /> : <PaperclipIcon className="size-5" />}
|
|
</button>
|
|
<input
|
|
ref={fileInput}
|
|
type="file"
|
|
aria-hidden="true"
|
|
tabIndex={-1}
|
|
accept="image/*,application/pdf,.docx,.pptx,.odt,.txt,.md,.csv"
|
|
className="sr-only"
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0];
|
|
if (file) onAttachFile(file);
|
|
event.target.value = "";
|
|
}}
|
|
/>
|
|
|
|
<textarea
|
|
ref={textarea}
|
|
value={value}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== "Enter" || event.shiftKey || isPhone) return;
|
|
if (event.nativeEvent.isComposing) return;
|
|
event.preventDefault();
|
|
if (canSend) onSend();
|
|
}}
|
|
rows={1}
|
|
placeholder={t("chat.placeholder")}
|
|
aria-label={t("chat.placeholder")}
|
|
className="max-h-[180px] min-h-[44px] min-w-0 flex-1 resize-none rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3.5 py-2.5 text-[16px] leading-relaxed text-[var(--text)] outline-none transition-[height,border-color] duration-150 ease-out placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 motion-reduce:transition-none"
|
|
/>
|
|
|
|
{busy ? (
|
|
<button
|
|
type="button"
|
|
onClick={onStop}
|
|
aria-label={t("chat.stop")}
|
|
title={t("chat.stop")}
|
|
className="touch-target grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text)] transition hover:bg-[var(--hover)]"
|
|
>
|
|
<StopIcon className="size-5" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={onSend}
|
|
disabled={!canSend}
|
|
aria-label={t("chat.send")}
|
|
title={t("chat.send")}
|
|
className="touch-target grad-ice grid size-10 shrink-0 place-items-center rounded-[var(--radius-control)] text-[#06111f] transition disabled:opacity-45"
|
|
>
|
|
<SendIcon className="size-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-2 flex items-center gap-2 overflow-x-auto">
|
|
<button
|
|
type="button"
|
|
data-picker-trigger
|
|
aria-haspopup="dialog"
|
|
aria-expanded={scopeOpen}
|
|
onClick={onOpenScope}
|
|
className={pickerChipClass(scopeOpen)}
|
|
>
|
|
<LayersIcon className="size-4 shrink-0" />
|
|
<span className="max-w-[190px] truncate">{scopeLabel}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-picker-trigger
|
|
aria-haspopup="dialog"
|
|
aria-expanded={modelOpen}
|
|
onClick={onOpenModel}
|
|
title={modelTitle ?? modelLabel}
|
|
className={pickerChipClass(modelOpen)}
|
|
>
|
|
<ChatIcon className="size-4 shrink-0" />
|
|
<span className="max-w-[190px] truncate">{modelLabel}</span>
|
|
</button>
|
|
{!online ? (
|
|
<span role="status" className="ml-auto shrink-0 text-[11.5px] text-amber-600 dark:text-amber-300">
|
|
{t("chat.offlineNote")}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
<AiDisclaimer plain className="mt-1.5" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Chip style for the two popover triggers (context, model): the shared chip
|
|
* language, read as pressed while the chip's own popover is open (the same
|
|
* chip opens and closes it).
|
|
*/
|
|
function pickerChipClass(open: boolean): string {
|
|
return cn(
|
|
"chip min-w-0 px-3 py-2 text-[12.5px] font-medium",
|
|
open && "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]",
|
|
);
|
|
}
|