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

@ -1,8 +1,11 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
import { SidebarChatSection } from "@/components/chat/SidebarChatSection";
import {
ChatIcon,
ChevronLeftIcon,
@ -35,6 +38,8 @@ const COLLAPSE_KEY = "freezmo.sidebarCollapsed";
* there is no drawer any more, so this is the only consumer of the rail.
*/
function NavContent({ collapsed = false }: { collapsed?: boolean }) {
const pathname = usePathname();
const onChat = pathname === "/chat";
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
const folders = useSyncExternalStore(
folderStore.subscribe,
@ -149,6 +154,7 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
{/* chat with your notes --------------------------------------------- */}
<Link
href="/chat"
aria-current={onChat ? "page" : undefined}
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
title={t("chat.nav")}
>
@ -156,19 +162,27 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
{!collapsed ? <span className="flex-1 truncate text-left">{t("chat.nav")}</span> : null}
</Link>
<nav aria-label={t("sidebar.library")} className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto">
{collapsed ? (
<button
type="button"
onClick={() => sidebarCollapse.set(false)}
aria-label={t("sidebar.expand")}
title={t("sidebar.expand")}
className="nav-row justify-center px-2"
>
<ChevronRightIcon className="size-4" />
</button>
) : null}
{/* The expand control sits outside the swappable section, so it stays
reachable when the chat list replaces the deck navigation. */}
{collapsed ? (
<button
type="button"
onClick={() => sidebarCollapse.set(false)}
aria-label={t("sidebar.expand")}
title={t("sidebar.expand")}
className="nav-row mb-1 justify-center px-2"
>
<ChevronRightIcon className="size-4" />
</button>
) : null}
{/* On the Chat route the chat list takes the deck navigation's place. */}
{onChat ? <SidebarChatSection collapsed={collapsed} /> : null}
<nav
aria-label={t("sidebar.library")}
className={cn("thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto", onChat && "hidden")}
>
{/* All decks + Unorganized are views, so they sit above Folders. */}
<div className={cn("space-y-0.5", !collapsed && "mb-1")}>
{viewRow(

View file

@ -51,7 +51,10 @@ export function TabBar() {
<motion.span
key={`${key}-${isActive ? pathname : "idle"}`}
animate={isActive && !reduceMotion ? { scale: [1, 1.16, 1] } : { scale: 1 }}
transition={springSnappy}
// A three-keyframe bounce needs a tween: framer-motion's dev build throws
// on springs with more than two keyframes (which broke client updates in
// the dev instance).
transition={isActive && !reduceMotion ? { duration: 0.32, ease: "easeOut" } : springSnappy}
className="relative z-10 grid place-items-center"
>
{children}

View file

@ -61,7 +61,7 @@ export function useTheme() {
return { theme, toggle: () => apply(theme === "dark" ? "light" : "dark") };
}
export function ThemeToggle({ className }: { className?: string }) {
export function ThemeToggle({ className, plain = false }: { className?: string; plain?: boolean }) {
const { theme, toggle } = useTheme();
const { t } = useI18n();
const dark = theme === "dark";
@ -77,8 +77,11 @@ export function ThemeToggle({ className }: { className?: string }) {
title={dark ? t("common.lightMode") : t("common.darkMode")}
className={cn(
// A bordered chip with a press affordance: the bare sun/moon glyph was
// easy to read as decoration rather than a control.
"touch-target inline-flex size-9 items-center justify-center rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] active:scale-95",
// easy to read as decoration rather than a control. `plain` drops the
// chip chrome so a header can style the button itself (chat header).
plain
? "text-[var(--text-muted)] transition hover:text-[var(--text)] active:scale-95"
: "touch-target inline-flex size-9 items-center justify-center rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] active:scale-95",
className,
)}
>

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>

View file

@ -0,0 +1,59 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useSyncExternalStore } from "react";
import { ChatHistoryList } from "./HistoryPanel";
import { chatActive } from "@/lib/chat-active";
import { chatHistoryStore, deleteConversation, renameConversation, saveConversation } from "@/lib/chat-store";
import { useI18n } from "@/lib/i18n";
import { showToast } from "@/lib/toast";
import type { ChatConversation } from "@/lib/schemas";
/**
* Chat history inside the main sidebar: when the Chat section is active the
* deck navigation is replaced by the chat list (search, new chat, items with
* rename/delete). Collapsed, it keeps one New-chat icon row.
*/
export function SidebarChatSection({ collapsed }: { collapsed: boolean }) {
const { t } = useI18n();
const router = useRouter();
const conversations = useSyncExternalStore(
chatHistoryStore.subscribe,
chatHistoryStore.getSnapshot,
chatHistoryStore.getServerSnapshot,
);
const activeId = useSyncExternalStore(chatActive.subscribe, chatActive.getSnapshot, chatActive.getServerSnapshot);
const [query, setQuery] = useState("");
const onDelete = (conversation: ChatConversation) => {
deleteConversation(conversation.id);
showToast({
message: t("chat.deletedChat"),
actionLabel: t("toast.undo"),
onAction: () => saveConversation(conversation),
});
};
// Collapsed, the list needs width — the highlighted Chat row above (and the
// expand chevron) already cover navigation, so the section renders nothing.
if (collapsed) return null;
return (
<div className="flex min-h-0 flex-1 flex-col">
<h2 className="px-2 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("chat.history")}
</h2>
<ChatHistoryList
conversations={conversations}
currentId={activeId}
query={query}
onQueryChange={setQuery}
onOpen={(id) => router.push(`/chat?c=${id}`)}
onRename={renameConversation}
onDelete={onDelete}
onNew={() => router.push("/chat")}
/>
</div>
);
}

View file

@ -321,8 +321,7 @@ export function AddToHomeIcon({ className }: IconProps) {
export function ChatIcon({ className }: IconProps) {
return (
<Svg className={className}>
<path d="M20 12a7.5 7.5 0 0 1-7.5 7.5H8.2L4 22v-4.6A7.5 7.5 0 0 1 12.5 4.5 7.5 7.5 0 0 1 20 12Z" />
<path d="M9 10.5h7M9 13.8h4.5" />
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z" />
</Svg>
);
}