Skip to content

Chat Message 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: Two new backend endpoints (ILIKE search with cursor pagination), one new frontend feature module (chat-search) with composable, search bar, and global results list. MessageBubble gains text highlighting. Both teams work in parallel after Task 1.

Tech Stack: PHP 8.3 / Slim 4 / Doctrine (backend), Nuxt 4 / TypeScript / Nuxt UI / Zod (frontend)

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


Backend Tasks (server repo)

Task B1: Repository — searchInConversation [S]

Files:

  • Modify: app/Domain/Repository/MessageRepositoryInterface.php
  • Modify: app/Infrastructure/Persistence/DoctrineMessageRepository.php
  • Test: tests/Unit/Infrastructure/Persistence/DoctrineMessageRepositoryTest.php

Step 1: Add interface method

In MessageRepositoryInterface.php, add:

php
/**
 * @return Message[]
 */
public function searchInConversation(int $conversationId, string $query, int $limit, ?int $afterId): array;

Step 2: Implement in DoctrineMessageRepository

Follow the findByConversationId pattern. Doctrine DQL does not support ILIKE, so use native SQL:

php
public function searchInConversation(int $conversationId, string $query, int $limit, ?int $afterId): array
{
    $sql = "SELECT m.id FROM messages m
            WHERE m.conversation_id = :conversationId
            AND m.type = 'text'
            AND m.text ILIKE :query
            " . ($afterId !== null ? "AND m.id < :afterId" : "") . "
            ORDER BY m.id DESC
            LIMIT :limit";

    $conn = $this->em->getConnection();
    $params = [
        'conversationId' => $conversationId,
        'query' => '%' . $query . '%',
        'limit' => $limit,
    ];
    $types = [
        'conversationId' => \Doctrine\DBAL\ParameterType::INTEGER,
        'query' => \Doctrine\DBAL\ParameterType::STRING,
        'limit' => \Doctrine\DBAL\ParameterType::INTEGER,
    ];

    if ($afterId !== null) {
        $params['afterId'] = $afterId;
        $types['afterId'] = \Doctrine\DBAL\ParameterType::INTEGER;
    }

    $ids = $conn->executeQuery($sql, $params, $types)->fetchFirstColumn();

    if (empty($ids)) {
        return [];
    }

    return $this->em->createQueryBuilder()
        ->select('m')
        ->from(Message::class, 'm')
        ->where('m.id IN (:ids)')
        ->setParameter('ids', $ids)
        ->orderBy('m.id', 'DESC')
        ->getQuery()
        ->getResult();
}

Step 3: Commit

bash
git add app/Domain/Repository/MessageRepositoryInterface.php \
       app/Infrastructure/Persistence/DoctrineMessageRepository.php
git commit -m "feat: add searchInConversation to message repository"

Task B2: Repository — searchAll [M]

Files:

  • Modify: app/Domain/Repository/MessageRepositoryInterface.php
  • Modify: app/Infrastructure/Persistence/DoctrineMessageRepository.php

Step 1: Add interface method

php
/**
 * @return array<array{message: Message, companion_id: int, companion_name: string, product_id: int, product_title: string}>
 */
public function searchAll(int $userId, string $query, int $limit, ?int $afterId): array;

Step 2: Implement with JOIN for conversation context

php
public function searchAll(int $userId, string $query, int $limit, ?int $afterId): array
{
    $sql = "SELECT m.id AS message_id,
                   CASE WHEN c.buyer_id = :userId THEN c.seller_id ELSE c.buyer_id END AS companion_id,
                   CASE WHEN c.buyer_id = :userId THEN su.name ELSE bu.name END AS companion_name,
                   c.product_id,
                   p.title AS product_title
            FROM messages m
            JOIN conversations c ON c.id = m.conversation_id
            JOIN users su ON su.id = c.seller_id
            JOIN users bu ON bu.id = c.buyer_id
            JOIN products p ON p.id = c.product_id
            WHERE m.type = 'text'
            AND m.text ILIKE :query
            AND (c.buyer_id = :userId OR c.seller_id = :userId)
            AND (c.buyer_id != :userId OR c.deleted_by_buyer = false)
            AND (c.seller_id != :userId OR c.deleted_by_seller = false)
            " . ($afterId !== null ? "AND m.id < :afterId" : "") . "
            ORDER BY m.id DESC
            LIMIT :limit";

    $conn = $this->em->getConnection();
    $params = [
        'userId' => $userId,
        'query' => '%' . $query . '%',
        'limit' => $limit,
    ];
    $types = [
        'userId' => \Doctrine\DBAL\ParameterType::INTEGER,
        'query' => \Doctrine\DBAL\ParameterType::STRING,
        'limit' => \Doctrine\DBAL\ParameterType::INTEGER,
    ];

    if ($afterId !== null) {
        $params['afterId'] = $afterId;
        $types['afterId'] = \Doctrine\DBAL\ParameterType::INTEGER;
    }

    $rows = $conn->executeQuery($sql, $params, $types)->fetchAllAssociative();

    if (empty($rows)) {
        return [];
    }

    $ids = array_column($rows, 'message_id');
    $contextMap = [];
    foreach ($rows as $row) {
        $contextMap[$row['message_id']] = $row;
    }

    $messages = $this->em->createQueryBuilder()
        ->select('m')
        ->from(Message::class, 'm')
        ->where('m.id IN (:ids)')
        ->setParameter('ids', $ids)
        ->orderBy('m.id', 'DESC')
        ->getQuery()
        ->getResult();

    $result = [];
    foreach ($messages as $message) {
        $ctx = $contextMap[$message->getId()];
        $result[] = [
            'message' => $message,
            'companion_id' => (int) $ctx['companion_id'],
            'companion_name' => $ctx['companion_name'],
            'product_id' => (int) $ctx['product_id'],
            'product_title' => $ctx['product_title'],
        ];
    }

    return $result;
}

Step 3: Commit

bash
git add app/Domain/Repository/MessageRepositoryInterface.php \
       app/Infrastructure/Persistence/DoctrineMessageRepository.php
git commit -m "feat: add searchAll to message repository"

Task B3: SearchMessagesAction (in-conversation) [S]

Files:

  • Create: app/Actions/Vendor/SearchMessagesAction.php
  • Modify: config/routes.php

Step 1: Create action

Follow ListMessagesAction pattern exactly — same auth check, same paginator usage.

php
<?php

declare(strict_types=1);

namespace App\Actions\Vendor;

use App\Application\Exceptions\NotFoundException;
use App\Application\Exceptions\AuthorizationException;
use App\Application\Exceptions\ValidationException;
use App\Application\Response\JsonResponder;
use App\Application\Service\CursorPaginator;
use App\Domain\Repository\ConversationRepositoryInterface;
use App\Domain\Repository\MessageRepositoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

final class SearchMessagesAction
{
    private const DEFAULT_LIMIT = 50;
    private const MIN_QUERY_LENGTH = 2;

    public function __construct(
        private readonly MessageRepositoryInterface $messageRepository,
        private readonly ConversationRepositoryInterface $conversationRepository,
        private readonly CursorPaginator $paginator,
        private readonly JsonResponder $responder,
    ) {}

    public function __invoke(ServerRequestInterface $request, ResponseInterface $response, string $id): ResponseInterface
    {
        $userId = (int) $request->getAttribute('user_id');
        $conversation = $this->conversationRepository->findById((int) $id);

        if ($conversation === null) {
            throw new NotFoundException('Conversation not found');
        }

        if (!$conversation->isParticipant($userId)) {
            throw new AuthorizationException('Access denied');
        }

        $queryParams = $request->getQueryParams();
        $q = trim((string) ($queryParams['q'] ?? ''));

        if (mb_strlen($q) < self::MIN_QUERY_LENGTH) {
            throw new ValidationException('Query must be at least ' . self::MIN_QUERY_LENGTH . ' characters');
        }

        $limit = min((int) ($queryParams['limit'] ?? self::DEFAULT_LIMIT), 100);
        $cursor = $queryParams['cursor'] ?? null;
        $afterId = $this->paginator->decodeCursor($cursor);

        $messages = $this->messageRepository->searchInConversation((int) $id, $q, $limit + 1, $afterId);
        $items = array_map(fn($m) => $m->toArray(), $messages);
        $result = $this->paginator->buildPaginatedResponse($items, $limit);

        return $this->responder->respond($result['items'], 200, $result['meta']);
    }
}

Step 2: Add route in config/routes.php

Inside the vendor group, add before the existing /conversations/{id}/messages route:

php
$group->get('/conversations/{id:[0-9]+}/messages/search', SearchMessagesAction::class);

Step 3: Commit

bash
git add app/Actions/Vendor/SearchMessagesAction.php config/routes.php
git commit -m "feat: add in-conversation message search endpoint"

Task B4: SearchAllMessagesAction (global) [M]

Files:

  • Create: app/Actions/Vendor/SearchAllMessagesAction.php
  • Modify: config/routes.php

Step 1: Create action

php
<?php

declare(strict_types=1);

namespace App\Actions\Vendor;

use App\Application\Exceptions\ValidationException;
use App\Application\Response\JsonResponder;
use App\Application\Service\CursorPaginator;
use App\Domain\Repository\MessageRepositoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

final class SearchAllMessagesAction
{
    private const DEFAULT_LIMIT = 20;
    private const MIN_QUERY_LENGTH = 2;

    public function __construct(
        private readonly MessageRepositoryInterface $messageRepository,
        private readonly CursorPaginator $paginator,
        private readonly JsonResponder $responder,
    ) {}

    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $userId = (int) $request->getAttribute('user_id');
        $queryParams = $request->getQueryParams();
        $q = trim((string) ($queryParams['q'] ?? ''));

        if (mb_strlen($q) < self::MIN_QUERY_LENGTH) {
            throw new ValidationException('Query must be at least ' . self::MIN_QUERY_LENGTH . ' characters');
        }

        $limit = min((int) ($queryParams['limit'] ?? self::DEFAULT_LIMIT), 100);
        $cursor = $queryParams['cursor'] ?? null;
        $afterId = $this->paginator->decodeCursor($cursor);

        $rows = $this->messageRepository->searchAll($userId, $q, $limit + 1, $afterId);

        $items = array_map(fn(array $row) => array_merge(
            $row['message']->toArray(),
            [
                'conversation' => [
                    'id' => $row['message']->getConversation()->getId(),
                    'companion' => [
                        'id' => $row['companion_id'],
                        'name' => $row['companion_name'],
                    ],
                    'product' => [
                        'id' => $row['product_id'],
                        'title' => $row['product_title'],
                    ],
                ],
            ],
        ), $rows);

        $result = $this->paginator->buildPaginatedResponse($items, $limit);

        return $this->responder->respond($result['items'], 200, $result['meta']);
    }
}

Step 2: Add route in config/routes.php

Inside the vendor group:

php
$group->get('/messages/search', SearchAllMessagesAction::class);

Step 3: Commit

bash
git add app/Actions/Vendor/SearchAllMessagesAction.php config/routes.php
git commit -m "feat: add global message search endpoint"

Task B5: Run PHPStan + CS-Fixer [S]

Step 1: Run static analysis

bash
composer analyse

Fix any issues found.

Step 2: Run code formatting

bash
composer cs-fix

Step 3: Commit fixes if any

bash
git add -A && git commit -m "style: fix code style in search actions"

Task B6: Update frontend-api-reference.md [S]

Files:

  • Modify: docs/frontend-api-reference.md

Step 1: Add documentation for both endpoints

Add a "Message Search" section under the Chat heading with:

  • GET /vendor/conversations/{id}/messages/search — params, response format
  • GET /vendor/messages/search — params, response format with conversation context
  • Error codes: VALIDATION_ERROR (query too short), NOT_FOUND, ACCESS_DENIED

Step 2: Commit

bash
git add docs/frontend-api-reference.md
git commit -m "docs: document message search endpoints"

Task B7: Manual API testing on dev [S]

After deploying to dev, verify both endpoints with curl:

bash
# 1. Get session cookie
# 2. Search in conversation
curl --user "$PARTIZAP_BASIC_AUTH_USER:$PARTIZAP_BASIC_AUTH_PASS" \
  -b cookies.txt \
  "https://dev.partizap.ru/api/vendor/conversations/1/messages/search?q=тест"

# 3. Global search
curl --user "$PARTIZAP_BASIC_AUTH_USER:$PARTIZAP_BASIC_AUTH_PASS" \
  -b cookies.txt \
  "https://dev.partizap.ru/api/vendor/messages/search?q=тест"

Verify: correct JSON structure, pagination, empty results for no matches, validation error for short query.


Frontend Tasks (partizap-frontend repo)

Task F1: Zod schemas + API functions [S]

Files:

  • Create: app/features/chat-search/api/chat-search.api.ts
  • Create: app/features/chat-search/model/chat-search.schema.ts

Step 1: Create schemas

ts
// app/features/chat-search/model/chat-search.schema.ts
import { z } from 'zod'
import { MessageSchema } from '~/entities/message/model/message.schema'

export const SearchMessageSchema = MessageSchema

export const GlobalSearchMessageSchema = MessageSchema.extend({
  conversation: z.object({
    id: z.number(),
    companion: z.object({ id: z.number(), name: z.string() }),
    product: z.object({ id: z.number(), title: z.string() }),
  }),
})

export type SearchMessage = z.infer<typeof SearchMessageSchema>
export type GlobalSearchMessage = z.infer<typeof GlobalSearchMessageSchema>

Step 2: Create API functions

ts
// app/features/chat-search/api/chat-search.api.ts
import type { ApiListResponse } from '~/shared/api/types'
import type { SearchMessage, GlobalSearchMessage } from '../model/chat-search.schema'

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

  return {
    searchInConversation(conversationId: number, q: string, limit = 50, cursor?: string) {
      return api.get<ApiListResponse<SearchMessage>>(
        `/vendor/conversations/${conversationId}/messages/search`,
        { q, limit, cursor },
      )
    },

    searchAll(q: string, limit = 20, cursor?: string) {
      return api.get<ApiListResponse<GlobalSearchMessage>>(
        '/vendor/messages/search',
        { q, limit, cursor },
      )
    },
  }
}

Step 3: Commit

bash
git add app/features/chat-search/
git commit -m "feat: add chat search schemas and API functions"

Task F2: useChatSearch composable [M]

Files:

  • Create: app/features/chat-search/model/use-chat-search.ts

Step 1: Create composable

ts
// app/features/chat-search/model/use-chat-search.ts
import { useDebounceFn } from '@vueuse/core'
import { useChatSearchApi } from '../api/chat-search.api'
import type { SearchMessage, GlobalSearchMessage } from './chat-search.schema'

const MIN_QUERY_LENGTH = 2

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

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

  const totalMatches = computed(() => results.value.length)
  const currentMatch = computed(() => currentIndex.value + 1)
  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.searchInConversation(conversationId.value, q)
      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,
  }
}

export function useGlobalMessageSearch() {
  const api = useChatSearchApi()

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

  const grouped = computed(() => {
    const map = new Map<number, { conversation: GlobalSearchMessage['conversation']; messages: GlobalSearchMessage[] }>()
    for (const msg of results.value) {
      const existing = map.get(msg.conversation_id)
      if (existing) {
        existing.messages.push(msg)
      } else {
        map.set(msg.conversation_id, { 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.searchAll(q)
      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.searchAll(query.value.trim(), 20, 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: Commit

bash
git add app/features/chat-search/model/use-chat-search.ts
git commit -m "feat: add chat search composables"

Task F3: ChatSearchBar component [M]

Files:

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

Step 1: Create component

html
<!-- app/features/chat-search/ui/ChatSearchBar.vue -->
<script setup lang="ts">
const props = 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') {
    if (e.shiftKey) {
      emit('prev')
    } else {
      emit('next')
    }
  }
}

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

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

Step 2: Commit

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

Task F4: MessageBubble — add text highlighting [S]

Files:

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

Step 1: Add highlightQuery prop

Add to the existing props:

ts
highlightQuery?: string

Step 2: Add highlight helper and replace text rendering

Add a computed or helper function:

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>')
}

Replace the plain &#123;&#123; message.text }} rendering (line ~87) with:

html
<span v-if="highlightQuery" v-html="highlightText(message.text!, highlightQuery)" />
<span v-else>&#123;&#123; message.text }}</span>

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

On the outermost <div> of the component, add:

html
:data-message-id="message.id"

Step 4: Commit

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

Task F5: Integrate search into chat page [M]

Files:

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

Step 1: Import and initialize composable

ts
import ChatSearchBar from '~/features/chat-search/ui/ChatSearchBar.vue'
import { useConversationSearch } from '~/features/chat-search/model/use-chat-search'

const search = useConversationSearch(conversationId)

Step 2: Add search toggle button to header

Add a search icon button next to the existing header content:

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

Step 3: Add ChatSearchBar below header

html
<ChatSearchBar
  v-if="search.isOpen.value"
  v-model:query="search.query.value"
  :total-matches="search.totalMatches.value"
  :current-match="search.currentMatch.value"
  :is-searching="search.isSearching.value"
  @next="search.goNext()"
  @prev="search.goPrev()"
  @close="search.close()"
/>

Step 4: Pass highlightQuery to MessageBubble

In the message loop, add the prop:

html
<MessageBubble
  ...existing-props
  :highlight-query="search.highlightQuery.value"
/>

Step 5: Watch currentMessageId for scroll

ts
watch(
  () => search.currentMessageId.value,
  (messageId) => {
    if (messageId === null) return
    nextTick(() => {
      const el = document.querySelector(`[data-message-id="${messageId}"]`)
      el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
    })
  },
)

Step 6: Handle Ctrl+F / Cmd+F shortcut

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

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

Step 7: Handle ?highlight=messageId query param from global search

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

// On mount, if highlight param present, scroll to that message
watch(highlightMessageId, async (id) => {
  if (!id) return
  await nextTick()
  const el = document.querySelector(`[data-message-id="${id}"]`)
  if (el) {
    el.scrollIntoView({ behavior: 'smooth', block: 'center' })
    el.classList.add('ring-2', 'ring-yellow-400')
    setTimeout(() => el.classList.remove('ring-2', 'ring-yellow-400'), 2000)
  }
}, { immediate: true })

Step 8: Commit

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

Task F6: GlobalSearchResults component [M]

Files:

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

Step 1: Create component

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

defineProps<{
  grouped: Array<{
    conversation: GlobalSearchMessage['conversation']
    messages: GlobalSearchMessage[]
  }>
  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 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>')
}

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 p-3">
    <div v-if="isSearching && grouped.length === 0" class="flex justify-center py-8">
      <UIcon name="i-heroicons-arrow-path" class="animate-spin text-gray-400 text-xl" />
    </div>

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

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

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

Task F7: Integrate global search into conversation list [M]

Files:

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

Step 1: Import and initialize

ts
import GlobalSearchResults from '~/features/chat-search/ui/GlobalSearchResults.vue'
import { useGlobalMessageSearch } from '~/features/chat-search/model/use-chat-search'

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

Step 2: Add search input above the filter buttons

html
<div class="px-3 pt-3">
  <div class="relative">
    <UIcon name="i-heroicons-magnifying-glass" class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
    <input
      v-model="globalSearch.query.value"
      type="text"
      class="w-full pl-9 pr-8 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm outline-none focus:ring-2 focus:ring-primary-500"
      :placeholder="$t('chat.searchAllPlaceholder')"
    />
    <UButton
      v-if="globalSearch.query.value"
      icon="i-heroicons-x-mark"
      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 conversation list

html
<GlobalSearchResults
  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="(convId, msgId) => navigateTo(`/cabinet/messages/${convId}?highlight=${msgId}`)"
/>

<!-- existing conversation list, hidden when searching -->
<div v-else>
  <!-- ...existing ConversationCard loop... -->
</div>

Step 4: Commit

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

Task F8: i18n keys [S]

Files:

  • Modify: i18n/locales/ru.json

Step 1: Add search-related keys under chat

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

Step 2: Commit

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

Task Dependency Graph

Backend (parallel):          Frontend (parallel, after B3+B4 deployed):
B1 → B2 → B3 → B4 → B5     F1 → F2 → F3 → F5
         ↘ B6                      ↘ F4 → F5
           B7 (after deploy)        F6 → F7
                                    F8 (anytime)

Backend and frontend can work in parallel — frontend can stub API responses for development. Backend should be deployed to dev before frontend integration testing (Task B7).

Complexity Key

SizeMeaning
SOne file, straightforward, ~15 min
M1-2 files, some logic, ~30 min
LMultiple files, complex logic, ~1 hr