Appearance
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 analyseFix any issues found.
Step 2: Run code formatting
bash
composer cs-fixStep 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 formatGET /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">
{{ currentMatch }}/{{ totalMatches }}
</span>
<span v-else-if="query.length >= 2 && !isSearching" class="text-xs text-gray-400 whitespace-nowrap">
{{ $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?: stringStep 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 {{ message.text }} rendering (line ~87) with:
html
<span v-if="highlightQuery" v-html="highlightText(message.text!, highlightQuery)" />
<span v-else>{{ 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">
{{ $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">
{{ group.conversation.companion.name }} · {{ 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">{{ 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
| Size | Meaning |
|---|---|
| S | One file, straightforward, ~15 min |
| M | 1-2 files, some logic, ~30 min |
| L | Multiple files, complex logic, ~1 hr |