Skip to content

Commit 3644a37

Browse files
committed
feat: hotkeys and search dialog
1 parent fd02319 commit 3644a37

24 files changed

Lines changed: 731 additions & 7 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!-- This module provides global components like search dialogs -->
2+
<script setup lang="ts">
3+
</script>
4+
5+
<template>
6+
<slot />
7+
8+
<div class="absolute hidden">
9+
<ChatSearchDialog :open="true" />
10+
</div>
11+
</template>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script setup lang="ts">
2+
const chatContext = useChatContext()
3+
4+
const keys = useMagicKeys({
5+
passive: false,
6+
onEventFired(e) {
7+
if (e.ctrlKey && e.shiftKey && e.key === 'O')
8+
e.preventDefault()
9+
},
10+
})
11+
12+
whenever(keys['Ctrl+Shift+O']!, () => {
13+
navigateTo('/chat')
14+
++chatContext.interfaceSRK.value
15+
})
16+
</script>

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ const threadIdRef = useThreadIdRef()
3838
const isThreadFrozen = computed(() => chatContext.activeThread.value?.frozen)
3939
const fetchKey = ref(0)
4040
41+
const sendMessageRef = useRouteQuery<string | undefined>('sendMessage')
42+
whenever(
43+
sendMessageRef,
44+
(v) => {
45+
handleSubmit({ input: v })
46+
sendMessageRef.value = undefined
47+
},
48+
{ immediate: true },
49+
)
50+
4151
const cachedThreadsMessages: {
4252
[threadId: string]: Array<CustomMessage>
4353
} = {}
@@ -104,7 +114,7 @@ const { ignoreUpdates: ignorePathUpdate } = watchIgnorable(
104114
&& message.streamId
105115
) {
106116
console.log('Attempting to resume stream for session:', message.streamId)
107-
nextTick(() => { uniquePromise(message.streamId!, () => resumeStreamProcess(message.streamId!, message.id)) })
117+
nextTick(() => { uniquePromise(message.streamId!, () => resumeStreamToMessage(message.streamId!, message.id)) })
108118
}
109119
}
110120
@@ -206,7 +216,7 @@ async function handleSubmit({ input }: HandleSubmitArgs) {
206216
)
207217
}
208218
209-
async function resumeStreamProcess(streamSessionId: string, messageId: string) {
219+
async function resumeStreamToMessage(streamSessionId: string, messageId: string) {
210220
const message = messagesKeyed.value[messageId]
211221
if (!message)
212222
return console.warn('Trying to resume stream for message that does not exist:', messageId)

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,11 @@ provideChatContext({
7878

7979
<template>
8080
<SidebarProvider>
81-
<ChatSidebar class="z-5" />
82-
<ChatInterface class="h-full w-full" />
83-
<ChatFloatingMenu class="absolute left-2 top-2 z-10" />
81+
<ChatGlobalsProvider>
82+
<ChatHotkeysHook />
83+
<ChatSidebar class="z-5" />
84+
<ChatInterface class="h-full w-full" />
85+
<ChatFloatingMenu class="absolute left-2 top-2 z-10" />
86+
</ChatGlobalsProvider>
8487
</SidebarProvider>
8588
</template>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<script lang="ts">
2+
import { createContext } from 'reka-ui'
3+
4+
export const [useCSDContext, provideCSDContext] = createContext<{
5+
open: Ref<boolean>
6+
}>('chat/CSD')
7+
</script>
8+
9+
<script setup lang="ts">
10+
import {
11+
CommandDialog,
12+
} from '@/lib/shadcn/components/ui/command'
13+
14+
const open = defineModel<boolean>('open', { required: true })
15+
16+
const { Meta_k, Ctrl_k } = useMagicKeys({
17+
passive: false,
18+
onEventFired(e) {
19+
if (e.key === 'k' && (e.metaKey || e.ctrlKey))
20+
e.preventDefault()
21+
},
22+
})
23+
24+
whenever(() => Meta_k!.value || Ctrl_k!.value, () => {
25+
open.value = !open.value
26+
})
27+
28+
provideCSDContext({ open })
29+
30+
// TODO: add custom `CommandDialog` component for fuzzy search
31+
</script>
32+
33+
<template>
34+
<CommandDialog v-model:open="open" disable-overlay>
35+
<CSDDialogContent />
36+
</CommandDialog>
37+
</template>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script setup lang="ts">
2+
import {
3+
CommandEmpty,
4+
CommandGroup,
5+
CommandList,
6+
CommandSeparator,
7+
} from '@/lib/shadcn/components/ui/command'
8+
import { useCSDContext } from '../ChatSearchDialog.vue'
9+
10+
const { threads } = useChatContext()
11+
const { open } = useCSDContext()
12+
</script>
13+
14+
<template>
15+
<CSDInput :placeholder="$t('chat.components.chatSearchDialog.placeholder')" />
16+
<CommandList>
17+
<!-- Without the separator the UI is buggy -->
18+
<CommandSeparator />
19+
<CommandEmpty>{{ $t('chat.components.chatSearchDialog.noResultsFound') }}</CommandEmpty>
20+
<CommandGroup>
21+
<template #heading>
22+
<div class="flex gap-1 text-sm">
23+
<div class="i-hugeicons:clock-01" />
24+
<div>{{ $t('recent') }}</div>
25+
</div>
26+
</template>
27+
28+
<CSDItem
29+
v-for="thread of threads"
30+
:key="thread._id"
31+
:value="thread.title"
32+
@select="navigateTo({ path: `/chat/${thread._id}` }); open = false"
33+
>
34+
<span>{{ thread.title }}</span>
35+
</CSDItem>
36+
</CommandGroup>
37+
</CommandList>
38+
</template>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<script setup lang="ts">
2+
import type { ListboxGroupProps } from 'reka-ui'
3+
import { reactiveOmit } from '@vueuse/core'
4+
import { ListboxGroup, ListboxGroupLabel, useId } from 'reka-ui'
5+
import { computed, type HTMLAttributes, onMounted, onUnmounted } from 'vue'
6+
import { cn } from '@/lib/shadcn/utils'
7+
import { provideCommandGroupContext, useCommand } from '~/lib/shadcn/components/ui/command'
8+
9+
const props = defineProps<ListboxGroupProps & {
10+
class?: HTMLAttributes['class']
11+
heading?: string
12+
}>()
13+
14+
const delegatedProps = reactiveOmit(props, 'class')
15+
16+
const { allGroups, filterState } = useCommand()
17+
const id = useId()
18+
19+
const isRender = computed(() => !filterState.search ? true : filterState.filtered.groups.has(id))
20+
21+
provideCommandGroupContext({ id })
22+
onMounted(() => {
23+
if (!allGroups.value.has(id))
24+
allGroups.value.set(id, new Set())
25+
})
26+
onUnmounted(() => {
27+
allGroups.value.delete(id)
28+
})
29+
</script>
30+
31+
<template>
32+
<ListboxGroup
33+
v-bind="delegatedProps"
34+
:id="id"
35+
:class="cn('overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground', props.class)"
36+
:hidden="isRender ? undefined : true"
37+
>
38+
<ListboxGroupLabel v-if="heading || $slots.heading" class="px-2 py-1.5 text-xs text-muted-foreground font-medium">
39+
<slot v-if="$slots.heading" name="heading" />
40+
<template v-else>
41+
{{ heading }}
42+
</template>
43+
</ListboxGroupLabel>
44+
<slot />
45+
</ListboxGroup>
46+
</template>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<script setup lang="ts">
2+
import type { HTMLAttributes } from 'vue'
3+
import { reactiveOmit } from '@vueuse/core'
4+
import { Slash } from 'lucide-vue-next'
5+
import { ListboxFilter, type ListboxFilterProps, useForwardProps } from 'reka-ui'
6+
import { cn } from '@/lib/shadcn/utils'
7+
import { useCommand } from '~/lib/shadcn/components/ui/command'
8+
import { useCSDContext } from '../ChatSearchDialog.vue'
9+
10+
defineOptions({
11+
inheritAttrs: false,
12+
})
13+
14+
const props = defineProps<ListboxFilterProps & {
15+
class?: HTMLAttributes['class']
16+
}>()
17+
18+
const delegatedProps = reactiveOmit(props, 'class')
19+
20+
const forwardedProps = useForwardProps(delegatedProps)
21+
22+
const { filterState } = useCommand()
23+
const { open } = useCSDContext()
24+
25+
function _newChat() {
26+
newThreadAndSubmit(filterState.search)
27+
open.value = false
28+
}
29+
</script>
30+
31+
<template>
32+
<div class="relative flex items-center border-b px-3" cmdk-input-wrapper>
33+
<div class="mr-3 flex items-center">
34+
<div class="i-hugeicons:search-01 h-4 w-4 shrink-0 opacity-50" />
35+
<Slash class="shrink-0 skew-x-[30deg] opacity-20 size-3!" />
36+
<div class="i-hugeicons:comment-add-02 h-4 w-4 shrink-0 opacity-50" />
37+
</div>
38+
<ListboxFilter
39+
v-bind="{ ...forwardedProps, ...$attrs }"
40+
v-model="filterState.search"
41+
auto-focus
42+
:class="cn('flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50', props.class)"
43+
@keydown.enter="(e: KeyboardEvent) => {
44+
if (filterState.search && (!filterState.filtered.count || e.ctrlKey))
45+
_newChat()
46+
}"
47+
/>
48+
<div class="absolute right-3 -bottom-6">
49+
<div v-show="filterState.search" class="flex items-center gap-1 text-xs text-muted-foreground">
50+
<kbd v-if="filterState.filtered.count" class="rounded bg-muted-foreground px-2 text-muted font-sans">Ctrl</kbd>
51+
<kbd class="rounded bg-muted-foreground px-2 text-muted font-sans">↵</kbd>
52+
<div>{{ $t('chat.components.chatSearchDialog.enterToSend.p2') }}</div>
53+
</div>
54+
</div>
55+
</div>
56+
</template>
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<script setup lang="ts">
2+
import type { ListboxItemEmits, ListboxItemProps } from 'reka-ui'
3+
import { reactiveOmit, useCurrentElement } from '@vueuse/core'
4+
import { ListboxItem, useForwardPropsEmits, useId } from 'reka-ui'
5+
import { computed, type HTMLAttributes, onMounted, onUnmounted, ref } from 'vue'
6+
import { cn } from '@/lib/shadcn/utils'
7+
import { useCommand, useCommandGroup } from '~/lib/shadcn/components/ui/command'
8+
9+
const props = defineProps<ListboxItemProps & { class?: HTMLAttributes['class'] }>()
10+
const emits = defineEmits<ListboxItemEmits>()
11+
12+
const delegatedProps = reactiveOmit(props, 'class')
13+
14+
const forwarded = useForwardPropsEmits(delegatedProps, emits)
15+
16+
const id = useId()
17+
const { filterState, allItems, allGroups } = useCommand()
18+
const groupContext = useCommandGroup()
19+
20+
const isRender = computed(() => {
21+
if (!filterState.search) {
22+
return true
23+
}
24+
else {
25+
const filteredCurrentItem = filterState.filtered.items.get(id)
26+
// If the filtered items is undefined means not in the all times map yet
27+
// Do the first render to add into the map
28+
if (filteredCurrentItem === undefined) {
29+
return true
30+
}
31+
32+
// Check with filter
33+
return filteredCurrentItem > 0
34+
}
35+
})
36+
37+
const itemRef = ref()
38+
const currentElement = useCurrentElement(itemRef)
39+
onMounted(() => {
40+
if (!(currentElement.value instanceof HTMLElement))
41+
return
42+
43+
// textValue to perform filter
44+
allItems.value.set(id, currentElement.value.textContent ?? props?.value!.toString())
45+
46+
const groupId = groupContext?.id
47+
if (groupId) {
48+
if (!allGroups.value.has(groupId)) {
49+
allGroups.value.set(groupId, new Set([id]))
50+
}
51+
else {
52+
allGroups.value.get(groupId)?.add(id)
53+
}
54+
}
55+
})
56+
onUnmounted(() => {
57+
allItems.value.delete(id)
58+
})
59+
</script>
60+
61+
<template>
62+
<ListboxItem
63+
v-if="isRender"
64+
v-bind="forwarded"
65+
:id="id"
66+
ref="itemRef"
67+
:class="cn('relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0', props.class)"
68+
>
69+
<slot />
70+
</ListboxItem>
71+
</template>

apps/frontend/app/components/chat/settings/GeneralSettingsSheet.vue

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ for (const provider of supportedProvidersCommon) {
3535
}
3636
3737
const nicknameRef = useChatNickname()
38+
39+
const [DefineKbd, ReuseKbd] = createReusableTemplate()
40+
const [DefineShortcutLi, ReuseShortcutLi] = createReusableTemplate<{ title: string, keys: string[] }>()
3841
</script>
3942

4043
<template>
@@ -118,6 +121,53 @@ const nicknameRef = useChatNickname()
118121
</div>
119122
</div>
120123
</div>
124+
125+
<SheetFooter>
126+
<div class="w-full flex flex-col gap-3 rounded-md bg-primary-50 p-4 text-sm dark:bg-primary-950">
127+
<!-- Define locally reusable components -->
128+
<div class="hidden">
129+
<DefineKbd v-slot="{ k }">
130+
<kbd class="rounded bg-background px-2 py-1 text-sm font-sans">{{ k }}</kbd>
131+
</DefineKbd>
132+
133+
<DefineShortcutLi v-slot="{ title, keys }">
134+
<li class="flex items-center justify-between">
135+
<div>{{ title }}</div>
136+
<div class="flex gap-1">
137+
<ReuseKbd v-for="k, index of keys" :key="index" :k />
138+
</div>
139+
</li>
140+
</DefineShortcutLi>
141+
</div>
142+
143+
<h4 class="font-semibold">
144+
{{ $t('keyboard-shortcuts') }}
145+
</h4>
146+
147+
<hr>
148+
149+
<ul class="flex flex-col gap-4">
150+
<ReuseShortcutLi
151+
v-bind="{
152+
title: $t('chat.settings.general.shortcuts.search'),
153+
keys: ['Ctrl', 'K'],
154+
}"
155+
/>
156+
<ReuseShortcutLi
157+
v-bind="{
158+
title: $t('chat.settings.general.shortcuts.newChat'),
159+
keys: ['Ctrl', 'Shift', 'O'],
160+
}"
161+
/>
162+
<ReuseShortcutLi
163+
v-bind="{
164+
title: $t('chat.settings.general.shortcuts.toggleSidebar'),
165+
keys: ['Ctrl', 'B'],
166+
}"
167+
/>
168+
</ul>
169+
</div>
170+
</SheetFooter>
121171
</SheetContent>
122172
</Sheet>
123173
</template>

0 commit comments

Comments
 (0)