Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 65 additions & 4 deletions src/renderer/src/components/WindowSideBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,53 @@
</div>
</div>

<div v-if="!collapsed" class="px-3 pb-2">
<div class="relative">
<Icon
icon="lucide:search"
class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/70"
/>
<Input
v-model="sessionSearchQuery"
class="h-8 rounded-xl border-0 bg-muted/60 pl-8 pr-8 text-xs shadow-none focus-visible:ring-1 focus-visible:ring-primary/30"
:placeholder="t('chat.sidebar.searchPlaceholder')"
:aria-label="t('chat.sidebar.searchAriaLabel')"
autocapitalize="off"
autocomplete="off"
spellcheck="false"
/>
<button
v-if="sessionSearchQuery"
type="button"
class="absolute right-1.5 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground"
:title="t('common.close')"
:aria-label="t('common.close')"
@click="sessionSearchQuery = ''"
>
<Icon icon="lucide:x" class="h-3.5 w-3.5" />
</button>
</div>
</div>

<!-- Empty state -->
<div
v-if="pinnedSessions.length === 0 && filteredGroups.length === 0"
class="flex flex-col items-center justify-center h-full px-4 text-center"
>
<Icon icon="lucide:message-square-plus" class="w-8 h-8 text-muted-foreground/40 mb-3" />
<p class="text-sm text-muted-foreground/60">{{ t('chat.sidebar.emptyTitle') }}</p>
<p class="text-sm text-muted-foreground/60">
{{
sessionSearchQuery
? t('chat.sidebar.searchEmptyTitle')
: t('chat.sidebar.emptyTitle')
}}
</p>
<p class="text-xs text-muted-foreground/40 mt-1">
{{ t('chat.sidebar.emptyDescription') }}
{{
sessionSearchQuery
? t('chat.sidebar.searchEmptyDescription')
: t('chat.sidebar.emptyDescription')
}}
</p>
</div>

Expand Down Expand Up @@ -189,6 +227,7 @@
region="pinned"
:hero-hidden="pinFlightSessionId === session.id"
:pin-feedback-mode="pinFeedbackSessionId === session.id ? pinFeedbackMode : null"
:search-query="sessionSearchQuery"
@select="handleSessionClick"
@toggle-pin="handleTogglePin"
@delete="openDeleteDialog"
Expand Down Expand Up @@ -229,6 +268,7 @@
region="grouped"
:hero-hidden="pinFlightSessionId === session.id"
:pin-feedback-mode="pinFeedbackSessionId === session.id ? pinFeedbackMode : null"
:search-query="sessionSearchQuery"
@select="handleSessionClick"
@toggle-pin="handleTogglePin"
@delete="openDeleteDialog"
Expand Down Expand Up @@ -269,6 +309,7 @@ import {
TooltipTrigger
} from '@shadcn/components/ui/tooltip'
import { Button } from '@shadcn/components/ui/button'
import { Input } from '@shadcn/components/ui/input'
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -302,6 +343,7 @@ const agentStore = useAgentStore()
const sessionStore = useSessionStore()

const collapsed = ref(false)
const sessionSearchQuery = ref('')
const remoteControlStatus = ref<{
telegram: TelegramRemoteStatus | null
feishu: FeishuRemoteStatus | null
Expand Down Expand Up @@ -381,8 +423,27 @@ const remoteControlIconClass = computed(() => {

const isPinnedSectionCollapsed = ref(false)
const collapsedGroupIds = ref<Set<string>>(new Set())
const pinnedSessions = computed(() => sessionStore.getPinnedSessions(agentStore.selectedAgentId))
const filteredGroups = computed(() => sessionStore.getFilteredGroups(agentStore.selectedAgentId))
const normalizedSessionSearchQuery = computed(() => sessionSearchQuery.value.trim().toLowerCase())
const matchesSessionSearch = (session: UISession) => {
if (!normalizedSessionSearchQuery.value) {
return true
}

return session.title.toLowerCase().includes(normalizedSessionSearchQuery.value)
}
const pinnedSessions = computed(() =>
sessionStore.getPinnedSessions(agentStore.selectedAgentId).filter(matchesSessionSearch)
)
const filteredGroups = computed(() =>
sessionStore
.getFilteredGroups(agentStore.selectedAgentId)
.map((group) => ({
label: group.label,
labelKey: group.labelKey,
sessions: group.sessions.filter(matchesSessionSearch)
}))
.filter((group) => group.sessions.length > 0)
)
const pinFlightSessionId = ref<string | null>(null)
const pinFeedbackSessionId = ref<string | null>(null)
const pinFeedbackMode = ref<PinFeedbackMode | null>(null)
Expand Down
53 changes: 52 additions & 1 deletion src/renderer/src/components/WindowSideBarSessionItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const props = defineProps<{
region: SessionItemRegion
heroHidden?: boolean
pinFeedbackMode?: PinFeedbackMode | null
searchQuery?: string
}>()

const emit = defineEmits<{
Expand All @@ -32,6 +33,46 @@ const { session, active } = toRefs(props)
const pinActionLabel = computed(() =>
session.value.isPinned ? t('thread.actions.unpin') : t('thread.actions.pin')
)

const titleSegments = computed(() => {
const title = session.value.title
const query = props.searchQuery?.trim()
if (!query) {
return [{ text: title, match: false }]
}

const lowerTitle = title.toLowerCase()
const lowerQuery = query.toLowerCase()
const segments: Array<{ text: string; match: boolean }> = []
let searchIndex = 0
let matchIndex = lowerTitle.indexOf(lowerQuery)

while (matchIndex !== -1) {
if (matchIndex > searchIndex) {
segments.push({
text: title.slice(searchIndex, matchIndex),
match: false
})
}

segments.push({
text: title.slice(matchIndex, matchIndex + query.length),
match: true
})

searchIndex = matchIndex + query.length
matchIndex = lowerTitle.indexOf(lowerQuery, searchIndex)
}

if (searchIndex < title.length) {
segments.push({
text: title.slice(searchIndex),
match: false
})
}

return segments.length > 0 ? segments : [{ text: title, match: false }]
})
</script>

<template>
Expand Down Expand Up @@ -62,7 +103,10 @@ const pinActionLabel = computed(() =>
:class="{ 'session-title--loading': session.status === 'working' }"
>
<span class="session-title__label">
{{ session.title }}
<template v-for="(segment, index) in titleSegments" :key="`${session.id}-${index}`">
<mark v-if="segment.match" class="session-title__highlight">{{ segment.text }}</mark>
<template v-else>{{ segment.text }}</template>
</template>
</span>
<span v-if="session.status === 'working'" aria-hidden="true" class="session-title__sheen">
{{ session.title }}
Expand Down Expand Up @@ -256,6 +300,13 @@ const pinActionLabel = computed(() =>
color: var(--session-loading-base);
}

.session-title__highlight {
border-radius: 0.35rem;
background: color-mix(in srgb, var(--primary) 14%, transparent);
color: inherit;
padding: 0 0.08rem;
}

.session-title__sheen {
position: absolute;
inset: 0;
Expand Down
135 changes: 135 additions & 0 deletions src/renderer/src/components/chat/ChatSearchBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<template>
<div class="chat-search-bar flex w-full max-w-[24rem] items-center gap-2 rounded-2xl border bg-background/90 px-2.5 py-2 shadow-lg backdrop-blur-xl">
<div class="relative min-w-0 flex-1">
<Icon
icon="lucide:search"
class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/70"
/>
<Input
ref="inputRef"
:model-value="modelValue"
class="h-8 border-0 bg-transparent pl-8 pr-2 text-sm shadow-none focus-visible:ring-0"
:placeholder="t('chat.inlineSearch.placeholder')"
:aria-label="t('chat.inlineSearch.ariaLabel')"
autocapitalize="off"
autocomplete="off"
spellcheck="false"
@update:model-value="emit('update:modelValue', $event)"
@keydown="handleKeydown"
/>
</div>

<span
class="min-w-[3.5rem] shrink-0 text-right text-xs tabular-nums text-muted-foreground"
aria-live="polite"
>
{{ totalMatches > 0 ? `${activeMatch + 1} / ${totalMatches}` : '0 / 0' }}
</span>

<div class="flex shrink-0 items-center gap-0.5">
<Button
variant="ghost"
size="icon"
class="h-7 w-7 rounded-xl text-muted-foreground hover:text-foreground"
:title="t('chat.inlineSearch.previous')"
:aria-label="t('chat.inlineSearch.previous')"
@click="emit('previous')"
>
<Icon icon="lucide:chevron-up" class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 rounded-xl text-muted-foreground hover:text-foreground"
:title="t('chat.inlineSearch.next')"
:aria-label="t('chat.inlineSearch.next')"
@click="emit('next')"
>
<Icon icon="lucide:chevron-down" class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 rounded-xl text-muted-foreground hover:text-foreground"
:title="t('chat.inlineSearch.close')"
:aria-label="t('chat.inlineSearch.close')"
@click="emit('close')"
>
<Icon icon="lucide:x" class="h-4 w-4" />
</Button>
</div>
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Icon } from '@iconify/vue'
import { Button } from '@shadcn/components/ui/button'
import { Input } from '@shadcn/components/ui/input'

defineProps<{
modelValue: string
activeMatch: number
totalMatches: number
}>()

const emit = defineEmits<{
'update:modelValue': [value: string]
previous: []
next: []
close: []
}>()

const { t } = useI18n()
const inputRef = ref<InstanceType<typeof Input> | HTMLInputElement | null>(null)

const resolveInputElement = (): HTMLInputElement | null => {
const candidate = inputRef.value
if (candidate instanceof HTMLInputElement) {
return candidate
}

if (candidate && '$el' in candidate) {
const element = (candidate.$el as HTMLElement | undefined)?.querySelector('input')
return element instanceof HTMLInputElement ? element : null
}

return null
}

const focusInput = () => {
resolveInputElement()?.focus()
}

const selectInput = () => {
const element = resolveInputElement()
element?.focus()
element?.select()
}

const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}

if (event.key !== 'Enter') {
return
}

event.preventDefault()
if (event.shiftKey) {
emit('previous')
return
}

emit('next')
}

defineExpose({
focusInput,
selectInput
})
</script>
13 changes: 12 additions & 1 deletion src/renderer/src/i18n/da-DK/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,11 @@
"pinned": "Fastgjorte",
"emptyTitle": "Ingen samtaler endnu",
"emptyDescription": "Start en ny samtale for at komme i gang",
"remoteControlDisabled": "Deaktiveret"
"remoteControlDisabled": "Deaktiveret",
"searchPlaceholder": "Søg i chats",
"searchAriaLabel": "Søg i chats",
"searchEmptyTitle": "Ingen matchende samtaler",
"searchEmptyDescription": "Prøv et andet nøgleord i titlen"
},
"floatingWidget": {
"title": "DeepChat",
Expand All @@ -306,5 +310,12 @@
},
"subagents": {
"label": "Underagenter"
},
"inlineSearch": {
"placeholder": "Søg i samtalen",
"ariaLabel": "Søg i den aktuelle samtale",
"previous": "Forrige match",
"next": "Næste match",
"close": "Luk søgning"
}
}
13 changes: 12 additions & 1 deletion src/renderer/src/i18n/en-US/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,17 @@
"groupByProject": "Group by project",
"pinned": "Pinned",
"emptyTitle": "No conversations yet",
"emptyDescription": "Start a new chat to begin"
"emptyDescription": "Start a new chat to begin",
"searchPlaceholder": "Search chats",
"searchAriaLabel": "Search chats",
"searchEmptyTitle": "No matching conversations",
"searchEmptyDescription": "Try a different title keyword"
},
"inlineSearch": {
"placeholder": "Search in conversation",
"ariaLabel": "Search in current conversation",
"previous": "Previous match",
"next": "Next match",
"close": "Close search"
}
}
Loading