refactor(style): update chat layout and scrolling
This commit is contained in:
@@ -4,21 +4,55 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
|
||||
content="width=device-width, initial-scale=0.9, maximum-scale=0.9, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#f8f7ff" />
|
||||
<title>Aethera - AI Conversation & Image Generator</title>
|
||||
<script type="module" src="./dist/main.js"></script>
|
||||
<link rel="stylesheet" href="./dist/styles.css" />
|
||||
</head>
|
||||
<body class="bg-primary-50" x-data x-init="$store.navigation.init()">
|
||||
<!-- Nav -->
|
||||
<div
|
||||
class="isolate fixed z-50 w-full flex justify-between mt-4 px-4 md:px-6"
|
||||
<!-- Nav - Fixed and fully transparent so page content always sits behind
|
||||
it (including under the iOS dynamic island, even at scroll=0). The
|
||||
pill inside provides its own background. Pages clear the nav with
|
||||
padding-top: var(--nav-h). -->
|
||||
<header
|
||||
class="fixed top-0 left-0 right-0 z-50 flex justify-between px-4 md:px-6 pb-3 pointer-events-none"
|
||||
style="padding-top: max(1rem, env(safe-area-inset-top));"
|
||||
>
|
||||
<div class="size-9"></div>
|
||||
<div class="size-9 flex items-center justify-start pointer-events-auto">
|
||||
<button
|
||||
x-show="$store.navigation.activeTab === 'chats'"
|
||||
@click="$store.chatSidebar.toggleMobile()"
|
||||
:aria-expanded="$store.chatSidebar.mobileOpen ? 'true' : 'false'"
|
||||
aria-label="Toggle conversation list"
|
||||
class="md:hidden p-2 rounded-md text-primary-700 hover:bg-primary-300 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
x-show="!$store.chatSidebar.mobileOpen"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<svg
|
||||
x-show="$store.chatSidebar.mobileOpen"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main Nav -->
|
||||
<nav class="inline-flex bg-primary-100 rounded-full shadow-sm">
|
||||
<nav class="inline-flex bg-primary-100 rounded-full pointer-events-auto">
|
||||
<a
|
||||
href="#/chats"
|
||||
:class="[
|
||||
@@ -58,7 +92,7 @@
|
||||
<button
|
||||
@click="$store.theme.cycleTheme()"
|
||||
x-init="$store.theme.init()"
|
||||
class="p-2 cursor-pointer rounded-md text-primary-700 hover:bg-primary-300 transition-colors"
|
||||
class="p-2 cursor-pointer rounded-md text-primary-700 hover:bg-primary-300 transition-colors pointer-events-auto"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<svg
|
||||
@@ -107,9 +141,9 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main id="page-content" class="h-dvh"></main>
|
||||
<!-- Main Content Area - No fixed height; the document scrolls. -->
|
||||
<main id="page-content"></main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
<div
|
||||
class="flex flex-col gap-4 pt-16 mx-auto px-4 md:px-6 max-w-6xl"
|
||||
class="flex flex-col gap-4 pb-4 mx-auto px-4 md:px-6 max-w-6xl"
|
||||
style="padding-top: var(--nav-h);"
|
||||
x-data="imageGenerator()"
|
||||
>
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<form
|
||||
x-data="settingsManager()"
|
||||
@submit.prevent="saveSettings"
|
||||
class="p-0.5 w-full flex flex-col gap-4 pt-16 mx-auto px-4 md:px-6 max-w-6xl"
|
||||
class="p-0.5 w-full flex flex-col gap-4 pb-4 mx-auto px-4 md:px-6 max-w-6xl"
|
||||
style="padding-top: var(--nav-h);"
|
||||
>
|
||||
<div>
|
||||
<span class="text-sm font-medium font-semibold text-primary-700"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../client';
|
||||
import { Chat, Message, MessageChunk, Model, Settings } from '../types';
|
||||
import { applyFilter } from '../utils';
|
||||
import { createAutoScroll, AutoScroll } from '../utils/autoScroll';
|
||||
|
||||
const CHAT_ROUTE = '#/chats';
|
||||
const MODEL_KEY = 'aethera-chat-model';
|
||||
@@ -44,11 +45,14 @@ Alpine.data('chatManager', () => ({
|
||||
error: '',
|
||||
|
||||
selectedChatID: null as string | null,
|
||||
chatListOpen: typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches,
|
||||
loading: false,
|
||||
activeStreamChatID: null as string | null,
|
||||
|
||||
_autoScroll: null as AutoScroll | null,
|
||||
|
||||
async init() {
|
||||
this._autoScroll = createAutoScroll();
|
||||
|
||||
// Acquire Data
|
||||
this._models = await getModels();
|
||||
this.settings = await getSettings();
|
||||
@@ -58,6 +62,7 @@ Alpine.data('chatManager', () => ({
|
||||
// Route Chat
|
||||
const chatID = window.location.hash.split('/')[2];
|
||||
if (chatID) await this.selectChat(chatID);
|
||||
this._autoScroll.scrollToBottom();
|
||||
},
|
||||
|
||||
async loadChats() {
|
||||
@@ -82,7 +87,6 @@ Alpine.data('chatManager', () => ({
|
||||
if (this.selectedChatID == chatId) {
|
||||
const newIndex = Math.min(chatIndex, this.chats.length - 1);
|
||||
this.selectChat(this.chats[newIndex]?.id);
|
||||
if (!this.selectedChatID) this.chatListOpen = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting conversation:', err);
|
||||
@@ -144,6 +148,7 @@ Alpine.data('chatManager', () => ({
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
currentChat.message_count += 1;
|
||||
this._autoScroll?.scrollToBottom('smooth');
|
||||
|
||||
try {
|
||||
await sendMessage(
|
||||
@@ -192,6 +197,8 @@ Alpine.data('chatManager', () => ({
|
||||
if (chunk.user_message) this.upsertMessage(currentChat, chunk.user_message);
|
||||
if (chunk.assistant_message)
|
||||
this.upsertMessage(currentChat, chunk.assistant_message);
|
||||
|
||||
this._autoScroll?.maybeScrollToBottom();
|
||||
},
|
||||
|
||||
upsertMessage(chat: Chat, message: Message) {
|
||||
@@ -222,8 +229,10 @@ Alpine.data('chatManager', () => ({
|
||||
|
||||
// Load Messages
|
||||
this.selectedChatID = chatID;
|
||||
if (!this.selectedChatID) this.chatListOpen = false;
|
||||
else this.loadChatMessages();
|
||||
if (this.selectedChatID) {
|
||||
await this.loadChatMessages();
|
||||
this._autoScroll?.scrollToBottom();
|
||||
}
|
||||
},
|
||||
|
||||
async loadChatMessages() {
|
||||
@@ -285,7 +294,11 @@ Alpine.data('chatManager', () => ({
|
||||
const currentChat =
|
||||
this.chats.find((c) => c.id === this.selectedChatID) ?? null;
|
||||
if (!currentChat) return [];
|
||||
return [...currentChat.messages].reverse();
|
||||
return currentChat.messages;
|
||||
},
|
||||
|
||||
get chatGroups(): { label: string; chats: Chat[] }[] {
|
||||
return groupChatsByDay(this.chats);
|
||||
},
|
||||
|
||||
renderMarkdown(content: string) {
|
||||
@@ -293,6 +306,47 @@ Alpine.data('chatManager', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function startOfDay(d: Date): number {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
}
|
||||
|
||||
function groupChatsByDay(
|
||||
chats: Chat[],
|
||||
): { label: string; chats: Chat[] }[] {
|
||||
const now = new Date();
|
||||
const today = startOfDay(now);
|
||||
const yesterday = today - 86_400_000;
|
||||
|
||||
const groups: { key: number; label: string; chats: Chat[] }[] = [];
|
||||
let current: { key: number; label: string; chats: Chat[] } | null = null;
|
||||
|
||||
for (const chat of chats) {
|
||||
const created = new Date(chat.created_at);
|
||||
const day = startOfDay(created);
|
||||
|
||||
if (!current || current.key !== day) {
|
||||
let label: string;
|
||||
if (day === today) label = 'Today';
|
||||
else if (day === yesterday) label = 'Yesterday';
|
||||
else {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
};
|
||||
if (created.getFullYear() !== now.getFullYear())
|
||||
opts.year = 'numeric';
|
||||
label = created.toLocaleDateString(undefined, opts);
|
||||
}
|
||||
current = { key: day, label, chats: [] };
|
||||
groups.push(current);
|
||||
}
|
||||
|
||||
current.chats.push(chat);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function parseError(err: unknown): string {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
|
||||
24
frontend/src/components/chatSidebarStore.ts
Normal file
24
frontend/src/components/chatSidebarStore.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import Alpine from 'alpinejs';
|
||||
|
||||
const COLLAPSED_KEY = 'aethera-chat-sidebar-collapsed';
|
||||
|
||||
interface ChatSidebarStore {
|
||||
mobileOpen: boolean;
|
||||
collapsed: boolean;
|
||||
toggleMobile(): void;
|
||||
toggleCollapsed(): void;
|
||||
}
|
||||
|
||||
const store: ChatSidebarStore = {
|
||||
mobileOpen: false,
|
||||
collapsed: localStorage.getItem(COLLAPSED_KEY) === 'true',
|
||||
toggleMobile() {
|
||||
this.mobileOpen = !this.mobileOpen;
|
||||
},
|
||||
toggleCollapsed() {
|
||||
this.collapsed = !this.collapsed;
|
||||
localStorage.setItem(COLLAPSED_KEY, String(this.collapsed));
|
||||
},
|
||||
};
|
||||
|
||||
Alpine.store('chatSidebar', store);
|
||||
@@ -13,6 +13,7 @@ import './components/imageManager';
|
||||
import './components/settingsManager';
|
||||
import './components/themeManager';
|
||||
import './components/navigationManager';
|
||||
import './components/chatSidebarStore';
|
||||
|
||||
// Start Alpine
|
||||
window.Alpine = Alpine;
|
||||
|
||||
43
frontend/src/utils/autoScroll.ts
Normal file
43
frontend/src/utils/autoScroll.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Pin-to-bottom controller for the document scroll.
|
||||
//
|
||||
// Tracks whether the user is "near" the bottom of the page. While pinned, new
|
||||
// content (e.g. streaming tokens) scrolls the viewport to keep up. Once the
|
||||
// user scrolls up, pinning releases and updates stop forcing scroll until the
|
||||
// user returns to the bottom.
|
||||
|
||||
const PIN_THRESHOLD_PX = 80;
|
||||
|
||||
export interface AutoScroll {
|
||||
isPinned(): boolean;
|
||||
scrollToBottom(behavior?: ScrollBehavior): void;
|
||||
maybeScrollToBottom(): void;
|
||||
}
|
||||
|
||||
export function createAutoScroll(): AutoScroll {
|
||||
let pinned = true;
|
||||
|
||||
const scrollEl = () => document.scrollingElement || document.documentElement;
|
||||
const distanceFromBottom = () => {
|
||||
const el = scrollEl();
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
pinned = distanceFromBottom() < PIN_THRESHOLD_PX;
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
return {
|
||||
isPinned: () => pinned,
|
||||
scrollToBottom(behavior: ScrollBehavior = 'auto') {
|
||||
window.scrollTo({ top: scrollEl().scrollHeight, behavior });
|
||||
pinned = true;
|
||||
},
|
||||
maybeScrollToBottom() {
|
||||
if (!pinned) return;
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: scrollEl().scrollHeight });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
:root {
|
||||
/* Top nav clearance - pages use this to pad past the fixed nav. */
|
||||
--nav-h: calc(max(1rem, env(safe-area-inset-top)) + 3rem);
|
||||
background: var(--color-primary-50);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--color-primary-50);
|
||||
}
|
||||
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user