Skip to content

Commit baff73b

Browse files
committed
feat: frozen thread
1 parent c2ded57 commit baff73b

30 files changed

Lines changed: 697 additions & 111 deletions

apps/backend-convex/convex/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const aiChatTables = {
1717
lockerKey: v.optional(v.string()),
1818
branchedFrom: v.optional(v.id('messages')),
1919
parentThread: v.optional(v.id('threads')),
20+
frozen: v.optional(v.boolean()),
2021
})
2122
.index('by_user_id', ['userId'])
2223
.index('by_user_id_and_timestamp', ['userId', 'timestamp'])

apps/backend-convex/convex/threads.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,42 @@ export const branchThreadFromMessage = mutation({
142142
},
143143
})
144144

145+
export const freeze = mutation({
146+
args: {
147+
threadId: v.id('threads'),
148+
lockerKey: v.optional(v.string()),
149+
},
150+
handler: async (ctx, args) => {
151+
const thread = await ctx.db.get(args.threadId)
152+
if (!thread)
153+
throw new ConvexError('Thread not found')
154+
155+
await assertThreadAccess(ctx, { thread, lockerKey: args.lockerKey })
156+
157+
await ctx.db.patch(args.threadId, {
158+
frozen: true,
159+
})
160+
},
161+
})
162+
163+
export const unfreeze = mutation({
164+
args: {
165+
threadId: v.id('threads'),
166+
lockerKey: v.optional(v.string()),
167+
},
168+
handler: async (ctx, args) => {
169+
const thread = await ctx.db.get(args.threadId)
170+
if (!thread)
171+
throw new ConvexError('Thread not found')
172+
173+
await assertThreadAccess(ctx, { thread, lockerKey: args.lockerKey })
174+
175+
await ctx.db.patch(args.threadId, {
176+
frozen: false,
177+
})
178+
},
179+
})
180+
145181
// Todo: maybe rate limit
146182
export const generateThreadTitle = action({
147183
args: {

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

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import VanishingInput from '~/lib/shadcn/components/ui/vanishing-input/Vanishing
1515
import LiquidGlassDiv from '../LiquidGlassDiv.vue'
1616
1717
const isDev = import.meta.dev
18-
const { $auth, $init } = useNuxtApp()
18+
const { $auth } = useNuxtApp()
1919
const { convexApiUrl } = useRuntimeConfig().public
2020
const convex = useConvexClient()
2121
const chatContext = useChatContext()
@@ -41,6 +41,7 @@ const nearTopBottom = computed(() => {
4141
})
4242
4343
const threadIdRef = useThreadIdRef()
44+
const isThreadFrozen = computed(() => chatContext.activeThread.value?.frozen)
4445
4546
const cachedThreadsMessages: {
4647
[threadId: string]: Array<CustomMessage>
@@ -117,6 +118,15 @@ async function handleSubmit({ input, confirmMultiStream = false }: HandleSubmitA
117118
if (!userInput)
118119
return
119120
121+
if (isThreadFrozen.value) {
122+
const lastMessage = messages.value[messages.value.length - 1]
123+
if (!lastMessage)
124+
throw new Error(`Can't branch off empty thread`)
125+
126+
return await _branchThreadFromMessage({ messageId: lastMessage._id, lockerKey: getLockerKey(lastMessage.threadId) })
127+
.then(() => { sleep(500).then(() => handleSubmit({ input })) })
128+
}
129+
120130
if (!confirmMultiStream && streamingMessages.value > 0)
121131
return alertIsStreaming(userInput)
122132
@@ -311,14 +321,17 @@ async function streamToMessage({ message, content, resumeStreamId }: StreamToMes
311321
console.log('Stream completed')
312322
}
313323
314-
async function _branchThreadFromMessage({ messageId, sessionId, lockerKey }: BranchThreadFromMessageArgs) {
324+
async function _branchThreadFromMessage({ messageId, lockerKey }: BranchThreadFromMessageArgs) {
325+
if (streamingMessages.value > 0)
326+
throw new Error('Can not branch while streaming')
327+
315328
const messagesLte = messages.value.slice(0, messages.value.findIndex(m => m._id === messageId) + 1)
316329
317330
cachedThreadsMessages[threadIdRef.value] = messages.value
318331
319332
messages.value = messagesLte
320333
321-
await branchThreadFromMessage(convex, { messageId, sessionId, lockerKey })
334+
await branchThreadFromMessage(convex, { messageId, lockerKey })
322335
.then((threadId) => {
323336
ignorePathUpdate(() => { threadIdRef.value = threadId })
324337
if (lockerKey)
@@ -381,18 +394,16 @@ function alertIsStreaming(input: string) {
381394
v-show="!(isFetching && !messages.length)"
382395
class="absolute left-0 z-0 h-screen w-full place-content-center overflow-hidden transition-height"
383396
>
384-
<component
385-
:is="chatContext.insaneUI.value ? LiquidGlassDiv : 'div'"
397+
<IUIMaybeGlassCard
386398
v-if="!messages.length"
387399
:key="chatContext.interfaceSRK.value"
388400
v-motion-pop-visible-once
389-
class="relative z-2 mx-auto w-fit whitespace-pre-wrap rounded-$radius p-4 text-center text-4xl font-medium tracking-tighter $c-radius=$radius"
390-
:class="chatContext.insaneUI.value ? 'text-shadow-md bg-secondary/20 text-primary-950 dark:(bg-primary/20 text-secondary-50)' : 'text-gray-500 dark:text-gray-400'"
401+
class="relative z-2 mx-auto w-fit whitespace-pre-wrap p-4 text-center text-4xl font-medium tracking-tighter"
391402
>
392403
<p>
393404
{{ threadIdRef ? $t('chat.interface.sendToStart') : $t('chat.interface.selectOrStart') }}
394405
</p>
395-
</component>
406+
</IUIMaybeGlassCard>
396407
</div>
397408

398409
<div v-if="messages.length" class="relative z-2 space-y-4">
@@ -456,7 +467,6 @@ function alertIsStreaming(input: string) {
456467
<Button
457468
variant="ghost" size="icon" class="size-7" @click="_branchThreadFromMessage({
458469
messageId: m._id,
459-
sessionId: $init.sessionId,
460470
lockerKey: getLockerKey(m.threadId),
461471
})"
462472
>
@@ -470,6 +480,13 @@ function alertIsStreaming(input: string) {
470480
</div>
471481
</div>
472482

483+
<IUIMaybeGlassCard
484+
v-if="isThreadFrozen"
485+
class="mx-auto w-fit border p-2 px-8 font-medium tracking-wide"
486+
>
487+
{{ $t('chat.thread.frozenWithDescription') }}
488+
</IUIMaybeGlassCard>
489+
473490
<div class="pb-40" />
474491
</div>
475492
</div>

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

Lines changed: 101 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,18 @@ import { api } from 'backend-convex/convex/_generated/api'
66
import { useConvexQuery } from 'convex-vue'
77
import { Split } from 'lucide-vue-next'
88
import { computed, ref } from 'vue'
9-
import {
10-
AlertDialog,
11-
AlertDialogAction,
12-
AlertDialogCancel,
13-
AlertDialogContent,
14-
AlertDialogDescription,
15-
AlertDialogFooter,
16-
AlertDialogHeader,
17-
AlertDialogTitle,
18-
AlertDialogTrigger,
19-
} from '@/lib/shadcn/components/ui/alert-dialog'
9+
2010
import {
2111
Avatar,
2212
AvatarFallback,
2313
AvatarImage,
2414
} from '@/lib/shadcn/components/ui/avatar'
15+
import {
16+
ContextMenu,
17+
ContextMenuContent,
18+
ContextMenuItem,
19+
ContextMenuTrigger,
20+
} from '@/lib/shadcn/components/ui/context-menu'
2521
import {
2622
Sidebar,
2723
SidebarContent,
@@ -102,16 +98,6 @@ const filteredThreads = computed(() => {
10298
)
10399
})
104100
105-
const activeThread = computed<Doc<'threads'> | undefined>(() => {
106-
if (!threadIdRef.value)
107-
return undefined
108-
109-
return threads.value.find(thread => thread._id === threadIdRef.value)
110-
})
111-
useHead({
112-
title: computed(() => activeThread.value?.title ?? '> New Chat'),
113-
})
114-
115101
function pinThread(thread: Doc<'threads'>) {
116102
pinnedThreadIds.value.push(thread._id)
117103
nextTick(() => { document.getElementById(`li_thread_${thread._id}`)?.scrollIntoView({ behavior: 'smooth' }) })
@@ -123,6 +109,9 @@ function unpinThread(thread: Doc<'threads'>) {
123109
}
124110
125111
async function _deleteThread(thread: Doc<'threads'>) {
112+
if (threadIdRef.value === thread._id)
113+
threadIdRef.value = ''
114+
126115
threads.value.splice(threads.value.indexOf(thread), 1)
127116
await deleteThread(convex, { threadId: thread._id, lockerKey: $auth.loggedIn ? undefined : thread.lockerKey })
128117
}
@@ -182,92 +171,104 @@ const [DefineThreadLiItem, ReuseThreadLiItem] = createReusableTemplate<{ thread:
182171
<!-- Define reusable items -->
183172
<div class="hidden">
184173
<DefineDeleteBtn v-slot="{ thread }">
185-
<AlertDialog>
186-
<Tooltip :delay-duration="500">
187-
<AlertDialogTrigger v-show="!thread.userId || (thread.userId === $auth?.user?.sub)" as-child>
188-
<TooltipTrigger as-child>
189-
<Button tabindex="-1" variant="ghost" size="icon" class="size-7 transition-none" @pointerdown.stop.prevent @click.shift.stop.prevent="_deleteThread(thread)">
190-
<div class="i-hugeicons:cancel-01" />
191-
</Button>
192-
</TooltipTrigger>
193-
<TooltipContent side="bottom" :side-offset="6">
194-
<p>{{ $t('chat.thread.delete') }}</p>
195-
</TooltipContent>
196-
</AlertDialogTrigger>
197-
</Tooltip>
198-
<AlertDialogContent>
199-
<AlertDialogHeader>
200-
<AlertDialogTitle>{{ $t('chat.sidebar.deleteThread.title') }}</AlertDialogTitle>
201-
<AlertDialogDescription class="whitespace-pre-line">
202-
{{ $t('chat.sidebar.deleteThread.description', { name: thread.title }) }}
203-
</AlertDialogDescription>
204-
</AlertDialogHeader>
205-
<AlertDialogFooter>
206-
<AlertDialogCancel>{{ $t('cancel') }}</AlertDialogCancel>
207-
<AlertDialogAction @click="_deleteThread(thread)">
208-
{{ $t('confirm') }}
209-
</AlertDialogAction>
210-
</AlertDialogFooter>
211-
</AlertDialogContent>
212-
</AlertDialog>
174+
<DeleteThreadAlertDialog :thread :callback="() => { _deleteThread(thread) }">
175+
<Button tabindex="-1" variant="ghost" size="icon" class="size-7 transition-none">
176+
<div class="i-hugeicons:cancel-01" />
177+
</Button>
178+
</DeleteThreadAlertDialog>
213179
</DefineDeleteBtn>
214180

215181
<DefineThreadLiItem v-slot="{ thread, pinned }">
216182
<li :id="`li_thread_${thread._id}`">
217-
<!-- Using [&.active] instead of :active-class because of reactivity bug -->
218-
<NuxtLink
219-
:to="`/chat/${thread._id}`"
220-
class="group/thread relative block flex items-center overflow-hidden rounded-md p-2 px-3 [&.router-link-exact-active]:bg-primary/10 hover:bg-primary/20"
221-
@pointerdown="sidebarContext.setOpenMobile(false)"
222-
>
223-
<Tooltip v-if="thread.parentThread" :delay-duration="500">
224-
<TooltipTrigger as-child>
225-
<NuxtLink
226-
:to="`/chat/${thread.parentThread}`"
227-
class="mr-2 size-4"
228-
>
229-
<Button variant="link" size="icon" class="size-full text-current opacity-40 transition-opacity hover:(text-primary opacity-100)">
230-
<Split />
231-
</Button>
232-
</NuxtLink>
233-
</TooltipTrigger>
234-
<TooltipContent side="bottom" :side-offset="6">
235-
<p>{{ $t('chat.thread.branchedFrom', { title: threadsKeyed[thread.parentThread]?.title }) }}</p>
236-
</TooltipContent>
237-
</Tooltip>
238-
239-
<Tooltip :delay-duration="500">
240-
<TooltipTrigger as-child>
241-
<div class="line-clamp-1">
242-
{{ thread.title }}
183+
<ContextMenu>
184+
<ContextMenuTrigger>
185+
<!-- Using [&.active] instead of :active-class because of reactivity bug -->
186+
<NuxtLink
187+
:to="`/chat/${thread._id}`"
188+
class="group/thread relative block flex items-center gap-2 overflow-hidden rounded-md p-2 px-3 text-sm [&.router-link-exact-active]:bg-primary/10 hover:bg-primary/20"
189+
@pointerdown="sidebarContext.setOpenMobile(false)"
190+
>
191+
<div class="h-4 flex items-center gap-1">
192+
<Tooltip v-if="thread.parentThread" :delay-duration="500">
193+
<TooltipTrigger as-child>
194+
<NuxtLink
195+
:to="`/chat/${thread.parentThread}`"
196+
class="size-4"
197+
>
198+
<Button variant="link" size="icon" class="size-4 text-current opacity-40 transition-opacity hover:(text-primary opacity-100)">
199+
<Split />
200+
</Button>
201+
</NuxtLink>
202+
</TooltipTrigger>
203+
<TooltipContent side="bottom" :side-offset="6">
204+
<p>{{ $t('chat.thread.branchedFrom', { title: threadsKeyed[thread.parentThread]?.title }) }}</p>
205+
</TooltipContent>
206+
</Tooltip>
207+
<Tooltip v-if="thread.frozen" :delay-duration="500">
208+
<TooltipTrigger as-child>
209+
<Button variant="link" size="icon" class="size-4 text-current opacity-40 transition-color hover:(text-primary opacity-100)">
210+
<div class="i-hugeicons:snow" />
211+
</Button>
212+
</TooltipTrigger>
213+
<TooltipContent side="bottom" :side-offset="6">
214+
<p>{{ $t('chat.thread.frozen') }}</p>
215+
</TooltipContent>
216+
</Tooltip>
243217
</div>
244-
</TooltipTrigger>
245-
<TooltipContent side="bottom" :side-offset="6">
246-
<p>{{ thread.title }}</p>
247-
</TooltipContent>
248-
</Tooltip>
249218

250-
<LiquidGlassDiv
251-
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"
252-
@click.stop.prevent
253-
>
254-
<Tooltip :delay-duration="500">
255-
<TooltipTrigger as-child>
256-
<Button
257-
tabindex="-1" variant="ghost" size="icon" class="size-7 transition-none hover:bg-surface-200/20!"
258-
@pointerdown.stop.prevent @click.stop.prevent="pinned ? unpinThread(thread) : pinThread(thread)"
259-
>
260-
<div :class="[pinned ? 'i-hugeicons:pin-off' : 'i-hugeicons:pin']" />
261-
</Button>
262-
</TooltipTrigger>
263-
<TooltipContent side="bottom" :side-offset="6">
264-
<p>{{ pinned ? $t('chat.thread.unpin') : $t('chat.thread.pin') }}</p>
265-
</TooltipContent>
266-
</Tooltip>
219+
<Tooltip :delay-duration="500">
220+
<TooltipTrigger as-child>
221+
<div class="truncate">
222+
{{ thread.title }}
223+
</div>
224+
</TooltipTrigger>
225+
<TooltipContent side="bottom" :side-offset="6">
226+
<p>{{ thread.title }}</p>
227+
</TooltipContent>
228+
</Tooltip>
229+
230+
<LiquidGlassDiv
231+
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"
232+
@click.stop.prevent
233+
>
234+
<Tooltip :delay-duration="500">
235+
<TooltipTrigger as-child>
236+
<Button
237+
tabindex="-1" variant="ghost" size="icon" class="size-7 transition-none hover:bg-surface-200/20!"
238+
@pointerdown.stop.prevent @click.stop.prevent="pinned ? unpinThread(thread) : pinThread(thread)"
239+
>
240+
<div :class="[pinned ? 'i-hugeicons:pin-off' : 'i-hugeicons:pin']" />
241+
</Button>
242+
</TooltipTrigger>
243+
<TooltipContent side="bottom" :side-offset="6">
244+
<p>{{ pinned ? $t('chat.thread.unpin') : $t('chat.thread.pin') }}</p>
245+
</TooltipContent>
246+
</Tooltip>
267247

268-
<ReuseDeleteBtn :thread />
269-
</LiquidGlassDiv>
270-
</NuxtLink>
248+
<ReuseDeleteBtn :thread />
249+
</LiquidGlassDiv>
250+
</NuxtLink>
251+
</ContextMenuTrigger>
252+
<ContextMenuContent>
253+
<ContextMenuItem
254+
@click="pinned ? unpinThread(thread) : pinThread(thread)"
255+
>
256+
{{ pinned ? $t('chat.thread.unpin') : $t('chat.thread.pin') }}
257+
</ContextMenuItem>
258+
<ContextMenuItem @select.prevent>
259+
<DeleteThreadAlertDialog :thread :callback="() => { _deleteThread(thread) }" :tip-only="true">
260+
<p>{{ $t('chat.thread.delete') }}</p>
261+
</DeleteThreadAlertDialog>
262+
</ContextMenuItem>
263+
<ContextMenuItem
264+
@click="thread.frozen
265+
? unfreezeThread(convex, { threadId: thread._id, lockerKey: thread.lockerKey })
266+
: freezeThread(convex, { threadId: thread._id, lockerKey: thread.lockerKey })"
267+
>
268+
{{ thread.frozen ? $t('chat.thread.unfreeze') : $t('chat.thread.freeze') }}
269+
</ContextMenuItem>
270+
</ContextMenuContent>
271+
</ContextMenu>
271272
</li>
272273
</DefineThreadLiItem>
273274
</div>

0 commit comments

Comments
 (0)