Skip to content

Chat Search — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add message search to the chat system — search within a single conversation and global search across all conversations.

Architecture: New FSD feature slice chat-search with two composables and two UI components. MessageBubble gains text highlighting. useMessages gains scrollToMessage. Both pages integrate search via composables.

Tech Stack: Nuxt 4 / TypeScript / Nuxt UI v3 / Vue 3.5 / VueUse / Zod

Design doc: docs/plans/2026-02-26-chat-search-design.md


Task 1: Add i18n keys for chat search [S]

Files:

  • Modify: i18n/locales/ru.json (lines 443-468, inside "chat" object)

Step 1: Add search keys to the chat section

In i18n/locales/ru.json, inside the "chat" object, add these keys after "imageCounter":

json
    "imageCounter": "{current} из {total}",
    "searchPlaceholder": "Поиск по сообщениям...",
    "searchAllPlaceholder": "Поиск по всем чатам...",
    "noSearchResults": "Ничего не найдено",
    "searchPrev": "Предыдущее совпадение",
    "searchNext": "Следующее совпадение",
    "searchClose": "Закрыть поиск",
    "searchLoading": "Загрузка сообщений...",
    "loadMore": "Загрузить ещё"

Step 2: Run lint

Run: npm run lint -- --no-error-on-unmatched-pattern i18n/locales/ru.json Expected: No errors

Step 3: Commit

bash
git add i18n/locales/ru.json
git commit -m "feat(chat): add i18n keys for message search"

Task 2: Add highlight and data-message-id to MessageBubble [S]

Files:

  • Modify: app/entities/message/ui/MessageBubble.vue

Step 1: Add highlightQuery prop

In the defineProps, add highlightQuery after companionId:

ts
const props = defineProps<{
  message: GhostMessage
  isMine: boolean
  showAvatar?: boolean
  avatarUrl?: string | null
  displayName?: string
  companionId?: number
  highlightQuery?: string
}>()

Step 2: Add highlight helper function

After the existing computed properties (after line 27), add:

ts
function highlightText(text: string, query: string): string {
  if (!query || query.length < 2) return text
  const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  return text.replace(
    new RegExp(`(${escaped})`, 'gi'),
    '<mark class="bg-yellow-200 dark:bg-yellow-800 rounded px-0.5">$1</mark>',
  )
}

const highlightedText = computed(() => {
  if (!props.message.text) return ''
  if (!props.highlightQuery) return ''
  return highlightText(props.message.text, props.highlightQuery)
})

Step 3: Add data-message-id to the root elements

On the system message <div> (line 32), add the attribute:

html
<div v-if="isSystem" class="my-2 flex justify-center" :data-message-id="message.id">

On the regular message <div> (line 41), add the attribute:

html
<div v-else class="flex items-end gap-2" :class="isMine ? 'justify-end' : 'justify-start'" :data-message-id="message.id">

Step 4: Replace text rendering with highlight-aware version

Replace line 87:

html
      <p v-if="message.text" class="whitespace-pre-wrap break-words text-sm">
        &#123;&#123; message.text }}
      </p>

With:

html
      <p v-if="message.text" class="whitespace-pre-wrap break-words text-sm">
        <span v-if="highlightedText" v-html="highlightedText" />
        <template v-else>&#123;&#123; message.text }}</template>
      </p>

Step 5: Run lint

Run: npm run lint:fix -- app/entities/message/ui/MessageBubble.vue Expected: Clean or auto-fixed

Step 6: Commit

bash
git add app/entities/message/ui/MessageBubble.vue
git commit -m "feat(chat): add text highlighting and data-message-id to MessageBubble"

Task 3: Add scrollToMessage to useMessages [M]

Files:

  • Modify: app/features/chat-messaging/composables/useMessages.ts

Step 1: Add isScrolling ref and scrollToMessage function

After line 11 (const cursor = ref<string | null>(null)), add:

ts
  const isScrollingToMessage = ref(false)

Before the return block (before line 217), add the scrollToMessage function:

ts
  async function scrollToMessage(messageId: number): Promise<boolean> {
    isScrollingToMessage.value = true
    try {
      await nextTick()
      let el = document.querySelector(`[data-message-id="${messageId}"]`)
      if (el) {
        el.scrollIntoView({ behavior: 'smooth', block: 'center' })
        return true
      }

      while (hasMore.value) {
        await loadOlder()
        await nextTick()
        el = document.querySelector(`[data-message-id="${messageId}"]`)
        if (el) {
          el.scrollIntoView({ behavior: 'smooth', block: 'center' })
          return true
        }
      }

      return false
    }
    finally {
      isScrollingToMessage.value = false
    }
  }

Step 2: Expose in return

Update the return block to include the new exports:

ts
  return {
    messages: readonly(messages),
    hasMore: readonly(hasMore),
    isLoading: readonly(isLoading),
    isScrollingToMessage: readonly(isScrollingToMessage),
    loadOlder,
    send,
    sendImage,
    retryImage,
    markRead,
    scrollToMessage,
  }

Step 3: Run lint

Run: npm run lint:fix -- app/features/chat-messaging/composables/useMessages.ts Expected: Clean

Step 4: Commit

bash
git add app/features/chat-messaging/composables/useMessages.ts
git commit -m "feat(chat): add scrollToMessage to useMessages composable"

Task 4: Create useConversationSearch composable [M]

Files:

  • Create: app/features/chat-search/composables/useConversationSearch.ts

Step 1: Create the composable

ts
// app/features/chat-search/composables/useConversationSearch.ts
import { useDebounceFn } from '@vueuse/core'
import type { Message } from '~/entities/message/model/message.schema'
import type { ApiListResponse } from '~/shared/api/types'

const MIN_QUERY_LENGTH = 2

export function useConversationSearch(conversationId: Ref<number>) {
  const api = useApi()

  const query = ref('')
  const results = ref<Message[]>([])
  const currentIndex = ref(0)
  const isSearching = ref(false)
  const isOpen = ref(false)

  const totalMatches = computed(() => results.value.length)
  const currentMatch = computed(() => (totalMatches.value > 0 ? currentIndex.value + 1 : 0))
  const currentMessageId = computed(() => results.value[currentIndex.value]?.id ?? null)
  const highlightQuery = computed(() => (results.value.length > 0 ? query.value : ''))
  const matchedIds = computed(() => new Set(results.value.map(m => m.id)))

  async function search() {
    const q = query.value.trim()
    if (q.length < MIN_QUERY_LENGTH) {
      results.value = []
      currentIndex.value = 0
      return
    }
    isSearching.value = true
    try {
      const res = await api.get<ApiListResponse<Message>>(
        `/vendor/conversations/${conversationId.value}/messages/search`,
        { q, limit: 50 },
      )
      results.value = res.data
      currentIndex.value = 0
    }
    catch {
      results.value = []
    }
    finally {
      isSearching.value = false
    }
  }

  const debouncedSearch = useDebounceFn(search, 300)
  watch(query, () => debouncedSearch())

  function goNext() {
    if (totalMatches.value === 0) return
    currentIndex.value = (currentIndex.value + 1) % totalMatches.value
  }

  function goPrev() {
    if (totalMatches.value === 0) return
    currentIndex.value = (currentIndex.value - 1 + totalMatches.value) % totalMatches.value
  }

  function open() {
    isOpen.value = true
  }

  function close() {
    isOpen.value = false
    query.value = ''
    results.value = []
    currentIndex.value = 0
  }

  return {
    query,
    results,
    currentIndex,
    isSearching,
    isOpen,
    totalMatches,
    currentMatch,
    currentMessageId,
    highlightQuery,
    matchedIds,
    goNext,
    goPrev,
    open,
    close,
  }
}

Step 2: Run lint

Run: npm run lint:fix -- app/features/chat-search/composables/useConversationSearch.ts Expected: Clean

Step 3: Commit

bash
git add app/features/chat-search/
git commit -m "feat(chat): add useConversationSearch composable"

Task 5: Create ChatSearchBar component [M]

Files:

  • Create: app/features/chat-search/ui/ChatSearchBar.vue

Step 1: Create the component

html
<!-- app/features/chat-search/ui/ChatSearchBar.vue -->
<script setup lang="ts">
defineProps<{
  query: string
  totalMatches: number
  currentMatch: number
  isSearching: boolean
}>()

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

const inputRef = ref<HTMLInputElement>()

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape') {
    emit('close')
  }
  else if (e.key === 'Enter') {
    e.preventDefault()
    if (e.shiftKey) {
      emit('prev')
    }
    else {
      emit('next')
    }
  }
}

onMounted(() => {
  inputRef.value?.focus()
})
</script>

<template>
  <div class="flex items-center gap-2 border-b border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-800/50">
    <UIcon name="i-lucide-search" class="shrink-0 text-gray-400" />
    <input
      ref="inputRef"
      :value="query"
      type="text"
      class="flex-1 bg-transparent text-sm outline-none placeholder-gray-400 dark:text-white"
      :placeholder="$t('chat.searchPlaceholder')"
      @input="emit('update:query', ($event.target as HTMLInputElement).value)"
      @keydown="onKeydown"
    />
    <span v-if="isSearching" class="shrink-0">
      <UIcon name="i-lucide-loader-2" class="h-4 w-4 animate-spin text-gray-400" />
    </span>
    <span v-else-if="totalMatches > 0" class="shrink-0 text-xs text-gray-500 whitespace-nowrap">
      &#123;&#123; currentMatch }}/&#123;&#123; totalMatches }}
    </span>
    <span v-else-if="query.length >= 2" class="shrink-0 text-xs text-gray-400 whitespace-nowrap">
      &#123;&#123; $t('chat.noSearchResults') }}
    </span>
    <UButton
      v-if="totalMatches > 1"
      icon="i-lucide-chevron-up"
      variant="ghost"
      size="xs"
      :aria-label="$t('chat.searchPrev')"
      @click="emit('prev')"
    />
    <UButton
      v-if="totalMatches > 1"
      icon="i-lucide-chevron-down"
      variant="ghost"
      size="xs"
      :aria-label="$t('chat.searchNext')"
      @click="emit('next')"
    />
    <UButton
      icon="i-lucide-x"
      variant="ghost"
      size="xs"
      :aria-label="$t('chat.searchClose')"
      @click="emit('close')"
    />
  </div>
</template>

Step 2: Run lint

Run: npm run lint:fix -- app/features/chat-search/ui/ChatSearchBar.vue Expected: Clean

Step 3: Commit

bash
git add app/features/chat-search/ui/ChatSearchBar.vue
git commit -m "feat(chat): add ChatSearchBar component"

Task 6: Integrate in-conversation search into [id].vue [M]

Files:

  • Modify: app/pages/cabinet/messages/[id].vue

Step 1: Import search composable and set it up

After line 20 (the useMessages destructuring), add:

ts
const search = useConversationSearch(conversationId)

Step 2: Add Ctrl+F / Cmd+F keyboard shortcut

After onMounted(() => { markRead() }) (line 153), add:

ts
function onSearchKeydown(e: KeyboardEvent) {
  if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
    e.preventDefault()
    search.open()
  }
}

onMounted(() => document.addEventListener('keydown', onSearchKeydown))
onUnmounted(() => document.removeEventListener('keydown', onSearchKeydown))

Step 3: Watch currentMessageId for scroll navigation

After the keyboard shortcut code, add:

ts
watch(
  () => search.currentMessageId.value,
  (messageId) => {
    if (messageId === null) return
    scrollToMessage(messageId)
  },
)

Step 4: Handle ?highlight= query param from global search

After the watcher above, add:

ts
const highlightMessageId = computed(() => {
  const id = route.query.highlight
  return id ? Number(id) : null
})

watch(highlightMessageId, async (id) => {
  if (!id) return

  // Wait for initial messages to load
  await nextTick()

  const found = await scrollToMessage(id)
  if (found) {
    await nextTick()
    const el = document.querySelector(`[data-message-id="${id}"]`)
    if (el) {
      el.classList.add('ring-2', 'ring-yellow-400')
      setTimeout(() => el.classList.remove('ring-2', 'ring-yellow-400'), 2000)
    }
  }

  // Clean up URL
  navigateTo(route.path, { replace: true })
}, { immediate: true })

Step 5: Add search button to header

In the header section, after the closing </NuxtLink> (line 209), add a search button:

html
      <UButton
        variant="ghost"
        icon="i-lucide-search"
        size="sm"
        @click="search.open()"
      />

Step 6: Add ChatSearchBar below the product strip

After the product context strip </NuxtLink> (line 234), add:

html
    <!-- Search bar -->
    <FeaturesChatSearchUiChatSearchBar
      v-if="search.isOpen.value"
      :query="search.query.value"
      :total-matches="search.totalMatches.value"
      :current-match="search.currentMatch.value"
      :is-searching="search.isSearching.value"
      @update:query="search.query.value = $event"
      @next="search.goNext()"
      @prev="search.goPrev()"
      @close="search.close()"
    />

Note: Using auto-imported component name FeaturesChatSearchUiChatSearchBar per FSD conventions (layer prefix + path). If auto-import doesn't resolve this name, use explicit import:

ts
import ChatSearchBar from '~/features/chat-search/ui/ChatSearchBar.vue'

and <ChatSearchBar ... /> in the template.

Step 7: Pass highlightQuery to MessageBubble

Update the <MessageBubble> component (around line 243) to include the highlight prop:

html
      <MessageBubble
        v-for="msg in messagesWithMeta"
        :key="msg.id"
        :message="msg"
        :is-mine="msg.isMine"
        :show-avatar="msg.showAvatar"
        :avatar-url="msg.avatarUrl ?? null"
        :display-name="msg.displayName ?? ''"
        :companion-id="conversation?.companion.id"
        :highlight-query="search.highlightQuery.value"
        @image-click="handleImageClick"
        @retry-upload="retryImage"
      />

Step 8: Add isScrollingToMessage loading indicator

In the messages container, after the existing loading spinner (line 240-242), add a search-loading indicator:

html
      <div v-if="isScrollingToMessage" class="flex justify-center py-4">
        <div class="flex items-center gap-2 text-sm text-gray-400">
          <UIcon name="i-lucide-loader-2" class="h-4 w-4 animate-spin" />
          &#123;&#123; t('chat.searchLoading') }}
        </div>
      </div>

And update the destructuring of useMessages (line 20) to include isScrollingToMessage and scrollToMessage:

ts
const { messages, hasMore, isLoading, isScrollingToMessage, loadOlder, send, sendImage, retryImage, markRead, scrollToMessage } = useMessages(conversationId)

Step 9: Run lint

Run: npm run lint:fix -- app/pages/cabinet/messages/\\[id\\].vue Expected: Clean

Step 10: Commit

bash
git add app/pages/cabinet/messages/\\[id\\].vue
git commit -m "feat(chat): integrate in-conversation search into message page"

Task 7: Create useGlobalSearch composable [M]

Files:

  • Create: app/features/chat-search/composables/useGlobalSearch.ts

Step 1: Create the composable

ts
// app/features/chat-search/composables/useGlobalSearch.ts
import { useDebounceFn } from '@vueuse/core'
import type { Message } from '~/entities/message/model/message.schema'
import type { ApiListResponse } from '~/shared/api/types'

export interface GlobalSearchMessage extends Message {
  conversation: {
    id: number
    companion: { id: number; name: string }
    product: { id: number; title: string }
  }
}

export interface GlobalSearchGroup {
  conversation: GlobalSearchMessage['conversation']
  messages: GlobalSearchMessage[]
}

const MIN_QUERY_LENGTH = 2

export function useGlobalSearch() {
  const api = useApi()

  const query = ref('')
  const results = ref<GlobalSearchMessage[]>([])
  const isSearching = ref(false)
  const hasMore = ref(false)
  const cursor = ref<string | undefined>()

  const grouped = computed<GlobalSearchGroup[]>(() => {
    const map = new Map<number, GlobalSearchGroup>()
    for (const msg of results.value) {
      const convId = msg.conversation.id
      const existing = map.get(convId)
      if (existing) {
        existing.messages.push(msg)
      }
      else {
        map.set(convId, { conversation: msg.conversation, messages: [msg] })
      }
    }
    return Array.from(map.values())
  })

  async function search() {
    const q = query.value.trim()
    if (q.length < MIN_QUERY_LENGTH) {
      results.value = []
      hasMore.value = false
      cursor.value = undefined
      return
    }
    isSearching.value = true
    try {
      const res = await api.get<ApiListResponse<GlobalSearchMessage>>(
        '/vendor/messages/search',
        { q, limit: 20 },
      )
      results.value = res.data
      hasMore.value = res.meta.has_more
      cursor.value = res.meta.next_cursor ?? undefined
    }
    catch {
      results.value = []
    }
    finally {
      isSearching.value = false
    }
  }

  async function loadMore() {
    if (!hasMore.value || !cursor.value) return
    isSearching.value = true
    try {
      const res = await api.get<ApiListResponse<GlobalSearchMessage>>(
        '/vendor/messages/search',
        { q: query.value.trim(), limit: 20, cursor: cursor.value },
      )
      results.value.push(...res.data)
      hasMore.value = res.meta.has_more
      cursor.value = res.meta.next_cursor ?? undefined
    }
    catch {
      // keep existing results
    }
    finally {
      isSearching.value = false
    }
  }

  const debouncedSearch = useDebounceFn(search, 300)
  watch(query, () => {
    cursor.value = undefined
    debouncedSearch()
  })

  function clear() {
    query.value = ''
    results.value = []
    hasMore.value = false
    cursor.value = undefined
  }

  return {
    query,
    results,
    grouped,
    isSearching,
    hasMore,
    loadMore,
    clear,
  }
}

Step 2: Run lint

Run: npm run lint:fix -- app/features/chat-search/composables/useGlobalSearch.ts Expected: Clean

Step 3: Commit

bash
git add app/features/chat-search/composables/useGlobalSearch.ts
git commit -m "feat(chat): add useGlobalSearch composable"

Task 8: Create GlobalSearchResults component [M]

Files:

  • Create: app/features/chat-search/ui/GlobalSearchResults.vue

Step 1: Create the component

html
<!-- app/features/chat-search/ui/GlobalSearchResults.vue -->
<script setup lang="ts">
import type { GlobalSearchGroup } from '../composables/useGlobalSearch'

defineProps<{
  grouped: GlobalSearchGroup[]
  query: string
  isSearching: boolean
  hasMore: boolean
}>()

const emit = defineEmits<{
  loadMore: []
  select: [conversationId: number, messageId: number]
}>()

function highlightText(text: string, query: string): string {
  if (!query || query.length < 2) return escapeHtml(text)
  const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  const safeText = escapeHtml(text)
  const safeQuery = escapeHtml(query)
  // Re-apply highlight on escaped HTML
  return safeText.replace(
    new RegExp(`(${escaped.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`)})`, 'gi'),
    '<mark class="bg-yellow-200 dark:bg-yellow-800 rounded px-0.5">$1</mark>',
  )
}

function escapeHtml(text: string): string {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}

function formatDate(dateStr: string): string {
  return new Date(dateStr).toLocaleString('ru-RU', {
    day: '2-digit',
    month: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
  })
}
</script>

<template>
  <div class="flex flex-col gap-4 py-3">
    <div v-if="isSearching && grouped.length === 0" class="flex justify-center py-8">
      <UIcon name="i-lucide-loader-2" class="h-6 w-6 animate-spin text-gray-400" />
    </div>

    <p v-else-if="!isSearching && grouped.length === 0 && query.length >= 2" class="py-8 text-center text-sm text-gray-400">
      &#123;&#123; $t('chat.noSearchResults') }}
    </p>

    <div v-for="group in grouped" :key="group.conversation.id" class="space-y-1">
      <div class="px-1 text-sm font-medium text-gray-700 dark:text-gray-300">
        &#123;&#123; group.conversation.companion.name }}
        <span class="text-gray-400">&middot;</span>
        &#123;&#123; group.conversation.product.title }}
      </div>
      <button
        v-for="msg in group.messages"
        :key="msg.id"
        class="w-full rounded-lg px-3 py-2 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
        @click="emit('select', group.conversation.id, msg.id)"
      >
        <p class="line-clamp-2 text-sm text-gray-600 dark:text-gray-400" v-html="highlightText(msg.text ?? '', query)" />
        <span class="text-xs text-gray-400">&#123;&#123; formatDate(msg.created_at) }}</span>
      </button>
    </div>

    <UButton
      v-if="hasMore"
      variant="ghost"
      :label="$t('chat.loadMore')"
      :loading="isSearching"
      class="self-center"
      @click="emit('loadMore')"
    />
  </div>
</template>

Step 2: Run lint

Run: npm run lint:fix -- app/features/chat-search/ui/GlobalSearchResults.vue Expected: Clean

Step 3: Commit

bash
git add app/features/chat-search/ui/GlobalSearchResults.vue
git commit -m "feat(chat): add GlobalSearchResults component"

Task 9: Integrate global search into conversation list page [M]

Files:

  • Modify: app/pages/cabinet/messages/index.vue

Step 1: Add global search composable

After line 7 (const { conversations, filter, isLoading, refresh, loadMore } = useConversations()), add:

ts
const globalSearch = useGlobalSearch()
const isSearchActive = computed(() => globalSearch.query.value.trim().length >= 2)

function handleSearchSelect(conversationId: number, messageId: number) {
  navigateTo(`/cabinet/messages/${conversationId}?highlight=${messageId}`)
}

Step 2: Add search input above the filter buttons

After the <h1> heading row (after line 22), add:

html
    <!-- Global search -->
    <div class="mb-4">
      <div class="relative">
        <UIcon name="i-lucide-search" class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
        <input
          v-model="globalSearch.query.value"
          type="text"
          class="w-full rounded-lg border border-gray-200 bg-white py-2 pl-9 pr-8 text-sm outline-none focus:ring-2 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
          :placeholder="$t('chat.searchAllPlaceholder')"
        />
        <UButton
          v-if="globalSearch.query.value"
          icon="i-lucide-x"
          variant="ghost"
          size="xs"
          class="absolute right-1 top-1/2 -translate-y-1/2"
          @click="globalSearch.clear()"
        />
      </div>
    </div>

Step 3: Conditionally show search results or normal content

Wrap the existing filter buttons + conversation list + empty state + sentinel in a v-if="!isSearchActive" block, and add the global search results:

Replace lines 24-51 (everything from the filter buttons to the sentinel) with:

html
    <!-- Search results mode -->
    <FeaturesChatSearchUiGlobalSearchResults
      v-if="isSearchActive"
      :grouped="globalSearch.grouped.value"
      :query="globalSearch.query.value"
      :is-searching="globalSearch.isSearching.value"
      :has-more="globalSearch.hasMore.value"
      @load-more="globalSearch.loadMore()"
      @select="handleSearchSelect"
    />

    <!-- Normal conversation list -->
    <template v-else>
      <div class="flex gap-2 mb-4">
        <UButton
          :variant="filter === 'all' ? 'solid' : 'ghost'"
          size="sm"
          :label="t('chat.filterAll')"
          @click="filter = 'all'"
        />
        <UButton
          :variant="filter === 'unread' ? 'solid' : 'ghost'"
          size="sm"
          :label="t('chat.filterUnread')"
          @click="filter = 'unread'"
        />
      </div>

      <div v-if="conversations.length" class="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
        <ConversationCard v-for="conv in conversations" :key="conv.id" :conversation="conv" />
      </div>

      <div v-else-if="!isLoading" class="text-center py-16">
        <UIcon name="i-lucide-message-circle" class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
        <p class="text-gray-500 dark:text-gray-400">&#123;&#123; t('chat.noConversations') }}</p>
      </div>

      <div ref="sentinel" class="py-4 text-center">
        <UButton v-if="isLoading" loading variant="ghost" />
      </div>
    </template>

Note: Same auto-import consideration as Task 6. Use FeaturesChatSearchUiGlobalSearchResults or explicit import.

Step 4: Run lint

Run: npm run lint:fix -- app/pages/cabinet/messages/index.vue Expected: Clean

Step 5: Commit

bash
git add app/pages/cabinet/messages/index.vue
git commit -m "feat(chat): integrate global message search into conversation list"

Task 10: Verify — typecheck and lint [S]

Step 1: Run full lint

Run: npm run lint Expected: Clean (no errors)

Step 2: Run typecheck

Run: npm run typecheck Expected: Clean (no type errors)

Step 3: Fix any issues found

If lint or typecheck reports errors, fix them in the relevant files.

Step 4: Commit fixes if any

bash
git add -A && git commit -m "fix(chat): resolve lint/typecheck issues in chat search"

Task Dependency Graph

Task 1: i18n keys                              (independent)
Task 2: MessageBubble highlight                 (independent)
Task 3: scrollToMessage in useMessages          (independent)
Task 4: useConversationSearch composable        (independent)
Task 5: ChatSearchBar component                 (after Task 1)
Task 6: Integrate into [id].vue                 (after Tasks 2, 3, 4, 5)
Task 7: useGlobalSearch composable              (independent)
Task 8: GlobalSearchResults component           (after Tasks 1, 7)
Task 9: Integrate into index.vue                (after Tasks 7, 8)
Task 10: Verify                                 (after all)

Tasks 1, 2, 3, 4, 7 can run in parallel. Critical path: 1 → 5 → 6 → 10.

Complexity Key

SizeMeaning
SOne file, straightforward, ~5 min
M1-2 files, some logic, ~15 min