Chat layout: history in the main sidebar, consistent header, cleaner icon

- Desktop: on /chat the main sidebar shows the chat list (search, New chat,
  items with rename/delete, active highlight) in place of the deck
  navigation; the separate second column is gone and the chat uses the full
  width. The expand/collapse chevron sits outside the swappable section and
  a collapsed rail stays clean (the highlighted Chat row already opens a
  new chat). Active conversation travels via a tiny store (lib/chat-active)
  so the sidebar needs no useSearchParams and pages stay static.
- Header (phones): history, theme and New chat are now identical chips -
  same 44px hit area, rounded-2xl, border/surface tokens, size-5 icons,
  even spacing; the title block truncates cleanly. ThemeToggle gained a
  'plain' variant so the header styles it without touching other pages.
- ChatIcon redrawn (clean message-circle, no inner lines) and the empty
  state uses the app's icon-chip pattern (size-12 chip, accent-600/200)
  instead of the bare oversized glyph; the model chip icon follows.
- Also fixed a pre-existing dev-only breakage found while verifying: the
  tab-bar bounce animated three keyframes with a spring, which framer
  motion's dev build throws on - breaking client updates in the dev
  instance (library tab switching, sheet Escape). It now uses a tween.
- Dev metadata keeps the Apple icons in both branches.
- SW -> freezmo-v35.
This commit is contained in:
Martin Dvořák 2026-10-11 20:11:11 +00:00
commit 8a3bef0add
9 changed files with 192 additions and 75 deletions

View file

@ -7,7 +7,7 @@ import Link from "next/link";
import { Composer, type PendingAttachment } from "./Composer";
import { ContextPicker } from "./ContextPicker";
import { ChatHistoryList, HistorySheet } from "./HistoryPanel";
import { HistorySheet } from "./HistoryPanel";
import { MessageRow } from "./MessageRow";
import { ModelPicker } from "./ModelPicker";
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, PlusIcon, RefreshIcon } from "@/components/icons";
@ -18,6 +18,7 @@ import { ApiError } from "@/lib/client-api";
import { extractChatAttachment, streamChat } from "@/lib/chat-client";
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
import { chatActive } from "@/lib/chat-active";
import { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
import {
chatMessageId,
@ -123,6 +124,7 @@ export function ChatScreen() {
setError(null);
if (found.model) setModel(found.model);
loadedRef.current = found.id;
chatActive.set(found.id);
}, []);
const conversationParam = search.get("c");
@ -240,6 +242,7 @@ export function ChatScreen() {
const id = conversationId ?? chatMessageId("chat");
if (!conversationId) {
setConversationId(id);
chatActive.set(id);
// Keep the URL in step so a reload continues this conversation.
// history.replaceState avoids a router round-trip for the same page.
window.history.replaceState(null, "", `/chat?c=${id}`);
@ -403,7 +406,7 @@ export function ChatScreen() {
setMessages(messages.slice(0, lastUserIndex));
};
const newChat = () => {
const resetView = useCallback(() => {
abortRef.current?.abort();
loadedRef.current = null;
setConversationId(null);
@ -413,10 +416,28 @@ export function ChatScreen() {
setAttachments([]);
setDraft("");
setError(null);
setHistoryOpen(false);
chatActive.set(null);
window.history.replaceState(null, "", "/chat");
}, []);
const newChat = () => {
resetView();
setHistoryOpen(false);
};
/* The sidebar can start a new chat (URL -> /chat) while this screen is open. */
useEffect(() => {
if (conversationParam || !loadedRef.current) return;
resetView();
}, [conversationParam, resetView]);
/* ...and delete the conversation that is currently open. */
useEffect(() => {
if (!conversationId || !loadedRef.current) return;
if (getConversation(conversationId)) return;
resetView();
}, [conversations, conversationId, resetView]);
const openConversation = (id: string) => {
abortRef.current?.abort();
setHistoryOpen(false);
@ -501,31 +522,15 @@ export function ChatScreen() {
return (
<div className="flex h-full min-h-0 w-full">
{/* Desktop: the conversation list lives in its own column. */}
<aside className="hidden w-64 shrink-0 flex-col border-r border-[var(--hairline-soft)] bg-[var(--surface-sunken)] p-2 lg:flex">
<h2 className="px-1 pb-1 pt-1 text-[12px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("chat.history")}
</h2>
<ChatHistoryList
conversations={conversations}
currentId={conversationId}
query={query}
onQueryChange={setQuery}
onOpen={openConversation}
onRename={renameConversation}
onDelete={handleDeleteConversation}
onNew={newChat}
/>
</aside>
{/* Desktop's conversation list lives in the main sidebar (on /chat). */}
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
<header className="glass sticky top-0 z-30 flex h-14 shrink-0 items-center gap-1.5 px-2 sm:px-4">
<header className="glass sticky top-0 z-30 flex h-14 shrink-0 items-center gap-2 px-2 sm:px-4">
<button
type="button"
onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))}
aria-label={t("chat.back")}
title={t("chat.back")}
className="grid size-10 shrink-0 place-items-center rounded-2xl text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
className="grid size-11 shrink-0 place-items-center rounded-2xl text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
>
<ChevronLeftIcon className="size-5" />
</button>
@ -536,26 +541,31 @@ export function ChatScreen() {
{seed ? ` · ${seed.label}` : ""}
</p>
</div>
<button
type="button"
onClick={() => setHistoryOpen(true)}
aria-label={t("chat.history")}
title={t("chat.history")}
className="grid size-10 shrink-0 place-items-center rounded-2xl text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
>
<HistoryIcon className="size-5" />
</button>
<ThemeToggle className="hidden lg:grid" />
<button
type="button"
onClick={newChat}
aria-label={t("chat.new")}
title={t("chat.new")}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-3 text-[12.5px] font-medium text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<PlusIcon className="size-4" />
<span className="hidden sm:inline">{t("chat.new")}</span>
</button>
{/* Actions share one size, radius, border, background and hit area. */}
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => setHistoryOpen(true)}
aria-label={t("chat.history")}
title={t("chat.history")}
className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:hidden"
>
<HistoryIcon className="size-5" />
</button>
<ThemeToggle
plain
className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)]"
/>
<button
type="button"
onClick={newChat}
aria-label={t("chat.new")}
title={t("chat.new")}
className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<PlusIcon className="size-5" />
</button>
</div>
</header>
<div className="relative min-h-0 flex-1">
@ -564,8 +574,10 @@ export function ChatScreen() {
{messages.length === 0 ? (
<div className="space-y-5 py-6">
<div className="text-center">
<ChatIcon className="mx-auto size-10 text-accent-400" />
<h2 className="mt-3 text-[20px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("chat.emptyTitle")}</h2>
<span className="mx-auto mb-4 grid size-12 place-items-center rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] text-accent-600 dark:text-accent-200">
<ChatIcon className="size-6" />
</span>
<h2 className="text-[20px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("chat.emptyTitle")}</h2>
<p className="mx-auto mt-1.5 max-w-sm text-[13.5px] leading-relaxed text-[var(--text-subtle)]">
{t("chat.emptyHint")}
</p>