refactor(style): update chat layout and scrolling
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
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/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;
|
||||||
|
|||||||
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";
|
@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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user