refactor(style): update chat layout and scrolling

This commit is contained in:
2026-05-17 17:57:30 -04:00
parent 6307a64c9c
commit eddf5bf12d
9 changed files with 646 additions and 529 deletions

View File

@@ -4,21 +4,55 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
name="viewport" 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> <title>Aethera - AI Conversation & Image Generator</title>
<script type="module" src="./dist/main.js"></script> <script type="module" src="./dist/main.js"></script>
<link rel="stylesheet" href="./dist/styles.css" /> <link rel="stylesheet" href="./dist/styles.css" />
</head> </head>
<body class="bg-primary-50" x-data x-init="$store.navigation.init()"> <body class="bg-primary-50" x-data x-init="$store.navigation.init()">
<!-- Nav --> <!-- Nav - Fixed and fully transparent so page content always sits behind
<div it (including under the iOS dynamic island, even at scroll=0). The
class="isolate fixed z-50 w-full flex justify-between mt-4 px-4 md:px-6" 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 --> <!-- 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 <a
href="#/chats" href="#/chats"
:class="[ :class="[
@@ -58,7 +92,7 @@
<button <button
@click="$store.theme.cycleTheme()" @click="$store.theme.cycleTheme()"
x-init="$store.theme.init()" 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" aria-label="Toggle theme"
> >
<svg <svg
@@ -107,9 +141,9 @@
/> />
</svg> </svg>
</button> </button>
</div> </header>
<!-- Main Content Area --> <!-- Main Content Area - No fixed height; the document scrolls. -->
<main id="page-content" class="h-dvh"></main> <main id="page-content"></main>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
<div <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()" x-data="imageGenerator()"
> >
<div> <div>

View File

@@ -1,7 +1,8 @@
<form <form
x-data="settingsManager()" x-data="settingsManager()"
@submit.prevent="saveSettings" @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> <div>
<span class="text-sm font-medium font-semibold text-primary-700" <span class="text-sm font-medium font-semibold text-primary-700"

View File

@@ -14,6 +14,7 @@ import {
} from '../client'; } from '../client';
import { Chat, Message, MessageChunk, Model, Settings } from '../types'; import { Chat, Message, MessageChunk, Model, Settings } from '../types';
import { applyFilter } from '../utils'; import { applyFilter } from '../utils';
import { createAutoScroll, AutoScroll } from '../utils/autoScroll';
const CHAT_ROUTE = '#/chats'; const CHAT_ROUTE = '#/chats';
const MODEL_KEY = 'aethera-chat-model'; const MODEL_KEY = 'aethera-chat-model';
@@ -44,11 +45,14 @@ Alpine.data('chatManager', () => ({
error: '', error: '',
selectedChatID: null as string | null, selectedChatID: null as string | null,
chatListOpen: typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches,
loading: false, loading: false,
activeStreamChatID: null as string | null, activeStreamChatID: null as string | null,
_autoScroll: null as AutoScroll | null,
async init() { async init() {
this._autoScroll = createAutoScroll();
// Acquire Data // Acquire Data
this._models = await getModels(); this._models = await getModels();
this.settings = await getSettings(); this.settings = await getSettings();
@@ -58,6 +62,7 @@ Alpine.data('chatManager', () => ({
// Route Chat // Route Chat
const chatID = window.location.hash.split('/')[2]; const chatID = window.location.hash.split('/')[2];
if (chatID) await this.selectChat(chatID); if (chatID) await this.selectChat(chatID);
this._autoScroll.scrollToBottom();
}, },
async loadChats() { async loadChats() {
@@ -82,7 +87,6 @@ Alpine.data('chatManager', () => ({
if (this.selectedChatID == chatId) { if (this.selectedChatID == chatId) {
const newIndex = Math.min(chatIndex, this.chats.length - 1); const newIndex = Math.min(chatIndex, this.chats.length - 1);
this.selectChat(this.chats[newIndex]?.id); this.selectChat(this.chats[newIndex]?.id);
if (!this.selectedChatID) this.chatListOpen = false;
} }
} catch (err) { } catch (err) {
console.error('Error deleting conversation:', err); console.error('Error deleting conversation:', err);
@@ -144,6 +148,7 @@ Alpine.data('chatManager', () => ({
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}); });
currentChat.message_count += 1; currentChat.message_count += 1;
this._autoScroll?.scrollToBottom('smooth');
try { try {
await sendMessage( await sendMessage(
@@ -192,6 +197,8 @@ Alpine.data('chatManager', () => ({
if (chunk.user_message) this.upsertMessage(currentChat, chunk.user_message); if (chunk.user_message) this.upsertMessage(currentChat, chunk.user_message);
if (chunk.assistant_message) if (chunk.assistant_message)
this.upsertMessage(currentChat, chunk.assistant_message); this.upsertMessage(currentChat, chunk.assistant_message);
this._autoScroll?.maybeScrollToBottom();
}, },
upsertMessage(chat: Chat, message: Message) { upsertMessage(chat: Chat, message: Message) {
@@ -222,8 +229,10 @@ Alpine.data('chatManager', () => ({
// Load Messages // Load Messages
this.selectedChatID = chatID; this.selectedChatID = chatID;
if (!this.selectedChatID) this.chatListOpen = false; if (this.selectedChatID) {
else this.loadChatMessages(); await this.loadChatMessages();
this._autoScroll?.scrollToBottom();
}
}, },
async loadChatMessages() { async loadChatMessages() {
@@ -285,7 +294,11 @@ Alpine.data('chatManager', () => ({
const currentChat = const currentChat =
this.chats.find((c) => c.id === this.selectedChatID) ?? null; this.chats.find((c) => c.id === this.selectedChatID) ?? null;
if (!currentChat) return []; if (!currentChat) return [];
return [...currentChat.messages].reverse(); return currentChat.messages;
},
get chatGroups(): { label: string; chats: Chat[] }[] {
return groupChatsByDay(this.chats);
}, },
renderMarkdown(content: string) { 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 { function parseError(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);

View 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);

View File

@@ -13,6 +13,7 @@ import './components/imageManager';
import './components/settingsManager'; import './components/settingsManager';
import './components/themeManager'; import './components/themeManager';
import './components/navigationManager'; import './components/navigationManager';
import './components/chatSidebarStore';
// Start Alpine // Start Alpine
window.Alpine = Alpine; window.Alpine = Alpine;

View 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 });
});
},
};
}

View File

@@ -1,6 +1,17 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography"; @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] { [x-cloak] {
display: none !important; display: none !important;
} }