Skip to content

Commit 7d67d8b

Browse files
committed
feat: InsaneUI mode, UI polishing, scroll logic changed again
1 parent d91fb65 commit 7d67d8b

6 files changed

Lines changed: 198 additions & 71 deletions

File tree

apps/frontend/app/components/chat/ChatInterface.vue

Lines changed: 96 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,42 @@ import { VueLenis } from 'lenis/vue'
1010
import { Skeleton } from '@/lib/shadcn/components/ui/skeleton'
1111
import { Card, CardContent } from '~/lib/shadcn/components/ui/card'
1212
import VanishingInput from '~/lib/shadcn/components/ui/vanishing-input/VanishingInput.vue'
13+
import LiquidGlassDiv from '../LiquidGlassDiv.vue'
1314
15+
const isDev = import.meta.dev
1416
const { $auth } = useNuxtApp()
1517
const { convexApiUrl } = useRuntimeConfig().public
1618
const convex = useConvexClient()
19+
const chatContext = useChatContext()
1720
18-
// For [...all] routing the value is an array
19-
const threadIdRef = useRouteParams<string>('all', undefined, { transform: { get: s => Array.isArray(s) ? s[0] : s } })
21+
// Lenis have bug with useTemplateRef
22+
const lenisRef = ref<{ $el: HTMLElement, lenis: Lenis }>()
23+
const { y: scrollY } = useScroll(computed(() => lenisRef.value?.$el))
24+
const nearTopBottom = computed(() => {
25+
const el = lenisRef.value?.$el
26+
if (!el)
27+
return [null, null]
28+
29+
const currentScroll = lenisRef.value!.lenis.targetScroll || scrollY.value
30+
31+
const gapFromTop = currentScroll
32+
const gapFromBottom = el.scrollHeight - el.clientHeight - currentScroll
33+
34+
const nearTop = gapFromTop < 69
35+
const nearBottom = gapFromBottom < 69
36+
return [nearTop && gapFromTop + 1, nearBottom && gapFromBottom + 1, lenisRef.value!.lenis.targetScroll, scrollY.value]
37+
})
38+
39+
const threadIdRef = useThreadIdRef()
2040
2141
const cachedThreadsMessages: {
2242
[threadId: string]: Array<CustomMessage>
2343
} = {}
24-
2544
const messages = ref<Array<CustomMessage>>([])
2645
const messagesMapped = computed(() => keyBy(messages.value, 'id'))
2746
const streamingMessages = ref(0)
28-
29-
const chatInput = ref('')
30-
3147
const isFetching = ref(false)
32-
33-
// Lenis have bug with useTemplateRef
34-
const lenisRef = ref<{ $el: HTMLElement, lenis: Lenis }>()
48+
const chatInput = ref('')
3549
3650
// Fetch messages and resume streams
3751
const { ignoreUpdates: ignorePathUpdate } = watchIgnorable(
@@ -73,7 +87,7 @@ const { ignoreUpdates: ignorePathUpdate } = watchIgnorable(
7387
}
7488
}
7589
76-
doScrollBottom({ forceTries: 3 })
90+
nextTick(() => doScrollBottom({ tries: 6 }))
7791
}
7892
},
7993
{ immediate: true },
@@ -107,7 +121,7 @@ async function handleSubmit({ input, confirmMultiStream = false }: HandleSubmitA
107121
108122
chatInput.value = ''
109123
110-
nextTick(() => { doScrollBottom() })
124+
nextTick(() => { doScrollBottom({ tries: 2 }) })
111125
112126
// Create new thread
113127
if (!threadIdRef.value) {
@@ -177,8 +191,7 @@ async function pollToMessage({ message, resumeStreamId, threadId = threadIdRef.v
177191
console.log('Poll completed')
178192
}
179193
180-
if (isNearBottom())
181-
setTimeout(() => { doScrollBottom({ forceTries: 1 }) }, 100)
194+
nextTick(() => { doScrollBottom({ maybe: true }) })
182195
}
183196
184197
interface StreamToMessageArgs {
@@ -266,8 +279,7 @@ async function streamToMessage({ message, content, resumeStreamId }: StreamToMes
266279
if (state.content)
267280
message.content += state.content
268281
269-
if (isNearBottom())
270-
setTimeout(() => { doScrollBottom({ forceTries: 1 }) }, 100)
282+
nextTick(() => { doScrollBottom({ maybe: true }) })
271283
}
272284
273285
message.isStreaming = false
@@ -283,45 +295,38 @@ async function streamToMessage({ message, content, resumeStreamId }: StreamToMes
283295
console.log('Stream completed')
284296
}
285297
286-
function doScrollBottom({ smooth = true, maybe = false, forceTries = 0, lastScrollTop = 0 } = {}) {
298+
function doScrollBottom({ smooth = true, maybe = false, tries = 0, lastScrollTop = 0 } = {}) {
287299
if (!lenisRef.value)
288300
return
289301
290302
const l = lenisRef.value
291303
const scrollHeight = l.$el.scrollHeight
292304
293-
if (l.$el.scrollTop < lastScrollTop)
294-
forceTries = 0
305+
// Allow user to try escape the tries
306+
if (tries && (l.$el.scrollTop < lastScrollTop))
307+
tries = 0
295308
296-
if (!forceTries && maybe && !isNearBottom())
309+
if (maybe && l.lenis.direction !== 1)
297310
return
298311
299-
if (scrollHeight !== l.lenis.limit + l.$el.clientHeight)
312+
if (scrollHeight !== l.lenis.limit + l.$el.clientHeight) {
300313
l.lenis.resize()
314+
++tries
315+
}
301316
302317
smooth
303318
? l.lenis.scrollTo(scrollHeight)
304319
: l.$el.scrollTop = scrollHeight
305320
306321
lastScrollTop = l.$el.scrollTop
307322
308-
if (forceTries) {
309-
countdown(200, () => {
310-
sleep(0).then(() => doScrollBottom({ smooth, maybe, forceTries: forceTries - 1, lastScrollTop }))
323+
if (tries > 1) {
324+
countdown(250, () => {
325+
sleep(0).then(() => doScrollBottom({ smooth, maybe, tries: tries - 1, lastScrollTop }))
311326
}, { key: 'dSB', replace: true })
312327
}
313328
}
314329
315-
function isNearBottom() {
316-
if (!lenisRef.value)
317-
return
318-
319-
const l = lenisRef.value
320-
const scrollHeight = l.$el.scrollHeight
321-
322-
return (l.$el.scrollTop + l.$el.clientHeight) > (scrollHeight - 69)
323-
}
324-
325330
const multiStreamConfirmDialogOpen = ref(false)
326331
let savedChatInput = ''
327332
function alertIsStreaming(input: string) {
@@ -334,15 +339,16 @@ function alertIsStreaming(input: string) {
334339
<div class="relative flex flex-col">
335340
<VueLenis ref="lenisRef" class="h-screen overflow-y-scroll px-4">
336341
<div class="mx-auto h-full max-w-full lg:max-w-4xl">
342+
<FlickeringGrid
343+
v-if="chatContext.insaneUI.value"
344+
class="absolute inset-0 z-0 place-content-center" :square-size="10" :grid-gap="5"
345+
color="#60A5FA" :max-opacity="0.5" :flicker-chance="0.1"
346+
/>
347+
337348
<div
338349
v-show="!(isFetching && !messages.length)"
339-
class="pointer-events-none absolute left-0 h-screen w-full place-content-center overflow-hidden"
350+
class="pointer-events-none absolute left-0 z-0 h-screen w-full place-content-center overflow-hidden"
340351
>
341-
<FlickeringGrid
342-
v-if="false" class="absolute inset-0 z-0 place-content-center" :square-size="4" :grid-gap="6"
343-
color="#60A5FA" :max-opacity="0.5" :flicker-chance="0.1" :width="2000" :height="2000"
344-
/>
345-
346352
<div
347353
v-if="!messages.length"
348354
class="relative z-2 whitespace-pre-wrap px-2 text-center text-4xl text-gray-400 font-medium tracking-tighter dark:text-gray-500 dark:text-white"
@@ -353,48 +359,73 @@ function alertIsStreaming(input: string) {
353359
</div>
354360
</div>
355361

356-
<div v-if="messages.length" class="z-2 space-y-4">
362+
<div v-if="messages.length" class="relative z-2 space-y-4">
357363
<div class="pt-6" />
358364

359365
<div
360366
v-for="m of messages" :key="m.id" class="flex"
361367
:class="m.role === 'user' ? 'justify-end' : 'justify-start'"
362368
>
363-
<Card
364-
class="bg-transparent shadow-md"
369+
<component
370+
:is="chatContext.insaneUI.value ? LiquidGlassDiv : 'div'"
371+
class="rounded-$radius $c-radius=$radius"
365372
:class="[
366-
m.role === 'user' ? 'border-secondary-200' : 'border-primary-200',
367-
m.role === 'user' ? 'max-w-80% md:max-w-2xl' : 'max-w-full md:max-w-3xl',
373+
m.role === 'user'
374+
? 'bg-secondary-100 dark:bg-secondary-950'
375+
: 'bg-primary-100 dark:bg-primary-950',
376+
chatContext.insaneUI.value
377+
? 'bg-opacity-50!'
378+
: 'bg-opacity-5!',
368379
]"
369380
>
370-
<!-- <CardHeader class="px-4 py-2">
371-
<CardTitle class="text-sm font-semibold">
372-
{{ m.role === 'user' ? $t('pages.chat.userLabel') : $t('pages.chat.aiLabel') }}
373-
</CardTitle>
374-
</CardHeader> -->
375-
<CardContent class="px-4 py-3">
376-
<div v-if="m.isStreaming && !m.content" class="flex gap-2">
377-
<div>{{ $t('generating') }}</div>
378-
<div class="spinner h-5 w-5" />
379-
</div>
380-
<MDC v-else :value="m.content" />
381-
<div class="hidden first:block">
382-
<Skeleton
383-
class="h-5 w-$c-W rounded-full bg-muted-foreground" :style="{
384-
'--c-W': `${(Math.floor(Math.random() * (300 - 100 + 1)) + 100) * (m.role === 'user' ? 1 : 2)}px`,
385-
}"
386-
/>
387-
</div>
388-
</CardContent>
389-
</Card>
381+
<Card
382+
class="bg-transparent shadow-md"
383+
:class="[
384+
m.role === 'user'
385+
? 'border-secondary-200 max-w-80% md:max-w-2xl'
386+
: 'border-primary-200 max-w-full md:max-w-3xl',
387+
]"
388+
>
389+
<!-- <CardHeader class="px-4 py-2">
390+
<CardTitle class="text-sm font-semibold">
391+
{{ m.role === 'user' ? $t('pages.chat.userLabel') : $t('pages.chat.aiLabel') }}
392+
</CardTitle>
393+
</CardHeader> -->
394+
<CardContent class="min-w-25 px-4 py-3 [&_.prose-hr]:(border-accent-foreground!)">
395+
<div v-if="m.isStreaming && !m.content" class="my-4 flex gap-2">
396+
<div>{{ $t('generating') }}</div>
397+
<div class="spinner h-5 w-5" />
398+
</div>
399+
<MDC v-else :value="m.content" />
400+
<div class="my-4 hidden first:block">
401+
<Skeleton
402+
class="h-5 w-$c-W rounded-full bg-muted-foreground" :style="{
403+
'--c-W': `${(Math.floor(Math.random() * (300 - 100 + 1)) + 100) * (m.role === 'user' ? 1 : 2)}px`,
404+
}"
405+
/>
406+
</div>
407+
</CardContent>
408+
</Card>
409+
</component>
390410
</div>
391411

392412
<div class="pb-40" />
393413
</div>
394414
</div>
395415
</VueLenis>
396416

397-
<LiquidGlassDiv class="bottom-0 left-0 max-w-full w-full border-t border-secondary p-4 $c-radius=0px absolute!">
417+
<LiquidGlassDiv class="bottom-0 left-0 z-3 max-w-full w-full border-t border-secondary p-4 $c-radius=0px absolute!">
418+
<div v-if="isDev" class="absolute bottom-100%">
419+
{{ nearTopBottom }}
420+
</div>
421+
<div class="absolute bottom-100% right-6 mb-2 flex flex-col gap-2">
422+
<Button variant="outline" size="icon" class="rounded-xl p-1 opacity-100 transition-opacity duration-500" :class="nearTopBottom[0] && 'invisible opacity-0'" @click="lenisRef!.lenis.scrollTo('top')">
423+
<div class="i-hugeicons:circle-arrow-up-03 h-full w-full" />
424+
</Button>
425+
<Button variant="outline" size="icon" class="rounded-xl p-1 opacity-100 transition-opacity duration-500" :class="nearTopBottom[1] && 'invisible opacity-0'" @click="lenisRef!.lenis.scrollTo('bottom')">
426+
<div class="i-hugeicons:circle-arrow-down-03 h-full w-full" />
427+
</Button>
428+
</div>
398429
<div>
399430
<div class="mx-auto max-w-lg flex flex-col gap-3">
400431
<VanishingInput

apps/frontend/app/components/chat/ChatSidebar.vue

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ import { Button } from '~/lib/shadcn/components/ui/button'
2727
const { $auth, $init } = useNuxtApp()
2828
const colorMode = useColorMode()
2929
const convex = useConvexClient()
30+
const chatContext = useChatContext()
3031
31-
// For [...all] routing the value is an array
32-
const threadIdRef = useRouteParams<string>('all', undefined, { transform: { get: s => Array.isArray(s) ? s[0] : s } })
32+
const threadIdRef = useThreadIdRef()
3333
3434
// Load local threads
3535
const { data: threads } = useIDBKeyval<Doc<'threads'>[]>('threads', [])
@@ -152,7 +152,7 @@ const [DefineThreadLiItem, ReuseThreadLiItem] = createReusableTemplate<{ thread:
152152
</Button>
153153
</div>
154154
</SidebarHeader>
155-
<SidebarContent>
155+
<SidebarContent class="p-2">
156156
<SidebarGroup v-if="!threads?.length">
157157
<div class="py-4 text-center text-gray-500 dark:text-gray-400">
158158
{{ isFetching
@@ -198,7 +198,7 @@ const [DefineThreadLiItem, ReuseThreadLiItem] = createReusableTemplate<{ thread:
198198
{{ thread.title }}
199199
</div>
200200
<LiquidGlassDiv
201-
class="right-0 top-0 h-full flex translate-x-[calc(100%+1rem)] items-center px-2 transition-transform will-change-transform $c-radius=6px absolute! group-hover/thread:translate-x-0"
201+
class="right-0 top-0 h-full flex translate-x-[calc(100%+1rem)] items-center gap-1 px-2 pr-1 transition-transform will-change-transform $c-radius=6px absolute! group-hover/thread:translate-x-0"
202202
@click.stop.prevent
203203
>
204204
<Button
@@ -233,6 +233,38 @@ const [DefineThreadLiItem, ReuseThreadLiItem] = createReusableTemplate<{ thread:
233233
</SidebarGroup>
234234
</template>
235235
</SidebarContent>
236-
<SidebarFooter />
236+
<SidebarFooter>
237+
<SidebarMenu>
238+
<SidebarMenuItem>
239+
<DropdownMenu>
240+
<DropdownMenuTrigger as-child>
241+
<SidebarMenuButton class="h-auto flex items-center justify-between">
242+
<div class="h-9 flex items-center gap-2 text-sm leading-tight">
243+
<template v-if="$auth.loggedIn">
244+
<img :src="$auth.user.picture" class="aspect-1 h-full rounded-md">
245+
<div class="truncate">
246+
<p>{{ $auth.user.name }}</p>
247+
<p class="truncate text-xs">
248+
{{ $auth.user.email }}
249+
</p>
250+
</div>
251+
</template>
252+
</div>
253+
<div class="i-hugeicons:dashboard-square-setting size-5 shrink-0" />
254+
</SidebarMenuButton>
255+
</DropdownMenuTrigger>
256+
<DropdownMenuContent
257+
side="top"
258+
class="w-[--reka-popper-anchor-width]"
259+
>
260+
<DropdownMenuItem class="justify-between" @click="chatContext.insaneUI.value = !chatContext.insaneUI.value">
261+
<div>{{ 'InsaneUI' }}</div>
262+
<div :class="chatContext.insaneUI.value ? ' i-hugeicons:crazy' : ' i-hugeicons:confused'" />
263+
</DropdownMenuItem>
264+
</DropdownMenuContent>
265+
</DropdownMenu>
266+
</SidebarMenuItem>
267+
</SidebarMenu>
268+
</SidebarFooter>
237269
</Sidebar>
238270
</template>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* @credit modified from pmucha/nuxt3-local-storage
3+
*
4+
* A wrapper for Nuxt3 `useState` which allows the data
5+
* to be saved in browser's `localStorage` as JSON.
6+
* The API is the same as `useState`.
7+
*
8+
* @param key A string key
9+
* @param defaultValue Optional default value of the key
10+
*/
11+
export function useLocalState<T>(key: string, defaultValue?: () => T): Ref<T> {
12+
const state = useState<T>(key, defaultValue)
13+
14+
// Keep the keys so there are no duplicate watch-ers.
15+
// The keys also should be only on the client side.
16+
let keys = ref<string[]>([])
17+
if (import.meta.client && localStorage) {
18+
keys = useState<string[]>('useLocalState-watch-keys', () => [] as string[])
19+
const storedState = localStorage.getItem(key)
20+
if (storedState)
21+
state.value = JSON.parse(storedState)
22+
}
23+
24+
// Watch only if there are no other watchers
25+
if (!keys.value.includes(key)) {
26+
watch(
27+
() => state.value,
28+
(newValue) => {
29+
if (import.meta.client && localStorage) {
30+
if (newValue === undefined) { // clear the state
31+
keys.value = keys.value.filter(v => v !== key)
32+
try {
33+
localStorage.removeItem(key)
34+
}
35+
catch { }
36+
}
37+
else { // set state
38+
keys.value.push(key)
39+
try {
40+
localStorage.setItem(key, JSON.stringify(state.value))
41+
}
42+
catch { }
43+
}
44+
}
45+
},
46+
)
47+
}
48+
49+
return state
50+
}

apps/frontend/app/lib/shadcn/components/ui/flickering-grid/FlickeringGrid.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ onMounted(() => {
161161
});
162162
intersectionObserver = new IntersectionObserver(
163163
([entry]) => {
164-
isInView.value = entry!.isIntersecting;
164+
isInView.value = entry.isIntersecting;
165165
animationFrameId = requestAnimationFrame(animate);
166166
},
167167
{ threshold: 0 },

0 commit comments

Comments
 (0)