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>

View File

@@ -1,13 +1,140 @@
<div x-data="chatManager()"> <div x-data="chatManager()">
<!-- Chat Content -->
<div <div
:class="chatListOpen ? 'md:pl-[23rem]' : ''" :class="$store.chatSidebar.collapsed ? 'md:grid-cols-[3.5rem_minmax(0,1fr)]' : 'md:grid-cols-[18rem_minmax(0,1fr)]'"
class="h-dvh pt-16 flex flex-col-reverse pb-36 overflow-scroll mx-auto px-4 md:px-6 max-w-6xl transition-all duration-300 ease-out" class="md:grid transition-[grid-template-columns] duration-300 ease-out"
>
<!-- Desktop Sidebar - Flat rail flush with the page. Extends the full
viewport height; the transparent nav floats above its top region.
Internal padding-top clears the nav. -->
<aside
class="hidden md:flex md:flex-col md:sticky top-0 border-r border-primary-200/60"
style="height: 100dvh;"
>
<!-- Expanded Rail -->
<template x-if="!$store.chatSidebar.collapsed">
<div
class="flex flex-col h-full"
style="padding-top: var(--nav-h);"
>
<div class="px-2 py-3 flex items-center gap-1">
<button
@click="$store.chatSidebar.toggleCollapsed()"
class="p-1.5 rounded-md text-primary-500 hover:bg-primary-200 hover:text-primary-700 transition-colors cursor-pointer flex-shrink-0"
title="Collapse sidebar"
aria-label="Collapse sidebar"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
@click="selectChat(null)"
class="flex-1 flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-200/60 hover:bg-primary-200 text-primary-800 text-sm font-medium transition-colors cursor-pointer"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span>New conversation</span>
</button>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4">
<div
x-show="chats.length === 0"
class="h-full flex flex-col justify-center text-center py-8 text-primary-500"
>
<p class="text-sm">No chats yet</p>
</div>
<template x-for="group in chatGroups" :key="group.label">
<div class="mt-2 first:mt-0">
<div
class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-wider text-primary-500/70 font-medium"
x-text="group.label"
></div>
<div class="space-y-0.5">
<template x-for="chat in group.chats" :key="chat.id">
<div
@click="selectChat(chat.id)"
:class="selectedChatID === chat.id ? 'bg-primary-200' : 'hover:bg-primary-200/60'"
class="group relative px-3 py-2 rounded-lg cursor-pointer transition-colors"
:title="chat.title || chat.initial_message"
>
<div class="flex items-center justify-between gap-2">
<div
:class="selectedChatID === chat.id ? 'font-semibold' : 'font-medium'"
class="text-sm text-primary-900 truncate min-w-0 flex-1"
x-text="chat.title || chat.initial_message || 'New conversation'"
></div>
<button
@click.stop="deleteChat($event, chat.id)"
class="opacity-0 group-hover:opacity-100 focus:opacity-100 shrink-0 p-1 -mr-1 text-primary-400 hover:text-tertiary-600 hover:bg-tertiary-100 rounded transition-opacity"
title="Delete chat"
aria-label="Delete chat"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div
x-show="chat.title && chat.initial_message"
class="text-xs text-primary-500/80 truncate mt-0.5"
x-text="chat.initial_message"
></div>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- Collapsed Icon Rail -->
<template x-if="$store.chatSidebar.collapsed">
<div
class="flex flex-col items-center py-3 gap-1"
style="padding-top: var(--nav-h);"
>
<button
@click="$store.chatSidebar.toggleCollapsed()"
class="p-2 rounded-md text-primary-500 hover:bg-primary-200 hover:text-primary-700 transition-colors cursor-pointer"
title="Expand sidebar"
aria-label="Expand sidebar"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<button
@click="selectChat(null)"
class="p-2 rounded-md text-primary-700 hover:bg-primary-200 transition-colors cursor-pointer"
title="New conversation"
aria-label="New conversation"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
</template>
</aside>
<!-- Main Chat Column - min-h ensures the sticky input pins to the viewport bottom
even when there are few messages (the column always fills the visible area).
The reading column (max-w-3xl) is centered within whatever space the grid gives. -->
<div
class="flex flex-col min-w-0 px-4 md:px-6"
style="min-height: 100dvh;"
>
<!-- Messages (oldest first, newest last) -->
<div
class="flex-1 flex flex-col gap-1 pb-4 w-full"
style="padding-top: var(--nav-h);"
> >
<template x-for="message in currentChatMessages" :key="message.id"> <template x-for="message in currentChatMessages" :key="message.id">
<div <div :class="['mb-4', message.role === 'user' ? 'text-right' : 'text-left']">
:class="['mb-4', message.role === 'user' ? 'text-right' : 'text-left']"
>
<div <div
:class="['inline-block px-4 py-3 text-left rounded-lg max-w-[95%] md:max-w-[85%]', :class="['inline-block px-4 py-3 text-left rounded-lg max-w-[95%] md:max-w-[85%]',
message.role === 'user' message.role === 'user'
@@ -15,7 +142,6 @@
: 'bg-primary-200 text-primary-900 rounded-bl-none' : 'bg-primary-200 text-primary-900 rounded-bl-none'
]" ]"
> >
<!-- User Images -->
<div <div
x-show="message.role === 'user' && message.images && message.images.length > 0" x-show="message.role === 'user' && message.images && message.images.length > 0"
class="flex gap-1 mb-2 flex-wrap" class="flex gap-1 mb-2 flex-wrap"
@@ -25,18 +151,13 @@
</template> </template>
</div> </div>
<!-- Thinking Section -->
<div <div
x-show="message.thinking" x-show="message.thinking"
x-data="{ expanded: false }" x-data="{ expanded: false }"
@click="expanded = !expanded" @click="expanded = !expanded"
> >
<div <div class="cursor-pointer rounded-lg overflow-hidden bg-primary-100 hover:bg-primary-50">
class="cursor-pointer rounded-lg overflow-hidden bg-primary-100 hover:bg-primary-50" <div class="flex justify-center w-full px-3 py-2 text-xs text-primary-700 flex items-center gap-2 transition-colors">
>
<div
class="flex justify-center w-full px-3 py-2 text-xs text-primary-700 flex items-center gap-2 transition-colors"
>
<span x-text="expanded ? '▼' : '◀'"></span> <span x-text="expanded ? '▼' : '◀'"></span>
<span class="font-medium">Reasoning</span> <span class="font-medium">Reasoning</span>
<span x-text="expanded ? '▼' : '▶'"></span> <span x-text="expanded ? '▼' : '▶'"></span>
@@ -51,30 +172,22 @@
<hr x-show="message.thinking" class="my-2 border-primary-400/50" /> <hr x-show="message.thinking" class="my-2 border-primary-400/50" />
<!-- Loading Spinner (Streaming with no content yet) -->
<div <div
x-show="message.role === 'assistant' && message.status === 'streaming' && !message.thinking && !message.content" x-show="message.role === 'assistant' && message.status === 'streaming' && !message.thinking && !message.content"
class="flex items-center gap-2 py-1" class="flex items-center gap-2 py-1"
> >
<div <div class="h-4 w-4 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"></div>
class="h-4 w-4 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
></div>
<span class="text-xs text-primary-600">Thinking...</span> <span class="text-xs text-primary-600">Thinking...</span>
</div> </div>
<!-- Main Content -->
<div <div
x-show="message.content || message.status !== 'streaming'" x-show="message.content || message.status !== 'streaming'"
class="prose max-w-none text-sm prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-pre:p-0" class="prose max-w-none text-sm prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-pre:p-0"
x-html="renderMarkdown(message.content)" x-html="renderMarkdown(message.content)"
></div> ></div>
<!-- Message Metadata -->
<div class="flex items-center justify-between gap-2 mt-2"> <div class="flex items-center justify-between gap-2 mt-2">
<div <div class="text-[10px] opacity-60" x-text="new Date(message.created_at).toLocaleTimeString()"></div>
class="text-[10px] opacity-60"
x-text="new Date(message.created_at).toLocaleTimeString()"
></div>
<div <div
x-show="message.role === 'assistant' && ['stopped', 'error', 'failed'].includes(message.status)" x-show="message.role === 'assistant' && ['stopped', 'error', 'failed'].includes(message.status)"
:class="message.status === 'stopped' ? 'bg-primary-300/50 text-primary-700' : 'bg-tertiary-100 text-tertiary-700'" :class="message.status === 'stopped' ? 'bg-primary-300/50 text-primary-700' : 'bg-tertiary-100 text-tertiary-700'"
@@ -84,37 +197,20 @@
</div> </div>
</div> </div>
<!-- Stats Badges (Assistant) -->
<div <div
x-show="message.role === 'assistant' && message.stats" x-show="message.role === 'assistant' && message.stats"
class="flex items-center gap-1 py-2 flex-wrap justify-start text-primary-700" class="flex items-center gap-1 py-2 flex-wrap justify-start text-primary-700"
> >
<!-- Cumulative Tokens with Hover Breakdown -->
<div <div
x-show="message.stats?.prompt_tokens || message.stats?.generated_tokens" x-show="message.stats?.prompt_tokens || message.stats?.generated_tokens"
class="group relative px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full cursor-help" class="group relative px-2 py-0.5 text-[10px] bg-primary-300/50 rounded-full cursor-help"
> >
<span <span x-text="`${(message.stats?.prompt_tokens || 0) + (message.stats?.generated_tokens || 0)} tokens`"></span>
x-text="`${(message.stats?.prompt_tokens || 0) + (message.stats?.generated_tokens || 0)} tokens`" <div class="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 m-2 px-2 py-1 bg-gray-900 text-white text-[10px] rounded whitespace-nowrap pointer-events-none grid grid-cols-[auto_1fr] gap-x-2">
></span> <div x-show="message.stats?.prompt_tokens" x-text="message.stats?.prompt_tokens"></div>
<!-- Tokens -->
<div
class="invisible group-hover:visible absolute bottom-full left-1/2 -translate-x-1/2 m-2 px-2 py-1 bg-gray-900 text-white text-[10px] rounded whitespace-nowrap pointer-events-none grid grid-cols-[auto_1fr] gap-x-2"
>
<div
x-show="message.stats?.prompt_tokens"
x-text="message.stats?.prompt_tokens"
></div>
<div x-show="message.stats?.prompt_tokens">prompt tokens</div> <div x-show="message.stats?.prompt_tokens">prompt tokens</div>
<div x-show="message.stats?.generated_tokens" x-text="message.stats?.generated_tokens"></div>
<div <div x-show="message.stats?.generated_tokens">generated tokens</div>
x-show="message.stats?.generated_tokens"
x-text="message.stats?.generated_tokens"
></div>
<div x-show="message.stats?.generated_tokens">
generated tokens
</div>
</div> </div>
</div> </div>
@@ -138,17 +234,15 @@
</template> </template>
</div> </div>
<!-- Floating Input and Model Selection --> <!-- Sticky Input Bar -->
<div <div
:class="chatListOpen ? 'md:pl-[23rem]' : ''" class="sticky bottom-0 pt-2 bg-primary-50"
class="fixed bottom-4 w-full flex justify-center px-4 md:px-6 transition-all duration-300 ease-out" style="padding-bottom: max(0.5rem, env(safe-area-inset-bottom));"
> >
<div class="w-full sm:w-[calc(100%-2rem)] max-w-3xl z-10">
<form <form
@submit.prevent="sendMessage" @submit.prevent="sendMessage"
class="flex flex-col gap-2 p-3 bg-primary-50/95 backdrop-blur-sm rounded-2xl shadow-2xl border border-primary-200" class="flex flex-col gap-2 p-3 bg-primary-50 rounded-2xl border border-primary-200"
> >
<!-- Hidden File Input -->
<input <input
x-ref="fileInput" x-ref="fileInput"
type="file" type="file"
@@ -165,11 +259,7 @@
" "
/> />
<!-- Image Preview Strip --> <div x-show="selectedImages.length > 0" class="flex gap-2 flex-wrap">
<div
x-show="selectedImages.length > 0"
class="flex gap-2 flex-wrap"
>
<template x-for="(img, idx) in selectedImages" :key="idx"> <template x-for="(img, idx) in selectedImages" :key="idx">
<div class="relative"> <div class="relative">
<img :src="img" class="w-20 h-20 object-cover rounded-lg" /> <img :src="img" class="w-20 h-20 object-cover rounded-lg" />
@@ -184,7 +274,6 @@
</template> </template>
</div> </div>
<!-- Message Textarea (dominant) -->
<textarea <textarea
x-model="inputMessage" x-model="inputMessage"
placeholder="Type your message..." placeholder="Type your message..."
@@ -194,9 +283,7 @@
@input="$el.style.height = 'auto'; $el.style.height = $el.scrollHeight + 'px'" @input="$el.style.height = 'auto'; $el.style.height = $el.scrollHeight + 'px'"
></textarea> ></textarea>
<!-- Bottom Toolbar: model badge + actions -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Model Badge Select -->
<div class="relative min-w-0 flex-shrink inline-flex items-center pl-7 pr-6 py-1 bg-primary-200/70 hover:bg-primary-300 border border-primary-200 rounded-full transition-colors"> <div class="relative min-w-0 flex-shrink inline-flex items-center pl-7 pr-6 py-1 bg-primary-200/70 hover:bg-primary-300 border border-primary-200 rounded-full transition-colors">
<span <span
class="text-primary-900 text-xs font-medium truncate pointer-events-none" class="text-primary-900 text-xs font-medium truncate pointer-events-none"
@@ -209,41 +296,17 @@
> >
<option value="">Select Model</option> <option value="">Select Model</option>
<template x-for="model in models" :key="model.id"> <template x-for="model in models" :key="model.id">
<option <option :value="model.id" x-text="model.name || model.id"></option>
:value="model.id"
x-text="model.name || model.id"
></option>
</template> </template>
</select> </select>
<svg <svg class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary-500 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary-500 pointer-events-none" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg> </svg>
<svg <svg class="absolute right-1.5 top-1/2 -translate-y-1/2 h-3 w-3 text-primary-500 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
class="absolute right-1.5 top-1/2 -translate-y-1/2 h-3 w-3 text-primary-500 pointer-events-none" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg> </svg>
</div> </div>
<!-- Thinking Toggle Badge -->
<button <button
type="button" type="button"
@click="thinkingEnabled = !thinkingEnabled" @click="thinkingEnabled = !thinkingEnabled"
@@ -278,7 +341,6 @@
</svg> </svg>
</button> </button>
<!-- Attach Image Badge Button -->
<button <button
type="button" type="button"
@click="$refs.fileInput.click()" @click="$refs.fileInput.click()"
@@ -287,31 +349,19 @@
aria-label="Attach Image" aria-label="Attach Image"
> >
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
stroke-linecap="round" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
stroke-linejoin="round"
stroke-width="2"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg> </svg>
</button> </button>
<div class="ml-auto flex items-center gap-2">
<!-- Stop Button -->
<button <button
x-show="loading" x-show="loading"
type="button" type="button"
@click="stopResponse()" @click="stopResponse()"
:disabled="!activeStreamChatID" :disabled="!activeStreamChatID"
:class="!activeStreamChatID ? 'opacity-50 cursor-not-allowed' : 'hover:bg-tertiary-700'" :class="!activeStreamChatID ? 'opacity-50 cursor-not-allowed' : 'hover:bg-tertiary-700'"
class="ml-auto h-7 w-7 bg-tertiary-600 text-white rounded-full transition-colors flex items-center justify-center flex-shrink-0" class="h-7 w-7 bg-tertiary-600 text-white rounded-full transition-colors flex items-center justify-center flex-shrink-0"
title="Stop response" title="Stop response"
aria-label="Stop response" aria-label="Stop response"
> >
@@ -320,12 +370,11 @@
</svg> </svg>
</button> </button>
<!-- Send Button -->
<button <button
type="submit" type="submit"
:disabled="(!inputMessage.trim() && selectedImages.length === 0) || loading" :disabled="(!inputMessage.trim() && selectedImages.length === 0) || loading"
:class=" ((!inputMessage.trim() && selectedImages.length === 0) || loading) ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-md'" :class=" ((!inputMessage.trim() && selectedImages.length === 0) || loading) ? 'opacity-50 cursor-not-allowed' : 'hover:bg-primary-600'"
class="ml-auto h-7 w-7 bg-gradient-to-r from-primary-600 to-primary-500 text-white rounded-full transition-all flex items-center justify-center flex-shrink-0" class="h-7 w-7 bg-gradient-to-r from-primary-600 to-primary-500 text-white rounded-full transition-all flex items-center justify-center flex-shrink-0"
title="Send" title="Send"
aria-label="Send" aria-label="Send"
> >
@@ -334,18 +383,13 @@
</template> </template>
<template x-if="!loading"> <template x-if="!loading">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg> </svg>
</template> </template>
</button> </button>
</div> </div>
</div>
<!-- Error Message -->
<div <div
x-show="error" x-show="error"
class="bg-tertiary-50 border border-tertiary-200 rounded-lg px-4 py-2" class="bg-tertiary-50 border border-tertiary-200 rounded-lg px-4 py-2"
@@ -355,187 +399,91 @@
</form> </form>
</div> </div>
</div> </div>
<!-- Floating Conversation List Toggle -->
<button
@click="chatListOpen = !chatListOpen"
:aria-expanded="chatListOpen ? 'true' : 'false'"
aria-label="Toggle left navigation"
class="isolate cursor-pointer fixed z-50 flex justify-between top-4 left-4 md:left-6 p-2 rounded-md text-primary-700 hover:bg-primary-300 transition-colors"
>
<svg
x-show="!chatListOpen"
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="chatListOpen"
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>
<!-- Floating Conversation List -->
<div
x-show="chatListOpen"
x-transition:enter="transform transition-all duration-300 ease-out"
x-transition:enter-start="-translate-x-full opacity-0"
x-transition:enter-end="translate-x-0 opacity-100"
x-transition:leave="transform transition-all duration-300 ease-in"
x-transition:leave-start="translate-x-0 opacity-100"
x-transition:leave-end="-translate-x-full opacity-0"
class="fixed top-16 left-0 right-0 mx-auto md:left-6 md:right-auto md:mx-0 bottom-4 w-86 bg-primary-100 rounded-xl shadow-lg z-20 overflow-hidden flex flex-col"
>
<div class="px-4 py-3 border-b border-primary-200 flex justify-center">
<h4 class="font-semibold text-primary-900">
<span>Conversations</span>
</h4>
</div> </div>
<!-- Conversation List--> <!-- Mobile Sidebar Drawer (controlled from the top nav hamburger) -->
<div id="left-nav-desktop" class="flex-1 overflow-y-auto p-4"> <div
x-show="$store.chatSidebar.mobileOpen"
x-cloak
x-transition.opacity
@click="$store.chatSidebar.mobileOpen = false"
class="md:hidden fixed inset-0 z-40 bg-black/40"
></div>
<div
x-show="$store.chatSidebar.mobileOpen"
x-cloak
x-transition:enter="transform transition-transform duration-300 ease-out"
x-transition:enter-start="-translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition-transform duration-300 ease-in"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="-translate-x-full"
class="md:hidden fixed top-0 left-0 bottom-0 z-40 w-80 max-w-[85vw] bg-primary-50 shadow-2xl flex flex-col"
>
<div
class="px-3 pb-3 flex items-center gap-2 border-b border-primary-200/60"
style="padding-top: var(--nav-h);"
>
<button
@click="selectChat(null); $store.chatSidebar.mobileOpen = false"
class="flex-1 flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-200/60 hover:bg-primary-200 text-primary-800 text-sm font-medium transition-colors cursor-pointer"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span>New conversation</span>
</button>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4">
<div <div
x-show="chats.length === 0" x-show="chats.length === 0"
class="h-full flex flex-col justify-center text-center py-8 text-primary-600" class="h-full flex flex-col justify-center text-center py-8 text-primary-500"
> >
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 mx-auto mb-2 text-primary-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<p class="text-sm">No chats yet</p> <p class="text-sm">No chats yet</p>
</div> </div>
<div class="space-y-2"> <template x-for="group in chatGroups" :key="group.label">
<template x-for="chat in chats" :key="chat.id"> <div class="mt-2 first:mt-0">
<div <div
@click="selectChat(chat.id); if (!window.matchMedia('(min-width: 768px)').matches) chatListOpen = false;" class="px-3 pt-3 pb-1 text-[10px] uppercase tracking-wider text-primary-500/70 font-medium"
:class="[ x-text="group.label"
'p-3 rounded-lg cursor-pointer transition-all border-l-3', ></div>
selectedChatID === chat.id <div class="space-y-0.5">
? 'bg-primary-200 border-l-primary-600' <template x-for="chat in group.chats" :key="chat.id">
: 'hover:bg-primary-200 border-l-transparent'
]"
:title="chat.title"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="mt-0.5 shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
:class="[
'h-4 w-4',
selectedChatID === chat.id ? 'text-primary-600' : 'text-primary-400'
]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="font-medium text-sm text-primary-900 truncate">
<span x-text="chat.title || 'New Conversation'"></span>
</div>
<div <div
class="flex items-center gap-2 mt-1.5 text-xs text-primary-600" @click="selectChat(chat.id); $store.chatSidebar.mobileOpen = false"
:class="selectedChatID === chat.id ? 'bg-primary-200' : 'hover:bg-primary-200/60'"
class="group relative px-3 py-2 rounded-lg cursor-pointer transition-colors"
:title="chat.title || chat.initial_message"
> >
<span <div class="flex items-center justify-between gap-2">
x-show="chat.message_count > 0" <div
class="shrink-0 bg-primary-300 text-primary-700 px-1.5 py-0.5 rounded text-[10px] font-medium" :class="selectedChatID === chat.id ? 'font-semibold' : 'font-medium'"
x-text="chat.message_count" class="text-sm text-primary-900 truncate min-w-0 flex-1"
></span> x-text="chat.title || chat.initial_message || 'New conversation'"
<span class="truncate" x-text="chat.initial_message"></span> ></div>
</div>
</div>
<button <button
@click.stop="deleteChat($event, chat.id)" @click.stop="deleteChat($event, chat.id)"
class="cursor-pointer shrink-0 p-1 text-primary-400 hover:text-tertiary-600 hover:bg-tertiary-100 rounded transition-colors" class="shrink-0 p-1 -mr-1 text-primary-400 hover:text-tertiary-600 hover:bg-tertiary-100 rounded transition-colors"
title="Delete Chat" title="Delete chat"
aria-label="Delete chat"
> >
<svg <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
xmlns="http://www.w3.org/2000/svg" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg> </svg>
</button> </button>
</div> </div>
<div
x-show="chat.title && chat.initial_message"
class="text-xs text-primary-500/80 truncate mt-0.5"
x-text="chat.initial_message"
></div>
</div>
</template>
</div>
</div> </div>
</template> </template>
</div> </div>
</div> </div>
<!-- Left Nav Footer -->
<div
x-show="$store.navigation.activeTab === 'chats'"
class="p-4 border-t border-primary-200 shrink-0"
>
<button
@click="selectChat(null)"
class="w-full px-4 py-2.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium flex cursor-pointer items-center justify-center gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
New Conversation
</button>
</div>
</div>
</div> </div>

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;
} }