freezmo/components/chat/HistoryPanel.tsx
pepaskopec-blip bfc0f806b9 The scene stops heating the MacBook
"Terribly high CPU usage, the MacBook gets really hot" - a fanless MacBook
Air on a 144Hz screen. Measured on that machine with headed engines
(process CPU plus the per-process GPU time macOS keeps): an idle Aero
library cost LibreWolf ~110% CPU and ~230ms/s of GPU, the study feed ~130%,
Chromium up to ~57% CPU and ~350ms/s of GPU, Safari ~220-250ms/s of
WindowServer GPU. Priced one by one:

- the gradient mask on .ice-floats re-rendered the animated layer offscreen
  on every frame (~75 points of Gecko CPU). The floats now rise through a
  plain rectangular band clear of the header, the bottom chrome and the
  rail, and fade in and out inside it, so the clip never cuts a visible one;
- the overlay grain forced a backdrop readback per frame for a texture that
  measured a mean <=0.11/255 difference: removed;
- the invisible blob drift made every frame a full-screen redraw: the blobs
  are painted once;
- static glass (answer tiles, feedback panel, chips, inputs, dropzones,
  lists, segmented controls, Aero hint chips) re-blurred the moving scene
  every frame: only floating glass blurs now, and Aero's baked chat chrome
  drops a live blur that never showed under the bake;
- the scene also parks while the window has no focus and under any dialog
  or sheet scrim (.scene-pause); the loading icon is a compositor-run CSS
  animation instead of a framer-motion main-thread loop.

After, focused and animating: LibreWolf library 109% -> 33% CPU, study
134% -> 34%, chat 130% -> 42%; Chromium GPU 2.5-4.4x lower with 7-22 points
less CPU; Safari's WindowServer GPU -20-26% in Aero, about half in dark.
Unfocused or under a sheet: nothing. bubbles-test sweeps every float through
41 phases against the band and pins the parking and the cost contract;
tests/heat-probe.mjs (macOS) repeats the measurement; the suites find
Playwright's Chromium on macOS.

All 15 suites pass, lint clean, the contrast scan lost a finding. Version
0.6.7, SW v89.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 19:42:29 +02:00

206 lines
8.5 KiB
TypeScript

"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
import { useEffect, useState } from "react";
import { CheckCircleIcon, PencilIcon, PlusIcon, XIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion";
import { conversationMatches } from "@/lib/chat";
import type { ChatConversation } from "@/lib/schemas";
import { cn, formatDate } from "@/lib/utils";
type HistoryProps = {
conversations: ChatConversation[];
currentId: string | null;
query: string;
onQueryChange: (value: string) => void;
onOpen: (id: string) => void;
onRename: (id: string, title: string) => void;
onDelete: (conversation: ChatConversation) => void;
onNew: () => void;
};
/** The conversation list: a column on desktop, a sheet on phones. */
export function ChatHistoryList({
conversations,
currentId,
query,
onQueryChange,
onOpen,
onRename,
onDelete,
onNew,
}: HistoryProps) {
const { t } = useI18n();
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const visible = conversations.filter((conversation) =>
conversationMatches(conversation.title, conversation.messages, query),
);
const startRename = (conversation: ChatConversation) => {
setRenaming(conversation.id);
setDraft(conversation.title);
};
const commit = () => {
if (renaming) onRename(renaming, draft);
setRenaming(null);
};
return (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="flex items-center gap-2 px-1">
<input
type="search"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={t("chat.searchChats")}
aria-label={t("chat.searchChats")}
className="min-w-0 flex-1 rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2.5 py-2 text-[16px] text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400/60 sm:text-[13px]"
/>
<Button size="sm" variant="secondary" className="touch-target shrink-0" onClick={onNew} aria-label={t("chat.new")}>
<PlusIcon className="size-4" />
<span className="hidden sm:inline">{t("chat.new")}</span>
</Button>
</div>
{visible.length === 0 ? (
<p className="px-2 py-8 text-center text-[12.5px] text-[var(--text-subtle)]">
{query ? t("chat.noResults") : t("chat.noHistory")}
</p>
) : (
<ul className="thin-scroll min-h-0 flex-1 space-y-1 overflow-y-auto overscroll-contain px-1 pb-2">
{visible.map((conversation) => {
const active = conversation.id === currentId;
return (
<li key={conversation.id}>
{renaming === conversation.id ? (
<form
onSubmit={(event) => {
event.preventDefault();
commit();
}}
className="flex items-center gap-1 px-1 py-1"
>
<input
autoFocus
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") setRenaming(null);
}}
aria-label={t("common.rename")}
className="min-w-0 flex-1 rounded-[var(--radius-chip)] border border-[var(--glass-border)] bg-[var(--surface-strong)] px-2 py-1.5 text-[16px] text-[var(--text)] outline-none sm:text-[13px]"
/>
<button
type="submit"
aria-label={t("common.save")}
className="grid size-9 shrink-0 place-items-center rounded-[var(--radius-chip)] text-emerald-600 transition hover:bg-[var(--hover)] dark:text-emerald-300"
>
<CheckCircleIcon className="size-4" />
</button>
<button
type="button"
onClick={() => setRenaming(null)}
aria-label={t("common.cancel")}
className="grid size-9 shrink-0 place-items-center rounded-[var(--radius-chip)] text-[var(--text-subtle)] transition hover:bg-[var(--hover)]"
>
<XIcon className="size-4" />
</button>
</form>
) : (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onOpen(conversation.id)}
aria-current={active ? "true" : undefined}
className={cn(
"min-w-0 flex-1 rounded-[var(--radius-chip)] px-2.5 py-2 text-left transition",
active ? "bg-[var(--nav-active-bg)]" : "hover:bg-[var(--hover)]",
)}
>
<span className="block truncate text-[13px] font-medium text-[var(--text)]">
{conversation.title || t("chat.untitled")}
</span>
<span className="block text-[11px] text-[var(--text-subtle)]">
{formatDate(conversation.updatedAt)}
</span>
</button>
<button
type="button"
onClick={() => startRename(conversation)}
aria-label={t("common.rename")}
title={t("common.rename")}
className="grid size-9 shrink-0 place-items-center rounded-[var(--radius-chip)] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<PencilIcon className="size-3.5" />
</button>
<button
type="button"
onClick={() => onDelete(conversation)}
aria-label={t("chat.deleteChat")}
title={t("chat.deleteChat")}
className="grid size-9 shrink-0 place-items-center rounded-[var(--radius-chip)] text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-500"
>
<XIcon className="size-3.5" />
</button>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
/** Phone sheet wrapper around the list (drag/backdrop close, spring). */
export function HistorySheet({ open, onClose, ...props }: HistoryProps & { open: boolean; onClose: () => void }) {
const { t } = useI18n();
const reduceMotion = useReducedMotion();
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
return (
<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t("chat.history")}>
<motion.button
type="button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : undefined}
className="scene-pause absolute inset-0 bg-black/45"
aria-label={t("common.close")}
onClick={onClose}
/>
<motion.div
initial={reduceMotion ? { opacity: 0 } : { y: "100%" }}
animate={reduceMotion ? { opacity: 1 } : { y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { y: "100%" }}
transition={reduceMotion ? { duration: 0 } : springSheet}
className="glass-panel glass-sheet absolute inset-x-0 bottom-0 flex max-h-[80dvh] flex-col rounded-b-none px-3 pb-safe pt-2"
>
<div className="sheet-grabber mx-auto" aria-hidden />
<h2 className="px-1 pb-2 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.history")}</h2>
<ChatHistoryList {...props} />
</motion.div>
</div>
) : null}
</AnimatePresence>
);
}