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:
parent
95643fa009
commit
8a3bef0add
9 changed files with 192 additions and 75 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
59
components/chat/SidebarChatSection.tsx
Normal file
59
components/chat/SidebarChatSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue