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
|
|
@ -36,18 +36,16 @@ export const metadata: Metadata = {
|
||||||
{ url: "/icons/splash-1668x2388.png", media: "(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)" },
|
{ url: "/icons/splash-1668x2388.png", media: "(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
icons: IS_DEV_INSTANCE
|
icons: {
|
||||||
? {
|
icon: IS_DEV_INSTANCE
|
||||||
// Dev wears its own favicon so the two instances are never confused.
|
? // Dev wears its own favicon so the two instances are never confused.
|
||||||
icon: [{ url: "/icons/dev-favicon.svg", type: "image/svg+xml" }],
|
[{ url: "/icons/dev-favicon.svg", type: "image/svg+xml" }]
|
||||||
}
|
: [
|
||||||
: {
|
// ?v=2 busts browser favicon caches after the icon redesign.
|
||||||
icon: [
|
{ url: "/icons/favicon-32.png?v=2", sizes: "32x32", type: "image/png" },
|
||||||
// ?v=2 busts browser favicon caches after the icon redesign.
|
{ url: "/icons/icon-192.png?v=2", sizes: "192x192", type: "image/png" },
|
||||||
{ url: "/icons/favicon-32.png?v=2", sizes: "32x32", type: "image/png" },
|
{ url: "/icons/icon-512.png?v=2", sizes: "512x512", type: "image/png" },
|
||||||
{ url: "/icons/icon-192.png?v=2", sizes: "192x192", type: "image/png" },
|
],
|
||||||
{ url: "/icons/icon-512.png?v=2", sizes: "512x512", type: "image/png" },
|
|
||||||
],
|
|
||||||
apple: [
|
apple: [
|
||||||
{ url: "/icons/apple-touch-icon.png?v=2", sizes: "180x180" },
|
{ url: "/icons/apple-touch-icon.png?v=2", sizes: "180x180" },
|
||||||
{ url: "/icons/apple-touch-icon-167.png?v=2", sizes: "167x167" },
|
{ url: "/icons/apple-touch-icon-167.png?v=2", sizes: "167x167" },
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
|
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { SidebarChatSection } from "@/components/chat/SidebarChatSection";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChatIcon,
|
ChatIcon,
|
||||||
ChevronLeftIcon,
|
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.
|
* there is no drawer any more, so this is the only consumer of the rail.
|
||||||
*/
|
*/
|
||||||
function NavContent({ collapsed = false }: { collapsed?: boolean }) {
|
function NavContent({ collapsed = false }: { collapsed?: boolean }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const onChat = pathname === "/chat";
|
||||||
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
|
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
|
||||||
const folders = useSyncExternalStore(
|
const folders = useSyncExternalStore(
|
||||||
folderStore.subscribe,
|
folderStore.subscribe,
|
||||||
|
|
@ -149,6 +154,7 @@ function NavContent({ collapsed = false }: { collapsed?: boolean }) {
|
||||||
{/* chat with your notes --------------------------------------------- */}
|
{/* chat with your notes --------------------------------------------- */}
|
||||||
<Link
|
<Link
|
||||||
href="/chat"
|
href="/chat"
|
||||||
|
aria-current={onChat ? "page" : undefined}
|
||||||
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
|
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
|
||||||
title={t("chat.nav")}
|
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}
|
{!collapsed ? <span className="flex-1 truncate text-left">{t("chat.nav")}</span> : null}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav aria-label={t("sidebar.library")} className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
{/* The expand control sits outside the swappable section, so it stays
|
||||||
{collapsed ? (
|
reachable when the chat list replaces the deck navigation. */}
|
||||||
<button
|
{collapsed ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => sidebarCollapse.set(false)}
|
type="button"
|
||||||
aria-label={t("sidebar.expand")}
|
onClick={() => sidebarCollapse.set(false)}
|
||||||
title={t("sidebar.expand")}
|
aria-label={t("sidebar.expand")}
|
||||||
className="nav-row justify-center px-2"
|
title={t("sidebar.expand")}
|
||||||
>
|
className="nav-row mb-1 justify-center px-2"
|
||||||
<ChevronRightIcon className="size-4" />
|
>
|
||||||
</button>
|
<ChevronRightIcon className="size-4" />
|
||||||
) : null}
|
</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. */}
|
{/* All decks + Unorganized are views, so they sit above Folders. */}
|
||||||
<div className={cn("space-y-0.5", !collapsed && "mb-1")}>
|
<div className={cn("space-y-0.5", !collapsed && "mb-1")}>
|
||||||
{viewRow(
|
{viewRow(
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,10 @@ export function TabBar() {
|
||||||
<motion.span
|
<motion.span
|
||||||
key={`${key}-${isActive ? pathname : "idle"}`}
|
key={`${key}-${isActive ? pathname : "idle"}`}
|
||||||
animate={isActive && !reduceMotion ? { scale: [1, 1.16, 1] } : { scale: 1 }}
|
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"
|
className="relative z-10 grid place-items-center"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export function useTheme() {
|
||||||
return { theme, toggle: () => apply(theme === "dark" ? "light" : "dark") };
|
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 { theme, toggle } = useTheme();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const dark = theme === "dark";
|
const dark = theme === "dark";
|
||||||
|
|
@ -77,8 +77,11 @@ export function ThemeToggle({ className }: { className?: string }) {
|
||||||
title={dark ? t("common.lightMode") : t("common.darkMode")}
|
title={dark ? t("common.lightMode") : t("common.darkMode")}
|
||||||
className={cn(
|
className={cn(
|
||||||
// A bordered chip with a press affordance: the bare sun/moon glyph was
|
// A bordered chip with a press affordance: the bare sun/moon glyph was
|
||||||
// easy to read as decoration rather than a control.
|
// easy to read as decoration rather than a control. `plain` drops the
|
||||||
"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",
|
// 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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import Link from "next/link";
|
||||||
|
|
||||||
import { Composer, type PendingAttachment } from "./Composer";
|
import { Composer, type PendingAttachment } from "./Composer";
|
||||||
import { ContextPicker } from "./ContextPicker";
|
import { ContextPicker } from "./ContextPicker";
|
||||||
import { ChatHistoryList, HistorySheet } from "./HistoryPanel";
|
import { HistorySheet } from "./HistoryPanel";
|
||||||
import { MessageRow } from "./MessageRow";
|
import { MessageRow } from "./MessageRow";
|
||||||
import { ModelPicker } from "./ModelPicker";
|
import { ModelPicker } from "./ModelPicker";
|
||||||
import { ChatIcon, ChevronDownIcon, ChevronLeftIcon, HistoryIcon, PlusIcon, RefreshIcon } from "@/components/icons";
|
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 { extractChatAttachment, streamChat } from "@/lib/chat-client";
|
||||||
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
|
import { buildChatContext, deckHasNotes } from "@/lib/chat-context";
|
||||||
import { takeChatSeed, type ChatSeed } from "@/lib/chat-seed";
|
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 { chatHistoryStore, conversationMessages, deleteConversation, ensureChatsHydrated, getConversation, renameConversation, saveConversation } from "@/lib/chat-store";
|
||||||
import {
|
import {
|
||||||
chatMessageId,
|
chatMessageId,
|
||||||
|
|
@ -123,6 +124,7 @@ export function ChatScreen() {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (found.model) setModel(found.model);
|
if (found.model) setModel(found.model);
|
||||||
loadedRef.current = found.id;
|
loadedRef.current = found.id;
|
||||||
|
chatActive.set(found.id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const conversationParam = search.get("c");
|
const conversationParam = search.get("c");
|
||||||
|
|
@ -240,6 +242,7 @@ export function ChatScreen() {
|
||||||
const id = conversationId ?? chatMessageId("chat");
|
const id = conversationId ?? chatMessageId("chat");
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
setConversationId(id);
|
setConversationId(id);
|
||||||
|
chatActive.set(id);
|
||||||
// Keep the URL in step so a reload continues this conversation.
|
// Keep the URL in step so a reload continues this conversation.
|
||||||
// history.replaceState avoids a router round-trip for the same page.
|
// history.replaceState avoids a router round-trip for the same page.
|
||||||
window.history.replaceState(null, "", `/chat?c=${id}`);
|
window.history.replaceState(null, "", `/chat?c=${id}`);
|
||||||
|
|
@ -403,7 +406,7 @@ export function ChatScreen() {
|
||||||
setMessages(messages.slice(0, lastUserIndex));
|
setMessages(messages.slice(0, lastUserIndex));
|
||||||
};
|
};
|
||||||
|
|
||||||
const newChat = () => {
|
const resetView = useCallback(() => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
loadedRef.current = null;
|
loadedRef.current = null;
|
||||||
setConversationId(null);
|
setConversationId(null);
|
||||||
|
|
@ -413,10 +416,28 @@ export function ChatScreen() {
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setDraft("");
|
setDraft("");
|
||||||
setError(null);
|
setError(null);
|
||||||
setHistoryOpen(false);
|
chatActive.set(null);
|
||||||
window.history.replaceState(null, "", "/chat");
|
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) => {
|
const openConversation = (id: string) => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
setHistoryOpen(false);
|
setHistoryOpen(false);
|
||||||
|
|
@ -501,31 +522,15 @@ export function ChatScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 w-full">
|
<div className="flex h-full min-h-0 w-full">
|
||||||
{/* Desktop: the conversation list lives in its own column. */}
|
{/* Desktop's conversation list lives in the main sidebar (on /chat). */}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col" style={{ paddingBottom: "var(--chat-keyboard, 0px)" }}>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))}
|
onClick={() => (window.history.length > 1 ? router.back() : router.push("/"))}
|
||||||
aria-label={t("chat.back")}
|
aria-label={t("chat.back")}
|
||||||
title={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" />
|
<ChevronLeftIcon className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -536,26 +541,31 @@ export function ChatScreen() {
|
||||||
{seed ? ` · ${seed.label}` : ""}
|
{seed ? ` · ${seed.label}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{/* Actions share one size, radius, border, background and hit area. */}
|
||||||
type="button"
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
onClick={() => setHistoryOpen(true)}
|
<button
|
||||||
aria-label={t("chat.history")}
|
type="button"
|
||||||
title={t("chat.history")}
|
onClick={() => setHistoryOpen(true)}
|
||||||
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"
|
aria-label={t("chat.history")}
|
||||||
>
|
title={t("chat.history")}
|
||||||
<HistoryIcon className="size-5" />
|
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"
|
||||||
</button>
|
>
|
||||||
<ThemeToggle className="hidden lg:grid" />
|
<HistoryIcon className="size-5" />
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<ThemeToggle
|
||||||
onClick={newChat}
|
plain
|
||||||
aria-label={t("chat.new")}
|
className="grid size-11 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)]"
|
||||||
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)]"
|
<button
|
||||||
>
|
type="button"
|
||||||
<PlusIcon className="size-4" />
|
onClick={newChat}
|
||||||
<span className="hidden sm:inline">{t("chat.new")}</span>
|
aria-label={t("chat.new")}
|
||||||
</button>
|
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>
|
</header>
|
||||||
|
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
|
|
@ -564,8 +574,10 @@ export function ChatScreen() {
|
||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<div className="space-y-5 py-6">
|
<div className="space-y-5 py-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<ChatIcon className="mx-auto size-10 text-accent-400" />
|
<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">
|
||||||
<h2 className="mt-3 text-[20px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("chat.emptyTitle")}</h2>
|
<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)]">
|
<p className="mx-auto mt-1.5 max-w-sm text-[13.5px] leading-relaxed text-[var(--text-subtle)]">
|
||||||
{t("chat.emptyHint")}
|
{t("chat.emptyHint")}
|
||||||
</p>
|
</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) {
|
export function ChatIcon({ className }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Svg className={className}>
|
<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="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" />
|
||||||
<path d="M9 10.5h7M9 13.8h4.5" />
|
|
||||||
</Svg>
|
</Svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
lib/chat-active.ts
Normal file
29
lib/chat-active.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which conversation the chat screen is currently showing, for surfaces
|
||||||
|
* outside the chat route (the sidebar list highlights it). A tiny external
|
||||||
|
* store instead of reading `?c=` from the URL, because the sidebar renders on
|
||||||
|
* every page and `useSearchParams` there would force pages dynamic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let active: string | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export const chatActive = {
|
||||||
|
set(id: string | null): void {
|
||||||
|
if (active === id) return;
|
||||||
|
active = id;
|
||||||
|
listeners.forEach((listener) => listener());
|
||||||
|
},
|
||||||
|
subscribe(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
getSnapshot(): string | null {
|
||||||
|
return active;
|
||||||
|
},
|
||||||
|
getServerSnapshot(): string | null {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*
|
*
|
||||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||||
*/
|
*/
|
||||||
const CACHE_VERSION = "freezmo-v34";
|
const CACHE_VERSION = "freezmo-v35";
|
||||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue